Eine Einführung in Typklassen in Scala

  • Teil 1: Dieser Beitrag
  • Teil 2: Typsichere Serialisierung in Scala
  • Teil 3: Typgerechte Fehlerbehandlung mit höherwertigen Typen

Was ist eine Typklasse?

Eines der größten Hindernisse für das Verständnis und die Verwendung von Typklassen in Ihrem eigenen Code ist der Name selbst. Im Gegensatz zu dem, was der Name hervorruft, ist eine Typklasse keine Klasse aus einer objektorientierten Sprache. Laut Wikipedia:

In der Informatik ist eine Typklasse ein Typsystemkonstrukt, das Ad-hoc-Polymorphismus unterstützt. Dies wird durch Hinzufügen von Einschränkungen zu Typvariablen in parametrisch polymorphen Typen erreicht.

Wir müssen auspacken:

  • Was ist Ad-hoc-Polymorphismus? Was auch immer es ist, das ist es, was Typklassen uns helfen werden.
  • Was ist ein ‚parametrisch polymorpher‘ Typ? Was auch immer es ist, so werden wir unsere eigenen Typklassen erstellen.

Typklassen erschienen zuerst in Haskell und sind eine grundlegende Art, Abstraktionen in Haskell aufzubauen. Sie sind so wichtig, dass sie auf der ersten Seite des Learn You a Haskell-Buches vorgestellt werden:

Eine Typklasse ist eine Art Schnittstelle, die ein bestimmtes Verhalten definiert. Wenn ein Typ Teil einer Typklasse ist, bedeutet dies, dass er das von der Typklasse beschriebene Verhalten unterstützt und implementiert.

Ok, es scheint, als würden wir versuchen, ein Verhalten für unsere Typen zu definieren und dieses Verhalten je nach Typ unterschiedlich zu implementieren. Der letzte Teil, „Implementieren Sie das Verhalten je nach Typ unterschiedlich“, ist das „Polymorphismus“ -Bit von früher. Der Begriff beginnt also Sinn zu machen: Es ist eine Klasse für Typen. Eine Klasse, die das von der Typklasse angegebene Verhalten implementiert, wird als Mitglied dieser Typklasse bezeichnet – ‚Typklasse‘.

An dieser Stelle wäre es angebracht zu sagen, dass dies nach Vererbung klingt. Beide ermöglichen es uns, Abstraktionen aufzubauen und Polymorphismus bereitzustellen.

Warum also überhaupt Typklassen erkunden? In vielen Fällen sind sie viel leistungsfähiger und erweiterbarer als eine Vererbungshierarchie. Sie können viel einfacher sein und passen viel besser, wenn Ihre Codebasis bereits FP über OO lehnt. Selbst in einem stark OO-Projekt können Typklassen immer noch den Tag retten.

Definieren einer Typklasse

Das ist genug Einführung — für den Rest des Artikels werden wir eine sehr vertraute Funktionalität untersuchen, die tatsächlich in Java über Vererbung implementiert ist, und sie mit einer Typklasse implementieren: stringification .

Wir alle kennen die Methode, die für alle Objekte in Java (und damit Scala) existiert: .toString . Sein Zweck ist es, eine Zeichenfolgendarstellung des betreffenden Objekts zu erzeugen. Schauen wir uns an, wie wir Typklassen verwenden würden, um dies zu erreichen. Haskells Version von toString heißt Show , also bleiben wir ungefähr bei diesem Namen:

trait Showable {
def show(a: A): String
}

Das war’s: Es gibt unsere Showable Typklasse. Warum wird ein Typparameter A benötigt? Wenn wir uns an unsere erste Definition von Typklassen erinnern, lesen wir das “ erreicht durch Hinzufügen von Einschränkungen zu Typvariablen in parametrisch polymorphen Typen.“ Showable ist unser parametrisch polymorpher Typ und A ist unsere Typvariable, was einfach bedeutet, dass ein Typ (A) als Typparameter verwendet wird. (Beachten Sie, dass wir A gemäß der Definition keine Einschränkungen auferlegt haben, aber das ist in Ordnung. Das ist etwas, was wir tun, wenn wir Typklassen zusammenstellen oder sie verwenden, was später kommen wird.)

