Funktionale Aspekte in Scala

Scala Tutorial Teil 3: Fehlerbehandlung

Johannes Dienst

© Shutterstock.com / Aquir

Als Multiparadigmensprache besitzt Scala neben Objektorientierung auch ausgeprägte Merkmale einer funktionalen Sprache. Dadurch wird sie für viele Einsatzgebiete interessant. In diesem Tutorial lernen Sie Schritt für Schritt die Programmierung mit Scala und lernen so ihre Vorteile kennen. In diesem Teil: Fehlerbehandlung – Viel Spaß!

In Teil 1 und Teil 2 des Tutorials wurde ausführlich auf die essenziellen Eigenschaften von Scala eingegangen. Dabei haben wir das Build-Tool sbt kennengelernt und ein erstes Projekt mit dem Namen „Die Bibliothek“ erstellt. Dieses wurde testgetrieben mit ScalaTest entwickelt, was den Umgang mit sbt und das Management von Fremdbibliotheken einschloss. Neben den ausgeprägten Eigenschaften einer objektorientierten Sprache, wie Klassen und Traits, wurde auch ausführlich auf die funktionalen Sprachfeatures eingegangen. Diese umfassten vor allem Funktionen höherer Ordnung, Unveränderlichkeit und das mächtige Pattern Matching.

JVM-Sprache Scala: Die Tutorial-Serie

Um die Grundlagen von Scala abschließend zu behandeln, ist noch ein weiterer Baustein nötig: Die Fehlerbehandlung an sich und das Exception Handling im Speziellen. Scala kennt insgesamt drei verschiedene Arten mit Fehlern/Exceptions umzugehen. Als Erstes ist das klassische try/catch-Konstrukt an der Reihe, das ähnlich wie in Java funktioniert, aber bedingt durch Pattern Matching kompakter daherkommt. Danach wird mit dem Typ Option ein Ansatz gezeigt, der den Umgang mit null erleichtert. Es ist grundsätzlich ratsam, in Scala null zu vermeiden. Stattdessen sollte man eine Option verwenden, falls der tatsächliche Wert null annehmen kann. Als dritte Möglichkeit steht Try zur Verfügung. Damit können Exceptions sogar über Thread-Grenzen hinweg zurückgegeben werden, sodass die aufrufende Methode sie behandeln kann. Das ist vor allem in Programmen mit Multithreading notwendig, damit Exceptions aus einem Thread in einem anderen Thread zur Verfügung stehen und dort Logik zur richtigen Behandlung greifen kann.

Try/Catch on steroids

Um das Exception Handling in Scala zu erörtern, wird eine neue Klasse implementiert: DatabaseCSV. Die auf CSV basierende Datendatei soll händisch ohne Fremdbibliotheken geparst werden. Dabei kann es zu einigen Fehlern oder Exceptions kommen, die entsprechend behandelt werden wollen. Listing 1 zeigt unsere Klasse, die im Moment in der Methode readFromFile() eine CSV-Datei auf etwas altmodische Art über einen BufferedReader einliest. Scala bietet natürlich dafür ein besseres API, aber dann könnte man diesen Fall des Exception Handlings nicht zeigen.

Listing 1: Behandlung von Exceptions mit try/catch

class DatabaseCSV(dbPath: String = "src/main/resources/database.csv") extends Database {

  val books: ListBuffer[Book] = ListBuffer[Book]()
  readFromFile()

  private def readFromFile() = {
    val reader = new BufferedReader(new FileReader(dbPath))
    try {
      for (line <- Iterator.continually(reader.readLine()).takeWhile(_ != null)) { println(line) } } catch { case e: IOException => e.printStackTrace()

      // DO NOT USE THIS: Catches JVM errors too!
      //case e: Throwable => e.printStackTrace()

      // Use this instead to catch everything non fatal
      case NonFatal(e) => e.printStackTrace()
    } finally {
      reader.close()
    }
  }
}

Kurze Einführung von Generatoren

