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 Com­piler 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:

trait Ord[T] {
  def compare(x: T, y: T): Int
}

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:

def sort[T](list: List[T])(using ord: Ord[T]): List[T] =
  // ...

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:

given Ord[Int] with {
  def compare(x: Int, y: Int) = x - y
}

sort(List(3, 1, 2)) // Ord-Instanz muss nicht übergeben werden.

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 Ver­erbungshierarchie 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:

trait Ord[T] {
  def compare(x: T, y: T): Int

  extension (x: T)
    def < (y: T) = compare(x, y) < 0
}

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:

def sort[T](list: List[T])(using Ord[T]): List[T] =
  list match {
    case Nil => Nil
    case pivot :: rest =>
      val (smaller, bigger) = rest.partition(e => e < pivot)
      sort(smaller) ++ List(pivot) ++ sort(bigger)
  }

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:

def sort[T : Ord](l: List[T]): List[T]

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 implicits 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:

The following import might fix the problem:

  import Givens.ordInt

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:

import scala.language.implicitConversions
given Conversion[Int, java.lang.Integer] =
  java.lang.Integer.valueOf(_)

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:

trait Ord[T]:
  def compare(x: T, y: T): Int
  extension (x: T) def < (y: T) = compare(x, y) < 0

given Ord[Int] with
  def compare(x: Int, y: Int) = x - y

def sort[T : Ord](list: List[T]): List[T] =
  list match
    case Nil => Nil
    case pivot :: rest =>
      val (smaller, bigger) = rest.partition(elem => elem < pivot)
      sort(smaller) ++ List(pivot) ++ sort(bigger)

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:

enum Color:
  case Red, Green, Blue

Aufzählungen können auch parametrisiert sein:

enum Color(val rgb: Int):
  case Red   extends Color(0xFF0000)
  case Green extends Color(0x00FF00)
  case Blue  extends Color(0x0000FF)

Doch die Ähnlichkeit zu Java ist nur oberflächlich. Scalas enums erlauben auch die elegante Definition von komplexen Datentypen:

enum Shape:
  case Line(length: Int)
  case Rect(width: Int, height: Int)
  case Circle(radius: Int)

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:

def scale(shape: Shape, factor: Int) =
  shape match
    case Shape.Line(length) =>
      Shape.Line(length * factor)
    // ...

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:

match may not be exhaustive.

It would fail on pattern case:
Shape.Rect(_, _), Shape.Circle(_)

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:

@main def hello = println("Hallo!")

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 ifs mit thens, whiles mit dos und so weiter kombiniert:

if x < 0 then
   "negative"
else if x == 0 then
   "zero"
else
   "positive"

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:

type 2D = Shape.Circle | Shape.Rect
val el1: 2D = Shape.Circle(2) // ok
val el2: 2D = Shape.Line(1) // not ok

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 enums beschränkt, sondern lassen sich mit beliebigen Typen definieren:

type DecimalNumber = Float | Double

Scala 3 kann außerdem bestimmte Ausdrücke, die bereits zur Compile-Zeit bekannt sind, komplett wegoptimieren; inklusive weiterer Spielereien im Typ­system, 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.

Fazit

Scala 3 ist tatsächlich Scala, wie es immer sein sollte. Geschickt wurden die besten Features aus Scala 2 beibehalten, die komplizierten entfernt und andere mit etwas schönerer Syntax garniert. Die zahlreichen Verbesserungen haben bereits viel Aufmerksamkeit auf sich gezogen, auch in der weiteren Programmierer:innen-Community. Das ist kein Wunder, denn zum Beispiel bei den Extension Methods hat man sich etwa Kotlin und C# angenähert. Die haben sich allerdings vorher ihrerseits von Scala 2 inspirieren lassen.

Wer bisher vor Scala zurückgeschreckt ist, sollte unbedingt einen zweiten Blick wagen. Der Compiler ist hilfreich und nimmt einiges an Arbeit ab, ohne zu bevormunden. Zum Zeitpunkt der Veröffentlichung befand sich Scala 3 kurz vor der „Release Candidate“-Phase. Ein Release der Version 3.0 wird Mitte 2021 erwartet. Experimentieren kann man mit Scala 3 aber schon seit geraumer Zeit, sogar im Browser lässt sich die Sprache ausprobieren.

TAGS

Kommentare