Blog

Typsichere Fehlerbehandlung mit gestaltlosen Koprodukten in Scala

Anton Lijcklama à Nijeholt

Anton Lijcklama à Nijeholt

Aktualisiert Oktober 16, 2025
22 Minuten

Einführung

Der Fehlerbehandlung wird oft nicht die Aufmerksamkeit geschenkt, die sie verdient. Wenn man von imperativen Sprachen kommt, ist es super einfach, überall Ausnahmen zu machen. Doch nur weil es einfach ist, heißt das noch lange nicht, dass es eine gute Praxis ist. Es unterbricht die referenzielle Transparenz und macht es schwierig, über den Code nachzudenken. Java hat zwar geprüfte Ausnahmen, aber das Problem verschwindet dadurch nicht, denn die referenzielle Transparenz wird dadurch immer noch unterbrochen. Es gibt noch viele weitere Gründe, warum das "Werfen von Ausnahmen" keine gute Praxis ist, aber darauf werde ich heute nicht eingehen. Meine Sorge bei der Verwendung von Ausnahmen ist, dass wir in vielen Fällen nicht über den "außergewöhnlichen" Teil sprechen, der in dem Wort "Ausnahme" enthalten ist.

Werfen wir einen Blick auf die Cambridge-Definition des Wortes "Ausnahme". Wenn wir uns die technischen Ausnahmen ansehen, kann ich dem letzten Teil der Definition voll und ganz zustimmen:

Ausnahme: "Jemand oder etwas, das nicht in einer Regel, Gruppe oder Liste enthalten ist oder das sich nicht in der erwarteten Weise verhält." --- Cambridge Wörterbuch

Hier finden Sie einige Beispiele für unerwartetes Verhalten aufgrund technischer Probleme:

  • Während des Speicherns von Daten in einer Datenbank wird die Verbindung unterbrochen und die Daten können nicht gespeichert werden.
  • Beim Hochladen von Bildern auf einen Server wird die Verbindung unterbrochen und das Bild kann nicht vollständig gesendet werden.
  • Während der Verarbeitung einer Kafka-Nachricht wird der Client beendet und kann die Nachricht nicht verarbeiten.

Dies sind alles Beispiele für Prozesse, die sich irgendwie nicht erwartungsgemäß verhalten haben. Die Ausnahme ist hier auf ein technisches Problem zurückzuführen. Betrachtet man jedoch einen reinen Bereich, so sind viele Ausnahmen keine Ausnahmen.

Nachfolgend finden Sie einige Beispiele für alltägliche Situationen, die sich nicht erwartungsgemäß verhalten haben:

  • Bevor wir einen neuen Benutzer registrieren, überprüfen wir die Daten und stellen fest, dass ein Feld fehlt. Daraufhin senden wir eine 400 bad-Anfrage an den Client zurück.
  • Während der Verarbeitung einer Finanztransaktion vermutet das System , dass etwas nicht stimmt, informiert die Benutzer, dass etwas schief gelaufen ist, und benachrichtigt automatisch das Betrugsteam, um die Transaktion zu untersuchen.
  • Wenn Sie versuchen, mit einer Geschenkkarte zu bezahlen, stellt das System fest, dass nicht genug Guthaben auf der Geschenkkarte vorhanden ist, um die Bestellung abzuschließen, und der Benutzer wird entsprechend informiert.

Die oben genannten Beispiele gehören zu Bereichen, in denen wir davon ausgehen, dass diese Situationen eintreten können und daher erwartet werden. Auch wenn die Wahrscheinlichkeit, dass sie eintreten, gering ist, sind dies Situationen, die wir erwarten. Anstatt Ausnahmen zu missbrauchen, können wir Fehler zu einem Teil unserer Domäne machen und das Konzept eines Domänenfehlers einführen. Der Vorteil der Verwendung eines anderen Typs (d.h. eines Typs, der nicht von Throwable erbt) ist, dass wir sie explizit behandeln müssen. Es gibt keine Möglichkeit, sie zu werfen; daher können wir keine try/catch-Konstruktion verwenden. Es gibt jedoch einen Nachteil bei der Verwendung von versiegelten Merkmalen.

In diesem Artikel werden wir eine einfache Kommandozeilenanwendung zum Dividieren von Zahlen erstellen. Wir gehen einige Iterationen des Codes durch und ich zeige Ihnen, auf welche Probleme wir stoßen werden und wie Shapeless-Koprodukte uns dabei helfen können.

Domainfehler sind überall

Warum sollten wir wieder Domänenfehler verwenden? Schauen wir uns die folgende Methode an:

def divide(x: Int, y: Int) = x / y