Um die Verwendung von for-Comprehension im weiteren Verlauf des Artikels zu verstehen, ist ein kleiner Exkurs notwendig. Dem aufmerksamen Leser wird die Zeile line <- Iterator.continually(…) aufgefallen sein. Dabei handelt es sich um einen Generator, der durch <- erzeugt wird. Aus einem Objekt vom Typ Iterable wird mit Iterator.continually(..) ein Iterator gebaut, der potenziell unendlich ist. Die Magie innerhalb der for-Comprehension verwandelt das in einen Generator. Der Generator gibt bei jedem Aufruf den nächsten Wert aus.

Hinweis: Wie im weiteren Verlauf dieses Tutorials klar wird (siehe Abschnitt Either) ist die Bezeichnung Generator nur ein Hilfskonstrukt, um for-Comprehensions besser zu verstehen. Tatsächlich werden flatMap und map geschickt miteinander verknüpft, um das Verhalten von for-Comprehensions zu erreichen.

 

Für die rudimentäre Implementierung soll über alle Zeilen der CSV-Datei iteriert werden, die unter src/main/resources/database.csv liegt und wie folgt aussieht:

Clean Coder;Robert Cecil Martin;0137081073
Code Complete 2;Steve McConnell;0735619670
The Pragmatic Programmer;Andrew Hunt;3446223096
The Pragmatic Programmer;Andrew Hunt;344

Wird ein Objekt vom Typ DatabaseCSV erzeugt, bekommt man auf der Konsole die gleiche Ausgabe, deswegen erspare ich dem Leser das. Viel interessanter ist dagegen die Verwendung des try/catch-Blocks, der sich stark an sein Java-Pendant anlehnt. Die Syntax ist bis auf den catch-Block identisch und es gibt auch einen finally-Block, der sich so verhält, wie man es aus Java gewohnt ist. Beim catch-Block wird es interessant. Hier wird wie so oft auf Pattern Matching zurückgegriffen, indem catch kein Exception-Objekt übergeben bekommt, sondern innerhalb des Blocks auf gematchte Exception-Typen reagiert. Das erste case-Statement behandelt die Checked Exception IOException, die beim Zugriff auf Dateien auftreten kann. Will man diese Arten von Exceptions behandeln, bietet sich die verwendete Syntax an.

Sollen stattdessen allgemein alle Exceptions behandelt werden, dann ist man vielleicht versucht, die Variante des zweiten (auskommentierten) case-Statements zu implementieren. Das hat den Nachteil, dass Throwable alle Exceptions umfasst, inklusive aller JVM-Exception, wie OutOfMemoryException. Das ist eventuell nicht das gewünschte Verhalten. Um alles außer JVM-Exceptions zu erwischen, sollte auf das Object NonFatal zurückgegriffen werden. Es matcht auf alle nicht fatalen Throwables und eignet sich deshalb, um diese Fehlerklasse sinnvoll zu behandeln.

Lesen Sie auch: Vert.x 3.4 wird polyglotter: Jetzt mit Support für Scala und Kotlin

Option statt null

Im letzten Teil wurde viel mit null gearbeitet, was ein wenig im Widerspruch zu der Aussage stand, dass in Scala alles ein Objekt sei. Tatsächlich ist die Verwendung von null möglich, aber in so gut wie allen Fällen nicht nötig, da Scala mächtigere Alternativen bereitstellt. Damit lassen sich dann auch die gefürchteten NullPointerExceptions vermeiden. Um ein eventuelles Auftreten von null zu Kennzeichnen wird die abstrakte Klasse Option[+A] verwendet. Dieser Typ besitzt zwei Untertypen Some[+A] und None. Wird eine Instanz vom Typ Some[A] verwendet, bedeutet es, dass ein Wert anwesend ist. Die Case-Klasse Some kann zur Erzeugung eines Objekts vom Typ Some[A] dienen:

val aInt: Option[Int] = Some(42)

Falls kein Wert anwesend ist (also der Wert null ist), dann verwendet man None:

val aNoneInt: Option[Int] = None

Normalerweise wird nicht direkt ein Objekt vom Typ Option[A] erzeugt, sondern immer Some[A] oder None. Will man sichergehen, dass nicht durch die Verwendung von Fremdcode doch null entsteht, kann das Companion-Object verwendet werden. Dieses enthält eine Factory-Methode, die für einen Wert entsprechend Some oder None zurückgibt:

val aCreatedString: Option[String] = Option("42")
val aCreatedNoneString: Option[String] = Option(null) // Creates None

Anzumerken ist dabei, dass das nur für Typen funktioniert, deren Bottom-Type null ist. Für String ist das null. Für Int das Objekt vom Typ Null (man beachte die Großschreibweise). Das Folgende würde also nicht funktionieren:

scala> val aCreatedNoneInt: Option[Int] = Option(null)
<console>:11: error: an expression of type Null is ineligible for implicit conversion
       val aCreatedNoneInt: Option[Int] = Option(null)

Sehen wir uns Option[A] am Besten in freier Wildbahn an. Listing 2 definiert die Methode lineToBook(line: String), die eine CSV-Zeile in ein Objekt vom Typ Book verwandeln soll. Da es auch ungültige Zeilen geben wird, muss mit Exceptions zur Laufzeit gerechnet werden. Deswegen wird die Logik in einen try/catch-Block gepackt und als Rückgabewert Option[Book] definiert. Es kann also sein, dass None statt eines fertig geparsten Objekts vom Typ Book zurückgegeben wird. Innerhalb des try-Blocks wird die Zeile zuerst nach dem Zellendelimiter ; zerlegt, anschließend jeder Wert getrimmt und dann der Methode fromCSV(line: Array[String]) des Companion-Objects von Book übergeben, um daraus ein Objekt vom Typ Book zu erstellen. Ist das erfolgreich, wird es noch mit Some als Option verpackt. Tritt beim Parsen eine Exception auf, dann wird diese gefangen und None zurückgegeben.

Listing 2: Option als Rückgabetyp

private def lineToBook(line: String): Option[Book] = {
  try {
    val splitLine = line.split(";").map(_.trim)
    return Some(Book.fromCSV(splitLine))
  } catch {
    case NonFatal(e) => None
  }
}

Die Methode lineToBook(line: String) soll nun in readFromFile() eingesetzt werden, um die Klassenvariable books zu befüllen. Hier kommt – wie könnte es auch anders sein – Pattern Matching auf das zurückgegebene Objekt vom Typ Option[Book] zum Einsatz. Listing 3 zeigt die zwei case-Statements, die auf die Typen Some[Book] und None matchen. Wenn ein Buch verfügbar ist, dann wird es Books hinzugefügt, ansonsten wird die ungültige Zeile in der Konsole gemeldet.

Listing 3: Nutzung von Option in readFromFile()

lineToBook(line) match {
  case Some(book) => update(book)
  case None => println("Line is not valid: " + line)
}

Options bieten noch ganz andere Möglichkeiten an, mit ihnen zu arbeiten. Zum Beispiel kann über die Methode isDefined abgefragt werden, ob eine Option einen Wert enthält:

if (books.isDefined) {
  println(books)
}

Obwohl das möglich ist, sollte davon kein Gebrauch gemacht werden, da die if-Abfrage gerne vergessen wird und es dann doch zu einem Laufzeitfehler kommt.
Die Methode getOrElse[B >: A](default: => B): A ist an dieser Stelle die bessere Wahl, da sie den Entwickler zwingt, einen Alternativwert anzugeben, sollte eine Option keinen Wert enthalten:

val bookList = books.getOrElse(List())

Eine weitere, vielleicht etwas überraschende Eigenschaft ist, dass eine Option sich wie eine Collection verhält. Dadurch kann auf ihr zum Beispiel foreach(f: (A) => Unit) aufgerufen werden:

books.foreach(l =&gt; println(l.size))

Zu beachten ist dabei, dass die übergebene anonyme Funktion genau einmal aufgerufen wird, nämlich wenn die Option ein Objekt vom Typ Some ist. Auch die Filterung ist möglich:

books.filter(_.isEmpty)) // Returns None
books.filter(!_.isEmpty)) // Returns Some(List[Book])

Beim Filtern zeigt sich, wie nützlich Option ist. Filtert man ein Objekt vom Typ None, tritt kein Fehler auf und der Rückgabewert ist wieder None. Enthält die Option hingegen einen Wert – ist also ein Objekt vom Typ Some – dann erhält man, je nachdem, ob das übergebene Prädikat false oder true ergibt, None oder Some(…) zurück. Bei map[B](f: (A) => B) verhält es sich analog:

println(books.map(_(0).title)) // Returns Some[String]
println(None.map(_ == true)) // Returns None

Flatmap und Option

Collections besitzen mit der Methode map[B](f: (A) => B) ein mächtiges Konstrukt für die funktionale Programmierung. Manchmal kann es jedoch sein, dass die Funktion, die auf jedes Element angewendet wird, eine Liste/Sequenz zurückliefert. Ein mögliches Ausgangsszenario könnte so aussehen:

val numbers: List[List[Int]] = List(List(), List(7), List(42, 27))</em>

Wendet man darauf map[B](f: (A) => B) mit einer einfachen Funktion numbers.map(_.map(_ * 2)) an, dann ergibt sich folgendes Ergebnis:

// List(List(), List(14), List(84, 54))
numbers.map(_.map(_ * 2))

Vielleicht hätte man in einigen Situationen aber lieber ein Objekt vom Typ List[Int] zur weiteren Verwendung zur Verfügung. Mit flatMap stellen Collections eine einfache Möglichkeit zur Verfügung, genau das zu erreichen:

// List(14, 84, 54)
numbers.flatMap(_.map(_ * 2))

Eine besondere Bedeutung gewinnt flatMap(…) in Verbindung mit Option. Gehen wir zuerst von einer Liste aus, die Objekte vom Typ Option[Int] enthält:

val flatMapOption: List[Option[Int]] = List(Some(27), None, Some(42))

Wie oben beschrieben, kann auf die in der Liste enthaltenen Objekte map[B](f: (A) => B) angewendet werden, was aber als Rückgabetyp List[Option[Int]] zur Folge hat:

// List(Some(54), None, Some(84))
flatMapOption.map(_.map(_ * 2))

Tatsächlich ist man aber meistens an den Werten selbst interessiert. Hierbei kann flatMap helfen:

// List(54, 84)
flatMapOption.flatMap(_.map(_ * 2))

Die zurückgegebene Liste enthält jetzt nur noch Objekte vom Typ Int. Dabei werden für Objekte vom Typ Some[Int] die tatsächlich enthaltenen Werte eingesetzt und Objekte vom Typ None aussortiert.

Lesen Sie auch: Reactive Microservices mit Scala und Akka

Option in for-Comprehensions

Objekte vom Typ Option können natürlich auch in for-Comprehensions verwendet werden. Der Vorteil ist, dass dadurch sehr lesbarer Code entsteht, da nur über den Positivfall eines vorhandenen Werts iteriert wird. Um die Verwendung zu erläutern, verwenden wir an dieser Stelle ein künstliches Beispiel mit einer Option in einer Option verschachtelt:

val nestedOption = Some(Some("Clean Code"))

Diese kann man jetzt innerhalb einer for-Comprehension schrittweise auspacken:

for {
  some <- nestedOption // some is Some[String]
  title <- some // title is String "Clean Code"
} yield title // String

Im ersten Schritt wird das erste Some ausgepackt, was man dann im zweiten Schritt dem String title zuweist. Am Ende wird title eingetragen. Das Schöne daran ist, dass beim Auftreten von None der Rückgabewert wieder None ist. Würde also nestedOption den Wert Some(None) annehmen, so würde kein Fehler geworfen, sondern einfach None zurückgegeben.
Richtig interessant wird es, wenn Listen ins Spiel kommen, wie im nächsten Beispiel:

val listTitleOptions: List[Option[String]] =
  List(Some("Clean Code"), None, Some("Code Complete"))

// yields: List("Clean Code", "Code Complete")
for {
  titles <- listTitleOptions
  title <- titles
} yield title

Hierbei wird wie bei der Verwendung von flatMap None herausgefiltert.

Either: Entweder Fehler oder Erfolg

Either[A, B] ist ein spezieller Typ, der ähnlich funktioniert wie Option. Im Gegensatz zu Option enthält er aber zwei Parametertypen: Either[A, B]. Either hat wie Option zwei Untertypen: Left und Right. Falls ein Objekt vom Typ Either[A, B] ein Objekt vom Typ A enthält, ist es vom Typ Left. Ansonsten ist es vom Typ Right.

