25 - az Option monád

Az Option monád opcionális értékek tárolására alkalmas, melyek esetleg hiányozhatnak.

a probléma felvetése, Javában

Lássuk, mi is az ,,opcionális'' értékek kezelésének bevett módja Javában (bár amióta Javában is megjelentek a funkcionális paradigma elemei, ott is létezik az Optional<T> osztály). A következő állatorvosi feladat alapját Gercsó Márk egyik Javás gyakorló feladata képezi:

Van egy Mazsola osztályunk, annek egy int meret adattagja. Írjunk egy Java függvényt, mely inputként kap egy Mazsola[] tömböt, melyben lehetnek nullok is, adja vissza a legnagyobb mazsolát, vagy nullt, ha a tömb összes eleme null!

Próbáljunk erre Javában egy implementációt készíteni.

1
2
3
4
5
6
7
8
Mazsola legnagyobb( Mazsola[] mazsolak ) {
  Mazsola top = null;
  for( Mazsola current: mazsolak ) {
    if( current == null ) continue;
    if( top == null || top.meret < current.meret ) top = current;
  }
  return top;
}

Válasz mutatása
Látható, hogy a folyamatos null checkek sokkal kevésbé olvashatóvá teszik a kódot, és persze a hibalehetőség is nagyobb. Plusz, ha csak annyit lát, aki meghívja ezt a függvényt, és nem olvassa el a(z esetleg nem is létező) dokumentációt, az lehet, hogy nem számít arra, hogy null is visszajöhet, nem látszik a függvény fejlécéből, sem pl az, hogy működnie kéne-e akkor, ha a tömbben lehetnek null értékek is.

Számos módon meg lehet ezt a ,,esetleg nem létezik az érték'' problémát, a funkcionális paradigmában erre az Option monádot idiomatikus használni.

az Option monád

