Archive for the ‘option’ Tag

Teil 17: Fehlerbehandlung

Fehler gehören zum alltäglichen Geschäft eines jeden Entwicklers – man sollte also stets auf sie vorbereitet sein und sie einer ordentlichen Behandlung unterziehen. Geht man nicht auf ihre Bedürfnisse ein kann es schon mal passieren, dass sie die Oberhand gewinnen und die Kontrolle der Codeausführung an sich reißen. Und ehe man sich versieht findet man sich wieder in den Tiefen der Funktionsaufrufe oder im Labyrinth der Kontrollstrukturen, mit dem man doch so wenig zu tun haben möchte. Trotz dieser Gefahren vernachlässigen viele Entwickler den Aspekt der Fehlerbehandlung, weshalb eine gute Sprache sie hier ein wenig an der Hand nehmen sollte um ihnen zu zeigen wo es lang geht. Leider gehört die Beihilfe für eine ordentliche Fehlerbehandlung bei einigen Programmiersprachen zu den eher vernachlässigten Sprachbestandteilen, weshalb der Entwickler am Ende doch wieder auf all seinen Fehlern sitzenbleibt.

Dies muss aber keinesfalls so sein – funktionale Programmiersprachen versuchen schon seit jeher potenzielles Auftreten von Fehlern schon im Keim zu ersticken indem sie dem Entwickler genügend Abstraktionen in die Hand geben um sie dann mit Hilfe eines strengen Typsystems vor den damit veranstalteten Dummheiten schützen zu können. Durch den konsequenten Einsatz von abstrakten Konzepten ist es funktionalen Programmiersprachen gelungen den Entwickler zu einer deklarativen Denkweise zu erziehen, in der nur noch modelliert wird was der Code machen soll und nicht wie er es tut. Dies allein verbessert die Les- und Wartbarkeit von Code schon enorm und vermindert gleichzeitig die Anzahl potenzieller Fehlerquellen. Ein Beispiel für ein imperatives Stück Code könnte z.B. so aussehen:

def multiply(n: Int): Seq[Int] = {
  var xs = List.empty[Int]
  var i = 0
  while (i < n) {
    i += 1
    xs = n :: xs
  }
  xs
}

Dieser Code vervielfacht eine Zahl gemäß ihrer Größe und er funktioniert so weit auch wie er soll. Doch wie lange braucht man um ihn zu schreiben und wie viele Fehler könnten sich hier wohl einschleichen? So ist es z.B. möglich die Indexvariable bis zu einem falschen Wert laufen zu lassen, sie falsch hoch zu zählen oder sie gar anstelle der geforderten Zahl zur Liste hinzuzufügen. Nicht zu vergessen, dass die alte Liste auch noch jedes Mal mit der Neuen ersetzt werden muss – ein grauenvoller Code, der bei einem Fehlerfall viel Debuggingzeit erfordern kann. Von potenziellen Wutausbrüchen und ausgesprochenen Flüchen des Entwicklern und den daraus resultierenden psychischen Schäden des Computers ob der wüsten Beschimpfungen will ich gar nicht erst anfangen. Alles in allem geht man all diesen Problemen aus dem Weg wenn man sich den Paradigmen der funktionalen Programmierwelt beugt und zu einer deklarativen Vorgehensweise greift:

def multiply(n: Int): Seq[Int] =
  1 to n map (_ => n)

Natürlich ist es auch hier nicht möglich alle Fehler gleich beseitigt zu wissen, aber dieses Beispiel ist so kurz und knapp, dass man hier höchstens noch ein paar Logikfehler einbauen kann. So könnte man anstatt von eins von null zu zählen beginnen oder anstatt „map“ irgendeine andere Funktion höherer Ordnung auswählen. Vor letzterem kann uns aber der Compiler effektiv schützen, weil es sehr wahrscheinlich ist , dass es Unstimmigkeiten zwischen den Typsignaturen geben wird. Und selbst wenn der Fall eintritt und eine Funktion nicht das macht was sie machen sollte muss man hier wohl nie zu einem Debugger greifen sondern kann durch erneutes Nachdenken herausfinden wo sich der Fehler verbirgt.

Auch wenn sich Fehler niemals komplett vermeiden lassen so fühlt man sich doch gleich ein wenig besser wenn man weiß, dass der entwickelte Code unter der schützenden Hand des Compilers steht. Gehen wir nun also von den theoretischen Überlegungen weg, wie man verhindern kann, dass Fehler überhaupt erst auftreten, hin zu den Überlegungen was getan werden soll wenn ein Fehler erst einmal aufgetreten ist. Wie man einen Fehler innerhalb einer Funktion an die aufrufende Funktion meldet und wie die aufrufende Funktion mit dem Fehler umgeht sind zwei Paar Stiefel, die es getrennt zu betrachten gilt. Weiterhin gilt, dass auch die Art, wie mit einem Fehler umgegangen wird, aus zwei separaten Blickwinkeln betrachtet wird: Zum Einen gibt es die Möglichkeit einen Code zurückzugeben, der signalisiert ob alles korrekt verlaufen ist oder ob ein Fehler aufgetreten ist. Zum Anderen verfügen die meisten modernen Programmiersprachen über das Konzept der Exceptions – ein Geflecht aus Sprunganweisungen, die den Programmfluss unterbrechen und klug eingesetzt werden müssen um die Stabilität eines Programms nicht zu gefährden.