Der obige Code ist prägnant und lesbar, aber wir informieren den Verbraucher unserer Methode nicht darüber, dass sie Fehler produzieren kann. Das Dividieren durch Null sollte nicht möglich sein. Ein ArithmeticException wird ausgelöst, wenn der Verbraucher es wagt, es zu versuchen. Das macht jedoch nicht viel Sinn. Wir wissen bereits, dass wir nicht durch Null dividieren können, also ist das keine Ausnahme. Es ist Teil unserer Domäne, und wir sollten unsere Programmierkollegen darüber informieren, dass wir keine Divisionen durch Null zulassen. Wenn wir das ausdrücklich sagen, werden unsere Kollegen verstehen, dass die Methode eine Fehlerbehandlung erfordert. Wir sollten uns immer bemühen, Fehlerbehandlungsprogramme zu erstellen, die vollständige Funktionen sind. Auf diese Weise kann der Compiler uns helfen, wenn wir versehentlich nicht alle Fehler behandeln. Andernfalls verlieren Sie jeglichen Nutzen und können genauso gut Ausnahmen verwenden.

Warum versiegelte Züge nicht die beste Option sind

Eine gängige Methode, mit Fehlern umzugehen, ist die Verwendung einer versiegelten Eigenschaft. Wenn wir uns unsere kleine Befehlszeilen-Applikation "Zahlendividieren" ansehen, wissen wir, dass wir auf mindestens zwei Probleme stoßen können:

  • Die Umwandlung von Eingaben aus der Befehlszeile in eine Zahl kann schief gehen
  • Dividieren durch Null ist nicht möglich

Durch die Einführung einer versiegelten Eigenschaft könnten wir diese Fehler in unserer Codebasis wie folgt dokumentieren:

sealed trait DomainError
case object DivideByZeroError extends DomainError
case class ParseNumberError(value: String) extends DomainError

Durch die Verwendung von Traits müssen wir anfangen, über die Strukturierung der Fehlerhierarchie nachzudenken.

  • Erben alle Domänenfehler von einer Wurzel? (z.B. DomainError im obigen Beispiel)
  • Führen wir Unterbereiche für Fehler ein, die einen gemeinsamen Vorgänger haben können oder auch nicht? (z.B. pro Endpunkt, pro Funktionalität oder vielleicht etwas anderes?)

In jedem Fall werden wir nicht begeistert sein.

Im ersten Szenario werden wir nicht in der Lage sein, genau zu beschreiben, welche Fehler wir in dem Moment zurückbekommen, in dem wir anfangen, Methoden zu komponieren. Da alle Fehler von DomainError geerbt werden, können wir nicht verstehen, welche Fehler tatsächlich auftreten können. Weitere Einzelheiten entnehmen Sie bitte dem folgenden Beispiel.

sealed trait DomainError
case class ErrorA() extends DomainError
case class ErrorB() extends DomainError
case class ErrorC() extends DomainError

object SomeClass {
  def methodA(s: String): Either[ErrorA, String] = if (s.isEmpty) Left(ErrorA()) else Right(s)
  def methodB(s: String): Either[ErrorB, Int] = s.toIntOption.fold[Either[ErrorB, Int]](Left(ErrorB()))(Right.apply)

  // This method has to return DomainError and is no longer accurate, as it can only ever return ErrorA or ErrorB.
  def methodAB(s: String): Either[DomainError, Int] = methodA(s).flatMap(methodB)
}

Im zweiten Szenario werden wir Schwierigkeiten beim Zusammenstellen von Methoden haben. In dem Moment, in dem die Subdomänen zu interagieren beginnen, müssen wir sie auf einen neuen Fehler übertragen, der der anderen Subdomäne bekannt ist. Das bedeutet, dass wir bei jeder Interaktion eine Duplikation einführen, und zwar als in Domäne A, die auch in Domäne B vorhanden sein muss, sobald die Interaktion beginnt. Wir könnten das Problem "lösen", indem wir einen gemeinsamen Vorfahren einführen, aber das würde uns zu dem Problem des vorherigen Szenarios führen.

Die mangelnde Transparenz, welche Fehler zurückgegeben werden, ist nicht zu übersehen. Eine Methode, die DomainError in einer Codebasis zurückgibt, in der wir Hunderte von Domänenfehlern definiert haben könnten, bietet keinerlei Hilfe. Der Verbraucher unseres Ansatzes müsste unseren Code untersuchen, um zu verstehen, welche Domänenfehler tatsächlich auftreten können. Gleichzeitig kann uns unser Compiler nicht mehr helfen, wenn wir versehentlich einen Fehlerfall vergessen haben. Gesamtfunktionen werden nutzlos, wenn wir anfangen, Fälle einzubeziehen, die niemals auftreten können. In großen Codebasen ist es außerdem unmöglich, zahlreiche Codeschichten zu durchlaufen, um herauszufinden, was zurückgegeben wird. Das bremst Sie als Programmierer aus und gibt Ihnen gleichzeitig nichts als ein Bauchgefühl. Haben Sie alles gründlich geprüft? Könnte es sein, dass Sie einen Fehlerfall übersehen haben? Denken Sie nur an die automatische Aktualisierung von Abhängigkeiten und Sie werden schnell feststellen, dass Sie ständig den Code von Drittanbietern durchgehen müssten, um herauszufinden, was Sie aus der Fehlerperspektive erwarten können.

