Typklassen in Scala3: Ein Leitfaden für Anfänger | Hauptbuch

Typklassen in Scala3: Ein Leitfaden für Anfänger | Hauptbuch

Typklassen in Scala3: Ein Leitfaden für Anfänger | Ledger PlatoBlockchain Data Intelligence. Vertikale Suche. Ai.

Dieses Dokument richtet sich an Scala3-Entwickler, die bereits mit der Scala-Prosa vertraut sind, sich aber über all das wundernimplicits` und parametrisierte Merkmale im Code.

In diesem Dokument wird das Warum, Wie, Wo und Wann erklärt Typklassen (TC).

Nach der Lektüre dieses Dokuments wird der Scala3-Anfänger solide Kenntnisse erwerben und in den Quellcode von ScalaXNUMX eintauchen können viel der Scala-Bibliotheken und beginnen Sie mit dem Schreiben von idiomatischem Scala-Code.

Beginnen wir mit dem Warum …

Das Ausdrucksproblem

In 1998, Philip Wadler erklärte dass „der Ausdruck Problem ein neuer Name für ein altes Problem ist“. Es ist das Problem der Software-Erweiterbarkeit. Laut Herrn Wadler muss die Lösung des Ausdrucksproblems den folgenden Regeln entsprechen:

  • Regel 1: Erlauben Sie die Implementierung von bestehende Verhaltensweisen (denken Sie an das Scala-Merkmal), auf das es angewendet werden soll neue Darstellungen (denken Sie an eine Fallklasse)
  • Regel 2: Erlauben Sie die Implementierung von neue Verhaltensweisen angewendet werden bestehende Darstellungen
  • Regel 3: Es darf nicht gefährdet werden Typ Sicherheit
  • Regel 4: Es darf kein Neukompilieren erforderlich sein vorhandener Code

Die Lösung dieses Problems wird der rote Faden dieses Artikels sein.

Regel 1: Umsetzung des bestehenden Verhaltens auf neue Darstellung

Jede objektorientierte Sprache verfügt über eine integrierte Lösung für Regel 1 mit Subtyp Polymorphismus. Sie können jedes ` sicher implementierentrait` definiert in einer Abhängigkeit von einem `class` in Ihrem eigenen Code, ohne die Abhängigkeit neu zu kompilieren. Sehen wir uns das in Aktion an:

Scala

def todo = 42
type Height = Int
type Block = Int

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo


object Lib2:
 import Lib1.*

 case class Polkadot() extends Blockchain:
 override def getBlock(height: Height): Block = todo

val eth = Lib1.Ethereum()
val btc = Lib1.Bitcoin()
val dot = Lib2.Polkadot()

In diesem fiktiven Beispiel ist die Bibliothek „Lib1` (Zeile 5) definiert ein Merkmal `Blockchain` (Zeile 6) mit 2 Implementierungen davon (Zeilen 9 und 12). `Lib1` bleibt in ALLEN Dokumenten gleich (Durchsetzung von Regel 4).

`Lib2` (Zeile 15) implementiert das vorhandene Verhalten `Blockchain`auf einer neuen Klasse`Polkadot` (Regel 1) auf typsichere (Regel 3) Weise, ohne Neukompilierung `Lib1` (Regel 4). 

Regel 2: Implementierung neuer Verhaltensweisen, die auf bestehende Darstellungen angewendet werden sollen