In funktionalen Programmiersprachen wird das Prinzip der Fehlercodes bevorzugt – also auch in Scala. Ich kann euch aber beruhigen, unter Fehlercode braucht ihr euch nicht das primitive und minderwertige Prinzip vorstellen, das z.B. in C vorkommt. Dort werden oft Integer-Werte zurückgegeben, die signalisieren ob eine Funktion erfolgreich durchlaufen werden konnte. In funktionalen Sprachen werden statt dessen typsichere Datentypen bzw. vollwertige Objekte zurückgegeben, die über ein zum Teil hochspezialisiertes Verhalten verfügen, mit dem die Art, wie mit dem Fehler umgegangen wird, bestimmt wird. In Scala begegnen uns diese „Fehlercodes“ in Form der Datentypen Option und Either, mit denen ich euch im Folgenden anfreunden möchte. Doch zuvor sollten wir einen Blick auf Exceptions werfen und uns verdeutlichen warum wir auf sie so gut es geht verzichten sollten.

Exceptions

Exceptions sind gleichermaßen mächtig wie gefährlich. Sie erlauben es, das eigentliche Programm zu jeder Zeit zu unterbrechen um es an einer anderen Stelle wieder fortzusetzen – was überhaupt nicht dem Sinne der strukturierten Programmierung entspricht. Falls Exceptions also zur Codeflusssteuerung genutzt werden, kann das schwere negative Folgen auf die Wartbar- und Übersichtlichkeit des Programms haben. Ob dieser Gefahren ist es sinnvoll Exceptions in erster Linie nur zur Verifizierung des Codes einzusetzen, was bedeutet, dass sie auf Programmierfehler hinweisen sollen und Laufzeitfehler, die mit I/O-Operationen zusammenhängen, aufdecken sollen.

Exceptions werden in Scala nach dem gleichen Prinzip erstellt wie in vielen anderen Sprachen auch:

def multOnlyPositive(i: Int) =
  if (i > 0) i*i else throw new UnsupportedOperationException

scala> multOnlyPositive(3)
res7: Int = 9

scala> multOnlyPositive(-3)
java.lang.UnsupportedOperationException
<stacktrace>

Das Schlüsselwort throw ist die einzige Möglichkeit ein Objekt vom Typ java.lang.Throwable durch das Programm zu „werfen“. Gefangen werden kann die Exception dann wieder mit der try-catch-expression:

def canProduceError(i: Int) = {
  val result = try multOnlyPositive(i) catch {
    case e: UnsupportedOperationException => 0
  }
  result == 0
}

scala> canProduceError(3)
res9: Boolean = false

scala> canProduceError(-3)
res10: Boolean = true

Wie jedes Konstrukt in Scala besitzt auch die try-catch-expression in Scala einen Rückgbabewert. Außerdem kann entweder ein ganzer Block oder auch nur ein einzelnes Statement an die Expression gebunden werden, wie es im Beispiel gut zu erkennen ist. Dies ermöglicht es uns die catch-expression an beliebige Funktionen zu binden:

def x: String = throw new UnsupportedOperationException
def y: String = throw new IllegalArgumentException
def z: String = throw new Exception

val handler1: PartialFunction[Throwable, String] = {
  case _: UnsupportedOperationException => "got it"
}
val handler2: PartialFunction[Throwable, String] = {
  case _: IllegalArgumentException => "and this too"
}

scala> try x catch handler1 orElse handler2
res12: String = got it

scala> try y catch handler1 orElse handler2
res13: String = and this too

scala> try z catch handler1 orElse handler2
java.lang.Exception
<stacktrace>

Das Beispiel zeigt, dass es über die übliche „orElse“-Methode einer Funktion möglich ist verschiedene Handler miteinander zu verbinden. Sollte der Fall eintreten, dass die catch-expression keinen Handler findet, der die Exception verarbeiten kann, so wird diese einfach weiter geworfen. Der Grund warum wir partielle Funktionen benötigen ist schnell erklärt: Da ein passender Exception-Handler über Pattern-Matching herausgesucht wird, würde es zu einem MatchError kommen sollte kein passender Handler gefunden werden. Da die catch-expression ein Throwable fangen kann ist es unabdingbar, dass die partielle Funktionen auch diesen Typ erwarten. Eine Spezialisierung, z.B. auf Exception, würde zu einem Typfehler führen.

