Doch es gibt nicht nur Verbesserungen in den Eingeweiden des Compilers. Die aktuell in der Release-Candidate-Phase befindliche Vorschau auf Scala 3 verspricht, das Programmiererlebnis deutlich zu verbessern.

In diesem Artikel möchte ich die völlig willkürlich ausgewählten Top 5 der Neuerungen vorstellen. Diese und alle weiteren Änderungen sind ausführlich auf der Dotty-Webseite dokumentiert.

Platz 5: Opake Typen

Wer kennt das Problem nicht? Man muss im Code physikalische Größen verwalten – zum Beispiel Zeiten – und verwechselt die Einheiten. Durch welche Zehnerpotenz muss man System.nanoTime() dividieren, um auf Sekunden zu kommen? Typischerweise sind Größen im Code als Int oder Double repräsentiert, so dass der Compiler kaum Hilfestellung geben kann, wenn man sich verrechnet. Dieses Problem ist in der Praxis derart häufig, dass sie teilweise sogar in rigoros analysiertem Code nicht aufgespürt werden.

In vielen funktionalen Programmiersprachen steht eine elegante Lösung dafür zur Verfügung: man definiert kurzerhand einen „Wrapper“-Typen, der selbst zwar auch nur einen Double enthält, aber explizite Konvertierungsmethoden anbietet.

In Scala 2 kann das zum Beispiel so aussehen:

object Time {
  case class Milliseconds(num: Int)

  case class Seconds(num: Int) {
    def toMillis: Milliseconds = Milliseconds(num * 1000)
  }
}

So ähnlich wurde die Modellierung von Zeiträumen auch in der Standardbibliothek für Timeouts von asynchronen Operationen umgesetzt.

Während Scala hier die Vorteile ihrer konzisen Syntax gegenüber Java voll ausspielen kann, ist diese Art der Programmierung der Performance oft nicht zuträglich. Denn müssen viele solcher Objekte erzeugt werden, wird ständig geboxt: ein Array[Seconds] hat benötigt viel mehr Speicher als ein Array[Int], denn bekanntlich macht Kleinvieh auch Mist.

Scala 2 hat sogenannte „Value Classes“ eingeführt, die aber das Boxing-Problem in bestimmten Situationen nicht lösen konnten.

Hier setzt jetzt Scala 3 mit den sogenannten „Opaque Type Aliases“ an. Innerhalb eines Objekts lassen sich mehrere Synonyme definieren, einschließlich Methoden zur sicheren Konvertierung:

object Time {
  opaque type Milliseconds = Int
  opaque type Seconds = Int

  def seconds(num: Int): Seconds = num

  extension (num: Seconds) def toMillis: Milliseconds =
    num * 1000
}

Das besondere hieran ist, dass die Identität Milliseconds = Int nur innerhalb des Objekt-Scopes gegeben ist, so dass man die Konvertierungsmethoden direkt auf Int implementieren kann.

Außerhalb des Scopes kann man aber nur auf die vorgegebenen Methoden zurückgreifen. Im Gegensatz zu den alten Value Classes sind folglich die Implementierungsdetails standardmäßig versteckt. So ist es im obigen Beispiel nicht möglich, ein Milliseconds-Wert ohne Umweg über Seconds zu konstruieren. Stattdessen geht man über Sekunden:

scala> Time.seconds(10).toMillis
val res0: Time.Milliseconds = 10000

Dabei sind die Opaque Type Aliases nicht bloß syntaktischer Zucker; sondern der Compiler garantiert auch, dass sie zur Laufzeit nicht existieren. Es wird also weder das JAR-File noch der Heap aufgebläht, wenn man großzügig Aliase benutzt.

Besonders interessant sind die Aliase also für Domain-driven Design, denn damit lassen sich fachliche Typen wie Adressen, Postleitzahlen oder Namen so modellieren, dass eine Verwechslung von vornherein ausgeschlossen wird.

Platz 4: Aufgeräumte implicits