Es hat sich dabei die Konvention eingebürgert, dass Left für den Fehlerfall steht und Right für den Erfolgsfall. Das ist aber nur zu beachten, wenn Either für die Fehlerbehandlung an sich eingesetzt wird, da A und B beliebige Typen sein können. Um dieses Verhalten näher zu betrachten, gehen wir zurück zu unserer Klasse DatabaseCSV, genauer gesagt zur Methode update(book: Book).

Dort kann es nämlich passieren, dass eine Zeile aus der Datenbank-Datei ungültig ist. Insbesondere soll in unserem Fall die ISBN10 auf ihre Gültigkeit überprüft werden. Dazu wird zuerst eine Hilfsmethode validate(book: Book) definiert, die als Rückgabetyp Either[String, Book] besitzt. Innerhalb der Methode wird bei gültiger ISBN10 das übergebene Objekt vom Typ Book eingepackt in Right zurückgegeben. Ansonsten gibt die Methode eine Fehlermeldung eingepackt in ein Objekt vom Typ Left zurück.

Um die Funktion anzuwenden wird hier ein neuer Ansatz gewählt. Die Methode fold(…) erwartet zwei Funktionen mit gleichem Rückgabetyp (in unserem Fall ist das Unit). Die erste wird aufgerufen, wenn Either vom Typ Left ist und gibt den Fehler auf der Konsole aus. Die zweite wird ausgeführt, wenn Either vom Typ Right ist und fügt der Klassenvariable books das Objekt book hinzu.

Listing 4: Either

def update(book: Book) = {

  def validate(book: Book): Either[String, Book] = {
    if (Book.validateISBN10(book.isbn10)) {
      Right(book)
    } else {
      Left("Book has invalid ISBN10: " + book)
    }
  }

  validate(book).fold(
    error => println(error),
    success => { this.books += book }
  )
}

In diesem speziellen Fall war fold die passende Wahl, da die übergebenen Funktionen beide den gleichen Rückgabewert hatten. Aber es gibt auch noch andere Methoden, bei denen jedoch zu beachten ist, dass Either sich nicht wie eine Collection verhält. Um die Eigenheiten von Either genauer zu beleuchten, wird wieder ein konstruiertes Beispiel verwendet:

val either = validate(new Book("Clean Code", "Uncle Bob", 137081073L))
val either2 = validate(new Book("Code Complete 2", "Steve McConnell", 735619670L))

Um Methoden wie map anzuwenden, muss zuerst eine Projektion auf Left/Right durchgeführt werden:

either.left.map(println(_)) // Either[Unit, Book]
either.right.map(println(_)) // Either[Unit, Book]

Im Gegensatz zu Option ist der Rückgabetyp auch nicht wie erwartet Unit/Book, sondern wieder vom Typ Either, was zu Unannehmlichkeiten führen kann. Zum Beispiel wenn man den Wert selbst als Rückgabewert bekommen, oder wie im nächsten Beispiel Aufrufe von map aneinanderreihen will. So ist es immer wieder notwendig, eine Projektion auf Right durchzuführen, bevor map aufgerufen werden kann:

val notFlat: Either[String, Either[String, String]] = 
  either.right.map(a =>
    either2.right.map(b =>
      s"Title1: $a.title, Title2: $b.title"
  )
)

Die Projektionen selbst verhalten sich wieder wie Collections, sodass bei ihnen flatMap zur Verfügung steht. Das verhindert, dass wie im  Beispiel oben verschachtelte Either-Rückgabewerte entstehen. Die Anwendung von flatMap ist wie gewohnt möglich:

val flat: Either[String, String] = either.right.flatMap(a =>
  either2.right.map(b =>
    s"Title1: $a.title, Title2: $b.title"
  )
)

Either macht auch die Arbeit mit for-Comprehensions zuweilen schwierig. Um das zu zeigen, soll zuerst aus den beiden weiter oben erzeugten Objekten vom Typ Either[String, Book] either und either2 das eigentliche Objekt mit einer Projektion auf Right gewonnen werden. Im yield-Teil folgt dann die Extraktion der Titel selbst:

val comprehend: Either[String, String] =
  for {
    e1 <- either.right
    e2 <- either2.right
  } yield s"Title1: $e1.title, Title2: $e2.title"

Dadurch verlagert sich die Logik zu großen Teilen in den yield-Abschnitt, was nicht so schön lesbar ist. Gerade wenn Werte von Variablen im Spiel sind, wäre es besser, diese direkt innerhalb der Comprehension zuzuweisen und sie im yield-Abschnitt nur noch zu nutzen. Ein erster Versuch kompiliert nicht aufgrund einer Eigenschaft von Either:

val comprehend2: Either[String, String] =
  for {
    e1 <- either.right // Generator
    e2 <- either2.right
    // Will not compile: no map on Either!!
    t1 = e1.title
    t2 = e2.title
  } yield s"Title1: $t1, Title2: $t2"

Bei der Zuweisung eines Werts mit = wird implizit map innerhalb einer for-Comprehension aufgerufen. Da das Ergebnis von map auf e1/e2 vom Typ Either ist und damit keine map-Methode besitzt, kommt es zum Fehler. Man wundert sich vielleicht, was genau passiert, aber bei genauer Betrachtung wird klar, wo es schief geht. Der folgende Code entspricht in etwa der obigen for-Comprehension:

either.right.flatMap { e1 =>
  either2.right.map { e2 =>
    val t1 = e1.title
    val t2 = e2.title
    (t1, t2)
  }.map { case (x, y) => s"Title1: $x, Title2: $y" } // map on Either!!
}

Durch die Zuweisung mit = innerhalb der for-Comprehension wird ein zusätzliches map eingefügt, was dazu führt, dass es auf Either aufgerufen wird und nicht auf einer Projektion auf Right (siehe Zeile 6). Stattdessen muss zu einem Trick gegriffen werden, indem statt der Zuweisung des Werts ein Generator verwendet wird. Dadurch wird es aber auch notwendig, den Wert zuerst mit einem Right(…) zu wrappen und anschließend sofort wieder mit der Methode right herauszuholen. Die kompilierbare Schreibweise wäre also:

t1 <- Right(e1.title).right
t2 <- Right(e2.title).right

Try: Exception Handling (nicht nur) für Multithreading

Try[A] ist ein funktionaler Ansatz, um Exceptions in Scala zu behandeln. Dabei ist es ähnlich zu Either, jedoch ohne dieselben Probleme zu besitzen und mit einem speziellerem Einsatzbereich. Am einfachsten ist es vergleichbar zu Option. Während Option[A] entweder Some[A] oder None zurückgeben kann, gibt Try[A] bei Erfolg A zurück und bei Fehlschlag Throwable.

Try[A] hat zwei Untertypen Success[A] und Failure[Throwable], die die entsprechenden Wrapper für den Wert A oder eben die geworfene Exception darstellen. Starten wir wieder mit einer Funktion aus DatabaseCSV, die eine Exception werfen kann. Zum Abspeichern des aktuellen Stands in das CSV-Format, wird die Methode save(filePath: String) implementiert. Gibt es die Datei auf dem übergebenen Pfad noch nicht, dann soll eine Exception geworfen werden.

Listing 5: Speichern des aktuellen Stands kann Exception werfen

def save(filePath: String = "src/main/resources/database.csv") = {
  if (!Files.exists(Paths.get(filePath))) {
    throw new AccessControlException("File does not exist")
  }
  val csv = books.map(b => b.exportCSV)
  val pw = new PrintWriter(new File(filePath))
  pw.write(csv.mkString("\n"))
  pw.close()
}

Es ist sofort ersichtlich, dass durch diese Implementierung der Aufrufer der Methode gezwungen wird, sich beim Auftreten einer Exception um diese zu kümmern. Der Aufruf wird deshalb mit Try gewrappt:

val saveResult = Try(db.save("blub"))