Tehát, ez is egy monád, azaz generikus típus: Option[T].

  • Egy Option[T] az vagy Some(value: T), vagy None (utóbbi, hasonlóan az üres Listhez, object, ami minden Option[T]-nek példánya
  • ,,nullable'' értékek helyett ezt használjuk
  • a map, flatMap, filter foreach, fold, isEmpty, size metódusok pontosan úgy viselkednek, mintha vagy egy üres, vagy egy egyelemű listával lenne dolgunk
  • mivel van flatMapje és foreache is, lehet tenni for comprehensionbe
  • lehet rá pattern matchelni, de inkább a for comprehension az idiomatikus megoldás
  • get metódus: ha nemüres, akkor visszaadja a value mezőt, ha üres, kivételt dob
  • getOrElse(default: =>T): T metódus: ha nemüres, akkor visszaadja az értéket, ha üres, akkor a default értéket adja (figyeljük meg: ez név szerinti paraméterátadás! ha bonyolult kifejezést adunk, az csak akkor kerül kiértékelésre, ha None az optionünk

példák

A lenti kódok eredményeit átgondolhatjuk azon az alapon is, hogy mi történne egy nulla-vagy-egy elemű listával a megfelelő műveletek alkalmazása során:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
val maybe:    Option[Int] = Some(8)  //Int, értéke 8
val maybeNot: Option[Int] = None     //üres

println( maybe.map( { x => 2*x } ) )   //prints Some(16)
println( maybeNot.map( {x => 2*x } ) ) //prints None

println( maybe.filter( { _>10 } ) )    //prints None
println( maybeNot.filter( { _>10 } ) ) //prints None

for( x <- maybe ) println( x )         //prints 8
for( x <- maybeNot ) println( x )      //does not print anything

println( maybe.flatMap( x => Some( x/2 ) ) )   // prints Some(4)

az osztály kezdetleges megvalósítása

Implementáljunk egy saját ilyen osztályt (egyelőre azzal, hogy az üres opció is lehet case class case object helyett)! Először még csak az Option[T] traitet a flatMap, map, filter, foreach, isEmpty, size metódusokkal. Meg tudjuk valósítani valamelyiket már a traitben?

1
2
3
4
5
6
7
8
9
trait Option[T] {
  def flatMap[U]( f: T => Option[U] ): Option[U]   //a flatmapnak ez kell legyen a szignatúrája, ha monád
  def map[U]( f: T => U ): Option[U] =
    flatMap( { x => Some(x) } )                    //a mapet lehet így definiálni, ha monád
  def filter( p: T => Boolean ): Option[T]         //a filtert külön kell implementálnunk az osztályban
  def foreach[U]( f: T => U ): Unit                //a foreachnek ez a szokásos szignatúrája
  def isEmpty: Boolean                             //nincs mellékhatása, nem teszünk () jeleket
  def size: Int                                    //ennek sincs
}

Válasz mutatása

Most implementáljuk a None osztályt! Egyelőre legyen case class.

1
2
3
4
5
6
7
8
case class None[T]() extends Option[T] {
  override def flatMap[U]( f: T => Option[U] ) = None      //üres opció flatmap után üres marad
                                                           //map nem kell, az már megvan a traitben
  override def filter( p: T => Boolean ) = this            //üres opció filterezve is üres marad, típusa is ugyanaz => jó a this
  override def foreach[U]( f: T => U ) = ()                //nincs mit tenni
  override def isEmpty = true                              //persze, üres
  override def size = 0                                    //0 a mérete
}

Válasz mutatása
Implementáljuk a Some[T] osztályt is!

1
2
3
4
5
6
7
8
9
case class Some[T](value: T) extends Option[T] {
  override def flatMap[U]( f: T => Option[U] ) = f(value)  //Some(Some(y)) alakúból Some(y)-t kell készítsünk
                                                           //map nem kell, az már megvan a traitben
  override def filter( p: T => Boolean ) =
    if ( p(value) ) this else None()                       //ha igaz az értékre a predikátum, maradhat önmage, különben None
  override def foreach[U]( f: T => U ) = { f(value);() }   //hogy Unit legyen, teszünk a végére ()
  override def isEmpty = false                             //nemüres
  override def size = 1                                    //1 a mérete
}

Válasz mutatása

a mazsolás feladat Optionnal

Mit kapjon akkor a megvalósítandó függvényünk, ha a mazsolás feladatot idiomatikus Scalában valósítjuk meg?

Vector[Option[Mazsola]] vektort: egy olyan vektort, melyben minden egyes pozin vagy van mazsola (és akkor Some[Mazsola] lesz), vagy nincs (akkor pedig None). Válasz mutatása

Mit adjon vissza?

Option[Mazsola]: mivel lehetséges, hogy egyetlen mazsola sincs a vektorban, hanem csupa None érkezik be, ezért lehet, hogy mi se fogunk tudni mazsolát visszaadni. Mivel pedig ez is egy opció, így legyen Option a visszatérési érték. Válasz mutatása

Mondjuk kezdetben a bejövö Vector[Option[Mazsola]]-ból szeretnénk készíteni egy Vector[Mazsola]-t, ami csak a tényleges értékeket (magukat a mazsolákat) tartalmazza.

Ez első megközelítésben megoldható például egy két enumerátoros for yield comprehensionnel. Hogyan?

1
2
3
4
5
6
7
8
def legnagyobb( mazsolak: Vector[Option[Mazsola]] ): Option[Mazsola] = {
  //gyűjtsük le az actual mazsolákat:
  val actualMazsolak = for(
    mazsolaVagyNone <- mazsolak;
    mazsola <- mazsolaVagyNone
  ) yield mazsola
  ???  //csak hogy forduljon
}

Tehát: az első enumerátor végigmegy a vektoron és a mazsolaVagyNone mindig egy Option[Mazsola] lesz az input vektorból; a második enumerátor pedig magán ezen az opción enumerál végig: ha ez egy None, akkor nem történik semmi, ha pedig Some(mazsola), akkor a mazsola kikerül a tömbbe. Próbáljuk is ki a függvényt!

Válasz mutatása

Itt tehát azt látjuk, hogy egy Vector enumerátoron belülre egy Option enumerátor rakása nem okoz gondot: a fenti kódból gondoljuk át, milyen kifejezést készít a Scala fordító?

1
mazsolak.flatMap( mazsolaVagyNone => mazsolaVagyNone.map( mazsola => mazsola ) )

ha nagyon mechanikusan írjuk át.

Válasz mutatása
Tudjuk ezt a fenti kódot egyszerűsíteni?
Tudjuk: a map( mazsola => mazsola ) kódrészlet nyilván visszaadja az eredeti mazsolaVagyNone opciót, így ezt kapjuk helyette:

1
mazsolak.flatMap( mazsolaVagyNone => mazsolaVagyNone )

ez meg az identikus függvény egy flatMapben, ahogy a flatten függvénynek kéne viselkednie, ha a belső monád ugyanaz, mint a külső. Ha kipróbáljuk, működik! a ,,végső'' megoldás:

1
mazsolak.flatten

(Erre már az idea is figyelmeztet. Az első átírásra, mikor két enumerátorunk van, még nem.)

Válasz mutatása
Hogy ez az egymásbaágyazott enumerátor működik, annak megint az az egyik oka, hogy a Vector is és az Option is IterableOnce-ok.

Amit tanultunk ebből a kódból:

  • ha Option[T]-k egy c kollekciójábol szeretnénk egy ugyanolyan kollekciót készíteni, de már a T underlying típussal, azokból az értékekből, melyek léteznek a c kollekcióban, akkor csak egy flattent kell hívnunk.

Most már, hogy van egy Vector[Mazsola]nk, ebből kell a legnagyobbat kiválasztani. Erre is van több lehetőségünk. Melyik volt az a listaművelet, amelyik erre alkalmasnak tűnhet?

A foldLeft vagy a reduce, hiszen ezek ,,aggregálnak'' egy értékké egy listát - vagy egy vektort, bármilyen kollekciót Válasz mutatása
A kettő közül most talán célravezetőbb lehet egy üresség check után reduceolni. Hogyan?

1
2
if( actualMazsolak.isEmpty ) None
else Some( actualMazsolak.reduce( (left,right) => if(left.meret >= right.meret) left else right ) )

Válasz mutatása
Mivel most az aggregált érték szintúgy mazsola lesz, mint ami a vektorban van, lehet használnunk a reduce függvényt, ami két mazsolából számolja ki az ,,aggregált'' mazsolát -- maximumot keresünk egy függvény szerint, tehát azt választjuk, amelyik a nagyobb. Ez rendben is lesz, nem baj, ha a sorrend nemdeterminisztikus, hiszen a maximum függvény asszociatív.

Scalában a tervezési elv az, hogy szinte minden gyakran használt use casere van függvény implementálva, így ilyenkor ha keresünk, jó eséllyel találunk is olyat, mellyel a kódunk kompaktabb lesz. Alapvetően ha a típus összehasonlítható, akkor pl.

  • a max függvény visszaadja a kollekcióban lévő legnagyobb elemet, vagy kivételt dob, ha a kollekció üres,
  • a maxOption függvény visszaadja egy Someban a kollekció legnagyobb elemét, ha az nemüres, és None-t ad, ha üres,
  • a maxBy( f: T=>B ): T függvényt akkor használhatjuk, ha vagy alapból nem összehasonlítható a kollekciónk alaptípusa, vagy nem aszerint a rendezés szerint akarunk maximumot keresni: ilyenkor megadhatunk egy függvényt, és azt az elemet kapjuk vissza, akin ez a megadott f függvény a legnagyobb értéket veszi fel (a kimeneti B típus rendezhető kell legyen, annak a rendezése szerinti maximumot keres), vagy kivételt dob, ha üres a kollekció,
  • a maxByOption( f: T=>B ): Option[T] pedig None-t ad, ha üres a kollekció és Some(value)-t, ha value az az eleme a kollekciónak, melyre f(value) a maximális.

A fentiek közül akkor melyiket használva tudunk olvashatóbb kódot írni, hogy kész legyen a legnagyobb függvényünk?

1
def legnagyobb( mazsolak: Vector[Option[Mazsola]] ): Option[Mazsola] = mazsolak.flatten.maxByOption( _.meret )

Válasz mutatása
Olvasható azoknak, akik láttak már Scalát, és maga a fejlesztő is meg lehet róla győződve, hogy nem követett el hibát, ha egy ilyen függvényt implementál. (Azért teszteljük le, lássuk, hogy ez tényleg fordul és működik.) Kicsit nagyobb rutinnal a kollekciók és az opciók terén persze nem kell végigjárni mindig ezt a fejlesztői kört, gyorsan tudunk írni hasonlóan kompakt, olvasható kódot.

egy nem forduló for yield

Mi van, ha fordított a helyzet monád egymásbaágyazás terén és nem egy Vector[Option[T]]-vel, hanem egy Option[Vector[T]]-vel kell dolgoznunk?

Gondoljuk át, mi és miért történik, ha egymásba ágyazott enumerátorokkal dolgozzuk fel.

A foreach nem gond:

1
2
3
val svs : Some[Vector[String]] = Some( Vector("egy", "kettő", "három") )

for( vector <- svs; elem <- vector ) print( elem )  //prints "egykettőháom"

Általában is, mivel a foreach eleve Unitot ad vissza, és úgyis eldobjuk, itt jellemzően fordulni fog bármi, mindegy, milyen sorrendben tesszük (persze a sorrend attól azért függ, hogy milyen sorrendben akarunk mellékhatni, pl printelni valamit), de a flatmappel már más a helyzet:

1
2
3
val svs : Some[Vector[String]] = Some( Vector("egy", "kettő", "három") )

for( vector <- svs; elem <- vector ) yield elem // NEM FORDUL, "Option reqiured"

Persze nem is világos, hogy egy ilyen hívásnak mit is kéne visszaadnia? Ha átgondoljuk, mire írja át a fordító:

1
svs.flatMap( vector => { vector.map( elem => elem ) } )

Ebből megint látunk egy elem => elem identikus függvény melletti mapet, ami fölösleges, minek írjuk át ugyanarra a vectort, ami marad:

1
svs.flatMap( vector => { vector } )

és ismét egy flatMap van identikus függvénnyel, erre mondtuk, hogy flatten, ha a belső és a külső monád ugyanaz, de most nem az: most van egy Option[Vector[String]]-ünk, amin az eddigi flatten alapján úgy kéne hatnunk, hogy a belső monádot megszüntetjük. Kapnunk kéne egy Option[String]-et - de hogyan? Milyen alapon készítenénk egy stringet (well, vagy nullát) abból a string vektorból? Persze, hogy nincs rá automatizmus, és ezért rekamál a fordító, hogy ahol ő egy Vector[String]et lát, ott csak egy Option[String]-gel tudna mit kezdeni.

Ezek alapján vajon miből mit lehet konvertálni List, Vector és Ǫption közt metódussal?

A Listnek és a Vectornak van toList és toVector metódusa, toOption viszont nincs. Az Optionnek van toList és toVector metódusa is, nulla- vagy egyelemű Listet vagy Vectort készít az optionből. Válasz mutatása


Utolsó frissítés: 2020-12-24 23:08:27