Stellen wir uns „in“ vorLib2„Wir wollen ein neues Verhalten“.lastBlock„jeweils spezifisch umzusetzen“.Blockchain`.

Das erste, was mir in den Sinn kommt, ist die Erstellung eines großen Schalters basierend auf der Art des Parameters.

Scala

def todo = 42
type Height = Int
type Block = Int

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo

object Lib2:
 import Lib1.*

 case class Polkadot() extends Blockchain:
 override def getBlock(height: Height): Block = todo

 def lastBlock(blockchain: Blockchain): Block = blockchain match
 case _:Ethereum => todo
 case _:Bitcoin => todo
 case _:Polkadot => todo
 

object Lib3:
 import Lib1.*

 case class Polygon() extends Blockchain:
 override def getBlock(height: Height): Block = todo

import Lib1.*, Lib2.*, Lib3.*
println(lastBlock(Bitcoin()))
println(lastBlock(Ethereum()))
println(lastBlock(Polkadot()))
println(lastBlock(Polygon()))

Diese Lösung ist eine schwache Neuimplementierung des typbasierten Polymorphismus, der bereits in die Sprache integriert ist!

`Lib1` bleibt unberührt (denken Sie daran, dass Regel 4 im gesamten Dokument durchgesetzt wird). 

Die in ` implementierte LösungLib2` ist okay bis in ` noch eine weitere Blockchain eingeführt wirdLib3`. Es verstößt gegen die Typsicherheitsregel (Regel 3), da dieser Code zur Laufzeit in Zeile 37 fehlschlägt. Und das Ändern von `Lib2` würde gegen Regel 4 verstoßen.

Eine andere Lösung ist die Verwendung eines „extension`.

Scala

def todo = 42
type Height = Int
type Block = Int

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo

object Lib2:
 import Lib1.*

 case class Polkadot() extends Blockchain:
 override def getBlock(height: Height): Block = todo

 def lastBlock(): Block = todo

 extension (eth: Ethereum) def lastBlock(): Block = todo

 extension (btc: Bitcoin) def lastBlock(): Block = todo

import Lib1.*, Lib2.*
println(Bitcoin().lastBlock())
println(Ethereum().lastBlock())
println(Polkadot().lastBlock())

def polymorphic(blockchain: Blockchain) =
 // blockchain.lastBlock()
 ???

`Lib1` bleibt unberührt (Durchsetzung von Regel 4 im gesamten Dokument). 

`Lib2„definiert das Verhalten für seinen Typ (Zeile 21) und „Erweiterungen“ für vorhandene Typen (Zeilen 23 und 25).

In den Zeilen 28–30 kann das neue Verhalten in jeder Klasse verwendet werden. 

Es gibt jedoch keine Möglichkeit, dieses neue Verhalten polymorph zu nennen (Zeile 32). Jeder Versuch, dies zu tun, führt zu Kompilierungsfehlern (Zeile 33) oder zu typbasierten Schaltern. 

Diese Regel Nr. 2 ist knifflig. Wir haben versucht, es mit unserer eigenen Definition von Polymorphismus und einem „Erweiterungstrick“ zu implementieren. Und das war seltsam.

Es fehlt ein Teil namens Ad-hoc-Polymorphismus: die Fähigkeit, eine Verhaltensimplementierung je nach Typ sicher zu versenden, unabhängig davon, wo das Verhalten und der Typ definiert sind. Geben Sie die ein Typklasse Muster.

Das Typklassenmuster

Das Musterrezept für die Typklasse (kurz TC) besteht aus drei Schritten. 

  1. Definieren Sie ein neues Verhalten
  2. Implementieren Sie das Verhalten
  3. Nutzen Sie das Verhalten

Im folgenden Abschnitt setze ich das TC-Muster auf einfachste Weise um. Es ist ausführlich, klobig und unpraktisch. Aber warten Sie, diese Vorbehalte werden im weiteren Verlauf des Dokuments Schritt für Schritt behoben.

1. Definieren Sie ein neues Verhalten
Scala

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

`Lib1` bleibt wieder einmal unangetastet.

Das neue Verhalten is der TC wird durch das Merkmal materialisiert. Die im Merkmal definierten Funktionen bieten eine Möglichkeit, einige Aspekte dieses Verhaltens anzuwenden.

Der Parameter `A` stellt den Typ dar, auf den wir Verhalten anwenden möchten, bei dem es sich um Untertypen von ` handeltBlockchain` in unserem Fall.

Einige Anmerkungen:

  • Bei Bedarf der parametrisierte Typ `A` kann durch das Scala-Typsystem weiter eingeschränkt werden. Wir könnten zum Beispiel „durchsetzen“.A` ein ` seinBlockchain`. 
  • Außerdem könnten im TC noch viele weitere Funktionen deklariert werden.
  • Schließlich kann jede Funktion noch viele weitere beliebige Parameter haben.

Aber der besseren Lesbarkeit halber sollten wir die Dinge einfach halten.

