Blog

Automatisches Ableiten von Typklasseninstanzen in Scala 3

Noel Markham

Noel Markham

Aktualisiert Oktober 20, 2025
10 Minuten

Wir werden die Untersuchung der Typklassen in Scala 3 fortsetzen. Beim letzten Mal haben wir uns damit beschäftigt, was Typklassen sind, warum sie gut sind und wie sie in Scala 3 funktionieren.

Überprüfen Sie enum

Schauen wir uns unsere Klasse BinaryTree aus dem enum Artikel an:

enum BinaryTree[+A]:
  case Node(value: A, left: BinaryTree[A], right: BinaryTree[A])
  case Leaf
scala> BinaryTree.Node(5,
     |                 BinaryTree.Leaf,
     |                 BinaryTree.Node(10,
     |                                 BinaryTree.Leaf,
     |                                 BinaryTree.Leaf
     |                                )
     |                )
val res0: BinaryTree[Int] = Node(5,Leaf,Node(10,Leaf,Leaf))

Wir können für Gleichheit sorgen:

trait Eq[A]:
  def eq(x: A, y: A): Boolean

Dies ist das gleiche Konzept wie im vorigen Artikel, so dass die Umsetzung hoffentlich keine allzu große Überraschung sein sollte:

given[A](using eqA: Eq[A]): Eq[BinaryTree[A]] with
  def eq(x: BinaryTree[A], y: BinaryTree[A]): Boolean = {
    (x, y) match {
      case (BinaryTree.Leaf, BinaryTree.Leaf) => true
      case (BinaryTree.Node(xv, xl, xr), BinaryTree.Node(yv, yl, yr)) => eqA.eq(xv, yv) && eq(xl, yl) && eq(xr, yr)
      case _ => false
    }
  }

Es gibt ein paar Dinge, auf die Sie hinweisen sollten:

  • Wir haben das neue Schlüsselwort using, das die Instanz Eq für A aufruft: Wenn wir die Gleichheit von A nicht messen können, dann können wir auch die Gleichheit von BinaryTree[A] nicht messen.
  • Wir führen einen Mustervergleich für die Aufzählung durch: Dies funktioniert wie in Scala 2.
  • Wir überprüfen rekursiv, ob die Teilbäume gleich sind.

Wir haben Eq für unseren Binärbaum implementiert: In Zukunft kann jeder unsere BinaryTree in einer Funktion verwenden, die Eq erfordert, solange der zugrundeliegende Typ Instanzen für diese Typklassen im Gültigkeitsbereich hat.

Wenden wir uns nun der Implementierung von Show für BinaryTree[A] zu:

trait Show[A]:
  def show(a: A): String

Show gibt eine String Darstellung des Werts A zurück.

Dies sollte ähnlich aussehen wie der Ansatz von Eq:

  • Wir benötigen den Compiler, um uns eine Show[A] Instanz zu liefern.
  • Basisfall: Überlegen Sie, was Sie für Leaf tun können: Drucken Sie den leeren String.
  • Konsensfall: Rufen Sie Show[A] für die Zweige im Baum auf und fügen Sie an, was show für den Teilbaum zurückgibt.

Vom Konzept her ist dies dasselbe wie unsere Eq Implementierung. Wenn man bedenkt, dass wir das Gleiche tun, wäre es nicht toll, wenn dies automatisch für uns geschrieben werden könnte?

Automatische Typklassenableitung

Glücklicherweise können wir Scala 3 anweisen, dies zu tun. Wir zerlegen einen Typ in seine Einzelteile und Show diese stattdessen.

Die Typklasse Mirror

Die Typklasse Mirror gibt uns Informationen über den Typ selbst, die Namen der Felder, die Typen der Felder und wie alles zusammenpasst. Der Compiler ist in der Lage, diese Informationen zu liefern:

  • Fallklassen
  • Fall-Objekte
  • Geschlossene Hierarchien mit Fallobjekten und Fallklassen
  • Enums
  • Enum-Fälle