Neben der try-catch-expression gibt es noch den finally-Block, den man mit einem try-catch kombinieren kann. Der finally-Block ermöglicht es Code auszuführen, der immer ausgeführt wird – egal ob eine Exception aufgetreten ist oder nicht:

def multOnlyPositive(i: Int) =
  if (i > 0) i*i else throw new UnsupportedOperationException

scala> try multOnlyPositive(-3) catch { case _ => 0 } finally println("finally is executed")
finally is executed
res4: Int = 0

scala> try multOnlyPositive(3) catch { case _ => 0 } finally println("finally is executed")
finally is executed
res5: Int = 9

Es ist zu beachten, dass der Rückgabewert von finally verworfen wird, d.h. er beeinflusst nicht die try-catch-expression. In ihm können mehr oder weniger sinnvolle Dinge getan werden wie z.B. eine Datenbankverbindung schließen nachdem bei deren Benutzung eine Exception geworfen wurde.

Anmerkung: Die try-expression kann auch ohne eine catch-expression existieren, hat dann aber keinen Zweck. Der Code wird genauso ausgeführt wie wenn die try-expression nicht existieren würde. Die catch-expression und der finally-Block dürfen dagegen niemals alleine stehen.

In den meisten Fällen, in denen Fehler im Programmcode auftreten, muss man aber keine Exceptions einsetzen. Stattdessen macht es mehr Sinn auf Scalas „Returncodes“ zu setzen, da diese über viele Methoden verfügen, die es ermöglichen einen Fehler einfach, unkompliziert und absolut typsicher zu bearbeiten.

Option

Den ersten Datentyp zur Fehlerbehandlung, den ich euch vorstellen möchte, ist Option. Er ist mehr oder weniger die funktionale Antwort auf „null“ dem grausamen Gebilde, das leider in viel zu vielen Programmiersprachen seinen Einsatz findet. Ich möchte euch im Nachfolgenden erklären, warum null so böse ist, Option dagegen einfach nur wundervoll und vor allem wie man damit vernünftig arbeitet.

Option kann zwei Typen annehmen – Some und None, wobei man letzteres mit null vergleichen kann. None ist ein Singleton, das für keinen näher bestimmten Rückgabewert steht. Demgegenüber steht Some, das den eigentlichen Rückgabewert einer Funktion in sich aufnimmt und deshalb als Container für beliebige Typen dient. Ein Beispiel zur Funktionsweise:

def valueOf(i: Int): Option[String] = {
  val db = IndexedSeq("hello", "world", "how", "are", "you")
  if (i > 0 && i < db.size) Some(db(i)) else None
}

scala> valueOf(2)
res10: Option[String] = Some(how)

scala> valueOf(5)
res11: Option[String] = None

Es gibt mehrere Wege die Typen von Option zu erstellen. Für Some geht es am einfachsten wenn man dessen apply-Methode oder dessen Konstruktor mit dem Wert, den es aufnehmen soll, aufruft. Da None nicht erzeugt werden muss, sondern genau wie bspw. Nil bereits vorhanden ist, kann man es einfach über dessen Namen referenzieren.

Eine weitere Möglichkeit ein Option zu erzeugen ist über die apply-Methode von Option. Wird diese apply-Methode mit null aufgerufen wird None zurückgegeben, ansonsten ein Some. Das ist ganz praktisch wenn eine Funktion einen Wert zurückgibt, der null sein kann, man aber lieber mit Option arbeiten möchte (was man in Scala immer möchte – sofern null nicht unbedingt benötigt wird).

Nun bleibt nur noch zu klären was es uns bringt Option einzusetzen. Wollen wir auf unsere gewrappten Werte zugreifen, bedarf es ein wenig Overhead gegenüber der Variante mit null:

val o = valueOf(2)
val v = if (o.isDefined) o.get else sys.error("not defined")

// instead of
val v = valueOf(2)
if (v == null) sys.error("not defined")

Ein klein wenig übersichtlicher wird die Variante mit Option wenn zu Pattern-Matching gegriffen wird:

val v = valueOf(2) match {
  case None => sys.error("not defined")
  case Some(v) => v
}

Aber wirklich was hermachen tut der Code noch nicht. Um die wahre Stärke von Option zu erkennen dürfen wir uns hier tatsächlich nicht mit dem Wert aufhalten, den Option beinhalten könnte. Statt dessen müssen wir Option selbst bzw. dessen Abstraktionsmöglichkeiten betrachten. Oh je, jetzt geht das schon wieder los. Immer diese Abstraktion. Ich werde euch damit aber so lange quälen bis ihr nicht mal mehr im Schlaf daran denkt etwas nicht abstrahiert (in userem Fall: funktional) zu programmieren.