Lassen Sie Fehler zu Bürgern erster Klasse werden! Wenn wir alles explizit machen können, haben wir keines der oben genannten Probleme. Wenn Abhängigkeiten aktualisiert und neue Fehlerfälle eingeführt werden, kommt es automatisch zu Kompilierfehlern, da unsere vollständige Funktion, die die Fehler behandelt, nicht mehr alle Fälle abdeckt. Keine Zeitverschwendung. Und Sie wissen genau, wo Sie was korrigieren müssen. Leider gibt es in Scala 2.x keine Klasse oder Typklasse, die wir verwenden könnten:

  • Die Typklasse Option befasst sich mit der Optionalität eines einzelnen Typs. Wir wissen jedoch bereits, dass eine Methode wahrscheinlich mehr als einen einzigen Fehler produzieren kann. Wenn Sie Methoden zusammenstellen, wird die Wahrscheinlichkeit noch größer.
  • Die Typklasse Either hat eine linke und eine rechte Projektion, wobei die linke für Fehlerfälle verwendet werden kann. Die linke Projektion hat nur Platz für einen einzelnen Typ, während wir mehr brauchen.
  • Die Typklasse List kann mehrere Werte speichern, aber nur für einen einzigen Typ.

Wir brauchen eine Art Struktur EitherN, die es uns ermöglicht, stattdessen zahlreiche Fehlertypen zurückzugeben, die nicht miteinander verbunden sein können und keinen gemeinsamen Vorfahren benötigen. Genau das werden wir mit Shapeless-Koprodukten erreichen.

Was ist eine HList?

Um mit verschiedenen Fehlern umgehen zu können, benötigen wir eine List Struktur, die verschiedene Typen speichern kann, ohne die Typsicherheit zu verlieren. Werfen wir einen Blick auf die Typklasse HList. Sie bietet eine Möglichkeit, eine Liste mit mehr als einem einzigen Typ zu erstellen. Erinnern Sie sich, dass die Typklasse in Scala immer eine Liste eines bestimmten Typs liefert (z.B. ). Mit HList können wir Listen mit mehr als nur einem Typ erstellen.

Siehe das folgende Beispiel für eine Liste, die mehrere Werte enthält (1, "world", 1.5) von verschiedenen Typen (String, Int, Double):

import shapeless.HNil
import shapeless.::
val hlist: String :: Int :: String :: Double :: HNil = "hello" :: 1 :: "world" :: 1.5 :: HNil

Was ist ein Koprodukt?

Koprodukt ist eine besondere Art von HList. Sie können es sich als Either in Scala vorstellen, mit einer beliebigen Anzahl von Auswahlmöglichkeiten.

import shapeless.{:+:, CNil, Coproduct}

type MyType = String :+: Int :+: Double :+: CNil
val coproduct: MyType = Coproduct[MyType](1.5)

In dem obigen Code enthält das Koprodukt entweder einen String, Int oder einen Double Wert.

Iterationen

Lassen Sie uns mit dem Code spielen, 7 Iterationen durchlaufen und versuchen, den Code jedes Mal so weit wie möglich zu verbessern.

Iteration 1: Keine Fehlerbehandlung

Kehren wir zu unserer Divide-App zurück und beginnen wir, ohne an die Fehlerbehandlung zu denken:

import scala.io.StdIn.readLine

object DivideAppIteration1 extends App {
  // Can throw an ArithmeticException when dividing by zero.
  def divide(x: Double, y: Double): Double = x / y

  // Potential IOException when using readLine.
  val x0 = readLine
  val y0 = readLine

  // Potential parse errors when converting String to Double.
  val x = x0.toInt
  val y = y0.toInt

  println(divide(x, y))
}

Wir wissen bereits, dass dieser Code nicht widerstandsfähig ist:

  • Wir verhindern keine Division durch Null.
  • Wir fangen keine Parse-Fehler ab.
  • Wir fangen die möglichen IOException nicht ab, die auftreten können.

Wussten Sie, dass readLine eine IOException auslösen kann? Das geht aus der eigentlichen Signatur der Methode überhaupt nicht hervor:

def readLine(): String = in.readLine()

Wenn wir eine Ebene tiefer gehen, sehen wir den folgenden Java-Code:

public String readLine() throws IOException {
  return readLine(false, null);
}

Dies zeigt deutlich, wie wichtig es ist, Fehler zu Bürgern erster Klasse in einem Programm zu machen. Wenn Sie nicht explizit wissen, welche Fehlerfälle auftreten können, werden Sie auf Laufzeitfehler stoßen oder müssen jede Codezeile selbst überprüfen. Nicht nur in Ihrem Code, sondern auch in allen Bibliotheken, die Sie möglicherweise verwenden. Nicht wirklich eine Option, wenn Sie die Dinge gerne erledigen...

Iteration 2: Entweder verwenden