Für den Moment ist der wichtigste Teil des Mirror ist die Aufteilung zwischen Sum und Product. Unser BinaryTree ist ein algebraischer Datentyp (ADT), der aus einem Sum von zwei Varianten: Node und Leaf. Node ist ein Product, das aus einer Kombination von A und zwei weiteren BinaryTree Instanzen besteht. Es kann hilfreich sein, sich ein Product als ein Tupel vorzustellen, das die Werte enthält, die dieser Typ enthalten würde, plus einige Metadaten über die Feldnamen und den Klassennamen.

Wir werden diese Importe für diesen Artikel verwenden:

import scala.deriving.*
import scala.compiletime.{erasedValue, summonInline}

Damit Scala die Instanzen der Typklasse Show für uns erstellen kann, müssen wir dem Compiler mitteilen, wie er Show Instanzen für Sums und Products erstellen soll:

Produkte anzeigen

Wir können davon ausgehen, dass es für alle Teile des Produkts eine Show Instanz gibt. Eine mögliche Definition für Showing eines Produkts könnte wie folgt aussehen:

def showProduct[T](shows: => List[Show[_]]): Show[T] =                    // (1)
  new Show[T]:                                                            // (2)
    def show(t: T): String = {
      (t.asInstanceOf[Product].productIterator).zip(shows.iterator).map { // (3)
        case (p, s) => s.asInstanceOf[Show[Any]].show(p)                  // (4)
      }.mkString
    }

Hier ist eine ganze Menge los!

  1. Für diese Funktion nehmen wir an (hoffen?), dass der Aufrufer uns eine Liste von Show Instanzen in derselben Reihenfolge gibt, in der sie in der Produktdefinition definiert sind. Für würde diese Liste also in dieser Reihenfolge enthalten. Es sollte klar sein, warum dies als Show[_] definiert ist: wir werden in der gesamten Liste unterschiedliche Werte für den Parameter type haben. Die Show Instanzen können durch Ableitung verfügbar sein (unter Verwendung der oben für Mirror beschriebenen Liste von Optionen) oder wenn ein explizites given angegeben wird.
  2. Diese Funktion erstellt bei jedem Aufruf eine neue Show Instanz. Dies ist zwar äußerst nützlich, aber wenn Sie große Bäume automatisch ableiten und dies viele Male tun, erhöht sich die Kompilierungszeit merklich. Es kann sinnvoll sein, in Ihrem Code einen Verweis auf eine automatisch abgeleitete Instanz zu erstellen und stattdessen auf diese zu verweisen. Auf diese Weise wird der Code für die automatische Ableitung nicht jedes Mal für denselben zugrunde liegenden Typ erneut ausgeführt.
  3. Wir paaren den Wert des Typs X als Product mit seinem Show[X].
  4. Wir show diesen Wert und fügen alles zu einem einzigen String zusammen. Es ist wichtig zu beachten, dass dies das Format Ihrer Zeichenkette für jeden von auto abgeleiteten Typ sein wird. Wenn Sie für einen bestimmten Typ etwas Ausgefallenes tun wollen (z.B. einen Binärbaum in eine schöne baumähnliche Struktur ausgeben), sollten Sie dies besser in einer eigenen Instanz tun. Dieser Code wird das Standardformat von für alle Typen sein, die es verwenden. Außerdem müssen wir unsere in einen Typ umwandeln. Es ist in Ordnung, dies in Any zu übertragen. Wir haben zu diesem Zeitpunkt keine weiteren Informationen über den Typ und der Compiler hat sichergestellt, dass die Instanz den richtigen Typ hat, bevor wir überhaupt zu diesem Punkt kommen.

Summen anzeigen

Einige der Summenarten werden sich aus Produkten zusammensetzen. Auch hier gehen wir davon aus, dass Show Instanzen für alle verschiedenen Summenvarianten verfügbar sind, einschließlich der automatischen Ableitung von Produkten, falls erforderlich.

Unsere Show für Summen könnte so aussehen:

def showSum[T](s: Mirror.SumOf[T], shows: => List[Show[_]]): Show[T] = // (1)
  new Show[T]:
    def show(t: T): String = {
      val index = s.ordinal(t)                                         // (2)
      shows(index).asInstanceOf[Show[Any]].show(t)                     // (3)
    }
  1. Diese Funktion verwendet den Typ SumOf innerhalb der Typklasse Mirror. Damit erhalten wir Informationen über die Struktur der verschiedenen Sum Varianten. Beachten Sie, dass wir für unser Produkt keinen Verweis auf eine Mirror benötigten. Es gibt eine Mirror.ProductOf[T], die wir nicht brauchten, aber wir könnten sie für kompliziertere Typklassen verwenden. Mirror.ProductOf[T] hat die Fähigkeit, eine Instanz von T aus einer Product zu erzeugen, so dass wir z.B. die Werte in einem Produkt ändern und an dieser Stelle eine neue T konstruieren könnten.
  2. Wenn wir wieder davon ausgehen, dass die List[Show[_]] in der Reihenfolge der Summenvarianten-Definitionen stehen, greifen wir uns die Show Instanz an der richtigen Position in der Liste.
  3. Wie bei dem Produkt werfen wir, rufen wir show auf und sind fertig.

Alles zusammenfügen

Jetzt, da wir wissen, wie wir Summen und Produkte ableiten können, müssen wir das Ganze zusammenfügen und dem Compiler mitteilen, wie er es automatisch verwenden soll.

Wenn wir eine given mit dem Namen derived einbinden, wird der Compiler diese Funktion verwenden, um eine Typklasse für uns abzuleiten. Sie hat eine sehr spezifische Signatur:

inline given derived[T](using m: Mirror.Of[T]): Show[T]

Zunächst benötigen wir eine Möglichkeit, entweder alle Instanzen der Summenvariante oder der Produktfeldtypklasse zu erfassen, für die wir eine Ableitung vornehmen möchten:

import scala.compiletime.{erasedValue, summonInline}

inline def summonAll[T <: Tuple]: List[Show[_]] =
  inline erasedValue[T] match
    case _: EmptyTuple => Nil
    case _: (t *: ts) => summonInline[Show[t]] :: summonAll[ts]

summonAll ruft die Instanzen in einer Liste auf, wobei die Reihenfolge der Felder eingehalten wird, wie wir sie für unsere Funktionen showSum und showProduct angenommen haben.

Wir können die Funktion derived implementieren: Wir haben alle benötigten Show Instanzen und wissen, wie wir Summen und Produkte ableiten können. Wir werden die Funktion derived zusammen mit allen "Basis"- oder Standard-Typklassen-Instanzen in das Begleitobjekt aufnehmen:

object Show:
  inline given derived[T](using m: Mirror.Of[T]): Show[T] =
    lazy val shows = summonAll[m.MirroredElemTypes]
    inline m match
      case s: Mirror.SumOf[T] => showSum(s, shows)
      case _: Mirror.ProductOf[T] => showProduct(shows)

lazy val bedeutet, dass rekursive Definitionen erst dann berechnet werden, wenn sie benötigt werden, da dies zu einer StackOverflowError führen würde, wenn man versucht, alle Fälle des ADT abzuleiten. Aus diesem Grund haben die List[Show[_]] Parameter für showProduct und showSum eine Call-by-Name-Markierung in der Definition.

Das war's! Jetzt können wir automatisch jede Instanz der Typklasse ableiten. Wir müssen eine kleine Anpassung an unserem Typ vornehmen, um zu sagen, dass er Show ableiten kann:

enum BinaryTree[+A] derives Show:
  case Node(value: A, left: BinaryTree[A], right: BinaryTree[A])
  case Leaf

Wenn wir nun in eine Konsole gehen, können wir, solange wir eine Show[A]ableiten können, eine Show[BinaryTree[A]] ableiten:

scala> given Show[String] with
     |   def show(s: String): String = s"$s!"
     |
// defined object given_Show_String

