Scala 3: Über Vereinigungen und Schnittmengen
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 über Union- und Intersection-Types sprechen. Zwar existierte für Intersection-Types bereits ein eingeschränkter Mechanismus, doch Union-Types sind gänzlich neu in Scala 3. Wie die neuen Typen verwendet werden können, welche Möglichkeiten diese bieten und wie sie sich zu aus Scala 2 bekannten Typen abgrenzen, wird in diesem Blogpost erörtert.
- Scala 3: Scala im neuen Gewand
- Eins für zwei - Scala 3 Enums
- Scala 3: Explizite Implicits
- Scala 3: Typ-Lambdas
Union-Types
Einer der zwei Typklassen, die in diesem Blogpost beleuchtet werden
sollen, sind Union-Types oder auch Vereinigungen. Es wird anhand des
|
-Operators ein neuer Typ definiert, der die Vereinigung mehrerer
Typen repräsentiert:
object UnionTypes:
type StringOrInt = String | Int
Ein Union-Type wird durch eine Aufzählung von zwei oder mehreren
Typen, getrennt durch einen Trennstrich, definiert. Der Typ
StringOrInt
ist entweder ein Int
oder ein String
. Ein Wert von
diesem Typ kann mögliche Werte aus der Menge aller Strings oder der
Menge aller Integer annehmen.
Definiert werden Werte vom Typ StringOrInt
wie folgt:
val a : StringOrInt = "a"
val b : StringOrInt = 3
Der Typ von a
und b
muss hier explizit als StringOrInt
angegeben
werden, da der Compiler sonst dessen Subtypen String
oder Int
inferiert. Der Scala 3 Compiler erlaubt es, auf Union-Types
erschöpfend zu matchen. Dabei wird, wie bei algebraischen Summentypen,
eine Compilerwarnung ausgegeben, wenn nicht alle Fälle abgedeckt
wurden:
def foo(x : StringOrInt) =
x match
case _ : String => "Hello " + x
In diesem Beispiel würde gewarnt, dass non-exhaustive gematcht wird und
angezeigt, dass der Typ Int
dabei nicht abgedeckt ist.
Union-Types vereinigen also Mengen von möglichen Werten, die von den
Subtypen angenommen werden können. Im folgenden Beispiel ist es daher
bei einem vorliegenden Wert auf Typeebene unmöglich zu unterscheiden,
ob ein Benutzername oder eine ID gemeint ist. Da beide den Subtyp String
haben. Der resultierende Type von UsernameOrId
ist daher auch nur String
:
object UnionTypes:
type Username = String
type Id = String
type UsernameOrId = Username | Id
In diesem Fall sollten sogenannte tagged unions oder auch Summentypen verwenden werden:
object SumTypes:
enum UsernameOrId:
case Username(name: String)
case Id(id: String)
Der Nachteil hierbei ist, dass sich dieser Typ im Nachhinein nicht flach erweitern lässt. Wollen wir noch die E-Mail-Adresse als möglichen Wert an anderer Stelle hinzufügen, muss ein neuer Summentyp implementiert werden, der entweder Obiges dupliziert, oder aber schachtelt:
object OtherSumTypes:
enum UsernameOrIdOrEmail:
case EMail(email: ???)
case UsernameOrId(usernameOrId: UsernameOrId)
Das sieht doch wirklich nicht schön aus!
Mit Union-Types ist die flache Erweiterung hingegen kein Problem:
object OtherUnionTypes:
type EMail = ???
type UsernameOrIdOrEmail = UnionTypes.UsernameOrId | EMail
Union-Types stellen sich also als wesentlich flexibler heraus. So können beispielsweise auch in Funktionsdefinitionen die Eingabeparameter ad hoc einfach um zusätzliche Typen erweitert werden:
def foo(x : UnionTypes.UsernameOrId | EMail) = ???
Union-Types sind kommutativ, das bedeutet, dass A | B
den gleichen
Typ beschreibt, wie B | A
. Zudem ist der Operator assoziativ: A |
(B | C)
ist äquivalent zu (A | B) | C
.
Intersection-Types
Intersection-Types stellen die Schnittmenge von Typen dar. Das bedeutet, dass wir einen neuen Typ definieren können, dessen mögliche Werte nur die Werte der Schnittmenge von Werten anderer Typen sind. Sinn ergibt das vor allem in Verbindung mit Traits und Mixins:
trait Editable:
def edit : Unit
trait Deletable:
def delete : Unit
object IntersectionTypes:
def foo(entity : Editable & Deletable) : Unit =
entity.edit
entity.delete
Wir implementieren eine Funktion foo
, die den Typ des
Eingabeparameters entity
derart definiert, dass dieser Editable
und Deletable
ist. Dazu wird der neue &
-Operator
verwendet. Ein Objekt mit dem gewünschten Typ kann zum Beispiel durch
case object SomeEntity extends Editable with Deletable
definiert werden.
Wie Union-Types sind Intersection-Types assoziativ und
kommutativ. Intersection-Types gab es in Scala 2 bereits, ausgedrückt
durch den with
-Operator.
Resümee
Union- und Intersection-Types sind willkommene Erweiterungen in Scala 3. Eine sprechende Syntax und klar definierte Specs erlauben eine intuitive Verwendung und bringen mehr Flexibilität in die Sprache. Während es in Scala 2 bereits so etwas wie Intersection-Types gab, freuen wir uns darauf, die neuen Möglichkeiten, die Union-Types bieten in verschiedenen Projekten auszuschöpfen.
Im nächsten Blogpost dieser Reihe werden wir eine weitere spannende Neuerung von Scala 3 besprechen: Type Lambdas.