Versuchen wir es noch einmal mit unserer Anwendung und machen sie etwas robuster, indem wir die Typklasse Either verwenden:

import scala.io.StdIn.readLine
import scala.util.Try

object DivideAppIteration2 extends App {
  // Using sealed traits to distinguish the type of errors
  sealed trait DomainError

  case object DivideByZeroError extends DomainError

  case class ParseNumberError(value: String) extends DomainError

  // Dividing an error now explicitly shows what kind of error it could produce.
  def divide(x: Double, y: Double): Either[DomainError, Double] =
    if (y == 0) Left(DivideByZeroError)
    else Right(x / y)

  // This now, unfortunately became an Object due to the mixing of DomainError and IOException
  def runDivide: Either[Object, Double] = for {
    x0 <- Try(readLine).fold(Left.apply, Right.apply)
    y0 <- Try(readLine).fold(Left.apply, Right.apply)
    x <- x0.toDoubleOption
           .fold[Either[ParseNumberError, Double]](Left(ParseNumberError(x0)))(Right.apply)
    y <- y0.toDoubleOption
           .fold[Either[ParseNumberError, Double]](Left(ParseNumberError(y0)))(Right.apply)
    r <- divide(x, y)
  } yield r

  runDivide.fold(
    // As err is of type Object, we cannot create any sensible error handler based on the type
    err => println(s"An error occurred: $err"),
    result => println(s"Result: $result")
  )
}

Obwohl der obige Code gut läuft, hat sich die Methode runDivide in ein untypisiertes Chaos verwandelt. Object beschreibt nicht genau, welche Art von Fehlern wir erwarten können. Das liegt daran, dass DomainError und IOException vom Standpunkt der Vererbung aus gesehen nur Object gemeinsam haben.

Iteration 3: Schaffung von Typsicherheit mit Koprodukten

Schauen wir uns an, wie wir die Typsicherheit mit Shapeless-Koprodukten verbessern können:

import shapeless.{:+:, CNil, Coproduct}

import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try

object DivideAppIteration3 extends App {
  // Notice that creating a sealed trait error hierarchy is no longer necessary.
  case object DivideByZeroError

  case class ParseNumberError(input: String)

  // Definition of the errors that can occur
  type TryDivideError = DivideByZeroError.type :+: CNil
  type TryReadLineError = IOException :+: CNil
  type TryParseNumberError = ParseNumberError :+: CNil

  def tryDivide(x: Double, y: Double): Either[TryDivideError, Double] =
    if (y == 0) Left(Coproduct(DivideByZeroError))
    else Right(x / y)

  def tryReadLine = Try(readLine).fold[Either[TryReadLineError, String]](
    ex => Left(Coproduct(new IOException(ex))),
    Right.apply
  )

  def tryParseDouble(s: String) =
    s.toDoubleOption.fold[Either[TryParseNumberError, Double]](
      Left(Coproduct(ParseNumberError(s)))
    )(Right.apply)

  type RunDivideError = TryReadLineError :+: TryParseNumberError :+: TryDivideError :+: CNil

  // Yes! We now have an actual error type that accurately describes the error cases.
  def runDivide: Either[RunDivideError, Double] = for {
    x0 <- tryReadLine.left.map(Coproduct[RunDivideError](_))
    y0 <- tryReadLine.left.map(Coproduct[RunDivideError](_))
     x <- tryParseDouble(x0).left.map(Coproduct[RunDivideError](_))
     y <- tryParseDouble(y0).left.map(Coproduct[RunDivideError](_))
     r <- tryDivide(x, y).left.map(Coproduct[RunDivideError](_))
  } yield r

  runDivide.fold(println, println)
}

Jede Methode beschreibt immer noch explizit, was ihre Fehlersignatur ist. Der große Unterschied ist, dass wir jetzt ein neues Koprodukt RunDivideError eingeführt haben, das entweder eines der Koprodukte ist, die jede bestimmte Methode erzeugt (entweder ein TryReadLineError, TryParseNumberError oder TryDivideError oder):

// Each individual error defined.
type TryDivideError = DivideByZeroError.type :+: CNil
type TryParseNumberError = ParseNumberError :+: CNil
type TryReadLineError = IOException :+: CNil

// A composition of errors we expect for the divide 'flow'.
type RunDivideError = TryDivideError :+: TryReadLineError :+: TryParseNumberError :+: CNil

Um eine RunDivideError zurückgeben zu können, müssen wir sicherstellen, dass das Ergebnis jeder Methode verbreitert wird. Wir können das tun, indem wir das Ergebnis in ein neues Koprodukt wie:

Coproduct[RunDivideError](resultOfTheMethod) // Widens the type returned from the method.

Hier gibt es ein kleines "Problemchen". Da Koprodukte nichts anderes als eine spezielle Art von HList sind, bedeutet die Definition von RunDivideErrors unten, dass wir doppelte Typen in unserem Fehlerkanal haben könnten. Das ist nicht unbedingt ein Problem, aber es wäre zumindest gut, es zu erwähnen.