Der Codeschnipsel vom vorherigen Platz enthält übrigens noch ein weiteres interessantes Feature von Scala 3: die „Extension Methods“. Diese sind eine der beiden Resultate des Frühjahrsputzes bei den Implicits.

Wollte man früher einer Klasse eine zusätzliche Methode spendieren, so musste man den Umweg über eine separate Klasse und einer zusätzlichen impliziten Konvertierung gehen:

case class Circle(x: Double, y: Double, radius: Double)

object CircleExtras {
  class CircleOps(c: Circle) {
    def circumference: Double = c.radius * math.Pi * 2
  }

  implicit def toCircleOps(c: Circle): CircleOps =
    new CircleOps(c)
}

Diese lästige Zeremonie wurde zuletzt durch die Einführung von Implicit Classes deutlich vereinfacht:

object CircleExtras {
  implicit class CircleOps(c: Circle) {
    def circumference: Double = c.radius * math.Pi * 2
  }
}

Scala 3 setzt auch hier die Axt an und beseitigt den Zwang zur eigentlich überflüssigen Klasse CircleOps:

extension (c: Circle) def circumference: Double =
  c.radius * math.Pi * 2

Man beachte, dass diese Methode direkt im Top Level einer Scala-Datei auftauchen kann und nicht in ein Objekt geschachtelt sein muss. Für Scala-Erfahrene wirkt die Syntax zunächst etwas befremdlich, aber erfahrungsgemäß gewöhnt man sich schnell daran, zumal sie auch mit der neuen Whitespace-Syntax zusammenspielt (nächster Platz).

Eine weitere Neuerung setzt bei den berühmt-berüchtigten Implicit Arguments an. Viele Bibliotheken wie Cats nutzen diese, um existierenden Typen neue Fähigkeiten zu verleihen. Betrachten wir zum Beispiel eine combine-Operation, die Bestandteil der Typklasse Semigroup ist:

trait Semigroup[T] {
  def combine(x: T, y: T): T

  def combineAll(xs: List[T]): T =
    xs.reduceLeft(combine)
}

Mit dieser praktischen Typklasse lässt sich die „Summe“ der Elemente einer Liste auch für andere Typen als Zahlen berechnen. Dazu muss man zunächst das Semigroup-Trait für existierende Typen implementieren:

given Semigroup[String] with {
  def combine(x: String, y: String): String =
    x + y
}

Auch diese Deklaration kann man direkt in einer Datei hinschreiben, ohne sie in ein Objekt zu kapseln. Nun kann man z.B. Listen von Strings summieren:

scala> summon[Semigroup[String]].combineAll(List("a", "b"))
val res0: String = ab

Weitere Typen, zum Beispiel fachliche Klassen, lassen sich mit einer weiteren given-Deklaration hinzufügen. Allerdings ist der Aufruf noch etwas zu umständlich. In Kombination mit den Extension Methods lässt sich das noch zusammendampfen:

trait Semigroup[T] {
  def combine(x: T, y: T): T

  extension (xs: List[T]) def combineAll: T =
    xs.reduceLeft(combine)
}

Jetzt geht es kompakter:

scala> List("a", "b").combineAll
val res0: String = ab

Durch das Zusammenspiel dieser zwei Features erreicht Scala 3 eine enorm hohe Ausdrucksstärke, die aber weiterhin beherrschbar bleibt.

Platz 3: Optionale Klammern

Wer bei der Version „Scala 3“ direkt an „Python 3“ denkt, liegt damit gar nicht falsch. Denn nicht nur gibt es einige Änderungen, die nicht rückwärts-kompatibel sind, sondern es wurde auch eine alternative Syntax basierend auf Einrückungen eingeführt.

Das obige Beispiel lässt sich damit wie folgt umschreiben:

trait Semigroup[T]:
  def combine(x: T, y: T): T

  extension (xs: List[T])
    def combineAll: T =
      xs.reduceLeft(combine)

given Semigroup[String] with
  def combine(x: String, y: String): String =
    x + y

