Die Selbstbeschreibung in Scalas Dokumentation bringt es auf den Punkt: „Scala ist eine moderne Multi-Paradigmen-Sprache, designt, um übliche Programmierschemata prägnant, elegant und typsicher auszudrücken.“ Das klingt interessant und nützlich, aber auch kompliziert. Tatsächlich steht Scala in dem Ruf, eine sehr flexible, dadurch aber auch schwer zu erlernende Programmiersprache zu sein. Mit Scala 3, auch Dotty genannt, soll das besser werden: Eines der drei großen Ziele des Versionssprunges ist es, den Umgang mit der Sprache leichter und sicherer zu machen.
Außerdem soll Scala auf eine solidere theoretische Grundlage gestellt werden: das DOT-Kalkül, von dem sich auch der Spitzname Dotty ableitet. Als drittes Ziel soll die interne Konsistenz der Sprache verbessert und ihre Ausdrucksstärke dadurch gesteigert werden. Über die Jahre fanden nämlich so einige Features Einzug in Scala 2, die nicht alle miteinander harmonieren. Solche „Warzen“ und Inkonsistenzen entfernt die neue Version und nimmt dabei Kompatibilitätsbrüche zu Scala 2 in Kauf.
Die verfluchte dritte Version
Manchen mag das bekannt vorkommen: Die Python-Entwickler:innen hatten sich auf dem Weg von Version 2 zu 3 Ähnliches auf die Fahnen geschrieben – so sollte die Sprache in sich konsistenter werden – aber mangels guter Migrationstools ging die Umstellung nur sehr schleppend voran. Ähnliche Probleme hatten Perl 6 (a.k.a. Raku) oder PHP 7.
Bei Scala 3 soll es anders laufen, wozu die Entwickler:innen einen mehrgleisigen Ansatz fahren. Zum einen ist der neue Scala-3-Compiler in der Lage, sowohl bereits kompilierten als auch im Quelltext vorliegenden Scala-2-Code zu verarbeiten. Zum anderen benutzt Scala 3 eine neue Zwischensprache namens „TASTy“, die seit Version 2.13.4 auch vom alten Compiler gelesen werden kann. Der Name leitet sich von „typed AST“ ab; es handelt sich um ein Binärformat für bereits geparste und getypte Ausdrücke. Dadurch können auch Scala-2-Programme Bibliotheken nutzen, die mit Dotty kompiliert worden sind.
Implizite Neuerungen
Eine der prominentesten Änderungen in Dotty ist die Aufspaltung der implicits
aus Scala 2. Bislang konnte man mit diesem Schlüsselwort dreierlei ausdrücken: Extension Methods, automatische Typkonvertierungen und besondere Fähigkeiten von Typen. Code, der diese impliziten Definitionen benutzte, war oft schwer zu verstehen (C++ lässt grüßen). Nun sind diese verschiedenen Anwendungsfälle mit neuen Keywords versehen und fein säuberlich getrennt worden.
Unter Typfähigkeiten versteht man die Möglichkeit, bestimmte Operationen mit Typen zu verknüpfen. Üblicherweise nutzt man dafür Schnittstellen, die von Klassen implementiert werden können. In Java heißt dergleichen interface
, Scala bietet traits
mit ähnlichem Einsatzzweck. In vielen Fällen sind Schnittstellen aber zu starr: Schon beim Implementieren der Klasse muss man die Schnittstelle kennen, bereits bestehende Klassen können nicht „nachträglich“ eine Schnittstelle nutzen.
Ein gängiges Beispiel ist die Sortierung einer Liste von Werten eines Typs, wofür man einen Vergleichsoperator auf diesem Typ braucht:
Die Ord
-Schnittstelle deklariert so einen Vergleichsoperator. Sie ist mit einem Typ T
parametrisiert und definiert die Methode compare
, mit der sich zwei Werte des Typs T
vergleichen lassen.
Die Methode sort
wiederum kann beliebige Listen sortieren, solange für den Elementtyp ein Ord
zur Verfügung steht. Damit Listenelemente dafür nicht Ord
implementieren müssen, nimmt die Methode ein passendes Ord
separat entgegen:
Das Schlüsselwort using
bewirkt, dass der Compiler automatisch nach einem passenden Ord
sucht und man es daher nicht übergeben muss. Der Compiler verlangt, dass solche „Kontextparameter“ in eine zweite Parameterliste ausgelagert werden. Bei Bedarf könnte man auch noch weitere using
-Parameter dort deklarieren.
Ruft man nun sort
mit einer Liste von Zahlen auf, wird sich Dotty beschweren, dass es kein passendes Ord
finden kann. Dem kann man abhelfen:
Mit dem given
-Schlüsselwort wird dem Compiler ein (im Beispiel anonymes) Objekt bekannt gemacht, das bei passenden using
-Parametern automatisch eingefügt wird. So weiß der Compiler, dass es ein Ord
für den Typ Int
gibt, obwohl Int
selbst nichts dergleichen implementiert und man keinen Parameter explizit übergeben hat.
Dadurch lassen sich in eigenen Projekten sehr einfach Funktionen und Klassen aus verschiedenen externen Bibliotheken kombinieren. Algorithmen können abstrakt über die Fähigkeiten von Typen geschrieben werden, ohne eine Vererbungshierarchie zwischen diesen Typen zu erfordern, was die Modularität von Programmcode fördert. Wohlgemerkt handelt es sich hierbei nicht um eine komplette Neuschöpfung von Scala; andere Programmiersprachen wie Haskell kennen dieses Konzept ebenfalls. Auch C++ setzt es neuerdings um.
Extension Methods
Sehr schön kombinieren kann man die „givens“ mit Extension Methods. Im obigen Beispiel existiert zwar eine allgemeine Vergleichsmethode, aber es wäre angenehmer, Operatoren wie <
benutzen zu können. Die müssten allerdings wieder vom Ziel-Typ implementiert werden – es sei denn, man stellt sie selbst für den Ziel-Typ zur Verfügung. Scala 3 bietet dafür das Schlüsselwort extension
:
Man kann nun für alle Typen, für die eine Ord
-Instanz zur Verfügung steht, die arithmetische Schreibweise x < y
benutzen. Die Syntax zur Definition von solchen Erweiterungsmethoden mag anfangs seltsam erscheinen, folgt aber einem logischen Prinzip: Zuerst kommt der Parameter, auf den sich die Erweiterung bezieht (hier x
vom Typ T
), dann folgt der Name der Methode (hier der symbolische Operator <
). Zum Schluss kommen alle weiteren Parameter der Methode (hier nur der Parameter y
). Auf diese Weise lassen sich nicht nur symbolische Operationen definieren, sondern auch gewöhnliche Methoden.
Wer Bedenken bezüglich der Performance dieser Indirektion hat, kann die Erweiterungsmethode mit inline
annotieren, sodass der eigentliche Funktionsaufruf vom Compiler wegoptimiert wird. (Das funktioniert auch bei allen anderen Funktionen, aber man sollte es spärlich einsetzen, weil es die Wartung von Code erschweren kann.)
Eine einfache Quicksort-Methode lässt sich nun wie folgt implementieren:
Der Parameter mit dem Typ Ord
wird von dieser Implementierung nicht explizit benutzt (stattdessen kommt die Erweiterungsmethode <
zum Einsatz). Dadurch muss der Parameter nicht mal mehr einen Namen bekommen, Ord
wird als „anonymer Kontextparameter“ übergeben. Die Methode partition
stammt aus der Standardbibliothek von Scala und teilt eine Liste in zwei, indem die übergebene Bedingung für jedes Element geprüft wird. Für ganz Eilige gibt es auch noch eine Kurzschreibweise der Methodensignatur:
In Scala 2 war diese Schreibweise auch möglich, sodass Anwender:innen nichts Neues zu lernen brauchen. Unter der Haube hat sich aber einiges getan, denn die Sprache beschränkt mit given
und using
anstelle von implicit
ihre Flexibilität an dieser Stelle deutlich. Das hält den Quelltext lesbarer und führt seltener zu überraschendem Verhalten des Compilers. Die flexibleren implicit
s haben den Compiler in Scala 2 nämlich gerne in unerwünschte Richtungen geführt, was seltsame und schwer zuzuordnende Typfehler zur Folge hatte.
Automatische Hilfestellung
Falls doch etwas schiefgeht, bietet Scala 3 außerdem bessere Fehlermeldungen. Wenn sich etwa ein given
hinter einem Import versteckt, den man aber vergessen hat, kommt vom Compiler eine Empfehlung:
Auf ähnliche Weise kann der Compiler helfen, wenn in einer Kaskade von given
- und using
-Konstrukten eine bestimmte Instanz nicht gefunden werden konnte. In Scala 2 hat der Compiler nur lapidar beim äußersten Aufruf einen Fehler produziert; Dotty hingegen kann genau diagnostizieren, an welcher Stelle etwas klemmt. Auftreten können solche Probleme zum Beispiel, wenn man eine geschachtelte Liste von Listen sortieren möchte.
Die berühmt-berüchtigten Typkonvertierungen aus Scala 2 sind in Scala 3 immer noch vorhanden, müssen aber jetzt auf eine spezielle Art und Weise – nämlich mit dem Interface Conversion
– deklariert werden. Außerdem warnt der Compiler, wenn Typkonvertierungen nicht explizit erlaubt werden. Programmierer können so leichter den Überblick behalten:
Solche Konvertierungen sorgen für reibungslose Integration mit Java. Das Beispiel wandelt native Scala-Zahlen automatisch in ihre Java-Objekt-Pendants um („Boxing“). Angewendet wird die Conversion immer, wenn eine Methode einen Wert des Zieltyps erwartet (java.lang.Integer
), aber der Quelltyp übergeben wird (Int
). Vernünftig eingesetzt, ermöglichen die Konvertierungen sehr sauberen Code.
Whitespace oder Klammern?
An manchen Stellen legt Scala 3 gegenüber Version 2 sogar an Flexibilität zu. Zum Beispiel lässt sich obiges Programm auf Wunsch auch ganz ohne geschweifte Klammern ausdrücken:
Die Scala-3-Dokumentation nennt diese Syntax „optionale Klammern“. Sie ist zwar standardmäßig aktiviert, aber noch in einer experimentellen Phase. Klammerfreier Quelltext kann übersichtlicher wirken, weil eine vernünftige Einrückung im Regelfall sowieso erwünscht ist. Es lassen sich so auch Fehler vermeiden, die entstehen, wenn sich Klammerung und Einrückung widersprechen. Man kennt diese Argumente von Python, das ebenfalls auf die Klammerung von Blöcken verzichtet, und auch Scala 2 erlaubte mit ähnlichen Argumenten bereits, Semikolons wegzulassen.
Doch an der klammerfreien Syntax scheiden sich die Geister: In der Community wurde kurz nach dem entsprechenden Pull Request hitzig darüber debattiert. Wohin die Reise geht und welche der beiden Varianten sich in der Praxis durchsetzen wird, muss die Zeit zeigen.
Aufzählungen
Scala 3 beseitigt auch einen langjährigen und recht augenfälligen Kritikpunkt der Sprache. Dotty führt das Schlüsselwort enum
als Syntax für Aufzählungen ein, das sich genau wie in Java und zahlreichen anderen Sprachen benutzen lässt:
Aufzählungen können auch parametrisiert sein:
Doch die Ähnlichkeit zu Java ist nur oberflächlich. Scalas enum
s erlauben auch die elegante Definition von komplexen Datentypen:
Mit dieser Deklaration erhält man eine Schnittstelle Shape
mit exakt drei verschiedenen Ausprägungen. Um mit einem solchen Wert zu arbeiten, kann man Pattern Matching benutzen:
Die Namen der Ausprägungen müssen über den Namen der Aufzählung aufgerufen werden (Shape.Line
), um Verwechselungen zwischen verschiedenen Aufzählungen zu vermeiden. Als Dreingabe erhält man auch noch eine Warnung samt Erklärung, wenn man einen Fall vergessen haben sollte:
Ferner liefen …
Neben den genannten Features im Schlaglicht bringt Scala 3 noch zahlreiche weitere Verbesserungen mit: Zum Beispiel können Methoden direkt in eine Datei geschrieben werden, ohne sie in eine Klasse zu verpacken („top-level methods“). Damit kann auch die lästige aus Java gewohnte Zeremonie wegfallen, die main
-Methode in eine separate Klasse zu verpacken. In Scala 3 kann man das einfach so schreiben:
Als logische Fortentwicklung der optionalen geschweiften Klammern wurde die Syntax der Kontrollstrukturen angepasst. Früher mussten die Bedingungen in Verzweigungen eingeklammert werden. In Scala 3 dürfen sie wegfallen, wenn man stattdessen if
s mit then
s, while
s mit do
s und so weiter kombiniert:
Für Freund:innen der gepflegten Objektorientierung wurde in Scala 3 das Schlüsselwort open
eingeführt, welches indiziert, dass eine Klasse oder eine Methode zur Vererbung freigegeben ist. In Scala 3.1 wird open
zur Pflicht: Klassen und Methoden sind dann standardmäßig final
, können also nicht überschrieben werden.
Auch im Typsystem hat sich einiges getan. Nur ein Beispiel sind „Union Types“, die von TypeScript übernommen wurden:
2D legt fest, dass nur Circle
und Rect
gültige Werte sind; alle anderen Fälle von Shape
werden dann abgelehnt. Union Types sind nicht auf Ausprägungen eines enum
s beschränkt, sondern lassen sich mit beliebigen Typen definieren:
Scala 3 kann außerdem bestimmte Ausdrücke, die bereits zur Compile-Zeit bekannt sind, komplett wegoptimieren; inklusive weiterer Spielereien im Typsystem, die hier aber den Rahmen sprengen würden.
Eine vollständige Liste der Änderungen am Typsystem und aller anderen Neuerungen von Scala 3 bietet die Dotty-Referenz.
Dem Rotstift zum Opfer gefallen
Um Scala 3 überschaubar zu halten – gerade angesichts der zahlreichen Neuerungen –, wurden aber auch diverse Scala-2-Features mehr oder weniger ersatzlos gestrichen. Dazu gehören XML- und Symbol-Literale oder auch eine prozedurale Syntax für Methoden. Makros, die sich in Scala 2 großer Beliebtheit erfreuen, wurden ebenfalls gestrichen und durch eine komplette Neuentwicklung ersetzt. Viele Bibliotheken müssen deswegen ihre Makros neu entwickeln. Hauptgrund dafür war, dass die bisherige Metaprogrammierung sehr fragil und damit schwer wartbar war. Bei der neuen Variante haben die Entwickler der Sprache dazugelernt, sie auf solidere Füße gestellt und dafür den Preis der Inkompatibilität gezahlt.
Die Scala-Gemeinschaft kennt diese Art von Übergängen bereits von Versionen der 2.x-Reihe und ist schon seit einiger Zeit fleißig dabei, die gängigen Bibliotheken für Scala 2 und 3 parallel bereitzustellen. Für viele Scala-Nutzer wird sich aber dank TASTy nicht viel ändern, denn damit wird Auf- und Abwärtskompatibilität zwischen den beiden Sprachversionen sichergestellt; zumindest für Features, bei denen das möglich ist.