34 - a Future monád

A következő programozási technikával JavaScriptben is nagyon gyakran találkozunk.

prímtényezőkre bontás

Hogy megnézzük, mi is a jelenség, először is írjunk egy metódust, ami a naiv, kettőtől négyzetgyökig forciklussal felfelé osztogatással egy Vectorba teszi az input Long típusú szám prímtényezőit!

Érdemes figyelni arra, hogy tail rekurzívak legyünk, mert a Long már bőven elég nagy ahhoz, hogy ha rekurzívan végigosztjuk rekurzívan a gyökéig, akkor elszálljon a programunk egy csinos stacktrace-el.

1
2
3
4
5
6
7
8
9
def factorize(n: Long): Vector[Long] = {
  @scala.annotation.tailrec
  def factorize(n: Long, m: Long, acc: Vector[Long]): Vector[Long] = 
    if (n <= 1) acc
    else if( n%m == 0 ) factorize( n/m, m, acc :+ m)
    else if( m*m > n ) acc :+ n
    else factorize( n, m+1, acc )
  factorize(n, Vector())
}

Válasz mutatása

konzollal beszélgetés

Első nekifutásra a tervünk egy olyan app, aminek a konzolban be tudunk gépelni (mondjuk pozitív egész) számokat és kiírja nekünk a prímtényezős felbontását, a tényezők közt csillagokkal.

Először írjunk egy showResult: (Long,Vector[Long]) => String függvényt, ami visszaadja az argumentumban kapott longot, egyenlőségjel, majd a vektorban lévő számokat, csillaggal szeparálva őket! Ehhez érdemes a Vector osztály mkString metódusát használnunk.

1
2
def showResult( n: Long, factors: Vector[Long]): String = 
  s"The factorization of $n is ${factors.mkString(" * ")}"

Válasz mutatása

Írjunk most egy processInput: String => String függvényt, mely kap egy stringet (ahogy a felhasználó begépelte), ezt megpróbálja Longgá alakítani, ha sikerült, akkor visszaadja a prímtényezőkre felbontás eredményét az előző függvény szerint formázva, ha nem, akkor azt adja vissza, hogy "Not an integer: " és magát a kapott inputot!

A longgá alakítást érdemes egy Try dobozban tegyük; ha Success jött vissza, akkor az értéket tovább mapeljük egy Stringgé, hívva a factorize és a showResult metódusokat, majd vagy ezt adjuk vissza ami a dobozban van, vagy a default error stringet:

1
2
3
4
5
def processInput( line: String ): String = {
  val tryLong = Try( line.toLong ) //ha valid Long, akkor Success
  tryLong.map { value => showResult( value, factorize(value) ) }
     .getOrElse( s"Not an integer: $line" )
}

Válasz mutatása

Most pedig írjunk egy mainLoop: Unit függvényt, mely rendre

  • kiírja, hogy írj be egy számot, vagy Q-t a kilépéshez,
  • vár a usertől egy szövegbevitelt (erre a scala.io.StdIn objektum readLine() metódusa alkalmas),
  • (hibatűrés jelleggel) levágja a kapott string elejéről-végéről a whitespace-eket, majd nagybetűssé alakítja,
  • ha így a "Q" stringet kapta, akkor terminál a függvény,
  • ha nem, akkor az előző metódussal feldolgozza az kapott stringet, kiírja a képernyőre az eredményt és várja a következő inputot!
1
2
3
4
5
6
7
8
9
@scala.annotation.tailrec
def mainLoop() : Unit = {  //mivel van I/O, jobb kitenni a () jeleket
  println("Please enter an integer to factorize, or Q to quit.")
  val line = StdIn.readline()
  line.trim.toUpperCase match {  //read about trim, toUpperCase in the scaladocs
    case "Q" => println("OK. Bye!"); () //kiköszönünk, visszaadjuk a () unitot
    case  _  => println( processInput( line ) ); mainLoop() 
  }
}
Válasz mutatása

a probléma

A baj az, hogy ez a faktorizáló algoritmus elég lassú, ha nagy inputot kap és ilyenkor, mivel minden számítás a fő szálon megy, ha egy olyan longot adunk be neki, mellyel sokáig elfoglalkozik a kódunk, mint pl. a 4611686018427387847 (ez egy prímszám, ezért ezt végigosztja a szintén milliárdos nagyságrendű négyzetgyökéig, eltart egy darabig), egész addig blokkolva lesz minden, amíg ezzel nem végzünk. (Próbáljuk ki!)

a megoldás

A legritkább esetben akarjuk azt, hogy egy esetleg hosszabb művelet (ami lehet egy adatbázis-lekérdezés, valami internetes erőforrás lekérése, egy komplexebb objektum elkódolása és mentése, stb.) blokkolja a fő szálunkat, elég bután nézne ki egy olyan böngésző is, amivel semmit nem tudnánk kezdeni egészen addig, amíg be nem tölti az aktuális böngészőfület teljesen.

Erre egy szokásos eljárás, hogy egy másik szálon elindítjuk a számításigényes folyamatot, és azonnal visszaadjuk a vezérlést a fő szálra.

Ezt a célt szolgálja a scala.concurrent.Future monád: alapvetően a Future[T]-nek a unitja (ami persze megint a companion object apply metódusa) megkapja név szerint a T típusú kifejezést, és pontosan ezt teszi: egy másik szálon elkezdi kiértékelni a kapott kifejezést, a hívó szál pedig tudja folytatni a futást, a kifejezés értéke pedig persze egy Future[T] objektum lesz, amiről mindjárt kicsit többet fogunk tudni.