2. Implementieren Sie das Verhalten
Scala

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 val ethereumLastBlock = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 val bitcoinLastBlock = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

Für jeden Typ das neue `LastBlock„Verhalten wird erwartet, es gibt einen bestimmten Fall dieses Verhaltens.“ 

Die `Ethereum` Implementierungszeile 22 wird aus ` berechneteth` Instanz als Parameter übergeben. 

Die Implementierung von `LastBlock` für `Bitcoin` Zeile 25 wird mit einem nicht verwalteten IO implementiert und verwendet seinen Parameter nicht.

Also, `Lib2` implementiert neues Verhalten `LastBlock` für `Lib1` Klassen.

3. Nutzen Sie das Verhalten
Scala

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 val ethereumLastBlock = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 val bitcoinLastBlock = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A, behavior: LastBlock[A]) =
 behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2), ethereumLastBlock))
println(useLastBlock(Bitcoin(), bitcoinLastBlock))

Zeile 30`useLastBlock` verwendet eine Instanz von `A` und das `LastBlock` Verhalten, das für diese Instanz definiert ist.

Zeile 33`useLastBlock` wird mit einer Instanz von ` aufgerufenEthereum` und eine Implementierung von `LastBlock` definiert in `Lib2`. Beachten Sie, dass es möglich ist, jede alternative Implementierung von ` zu übergebenLastBlock[A]` (Denken Sie daran Abhängigkeitsspritze).

`useLastBlock` ist der Klebstoff zwischen der Darstellung (dem tatsächlichen A) und seinem Verhalten. Daten und Verhalten werden getrennt, wofür die funktionale Programmierung plädiert.

Diskussion

Fassen wir noch einmal die Regeln des Ausdrucksproblems zusammen:

  • Regel 1: Erlauben Sie die Implementierung von bestehende Verhaltensweisen  angewendet werden neue Klassen
  • Regel 2: Erlauben Sie die Implementierung von neue Verhaltensweisen angewendet werden bestehende Klassen
  • Regel 3: Es darf nicht gefährdet werden Typ Sicherheit
  • Regel 4: Es darf kein Neukompilieren erforderlich sein vorhandener Code

Regel 1 kann sofort mit Subtyp-Polymorphismus gelöst werden.

Das gerade vorgestellte TC-Muster (siehe vorheriger Screenshot) löst Regel 2. Es ist typsicher (Regel 3) und wir haben „`“ nie berührtLib1` (Regel 4). 

Die Verwendung ist jedoch aus mehreren Gründen unpraktisch:

  • In den Zeilen 33–34 müssen wir das Verhalten explizit entlang seiner Instanz übergeben. Dies ist ein zusätzlicher Aufwand. Wir sollten einfach „schreiben“.useLastBlock(Bitcoin())`.
  • Zeile 31: Die Syntax ist ungewöhnlich. Wir würden es lieber vorziehen, einen prägnanten und objektorientierteren Text zu schreibeninstance.lastBlock()` Aussage.

Lassen Sie uns einige Scala-Funktionen für die praktische TC-Nutzung hervorheben. 

Verbesserte Entwicklererfahrung

Scala verfügt über einen einzigartigen Satz an Funktionen und syntaktischen Zuckern, die TC zu einem wirklich angenehmen Erlebnis für Entwickler machen.

Implizite

Der implizite Bereich ist ein spezieller Bereich, der zur Kompilierungszeit aufgelöst wird und in dem nur eine Instanz eines bestimmten Typs existieren kann. 

Ein Programm fügt eine Instanz mit dem ` in den impliziten Bereich eingiven` Schlüsselwort. Alternativ kann ein Programm mit dem Schlüsselwort „eine Instanz aus dem impliziten Bereich abrufenusing`.

Der implizite Bereich wird zur Kompilierzeit aufgelöst. Es gibt eine bekannte Möglichkeit, ihn zur Laufzeit dynamisch zu ändern. Wenn das Programm kompiliert wird, wird der implizite Bereich aufgelöst. Zur Laufzeit ist es nicht möglich, dass implizite Instanzen fehlen, in denen sie verwendet werden. Die einzig mögliche Verwirrung kann durch die Verwendung der falschen impliziten Instanz entstehen, aber dieses Problem bleibt der Kreatur zwischen Stuhl und Tastatur überlassen.