Implementieren einer Typklasse

Jetzt müssen wir Implementierungen des Showable Merkmals für alle Typen bereitstellen, die wir anzeigen möchten. Diese Implementierungen werden „Instanzen“ unserer Typklasse genannt, da sie buchstäblich Instanzen des Merkmals Showable . Dies sind meistens anonyme Klassen, die .show überschreiben, und da sie mit einem Boilerplate definiert werden (erinnern Sie sich an ‚Funktionsobjekte‘ in der Java-Programmierung vor Lambda?), definieren wir einen Helfer namens make. Beginnen wir mit einer Implementierung für einen Benutzernamentyp:

object Showable {
def make(showFn: A => String): Showable = new Showable {
override def show(a: A): String = showFn(a)
}
}final case class UserName(first: String, last: String)implicit val defaultUserNameShowable = Showable.make(userName => s"${userName.first} ${userName.last}")val janeName = UserName("Jane", "Doe")

Wie würden wir uns also darauf berufen? Wir könnten die Methode direkt von der showable Instanz aufrufen:

defaultUserNameShowable.show(janeName) // "Jane Doe"

Verbesserte Syntax

Wir haben es geschafft, unsere Showable Implementierung von UserName aufzurufen, aber es war nicht sehr schön. Wir mussten einen Verweis auf unsere Typklasseninstanz haben (defaultUserNameShowable) und ihren Namen kennen. Das ist etwas, was wir abstrahieren wollen. Wenn die Geschichte hier enden würde, wären Typklassen in Scala nicht so nett zu benutzen. Glücklicherweise können wir die Ergonomie mit Impliziten drastisch verbessern. Wir werden das Begleitobjekt von Showable mit einem Aufrufhelfer erweitern:

implicit class ShowSyntax(a: A) extends AnyVal {
def show(implicit showable: Showable): String = showable.show(a)
}
...
janeName.show

Indem wir diese „Erweiterungsmethode“ irgendwo im Gültigkeitsbereich platzieren, können wir einfach so tun, als hätten alle Objekte eine .show -Methode.

Was hat das erreicht

Warum also nicht einfach toString auf UserName überschreiben? Nehmen wir an, UserName lebt in einer Bibliothek, von der wir abhängig sind, und ist bereits kompiliert, wenn wir sie verbrauchen. Wir können es nicht in Unterklassen unterteilen, da es sich um eine final -Klasse handelt. Mit Typklassen können wir jedoch im Wesentlichen neues Verhalten ad hoc „anhängen“. Dies ist das „Ad-hoc“ -Stück der obigen Definition: wir können diesen Polymorphismus getrennt von der Stelle hinzufügen, an der der Typ selbst definiert ist.

Im Gegensatz zur Vererbung müssen Typklassen in Scala nicht ‚kohärent‘ sein, was bedeutet, dass wir mehrere Implementierungen für Showable definieren und zwischen ihnen wählen können. Für einige Typklassen ist dies eine gute Sache, für andere nicht so sehr.

In Haskell sind Typklassen kohärent. In Scala 3 können wir laut Martin Odersky für jede Typklasse auswählen, ob sie kohärent sein soll oder nicht.

Aber lassen Sie uns jetzt eine alternative Bedeutung von show für UserName:

implicit val secretUserNameShowable = Showable.make(_ => "<secret person>")

Alles, was wir jetzt tun müssen, ist sicherzustellen, dass sich jeweils nur eine unserer Showable Instanzen im Gültigkeitsbereich befindet. Und wenn nicht, ist es keine große Sache, wir erhalten zur Kompilierungszeit einen Fehler mit mehrdeutigen impliziten Werten:

janeName.show
> ambiguous implicit values:
both value secretUserNameShowable in object typeclasses of type => Showable
and value defaultUserNameShowable in object typeclasses of type => Showable
match expected type Showable

Ein wichtiger Hinweis ist, dass wir UserName und Showable nicht gekoppelt haben. Alles, was wir getan haben, ist eine Bedeutung von Showable für UserName zu definieren, während sie völlig unabhängig voneinander bleiben. Diese Entkopplung hat enorme Auswirkungen auf die Wartbarkeit und hilft, verwickelte Abhängigkeitsbeziehungen im gesamten Code zu verhindern.

