Domenowo ale bez alokacji

pawelwlodarski.blogspot.com 6 lat temu

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ę, iż 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 :

https://docs.scala-lang.org/overviews/core/value-classes.html

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, iż 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, iż 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."":(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 prawdopodobnie 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, iż 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ę, iż 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ę, iż jednak ta metoda przy case class wcale nie jest używana!!!

Według :

https://stackoverflow.com/questions/24227037/unapply-method-of-a-case-class-is-not-used-by-the-scala-compiler-to-do-pattern-m

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:

https://stackoverflow.com/questions/8783226/scala-case-class-unapply-vs-a-manual-implementation-and-type-erasure

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."":(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, iż jeżeli 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 interesujące 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, iż 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 adekwatnie 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ę, iż 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żeli 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ż zwykle używałem AnyVal jako zabezpieczenia w sygnaturze. Tutaj pojawia się nowa interesująca 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ć, iż 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.

Idź do oryginalnego materiału