Es unterscheidet sich von einem globalen Bereich, weil: 

  1. Es wird kontextbezogen gelöst. Zwei Speicherorte eines Programms können eine Instanz desselben Typs im impliziten Bereich verwenden, diese beiden Instanzen können jedoch unterschiedlich sein.
  2. Hinter den Kulissen übergibt der Code implizite Argumente von Funktion zu Funktion, bis die implizite Verwendung erreicht ist. Es wird kein globaler Speicherplatz verwendet.

Zurück zur Typklasse! Nehmen wir genau das gleiche Beispiel.

Scala

def todo = 42
type Height = Int
type Block = Int
def http(uri: String): Block = todo

object Lib1:
 trait Blockchain:
 def getBlock(height: Height): Block

 case class Ethereum() extends Blockchain:
 override def getBlock(height: Height) = todo

 case class Bitcoin() extends Blockchain:
 override def getBlock(height: Height) = todo

`Lib1` ist derselbe unveränderte Code, den wir zuvor definiert haben. 

Scala

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 given ethereumLastBlock:LastBlock[Ethereum] = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given bitcoinLastBlock:LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A)(using behavior: LastBlock[A]) =
 behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2)))
println(useLastBlock(Bitcoin()))

Zeile 19 ein neues Verhalten `LastBlock` ist genau so definiert, wie wir es zuvor getan haben.

Zeile 22 und Zeile 25, `val` wird durch ` ersetztgiven`. Beide Implementierungen von `LastBlock` werden in den impliziten Bereich eingefügt.

Zeile 31`useLastBlock„deklariert das Verhalten“.LastBlock` als impliziter Parameter. Der Compiler löst die entsprechende Instanz von „aufLastBlock` aus implizitem Bereich, kontextualisiert aus Aufruferstandorten (Zeilen 33 und 34). Zeile 28 importiert alles aus „Lib2`, einschließlich des impliziten Bereichs. Daher übergibt der Compiler die in den Zeilen 22 und 25 definierten Instanzen als letzten Parameter von „useLastBlock`. 

Als Bibliotheksbenutzer ist die Verwendung einer Typklasse einfacher als zuvor. In den Zeilen 34 und 35 muss ein Entwickler lediglich sicherstellen, dass eine Instanz des Verhaltens in den impliziten Bereich eingefügt wird (und dies kann nur ein ` seinimport`). Wenn ein Implizit nicht ` istgiven` wo der Code ist `using` es, sagt ihm der Compiler.

Scalas implizite Erleichterung erleichtert die Übergabe von Klasseninstanzen zusammen mit Instanzen ihres Verhaltens.

Implizite Zucker

Zeile 22 und 25 des vorherigen Codes können weiter verbessert werden! Lassen Sie uns die TC-Implementierungen wiederholen.

Scala

given LastBlock[Ethereum] = new LastBlock[Ethereum]:
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

Zeilen 22 und 25: Wenn der Name der Instanz nicht verwendet wird, kann er weggelassen werden.

Scala


 given LastBlock[Ethereum] with
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] with
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

In den Zeilen 22 und 25 kann die Wiederholung des Typs durch „`“ ersetzt werdenwith` Schlüsselwort.

Scala

given LastBlock[Ethereum] = _.lastBlock

 given LastBlock[Bitcoin] = _ => http("http://bitcoin/last")

Da wir ein degeneriertes Merkmal mit einer einzelnen Funktion verwenden, schlägt die IDE möglicherweise vor, den Code mit einem SAM-Ausdruck zu vereinfachen. Obwohl das richtig ist, glaube ich nicht, dass es sich um eine ordnungsgemäße Verwendung von SAM handelt, es sei denn, Sie spielen nebenbei Code-Golf.

Scala bietet syntaktische Zucker, um die Syntax zu optimieren und unnötige Benennung, Deklaration und Typredundanz zu vermeiden.

Erweiterung

Mit Bedacht verwendet, ist das `extension`-Mechanismus kann die Syntax für die Verwendung einer Typklasse vereinfachen.

Scala

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 given LastBlock[Ethereum] with
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] with
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

 extension[A](instance: A)
 def lastBlock(using tc: LastBlock[A]) = tc.lastBlock(instance)

import Lib1.*, Lib2.*

println(Ethereum(lastBlock = 2).lastBlock)
println(Bitcoin().lastBlock)

Zeilen 28-29 eine generische Erweiterungsmethode `lastBlock` ist für jedes ` definiertA` mit einem `LastBlock` TC-Parameter im impliziten Bereich.

In den Zeilen 33–34 nutzt die Erweiterung eine objektorientierte Syntax, um TC zu verwenden.

Scala

object Lib2:
 import Lib1.*

 trait LastBlock[A]:
 def lastBlock(instance: A): Block

 given LastBlock[Ethereum] with
 def lastBlock(eth: Ethereum) = eth.lastBlock

 given LastBlock[Bitcoin] with
 def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

 extension[A](instance: A)(using tc: LastBlock[A])
 def lastBlock = tc.lastBlock(instance)
 def penultimateBlock = tc.lastBlock(instance) - 1

import Lib1.*, Lib2.*

val eth = Ethereum(lastBlock = 2)
println(eth.lastBlock)
println(eth.penultimateBlock)

val btc = Bitcoin()
println(btc.lastBlock)
println(btc.penultimateBlock)

In Zeile 28 kann der TC-Parameter auch für die gesamte Erweiterung definiert werden, um Wiederholungen zu vermeiden. In Zeile 30 verwenden wir den TC in der Erweiterung wieder, um „zu definieren“.penultimateBlock` (auch wenn es auf ` implementiert werden könnteLastBlock` Merkmal direkt)

