33 - a Try monád

Kivételkezelésről nem volt még szó, pedig pl. Javában bevett szokás kivételek dobálásával alakítani a vezérlést, például:

  • ha egy metódus olyan argumentumot kap, ami nem felel meg a specifikációnak, szokás dobni egy IllegalArgumentExceptiont
  • ha egy file írásakor/olvasásakor vannak gondok, szokás dobni valamiféle IOExceptiont, például FileNotFoundExceptiont
  • ha a program valamiért inkonzisztens állapotba kerül (ez inkább programozási logikai hibára utal, mintsem felhasználói oldali hibára), szokás dobni IllegalStateExceptiont
  • ha egy tömböt túlcímzünk, szokás dobni ArrayIndexOutOfBoundsExceptiont
  • ha nullával egészosztunk, érkezik egy ArithmeticException
  • ha nem szám alakú stringet próbálunk számmá alakítani, érkezik egy NumberFormatException
  • ha egy üres lista headjét akarjuk lekérdezni, érkezik egy NoSuchElementException

stb.

Ebben a leckében megnézzük, hogy Scalában ezt hogyan kezeljük.

Mivel a Scalás beépített kivételosztályok hierarchiája nagy részben megegyezik a Javással (számos kivétel a Scalában egy az egyben Javából van importolva), érdemes lehet feleleveníteni a progegyen tanultakat legalább koncepcionálisan a lecke megkezdése előtt.

Option on steroids

Kivételek kezelésére a Try monád használata egy Scala idiomatikus módszer, hasonlít az Option-re.

Egy Option[T]:

  • vagy egy Some( value: T ), ami tárol egy T típusú értéket,
  • vagy a None objektum, ami ,,üres doboz'',

és ahelyett, hogy egy optionra matchelnénk, a map, flatMap, foreach stb. függvényekkel (akár for comprehensionben enumerálva) dolgozunk vele, és csak egy logikai folyamat legvégén ,,nézünk bele'' a dobozba.

Ehhez képest egy scala.util.Try[T]:

  • vagy egy Success( value: T), ami tárol egy T típusú értéket,
  • vagy egy Failure( problem: Throwable ), ami tárol egy Throwablét (tehát jellemzően egy kivételt pl. az intróban felsoroltak közül).

Ha egy olyan T típusú e kifejezést kell kiértékelnünk, mely kiértékelés közben kivételt dobhat, eljárhatunk pl. így:

1
2
3
import scala.util.Try

val tryE: Try[T] = Try( e )

Persze a típus megadása elhagyható, a fordító ezt a típust inferné enélkül is.

Ekkor

  • ha e kiértékelése sikerült, az érték mondjuk value, akkor tryE értéke Success( value ) lesz,
  • ha e kiértékelés közben dobta a problem kivételt, akkor tryE értéke Failure( problem ) lesz.

Például ha mondjuk egy számológépet implementálunk, és írunk bele kifejezés-kiértékelőt, van esélye, hogy nullával osszunk, amikor is elszáll a program:

öt per nulla

Ugyanakkor ha a kifejezés kiértékelését egy Try dobozban végezzük, nincs probléma:

try öt per nulla

Láthatjuk, hogy nem piros a konzol, a program szépen lefutott, kiírta a Failure case classt, benne a kivétellel, de szépen ment tovább a futás.

Hogyan implementálnánk magunk eddig ezt a Try osztályt?

Arra mindenképp ügyelni kell, hogy a Try objektum apply metódusa név szerint vegye át a kifejezés paramétert - ha érték szerint venné át, akkor ugyanúgy elszállna kivétellel, még mielőtt létrejöhetne maga a Failure objektum.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
trait Try[+T]  //miért is le legyen kovariáns
case class Success[+T](value: T) extends Try[T]
case class Failure[+T](problem: Throwable) extends Try[T]

object Try {
  def apply[T]( exp: =>T ): Try[T] = {
    try {
      Success( exp )
    } catch {
      case problem: Throwable => Failure( problem )
    }
  }
}

A kódból azt is látjuk, hogy ha Javás try-catch szintaxissal szeretnénk kivételkezelni, akkor azt hogyan tehetjük meg: Scalában a catch után egy match kifejezés következik, amit a kapott kivételre próbálunk illeszteni. Ebben az implementációban minden Throwable-t elkapunk és betesszük egy Failure dobozba, amit visszaadunk.