Zuerst sollten wir uns überlegen ob es wirklich notwendig ist, dass wir wissen ob unser Code fehlgeschlagen ist (sprich: es wurde None zurückgegeben) oder ob uns das nicht vollkommen egal sein kann. Tatsächlich dürfte es wohl so sein, dass wir Eingabewerte haben und diese zu Ausgabewerten transformieren wollen. Meistens sieht es doch so aus, dass wir erst in dem Moment in dem wir auf die potenziellen Ausgabewerte auch tatsächlich zugreifen wollen auch wissen müssen ob wir ein zufriedenstellendes Ergebnis erhalten haben oder ob irgendwo ein Fehler aufgetreten ist. Aber müssen wir auch bei der internen Datenverarbeitung tatsächlich immer wissen was für genaue Werte in userem Programm existieren? Überlegen wir uns das mal im Detail:

scala> val xs = List(3, -2, 1, 7, 9, 4) map valueOf
xs: List[Option[String]] = List(Some(are), None, Some(world), None, None, Some(you))

Wir haben eine Liste aus Eingabewerten und wollen diese verarbeiten. Bei einigen der Werten bekommen wir nur ein None zurück, was symbolisieren soll, dass unser Programm nichts mit den Eingabewerten anfangen kann. Noch ist unsere Datenverarbeitung aber nicht abgeschlossen:

scala> xs map (opt => if (opt.isDefined) Some(opt.get+"!") else None)
res3: List[Option[java.lang.String]] = List(Some(are!), None, Some(world!), None, None, Some(you!))

scala> xs map (opt => opt map (str => str+"!"))
res4: List[Option[java.lang.String]] = List(Some(are!), None, Some(world!), None, None, Some(you!))

scala> xs map (_ map (_+"!"))
res5: List[Option[java.lang.String]] = List(Some(are!), None, Some(world!), None, None, Some(you!))

Wir wollen an alle gefundenen Strings ein Ausrufezeichen anhängen, uns aber möglichst nicht weiter um die Datenpakete kümmern, die kein String zurückgegeben haben. Dieses Vorhaben erreichen wir viel besser indem wir abstrahieren, anstatt, wie im ersten Beispiel, jedes einzelne Ergebnis zu betrachten. Option verfügt genau wie die Klassen aus der Collection-Bibliothek über viele nützliche Funktionen höherer Ordnung, die es uns erlauben schnell und komfortabel die gewrappten Inhalte zu transformieren. Das Besondere an diesen Methoden ist, dass sie keine Exceptions werfen wenn es mal keine Daten zu verarbeiten gibt, sondern einfach wieder None zurückgeben bzw. einfach nichts machen. Dieses Verhalten erlaubt es uns ganz normal mit Option zu arbeiten, so wie wenn wir direkt mit den Werten arbeiten würden – alle lästigen null-Abfragen entfallen bzw. werden für uns von der Bibliothek übernommen. Dieses Prinzip kann in einer beliebigen Komplexitätsstufe fortgeführt werden, selbst wenn wir es mit noch so vielen potenziellen null-Werten zu tun bekommen. Wollen wir in obigem Beispiel nun an die tatsächlichen Daten herankommen reicht wieder der Einsatz einer entsprechenden Methode:

scala> val values = xs flatMap (_ map (_ + "!"))
values: List[java.lang.String] = List(are!, world!, you!)

„flatMap“ kann wie „map“ einzelne Werte zu beliebig Anderen mappen. Es kommt aber noch hinzu, dass es alle gemappten Werte auch noch auspackt und sie ohne ihre Containerklasse zur Ausgangs-Collection hinzufügt. Da es bei einem None nichts auszupacken gibt wird in unserem Fall einfach ein Nil an die Liste ergänzt, was keinen weiteren Effekt hat – somit verschwinden urplötzlich alle ungültigen Werte. Es ist genau so wie wenn die ungültigen Werte nie existiert hätten.

Im Folgenden noch ein Beispiel aus der imperativen Programmierwelt, das sich exzessive an null-Werten bedient:

case class Module(id: Int)

case class Context(modules: Map[String, Module])

def getAttribute(data: Int, config: String): String = data -> config match {
  case (1, "context") => "main_context"
  case (_, "context") => "not_defined"
  case (1, "name") => "module_ip"
  case _ => ""
}

def getContext(name: String): Context = name match {
  case "main_context" => Context(Map("module_ip" -> Module(9878624)))
  case _ => null
}

var modules: List[Module] = Nil

def resolve(data: Int) = {
  val contextName = getAttribute(data, "context")
  val name = getAttribute(data, "name")
  val module = {
    val context = getContext(contextName)
    if (context == null) null
    else {
      val result = context.modules.getOrElse(name, null)
      if (result != null) modules ::= result
      result
    }
  }
  if (module == null)
    throw new IllegalArgumentException("module is not defined")
  println(module)
}