Die Magie entsteht, wenn das TC verwendet wird. Der Ausdruck fühlt sich viel natürlicher an und vermittelt die Illusion dieses VerhaltenslastBlock` ist mit der Instanz verknüpft.

Allgemeiner Typ mit TC
Scala

import Lib1.*, Lib2.*

def useLastBlock1[A](instance: A)(using LastBlock[A]) = instance.lastBlock

def useLastBlock2[A: LastBlock](instance: A) = instance.lastBlock

val eth = Ethereum(lastBlock = 2)
assert(useLastBlock1(eth) == useLastBlock2(eth))

Zeile 34: Die Funktion verwendet einen impliziten TC. Beachten Sie, dass der TC nicht benannt werden muss, wenn dieser Name nicht erforderlich ist.

Das TC-Muster ist so weit verbreitet, dass es eine generische Typsyntax gibt, um „einen Typ mit implizitem Verhalten“ auszudrücken. Die Syntax in Zeile 36 ist eine prägnantere Alternative zur vorherigen (Zeile 34). Es wird vermieden, dass der unbenannte implizite TC-Parameter speziell deklariert wird.

Damit ist der Abschnitt zur Entwicklererfahrung abgeschlossen. Wir haben gesehen, wie Erweiterungen, Implizite und etwas syntaktischer Zucker zu einer weniger überladenen Syntax führen können, wenn TC verwendet und definiert wird.

Automatische Ableitung

Viele Scala-Bibliotheken verwenden TC und überlassen es dem Programmierer, sie in ihre Codebasis zu implementieren.

Beispielsweise verwendet Circe (eine JSON-Deserialisierungsbibliothek) TC `Encoder[T]` und `Decoder[T]` damit Programmierer sie in ihre Codebasis implementieren können. Nach der Implementierung kann der gesamte Umfang der Bibliothek genutzt werden. 

Diese Implementierungen von TC kommen mehr als häufig vor datenorientierte Mapper. Sie benötigen keine Geschäftslogik, sind langweilig zu schreiben und es ist mühsam, sie mit den Fallklassen synchron zu halten.

In einer solchen Situation bieten diese Bibliotheken das sogenannte an maschinell Ableitung bzw halbautomatisch Ableitung. Siehe zum Beispiel Circe maschinell und halbautomatisch Ableitung. Bei der halbautomatischen Ableitung kann der Programmierer eine Instanz einer Typklasse mit etwas geringerer Syntax deklarieren, während bei der automatischen Ableitung außer einem Import keine Codeänderung erforderlich ist.

Unter der Haube werden zur Kompilierzeit generische Makros inspiziert Typen als reine Datenstruktur und generieren ein TC[T] für Bibliotheksbenutzer. 

Die generische Ableitung eines TC ist weit verbreitet, daher hat Scala zu diesem Zweck eine komplette Toolbox eingeführt. Diese Methode wird in Bibliotheksdokumentationen nicht immer beworben, obwohl es sich um die Scala 3-Methode zur Verwendung der Ableitung handelt.

Scala

object GenericLib:

 trait Named[A]:
 def blockchainName(instance: A): String

 object Named:
 import scala.deriving.*

 inline final def derived[A](using inline m: Mirror.Of[A]): Named[A] =
 val nameOfType: String = inline m match
 case p: Mirror.ProductOf[A] => compiletime.constValue[p.MirroredLabel]
 case _ => compiletime.error("Not a product")
 new Named[A]:
 override def blockchainName(instance: A):String = nameOfType.toLowerCase

 extension[A] (instance: A)(using tc: Named[A])
 def blockchainName = tc.blockchainName(instance)

import Lib1.*, GenericLib.*

case class Polkadot() derives Named
given Named[Bitcoin] = Named.derived
given Named[Ethereum] = Named.derived

println(Ethereum(lastBlock = 2).blockchainName)
println(Bitcoin().blockchainName)
println(Polkadot().blockchainName)

Zeile 18 ein neues TC`Named` wird eingeführt. Dieses TC hat streng genommen nichts mit dem Blockchain-Geschäft zu tun. Sein Zweck besteht darin, die Blockchain basierend auf dem Namen der Fallklasse zu benennen.

Konzentrieren Sie sich zunächst auf die Definitionen, Zeilen 36–38. Es gibt zwei Syntaxen zum Ableiten eines TC:

  1. In Zeile 36 kann die TC-Instanz direkt auf der Case-Klasse mit dem ` definiert werdenderives` Schlüsselwort. Unter der Haube generiert der Compiler ein bestimmtes „Named` Instanz in `Polkadot` Begleitobjekt.
  2. Zeile 37 und 38, Typklasseninstanzen werden für bereits vorhandene Klassen mit ` angegebenTC.derived

In Zeile 31 wird eine generische Erweiterung definiert (siehe vorherige Abschnitte) und `blockchainName` wird natürlich verwendet.  

Die `derivesDas Schlüsselwort ` erwartet eine Methode mit der Form `inline def derived[T](using Mirror.Of[T]): TC[T] = ???`, das in Zeile 24 definiert ist. Ich werde nicht im Detail erklären, was der Code tut. In groben Zügen:

  • `inline def` definiert ein Makro
  • `Mirror` ist Teil der Toolbox zur Selbstprüfung von Typen. Es gibt verschiedene Arten von Spiegeln, und Zeile 26 des Codes konzentriert sich auf „Product` spiegelt wider (eine Fallklasse ist ein Produkt). Zeile 27, wenn Programmierer versuchen, etwas abzuleiten, das kein ` istProduct`, der Code lässt sich nicht kompilieren.
  • das `Mirror` enthält andere Typen. Einer von ihnen: „MirrorLabel`, ist eine Zeichenfolge, die den Typnamen enthält. Dieser Wert wird in der Implementierung, Zeile 29, von „ verwendetNamed` TC.