Válasz mutatása

Valójában a Try ,,doboz'' minden ún. NonFatal kivételt és errort elkap, ha megnézzük a NonFatal objektum forrását, azt láthatjuk, hogy ami a Throwablek közül Fatal és nem fogja a Try sem elkapni, azok valóban elég nasty problémák, többek közt

  • VirtualMachineError
  • ThreadDeath
  • InterruptedException
  • LinkageError

amikkel ha még nem is találkoztunk, nevük alapján valóban olyasminek hangzanak, amik ha felbukkannak valahol, ott a programot futtatni próbálni valami konzisztens állapotban tovább nincs értelme.

foreach, map, flatMap

A Try monadikus metódusai nagyon hasonlóak az Optionéhoz abban a tekintetben, hogy ha Success az objektumunk, akkor a belsejében lévő objektumon értékelünk ki egy függvényt, ha pedig Failure, akkor nem történik semmi, visszakapjuk a failúránkat változatlanul. Tehát:

foreach

A Try osztály foreach( f: T=>U ): Unit metódusa

  • ha ez egy Success( value ), akkor kiértékeli f(value)-t (vélhetően mellékhatás kedvéért),
  • ha ez egy Failure( problem ), akkor semmi nem történik.

Tehát például ha e valamiféle aritmetikai kifejezés, akkor azt hogy érhetjük el, hogy ha kiértékelhető egy számra, akkor írjuk ki azt a számot, ha meg nem, akkor ne írjunk ki semmit?

Többféleképp is lehet:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
//enumerátorral
for( value <- Try(e) ) println( value )

//közvetlen foreach-csel
Try(e).foreach( println )

//match kifejezéssel
Try(e) match {
  case Success(value) => println( value )
  case _ => ()
}

A legkulturáltabb kinézete ezek közül az (amúgy viselkedésükben ekvivalens) kódok közül az enumerátorosnak van.

Válasz mutatása

map

A Try osztály map[U]( f: T=>U ): Try[U] metódusa

  • ha ez egy Success( value ), akkor az érték Try( f(value) ) lesz;
  • ha ez egy Failure( problem ), akkor pedug Failure[U]( problem ) lesz.

Mi történik akkor, ha ugyan eredetileg Success a dobozunk, de az f(value) kiértékelése kivételt dob?

Ha az f(value) egy (nonfatal) problem2 throwablét dob, akkor (mivel Try dobozban próbáljuk f-et kiértékelni) a Failure( problem2 ) lesz az érték (különben pedig Success( f(value) )).

Azaz, Try objektum mappelésekor újabb Try-t kapunk, ami eltárolja a kivételt, ha menet közben érkezik.

Válasz mutatása

flatMap

Persze olyan is lehet, hogy olyan függvényt akarunk kiértékelni, mely maga is valamiféle Try[U]-t ad vissza, pontosan ekkor használható a Try osztály flatMap[U]( f: T=>Try[U] ): Try[U] metódusa:

  • ha ez egy Success( value ), akkor az eredmény f(value) lesz;
  • ha ez egy Failure( problem ), akkor pedig Failure[U]( problem ) lesz.

Ezt megint felfoghatjuk úgy, mintha a Try[ Try[ U ] ] doboznak a ,,külső rétege'' megszűnne mapelés után, mikor egy Successt flatmapelünk. flatMapelünk.

filter, getOrElse

A filter( p: T=>Boolean ): Try[T] viselkedése már az Option alapján nem teljesen egyértelmű: az az eset nem világos, hogy mi is kéne legyen az érték, ha egy Success( value )-t filterezünk olyankor, mikor p(value) == false?

  • ha ez egy Success( value ), melyre p(value) == true, akkor visszakapjuk az eredeti Success( value )-t,
  • ha ez egy Success( value ), melyre p(value) == false, akkor egy Failure( problem )-et kapunk, ahol problem egy NoSuchElementException.
  • ha ez egy Failure( problem ), akkor visszakapjuk az eredeti Failure( problem )-et.

A getOrElse( default: => T): T metódus ismét hasonló az Option esetéhez:

  • ha ez egy Success( value ), akkor megkapjuk value-t,
  • ha ez egy Failure( problem ), akkor pedig a default értéket kapjuk meg.