Die Methode, auf die wir unser Augenmerk richten wollen, ist „resolve“, die wieder einmal unsere Eingabewerte verarbeiten soll (der Einfachheit halber bestehen diese Daten nur aus einem Int). Zuerst werden verschiedene Attribute geladen, die mit den Daten schon vorher verknüpft wurden. Die Verknüpfungen stehen hier – wieder der Einfachheit halber – hardcodiert im Programm. Als Nächstes soll ein Modul geladen werden, das für die eingegebenen Daten zuständig ist. Nun wird dieses Modul im else-Zweig der Verknüpfung einer Liste hinzugefügt, wenn es denn tatsächlich definiert ist. Zuletzt folgt eine Prüfung ob ein Fehler aufgetreten ist und falls dies nicht der Fall sein sollte wird das Modul weiterverarbeitet, was hier symbolisch durch die Ausgabe auf der Konsole dargestellt wurde.

Natürlich funktioniert das Programm, es ist zum Einen aber sehr hässlich und zum Anderen sind die dauernden null-Abfragen unnötig. Eine einzige Abfrage, die erst ganz zum Schluss (nämlich dann wenn wir wissen wollen ob ein Modul erfolgreich geladen werden konnte) platziert wird, sollte unseren Ansprüchen vollkommen genügen. Betreiben wir also ein wenig Refactoring um einen schöneren Code zu bekommen.

Das Laden der Attribute können wir so lassen wie es ist, da die Rückgabe von ungültigen Werten im restlichen Code jederzeit aufgefangen werden kann. Erst unseren Context wollen wir mit Option absichern, sodass ein potenziell zurückgegebenes null keine Probleme mehr verursachen kann:

scala> val context1 = Option(getContext("main_context"))
context1: Option[Context] = Some(Context(Map(module_ip -> Module(9878624))))

scala> val context2 = Option(getContext("invalid"))
context2: Option[Context] = None

Wir können wieder mit der Methode „map“ den Inhalt des zurückgegebenen Options verarbeiten:

scala> val result = context1 map (_.modules get "module_ip")
result: Option[Option[Module]] = Some(Some(Module(9878624)))

scala> val result = context1 map (_.modules get "")
result: Option[Option[Module]] = Some(None)

scala> val result = context2 map (_.modules get "")
result: Option[Option[Module]] = None

Anstatt der Methode „getOrElse“ von Map, benutzen wir nur „get“, die bereits ein Option zurückgibt. Zwar erhalten wir damit Zugriff auf das Modul, haben dafür aber nun ein Option in einem Option, was ziemlich umständlich für eine etwaige Weiterverarbeitung ist. Benutzen wir doch statt dessen die Methode „flatMap“:

scala> val result = context1 flatMap (_.modules get "module_ip")
result: Option[Module] = Some(Module(9878624))

scala> val result = context1 flatMap (_.modules get "")
result: Option[Module] = None

scala> val result = context2 flatMap (_.modules get "")
result: Option[Module] = None

Nun fehlt nur noch der Teil, bei dem das Modul zu der Liste hinzugefügt werden soll:

result forach (modules ::= _)

Wird „foreach“ auf None aufgerufen, wird die übergebene Funktion nie ausgeführt. Nun können wir ohne weitere Sorgen prüfen ob während all diesen Berechnungen irgendwo ungültige Werte vorlagen:

result match {
  case None => // throw error
  case Some(module) => // use module
}

Dies alles resultiert in einem deutlich übersichtlicheren und vor sehr sicheren Code. Durch Scalas strenges Typsystem brauchen wir uns wenig Sorgen darum machen ob wir die richtigen Methoden auf die richtigen Typen aufrufen und ob all die Typen am Schluss auch zusammenpassen. Würden sie es nicht tun, würde unser Code nicht kompilieren – hätten wir jedoch statt Option böse null-Werte eingesetzt, könnten wir nie sicher sein ob unser Code zur Laufzeit auch tatsächlich funktioniert oder ob nicht doch noch irgendwo noch ein Fehler auftreten kann. Wenn wir zur Datenmanipulation nur auf Funktionen höherer Ordnung zurückgreifen, können wir dagegen schon sehr sicher sein, dass dort außer einem Logikfehler keine weiteren Leichtsinnsfehler mehr auftreten können.

Bauen wir die einzelnen Codeteile nun zusammen, erhalten wir folgenden Code:

def resolve(data: Int) {
  val contextName = getAttribute(data, "context")
  val name = getAttribute(data, "name")
  val context = Option(getContext(contextName))
  val result = context flatMap (_.modules get name)
  result foreach (modules ::= _)
  result match {
    case None =>
      throw new IllegalArgumentException("module is not defined")
    case Some(module) =>
      println(module)
  }
}

Je nach dem was für Aufgaben von der foreach-Methode und dem Pattern-Matching ausgeführt werden müssen, besteht auch die Möglichkeit die beiden Teile zusammenzulagern (das Hinzufügen des Moduls zur Liste könnte z.B. auch im Pattern-Matching erledigt werden).