scala> summon[Show[BinaryTree[String]]]
val res0: Show[BinaryTree[String]] = Show$$anon$2@5043da15

scala> res0.show(BinaryTree.Leaf)                                                                                                                     
val res1: String = ""

scala> res0.show(BinaryTree.Node("Hello", BinaryTree.Leaf, BinaryTree.Leaf))
val res2: String = Hello!

scala> res0.show(BinaryTree.Node("Hello", BinaryTree.Leaf, BinaryTree.Node("World", BinaryTree.Leaf, BinaryTree.Leaf)))                    
val res3: String = Hello!World!

Und natürlich funktioniert dies auch für Typen, die nichts mit BinaryTree zu tun haben. Unter Verwendung derselben Show[String] Instanz von oben:

scala> case class Record(s: String) derives Show
// defined case class Record

scala> summon[Show[Record]]
val res4: Show[Record] = Show$$anon$1@4ec6634c

scala> res4.show(Record("New record"))
val res5: String = New record!

Wenn es weitere Ableitungen von Typklassen gibt, die wir einbeziehen möchten, nimmt das Schlüsselwort derives eine durch Komma getrennte Liste entgegen:

enum BinaryTree[+A] derives Show, Eq:
  case Node(value: A, left: BinaryTree[A], right: BinaryTree[A])
  case Leaf

Überlegen Sie, wie Eq in Bezug auf Summe und Produkt implementiert werden würde. Wie unterscheidet sie sich von Show? Was bedeutet es, wenn ein Leaf mit einem Node verglichen wird? Wie sieht es mit verschiedenen Produkttypen aus?

scala> summon[Eq[BinaryTree[Int]]]                                                                                                                    
val res0: Eq[BinaryTree[Int]] = Eq$$anon$1@7c4384c7

scala> res0.eq(BinaryTree.Node(1, BinaryTree.Leaf, BinaryTree.Leaf), BinaryTree.Node(2, BinaryTree.Leaf, BinaryTree.Leaf))                 
val res1: Boolean = false

Wir können die Typklassenableitung auch anwenden, nachdem die Klasse definiert wurde. Dies ist nützlich, wenn Sie keine Kontrolle über die Klasse haben:

given[T: Show]: Show[BinaryTree[T]] = Show.derived
given[T: Eq] : Eq[BinaryTree[T]] = Eq.derived

Dies unterscheidet sich im Wesentlichen nicht von der Definition einer beliebigen Typklasseninstanz für einen gegebenen Typ. Sie verwenden lediglich die Funktion derived, die für die automatische Typklassenableitung erforderlich ist und Teil des Begleitobjekts ist.

Andere Typklassen

Beim letzten Mal haben wir eine Bool Typklasse erstellt. Können wir diesen Mechanismus nutzen, um Boolautomatisch abzuleiten? Es wäre großartig, and und or auf ein paar Binärbäumen zu verwenden und das ganze Klempnerhandwerk umsonst zu haben.

Leider ist das nicht möglich. Was bedeutet es, ? Man könnte argumentieren, dass die verworfen werden sollte, aber vergessen Sie nicht, dass dies ein spezieller Fall ist; wir müssten für den allgemeinen Fall korrekt sein. Was bedeutet , wobei die Regeln für binäre Bäume ebenfalls beibehalten werden? Oder eine etwas schwierigere Frage: Left(List(false, true)) booleanOr Right(100)? Auch hier könnten wir wahrscheinlich Regeln aufstellen, die die Gesetze einhalten, aber das ist eine Frage des Einzelfalls. Dies ist der Grund, warum wir keine automatische Ableitung von Typklassen für einige der komplizierteren Typklassen wie Monad oder Applicative sehen.

Aber es gibt Fälle, in denen dies sinnvoll ist. Gleichheit, to-string und Ordnung sind alles gute Kandidaten. Außerdem gibt es bereits Bibliotheken für das JSON-Parsing mit Circe, die Erzeugung beliebiger Werte mit Scalacheck und viele andere.

Verfasst von

Noel Markham

Contact

Let’s discuss how we can support your journey.