Hogyan valósítánk meg a következő függvényt: kapjunk meg egy Intre kiértékelődő kifejezést, és adjuk vissza a kifejezés értékét Stringgé konvertálva, ha ki lehet értékelni, és a "HIBA TÖRTÉNT" stringet, ha kivételt dob közben?

Arra figyelnünk kell, hogy név szerint vegyük át a kifejezést:
1
2
3
4
def evalOrHiba( e: =>Int ): String = Try(e).map( _.toString ).getOrElse("HIBA TÖRTÉNT")

println( evalOrHiba(5/2) ) //prints "2"
println( evalOrHiba(5/(2-2)) ) //prints "HIBA TÖRTÉNT"
Válasz mutatása

monád?

A Try[T]-re próbáljuk meg ellenőrizzük a monád axiómákat! A unit művelet (mivel annak most T-ből kell képeznie Try[T]-be) a Success lesz.

Lássuk sorban az axiómákat:

  • balegység: Success( x ).flatMap( f ) a fenti def szerint f(x), ez stimmel,
  • jobbegység: ha t: Try[T], akkor t.flatMap( x => Success(x) ) egyenlő lesz-e t-vel?

    • ha t == Success(x), akkor a flatMap def szerint x-en kell kiértékelni a belső függvényt, és ez lesz az eredmény, vagyis Success(x), ami pont t,
    • ha t == Failure(x), akkor a flatMap visszaadja módosítás nélkül, ez is stimmel.
  • asszociativitás: nézzük, mi lehet m.flatMap(f).flatMap(g) és m.flatMap( x => f(x).flatMap(g) ), az m különböző fajtáira:

    • ha m egy Failure, akkor a flatMap m-et fog visszaadni, bármi is az argumentuma, így mindkét oldalon m-et kapunk vissza, eddig OK.
    • ha m == Success( x ), és f(x) egy Failure( problem ), akkor m.flatMap(f).flatMap(g) == Failure(problem).flatMap(g), ami szintén Failure( problem ). Továbbá, ekkor m.flatMap( x => f(x).flatMap(g) ) == f(x).flatMap(g) == Failure( problem ).flatMap( g ) == Failure( problem ) szintén, tehát ez is OK.
    • ha m == Success( x ) és f(x) = Success( y ), akkor m.flatMap(f).flatMap(g) == Success( y ).flatMap( g ) == g(y). A másik oldalon pedig Success(x).flatMap( x => f(x).flatMap(g) ) == f(x).flatMap(g) == Success( y ).flatMap( g ) == g(y) szintén az érték, tehát ez is OK.

Tehát igen, Try egy monád.

Válasz mutatása

use case

Általában, ahelyett, ahogy Javában egy metódusnál jelöljük, hogy T func() throws XY milyen kivételeket dobhat, Scalában idiomatikusabb a metódus visszatérési értékét T helyett Try[T]-re deklarálni: def func(): Try[T]. Ekkor

  • a hívó oldalon az értéket egy Try[T]-ben visszük magunkkal,
  • ha tennénk valamit ezzel az értékkel, amennyiben tényleg egy T jött vissza, úgy azt a monadikus metódusokkal: map, flatMap, foreach stb, ha tehetjük, az átláthatóság végett enumerátorokkal tesszük,
  • így ami Javában finally ágba kerülne, azt ugyanúgy ki tudjuk értékelni attól függetlenül, hogy a Try belsejében éppen egy sikerérték van, vagy egy failúra,
  • tehát végeredményben mehet egy lineáris programlogika, catch és finally klózok nélkül, egészen addig, amíg a vezérlés el nem jut egy olyan pontra, ahol a kivételt, ha az jött, recoverelni van értelme, onnan pedig már Try nélkül, tényleges T értékként tudjuk tovább vinni az eredményt.

recovering

Egy módszert a kivétel kezelésére már láttunk: a getOrElse( default: =>T ): T metódust.

Ezen felül van még több lehetőségünk, ha magával a kivétellel is szeretnénk foglalkozni és annak jellegétől függően más és más recoveryt szeretnénk elvégezni:

Ha például maradnánk Try[T]-ben, de kivétel esetében egy fix Try[T]-t szeretnénk kapni, azaz egy Try( getOrElse( default ) )-ot hívnánk, ezt megkapjuk másképp is:

  • az orElse( default: => Try[T] ): Try[T] metódus
    • ha ez egy Success( value ), akkor visszakapjuk változtatás nélkül,
    • ha pedig Failure( problem ), akkor az eredmény a default érték lesz.