TC-Autoren können Metaprogrammierung verwenden, um Funktionen bereitzustellen, die generisch TC-Instanzen eines bestimmten Typs generieren. Programmierer können eine dedizierte Bibliotheks-API oder die Scala-Ableitungstools verwenden, um Instanzen für ihren Code zu erstellen.

Unabhängig davon, ob Sie generischen oder spezifischen Code zur Implementierung eines TC benötigen, gibt es für jede Situation eine Lösung. 

Zusammenfassung aller Vorteile

  • Es löst das Ausdrucksproblem
    • Neue Typen können bestehendes Verhalten durch traditionelle Merkmalsvererbung implementieren
    • Neue Verhaltensweisen können auf vorhandene Typen implementiert werden
  • Trennung des Anliegens
    • Der Code ist unverfälscht und leicht löschbar. Ein TC trennt Daten und Verhalten, was ein Motto der funktionalen Programmierung ist.
  • Es ist sicher
    • Es ist typsicher, da es nicht auf Selbstbeobachtung beruht. Es vermeidet große Mustervergleiche mit Typen. Wenn Sie solchen Code schreiben, entdecken Sie möglicherweise einen Fall, in dem das TC-Muster perfekt passt.
    • Der implizite Mechanismus ist kompilierbar! Wenn zur Kompilierungszeit eine Instanz fehlt, wird der Code nicht kompiliert. Zur Laufzeit keine Überraschung.
  • Es bringt Ad-hoc-Polymorphismus mit sich
    • Ad-hoc-Polymorphismus fehlt in der traditionellen objektorientierten Programmierung normalerweise.
    • Mit Ad-hoc-Polymorphismus können Entwickler dasselbe Verhalten für verschiedene nicht verwandte Typen implementieren, ohne die herkömmliche Untertypisierung (die den Code koppelt) zu verwenden.
  • Abhängigkeitsinjektion leicht gemacht
    • Eine TC-Instanz kann unter Berücksichtigung des Liskov-Substitutionsprinzips geändert werden. 
    • Wenn eine Komponente von einem TC abhängig ist, kann zu Testzwecken problemlos ein simulierter TC eingefügt werden. 

