Blick nach vorn: von Dotty zu Scala 3

Mit Scala 3 soll die Programmiersprache besser zugänglich werden – und ein Blick auf die geplanten Neuerungen lohnt sich schon jetzt.

In Pocket speichern vorlesen Druckansicht 57 Kommentare lesen
Blick nach vorn: von Dotty zu Scala 3
Lesezeit: 10 Min.
Von
  • Stefan López Romero
Inhaltsverzeichnis

Ende 2020 ist das nächste große Release von Scala geplant, einer Programmiersprache, die sich die Verschmelzung typisierter, objektorientierter und funktionaler Programmierung auf die Fahnen geschrieben hat. Die Arbeit an Scala 3 hat bereits vor sieben Jahren im Rahmen des Projekts Dotty begonnen. Erklärtes Ziel war herauszufinden, wie ein neues Scala aussehen könnte. Mittlerweile ist klar: Dotty wird zu Scala 3. Bei der Entwicklung standen drei Hauptziele im Fokus:

  • Scala mit dem DOT-Kalkül auf eine neue theoretische Basis zu stellen,
  • den Einstieg in die Sprache durch Zügeln mächtiger Sprachkonstrukte wie Implicits zu vereinfachen und
  • die Konsistenz und Aussagekraft der Konstrukte weiter zu verbessern.

Im Folgenden liegt der Fokus auf den Neuerungen, die aus Sicht des Autors für die tägliche Arbeit besonders relevant sind. Die vollständige Liste aller Features und detaillierte Informationen dazu finden sich in der Dokumentation zu Dotty.

Scala 3 erlaubt die Top-Level-Definition von Typen, Typ-Aliases, Methoden und ähnlichen Konstrukten. Entwickler müssen solche Definitionen somit nicht mehr in Klassen, Traits oder Objekten unterbringen. Die in Scala 2.8 eingeführten Package Objects erfüllen einen ähnlichen Zweck und werden somit hinfällig. Im Gegensatz zu den Package Objects erlaubt Scala 3 in einem Paket beliebig viele Quelldateien, die Top-Level-Definitionen enthalten.

Folgender Code zeigt beispielhaft Top-Level-Definitionen:

package p

val greeting = "Dear"

case class Person(firstName: String, lastName:String)

def greet(p:Person) : String =
    s"$greeting ${p.firstName} ${p.lastName}"

Scala bietet bisher zwei Ansätze zum Modellieren von Enums: Sealed Case Objects und Erweiterungen von scala.Enumeration. Beide haben ihre Tücken. Bei der Modellierung mit ersterem haben die Werte keine definierte Ordnung, und es fehlt die Funktion, ein Set mit allen Enum-Werten zu erhalten oder ein Enum über den Namen zu finden. scala.Enumeration steht in der Kritik, weil es nicht typsicher ist und nicht mit Java Enums zusammenarbeitet.

Scala 3 führt ein eigenes Sprachkonstrukt zur Definition von Enums ein:

enum State {
case Solid, Liquid, Gas, Plasma
}

Jeder Enum-Wert entspricht einem eindeutigen Integer und hat somit eine definierte Ordnung. Die Methode ordinal gibt die zugeordnete Zahl zurück:

scala> State.Solid.ordinal
val res2: Int = 0

Das Companion-Object einer Enum definiert zwei Hilfsmethoden: Die Methode valueOf gibt einen Enum-Wert anhand des übergebenen Namens und die values-Methode alle Werte eines Enums als Array zurück:

scala> State.values
val res0: Array[State] = Array(Solid, Liquid, Gas, Plasma)

scala> State.valueOf("Gas")
val res1: State = Gas

Um eine in Scala definierte Enumeration in Java verwenden zu können, muss sie lediglich von java.lang.Enum ableiten:

enum State extends java.lang.Enum[State] {
case Solid, Liquid, Gas, Plasma
}

Danach lässt sie sich wie eine Java-Enum verwenden:

scala> State.Solid.compareTo(State.Liquid)
val res2: Int = -1

Ein Union Type aus mehreren Typen ist zu einem definierten Zeitpunkt immer genau von einem der in der Definition angegebenen Typen. Die Notation zum Definieren lautet A | B.

Folgender Codeausschnitt zeigt eine area-Methode, die einen Union Type mit Rectangle | Square | Circle akzeptiert. Die Methode arbeitet über Pattern Matching mit dem passenden Typ:

case class Rectangle(length: Double, width: Double)
case class Square(length: Double)
case class Circle(radius: Double)

def area(shape: Rectangle | Square | Circle): Double =
  shape match {
    case Square(l) => l * l
    case Rectangle(l, w) => l * w
    case Circle(r) => Math.pow(r, 2.0) * Math.PI
  }

Union Types stammen aus der funktionalen Programmierung. In der objektorientierten Programmierung lässt sich das Gleiche durch Vererbung erreichen, aber Union Types sind deutlich schlanker, wie folgender Codeausschnitt verdeutlicht:

def size(number: String | Int) : Int =
  number match {
    case s: String => s.size
    case n: Int => n
  }

Um dasselbe durch Vererbung umzusetzen, wäre eine eigene Klassenhierarchie erforderlich. Ein Trait müsste zunächst StringOrInt definieren und anschließend String und Int in einen eigens davon abgeleiteten Typ wrappen. Das wäre umständlich und wenig intuitiv.

Das Gegenstück zu Union Types sind Intersection Types. Ein als A & B definierter Intersection Type aus den Typen A und B ist zu einem definierten Zeitpunkt sowohl vom Typ A als auch vom Typ B. Wie bei Union Types existiert in Scala bereits ein ähnliches Konstrukt: Entwickler können mit with sogenannte Compound-Types A with B definieren.

Folgender Code definiert die Methode reverseUppercase mit dem Intersection-Type Capitalizable & Reversable, sodass sich die Methoden reverse und uppercase aus den zusammengesetzten Typen verwenden lassen:

trait A {
    def message : String
}
trait Capitalizable extends A {
  def uppercase(s: String) : String = message.toUpperCase
}

trait Reversable extends A {
  def reverse : String = message.reverse
}

class B extends A with Reversable with Capitalizable {
  override def message = "Hello"
}

def reversUppercase(m: Capitalizable & Reversable) : String =
  s"${m.reverse} ${m.uppercase}"

Im Gegensatz zu den Compound Types sind die neuen Intersection Types kommutativ: Aus der Sicht des Typensystems ist A with B nicht dasselbe wie B with A, während A & B und B & A dasselbe sind und sich austauschbar verwenden lassen. Daher werden Intersection Types die Compound Types ablösen. Das bedeutet, dass die Syntax A with B in Typdefinitionen zukünftig veraltet (deprecated) sein wird. Das Keyword with ist jedoch weiterhin in Mixins erlaubt

Implicits dürften das gleichermaßen bekannteste und gefürchtetste Feature von Scala sein. Dabei handelt es sich um ein umfassendes Paradigma für eine Vielzahl von Anwendungsfällen wie das Implementieren von Typklassen, zur Dependency Injection und zum Erstellen von Kontexten.

Die Vielzahl der Einsatzmöglichkeiten und die einfache Deklaration, die nur einen Modifier benötigt, bergen die Gefahr, das Konstrukt im falschen Kontext, zu häufig oder zu leichtsinnig einzusetzen. Scala 3 will das Risiko durch eine Vielzahl von Verbesserungen in den Griff bekommen. Die auffälligste Veränderung ist, dass das Keyword given das bisherige implicit ersetzt, um die Intention hinter dem Konstrukt greifbarer zu gestalten.

Folgende Codeausschnitte zeigen den Einsatz von given anhand einer Monoid-Typklasse. Ein Monoid lässt sich durch folgenden Trait definieren:

trait Monoid[T] {
def combine(a: T, b: T) : T
def unit : T
}

Als Nächstes folgt die Definition der beiden Monoide für Int und String mit dem Keyword given, um zwei Instanzen der Typklasse Monoid für die Datentypen Int und String zu erstellen, die sich anschließend in Funktionen verwenden lassen:

object Monoids {
  given sumMonoid : Monoid[Int] {
    def combine(a: Int, b: Int) : Int =
      a + b
    def unit : Int = 0
  }

  given strMonoid : Monoid[String] {
    def combine(a: String, b: String) : String =
      a + b
    def unit : String = ""
  }
}

In Scala 2 wäre der passende Weg implicit val.

Um den Monoid in einer reduce-Funktion zu verwenden, müssen Entwickler ihn zunächst bereitstellen, indem sie ihn als Funktionsparameter angeben. Das Keyword given kennzeichnet an dieser Stelle, dass der Compiler eine passende Instanz des entsprechenden Typs finden und einsetzen soll.

def reduce[T](x: List[T])(given m: Monoid[T]) : T =
x.foldLeft(m.unit)(m.combine)

Die Definition entspricht einem Implicit-Parameter in Scala 2. Im Vergleich entfällt allerdings die Restriktion, nur eine Implicit-Parameterliste verwenden zu können: Scala 3 erlaubt unbegrenzt viele Listen mit Givens an beliebiger Stelle.

Um die reduce-Funktion anzuwenden, muss der definierte Monoid im Sichtbarkeitsbereich der Funktion sein. Im Vergleich zu Scala 2 ist Scala 3 deutlich restriktiver und verlangt eine neue Notation zum expliziten Import von Given Instances in der Form import A.given.

scala> import Monoids.given

scala> reduce(List(1,2,3,4,5))
val res1: Int = 15

Implicit Conversions sind in Scala 3 durch Given Instances der Klasse scala.Conversion definiert. Der explizit erforderliche Given Import mindert die Gefahr, durch Wildcard Imports versehentlich eine Typkonvertierungen zu aktivieren.