TryDivideError :+: TryReadLineError :+: TryParseNumberError :+: CNil

Lassen Sie uns ein einfaches Beispiel erstellen, das das potenzielle Problem der Typenduplizierung schnell veranschaulicht und wie wir es lösen können:

import shapeless.{:+:, CNil, Coproduct}

// Error definitions, there's a bit of overlap on the types.
type Method1Error = String :+: Int :+: CNil
type Method2Error = String :+: Double :+: CNil
type Method3Error = String :+: Int :+: Double :+: CNil

def method1 = Coproduct[Method1Error]("error 1")
def method2 = Coproduct[Method2Error]("error 2")
def method3 = Coproduct[Method3Error]("error 3")

// Wrapping in a new coproduct.
type StringErrors = Method1Error :+: Method2Error :+: Method3Error :+: CNil
val result1: String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil = Coproduct[StringErrors](method1).adjoined
val result2: String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil = Coproduct[StringErrors](method2).adjoined
val result3: String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil = Coproduct[StringErrors](method3).adjoined

// Embedding in an existing coproduct.
type StringError = String :+: Int :+: Double :+: CNil
val result4: String :+: Int :+: Double :+: CNil = method1.embed[StringError]
val result5: String :+: Int :+: Double :+: CNil = method2.embed[StringError]
val result6: String :+: Int :+: Double :+: CNil = method3.embed[StringError]

In dem obigen Beispiel sehen Sie, dass es einen großen Unterschied zwischen den beiden Ansätzen gibt.

Wenn Sie das Ergebnis in ein neues Koprodukt packen, entsteht ein größeres Koprodukt:

String :+: Int :+: String :+: Double :+: String :+: Int :+: Double :+: CNil

Das Einbetten flacht das Koprodukt ab und gibt nur die eindeutigen Typen zurück:

String :+: Int :+: Double :+: CNil

Beachten Sie, dass die Methode adjoined verwendet wird, um den Typ StringErrors in ein abgeflachtes Koprodukt zu verwandeln, um zu zeigen, dass es eine Typverdopplung gibt.

Iteration 4: Koprodukte einbetten

Da wir für unsere Anwendung nur an einer reduzierten Darstellung unserer Domänenfehler interessiert sind, verwenden wir die Funktion embed (einbetten), anstatt Koprodukte einzuschließen.

import shapeless.ops.adjoin.Adjoin
import shapeless.{:+:, CNil, Coproduct, Poly1}

import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try

object DivideAppIteration4 extends App {
  case object DivideByZeroError

  case class ParseNumberError(input: String)

  type TryDivideError = DivideByZeroError.type :+: CNil

  def tryDivide(x: Double, y: Double): Either[TryDivideError, Double] =
    if (y == 0) Left(Coproduct(DivideByZeroError))
    else Right(x / y)

  type TryReadLineError = IOException :+: CNil

  // Fold on Try will always give us a Throwable and is no longer an IOException.
  // For educational purposes I'm not going to bother doing anything about this and
  // I'll just wrap the exception into an IOException again.
  def tryReadLine = Try(readLine).fold[Either[TryReadLineError, String]](
    ex => Left(Coproduct(new IOException(ex))),
    Right.apply
  )

  type TryParseNumberError = ParseNumberError :+: CNil

  def tryParseDouble(s: String) =
    s.toDoubleOption.fold[Either[TryParseNumberError, Double]](
      Left(Coproduct(ParseNumberError(s)))
    )(Right.apply)

  // Flattens the types.
  val runDivideError = Adjoin[TryReadLineError :+: TryParseNumberError :+: TryDivideError]
  // Uses the resulting Out as our type signature.
  type RunDivideError = runDivideError.Out

  // We're now using the embed method instead.
  def runDivide: Either[RunDivideError, Double] = for {
    x0 <- tryReadLine.left.map(_.embed[RunDivideError])
    y0 <- tryReadLine.left.map(_.embed[RunDivideError])
    x <- tryParseDouble(x0).left.map(_.embed[RunDivideError])
    y <- tryParseDouble(y0).left.map(_.embed[RunDivideError])
    r <- tryDivide(x, y).left.map(_.embed[RunDivideError])
  } yield r

  // An error handler to demonstrate how we could deal with errors.
  object errorHandler extends Poly1 {
    implicit def ioException = at[IOException] { e =>
      println(s"An IOException occurred: ${e.getMessage}")
    }

    implicit def parseDoubleError = at[ParseNumberError] { e =>
      println(s"Cannot parse '${e.input}' as an integer")
    }

    implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
      println("Cannot divide by zero")
    }
  }

  runDivide.fold(_.fold(errorHandler), println)
}

Der obige Code ist immer noch ein wenig unübersichtlich. Zunächst einmal müssen wir den Typ für jeden Methodenaufruf erweitern. Das lässt sich nicht so gut komponieren. Schauen wir uns an, was passiert, wenn wir versuchen, die Zeile zu lesen und die Eingabe als neue Komposition zu parsen:

def runDivide: Either[RunDivideError, Int] = for {
  x <- tryReadLine
         .leftMap(_.embed[RunDivideError])
         .flatMap(tryParseInt(_).leftMap(_.embed[RunDivideError]))
  y <- tryReadLine
         .leftMap(_.embed[RunDivideError])
         .flatMap(tryParseInt(_).leftMap(_.embed[RunDivideError]))
  r <- tryDivide(x, y).leftMap(_.embed[RunDivideError])
} yield r

Um ehrlich zu sein, sieht das nicht besonders schön aus. Es ist nicht nur mühsam, unsere Typen ständig von Hand zu erweitern, sondern es führt auch zu Störungen, die es schwierig machen, unser Programm zu überdenken.

Iteration 5: Reduzierung des Rauschens durch Einführung eines Syntaxhelfers

Wie können wir das Rauschen im vorherigen Beispiel reduzieren? Eine Möglichkeit, dieses Problem zu lösen, wäre, die Methoden die Einbettung bereits selbst vornehmen zu lassen:

import DivideAppIteration5Syntax.EitherSyntax
import shapeless.ops.adjoin.Adjoin
import shapeless.ops.coproduct.Basis
import shapeless.{:+:, CNil, Coproduct, Poly1}

import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try

object DivideAppIteration5Syntax {
  implicit class EitherSyntax[E <: Coproduct, B](either: Either[E, B]) {
    def leftMap[A1](f: E => A1): Either[A1, B] = either match {
      case Left(l) => Left(f(l))
      case _ => either.asInstanceOf[Either[A1, B]]
    }

    def leftEmbed[Super <: Coproduct](implicit basis: Basis[Super, E]): Either[Super, B] = 
      leftMap(_.embed)
  }
}

object DivideAppIteration5 extends App {
  case object DivideByZeroError
  case class ParseNumberError(input: String)

  type TryDivideError = DivideByZeroError.type :+: CNil
  def tryDivide[Super <: Coproduct](x: Double, y: Double)(implicit
    basis: Basis[Super, TryDivideError]
  ): Either[Super, Double] =
    if (y == 0) Left(Coproduct[TryDivideError](DivideByZeroError).embed[Super])
    else Right(x / y)

  type TryReadLineError = IOException :+: CNil
  def tryReadLine[Super <: Coproduct](implicit
    basis: Basis[Super, TryReadLineError]
  ): Either[Super, String] = 
    Try(readLine)
      .fold[Either[TryReadLineError, String]](
        ex => Left(Coproduct(new IOException(ex))),
        Right.apply
      )
      .leftEmbed[Super]

  type TryParseNumberError = ParseNumberError :+: CNil
  def tryParseNumber[Super <: Coproduct](
    s: String
  )(implicit basis: Basis[Super, TryParseNumberError]): Either[Super, Double] =
    s.toDoubleOption
     .fold[Either[TryParseNumberError, Double]](
       Left(Coproduct(ParseNumberError(s)))
     )(Right.apply)
     .leftEmbed[Super]

  val runDivideError = Adjoin[TryReadLineError :+: TryParseNumberError :+: TryDivideError]
  type RunDivideError = runDivideError.Out

  def runDivide: Either[RunDivideError, Double] = for {
    x <- tryReadLine[RunDivideError].flatMap(tryParseNumber[RunDivideError])
    y <- tryReadLine[RunDivideError].flatMap(tryParseNumber[RunDivideError])
    r <- tryDivide[RunDivideError](x, y)
  } yield r

  object errorHandler extends Poly1 {
    implicit def ioException = at[IOException] { e =>
      println(s"An IOException occurred: ${e.getMessage}")
    }
    implicit def parseNumberError = at[ParseNumberError] { e =>
      println(s"Cannot parse '${e.input}' as a number")
    }
    implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
      println("Cannot divide by zero")
    }
  }

  runDivide.fold(_.fold(errorHandler), println)
}

Auch wenn dies viel besser aussieht als frühere Versionen, wäre es toll, wenn wir die überflüssige RunDivideError eliminieren könnten, die wir beim Zusammenstellen der Methoden an jede einzelne Methode übergeben müssen.

Iteration 6: Reduzierung des Rauschens durch Verschieben der Implikate

Können wir das Rauschen noch weiter reduzieren? Ja, das können wir, wenn wir die Implikate verschieben. Wir können dies ganz einfach tun, indem wir die Implikate in der runDivide Signatur hinzufügen:

import DivideAppIteration6Syntax.EitherSyntax
import shapeless.ops.adjoin.Adjoin
import shapeless.ops.coproduct.Basis
import shapeless.{:+:, CNil, Coproduct, Poly1}

import java.io.IOException
import scala.io.StdIn.readLine
import scala.util.Try

