poniedziałek, 4 czerwca 2018

Domenowo ale bez alokacji

Ten artykuł miał być początkowo wzmianką o ciekawym - acz niszowym mechanizmie - który umożliwia stosowanie pattern matchingu dla prostych typów bez alokacji niepotrzebnych obiektów na stercie. Jednakże w trakcie pisania i zabawy z javap -c ... okazało się, że tak w sumie to ja mam niekompletne pojęcie co się w wielu miejscach wyplutego z programu scali bytekodu JVM dzieje. Stąd też będzie to bardziej ogólny wpis o tym co produkuje kompilator i kiedy płacimy - a kiedy nie - dodatkową alokacją pamięci.

"Name based extractors"

Czasem dobrze zajrzeć do obcego kodu (ale tylko wtedy gdy pisał go ktoś faktycznie znający się na rzeczy) by odnaleźć warte przestudiowania konstrukcje. I taka konstrukcja pojawia się w Akka typed. Wertując bebechy Akki typowanej natrafimy na poniższe :

/**
 * INTERNAL API
 * Represents optional values similar to `scala.Option`, but
 * as a value class to avoid allocations.
 *
 * Note that it can be used in pattern matching without allocations
 * because it has name based extractor using methods `isEmpty` and `get`.
 * See https://hseeberger.wordpress.com/2013/10/04/name-based-extractors-in-scala-2-11/
 */