Implicit Classes dienten bisher in Scala dazu, bestehende Klassen um eine neue Funktionsweise zu erweitern, ohne Vererbung oder das Decorator-Pattern verwenden zu müssen. Folgender Code erweitert die Klasse String um eine Methode toCamelCase, die einen String in Camel-Case-Schreibweise zurückgibt.

object StringImplicits {

  implicit class AdditionalStringMethods(str: String) {

    def toCamelCase = {
      str.toLowerCase.split("\\s").
        foldLeft("")((acc, elem) =>
          s"$acc${elem.substring(0,1).toUpperCase}$
            {elem.substring(1)}")
    }
  }
}

Ist die Implicit Class im Sichtbarkeitsbereich verfügbar, lässt sich die Methode ebenso anwenden wie eine, die Teil der Klasse String ist:

object StringExtensionsDemo {
import StringImplicits._
val camelCaseString =
"The quick brown fox jumps over the lazy dog".toCamelCase
}

Da die bisherige Definition von Extension Methods etwas umständlich ist, existiert in Scala 3 eine einfachere und klarere Syntax: Entwickler schreiben die zu erweiternde Klasse in Klammern vor dem Namen der Methode. Als Beispiel dient erneut die toCamelCase-Methode innerhalb eines Objekts:

object StringExtensions {
  def (str: String) toCamelCase: String = {
    str.toLowerCase.split("\\s").foldLeft("")((acc, elem) =>
      s"$acc${elem.head.toUpperCase}${elem.substring(1)}")
  }
}

Die Methode ist zunächst nur in dem Sichtbarkeitsbereich verfügbar, in dem sie definiert ist. Um sie an anderer Stelle verwenden zu können, müssen Entwickler sie durch einen Import bereitstellen:

object StringExtensionsDemo {
  import StringExtensions._

  val camelCaseString = 
    "The quick brown fox jumps over the lazy dog".toCamelCase
}

Alternativ zum Import lässt sich die Methode per Vererbung oder als eine Given Instance in den Sichtbarkeitsbereich bringen. Unterm Strich sind Extension Methods in Scala 3 einfacher und intuitiver zu erstellen, da man den Umweg über Implicit Classes spart.

Neben den größeren konzeptuellen Änderungen in Scala 3, sind einige kleinere Änderungen angekündigt, die die tägliche Arbeit vereinfachen sollen.

Creator Applications bezeichnen das Konzept, Klassen ohne das Keyword new zu instanziieren, auch wenn sie keine _apply_-Methode besitzen:

val sb = StringBuilder()

Außerdem dürfen Entwickler künftig die Klammern um die Bedingungen in Kontrollausdrücken weglassen:

def abs(a: Int) : Int = {
  if a < 0 then
    -a
  else
    a
}

def abs(l: List[Int]) : List[Int] = {
  for x <- l
  yield abs(x)
}

In Scala 3 können Traits ähnlich wie Klassen Parameter haben:

trait Greet(val name: String) {
  def greet() : String =
    name
}

class HelloWorld extends Greet("Hello") {
  def helloWorld() : String =
    s"${greet()} World"

Scala 3 bringt große Veränderungen. Das gesetzte Ziel, eine klarere und verständlichere Sprache zu entwickeln, ist wohl erreicht. Damit wird die Sprache besonders für Einsteiger attraktiver, und Scala kann den Konkurrenzkampf mit Kotlin antreten.

Erfahrene Scala-Entwickler müssen sich an die neuen Konstrukte und die veränderte Syntax gewöhnen. Scala 3 wirkt an manchen Stellen wie eine neue Programmiersprache. Die weitreichenden Änderungen erfordern ein komplettes Neuschreiben der Standardwerke zu Scala. Daher wird es nach der Veröffentlichung noch eine Weile dauern, bis die neuen Features und Konstrukte allgemein bekannt sind und Entwickler sie verstehen und anwenden.

Eine baldige Migration von bestehenden Scala-2-Code zu Scala 3 lohnt sich jedoch aus Sicht des Autors unbedingt. Für den weitgehend schmerzfreien Umstieg sind Werkzeuge angekündigt, die große Teile des Codes direkt portieren. Dadurch sollte der Aufwand überschaubar sein und sich durch klareren und einfacheren Code und weniger Fallstricke schnell rentieren.

Scala 3 könnte der richtige Schritt sein, um eine Sprache zu rehabilitieren, die leider in den Ruf von hoher Komplexität und vielen Tücken geraten war.

Stefan López Romero
ist IT-Architekt bei MaibornWolff. Der Diplom-Informatiker(FH) beschäftigt sich privat seit vielen Jahren mit funktionaler Programmierung in Scala, Haskell und Kotlin. Als Mitglied der FP-Community bei MaibornWolff ist sein Ziel funktionale Programmierung im Projetalltag voranzutreiben.

Siehe dazu auf heise Developer:

(rme)