Dieser wirft tatsächlich eine AccessControlException, was aber nicht zum Absturz des Programms führt, weil der Aufruf mit Try gewrappt wurde. Stattdessen kann eine kontrollierte Fehlerbehandlung außerhalb der Methode erfolgen. Zum Beispiel kann wieder mit Pattern Matching gearbeitet und auf die Unterklassen Failure und Success gematcht werden:

saveResult match {
  case Failure(thrown) => {
    println(thrown)
  }
  case Success(s) => {
    println(s)
  }
}

Oder die Fehlerbehandlung geschieht mit getOrElse[B >: A](default: => B): A:

saveResult.getOrElse("That went wrong!")

Wie bei Option ist es jedoch nicht ratsam, direkt auf das Fehlerobjekt zuzugreifen, denn dafür ist explizit erforderlich zu prüfen, ob es vorhanden ist:

// DO NOT DO THIS
println(saveResult.failed.get)

Da Try sich wie Option verhält, empfehle ich, sich über die anderen Möglichkeiten wie map, flatMap, filter und foreach zu informieren. Stattdessen gehe ich lieber auf die unterschiedlichen Möglichkeiten ein, die es gibt, sich von einer aufgetretenen Exception zu erholen und erläutere anschließend die Besonderheiten bei for-Comprehensions.
Zuerst wäre da die Methode recover(…), die eine Funktion übergeben bekommt, in der die Logik beheimatet ist, die Exception entsprechend zu behandeln:

saveResult.recover( {
  case e => println("Your recovery logic here")
})

Will man die Exception austauschen, so sollte recoverWith(…) angewendet werden. Als Rückgabetyp des übergebenen Ausdrucks wird wieder ein Objekt vom Typ Try erwartet:

val diffSaveResult = saveResult.recoverWith( {
  case e => Failure(new IllegalArgumentException("Wrong filepath"))
  // Also possible
  // case e => Success("Just recovering")
})

Die Verwendung des Try in for-Comprehensions ist möglich. Werden dabei Fehler nicht behandelt, dann wird im yield-Abschnitt ein Objekt vom Typ Failure zurückgegeben:

// Yields: Failure(java.lang.NumberFormatException: For input string: "42a")
for {
  i <- Try(Integer.parseInt("42a"))
} yield i

Bei einem entsprechenden Aufruf von recover(…) erhält man ein Objekt vom Typ Success als Rückgabewert:

// Yields: Success(-1)
for {
  i <- Try(Integer.parseInt("42a")).recover({case e => -1})
} yield i

Bedienungsanleitung für Option, Either, Try

Im folgenden Abschnitt soll erläutert werden, welche Technik in welchem Szenario am ehesten eingesetzt werden sollte. Mit Option[A] und seinen Untertypen Some[A] und None steht in Scala eine mächtige Waffe im Kampf gegen NullPointerExceptions zur Verfügung. Wann immer eine Methode null zurückgeben könnte, sollte stattdessen der Rückgabewert Option[A] bevorzugt werden. Das verhindert diesen lästigen, vor allem aus Java bekannten Fehler. Aber selbst wenn Fremdmethoden genutzt werden, ist es sinnvoll, diese über das Companion-Object von Option in eine Option zu konvertieren. Hierbei ist zu beachten, dass nur das Companion-Object benutzt wird und nicht Some. Denn Some(null) ist grundsätzlich erlaubt und führt eben doch wieder zu einer NullPointerException.

Speziell für das Exception Handling steht Try[A] mit seinen Untertypen Success[A] und Failure[Throwable] bereit. Eigentlich speziell für die Arbeit mit Futures in Multithreading-Umgebungen entwickelt, spielt es seine Stärken ebenfalls beim Exception Handling aus, indem es als Wrapper und Recovery-Mechanismus für Exceptions dient. Von Ausnahmen abgesehen, in denen der klassische try/catch-Block weniger umständlich zu implementieren ist, ist Try grundsätzlich zu bevorzugen. Zu beachten ist jedoch, dass die Einführung von Try[A] als Rückgabewert einer Methode den Aufrufer zwingt, darauf entsprechend zu reagieren. Es kommt daher einer Checked Exception gleich.