Gehen wir den Code nun noch einmal in Ruhe durch und überlegen uns was denn dazu geführt hat, dass er entstanden ist. Wir haben Schrittweise eine Transformation nach der anderen durchgeführt, bis wir am Ende das Ergebnis bekommen haben, das wir erhalten wollten. Das Aufteilen eines Algorithmus in einzelne Teilaufgaben und die danach folgende mehr oder weniger sequentielle Ausführung jener Teilaufgaben ist typisch für das funktionale Programmieren. Man findet hier wenig Code, der einzelne Aufgaben wild durcheinander ausführt, wie es bei imperativen Sprachen sehr oft der Fall ist. Diese Strukturierung wird hauptsächlich ermöglicht durch ein mächtiges Typsystem, durch Closures und durch umfangreiche Bibliotheken, die es nicht weiter erforderlich machen, dass man immer wieder das Rad neu erfindet und jede noch so kleine Funktion immer wieder neu implementiert. Strukturierung führt letztendlich zu Abstraktion, da viel öfter beschrieben wird was getan werden soll anstatt wie es getan werden soll.

Es wird am Anfang eine gewisse Zeit dauern bis man sich an die angebotenen Abstraktionen gewöhtn hat und bis man gelernt hat wie man sie richtig einsetzen kann. Ist dieser Zeitpunkt aber erst einmal gekommen möchte man diese Abstraktionen nicht mehr missen.

Either

Der Einsatz von Option bietet sich an wenn man sich im Fehlerfall nicht weiter um den Fehler kümmern möchte, aber was wenn man den Fehler doch noch benötigen sollte? In diesem Fall ist es ratsam auf Either zurückzugreifen. Im Gegensatz zu Option kann Either nicht nur einen, sondern ganze zwei Typen annehmen – einen für das erwartete Ergebnis und einen für einen eventuellen Fehler.

def calc(i: Int): Either[String, Int] =
  if (i < 0) Left("i is negative")
  else if (i < 10) Left("i is too small")
  else Right(i*i)

scala> calc(-1)
res0: Either[String,Int] = Left(i is negative)

scala> calc(12)
res1: Either[String,Int] = Right(144)

Die beiden Typen heißen – vollkommen unspektakulär – Left und Right. Da beide Typen beliebig gewählt werden können ist es im Grunde genommen egal ob die rechte oder die linke Seite den Fehlertyp repräsentiert. Es hat sich jedoch die Konvention eingebürgert, dass die linke Seite den Fehlertyp repräsentieren soll und wenn man keine Verwirrungen erzeugen möchte, sollte man sich auch an daran halten. Mit Either geht es erst einmal wenig spektakulär weiter:

def get(i: Int) = calc(i) match {
  case Left(s) => println(s); 0
  case Right(i) => i-5
}

scala> get(3)
i is too small
res35: Int = 0

scala> get(15)
res36: Int = 220

Wieder einmal kommen wir über Pattern-Matching an die internen Werte heran. Das war es aber auch schon fast, denn wenn wir in die API von Either gucken, finden wir dort nur zwei jämmerliche Methoden, mit denen wir noch einigermaßen idiomatisch auf unsere Werte zugreifen können. Dies wäre zum Einen „fold“ und zum Anderen „join“:

scala> calc(3).fold(err => "error: "+err, succ => succ.toString)
res40: String = error: i is too small

scala> calc(11).fold(err => "error: "+err, succ => succ.toString)
res41: String = 121

scala> calc(3).fold(identity, succ => succ.toString)
res44: String = i is too small

Dieses „fold“ ist mit „map“ von Option vergleichbar – nur mit dem Unteschied, dass auch für den Fehlerfall angegeben werden muss was getan werden soll. Möchte man eine der beiden Seiten nicht verändern, bietet sich der Einsatz der Identitätsfunktion an, wie im dritten Beispiel gezeigt. Die Methode „join“ liegt für beide Seiten jeweils einmal vor:

scala> val e: Either[Either[String, Int], Int] = Left(Left("error"))
e: Either[Either[String,Int],Int] = Left(Left(error))

scala> e.joinLeft
res7: Either[String,Int] = Left(error)

scala> val e: Either[String, Either[String, Int]] = Right(Right(3))
e: Either[String,Either[String,Int]] = Right(Right(3))

scala> e.joinRight
res8: Either[String,Int] = Right(3)

scala> val e: Either[String, Either[String, Int]] = Right(Left("error"))
e: Either[String,Either[String,Int]] = Right(Left(error))

scala> e.joinRight
res11: Either[String,Int] = Left(error)

scala> e.joinLeft
<console>:9: error: Cannot prove that String <:< Either[C,Either[String,Int]].
              e.joinLeft
                ^

