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