private[akka] final class OptionVal[+A >: Null](val x: A) extends AnyVal {

To jest bardzo interesujące. Zawsze wydawało mi się ,iż AnyVal i PatternMatching wzajemnie się wykluczają gdyż stoi jak wał w oficjalnej dokumentacji :


A value class is actually instantiated when:
  1. a value class is treated as another type.
  2. a value class is assigned to an array.
  3. doing runtime type tests, such as pattern matching.

Wrócimy do tego przykładu ale najpierw krótka wycieczka po alokacji obiektów w scali by lepiej zrozumieć co się dzieje gdy do pracy ruszy kompilator. I tutaj na potrzeby edukacji załóżmy, że chcemy mieć taki domenowy typ dla Miesiąca. Będzie bardziej domenowo i biznesowo. Miesiąc jest podzbiorem inta gdzie kilka wartości ma sens a reszta już niekoniecznie.

class Month(private val n:Int) extends AnyVal{
  override def toString: String = s"Month($n)"
}

W teorii rozszerzenie AnyVal powinno zapobiec alokacji nowego obiektu na stercie czym zajmie się kompilator zastępując nasz domenowy Month zwykłym intem w trakcie kompilacji. Niestety ta reguła ma wiele wyjątków i czasem alokacja następuje wbrew naszym oczekiwaniom. Zejdziemy teraz dużo niżej by lepiej zrozumieć kiedy coś takiego następuje..

Poligon

Naszym Poligonem laboratoryjnym będzie domenowy Kalendarz, który operuje na domenowym Miesiącu (wszystko takie domenowe)

class Calendar{
  def somethingWithDate(m:Month) = {
    println(m)
  }
}

I po dekompilacji.

Compiled from "Poligon.scala"
public class jug.workshops.reactive.akka.typed.Calendar {
  public void somethingWithDate(int);
  public jug.workshops.reactive.akka.typed.Calendar();
}

Na pozór kod po dekompilacji wygląda ok, zamiast Month mamy Int i wydawać by się mogło, że wszystko idzie zgodnie z planem ale czeka nas niemiła niespodzianka...

Trochę mechaniki

Gdy zanurkujemy do asemblera to zobaczymy :

  Code:
       0: getstatic     #17                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
       3: new           #19                 // class jug/workshops/reactive/akka/typed/Month
       6: dup
       7: iload_1
       8: invokespecial #22                 // Method jug/workshops/reactive/akka/typed/Month."<init>":(I)V
      11: invokevirtual #26                 // Method scala/Predef$.println:(Ljava/lang/Object;)V
      14: return

Gdzie w linijce 3 pojawia się new czyli tworzenie nowego obiektu. Jest to zapewne Kochani moi polimorfizm w akcji gdzie println gdzie pod spodem musi być wywołane toString ale ze względu na dziedziczenie nie jest jasne z której klasy i tam cała ta maszyneria idzie w ruch. Bardzo łatwo pozbyć się alokacji usuwając polimorfizm ze sceny.

class Month(private val n:Int) extends AnyVal {
  def display: String = s"Month : $n"
}

class Calendar{
  def somethingWithDate(m:Month) = {
    println(m.display)
  }
}


 public void somethingWithDate(int);
    Code:
       0: getstatic     #17                 // Field scala/Predef$.MODULE$:Lscala/Predef$;
       3: getstatic     #22                 
// Field jug/workshops/reactive/akka/typed/Month$.MODULE$:Ljug/workshops/reactive/akka/typed/Month$;
       6: iload_1
       7: invokevirtual #26                 
// Method jug/workshops/reactive/akka/typed/Month$.display$extension:(I)Ljava/lang/String;
      10: invokevirtual #30                 
// Method scala/Predef$.println:(Ljava/lang/Object;)V
      13: return

Po tej wycieczce po alokacjach wracamy do pattern matchingu.

Pattern Matching bez kosztu

Pattern matching bazuje na dekompozycji obiektów i metodzie unapply, która jest takim lustrzanym odbiciem kompozycji i metody apply. Kompilator tutaj dużo działa w tle bo gdy napiszemy poniższą linijkę.

case class Day(v:Int)

No i każdy początkujący adept scali pewnie wie, że będzie z automatu wygenerowany comanion object, ktory ma 40000 metod a wśród nich unapply do pattern matchingu. Ten unapply ma tę wadę, że tak trochę niepotrzebnie za każdym razem Optiona tworzy który zaraz idzie do kosza.

public final class jug.workshops.reactive.akka.typed.Day$ extends ... {
...
  public scala.Option<java.lang.Object> unapply(jug.workshops.reactive.akka.typed.Day);
...
  }

I tutaj pierwsza niespodzianka dla mnie bo okazuje się, że jednak ta metoda przy case class wcale nie jest używana!!!

Według :

However, when we get to pattern matching (§8.1), case classes have their own section on matching, §8.1.6, which specifies their behaviour in pattern matching based on the parameters to the constructor, without any reference to the already-generated unapply/unapplySeq:

Oraz:

I can tell you that even though the compiler generates an unapply method for case classes, when it pattern matches on a case class it does not use that unapply method

No i faktycznie żadnego unapply w asemblerze nie widać. Dopiero gdy zmienimy case class na zwykłą klasę to unapply pójdzie w ruch z alokacją Optiona.

class Day(val v:Int)

object Day{
  def unapply(arg: Day): Option[Int] = Some(arg.v)
}
I asembler :
63: invokevirtual #45                 
// Method jug/workshops/reactive/akka/typed/Day$.unapply:(Ljug/workshops/reactive/akka/typed/Day;)Lscala/Option;


 public scala.Option<java.lang.Object> unapply(jug.workshops.reactive.akka.typed.Day);
    Code:
       0: new           #17                 // class scala/Some
       3: dup
       4: aload_1
       5: invokevirtual #23                 
// Method jug/workshops/reactive/akka/typed/Day.v:()I
       8: invokestatic  #29                 
// Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
      11: invokespecial #32                 
// Method scala/Some."<init>":(Ljava/lang/Object;)V
      14: areturn

Ale po co tam option?

Odsuwając ideologie programowania w kont - z punktu widzenia pattern matchingu to option trochę tak z ch*ja jest. A to dlatego, że jeśli o sam Pattern Matching chodzi to my jako programiści tego Optiona nie tykamy, nie widzimy, nie wykorzystujemy - to je taka wewnętrzna rzecz - i w złym świecie programowania można to opykać bez alokacji na parze wartość/null - pewnie usuną z czatu monadowego ale garbage collector powinien chodzić trochę lepiej - co kto lubi i potrzebuje emocjonalnie w życiu.

Zastosowanie

Jak użycie tego wygląda w Akka (Acce) ? W ActorContextImpl (ku*wa lata uczenia młodzieży by nie dawała w nazwach Impl i ch** wszystko strzelił) znajdziemy

 private var messageAdapterRef: OptionVal[ActorRef[Any]] = OptionVal.None

I dalej mamy pattern matching

val ref = messageAdapterRef match {
case OptionVal.Some(ref) ⇒ ref.asInstanceOf[ActorRef[U]]
case OptionVal.None ⇒ ...
    }

Tak jak ja to osobiście rozumiem - działają w tym miejscu dwa extractory. Pierwszy bezpośrednio w OptionVal. Ale ale zaczynają dziać się rzeczy ciekawe bo żadnego unapply tam nie ma - znajdziemy dwie metody potrzebne do tego całego "name based extraction".

private[akka] final class OptionVal[+A >: Null](val x: A) extends AnyVal {
...

 def get: A =
    if (x == null) throw new NoSuchElementException("OptionVal.None.get")
    else x

  
  def isEmpty: Boolean =  x == null
...
}

Wracamy do naszego laboratorium by sprawdzić co takiego dzieje się pod spodem. Na początek przypomnijmy co mamy :

class Month(private val n:Int) extends AnyVal {
  def isEmpty:Boolean = n < 0 || n > 12
  def get:Int = n
  def display: String = s"Month : $n"
}

object Month{
  def unapply(v: Int) = new Month(v)
}

Co umożliwi nam poniższe zabawy.

    val Month(n) = 2
    println(n)


    1 match {
      case Month(n) => println(n)
      case _ => println("empty")
    }

    3 match {
      case Month(n) => println(n)
      case _ => println("empty")
    }

Fajnie jest ale teraz zmodyfikujmy nasze unapply by przyjmowało bezpośrednio obiekt domenowy, wtedy będzie bardziej biznesowo i domenowo.

object Month{
  def unapply(v: Month) : Month = v
}

Co już nam daje znak, że Option jako taki z równania został wyłączony. Ale czy na pewno? Jest tylko jeden sposób by sprawdzić.

- kod wysokopoziomowy

def somethingWithDate(m:Month) = m match {
    case Month(n) if n <=6 => println("first half")
    case Month(n)  => println("second half")
    case _ => println("error")
  }

kod (a właściwie kawałek) niskopoziomowy

  public void somethingWithDate(int);
    Code:
       0: iload_1
       1: istore_3
       2: getstatic     #17                 
// Field jug/workshops/reactive/akka/typed/Month$.MODULE$:Ljug/workshops/reactive/akka/typed/Month$;
       5: iload_3
       6: invokevirtual #21                 
// Method jug/workshops/reactive/akka/typed/Month$.unapply:(I)I
       9: istore        4
      11: getstatic     #17                 
// Field jug/workshops/reactive/akka/typed/Month$.MODULE$:Ljug/workshops/reactive/akka/typed/Month$;
      14: iload         4
      16: invokevirtual #25                 
// Method jug/workshops/reactive/akka/typed/Month$.isEmpty$extension:(I)Z
      19: ifne          57
      22: getstatic     #17                 
// Field jug/workshops/reactive/akka/typed/Month$.MODULE$:Ljug/workshops/reactive/akka/typed/Month$;
      25: iload         4
      27: invokevirtual #28                 
// Method jug/workshops/reactive/akka/typed/Month$.get$extension:(I)I
      30: istore        5
      32: iload         5
      34: bipush        6
      36: if_icmpgt     54
      39: getstatic     #33                 
// Field scala/Predef$.MODULE$:Lscala/Predef$;
      42: ldc           #35                 // String first half

I jak to przestudiujecie to nie powinniście tam nigdzie znaleźć żadnego new a jedynie wywołania statyczne.

I wydaje mi się, że to co jest w Acce idzie jeszcze dalej. Przypomnijmy kod :

val ref = messageAdapterRef match {
      case OptionVal.Some(ref) ⇒ ref.asInstanceOf[ActorRef[U]]
      case OptionVal.None

I teraz jeśli chodzi o None to jest to zwykły obiekt z tym isEmpty oraz get.

val None = new OptionVal[Null](null)

I tutaj weź głęboki wdech i przypomnij sobie nasz poprzedni przykład z miesiącem. Dla Pattern Matchingu istotne jest co będzie zwrócone z unapply - żeby to miało get i isEmpty - i tutaj wymijamy unapply i od razu podrzucamy taką instancję singletona.

A co z tym drugim Kejsem? Tutaj mamy ładne customowe unapply.

  object Some {
    def apply[A >: Null](x: A): OptionVal[A] = new OptionVal(x)
    def unapply[A >: Null](x: OptionVal[A]): OptionVal[A] = x
  }

Po co to?

W moim odczuciu AnyVal to w pewnym stopniu udana próba implementacji mechanizmu przeniesienia typów prymitywnych do warstwy domenowej bez zbytniego cierpienia w runtime. Ponieważ koniec końców tam na dole zawsze będzie JVM to często czar pryska i trzeba instancję stworzyć, dlatego też zazwyczaj używałem AnyVal jako zabezpieczenia w sygnaturze. Tutaj pojawia się nowa ciekawa możliwość próby zaimplementowania takiego prostego i opartego na prymitywach ADT co widzieliśmy na przykładzie Akki gdzie działa taki Some/None jako nakładka na ActorRef i null.

Pojawiają się takie brzydkie słowa jak var czy null ale może to nie czas by się spuszczać nad programowaniem ideologicznym i pomyśleć, że ci ludzie oszczędzili tak ileś tam pamięci, którą musieli oszczędzić i poszli na piwo.

Podsumowanie

Także drodzy przyjaciele zachęcam do eksperymentów bo jak wspominałem na samym początku i dla mnie to była edukacja. Wrzućcie czasem kawałek kodu, odpalcie "javap -c" i zobaczcie co się tak naprawdę dzieje pod spodem. No a jak potrzebujecie inspiracji - to zerknijcie w jakiś kod pisany przez mądrych ludzi - najlepiej kod który zarabia jakieś pieniądze.

Brak komentarzy:

Prześlij komentarz