Eins für zwei - Scala 3 Enums
Nach 8 Jahren, 28000 Commits und 7400 Pull-Requests war es am 14. Mai 2021 endlich so weit: Scala 3 wurde veröffentlicht. Neben dem neuen Compiler „Dotty“ haben es eine neue Syntax sowie einige Neuerungen an der Sprache in Scala 3 geschafft. In diesem Blogpost der Serie über interessante Neuerungen werden wir Enums genauer unter die Lupe nehmen. Diese sind nicht nur klassische Enums, um Wertemengen aufzuzählen. Tatsächlich werden sie verwendet, um die in Scala 2 ermüdende Definition von algebraischen Datentypen eleganter zu gestalten.
- Scala 3: Scala im neuen Gewand
- Scala 3: Explizite Implicits
- Scala 3: Über Vereinigungen und Schnittmengen
- Scala 3: Typ-Lambdas
Voraussetzungen
Zwar werden Neuerungen und Änderungen in der Sprache von Grund auf erläutert und Vergleiche zu Scala 2 ausführlich erklärt, dennoch unterstützt die Kenntnis von Scala 2 das Verständnis dieses Blogposts. Im ersten Blogpost dieser Reihe wurde in die Scala 3 neue einrückungsbasierte Syntax vorgestellt. Da diese in diesem Post verwendet wird, ist es hilfreich, diesen Artikel gelesen zu haben.
Von Enums und Aufzählungen
Enums werden in Scala verwendet, um einen Typ zu definieren, der aus einer Menge von benannten Werten besteht. Man zählt diese Werte in der Definition auf (engl. enumerate).
Im vorherigen Blogpost haben wir Flaschen gefüllt. Die Flüssigkeit wurde durch einen String beschrieben. Dies wollen wir jetzt ändern und modellieren den Sachverhalt durch Scala 3-Enums. Dabei vergleichen wir Scala 2- mit Scala 3-Code:
Die Scala 3-Version des Codes unterscheidet sich in der Definition des Enums
deutlich von der in Scala 2. Während die Definition eines Enums in
Scala 2 über Vererbung an ein Objekt passiert, ist die Definition in
Scala 3 in die Sprache eingebaut. Das Schlüsselwort enum
ist in Scala 3 neu
hinzugekommen.
Die Implementierungen der Hilfsfunktionalität (isJuice
und juices
)
unterscheiden sich in beiden Fällen, abgesehen von der Syntax, nicht
signifikant. Dennoch gibt es ein paar
nicht sofort ersichtliche Unterschiede der Definitionen. Während
Liquid.AppleJuice
in Scala 2 vom Typ Liquid.Value
ist, ist es in der Scala
3-Variante vom Typ Liquid
. Das enum
-Schlüsselwort erzeugt also
einen neuen Typ, die aufgezählten Werte sind von eben diesem. Dass sich hier
unter der Haube einiges geändert hat, ist gut, denn Enums in Scala 2 waren
misslungen.
Matchen wir auf die Werte eines Enums in Scala 3, vergessen dabei jedoch einen aus der Aufzählung, bekommen wir vom Compiler eine Warnung, dass das Matching nicht erschöpfend sei. Diese Warnung wird beim Scala 2-Code nicht ausgegeben. Das erinnert den aufmerksamen Scala-Enthusiasten an etwas, oder etwa nicht?
Vom Enum zum Summentyp
Das Keyword enum
wird in Scala 3 außerdem verwendet, um Summentyp zu
definieren. Die entstehenden Typen können dabei selbst zusammengesetzte
Datentypen sein. Beispielsweise können wir die Flüssigkeiten auch so
beschreiben:
Juice (zu Deutsch: Saft) ist nun ein Typ, dessen Konstruktor selbst ein
Argument entgegennimmt, nämlich eine Frucht. In Scala 2 werden solche
Summentypen bisher basierend auf sogenannten sealed traits
definiert:
Dieser Code ist nicht sonderlich deklarativ. Die Erweiterung des Traits mit
Product with Serializable
ist in Scala 2 nötig, damit der Compiler die Typen
sauber
inferieren kann.
Damit bietet uns enum
aus Scala 3 eine deutlich kürzere und sprechendere
Definitionsmöglichkeit von Summentypen, deren Resultat genauso funktioniert wie
aus Scala 2 gewohnt, einschließlich erschöpfendem Matching.
Parameter, Typparameter & Vererbung
Enums selbst können Parameter und Typparameter entgegennehmen, um beispielsweise Methoden auf allen Werten des Enums oder Summentyps zu definieren:
In diesem Codebeispiel wurde eine Methode auf einem parametrisierten Enum
implementiert, die an die Kinder vererbt wird. Dabei können sogar Typparameter
gesetzt und inferiert werden. Der Zuckergehalt einer Frucht kann nun mit
FruitWithSugar.Orange.sugar
abgerufen werden.
Enum oder Summentyp oder beides?
Im ersten Beispiel zu den Scala 3-Enums haben wir alle Säfte aus dem Enum
Liquid
anhand von Liquid.values.filter(isJuice)
gefiltert. Ein Enum hat eine
Funktion values
, die alle zum Enum zugehörigen Werte zurückgibt. Rufen wir
diese Funktion jedoch auf dem Liquid
-Enum aus dem Abschnitt Vom Enum zum
Summentyp auf, bekommen wir folgende Fehlermeldung:
1 |Liquid.values
|^^^^^^^^^^^^^
|value values is not a member of object Liquid.
|Although class Liquid is an enum, it has non-singleton cases,
|meaning a values array is not defined
Diese Version von Liquid
beinhaltet einen zusammengesetzten Typen, nämlich
Juice
, dessen Werte unter Zuhilfename einer Frucht konstruiert werden.
Die vorgesehenen Enum-Funktionen wie values
aber auch valueOf
funktionieren nun nicht mehr.
Im Hintergrund wird beim Kompilieren der enum
-Anweisung Scala-Code erzeugt.
Diesen Vorgang nennt man auch Desugaring.
Folgendes passiert:
- Für das Enum wird in jedem Fall ein Companion-Objekt angelegt.
- Fälle mit Parametern werden in Klassendefinitionen übersetzt.
- Fälle ohne Parameter, aber mit
extend
-Anweisung, werden in Instanzen der durch die Enum-Definition erzeugten Elternklasse alsval
gebunden. - Für klassische Enum-Werte ohne Parameter und ohne
extend
werden Werte analog zur Scala 2-Version gebunden.
Die Regeln sind kompliziert und können hier nachgelesen werden.
Haben wir also keinen wirklichen Enum mehr, sobald zusammengesetzte Daten in
enum
definiert werden?
Darüber lässt sich sicher streiten. Jedoch ist es seltsam, dass hier für die
Definition zweier unterschiedlicher Konzepte dasselbe Schlüsselwort verwendet
wird. Die Konzepte ähneln sich zwar, doch eine Trennung wäre hier schön
gewesen. Das wird besonders deutlich, wenn wir die komplizierten Regeln im
verlinkten Issue
betrachten. Die Methode value
kann beispielsweise bei zusammengesetzten
Kindern im Enum nicht mehr funktionieren, da hier kein Wert gebunden,
sondern eine Klassendefinition erzeugt wird.
Fazit
Enums in Scala 3 gehen in die richtige Richtung: Die ermüdende Definition von
Summentypen wird einfacher und verständlicher. Eine kurze und sprechende
Definition, wie sie bereits in anderen Sprachen wie Rust, Haskell oder F#
vorhanden war, erhält nun endlich auch Einzug in Scala.
Dass dabei mithilfe von enum
klassische Enums, aber auch Summentypen definiert
werden können, die jeweils unterschiedliche Funktionalität bereitstellen, ist
zwar unschön, aber kein Showstopper. Dennoch sollte man sich die
Regeln zum Desugaring
ansehen, um nachvollziehen zu können, was im Hintergrund passiert.
An anderer Stelle, etwa bei den implicits
, macht es Scala 3 hingegen
richtig und trennt nun, was semantisch unterschiedlich sein sollte. Dazu dann
mehr im nächsten Blogpost.