Ha a hibától függő helyreállítást akarunk végezni, arra is több opciónk van:

  • a recover( pf: PartialFunction[Throwable, T] ): Try[T] metódus:
    • ha ez egy Success( value ), akkor visszakapjuk változtatás nélkül,
    • ha ez egy Failure( problem ), és pf.isDefinedAt(problem), akkor Success( pf(problem) ) lesz az eredmény,
    • különben pedig marad `Failure( problem ).

Tipikus alkalmazás, amikor is a pf egy { case nfe: NumberFormatException => ... ; case ioe: IOException => ... } alakú, mintaillesztéssel definiált parciális függvény, amivel is bizonyos kivételeket helyreállítunk, másokkal meg nem feltétlen tudunk kezdeni azon a ponton semmit, csak hagyni úgy, ahogy van.

De mi van, ha pf is dobhat kivételt? Akkor ő egy PartialFunction[ Throwable, Try[T] ] típusú parciális függvény és erre másik metódust használhatunk:

  • a recoverWith( pf: PartialFunction[Throwable, Try[T]] ): Try[T] metódus:
    • ha a tryunk Success, akkor visszakapjuk változtatás nélkül,
    • ha Failure( problem ), de pf.isDefined( problem ) == false (pl nem illeszkedik problemre egyik case se), akkor is visszakapjuk változtatás nélkül,
    • különben pedig az eredmény pf( problem ) lesz (ami szintén lehet egy success, helyreállított érték, vagy egy olyan Failure, amit már a pf kiértékelése okozott.

Ha a kivételünket csak el szeretnénk felejteni, akkor:

  • a toOption: Option[T] metódus
    • ha a tryunk Success(value), akkor visszakapjuk Some(value)-t,
    • ha pedig Failure, akkor None-t.

Amennyiben ezek a lehetőségek nem kínálnak megoldást, van egy általánosabb helyreállító metódus:

  • a transform[U]( s: T => Try[U], f: Throwable => Try[U] ): Try[U] metódus
    • ha a tryunk Success(value), akkor az eredmény s(value),
    • ha pedig Failure(problem), akkor f(problem).

Felmerülhet a kérdés, hogy ha olyan transformot szeretnénk alkalmazni, melynek nem Try[U] a kimenete, hanem egy U, akkor mit tegyünk, csak ezért ne csomagoljuk bele egy Success-be az eredményt, hogy aztán kibontsuk, nos, az itteni szokatlan fejlécű fold pont erre a célra való:

  • a fold[U]( fa: Throwable => U, fb: T => U ) metódus
    • ha a tryunk Success(value), akkor az eredmény fb(value),
    • ha pedig Failure(problem), akkor fa(problem).

Figyeljük meg, hogy a fold és a transform paraméterei pont fordított sorrendben vannak a másikhoz képest..

Végül még említésre méltó ,,hibakezelés'' címen a ,,dobjunk el mindent'' megoldás:

  • a get: T metódus
    • ha a tryunk Success(value), akkor az eredmény value,
    • ha pedig Failure(problem), akkor throwolja a problemet.

Scalán belül ez a legutóbbi ritkán lehet indokolt, azonban ha egy Java oldal felé kiajánlott metódusunk van, és az ottani konvenciók szerint ,,throws''oló metódust akarunk írni, akkor ez egy megoldás lehet: kiértékelünk egy kifejezést, amíg végig Scala oldalon vagyunk, addig dobozban tartjuk és a monadikus műveletekkel kezeljük a tartalmát, majd mikor a Java oldalra kell adjuk az eredményt, ahol nem értik a dobozainkat, akkor utolsó belerúgásként egy gettel hozzájuk vágjuk vagy a kivételünket, vagy az értéket, ha sikeres volt a számítás vagy helyre tudtuk meaningfully állítani.

Ha még ez is kevés, akkor lehet persze szétszedni a dobozunkat: matchelni a Try-ra, Success esetében kiértékelni valamit, Failure esetben valami mást, de erre azért kifejezetten ritkán lehet valid módon szükség.


Utolsó frissítés: 2021-01-02 20:30:17