Der Compiler rekonstruiert anhand der Einrückung die Blockstruktur des Quelltextes. Es ist allerdings weiterhin möglich, geschweifte Klammern hierfür zu benutzen. Das Risiko, dass beide Stile in einer Codebasis gemischt werden und dadurch Chaos herrscht, besteht durchaus. Im Zuge dessen hat das Dotty-Team die Möglichkeit vorgesehen, den Code automatisch vom Compiler automatisch umschreiben zu lassen. Dazu braucht man lediglich die Optionen -rewrite -indent zu übergeben.

Doch nicht nur die geschweiften Klammern lassen sich einsparen, auch bei einigen runden Klammern wurde der Rotstift angesetzt. Statt wie bisher bei Kontrollstrukturen die Bedingungen einzuklammern, kann man diese in Scala 3 wie folgt schreiben:

def fact(n: Int): Int =
  if n <= 0 then
    1
  else
    n * fact(n - 1)

Diese neue Syntax für Kontrollstrukturen lag bereits seit 2011 in der Schublade und stieß damals auf wenig Gegenliebe. Im Zuge der Entwicklung von Dotty wurde sie dann neu aufgelegt.

Gemäß des Wadler’schen Gesetzes über das Design von Programmiersprachen wurden diese syntaktischen Änderungen von Scala 3 am heißesten diskutiert. Meiner subjektiven Meinung nach gewöhnt man sich schnell daran und ich persönlich möchte sie nicht mehr missen.

Platz 2: Metaprogrammierung

Beim Begriff „Metaprogrammierung“ werden viele aufhorchen. Gibt es das überhaupt noch? Möchte man das überhaupt noch? Tatsächlich erfreuen sich Makros in Scala großer Beliebtheit, wobei man fairerweise noch dazu sagen sollte, dass es sich dabei um Compile-Time-Metaprogrammierung handelt. Das heißt, Makros werden entweder als Annotation oder als „gewöhnlicher“ Funktionsaufruf getarnt, aber direkt vom Compiler verarbeitet, so dass zur Laufzeit keine Rückstände der Metaprogrammierung übrig bleiben.

Seit Scala in der Version 2.10 die Makros eingeführt hat, kam es zu einer regelrechten kambrischen Explosion der Anwendungsfälle. Ein Dauerbrenner in modernen Applikationen ist die Serialisierung von Objekten nach JSON. Gängige Java-Frameworks arbeiten hier mit Reflection zur Laufzeit, was äußerst fehleranfällig ist. In Scala benutzt man stattdessen eine Technik, die unter dem Begriff „Derivation“ bekannt geworden ist. Beispielhaft kann man mit der circe-Bibliothek folgenden Code schreiben:

import io.circe._
import io.circe.generic.semiauto._

case class Account(holder: String, balance: Int)

object Account {
  implicit val accountEncoder: Encoder[Account] =
    deriveEncoder
}

Die Funktion deriveEncoder ist als Makro implementiert. Sofern die JSON-Kodierung nicht möglich ist (weil z.B. die Kodierung eines Unterobjekts nicht bekannt ist), bricht der Compiler mit einer Fehlermeldung ab.

Unglücklicherweise fordert die Implementierung dieses Makros einiges an Expertinnenwissen ab. Dotty vereinfacht diese Konstruktion deutlich; es ist nunmehr nur noch Code nötig, der „weiß“, wie man die Encoder der jeweiligen Unterobjekte zusammensteckt, um einen Encoder für das ganze Objekt zu erhalten.

Aber auch weniger spezifische Anwendungsfälle sind einfacher geworden. Dank verallgemeinerter inline-Definitionen kann man z.B. die obige Fakultätsfunktion direkt vom Compiler ausrechnen lassen:

inline def fact(inline n: Int): Int =
  if n <= 0 then
    1
  else
    n * fact(n - 1)

Doch Obacht: naive Implementierungen wie diese können die Compile-Zeiten drastisch erhöhen. Man sollte sie also sparsam einsetzen.

Platz 1: Enums

