Von Michel Schinz und Philipp Haller. Deutsche Übersetzung von Christian Krause.
Einleitung
Dieses Tutorial dient einer kurzen Vorstellung der Programmiersprache Scala und deren Compiler. Sie ist für fortgeschrittene Programmierer gedacht, die sich einen Überblick darüber verschaffen wollen, wie man mit Scala arbeitet. Grundkenntnisse in Objekt-orientierter Programmierung, insbesondere Java, werden vorausgesetzt.
Das erste Beispiel
Als erstes folgt eine Implementierung des wohlbekannten Hallo, Welt!-Programmes. Obwohl es sehr einfach ist, eignet es sich sehr gut, Scalas Funktionsweise zu demonstrieren, ohne dass man viel über die Sprache wissen muss.
object HalloWelt {
def main(args: Array[String]): Unit = {
println("Hallo, Welt!")
}
}
Die Struktur des Programmes sollte Java Anwendern bekannt vorkommen: es besteht aus einer Methode
namens main
, welche die Kommandozeilenparameter als Feld (Array) von Zeichenketten (String)
übergeben bekommt. Der Körper dieser Methode besteht aus einem einzelnen Aufruf der vordefinierten
Methode println
, die die freundliche Begrüßung als Parameter übergeben bekommt. Weiterhin hat die
main
-Methode keinen Rückgabewert - sie ist also eine Prozedur. Daher ist es auch nicht notwendig,
einen Rückgabetyp zu spezifizieren.
Was Java-Programmierern allerdings weniger bekannt sein sollte, ist die Deklaration object
HalloWelt
, welche die Methode main
enthält. Eine solche Deklaration stellt dar, was gemeinhin als
Singleton Objekt bekannt ist: eine Klasse mit nur einer Instanz. Im Beispiel oben werden also mit
dem Schlüsselwort object
sowohl eine Klasse namens HalloWelt
als auch die dazugehörige,
gleichnamige Instanz definiert. Diese Instanz wird erst bei ihrer erstmaligen Verwendung erstellt.
Dem aufmerksamen Leser ist vielleicht aufgefallen, dass die main
-Methode nicht als static
deklariert wurde. Der Grund dafür ist, dass statische Mitglieder (Attribute oder Methoden) in Scala
nicht existieren. Die Mitglieder von Singleton Objekten stellen in Scala dar, was Java und andere
Sprachen mit statischen Mitgliedern erreichen.
Das Beispiel kompilieren
Um das obige Beispiel zu kompilieren, wird scalac
, der Scala-Compiler verwendet. scalac
arbeitet
wie die meisten anderen Compiler auch: er akzeptiert Quellcode-Dateien als Parameter, einige weitere
Optionen, und übersetzt den Quellcode in Java-Bytecode. Dieser Bytecode wird in ein oder mehrere
Java-konforme Klassen-Dateien, Dateien mit der Endung .class
, geschrieben.
Schreibt man den obigen Quellcode in eine Datei namens HalloWelt.scala
, kann man diese mit dem
folgenden Befehl kompilieren (das größer-als-Zeichen >
repräsentiert die Eingabeaufforderung und
sollte nicht mit geschrieben werden):
> scalac HalloWelt.scala
Damit werden einige Klassen-Dateien in das aktuelle Verzeichnis geschrieben. Eine davon heißt
HalloWelt.class
und enthält die Klasse, die direkt mit dem Befehl scala
ausgeführt werden kann,
was im folgenden Abschnitt erklärt wird.
Das Beispiel ausführen
Sobald kompiliert, kann ein Scala-Programm mit dem Befehl scala
ausgeführt werden. Die Anwendung
ist dem Befehl java
, mit dem man Java-Programme ausführt, nachempfunden und akzeptiert dieselben
Optionen. Das obige Beispiel kann demnach mit folgendem Befehl ausgeführt werden, was das erwartete
Resultat ausgibt:
> scala -classpath . HalloWelt
Hallo, Welt!
Interaktion mit Java
Eine Stärke der Sprache Scala ist, dass man mit ihr sehr leicht mit Java interagieren kann. Alle
Klassen des Paketes java.lang
stehen beispielsweise automatisch zur Verfügung, während andere
explizit importiert werden müssen.
Als nächstes folgt ein Beispiel, was diese Interoperabilität demonstriert. Ziel ist es, das aktuelle Datum zu erhalten und gemäß den Konventionen eines gewissen Landes zu formatieren, zum Beispiel Frankreich.
Javas Klassen-Bibliothek enthält viele nützliche Klassen, beispielsweise Date
und DateFormat
.
Dank Scala Fähigkeit, nahtlos mit Java zu interoperieren, besteht keine Notwendigkeit, äquivalente
Klassen in der Scala Klassen-Bibliothek zu implementieren - man kann einfach die entsprechenden
Klassen der Java-Pakete importieren:
import java.util.{Date, Locale}
import java.text.DateFormat
import java.text.DateFormat._
object FrenchDate {
def main(args: Array[String]): Unit = {
val now = new Date
val df = getDateInstance(LONG, Locale.FRANCE)
println(df format now)
}
}
Scala Import-Anweisung ähnelt sehr der von Java, obwohl sie viel mächtiger ist. Mehrere Klassen des
gleichen Paketes können gleichzeitig importiert werden, indem sie, wie in der ersten Zeile, in
geschweifte Klammern geschrieben werden. Ein weiterer Unterschied ist, dass, wenn man alle
Mitglieder eines Paketes importieren will, einen Unterstrich (_
) anstelle des Asterisk (*
)
verwendet. Der Grund dafür ist, dass der Asterisk ein gültiger Bezeichner in Scala ist,
beispielsweise als Name für Methoden, wie später gezeigt wird. Die Import-Anweisung der dritten
Zeile importiert demnach alle Mitglieder der Klasse DateFormat
, inklusive der statischen Methode
getDateInstance
und des statischen Feldes LONG
.
Innerhalb der main
-Methode wird zuerst eine Instanz der Java-Klasse Date
erzeugt, welche
standardmäßig das aktuelle Datum enthält. Als nächstes wird mithilfe der statischen Methode
getDateInstance
eine Instanz der Klasse DateFormat
erstellt. Schließlich wird das aktuelle Datum
gemäß der Regeln der lokalisierten DateFormat
-Instanz formatiert ausgegeben. Außerdem
veranschaulicht die letzte Zeile eine interessante Fähigkeit Scalas Syntax: Methoden, die nur einen
Parameter haben, können in der Infix-Syntax notiert werden. Dies bedeutet, dass der Ausdruck
df format now
eine andere, weniger verbose Variante des folgenden Ausdruckes ist:
df.format(now)
Dies scheint nur ein nebensächlicher, syntaktischer Zucker zu sein, hat jedoch bedeutende Konsequenzen, wie im folgenden Abschnitt gezeigt wird.
Um diesen Abschnitt abzuschließen, soll bemerkt sein, dass es außerdem direkt in Scala möglich ist, von Java-Klassen zu erben sowie Java-Schnittstellen zu implementieren.
Alles ist ein Objekt
Scala ist eine pur Objekt-orientierte Sprache, in dem Sinne dass alles ein Objekt ist, Zahlen und
Funktionen eingeschlossen. Der Unterschied zu Java ist, dass Java zwischen primitiven Typen, wie
boolean
und int
, und den Referenz-Typen unterscheidet und es nicht erlaubt ist, Funktionen wie
Werte zu behandeln.
Zahlen sind Objekte
Zahlen sind Objekte und haben daher Methoden. Tatsächlich besteht ein arithmetischer Ausdruck wie der folgende
1 + 2 * 3 / x
exklusiv aus Methoden-Aufrufen, da es äquivalent zu folgendem Ausdruck ist, wie in vorhergehenden Abschnitt gezeigt wurde:
1.+(2.*(3)./(x))
Dies bedeutet außerdem, dass +
, *
, etc. in Scala gültige Bezeichner sind.
Funktionen sind Objekte
Vermutlich überraschender für Java-Programmierer ist, dass auch Funktionen in Scala Objekte sind. Daher ist es auch möglich, Funktionen als Parameter zu übergeben, als Werte zu speichern, und von anderen Funktionen zurückgeben zu lassen. Diese Fähigkeit, Funktionen wie Werte zu behandeln, ist einer der Grundsteine eines sehr interessanten Programmier-Paradigmas, der funktionalen Programmierung.
Ein sehr einfaches Beispiel, warum es nützlich sein kann, Funktionen wie Werte zu behandeln, ist eine Timer-Funktion, deren Ziel es ist, eine gewisse Aktion pro Sekunde durchzuführen. Wie übergibt man die durchzuführende Aktion? Offensichtlich als Funktion. Diese einfache Art der Übergabe einer Funktion sollte den meisten Programmieren bekannt vorkommen: dieses Prinzip wird häufig bei Schnittstellen für Rückruf-Funktionen (call-back) verwendet, die ausgeführt werden, wenn ein bestimmtes Ereignis eintritt.
Im folgenden Programm akzeptiert die Timer-Funktion oncePerSecond
eine Rückruf-Funktion als
Parameter. Deren Typ wird () => Unit
geschrieben und ist der Typ aller Funktionen, die keine
Parameter haben und nichts zurück geben (der Typ Unit
ist das Äquivalent zu void
). Die
main
-Methode des Programmes ruft die Timer-Funktion mit der Rückruf-Funktion auf, die einen Satz
ausgibt. In anderen Worten: das Programm gibt endlos den Satz “Die Zeit vergeht wie im Flug.”
einmal pro Sekunde aus.
object Timer {
def oncePerSecond(callback: () => Unit) {
while (true) {
callback()
Thread sleep 1000
}
}
def timeFlies() {
println("Die Zeit vergeht wie im Flug.")
}
def main(args: Array[String]): Unit = {
oncePerSecond(timeFlies)
}
}
Weiterhin ist zu bemerken, dass, um die Zeichenkette auszugeben, die in Scala vordefinierte Methode
println
statt der äquivalenten Methode in System.out
verwendet wird.
Anonyme Funktionen
Während das obige Programm schon leicht zu verstehen ist, kann es noch verbessert werden. Als erstes
sei zu bemerken, dass die Funktion timeFlies
nur definiert wurde, um der Funktion oncePerSecond
als Parameter übergeben zu werden. Dieser nur einmal verwendeten Funktion einen Namen zu geben,
scheint unnötig und es wäre angenehmer, sie direkt mit der Übergabe zu erstellen. Dies ist in Scala
mit anonymen Funktionen möglich, die eine Funktion ohne Namen darstellen. Die überarbeitete
Variante des obigen Timer-Programmes verwendet eine anonyme Funktion anstatt der Funktion
timeFlies
:
object TimerAnonymous {
def oncePerSecond(callback: () => Unit) {
while (true) {
callback()
Thread sleep 1000
}
}
def main(args: Array[String]): Unit = {
oncePerSecond(() => println("Die Zeit vergeht wie im Flug."))
}
}
Die anonyme Funktion erkennt man an dem Rechtspfeil =>
, der die Parameter der Funktion von deren
Körper trennt. In diesem Beispiel ist die Liste der Parameter leer, wie man an den leeren Klammern
erkennen kann. Der Körper der Funktion ist derselbe, wie bei der timeFlies
Funktion des
vorangegangenen Beispiels.
Klassen
Wie weiter oben zu sehen war, ist Scala eine pur Objekt-orientierte Sprache, und als solche enthält sie das Konzept von Klassen (der Vollständigkeit halber soll bemerkt sein, dass nicht alle Objekt-orientierte Sprachen das Konzept von Klassen unterstützen, aber Scala ist keine von denen). Klassen in Scala werden mit einer ähnlichen Syntax wie Java deklariert. Ein wichtiger Unterschied ist jedoch, dass Scalas Klassen Argumente haben. Dies soll mit der folgenden Definition von komplexen Zahlen veranschaulicht werden:
class Complex(real: Double, imaginary: Double) {
def re() = real
def im() = imaginary
}
Diese Klasse akzeptiert zwei Argumente, den realen und den imaginären Teil der komplexen Zahl. Sie müssen beim Erzeugen einer Instanz der Klasse übergeben werden:
val c = new Complex(1.5, 2.3)
Weiterhin enthält die Klasse zwei Methoden, re
und im
, welche als Zugriffsfunktionen (Getter)
dienen. Außerdem soll bemerkt sein, dass der Rückgabe-Typ dieser Methoden nicht explizit deklariert
ist. Der Compiler schlussfolgert ihn automatisch, indem er ihn aus dem rechten Teil der Methoden
ableitet, dass der Rückgabewert vom Typ Double
ist.
Der Compiler ist nicht immer fähig, auf den Rückgabe-Typ zu schließen, und es gibt leider keine einfache Regel, vorauszusagen, ob er dazu fähig ist oder nicht. In der Praxis stellt das üblicherweise kein Problem dar, da der Compiler sich beschwert, wenn es ihm nicht möglich ist. Scala-Anfänger sollten versuchen, Typ-Deklarationen, die leicht vom Kontext abzuleiten sind, wegzulassen, um zu sehen, ob der Compiler zustimmt. Nach einer gewissen Zeit, bekommt man ein Gefühl dafür, wann man auf diese Deklarationen verzichten kann und wann man sie explizit angeben sollte.
Methoden ohne Argumente
Ein Problem der obigen Methoden re
und im
ist, dass man, um sie zu verwenden, ein leeres
Klammerpaar hinter ihren Namen anhängen muss:
object ComplexNumbers {
def main(args: Array[String]): Unit = {
val c = new Complex(1.2, 3.4)
println("imaginary part: " + c.im())
}
}
Besser wäre es jedoch, wenn man den realen und imaginären Teil so abrufen könnte, als wären sie Felder, also ohne das leere Klammerpaar. Mit Scala ist dies möglich, indem Methoden ohne Argumente definiert werden. Solche Methoden haben keine Klammern nach ihrem Namen, weder bei ihrer Definition noch bei ihrer Verwendung. Die Klasse für komplexe Zahlen kann demnach folgendermaßen umgeschrieben werden:
class Complex(real: Double, imaginary: Double) {
def re = real
def im = imaginary
}
Vererbung und Überschreibung
Alle Klassen in Scala erben von einer Oberklasse. Wird keine Oberklasse angegeben, wie bei der
Klasse Complex
des vorhergehenden Abschnittes, wird implizit scala.AnyRef
verwendet.
Außerdem ist es möglich, von einer Oberklasse vererbte Methoden zu überschreiben. Dabei muss jedoch
explizit das Schlüsselwort override
angegeben werden, um versehentliche Überschreibungen zu
vermeiden. Als Beispiel soll eine Erweiterung der Klasse Complex
dienen, die die Methode
toString
neu definiert:
class Complex(real: Double, imaginary: Double) {
def re = real
def im = imaginary
override def toString() =
"" + re + (if (im < 0) "" else "+") + im + "i"
}
Container-Klassen und Musterabgleiche
Eine Datenstruktur, die häufig in Programmen vorkommt, ist der Baum. Beispielsweise repräsentieren Interpreter und Compiler Programme intern häufig als Bäume, XML-Dokumente sind Bäume und einige Container basieren auf Bäumen, wie Rot-Schwarz-Bäume.
Als nächstes wird anhand eines kleinen Programmes für Berechnungen gezeigt, wie solche Bäume in
Scala repräsentiert und manipuliert werden können. Das Ziel dieses Programmes ist, einfache
arithmetische Ausdrücke zu manipulieren, die aus Summen, Ganzzahlen und Variablen bestehen.
Beispiele solcher Ausdrücke sind: 1+2
und (x+x)+(7+y)
.
Dafür muss zuerst eine Repräsentation für die Ausdrücke gewählt werden. Die natürlichste ist ein Baum, dessen Knoten Operationen (Additionen) und dessen Blätter Werte (Konstanten und Variablen) darstellen.
In Java würde man solche Bäume am ehesten mithilfe einer abstrakten Oberklasse für den Baum und konkreten Implementierungen für Knoten und Blätter repräsentieren. In einer funktionalen Sprache würde man algebraische Datentypen mit dem gleichen Ziel verwenden. Scala unterstützt das Konzept einer Container-Klasse (case class), die einen gewissen Mittelweg dazwischen darstellen. Der folgenden Quellcode veranschaulicht deren Anwendung:
abstract class Tree
case class Sum(l: Tree, r: Tree) extends Tree
case class Var(n: String) extends Tree
case class Const(v: Int) extends Tree
Die Tatsache, dass die Klassen Sum
, Var
und Const
als Container-Klassen deklariert sind,
bedeutet, dass sie sich in einigen Gesichtspunkten von normalen Klassen unterscheiden:
- das Schlüsselwort
new
ist nicht mehr notwendig, um Instanzen dieser Klassen zu erzeugen (man kann alsoConst(5)
anstelle vonnew Const(5)
schreiben) - Zugriffsfunktionen werden automatisch anhand der Parameter des Konstruktors erstellt (man kann
den Wert
v
einer Instanzc
der KlasseConst
erhalten, indem manc.v
schreibt) - der Compiler fügt Container-Klassen automatisch Implementierungen der Methoden
equals
undhashCode
hinzu, die auf der Struktur der Klassen basieren, anstelle deren Identität - außerdem wird eine
toString
-Methode bereitgestellt, die einen Wert in Form der Quelle darstellt (der String-Wert des Baum-Ausdruckesx+1
istSum(Var(x),Const(1))
) - Instanzen dieser Klassen können mithilfe von Musterabgleichen zerlegt werden, wie weiter unten zu sehen ist
Da jetzt bekannt ist, wie die Datenstruktur der arithmetischen Ausdrücke repräsentiert wird, können
jetzt Operationen definiert werden, um diese zu manipulieren. Der Beginn dessen soll eine Funktion
darstellen, die Ausdrücke in einer bestimmten Umgebung auswertet. Das Ziel einer Umgebung ist es,
Variablen Werte zuzuweisen. Beispielsweise wird der Ausdruck x+1
in der Umgebung, die der Variable
x
den Wert 5
zuweist, geschrieben als { x -> 5 }
, mit dem Resultat 6
ausgewertet.
Demnach muss ein Weg gefunden werden, solche Umgebungen auszudrücken. Dabei könnte man sich für eine
assoziative Datenstruktur entscheiden, wie eine Hash-Tabelle, man könnte jedoch auch direkt eine
Funktion verwenden. Eine Umgebung ist nicht mehr als eine Funktion, die Werte mit Variablen
assoziiert. Die obige Umgebung { x -> 5 }
wird in Scala folgendermaßen notiert:
{ case "x" => 5 }
Diese Schreibweise definiert eine Funktion, welche bei dem String "x"
als Argument die Ganzzahl
5
zurückgibt, und in anderen Fällen mit einer Ausnahme fehlschlägt.
Vor dem Schreiben der Funktionen zum Auswerten ist es sinnvoll, für die Umgebungen einen eigenen Typ
zu definieren. Man könnte zwar immer String => Int
verwenden, es wäre jedoch besser einen
dedizierten Namen dafür zu verwenden, der das Programmieren damit einfacher macht und die Lesbarkeit
erhöht. Dies wird in Scala mit der folgenden Schreibweise erreicht:
type Environment = String => Int
Von hier an wird Environment
als Alias für den Typ von Funktionen von String
nach Int
verwendet.
Nun ist alles für die Definition der Funktion zur Auswertung vorbereitet. Konzeptionell ist die Definition sehr einfach: der Wert der Summe zweier Ausdrücke ist die Summe der Werte der einzelnen Ausdrücke, der Wert einer Variablen wird direkt der Umgebung entnommen und der Wert einer Konstante ist die Konstante selbst. Dies in Scala auszudrücken, ist nicht viel schwieriger:
def eval(t: Tree, env: Environment): Int = t match {
case Sum(l, r) => eval(l, env) + eval(r, env)
case Var(n) => env(n)
case Const(v) => v
}
Diese Funktion zum Auswerten von arithmetischen Ausdrücken nutzt einen Musterabgleich (pattern
matching) am Baumes t
. Intuitiv sollte die Bedeutung der einzelnen Fälle klar sein:
-
Als erstes wird überprüft, ob
t
eine Instanz der KlasseSum
ist. Falls dem so ist, wird der linke Teilbaum der Variablenl
und der rechte Teilbaum der Variablenr
zugewiesen. Daraufhin wird der Ausdruck auf der rechten Seite des Pfeiles ausgewertet, der die auf der linken Seite gebundenen Variablenl
undr
verwendet. -
Sollte die erste Überprüfung fehlschlagen, also
t
ist keineSum
, wird der nächste Fall abgehandelt und überprüft, obt
eineVar
ist. Ist dies der Fall, wird analog zum ersten Fall der Wert ann
gebunden und der Ausdruck rechts vom Pfeil ausgewertet. -
Schlägt auch die zweite Überprüfung fehl, also
t
ist wederSum
nochVal
, wird überprüft, ob es eine Instanz des TypsConst
ist. Analog wird bei einem Erfolg wie bei den beiden vorangegangenen Fällen verfahren. -
Schließlich, sollten alle Überprüfungen fehlschlagen, wird eine Ausnahme ausgelöst, die signalisiert, dass der Musterabgleich nicht erfolgreich war. Dies wird unweigerlich geschehen, sollten neue Baum-Unterklassen erstellt werden.
Die prinzipielle Idee eines Musterabgleiches ist, einen Wert anhand einer Reihe von Mustern abzugleichen und, sobald ein Treffer erzielt wird, Werte zu extrahieren, mit denen darauf weitergearbeitet werden kann.
Erfahrene Objekt-orientierte Programmierer werden sich fragen, warum eval
nicht als Methode der
Klasse Tree
oder dessen Unterklassen definiert wurde. Dies wäre möglich, da Container-Klassen
Methoden definieren können, wie normale Klassen auch. Die Entscheidung, einen Musterabgleich oder
Methoden zu verwenden, ist Geschmackssache, hat jedoch wichtige Auswirkungen auf die
Erweiterbarkeit:
- einerseits ist es mit Methoden einfach, neue Arten von Knoten als Unterklassen von
Tree
hinzuzufügen, andererseits ist die Ergänzung einer neuen Operation zur Manipulation des Baumes mühsam, da sie die Modifikation aller Unterklassen vonTree
erfordert - nutzt man einen Musterabgleich kehrt sich die Situation um: eine neue Art von Knoten erfordert die Modifikation aller Funktionen die einen Musterabgleich am Baum vollführen, wogegen eine neue Operation leicht hinzuzufügen ist, indem einfach eine unabhängige Funktion dafür definiert wird
Einen weiteren Einblick in Musterabgleiche verschafft eine weitere Operation mit arithmetischen Ausdrücken: partielle Ableitungen. Dafür gelten zur Zeit folgende Regeln:
- die Ableitung einer Summe ist die Summe der Ableitungen
- die Ableitung einer Variablen ist eins, wenn sie die abzuleitende Variable ist, ansonsten
0
- die Ableitung einer Konstanten ist
0
Auch diese Regeln können fast wörtlich in Scala übersetzt werden:
def derive(t: Tree, v: String): Tree = t match {
case Sum(l, r) => Sum(derive(l, v), derive(r, v))
case Var(n) if (v == n) => Const(1)
case _ => Const(0)
}
Diese Funktion führt zwei neue, mit dem Musterabgleich zusammenhängende Konzepte ein. Der zweite,
sich auf eine Variable beziehende Fall hat eine Sperre (guard), einen Ausdruck, der dem
Schlüsselwort if
folgt. Diese Sperre verhindert eine Übereinstimmung, wenn der Ausdruck falsch
ist. In diesem Fall wird sie genutzt, die Konstante 1
nur zurückzugeben, wenn die Variable die
abzuleitende ist. Die zweite Neuerung ist der Platzhalter _
, der mit allem übereinstimmt, jedoch
ohne einen Namen dafür zu verwenden.
Die volle Funktionalität von Musterabgleichen wurde mit diesen Beispielen nicht demonstriert, doch
soll dies fürs Erste genügen. Eine Vorführung der beiden Funktionen an realen Beispielen steht immer
noch aus. Zu diesem Zweck soll eine main
-Methode dienen, die den Ausdruck (x+x)+(7+y)
als
Beispiel verwendet: zuerst wird der Wert in der Umgebung { x -> 5, y -> 7 }
berechnet und darauf
die beiden partiellen Ableitungen gebildet:
def main(args: Array[String]): Unit = {
val exp: Tree = Sum(Sum(Var("x"),Var("x")),Sum(Const(7),Var("y")))
val env: Environment = {
case "x" => 5
case "y" => 7
}
println("Ausdruck: " + exp)
println("Auswertung mit x=5, y=7: " + eval(exp, env))
println("Ableitung von x:\n " + derive(exp, "x"))
println("Ableitung von y:\n " + derive(exp, "y"))
}
Führt man das Programm aus, erhält man folgende Ausgabe:
Ausdruck: Sum(Sum(Var(x),Var(x)),Sum(Const(7),Var(y)))
Auswertung mit x=5, y=7: 24
Ableitung von x:
Sum(Sum(Const(1),Const(1)),Sum(Const(0),Const(0)))
Ableitung von y:
Sum(Sum(Const(0),Const(0)),Sum(Const(0),Const(1)))
Beim Anblick dieser Ausgabe ist offensichtlich, dass man die Ergebnisse der Ableitungen noch vereinfachen sollte. Eine solche Funktion zum Vereinfachen von Ausdrücken, die Musterabgleiche nutzt, ist ein interessantes, aber gar nicht so einfaches Problem, was als Übung offen steht.
Traits
Neben dem Vererben von Oberklassen ist es in Scala auch möglich von mehreren, sogenannten Traits zu erben. Der beste Weg für einen Java-Programmierer einen Trait zu verstehen, ist sich eine Schnittstelle vorzustellen, die Implementierungen enthält. Wenn in Scala eine Klasse von einem Trait erbt, implementiert sie dessen Schnittstelle und erbt dessen Implementierungen.
Um die Nützlichkeit von Traits zu demonstrieren, werden wir ein klassisches Beispiel implementieren:
Objekte mit einer natürlichen Ordnung oder Rangfolge. Es ist häufig hilfreich, Instanzen einer
Klasse untereinander vergleichen zu können, um sie beispielsweise sortieren zu können. In Java
müssen die Klassen solcher Objekte die Schnittstelle Comparable
implementieren. In Scala kann dies
mit einer äquivalenten, aber besseren Variante von Comparable
als Trait bewerkstelligt werden, die
im Folgenden Ord
genannt wird.
Wenn Objekte verglichen werden, sind sechs verschiedene Aussagen sinnvoll: kleiner, kleiner gleich, gleich, ungleich, größer, und größer gleich. Allerdings ist es umständlich, immer alle sechs Methoden dafür zu implementieren, vor allem in Anbetracht der Tatsache, dass vier dieser sechs durch die verbliebenen zwei ausgedrückt werden können. Sind beispielsweise die Aussagen für gleich und kleiner gegeben, kann man die anderen damit ausdrücken. In Scala können diese Beobachtungen mit dem folgenden Trait zusammengefasst werden:
trait Ord {
def < (that: Any): Boolean
def <=(that: Any): Boolean = (this < that) || (this == that)
def > (that: Any): Boolean = !(this <= that)
def >=(that: Any): Boolean = !(this < that)
}
Diese Definition erzeugt sowohl einen neuen Typ namens Ord
, welcher dieselbe Rolle wie Javas
Schnittstelle Comparable
spielt, und drei vorgegebenen Funktionen, die auf einer vierten,
abstrakten basieren. Die Methoden für Gleichheit und Ungleichheit erscheinen hier nicht, da sie
bereits in allen Objekten von Scala vorhanden sind.
Der Typ Any
, welcher oben verwendet wurde, stellt den Ober-Typ aller Typen in Scala dar. Er kann
als noch allgemeinere Version von Javas Object
angesehen werden, da er außerdem Ober-Typ der
Basis-Typen wie Int
und Float
ist.
Um Objekte einer Klasse vergleichen zu können, ist es also hinreichend, Gleichheit und die kleiner-als-Beziehung zu implementieren, und dieses Verhalten gewissermaßen mit der eigentlichen Klasse zu vermengen (mix in). Als Beispiel soll eine Klasse für Datumsangaben dienen, die Daten eines gregorianischen Kalenders repräsentiert. Solche Daten bestehen aus Tag, Monat und Jahr, welche durch Ganzzahlen dargestellt werden:
class Date(y: Int, m: Int, d: Int) extends Ord {
def year = y
def month = m
def day = d
override def toString = year + "-" + month + "-" + day
Der wichtige Teil dieser Definition ist die Deklaration extends Ord
, welche dem Namen der Klasse
und deren Parametern folgt. Sie sagt aus, dass Date
vom Trait Ord
erbt.
Nun folgt eine Re-Implementierung der Methode equals
, die von Object
geerbt wird, so dass die
Daten korrekt nach ihren Feldern verglichen werden. Die vorgegebene Implementierung von equals
ist
dafür nicht nützlich, da in Java Objekte physisch, also nach deren Adressen im Speicher, verglichen
werden. Daher verwenden wir folgende Definition:
override def equals(that: Any): Boolean =
that.isInstanceOf[Date] && {
val o = that.asInstanceOf[Date]
o.day == day && o.month == month && o.year == year
}
Diese Methode verwendet die vordefinierten Methoden isInstanceOf
und asInstanceOf
. Erstere
entspricht Javas instanceof
-Operator und gibt true
zurück, wenn das zu testende Objekt eine
Instanz des angegebenen Typs ist. Letztere entspricht Javas Operator für Typ-Umwandlungen (cast):
ist das Objekt eine Instanz des angegebenen Typs, kann es als solcher angesehen und gehandhabt
werden, ansonsten wird eine ClassCastException
ausgelöst.
Schließlich kann die letzte Methode definiert werden, die für Ord
notwendig ist, und die
kleiner-als-Beziehung implementiert. Diese nutzt eine andere, vordefinierte Methode, namens error
,
des Paketes sys
, welche eine RuntimeException
mit der angegebenen Nachricht auslöst.
def <(that: Any): Boolean = {
if (!that.isInstanceOf[Date])
sys.error("cannot compare " + that + " and a Date")
val o = that.asInstanceOf[Date]
(year < o.year) ||
(year == o.year && (month < o.month ||
(month == o.month && day < o.day)))
}
}
Diese Methode vervollständigt die Definition der Date
-Klasse. Instanzen dieser Klasse stellen
sowohl Daten als auch vergleichbare Objekte dar. Vielmehr implementiert diese Klasse alle sechs
Methoden, die für das Vergleichen von Objekten notwendig sind: equals
und <
, die direkt in der
Definition von Date
vorkommen, sowie die anderen, in dem Trait Ord
definierten Methoden.
Traits sind nützlich in Situationen wie der obigen, den vollen Funktionsumfang hier zu zeigen, würde allerdings den Rahmen dieses Dokumentes sprengen.
Generische Programmierung
Eine weitere Charakteristik Scalas, die in diesem Tutorial vorgestellt werden soll, behandelt das Konzept der generischen Programmierung. Java-Programmierer, die die Sprache noch vor der Version 1.5 kennen, sollten mit den Problemen vertraut sein, die auftreten, wenn generische Programmierung nicht unterstützt wird.
Generische Programmierung bedeutet, Quellcode nach Typen zu parametrisieren. Beispielsweise stellt
sich die Frage für einen Programmierer bei der Implementierung einer Bibliothek für verkettete
Listen, welcher Typ für die Elemente verwendet werden soll. Da diese Liste in verschiedenen
Zusammenhängen verwendet werden soll, ist es nicht möglich, einen spezifischen Typ, wie Int
, zu
verwenden. Diese willkürliche Wahl wäre sehr einschränkend.
Aufgrund dieser Probleme griff man in Java vor der Einführung der generischen Programmierung zu dem
Mittel, Object
, den Ober-Typ aller Typen, als Element-Typ zu verwenden. Diese Lösung ist
allerdings auch weit entfernt von Eleganz, da sie sowohl ungeeignet für die Basis-Typen, wie int
oder float
, ist, als auch viele explizite Typ-Umwandlungen für den nutzenden Programmierer
bedeutet.
Scala ermöglicht es, generische Klassen und Methoden zu definieren, um diesen Problemen aus dem Weg zu gehen. Für die Demonstration soll ein einfacher, generischer Container als Referenz-Typ dienen, der leer sein kann, oder auf ein Objekt des generischen Typs zeigt:
class Reference[T] {
private var contents: T = _
def get: T = contents
def set(value: T) {
contents = value
}
}
Die Klasse Reference
ist anhand des Types T
parametrisiert, der den Element-Typ repräsentiert.
Dieser Typ wird im Körper der Klasse genutzt, wie bei dem Feld contents
. Dessen Argument wird
durch die Methode get
abgefragt und mit der Methode set
verändert.
Der obige Quellcode führt veränderbare Variablen in Scala ein, welche keiner weiteren Erklärung
erfordern sollten. Schon interessanter ist der initiale Wert dieser Variablen, der mit _
gekennzeichnet wurde. Dieser Standardwert ist für numerische Typen 0
, false
für Wahrheitswerte,
()
für den Typ Unit
und null
für alle anderen Typen.
Um diese Referenz-Klasse zu verwenden, muss der generische Typ bei der Erzeugung einer Instanz angegeben werden. Für einen Ganzzahl-Container soll folgendes Beispiel dienen:
object IntegerReference {
def main(args: Array[String]): Unit = {
val cell = new Reference[Int]
cell.set(13)
println("Reference contains the half of " + (cell.get * 2))
}
}
Wie in dem Beispiel zu sehen ist, muss der Wert, der von der Methode get
zurückgegeben wird, nicht
umgewandelt werden, wenn er als Ganzzahl verwendet werden soll. Es wäre außerdem nicht möglich,
einen Wert, der keine Ganzzahl ist, in einem solchen Container zu speichern, da er speziell und
ausschließlich für Ganzzahlen erzeugt worden ist.
Zusammenfassung
Dieses Dokument hat einen kurzen Überblick über die Sprache Scala gegeben und dazu einige einfache Beispiele verwendet. Interessierte Leser können beispielsweise mit dem Dokument Scala by Example fortfahren, welches fortgeschrittenere Beispiele enthält, und die Scala Language Specification konsultieren, sofern nötig.