Die Funktionsweise ist an „flatMap“ angelehnt, nur dass es kein „map“ gibt. Das innere Either wird entpackt und zurückgegeben. Dabei muss man nur aufpassen, dass man die richtige Methode aufruft – das ist immer die, die den gleichen Namen (mit dem Präfix „join“ davor) wie das äußere Either hat. Ruft man die Falsche auf, bekommt man einen unschönen Typfehler, der „übersetzt“ so viel bedeutet wie, dass der Compiler festgestellt hat, dass ein String kein Subtyp (<:<) von Either ist. Wie der Fehler genau zu Stande kommt soll uns hier erst einmal egal sein. Es ist schnell zu erkennen, dass diese Methoden lange nicht so praktisch sind wie die von Option – was dem Problem zu verdanken ist, dass man auf beide Seiten gleichermaßen zugreifen können muss.

Dennoch besteht die Möglichkeit jeweils eine der Seiten zu ändern – dafür muss man sich nur den beiden Methoden „left“ und „right“ bedienen, die jeweils nochmal einen Wrapper zurückgeben. Dieser wrappt dabei den kompletten Either und erlaubt somit mit der von Option bereits bekannten Abstraktionsstufe zu operieren:

scala> val e = calc(11)
e: Either[String,Int] = Right(121)

scala> e.left.map(_*2)
res23: Either[String,Int] with Product with Serializable = Right(121)

scala> e.right.map(_*2)
res24: Either[String,Int] with Product with Serializable = Right(242)

Eine Projektion arbeitet immer nur mit einer Seite – wollen wir also die rechte Seite manipulieren, benötigen wir auch eine „RightProjection“. Die Projektion gibt dabei wieder ein Either zurück, weshalb wir für jeden weiteren Transformationsschritt wiederum eine neue Projektion erschaffen müssen. Either eignet sich besonders gut wenn man eventuelle Fehlermeldungen stacken möchte, damit man sie gebündelt an den Aufrufer zurückgeben kann. Schauen wir uns mal wie wir das erreichen können. Zuerst passen wir unsere calc-Methode minimal an:

def calc(i: Int): Either[String, Int] =
  if (i < 0) Left("value is negative")
  else if (i < 10) Left("value is too small")
  else Right(i*i)

Nun benötigen wir noch ein paar Eingabewerte, die wir in unsere Methode einspeisen können:

scala> val calculations = List(-1, 15, 3, 11, 7) map calc
calculations: List[Either[String,Int]] = List(Left(value is negative), Right(225), Left(value is too small), Right(121), Left(value is too small))

Da es sehr viele Eingabewerte geben kann, wollen wir dem Aufrufer die Möglichkeit geben, schnell herauszufinden welche der Daten einen Fehler verursacht haben:

scala> :paste
// Entering paste mode (ctrl-D to finish)

val mappings = calculations.zipWithIndex map {
  case (calc, i) => calc.left map ("error at position "+i+": "+_)
}

// Exiting paste mode, now interpreting.

mappings: List[Either[String,Int] with Product with Serializable] = List(Left(error at position 0: value is negative), Right(225), Left(error at position 2: value is too small), Right(121), Left(error at position 4: value is too small))

Wir müssen die berechneten Werte nur mit ihrem Index verknüpfen und dann bei jedem Left die Fehlermeldung ein wenig anpassen. Nun können wir uns alle Fehlermeldungen heraussuchen:

scala> val errors = mappings collect { case Left(e) => e }
errors: List[String] = List(error at position 0: value is negative, error at position 2: value is too small, error at position 4: value is too small)

Zu guter Letzt müssen wir noch herausbekommen ob überhaupt ein Fehler aufgetreten ist. Dazu genügt eine kleine Abfrage:

if (errors.isEmpty) Right(calculations map { case Right(i) => i })
else Left(errors)

Gab es keinen Fehler, wissen wir automatisch, dass alle Werte in „calculations“ ein Right sind – es genügt also diese über ein „map“ zu entpacken. Würden wir dieses Wissen nicht haben, müssten wir auf „collect“ zurückgreifen, wie im vorherigen Beispiel gezeigt, da wir sonst einen Match-Error bekommen würden. Alles in allem wollen wir diesen Code natürlich mehrmals aufrufen können. Fassen wir ihn also in einer Methode zusammen:

def calcValues(xs: List[Int]): Either[List[String], List[Int]] = {
  val calculations = xs map calc
  val mappings = calculations.zipWithIndex map {
    case (calc, i) => calc.left map ("error at position "+i+": "+_)
  }
  val errors = mappings collect { case Left(e) => e }
  if (errors.isEmpty) Right(calculations map { case Right(i) => i })
  else Left(errors)
}

scala> calcValues(List(-1, 15, 3, 11, 7))
res44: Either[List[String],List[Int]] = Left(List(error at position 0: value is negative, error at position 2: value is too small, error at position 4: value is too small))

scala> calcValues(List(15, 11))
res45: Either[List[String],List[Int]] = Right(List(225, 121))