Egy apróság van, amiben különbözik attól, amit eddig láttunk: valójában a Future.apply[T] nem egy Future[T]-t ad vissza, hanem egy ExecutionContext => Future[T] függvényt, tehát hogy ténylegesen el is indítsa a számítást, át kell adnunk neki egy végrehajtási környezetet is - ebbe most részleteiben nem megyünk bele, ahogy abba sem, hogy ez egy implicit paraméter, ami azt jelenti, hogy ha nem szállítunk neki ilyen környezetet, akkor próbál keresni egy alapértelmezettet és azzal elkészíteni a Future[T] objektumot. Az implicit paraméterek a Scalának egy eléggé kontroverziális része és talán annyira nem is core element a funkcionális programozásban, ezért ennek a kurzusnak a keretein belül nem mélyedünk el benne jobban, csak annyira, hogy tudjunk dolgozni egy Future objektummal:

1
2
3
4
5
6
import scala.concurrent.Future //hogy ne kelljen kiírni a Future teljes nevét
import scala.concurrent.ExecutionContext.Implicits.global //hogy implicit megkapja lentebb az EC-t

println( "Hello!" )
val futureFactors = Future( println( processInput("4611686018427387847") ) )
println( "Bye!" )

Ha összességében ennyit írunk az App osztályunk törzsébe, akkor mindössze ennyit fogunk látni:

hellobye

Mi is történik itt?

  • először a fő szál kiírja, hogy Hello!
  • majd egy másik szálon elindul annak a nagy számnak a hosszadalmas művelete, de a fő szál visszakapja a vezérlést,
  • a fő szál kiírja szinte azonnal ezután, hogy Bye!
  • és ami fontos: ezután a főszál ahogy terminál, kilövi az összes többi szálat is, amit ő elindított, így a számítás nem fog végig lefutni.

Érdemes megjegyeznünk, hogy ha akkor terminálhat a programunk, mikor egy Future számítás még zajlik, akkor nincs semmi garancia arra, hogy a future kiértékelés is befejeződik.

De ha ezt a mainLoopunkba tesszük bele:

1
2
3
4
5
6
7
8
9
@scala.annotation.tailrec
def mainLoop() : Unit = {  //mivel van I/O, jobb kitenni a () jeleket
  println("Please enter an integer to factorize, or Q to quit.")
  val line = StdIn.readline()
  line.trim.toUpperCase match {  //read about trim, toUpperCase in the scaladocs
    case "Q" => println("OK. Bye!"); () //kiköszönünk, visszaadjuk a () unitot
    case  _  => Future( println( processInput( line ) ) ) ; mainLoop() 
  }
}

ez máris mindjárt eggyel meggyőzőbb (próbáljuk ki!) ahogy interaktálunk a konzollal, írhatjuk bele a számokat ízlés szerint, mindegyikre elindít egy-egy szálon egy faktorizálást, és amikor valamelyikkel végez, kiírja a konzolra az eredményt aszinkron módon: aki előbb lefut, az ír előbb a konzolra. Mi meg írhatjuk bele a számokat, ahogy eszünkbe jut és nincs blokkolva a user interface azért, mert számítások zajlanak.

A Future[T] majdani eredményének további felhasználása

Ez eddig nem rossz, akkor, ha tényleg csak annyit szeretnénk csinálni, hogy egy Unit típusú kifejezést kiértékelni. Ilyen szerencsénk ritkán van: inkább a ha kiértékelted a kifejezést, akkor csináld az eredménnyel ezt-és-ezt a jellemző, amit tennünk kell. Sok programozási nyelven ezt úgynevezett callback függvények átadásával érjük el: egy aszinkron futó függvény az inputján kívül megkap egy függvényt is, amit akkor kell lefuttasson, ha az input kiértékelése már lezajlott.

Ha a Future[T]-t használjuk, akkor ehelyett a szokásos monadikus metódusokat javallott alkalmazni:

  • Future[T].map[U]( f: T=>U ) : Future[U] - a map metódus kap argumentumként egy függvényt. Ha egyszer végez a T kiszámításával a Future dobozunk, akkor a kapott értéket behelyettesíti a kapott f függvénybe és kiértékeli. Ennek az eredménye egy Future[U] típusú Future doboz lesz.

  • Future[T].foreach[U]( f: T=>U ) : Unit a foreach metódus szintén a szokásos módon kap egy függvényt, melyet ha sikerült kiértékelni a Future dobozunkban a kifejezést, behelyettesíti az f metódusba. Itt, ahogy máskor is szoktunk, Unitot kapunk, így ezeket az f-eket rendszerint a mellékhatásuk miatt szoktuk hívni.

  • Future[T].flatMap[U]( f: T => Future[U] ): Future[U] a flatMap pedig, hasonlóan a maphez, ha sikerül kiértékelnie a kapott kifejezést, akkor ezzel példányosít egy Future[U]-t.

Mindhárom metódus azonnal visszatér és megkapjuk a Future dobozunkat.

Hogyan írhatnánk át a fenti Future( println( processInput( line ) ) ) hívást ezek valamelyikének használatával?

1
Future( processInput( line ) ).foreach( println _ )

vagy enumerátorként:

1
for( result <- Future( processInput( line ) ) ) println( result )

Válasz mutatása


Utolsó frissítés: 2021-02-07 23:06:47