object DivideAppIteration6Syntax {
  implicit class EitherSyntax[E <: Coproduct, B](either: Either[E, B]) {
    def leftMap[A1](f: E => A1): Either[A1, B] = either match {
      case Left(l) => Left(f(l))
     case _ => either.asInstanceOf[Either[A1, B]]
    }

    def leftEmbed[Super <: Coproduct](implicit basis: Basis[Super, E]): Either[Super, B] =
      leftMap(_.embed)
  }
}

object DivideAppIteration6 extends App {
  case object DivideByZeroError
  case class ParseNumberError(input: String)

  type TryDivideError = DivideByZeroError.type :+: CNil
  def tryDivide[Super <: Coproduct](x: Double, y: Double)(implicit
    basis: Basis[Super, TryDivideError]
  ): Either[Super, Double] =
    if (y == 0) Left(Coproduct[TryDivideError](DivideByZeroError).embed[Super])
    else Right(x / y)

  type TryReadLineError = IOException :+: CNil
  def tryReadLine[Super <: Coproduct](implicit
    basis: Basis[Super, TryReadLineError]
  ): Either[Super, String] = 
    Try(readLine)
      .fold[Either[TryReadLineError, String]](
        ex => Left(Coproduct(new IOException(ex))),
        Right.apply
      )
      .leftEmbed[Super]

  type TryParseNumberError = ParseNumberError :+: CNil
  def tryParseNumber[Super <: Coproduct](
    s: String
  )(implicit basis: Basis[Super, TryParseNumberError]): Either[Super, Double] =
    s.toDoubleOption
     .fold[Either[TryParseNumberError, Double]](
        Left(Coproduct(ParseNumberError(s)))
     )(Right.apply)
     .leftEmbed[Super]

  val runDivideError = Adjoin[TryReadLineError :+: TryParseNumberError :+: TryDivideError]
  type RunDivideError = runDivideError.Out

  def runDivide(implicit
    b1: Basis[RunDivideError, TryReadLineError],
    b2: Basis[RunDivideError, TryParseNumberError],
    b3: Basis[RunDivideError, TryDivideError]
  ): Either[RunDivideError, Double] =
    for {
      x <- tryReadLine.flatMap(tryParseNumber(_))
      y <- tryReadLine.flatMap(tryParseNumber(_))
      r <- tryDivide(x, y)
    } yield r

  object errorHandler extends Poly1 {
    implicit def ioException = at[IOException] { e =>
      println(s"An IOException occurred: ${e.getMessage}")
    }
    implicit def parseNumberError = at[ParseNumberError] { e =>
      println(s"Cannot parse '${e.input}' as a number")
    }
    implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
      println("Cannot divide by zero")
    }
  }

  runDivide.fold(_.fold(errorHandler), println)
}

Leider müssen wir tryParseNumber(_) schreiben, damit die Typinferenz funktioniert, und wir können tryParseNumber nicht einfach als Funktion übergeben.

Iteration 7: Umwandlung in eine REST-API

Das Schöne an einem funktionalen Programm ist, dass wir beschreiben können, was unser Programm tun soll, unabhängig von unserem Technologie-Stack. In dieser letzten Iteration werden Sie sehen, wie schnell wir unsere Befehlszeilen-App in eine einfache REST-API verwandeln können, ohne viel Aufwand. Wir verwenden das Akka HTTP Hallo-Welt Beispiel als Basis, aber Sie können jedes beliebige Framework verwenden.

import DivideAppIteration6Syntax.EitherSyntax
import akka.actor.typed.ActorSystem
import akka.actor.typed.scaladsl.Behaviors
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.StatusCodes.{BadRequest, InternalServerError, OK}
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
import shapeless.ops.adjoin.Adjoin
import shapeless.ops.coproduct.Basis
import shapeless.{:+:, CNil, Coproduct, Poly1}

import scala.io.StdIn

object DivideAppIteration7Syntax {
  implicit class EitherSyntax[E <: Coproduct, B](either: Either[E, B]) {
    def leftMap[A1](f: E => A1): Either[A1, B] = either match {
      case Left(l) => Left(f(l))
      case _ => either.asInstanceOf[Either[A1, B]]
    }

    def leftEmbed[Super <: Coproduct](implicit basis: Basis[Super, E]): Either[Super, B] =
      leftMap(_.embed)
    }
}

object DivideAppIteration7WebApp {
  case object DivideByZeroError
  case class ParseNumberError(input: String)

  type TryDivideError = DivideByZeroError.type :+: CNil
  def tryDivide[Super <: Coproduct](x: Double, y: Double)(implicit
    basis: Basis[Super, TryDivideError]
  ): Either[Super, Double] =
    if (y == 0) Left(Coproduct[TryDivideError](DivideByZeroError).embed[Super])
    else Right(x / y)

  type TryParseNumberError = ParseNumberError :+: CNil
  def tryParseNumber[Super <: Coproduct](
    s: String
  )(implicit basis: Basis[Super, TryParseNumberError]): Either[Super, Double] =
    s.toDoubleOption
     .fold[Either[TryParseNumberError, Double]](
       Left(Coproduct(ParseNumberError(s)))
     )(Right.apply)
     .leftEmbed[Super]

