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