Scala: Integration Objektorientierter und funktionaler Programmierung

Werbung
Seminar Programmiersprachen und Programmiersysteme
Scala: Integration Objektorientierter und funktionaler
Programmierung
Nicolas Günther
22. Juni 2009
Betreuer: Prof. Dr. Michael Hanus
1
Inhaltsverzeichnis
1 Einleitung
2 Grundlagen
2.1 Variablen und Werte . .
2.2 Klassen . . . . . . . . .
2.3 Generizität und Varianz
2.4 Funktionen . . . . . . .
3
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
3 Funktionen als Objekte
4
4
4
6
7
8
4 Funktionale Programmierung in Scala
4.1 Curryfizierte Funktionen und partielle Applikation . . . . . . . . . . . . . . . .
4.2 Pattern-Matching . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.3 Lazy-Auswertung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9
9
9
11
5 Implizite Konvertierungen
13
6 Beispiel: Actors
15
7 Zusammenfassung
17
2
1
Einleitung
Die Programmiersprache Scala wird seit 2001 an der École Polytechnique Fédérale de Lausanne unter der Leitung von Prof. Martin Odersky entwickelt. Die zweite, in dieser Ausarbeitung
behandelte Version von Scala wurde im Jahr 2004 veröffentlicht. Martin Odersky ist unter
anderem für das Erweitern von Java um das Konzept der Generics bekannt.
Auslöser der Entscheidung zur Entwicklung einer neuen Programmiersprache war die Unzufriedenheit mit vorhandenen Sprachen bezüglich der Unterstützung von Konzepten zur
Programmierung und Komposition von Komponenten:
Progress in component systems has been slowed down by shortcomings in the
programming languages used to define and integrate components. 1
Mit Hilfe von Scala sollen zwei Hypothesen überprüft werden, die eine bessere Behandlung
von Komponenten versprechen:
1. Sprachen zur Programmierung von Komponenten müssen skalierbar sein, d.h. dieselben Konzepte sollten sich sowohl zur Beschreibung von kleinen, als auch von grossen
Programmteilen verwenden lassen.
2. Diese Form der Skalierbarkeit wird erreicht durch das Verallgemeinern und das Verbinden von Konstrukten aus der funktionalen und aus der objektorientierten Programmierung.
Wie in funktionalen Sprachen üblich soll Scala also auf einem Kern aus wenigen leistungsfähigen
Konzepten aufbauend das Erstellen von Bibliotheken ermöglichen, die die Sprache bezüglich
der jeweiligen Bedürfnisse anpassen bzw. erweiteren. Dazu sollen funktionale und objektorientierte Konzepte vereint werden.
Um diese Hypothesen zu überprüfen ist es nötig, dass Scala in einem grossen Umfang zur
Umsetzung von Projekten verschiedener Grössenordungen eingesetzt wird. Um diesen Schritt
zu erleichtern, ist nicht nur die Syntax von Scala an die populären Sprachen Java und C#
angelehnt, sondern Scala arbeitet mit wahlweise einer dieser Technologien als Host-System,
ohne aber vollständig Quellcode-kompatibel zu bleiben. So ist es auch möglich, die in großem
Umfang zur Verfügung stehenden Bibliotheken und Frameworks für diese Sprachen in ScalaProgrammen zu verwenden.
Entgegen dem Trend erfolgreicher neuerer Sprachen, die wie z.B. Ruby oder Python meist
auf dynamische Typisierung setzen, ist die Typisierung in Scala statisch. Es ist aber oft
möglich auf Typannotationen zu verzichten, wobei die Typen dann mittels Typinferenz hergeleitet werden.
Diese Ausarbeitung behandelt vor allem die Integration funktionaler und objektorientierter Konzepte in Scala, ist also als Einführung in die Programmierung in Scala nur bedingt
geeignet. In 2 werden einleitend Syntax und grundlegende Konstrukte vorgestellt. Anschließend wird in 3 beschrieben wie Funktionen in Scala durch Objekte repräsentiert werden.
Abschnitt 4 beschreibt wie in Scala in einem funktionalen Stil programmiert werden kann. In
5 wird das Konzept der impliziten Konversionen vorgestellt. Schließlich beschreibt 6 am Beispiel der Actors-Bibliothek zur nebenläufigen Programmierung, wie sich in Scala Bibliotheken
auf natürliche Weise in die Sprache einbetten lassen.
1
aus [4]: M. Odersky. The Scala Experiment - Can We Provide Better Language Support for Component
Systems?
3
2
Grundlagen
In diesem Abschnitt betrachten wir zunächst grundlegende Konstrukte, die zum Verständnis
notwendig sind. So behandelt 2.1 die Deklaration von Variablen bzw. Werten.
In 2.2 wird die Syntax zur Deklaration von Klassen und Methoden vorgestellt. Ferner wird
das Definieren von eigenen Operatoren erläutert und schließlich die Struktur der Klassenhierarchie von Scala vorgestellt.
Zum Verständnis der Implementierung von Funktionen als Objekte ist die Umsetzung
von Generizität in Scala und das Konzept der Varianzannotationen wichtig. Dies wir in 2.3
behandelt.
Schließlich werden in 2.4 verschiedene Möglichkeiten zur Deklaration von Funktionen sowie
deren Verarbeitung gezeigt.
2.1
Variablen und Werte
In Scala wird zwischen Variablen und Werten unterschieden. Eine Variable in Scala entspricht
einer Variable in imperativen Sprachen und muss bei der Deklaration initialisiert werden:
var x: Int = 0
Ein Wert in Scala entspricht einer Variablen in funktionalen Sprachen, kann also nach der
Deklaration nicht mehr verändert werden:
val x: String = "abc"
Die Typannotation ist in beiden Fällen optional, solange der passende Typ durch den TypinferenzAlgorithmus von Scala hergeleitet werden kann.
2.2
Klassen
Die Syntax zur Definition von Klassen in Scala entpricht im wesentlichen der Syntax von
Java:
class Rational(val n: Int, val d: Int) {
val view: String = n + "/" + d
override def toString = view
def this(n: Int) = this(n, 1)
def +(that: Rational): Rational =
new Rational(
n * that.d + that.n * d,
d * that.d
)
}
Die Klasse Rational repräsentiert eine rationale Zahl, definiert durch ihren Nenner n und
ihren Zähler d. Die Konstruktorargumente werden direkt als Parameter der Klasse angegeben.
Zusätzlich ist ein Konstruktor zum bequemeren Erzeugen einer rationalen Zahl aus einer
ganzen Zahl angegeben. Die erste Anweisung in einem solchen auxiliary constructor muss ein
Aufruf des primary constructor sein.
Ferner werden die Methoden toString und + definiert. Die Methode toString muss mit
dem Schlüsselwort override versehen werden, da sie die gleichnamige Methode der Klasse
4
AnyRef überschreibt, welche die Oberklasse aller benutzerdefinierten Klassen in Scala ist. Die
Methode + liefert die Summe zweier rationaler Zahlen zurück. Sonderzeichen wie + sind in
(bzw. als) Methodennamen erlaubt.
Aufrufe von einstelligen Methoden sind zusätzlich zu der klassischen Punkt-Notation auch
in Infix-Schreibweise möglich: 2
var a = new Rational(2)
var b = new Rational(2,3)
var c = a + b //enspricht a.+(b)
Der Programmierer hat so eine bequeme Möglichkeit, eigene Operatoren zu definieren. Genauer entspricht in Scala jede Verwendung eines Operators einem Methodenaufruf.
Der Rumpf der Methode + kann optional auch in der von Java bekannten Schreibweise
eines Blockes in geschweiften Klammern angegeben werden. Die vorgestellte Schreibweise soll
darauf hinweisen, das es sich bei der Methode + um eine Funktion (ohne Seiteneffekte) handelt.
Die Wurzel der Klassenhierarchie in Scala ist die Klasse Any, welche nur die beiden direkten
Unterklassen AnyVal und AnyRef hat. Wie bereits erwähnt, erben alle benutzerdefinierten
Abbildung 1: Klassenhierarchie (aus [6])
Klassen implizit von AnyRef. Die Klasse AnyVal dient dazu, die primitiven Typen des jeweilgen
Host-Systems als Klassen zu kapseln. Wird Scala in einem Java-Umfeld eingesetzt, sind das
die primitiven Typen int, float etc., während AnyRef der Java-Klasse Object entspricht.
2
Die Bindungsstärke und Assoziativität hängt davon ab auf welches Sonderzeichen der Methodenname
endet. (siehe [1])
5
2.3
Generizität und Varianz
Scala erlaubt das Definieren von generischen Klassen mittels Typparametrisierung. Zum Beispiel kann man in Scala eine einfache generische Queue mit Typparameter T auf Basis von
Listen wie folgt definieren (::: ist eine Methode der Klasse List zur Konkatenation zweier
Listen):
class Queue[T](elems: List[T]) {
def head = elems.head
def append(x: T) = new Queue[T](elems ::: List(x))
}
Gültige Typen von Queue sind nun z.B. Queue[Int] oder Queue[Any]. Anders als in Java
ist eine typparametrisierte Klasse standardmäßig nicht-variant. D.h. Queue[String] ist kein
Untertyp von Queue[Any] obwohl Int Untertyp von Any ist und auch andersherum besteht
keine Untertyp-Beziehung.
Beeinflussen kann man die Varianz mit Varianzannotationen. Beispielsweise könnten wir
die Defintion der Queue alternativ mit class Queue[+T] oder class Queue[-T] einleiten.
Im ersten Fall wären alle konkreten Typen von Queue Kovariant in der Untertyp-Beziehung
bezüglich dem Typparameter T. Queue[Int] wäre nun z.B. Untertyp von Queue[Any]. Andersherum bedeutet die Varianzannotation im zweiten Falle Kontravarianz bezüglich T, d.h.
Queue[Any] wäre Untertyp von Queue[Int].
Das kovariante Definieren von T in Queue wird vom Compiler aber abgewiesen, da der Typ
T in der Methode append an kontravarianter Position steht. An einem Beispiel wird dieser
Umstand schnell deutlich: Angenommen es gibt eine Klasse Fruit und die beiden Klassen
Apple und Orange als Unterklassen von Fruit. Im Falle einer kovarianten Queue wollen wir
nun definieren:
val q: Queue[Fruit] = new Queue[Apple](List())
Dies ist offensichtlich nicht möglich, da die append-Methode nur Objekte vom Typ Apple
aufnimmt, d.h. eine Queue[Apple] ist keine Queue[Fruit] und somit nicht kovariant.
Es reicht also nicht aus, die Klasse Queue mit einem als kovariant annotierten Typparameter zu versehen, sondern es muss zusätzlich die append-Methode verallgemeinert werden:
class Queue[+T](elems: List[T]) {
def head = elems.head
def append[U >: T](x: U) = new Queue[U](elems ::: List(x))
}
Die Notation U >: T ist ein lower bound, also eine untere Typschranke. In diesem Beispiel
bewirkt diese Schranke, dass der Typ des Arguments x der Methode append entweder vom
Typ T oder aber ein Obertyp von T sein muss. Nun ist folgende Operation möglich:
q.append(new Orange)
wobei der Rückgabetyp von append nun Queue[Fruit] ist.
Analog ist es möglich, obere Typschranken mit <: anzugeben, so dass man Queue auch
kontravariant definieren kann, in dem man head parametrisiert, was allerdings semantisch
keinen Sinn macht.
6
2.4
Funktionen
Es gibt in Scala verschiedene Möglichkeiten, Funktionen zu definieren. Eine Möglichkeit wurde
bereits in 2.2 gezeigt: Als Merkmal einer Klasse, also als Methode.
Eine weitere Möglichkeit ist das Definieren einer Funktion als lokale Funktion innerhalb
einer anderen Funktion:
def foo(i: Int): Int = {
def bar(j: Int): Int =
bar(i)
}
j * 2
Funktionen sind in Scala Werte erster Klasse, z.B. lassen sich anonyme Funktionen, d.h.
Funktionsliterale ohne Namen angeben:
(x: Int) => x % 2 == 0
Die Funktion testet eine ganze Zahl auf Geradzahligkeit. So ein Funktionsliteral lässt sich
auch einem Wert oder einer Variablen zuweisen:
val even = (x: Int) => x % 2 == 0
Scala unterstützt auch Funktionen höherer Ordnung. Beispielsweise kann man eine FilterFunktion auf Listen mit Elementen beliebigen Typs wie folgt definieren: 3
def filter[A](l: List[A], p: (A) => Boolean): List[A] =
if(l.isEmpty) Nil else {
if(p(l.head)) l.head :: filter(l.tail, p)
else filter(l.tail, p)
}
Die Funktion filter erwartet hier neben einer Liste l eine Funktion p vom Typ (A) =>
Boolean. Wir können filter und even nun einsetzen, um aus einer Liste ganzer Zahlen eine
Liste der enthaltenen geraden Zahlen zu generieren:
val l: List[Int] = List(1,2,3,4,5,6,7,8,9)
filter(l, even)
Analog zu der Schreibweise in der Argumentliste von filter kann man Funktionstypen auch
als Rückgabetyp einer Funktion angeben.
Eine weitere Variante von Funktionen in Scala sind Closures. Eine Closure ist ein Funktionsliteral, das freie Variablen enthält:
(x: Int) => x % y == 0
In diesem Beispiel ist y eine freie Variable. Das Definieren dieser Closure setzt vorraus, dass
im aktuellen Scope eine Bindung für y vorhanden ist.
3
:: ist eine Methode der Klasse List zum Anfügen eines Elements an eine Liste
7
3
Funktionen als Objekte
Da in Scala alles ein Objekt ist, sind auch Funktionen als Objekte implementiert. Die Darstellung von Funktionsliteralen ist lediglich syntaktischer Zucker für Objekte vom Typ Functionn[-S,+T].
Beispielsweise ist die Klasse für einstellige Funktionen in der Scala-Bibliothek wie folgt definiert:
abstract class Function1[-S, +T] {
def apply(x: S): T
}
Klassen für Funktionen mit mehr als einem Argument (bis zu 22) sind analog zu Function1
definiert. Die Deklaration der Funktion even in 2.4 ist äquivalent zu:
val even = new Function1[Int, Boolean] {
def apply(x: Int): Boolean = x % 2 == 0
}
Ein Aufruf von even mit einem Parameter even(7) wird vom Scala-Compiler als ein Aufruf
der apply-Methode interpretiert: even.apply(7).
Funktionen sind in Scala Kontravariant bezüglich ihrer Argumenttypen und Kovariant
bezüglich der Rückgabetypen. Das heißt ein Funktionstyp f 0 ist Untertyp eines Funktionstyps
f falls gilt:
1. die Argumenttypen von f sind Untertypen der Argumenttypen von f 0
2. der Ergebnistyp von f 0 ist Untertyp des Ergebnistypen von f
Einfach ausgedrückt hat also f 0 einen allgemeineren Definitions- und einen spezielleren Bildbereich als f . So kann eine Funktion vom Typ f 0 auch überall dort eingesetzt werden, wo als
Typ f verlangt wird.
Zum Beispiel können wir einem Wert vom Typ Function1[Int,Any] ein Funktionsliteral
vom Typ Function1[Any,String] zuweisen:
val f: Function1[Int,Any] = (x: Any) => "hello": String
Anders als Funktionsliterale sind Methoden (wie in Java) als Merkmale von Klassen implementiert, bestehend aus dem Methodennamen, einer Signatur und zugeordnetem Bytecode.
Dennoch können in Scala Methoden als Funktionen erster Klasse behandelt werden. Im Moment der Zuweisung einer Methode an einen Wert, bei der partiellen Applikation oder bei
der Übergabe einer Methode als Parameter an eine Funktion wird wie oben beschrieben ein
Funktionsobjekt instanziiert.
Zusätzlich gibt es in Scala eine abstrakte Klasse PartialFunction[-S,+T] als Unterklasse von Function1 zur Repräsentation von partiellen Funktionen. Möchte man eine partielle
Funktion definieren, reicht es nicht ein Funktionsliteral anzugeben, man muss PartialFunction
von Hand erweitern. PartialFunction schreibt die Definition einer Methode isDefinedAt(x:
S) vor, die true liefert falls die Funktion für ein Objekt x definiert ist. In der Scala-Bibliothek
sind z.B. Maps als partielle Funktionen implementiert.
8
4
Funktionale Programmierung in Scala
Scala erlaubt das Programmieren in einem imperativen und in einem eher funktionalen Stil, erzwingt aber nicht das Einhalten einer dieser Stile. Dennoch ist das Programmieren in the small
im funktionalen Stil eher zu empfehlen. Es führt zu einer teils erheblichen Code-Reduktion,
vermeidet typische Fehler, die durch Seiteneffekte entstehen, und erleichtert nach einiger Einarbeitungszeit das Lesen von Quellcode.
Beim Programmieren in the large kommen die Vorzüge objektorientierter Programmierung zum Zuge. Scala verfügt über mächtige Konstrukte zur Komposition von Komponenten
zu grossen Software-Systemen. Dies ist aber nicht das zentrale Thema dieser Ausarbeitung.
Stattdessen soll in diesem Abschnitt gezeigt werden wie typische, aus der funktionalen Programmierung bekannte Konzepte in Scala angewandt werden können, um eben solche Komponenten in einem funktionalen Stil zu entwerfen.
In 4.1 wird gezeigt wie man in Scala Funktionen curryfizieren und partiell applizieren
kann. Eine weitere typische Eigenschaft funktionaler Sprachen ist das Pattern-Matching auf
algebraischen Datentypen. Scala erlaubt das Pattern-Matching auf Klassen, dies wird in 4.2
beschrieben. In 4.3 wird untersucht, wie man in Scala die Vorzüge der aus der funktionalen
Programmierung bekannten Lazy-Auswertung nutzen kann.
4.1
Curryfizierte Funktionen und partielle Applikation
Wir betrachten die Umsetzung der Curryfizierung von Funktionen in Scala an einem einfachen
Beispiel:
def sum(x: Int, y: Int) = x + y
Die Funktion sum hat den Typ sum: (Int,Int)Int. Statt die Parameter in Form einer Liste
anzugeben, können wir die Funktion zum Summieren zweier ganzer Zahlen auch in einer
curryfizierten Variante mit mehreren Parameterlisten definieren:
def sum(x: Int)(y: Int) = x + y
Nun hat sum den Typ sum: (Int)(Int)Int. Mittels partieller Applikation können wir nun
aus sum eine Funktion gewinnen die eine ganze Zahl inkrementiert:
def inc = sum(1)_
Der Unterstrich ist hier ein Platzhalter für das fehlende zweite Argument. Eine weitere
Möglichkeit, eine Funktion nur partiell zu applizieren ist in einer nicht curryfizierten Funktion
die fehlenden Argumente in der Parameterliste durch Platzhalter zu ersetzen:
def inc = sum(_: Int, 1)
4.2
Pattern-Matching
In funktionalen Sprachen wird Pattern-Matching üblicherweise durch algebraische Datentypen
ermöglicht. Scala’s Äquivalent zum baumartigen strukturieren von Daten sind die sogenannten
Case-Klassen. Wir können eine Datenstruktur für einfache arithmetische Ausdrücke mit CaseKlassen definieren:
9
abstract class Expr
case class Var(name: String) extends Expr
case class Number(num: Double) extends Expr
case class BinOp(operator: String, left: Expr, right: Expr) extends Expr
Alle Argumente von Case-Klassen sind automatisch als Merkmale der Klasse verfügbar. Beim
Instanziieren von Case-Klassen entfällt das Schlüsselwort new, so das verschachtelte Ausdrücke
besser lesbar sind:
val exp = BinOp("*", Var(x), Number(2))
Wir können nun mittels Pattern-Matching eine Funktion angeben, die den obersten Teilausdruck eines arithmetischen Ausdrucks vereinfacht:
def simplify(expr: Expr): Expr = expr match {
case BinOp("+", e, Number(0)) => e //Addition mit 0
case BinOp("*", e, Number(1)) => e //Multiplikation mit 1
case _ => expr
}
In diesem Beispiel kommen verschiedene Pattern zum Einsatz:
• Das Pattern BinOp("+", e, Number(0)) ist ein constructor pattern, welches wiederum
verschiedene Pattern enthält:
• "+" ist ein constant pattern, dass das erste Argument mittels der ==-Methode der Klasse
String auf Gleichheit überprüft.
• e ist ein variable pattern, dass einen beliebigen Wert an die Variable e bindet, welche
dann auf der rechten Seite der Case-Anweisung zur Verfügung steht.
•
ist das wildcard pattern, dass ebenso auf jeden Wert passt, aber keine Variable einführt.
In dem Beispiel stellt die letzte Case-Anweisung sicher, das für alle Konstruktoren von Expr
ein Pattern greift. Um Fehler zu vermeiden, können wir den Compiler mit dem Schlüsselwort
sealed anweisen, bei der Verwendung von Pattern-Matching zu prüfen, ob alle möglichen
Fälle abgedeckt sind: sealed abstract class Expr.
Eine Case-Sequenz in geschweiften Klammern enspricht einem Funktionsliteral, wobei die
Fälle verschiedene Einstiegspunkte in die Funktion definieren und die Parameter der Funktion
durch die Pattern spezifiziert sind. Deckt die Case-Sequenz nicht alle möglichen Fälle ab,
entspricht sie einer partiellen Funktion. Eine solche Case-Sequenz können wir einem Wert
vom Typ PartialFunction zuweisen:
val second: PartialFunction[List[Int],Int] = {
case x :: y :: _ => y
}
Die Funktion second ist nur auf mindestens zweielementige Listen definiert. Als Objekt vom
Typ PartialFunction steht nun automatisch die Methode isDefinedAt zur Verfügung: Ein
Aufruf von second.isDefinedAt(List(1,2,3)) liefert true, second.isDefinedAt(List())
liefert false.
Eine andere Variante von Pattern-Matching dient der Dekomposition von Objekten, die
durch Case-Klassen definiert sind:
10
val exp = BinOp("*", Var(x), Number(2))
val BinOp(op, left, right) = exp
Als Konsequenz sind nun folgende Werte gebunden: op: String = *, left: Expr = Var("x")
und right: Expr = Number(2).
4.3
Lazy-Auswertung
Ein weiteres wichtiges Konzept aus funktionalen Sprachen ist die Lazy-Auswertung, bei der
Werte erst dann berechnet werden, wenn sie zum ersten Mal benötigt werden. Ausserdem
wird bei der Lazy-Auswertung kein Wert ein zweites Mal berechnet. Dies wird ermöglicht
durch das Konzept der referentiellen Transparenz. D.h. der Wert eines Ausdrucks hängt nur
von den Werten seiner Teilausdrücke ab, nicht aber von der Reihenfolge deren Auswertung.
In Scala haben wir die Möglichkeit, Werte mit dem Schlüsselwort lazy zu versehen, so das
diese Werte erst bei Bedarf initialisiert werden. Das kann uns beispielsweise in folgender Situation helfen. Angenommen es existiert eine Funktion computePi, die die Zahl Pi approximiert,
und wir wollen eine Klasse entwerfen die ein Attribut Pi hat:
class A { val Pi: Double = computePi ... }
Nun würde Pi schon beim Instanziieren der Klasse A berechnet werden, obwohl nicht sicher
ist, ob Pi benötigt wird. Alternativ könnten wir Pi als Methode definieren:
class A { def Pi: Double = computePi ... }
Nun würde Pi erst bei Bedarf berechnet werden, allerdings würde diese Berechnung bei jeder
Verwendung von Pi erneut erfolgen. Besser ist es die Klasse A wie folgt zu definieren:
class A { lazy val Pi: Double = computePi ... }
In dieser Variante wird Pi erst bei Bedarf berechnet und muss bei einer späteren erneuten
Verwendung nicht ein zweites Mal ermittelt werden.
Die Programmierung mit Lazy-Werten in Scala ist problematisch, da keine referentielle
Transparenz sichergestellt ist. Beschränkt man sich darauf, im funktionalen Stil zu Programmieren, d.h. man verwendet keine Variablen sowie keine Funktionen mit Seiteneffekten, ist
das Verwenden von Lazy-Werten unproblematisch. Programmiert man aber im imperativen
Stil oder mischt beide Ansätze, kann es passieren das die Reihenfolge der Auswertung von
Ausdrücken Einfluss auf das Ergebnis der Auswertung hat.
Möchte man die Lazy-Auswertung beim Programmieren in Scala verwenden, muss man
also darauf achten in einem funktionalen Stil zu programmieren und funktionalen Code
sorgfältig von Code mit Seiteneffekten trennen.
Eine häufig in funktionalen Sprachen eingesetzte Technik im Zusammenhang mit der
Lazy-Auswertung ist das Rechnen mit unendlichen Datenstrukturen. Die Scala-Bibliothek
bietet beispielsweise sogenannte Streams an, die potentiell unendlichen Listen entsprechen.
Die Implementierung der Klasse Stream verwendet ausser Lazy-Werten einige fortgeschrittene Techniken, so das sie in dieser Ausarbeitung nicht ausreichend erläutert werden kann. Wir
betrachten aber ein Beispiel zur Verwendung solcher unendlicher Listen.
Zuerst definieren wir eine Funktion fibgen, welche die unendliche Liste aller FibonacciZahlen ab zweier vorgegebener aufeinander folgender Fibonacci-Zahlen erzeugt:
def fibgen(n1: Int, n2: Int): Stream[Int] =
Stream.cons(n1, fibgen(n2, (n1+n2)))
11
Nun können wir eine Funktion definieren, die die unendliche Liste aller Fibonacci-Zahlen
liefert:
def fibs = fibgen(0, 1)
Folgender Aufruf liefert jetzt die Liste der ersten zehn Fibonacci-Zahlen:
fibs take 10
12
5
Implizite Konvertierungen
Ein weiteres interessantes Konzept in Scala sind die sogenannten implicit conversions. Im Falle
von potentiellen Typfehlern prüft der Compiler, ob sich im aktuellen Scope eine Funktion
befindet, die im falschen Typkontext eingesetze Objekte auf den erwarteten Typ abbildet.
Solche Funktionen müssen mit dem Schlüsselwort implicit gekennzeichnet sein.
In 2.2 haben wir eine Klasse Rational definiert, die rationale Zahlen repräsentiert. Diese
Klasse besitzt einen Konstruktor zur einfachen Erzeugung einer rationalen Zahlen aus einer
ganzen Zahl:
val x: Rational = new Rational(3)
Wir können eine implizite Konversion definieren, um eine natürlichere Syntax zur Erzeugung
rationaler Zahlen aus ganzen Zahlen zu erhalten:
implicit def int2rational(x: Int): Rational = new Rational(x)
Befindet sich diese Funktion im aktuellen Scope, können wir nun einem Wert vom Typ
Rational ein Integer-Literal zuweisen:
val x: Rational = 3
Der Compiler vermeidet den potentiellen Typfehler durch Einsetzen der impliziten Konvertierung int2rational:
val x: Rational = int2rational(3)
Das Einsetzen passender impliziter Konvertierungen geschieht nach folgenden Regeln:
• Nur mit dem Schlüsselwort implicit gekennzeichnete Definitionen werden eingesetzt.
• Eine implizite Konvertierung muss sich als ein einzelner Identifier, d.h. auf oberster
Ebene im Scope befinden. Z.B. würde eine Methode convert eines Objekts o, die über
o.convert sichtbar ist, nicht eingesetzt werden.
• Eine implizite Konvertierung wird nicht eingesetzt, wenn sich mehrere passende implizite
Konvertierungen im Scope befinden.
• Es wird nur eine implizite Konvertierung angewandt, auch wenn der Typfehler durch
Verschachteln mehrerer impliziter Konvertierungen verhindert werden könnte.
• Es werden keine impliziten Konvertierungen in typkorrekten Code eingesetzt.
Die Verwendung von impliziten Konvertierungen ist nicht nur in Zuweisungen möglich, sondern erlauben auch das implizite Konvertieren des Empfängers eines Methodenaufrufs. So
erlaubt uns die Konvertierung int2rational auch das Addieren von rationalen Zahlen mit
ganzen Zahlen mittels der +-Methode der Klasse Rational:
3 + new Rational(3,4)
wird automatisch ersetzt durch:
int2rational(3).+(new Rational(3,4))
13
Dieser Mechanismus erlaubt es, bestehenden Klassen neue Funktionalität hinzuzufügen, ohne deren Code zu modifizieren. Nach dieser Sichtweise erweitert die implizite Konvertierung
int2rational die Klasse Int um eine Methode + zur Addition mit rationalen Zahlen. Dies
kann insbesondere genutzt werden, um fremde Bibliotheken den eigenen Bedürfnissen anzupassen und besser in den eigenen Code zu integrieren.
Eine weitere Möglichkeit der Verwendung impliziter Konvertierungen sind implizite Parameter. In der letzten curryfizierten Argumentliste einer Funktion können wir Argumente mit
implicit kennzeichnen: 4
def qsort[T](list: List[T])(implicit ord: T => Ordered[T]): List[T] =
list match {
case Nil => Nil
case x :: xs => qsort(list.filter(_ < x))
::: List(x)
::: qsort(list.filter(_ > x))
}
Wir verlangen damit die Existenz einer impliziten Konvertierung ord, die Elemente des Typs
T auf Elemente des Typs Ordered[T] abbildet. Ordered ist eine in Scala vordefinierte abstrakte Klasse, die typische Vergleichsoperationen vorschreibt. Ähnlich wie Typklassen in Haskell
erlauben implizite Parameter zusätzliche Anforderungen an Typparameter zu stellen 5 . Befindet sich nun eine geeignete implizite Konvertierung im Scope, können wir die Funktion qsort
ohne Angabe des zweiten Arguments verwenden:
qsort(List(7,6,5,4,3,2,1))
oder selbst eine Funktion zur Konvertierung angeben:
qsort(List("h","e","l","l","o"))(myord)
Für solch eine typklassenähnliche Konstruktion gibt es in Scala folgende abkürzende Schreibweise:
def qsort[T <% Ordered[T]](list: List[T]): List[T] =
list match {
case Nil => Nil
case x :: xs => qsort(list.filter(_ < x))
++ List(x)
++ qsort(list.filter(_ > x))
}
Die Schreibweise qsort[T <% Ordered[T]] heisst view bound und bedeutet: “Erlaube in
qsort solche Typen T, die wie ein Ordered[T] behandelt werden können”.
4
5
Die Notation ( < x) ist eine abkürzende Schreibweise für die Closure (a: Int) => a < x
siehe [3]: M. Odersky: Poor man’s type classes
14
6
Beispiel: Actors
Ein gutes Beispiel für das Zusammenspiel von objektorientierten und funktionalen Konzepten
in Scala ist die Actors-Bibliothek zur nebenläufigen Programmierung mit Message-Passing im
Stile von Erlang. Darüberhinaus zeigt die Actors-Bibliothek, wie man mit den in dieser Ausarbeitung vorgestellten Konzepten eine domänenspezifische Sprache als Bibliothek einbetten
kann.
Zur Illustration betrachten wir folgendes “Ping-Pong-Spiel”, in dem sich zwei Prozesse
abwechselnd die Nachrichten “ping” und “pong” zusenden: 6
1
import scala.actors._
2
3
4
case class Ping()
case class Pong()
5
6
7
8
9
10
11
12
13
14
15
16
17
class PingActor(pong: Actor) extends Actor {
def act() {
pong ! Ping
while(true) {
receive {
case Pong =>
println("Ping: pong")
pong ! Ping
}
}
}
}
18
19
20
21
22
23
24
25
26
27
28
29
class PongActor extends Actor {
def act() {
while(true) {
receive {
case Ping =>
println("Pong: ping")
sender ! Pong
}
}
}
}
30
31
32
33
34
35
36
object pingpong extends Application {
val pong = new PongActor
val ping = new PingActor(pong)
ping.start
pong.start
}
6
In diesem Beispiel wird das Schlüsselwort object zur Deklaration einer Klasse verwendet. Eine so erzeugte
Klasse ist nur einmal instanziierbar. Dies entspricht nicht einer statischen Klasse in Java, sondern dem
Singleton-Pattern.
15
Das Programm beginnt mit dem Importieren der Actors-Bibliothek. Anschliessend definieren wir die beiden möglichen Nachrichten als Case-Klassen ohne weitere Merkmale. In den
Zeilen 6 und 19 beginnen die Definitionen der beiden Actors. Beide implementieren die actMethode der abstrakten Klasse Actor. Der PingActor erhält über seinen Konstruktor eine
Referenz pong auf den PongActor. Die beiden Actors werden dann in den Zeilen 34 und 35
mit der start-Methode als eigene Prozesse gestartet, was dazu führt das deren act-Methoden
ausgeführt werden.
Der PingActor beginnt die Kommunikation, indem er dem zweiten Actor in Zeile 8 die
Nachricht Ping schickt. Dazu rufen wir die Methode ! des PongActor auf und übergeben
Ping. Anschliessend warten beide Actors in einer Endlosschleife auf eingehende Nachrichten,
die mit Pattern-Matching geprüft und dann entsprechende Antworten versandt werden. Das
Konstrukt receive ist kein Sprachbestandteil, sondern eine gewöhnliche Methode der Klasse
Actor mit folgender Signatur:
def receive[R](f : PartialFunction[Any, R]) : R
Genauso ist sender eine Methoder der Klasse Actor. So wird vermieden, bei jedem Verschicken
einer Nachricht eine Referenz auf den Absender übergeben zu müssen. Wie bereits in 4.2
gezeigt entspricht eine Case-Sequenz einer partiellen Funktion. Im Beispiel übergeben wir eine
solche Sequenz an receive. Scala erlaubt das Übergeben von Argumenten wahlweise auch in
geschweiften Klammern. Jede eingehende Nachricht in einem receive-Block wird zuerst an
die Methode isDefinedAt der Case-Sequenz weitergereicht und anschließend appliziert, falls
eine entsprechende Case-Klausel existiert.
16
7
Zusammenfassung
Den Entwicklern von Scala ist es gelungen, objektorientierte und funktionale Konzepte auf
konsistente Weise in einer Sprache zu vereinen. Aufgrund der Verwandschaft zu Java und C#
wirkt die Syntax im Vergleich zu funktionalen Sprachen wie Haskell oft etwas sperrig. Auf der
anderen Seite ist es durch eben diese Verwandschaft gelungen viele Programmierer, auch aus
der objektorientierten Szene, für Scala zu begeistern. Es hat sich bereits eine große und schnell
wachsende Community um Scala gebildet. Eine erste Erfolgsgeschichte ist beispielsweise die
Webapplikation Twitter 7 , welche in weiten Teilen in Scala geschrieben ist. Aktuell entstehen
viele Tools und Frameworks zur Entwicklung in Scala, so wurde z.B. kürzlich das an Ruby on
Rails angelehnte Webframework Lift 8 veröffentlicht, was die Verbreitung von Scala vermutlich
noch beschleunigen wird.
In der offiziellen Scala-Mailingliste und im Web scheint nun ein Hype um das Paradigma
der “objekt-funktionalen-Programmierung” zu entstehen, es ist auch schon ein Buch zu diesem
Thema erschienen 9 . Ob dieser Ansatz aber hält was er verspricht, insbesondere im Hinblick
auf die eingangs erwähnten Hypothesen, bleibt abzuwarten. Es wäre interessant zu erfahren,
wie die Entwickler von Scala um Martin Odersky dies in einigen Jahren rückblickend bewerten.
Sicher aber ist, das Scala das komfortable Programmieren auf einer hohen Abstraktionsebene erlaubt. Scala ist so eine gelungene Alternative zu Java und wird sicherlich vielen
Entwicklern das Programmieren in einem funktionalen Stil vermitteln. Die Scala-Bibliothek
wirkt sehr durchdacht und bietet gute Werkzeuge für typische Problemstellungen die sich
nahtlos in die Sprache integrieren.
Nicht behandelt in dieser Ausarbeitung wurden vor allem Konzepte zur Komposition von
Objekten, wie z.B. Traits, die das Konstruieren von Objekten aus mehreren Klassen erlauben, dabei aber die Probleme der Mehrfachvererbung vermeiden. Nicht behandelt wurden
auch sogenannte for-Comprehensions, die eine Kontrollstruktur im Stile von Iteratoren sind,
aber ähnlich wie Datenbankanfragesprachen Möglichkeiten zum filtern und transformieren
von Daten bieten. Oder auch das fortgeschrittene Konzept der extractors, das das PatternMatching nicht nur auf Case-Klassen erlaubt. Darüberhinaus gibt es vor allem viele interessante Bibliotheken die eine Auseinandersetzung Wert wären. So gibt es Bibliotheken zur
komfortablen Verarbeitung von XML, zum Parsen mit Kombinatoren, zur Programmierung
grafischer Oberflächen und viele mehr.
7
http://twitter.com
http://liftweb.net
9
http://www.scala-lang.org/node/959
8
17
Literatur
[1] M. Odersky et al. An overview of the scala programming language. Technical report,
EFPL Zuerich, 2006.
[2] B. Venners M. Odersky, L. Spoon. Programming In Scala. artima, 2008.
[3] M. Odersky. Poor man’s type classes. Talk at IFIP WG 2.8, 2006.
[4] M. Odersky. The scala experiment - can we provide better language support for component
systems? Talk at POPL’06, 2006.
[5] M. Odersky. The scala experience - programming with functional objects. Talk at PPPJ
2007, 2007.
[6] M. Odersky. The Scala Language Specification Version 2.7, 2009.
18
Herunterladen