  val runDivideError = Adjoin[TryParseNumberError :+: TryDivideError]
  type RunDivideError = runDivideError.Out

  def runDivide(x: String, y: String)(implicit
    b1: Basis[RunDivideError, TryParseNumberError],
    b2: Basis[RunDivideError, TryDivideError]
  ): Either[RunDivideError, Double] =
    for {
      a <- tryParseNumber(x)
      b <- tryParseNumber(y)
      r <- tryDivide(a, b)
    } yield r

  object divideEndpointErrorHandler extends Poly1 {
    implicit def parseNumberError = at[ParseNumberError] { e =>
      complete(HttpResponse(BadRequest, entity = "Invalid number specified"))
    }
    implicit def divideByZeroError = at[DivideByZeroError.type] { _ =>
      complete(HttpResponse(InternalServerError, entity = "Cannot divide by zero"))
    }
  }

  def divideEndpointSuccessHandler = (d: Double) => complete(HttpResponse(OK, entity = d.toString))

  def main(args: Array[String]): Unit = {
    implicit val system = ActorSystem(Behaviors.empty, "my-system")
    // needed for the future flatMap/onComplete in the end
    implicit val executionContext = system.executionContext

    val tryDivideEnpointError = Adjoin[TryParseNumberError :+: TryDivideError]
    type TryDivideEndpointError = tryDivideEnpointError.Out
    val divideRoute =
      path("divide") {
        get {
          // Normally you would leverage Akka's built-in functionality to parse strings.
          // As I would not like to diverge from our existing application, I decided to
          // keep doing the parsing our selves, and use the divideEndpointErrorHandler
          // to convert our errors to HTTP responses.
          parameters("x", "y").apply { case (x, y) =>
          runDivide(x, y).fold(_.fold(divideEndpointErrorHandler), divideEndpointSuccessHandler)
        }
      }
    }

  val bindingFuture = Http().newServerAt("localhost", 8080).bind(divideRoute)

  println(
    s"Server now online. Please navigate to http://localhost:8080/dividenPress RETURN to stop..."
  )
  StdIn.readLine() // Let it run until user presses return
  bindingFuture
    .flatMap(_.unbind()) // Trigger unbinding from the port
    .onComplete(_ => system.terminate()) // Shutdown when done
  }
}

Fazit

Wir haben uns Shapeless-Koprodukte angeschaut und gesehen, dass es sich um einen Union-Typ handelt (d.h. es kann mehr als einen einzigen Typ zurückgeben). Das kann praktisch sein, wenn eine Methode mehr als nur einen Fehler zurückgeben kann. In diesem Artikel haben wir eine einfache Befehlszeilenanwendung erstellt und einige Iterationen von Verbesserungen durchgeführt. Dabei haben wir gesehen, wie einfach es ist, den Code unserer Befehlszeilenanwendung dank funktionaler Komposition in eine REST-API zu übertragen, ohne dass sich etwas ändert.

Die große Frage bleibt: Sollte ich Shapeless-Koprodukte in meiner Codebasis verwenden?

Die Antwort darauf lautet: Es kommt darauf an

  • Angenommen, ich würde noch mit Scala 2.x arbeiten. In diesem Fall würde ich versuchen, die Codebasis auf Scala 3.0 zu bringen. Diese Migration sollte recht einfach sein, wenn Sie mit 2.13.8 arbeiten (denken Sie daran, dass die müheloseste Migration auf 3.0 statt auf eine spätere 3.x-Version erfolgt). Die Verwendung nativer Union-Typen in Scala 3.x ist eine bessere Lösung, da sie den Code nicht auf so unglückliche Weise verschmutzt. Ich werde demnächst einen Folgeartikel mit demselben Code in Scala 3.x veröffentlichen, und Sie werden sehen, wie einfach es ist, ihn zu verwenden.
  • Wenn Sie noch an Scala 2 gebunden sind, könnte die Verwendung von Shapeless eine gute Möglichkeit sein, einen typsicheren Fehlerkanal zu erstellen.

Ob ein typsicherer Fehlerkanal (d.h. "Effektverfolgung") gut oder schlecht ist, wird heftig diskutiert:

Ich bin mir immer noch nicht ganz sicher, wo ich hier stehe. Ich denke, dass es keine gute Praxis ist, Fehler explizit zu machen, nur um sie explizit zu machen. Ich halte es jedoch für sinnvoll, Fehler explizit zu machen, die im vorgelagerten Bereich (d.h. beim Aufrufer oder noch weiter oben in der Kette) berücksichtigt werden müssen. Wie in vielen anderen Fällen hängt es davon ab, was Sie erreichen wollen, ob dies sinnvoll ist.

Verfasst von

Anton Lijcklama à Nijeholt

Contact

Let’s discuss how we can support your journey.