Either[A, B] verhält sich ähnlich wie Try[A], wobei der Fokus generell in der Fehlerbehandlung liegt und nicht im Exception Handling. Mit seinen zwei Untertypen Left[A] und Right[B], die nur per Konvention auf Fehler und Erfolg festgelegt sind, sind auch andere Anwendungsfälle denkbar. Aufgrund der Probleme in for-Comprehension sollte der Einsatz von Either[A, B] überlegt geschehen.

Fazit

Scala ist reichhaltig ausgestattet, um Fehler und Exceptions sinnvoll und effizient zu behandeln. Mit dem klassischen try/catch-Block steht eine aus Java bekannte Technik zur Verfügung, die in manchen Fällen den funktionalen Ansätzen überlegen ist. Im Zusammenspiel mit Pattern Matching kann gezielt auf alle Exceptions gematcht werden. Vorsicht ist nur geboten, wenn alle nicht fatalen, sprich nicht JVM-Exceptions, abgefangen werden sollen. Hier ist auf NonFatal(e) zu matchen. Es ist die richtige Wahl, sollte ein finally-Block gewünscht sein.

Die gefürchtetste aller Exceptions NullPointerException kann in Scala elegant mit dem Typ Option[A] vermieden werden. Dieser kann benutzt werden, um Variablen zu wrappen, die potenziell null werden können. Seine zwei Untertypen Some[A] und None stellen dabei das Vorhandensein bzw. die Abwesenheit eines Werts da. Gerade im Umgang mit Fremdbibliotheken lohnt sich das explizite Wrappen über das Companion-Object. Ein Objekt vom Typ Option verhält sich wie eine Collection, was vielfältige Möglichkeiten eröffnet: Den Einsatz von map, flatMap, foreach oder in for-Comprehensions. Bei all diesen Operationen tritt dadurch kein Laufzeitfehler auf, was die Arbeit mit Option in höchstem Maße nützlich macht.

Zur allgemeinen Behandlung von Fehlern gibt es in Scala den Typ Either[A, B], der nicht nur auf Exception Handling beschränkt ist. Mit seinen zwei Untertypen Left[A] und Right[B] steht per Konvention Left für den Fehlerfall und Right für den Erfolgsfall. Der Umgang mit Either ist schwieriger, da es sich nicht wie eine Collection verhält. Erst durch die Projektion auf Left oder Right sind die Methoden map, flatMap und foreach verfügbar. Der Einsatz in for-Comprehension gestaltet sich mitunter schwierig, da bei den oben genannten Methoden wieder ein Objekt vom Typ Either zurückgegeben wird und eine erneute Projektion notwendig macht. Deshalb sollte Either mit bedacht eingesetzt werden.

Ein funktionaler Ansatz für Exception Handling in Scala ist Try[A]. Es besitzt zwei Untertypen Success[A] und Failure[Throwable]. Durch seinen Aufbau ist es für das Exception Handling allgemein und für den Einsatz in Multithreading-Umgebungen im Speziellen geeignet. Es sollte bevorzugt verwendet werden, da es sich ähnlich wie Option wie eine Collection verhält, mit allen Vorteilen. Die Nachteile von Either teilt es aber nicht. Einziger Wermutstropfen ist, dass es sich als Rückgabewert praktisch wie eine Checked Exception verhält und den Aufrufer dadurch zwingt, darauf gezielt zu reagieren.

Das Projekt ist auf GitHub verfügbar. Mit git checkout tags/part-three erhält man den abschließenden Stand dieses Tutorials.

Scala in der Welt der JVM-Sprachen
Schon seit jeher haben sich Entdecker, Seefahrer und Freibeuter aller Länder an Seekarten orientiert, um auf Schatzsuche zu gehen. Wie müsste da eine Seekarte der Java-Plattform aussehen, mit der Entwickler die verborgenen Schätze der JVM aufspüren könnten?

Arrr! Hier geht’s zu unseren Pirates of the JVM 

Geschrieben von
Johannes Dienst
Johannes Dienst
Johannes Dienst ist Clean Coder aus Leidenschaft bei der MULTA MEDIO Informationssysteme AG. Seine Tätigkeitsschwerpunkte sind die Wartung und Gestaltung von serverseitigen Java, Python und JavaScript-Applikationen. Twitter: @JohannesDienst
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.