Saturday, February 15, 2020

04 - Call by value, call by name

Az előző posztban láthattuk, hogy pl. egy tailSum(41,5+4) hívást intéztünk, az futás közben előbb kiértékeli az első argumentumot: tailSum(3,5+4), majd a másodikat (szép sorban az összeset), tailSum(3,9) és miután már megvan az argumentumok értéke, ezután hívja meg a függvényt (és, a substitution modelnek megfelelően, itt épp a 3 és a 9 értékeket helyettesíti be a függvénytörzsben a formális paraméterek helyére).

Ha egy függvény egy paraméterére ez az eljárás, azt call-by-value, azaz CBV konvenciónak nevezzük, és a Scala esetében ez az alapértelmezett viselkedés.

Egy másik lehetőség lenne az, hogy (ennek a konkrét kifejezésnek az esetében) nem értékeljük ki a 41 ill. 5+4 kifejezések értékeit, hanem így ahogy vannak helyettesítjük be őket a formális paraméterek helyére. Ezt a konvenciót nevezik call-by-name, azaz CBN -nek.

Értékeljük ki az előző poszt tailSum(5,0) kifejezésének értékét úgy, hogy CBN kezeljük a paramétereket:

tailSum(5,0)if(50) 0 else tailSum(51,0+5)if(false) 0 else tailSum(51,0+5)tailSum(51,0+5)if((51)0) 0 else tailSum((51)1,0+5+(51)))if(40) 0 else tailSum((51)1,0+5+(51)))if(false) 0+5 else tailSum((51)1,0+5+(51)))tailSum((51)1,0+5+(51)))if((51)10) 0+5+(51) else tailSum(((51)1)1,0+5+(51)+(52))if(410) 0+5+(51) else tailSum(((51)1)1,0+5+(51)+((51)1))if(30) 0+5+(51) else tailSum(((51)1)1,0+5+(51)+((51)1))if(false) 0+5+(51) else tailSum(((51)1)1,0+5+(51)+((51)1))tailSum(((51)1)1,0+5+(51)+((51)1))if(((51)1)10) 0+5+(51)+((51)1) else tailSum(((51)1)1)1,0+5+(51)+((51)1)+(((51)1)1))
(itt most akkor leállunk, mert nem fér ki a képernyőre széltében) hát. Persze, sejtjük, hogy ez matematikailag ugyanaz, mint a call-by-value implementáció, de rengetegszer számolja ki ugyanazokat a kifejezéseket (ami, ha nincs mellékhatása az adott kifejezésnek, érezhetően fölösleges meló), másrészt mintha a kifejezés mérete is így, hogy nem értékeljük ki a belső részeit, növekedne.

Scalában ha egy paramétert call-by-name akarunk átadni, azt a kettőspont és a típus közé írt => jelöli. A tailSum függvény call-by-name hívva:


import scala.annotation.tailrec

object Main extends App {
  def tailSum(n: Int) = {
    @tailrec
    def tailSum(n: =>Int, acc: =>Int): Int =
      if (n <= 0) acc else tailSum(n - 1, acc + n)

    tailSum(n, 0)
  }

  println(tailSum(20000)) //StackOverflowError
}

és talán nem lep meg senkit: ez így ebben a formában megint kivételt fog nekünk dobni - kapunk egy StackOverflowErrort. Hogy ez pontosan miért történik, azt később érteni is fogjuk - dióhéjban, a call-by-name paramétereknél a fordító egy függvényt fog nekünk készíteni és fordítani, és ezeknek a függvényeknek az egymásba stackelése lesz az, amit nem bír el a call stack.

Felmerül a kérdés, hogy ugyan miért akarna bárki call-by-name paramétert. Amit meg tudunk spórolni: ha a paramétert a függvény törzsének kiértékelésekor maximum egyszer értékeljük ki és előfordulhat, hogy egyáltalán nem, akkor érdemes lehet call-by-name hívni, főleg, ha az adott paramétert esetleg sokáig tarhat kiértékelni (ha aztán nem is használjuk, akkor minek értékeljük ki - hacsaknem egy mellékhatást szeretnénk előhozni).

Egy példa lehet a logikai műveletek (éselés, vagyolás) gyorsított kiértékelése: ha az első argumentum már meghatározza a kifejezés értékét, a másodikat nem értékeljük ki. Erre pl. egy if-else megoldás:


object Main extends App {
  def loop: Boolean = loop //végtelen ciklus
  def myNeg(x: Boolean) = if(x) false else true
  def myAnd(x: Boolean, y: =>Boolean) = if (x) y else false
  def myOr(x: Boolean, y: =>Boolean) = if (x) true else y

  println(myAnd(false, loop)) //prints false
}

Persze Scalában a Boolean változókat a C-ben / Javában szokott módon, direktben is tudjuk a !, && és || operátorokkal negálni, éselni ill. vagyolni, de példának jó ez. Figyeljük meg azt is, hogy a myAnd(false,loop) hívás, mivel a loop kifejezés végtelen ciklusban értékelődik ki és nem is terminál (sőt, mivel tail recursive, a call stacket sem tölti, ezért ha ki próbáljuk értékelni, utána kézzel ki kell lőni a programot, nem öli meg a JVM), mégis terminál, mert a második paraméter call-by-name és ennél a kifejezésnél nem lesz szükség az értékére. Ebből jó lehet a call-by-name paraméterátadás, de van overheadje is és többször kiértékelni sem biztos, hogy akarjuk a kifejezéseinket.

No comments:

Post a Comment