Lange wurde Scala dafür gescholten, dass eines der grundlegendsten Features von Java nicht angeboten wird: die Enumerations. Will man in Scala 2 z.B. eine Aufzählung für Farben definieren, hat man prinzipiell drei Möglichkeiten.

Zunächst gibt es eine eingebaute Enumeration-Klasse in der Standardbibliothek:

object Colour extends Enumeration {
  val Cyan, Magenta, Red = Value
}

Dummerweise sind solche Aufzählungen nicht gut gegen nachträgliche Erweiterung geschützt. Außerdem hilft einem der Compiler nicht, wenn man in einem Match-Ausdruck Fälle vergessen hat:

c match {
  case Colour.Cyan => 1
  case Colour.Magenta => 2
  // keine Warnung dass Red fehlt!
}

Wegen dieser Einschränkungen wurde diese Klasse nur selten eingesetzt.

Alternativ kann man sich mit einer Struktur aus einem sealed trait und case objects behelfen:

sealed trait Colour
object Colour {
  case object Cyan extends Colour
  case object Magenta extends Colour
  case object Red extends Colour
}

Diese Darstellung erfüllt die gewünschten Eigenschaften, ist aber geschwätzig.

Als dritte Möglichkeit kann man zusätzlich eine externe Bibliothek nutzen, die zwar den Boilerplate-Code nicht reduziert, aber noch einige nützliche Helferlein bietet, zum Beispiel die Methode withName, die für einen String das passende Aufzählungsobjekt mit diesem Namen zurückliefert.

Scala 3 räumt mit diesem Wildwuchs auf und bietet eine standardisierte, kompakte Notation an:

enum Colour:
   case Cyan, Magenta, Red

Praktischerweise ist diese Notation äquivalent zum obigen Code-Schnipsel mit sealed trait und case object und bietet daher die gleichen Eigenschaften an, wie z.B. die sichere Verwendung in einem Match-Ausdruck. Die praktischen Helferlein gibt es als Dreingabe vom Scala-Compiler, ohne dass man dafür weitere Bibliotheken bräuchte.

Man kann sich jetzt zurecht fragen, warum dieses „harmlose“ Feature in meiner Liste auf dem ersten Platz ist. Das liegt daran, dass – wie so oft in Scala – eine Vorlage aus Java gnadenlos aufgebohrt worden ist. Betrachten wir zum Beispiel das Datenmodell für eine Zeichenapplikation, bei der wir verschiedene Pinseltypen benutzen möchten:

enum BrushType:
    case Pencil(colour: Colour)
    case Ink(colour: Colour, tilt: Float)
    case Smudge(force: Int)
    case Eraser

case class Brush(size: Int, brushType: BrushType)

Jeder Fall kann ein oder mehrere Attribute besitzen. Das gemeinsame Attribut (Größe des Pinsels) kann wie hier in eine separate Klasse ausgelagert werden, oder alternativ in jedem Einzelfall gelistet sein.

Gerade diese praktische Schreibweise wurde von der Scala-Community seit langer Zeit erwartet und wird meiner Einschätzung nach als erstes in den Codebasen Einzug halten.

Conclusion

Scala war schon immer eine Programmiersprache für diejenigen, die der JVM treu bleiben, aber dennoch funktional programmieren möchten. Doch die Konkurrenz hat nicht geschlafen; sowohl Kotlin als auch Java selbst haben mit verschiedenen Verbesserungen vorgelegt und damit Scala mehr oder weniger erfolgreich Wasser abgegraben. Scala 3 nur als bloße Reaktion auf diesen Trend zu verstehen, greift jedoch zu kurz, denn das Team um Martin Odersky hat sich die Zeit genommen, die Essenz der Sprache herauszuarbeiten, beliebte Features nachzuschärfen und alte Zöpfe abzuschneiden. Damit das Migrations-Debakel von Python 2 auf 3 sich in Scala nicht wiederholt, stehen verschiedene Tools bereit, die es erlauben, den selben Code in beiden Versionen zu nutzen.

TAGS

Comments