Sicherheit zur Kompilierungszeit

Was passiert, wenn wir versuchen, .show für ein Objekt aufzurufen, für das wir keine Bedeutung von show angegeben haben? Mit anderen Worten, wenn es keine Instanz von Showable für unser Ziel gibt? Wir bekommen einfach einen Compilerfehler:

java.util.UUID.randomUUID().show
> could not find implicit value for parameter showable: Showable

Wir können diese Fehlermeldung tatsächlich anpassen, wenn wir unsere Typklasse definieren:

@scala.annotation.implicitNotFound("No Showable in scope for A = ${A}. Try importing or defining one.")
trait Showable {
...
}
...
janeName.show
> No Showable in scope for A = java.util.UUID. Try importing or defining one.

Wichtige Erkenntnisse

  • Typklassen ermöglichen es uns, eine Reihe von Verhaltensweisen völlig getrennt von den Objekten und Typen zu definieren, die diese Verhaltensweisen implementieren.
  • Typklassen werden in reinem Scala mit Merkmalen ausgedrückt, die Typparameter annehmen, und implizit, um die Syntax sauber zu machen.
  • Typklassen ermöglichen es uns, das Verhalten für Typen und Objekte zu erweitern oder zu implementieren, deren Quelle wir nicht ändern können. (Sie liefern den Ad-hoc-Polymorphismus, der zur Lösung des Expressionsproblems erforderlich ist).

Was als nächstes kommt

Zugegeben, Showable ist eine triviale Typklasse, wenn auch nicht ohne ihre Verwendung. Wir können uns jedoch leicht weitaus nützlichere vorstellen:

trait SafeSerializable {
def serialize(a: A): Array
def deserialize(bytes: Array): A
}
trait Convertible {
def to(a: A): B
def from(b: B): A
}
trait Functor] {
def map(a: F, f: A => B): F
}

SafeSerializable könnte eine großartige Alternative zu Akka Serialization sein, bei der wir bis zur Laufzeit warten, um festzustellen, ob ein Serializer für unseren Typ registriert ist oder nicht. Es ist viel sicherer, Ihre Serialisierungsimplementierung zur Kompilierungszeit implizit zu übergeben!

Convertible könnte eine großartige wiederverwendbare Möglichkeit sein, Daten typübergreifend zu konvertieren. Es gibt eine Bibliothek von Twitter mit dem gleichen Ziel namens Bijection.

Functor ist ein Baustein für einige extrem leistungsfähige Abstraktionen in einem Bereich der funktionalen Programmierung, der als kategoriale Programmierung bezeichnet wird. Bibliotheken wie ScalaZ und Cats basieren auf Typklassen wie Functor . Eine wichtige Idee, die wir dieses Mal nicht besprochen haben, ist, dass Typklassen leicht zusammengesetzt werden können, um aus einfachen Bausteinen wie Functor immer komplexeres Verhalten aufzubauen.

Darüber hinaus gibt es viele wichtige Bibliotheken im Scala-Ökosystem (Play JSON, Slick, etc. al) Verwenden Sie Typklassen und machen Sie sie in vielen Fällen als Haupttreiber ihrer API verfügbar (e.g. Spielen Sie die Format -Typklasse von JSON ab). Das Verständnis von Typklassen selbst ist eine großartige Möglichkeit, effektiver zu nutzen, was es im Scala-Ökosystem gibt, auch wenn Sie nie Ihre eigenen schreiben.

In Zukunft werde ich mir einige sofort praktische Typklassen wie SafeSerializable ansehen. Eine Einführung in die abstrakteren kategorialen Typklassen wie Functor] und Monad] finden Sie in der cats -Bibliotheksdokumentation . Eine ausführlichere Anleitung zur Programmierung in Scala mithilfe von Typklassen finden Sie in der underscore.io hat ein fantastisches Buch zum Thema: Scala mit Cats

Teil 2: Typsichere Serialisierung in Scala

GIST mit Code

Schreibe einen Kommentar

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