Ein Aufrufer muss nun nur noch den Rückgabewert prüfen, was komfortabel über Pattern-Matching geht. Dieser Code zeigt deutlich, genau wie das Beispiel mit Option, dass abstrahiertes Programmieren uns viel Arbeit ersparen kann. Innerhalb von „calcValues“ brauchen wir uns niemals Gedanken darum machen ob nun irgendwo ein Fehler aufgetreten ist. Hätten wir statt auf Either auf Exceptions gesetzt, hätten wir auch überall hässliche try-catch-Expressions einbauen müssen, die den Code viel unübersichtlicher und schwerer wartbar gemacht hätten.

Zusammenfassung

Pattern-Matching, Lambdas, Typinferenz und eine mächtige Bibliothek, die viele Funktionen höherer Ordung anbietet, bilden ein mächtiges Gespann, das richtig eingesetzt der imperativen Denkweise um ein vielfaches überlegen ist. Ich hoffe, dass ich es geschafft habe, euch die funktionale Sichtweise auf Abstraktionen nochmals ein ganzes Stück näher zu bringen. Ich möchte euch noch ein paar Regeln mit auf den Weg geben, die euch helfen sollen euch an die Abstraktionen zu gewöhnen:

  • Versucht unter allen Umständen „null“ zu vermeiden.
  • Versucht bei Option und Either nur dann Pattern-Matching einzusetzen, wenn ihr auch wirklich auf die beinhalteten Werte zugreifen müsst. In der Regel ist das erst ganz am Ende der Datenverarbeitung der Fall.
  • Vermeidet bei Option und den Projektionen von Either den Aufruf von „get“ (und die meistens vorhergehenden Aufrufe von „isDefined“, „isRight“ oder „isLeft“). Benutzt statt dessen Funktionen höherer Ordung um auf die Werte zugreifen zu können.
  • Benutzt Either als Ersatz von Exceptions wenn irgendwie möglich.
  • Beim Arbeiten mit Either sind die Projektionen der weitaus wichtigere Teil – das Either selbst ist für nicht viel mehr als zum Pattern-Matching zu gebrauchen.

Die obigen Punkte sind natürlich keinesfalls so zu verstehen, dass sie immer und unter allen Umständen eingehalten werden müssen – sie bilden mehr ein Design-Pattern, bei dessen Einhaltung man Fehler durch die Entwicklung von besserem Code vermeidet. Manchmal kann es sinnvoll sein, dass man auf diese Design-Pattern verzichtet. Das ist vor allem dann der Fall wenn man von Scala aus imperativ entwickelte Java-Bibliotheken ansteuern möchte oder wenn man hocheffizienten Code benötigt, der nur imperativ geschrieben auch wirklich effizient ist. Diese Fälle treten in der Regel aber nur selten auf und generell gilt: Wenn man nicht entscheiden kann ob man imperativ programmieren muss, dann muss man es auch nicht. Daraus folgt, dass man sich an die obigen Punkte dann auch halten sollte.

Einige Schwierigkeiten, die beim Vermeiden von Abstraktionen entstehen, bekommt man durch die bereits angesprochenen Exceptions (Ich hoffe, dass die Argumente gegen sie beim Lesen des Kapitels rübergekommen sind). Auch das manuelle Heraussuchen von entsprechenden Werten aus Collections wäre eine mühselige Angelegenheit und hätte der Übersichtlichkeit wegen in weitere Methoden ausgelagert werden müssen. Vor allem fehlende Typinferenz wäre oftmals ein K.O.-Kriterium für den Einsatz von Either oder Option. Sollten diese nämlich einmal geschachtelt auftauchen und dann auch noch mit einem parametrisierten Typen als Inhalt müsste man Typen notieren, die jeden Code im Typchaos versinken lassen würden.

Zu Excepitons lässt sich noch sagen, dass wenn man mal tatsächlich mit ihnen arbeiten muss, man noch immer auf Scalas skalierbare Snytax zurückgreifen kann um sie sich vom Hals zu halten:

def catchable[A](f: => A): Either[Exception, A] =
  try Right(f) catch { case e: Exception => Left(e) }
  
scala> catchable("987".toInt)
res49: Either[Exception,Int] = Right(987)

scala> catchable("98s7".toInt)
res50: Either[Exception,Int] = Left(java.lang.NumberFormatException: For input string: "98s7")

Vor allem wenn geschweifte Klammern eingesetzt werden, sieht der Code tatsächlich so aus, wie wenn man eine in die Sprache schon eingebaute Kontrollstruktur benuzten würde:

val reslut = catchable {
  // do heavy calculation here
  // return some value
}

Als Hilfestellung beim Einsatz von Option bietet sich außerdem die Lektüre folgenden Artikels an, der die in Option eingebauten Funktionen höherer Ordnung aufschlüsselt und zeigt, was für ein Pattern-Matching Konstrukt man statt dessen nutzen müsste: scala.Option Cheat Sheet

Advertisements