Gegenanzeigen

Jeder Hammer ist für eine Reihe von Problemen konzipiert.

Typklassen dienen der Behandlung von Verhaltensproblemen und dürfen nicht zur Datenvererbung verwendet werden. Verwenden Sie zu diesem Zweck eine Komposition.

Die übliche Untertypisierung ist einfacher. Wenn Sie über die Codebasis verfügen und keine Erweiterbarkeit anstreben, sind Typklassen möglicherweise übertrieben.

Im Scala-Kern gibt es beispielsweise ein „Numeric` Typklasse:

Scala

trait Numeric[T] extends Ordering[T] {
 def plus(x: T, y: T): T
 def minus(x: T, y: T): T
 def times(x: T, y: T): T

Es ist wirklich sinnvoll, eine solche Typklasse zu verwenden, da sie nicht nur die Wiederverwendung algebraischer Algorithmen für Typen ermöglicht, die in Scala eingebettet sind (Int, BigInt, …), sondern auch für benutzerdefinierte Typen (ein `ComplexNumber` zum Beispiel).

Andererseits wird bei der Implementierung von Scala-Sammlungen meist Subtypisierung anstelle von Typklassen verwendet. Dieses Design ist aus mehreren Gründen sinnvoll:

  • Die Sammlungs-API soll vollständig und stabil sein. Es legt allgemeines Verhalten durch Merkmale offen, die von Implementierungen geerbt werden. Eine hohe Erweiterbarkeit ist hier kein besonderes Ziel.
  • Es muss einfach zu bedienen sein. TC erhöht den mentalen Aufwand für den Endbenutzer-Programmierer.
  • TC kann auch zu einem geringen Leistungsaufwand führen. Dies kann für eine Sammlungs-API von entscheidender Bedeutung sein.
  • Die Sammlungs-API ist jedoch weiterhin durch neue TCs erweiterbar, die in Bibliotheken von Drittanbietern definiert werden.

Zusammenfassung

Wir haben gesehen, dass TC ein einfaches Muster ist, das ein großes Problem löst. Dank der Scala-reichen Syntax kann das TC-Muster auf vielfältige Weise implementiert und verwendet werden. Das TC-Muster steht im Einklang mit dem Paradigma der funktionalen Programmierung und ist ein hervorragendes Werkzeug für eine saubere Architektur. Es gibt kein Allheilmittel und das TC-Muster muss angewendet werden, wenn es passt.

Ich hoffe, Sie haben durch die Lektüre dieses Dokuments Wissen gewonnen. 

Der Code ist verfügbar unter https://github.com/jprudent/type-class-article. Bitte wenden Sie sich an mich, wenn Sie Fragen oder Anmerkungen haben. Wenn Sie möchten, können Sie Issues oder Codekommentare im Repository verwenden.


Jerome PRUDENT

Software IngenieurIn

Zeitstempel:

Mehr von Ledger