Heiko Seeberger, Roman Roelofsen
Durchstarten mit Scala
Heiko Seeberger, Roman Roelofsen Durchstarten mit Scala ISBN: 978-3-86802-251-3 © 2011 entwickler.press Ein Imprint der Software & Support Media GmbH
Bibliografische Information Der Deutschen Bibliothek Die Deutsche Bibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.ddb.de abrufbar.
Ihr Kontakt zum Verlag und Lektorat: Software & Support Media GmbH entwickler.press Geleitsstr. 14 60599 Frankfurt am Main Tel.: +49 (0)69 630089-0 Fax: +49 (0)69 930089-89
[email protected] http://www.entwickler-press.de
Lektorat: Sebastian Burkart Korrektorat: Redaktion ALUAN Köln Satz: Dominique Kalbassi Belichtung, Druck & Bindung: M.P. Media-Print Informationstechnologie GmbH, Paderborn Alle Rechte, auch für Übersetzungen, sind vorbehalten. Reproduktion jeglicher Art (Fotokopie, Nachdruck, Mikrofilm, Erfassung auf elektronischen Datenträgern oder anderen Verfahren) nur mit schriftlicher Genehmigung des Verlags. Jegliche Haftung für die Richtigkeit des gesamten Werks kann, trotz sorgfältiger Prüfung durch Autor und Verlag, nicht übernommen werden. Die im Buch genannten Produkte, Warenzeichen und Firmennamen sind in der Regel durch deren Inhaber geschützt.
I
Inhaltsverzeichnis
Vorwort
1 Warum Scala?
9 11
1.1 Was ist Scala?
11
1.2 Warum Scala statt Java?
12
1.3 Warum Scala statt Groovy, JRuby & Co?
16
2 Entwicklungsumgebung 2.1 Kommandozeilen-Werkzeuge
17 17
2.1.1 scalac und fsc
18
2.1.2 scala
20
2.1.3 scaladoc
21
2.2 Build-Werkzeuge
21
2.2.1 Ant
22
2.2.2 Maven
23
2.2.3 SBT
2.3 IDEs
24 27
2.3.1 Scala IDE for Eclipse
28
2.3.2 IntelliJ IDEA
29
3 Das Fallbeispiel „ScalaTrain“
31
4 Erste Gehversuche in der REPL
33
4.1 Variablen
33
4.1.1 Unveränderliche Variablen
33
4.1.2 Veränderliche Variablen
35
4.2 Methoden
35
4.2.1 Alles hat ein Ergebnis
35
4.2.2 Unit-Methoden
37
4.3 Funktionen
Durchstarten mit Scala
38
5
1 – Inhaltsverzeichnis 5 OO-Grundlagen 5.1 Vorbereitung: Projekt initialisieren
41
5.2 Klassen
42
5.2.1 Klassenparameter und Konstruktoren
43
5.2.2 Felder
45
5.2.3 Methoden
47
5.2.4 Named and Default Arguments
50
5.3 Packages und Sichtbarkeit
51
5.3.1 Verschachtelte Packages
52
5.3.2 Imports
53
5.3.3 Sichtbarkeit 5.4 Singleton Objects
54 55
5.4.1 Companion Objects
56
5.4.2 Predef
56
5.5 Case Classes
57
5.6 Projekt-Code: aktueller Stand
60
6 Testen von Scala-Programmen 6.1 Unit-Tests mit specs
61 61
6.1.1 Vorbereitung: Dependencies verwalten mit SBT
61
6.1.2 Testfälle einfach gemacht
64
6.1.3 Testdaten einfach gemacht
66
6.2 Test Coverage mit scct
68
6.3 Projekt-Code: aktueller Stand
71
7 Erste Schritte mit FP 7.1 Scala-Collections
6
41
73 74
7.1.1 Klassenhierarchie
74
7.1.2 Collection-Instanzen erzeugen
75
7.1.3 Typ-Parameter
76
7.1.4 Tupel
77
7.1.5 Unveränderliche und veränderliche Collections
78
7.1.6 Collections in ScalaTrain
80
7.2 Funktionale Collections
81
7.2.1 Funktionsliterale
81
7.2.2 Funktions-Typen
83
7.2.3 Funktionale Collections in ScalaTrain
85
7.2.4 map, flatMap und filter im Detail
90
7.3 For Expressions und For Loops
93
7.3.1 For Expressions
95
7.3.2 For Loops und foreach
98
7.4 Projekt-Code: aktueller Stand
100
8 Vererbung und Traits 8.1 Vererbung
101 101
8.1.1 Sub-Klassen mit extends definieren
101
8.1.2 Member überschreiben
104
8.1.3 Abstrakte Klassen und Member
107
8.1.4 Scala-Typhierarchie
110
8.2 Traits
112
8.2.1 Traits hinein mixen
114
8.2.2 Linearisierung
115
8.2.3 Beispiel: Ordered implementieren
118
8.2.4 Einschub: By-Name Parameters
119
8.2.5 Self Types
121
8.3 Projekt-Code: aktueller Stand 9 Pattern Matching
123 127
9.1 match-Ausdrücke
127
9.2 Welche Pattern gibt es?
128
9.2.1 Wildcard Pattern
128
9.2.2 Constant Pattern
128
9.2.3 Variable Pattern und Typed Pattern
128
9.2.4 Tuple Pattern
129
9.2.5 Constructor Pattern
129
9.2.6 Sequence Pattern
131
9.3 Pattern Guards und Variable Binding
132
9.4 Pattern Matching außerhalb von match-Ausdrücken
133
9.5 Projekt-Code: aktueller Stand
134
Durchstarten mit Scala
7
1 – Inhaltsverzeichnis 10 Scala und XML 10.1 XML-Literale
137
10.2 XML-Verarbeitung
138
10.3 XML für ScalaTrain
140
10.4 Projekt-Code: aktueller Stand
141
11 Implicits 11.1 Implicit Conversions 11.1.1 Implicit Conversions zum Expected Type 11.1.2 Implicit Conversions des Receivers
143 144 146 149
11.3 Type Classes
152
11.4 Projekt-Code: aktueller Stand
155 159
12.1 Rekursion
159
12.2 Upper Bounds und View Bounds
162
12.2.1 Einschub: Package Objects
162
12.2.2 Einschub: Varianz
163
12.2.3 Upper Bounds
164
12.2.4 View Bounds
165
12.3 Existential Types
166
12.4 Vertiefung objekt-funktionale Programmierung
168
12.4.1 Problemstellung
168
12.4.2 Lösungsansatz
169
12.4.3 Streckenabschnitte ermitteln
171
12.5 Verbindungen ermitteln
174
12.6 Projekt-Code: aktueller Stand
176
13 Scala Libraries
8
143
11.2 Implicit Parameters
12 Fortgeschrittene Konzepte
137
181
13.1 Validieren mit scalaz
181
13.2 Web-Applikationen mit Lift
186
13.2.1 Lift-Konfiguration
187
13.2.2 View First
188
13.2.3 Snippets
191
13.3 Abschluss – Endgültiger Stand
194
Stichwortverzeichnis
201
V Vorwort
Scala ist eine Programmiersprache, die zugleich radikal und praktisch ist. Radikal, weil sie gängige Techniken der objektorientierten Programmierung durch funktionales Programmieren ergänzt und in dieser Kombination vermutlich weitergeht als alle anderen gängigen Programmiersprachen. Praktisch, weil die Sprache und zugehörige Werkzeugkette großen Wert auf Interoperabilität mit Java und den zugehörigen Standards legen. Diese Kombination erfreut sich zunehmender Beliebtheit. Das spiegelt sich auch im Buchmarkt wider, wo nach letztem Stand bereits 20 Titel über Scala und verwandte Technologien erschienen sind. Warum also noch ein neues Werk, ist nicht schon alles geschrieben worden? Tatsächlich füllt „Durchstarten mit Scala“ eine wichtige Lücke: Es richtet sich an praktizierende Java-Programmierer und bringt ihnen Scala in kondensierter und doch verständlicher Form bei. Das Buch ist wunderbar zielgerichtet. Ein einziges Programmierbeispiel, das Erstellen einer Fahrplanauskunft, zieht sich wie ein roter Faden durch das ganze Werk hindurch. Der Leser ist angehalten, das Beispiel selbst als Projekt anzupacken, von ersten Gehversuchen in der neuen Sprache bis zu einer funktionsfähigen Webanwendung, die auf dem Lift-Webframework basiert. Die wichtigen Aspekte der Sprache, die das Programmieren einfacher und produktiver machen, werden dem Leser Schritt für Schritt näher gebracht. Es soll hier keine Referenz aller verfügbaren Konstrukte der Sprache angeboten werden, dafür gibt es andere Titel. Aber trotz der kompakten Darstellung wird eine große Anzahl von Konstrukten kompetent eingeführt. Das schließt Scalas objektorientierte Seite ein, mit Konstrukten wie Klassen, Konstruktoren, Traits und Self-Types. Und Scalas funktionale Seite kommt auch nicht zu kurz: Unveränderbare Datenstrukturen, Funktionen höherer Ordnung, Rekursion, Pattern-Matching werden alle behandelt. Sogar einige fortgeschrittene Konzepte für Softwarearchitekten, wie z. B. implizite Konversionen und Parameter, werden erklärt. Das Buch ist voll und ganz an der Praxis ausgerichtet. Gleichzeitig mit der Sprache werden auch die wesentlichen Entwicklungswerkzeuge vorgestellt. Von der Werkzeuginstallation bis zum systematischen Test ist nichts ausgelassen. Jeder notwendige Schritt, der nötig ist, um zu funktionierenden Anwendungen zu kommen, wird detailliert beschrieben, unnötige Umwege werden vermieden. Das Buch ist deshalb hervorragend geeignet für Leser, die Scala lernen wollen, aber nicht viel Zeit haben, und das dürften wohl die meisten sein.
Durchstarten mit Scala
9
1 – Vorwort Heiko Seeberger und Roman Roelofsen zeigen in „Durchstarten mit Scala“, dass sie wissen, worüber sie schreiben. Als Committer der Lift- und Akka-Projekte und Trainer für Scala-Kurse kennen sie die Materie genau. Sie haben hier hervorragende Arbeit geleistet, Scala komprimiert und systematisch aufzubereiten. Ich hoffe, die Leser werden ebenso viel Freude wie ich daran haben, dem roten Faden des Buches zu folgen.
Professor Martin Odersky
10
1 1
Warum Scala?
Ja, warum sollten wir uns eigentlich mit Scala beschäftigen? Mit Blick auf die schiere Vielzahl an Programmiersprachen eine berechtigte Frage. Wir können diese auch anders formulieren, und zwar: Welche Vorteile bringt Scala gegenüber anderen Programmiersprachen? Mit der Antwort wollen wir es uns hier ein bisschen einfacher machen, indem wir ausschließlich die Java-Plattform betrachten. Das bedeutet, dass wir nur solche Programmiersprachen unter die Lupe nehmen, die auf der JVM (Java Virtual Machine) laufen. Diese Einschränkung halten wir deswegen für legitim, weil die Java-Plattform sehr weit verbreitet ist und gerade im dynamischen Bereich der Enterprise Applications eine recht dominante Stellung genießt. Gute Gründe hierfür sind – ohne Anspruch auf Vollständigkeit - das Write-Once-Run-Everywhere-Prinzip sowie die Menge an verfügbaren Libraries für nahezu alle denkbaren Anwendungsfälle.
1.1
Was ist Scala?
Vor der Frage nach dem Warum steht erst einmal diejenige nach dem Was. Auf einen Satz kondensiert lautet unsere Antwort: Scala ist eine moderne und dennoch reife, objektfunktionale, praxisorientierte, statisch typisierte und trotzdem leichtgewichtige Sprache für die JVM, die vollständig kompatibel zu Java ist. Wir wollen im Folgenden kurz ein paar dieser Eigenschaften herauspicken und genauer erläutern. Alles weitere bzw. sich ein Bild von Scala zu machen, überlassen wir dann dem Leser für den weiteren Verlauf dieses Buches.
Scala wurde von Martin Odersky, Professor an der Schweizer Hochschule EPFL1 erfunden, der zuvor bereits an wichtigen konzeptionellen Neuerungen für Java gearbeitet hatte, zum Beispiel an den Generics. Das erste Scala-Release gab es bereits 2003 und aktuell liegt die Version 2.8.1 vor, sodass wir Scala trotz dem Hype, den wir seit ein oder zwei Jahren sehen, getrost als reife Sprache betrachten können. Dieses Bild wird verstärkt durch die Gründung der Fima Scala Solutions2 im Jahr 2010, die kommerziell hinter der Sprache steht.
1
http://www.epfl.ch
2
http://scalasolutions.com
Durchstarten mit Scala
11
1 – Warum Scala? Das vielleicht wichtigste Kriterium, um Scala zu charakterisieren, ist der hybride Charakter der Sprache: Scala ist einerseits objektorientiert, und das sogar viel stringenter als Java. Andererseits ermöglicht Scala funktionale Programmierung3, also „so etwas mit Closures“, die ja spätestens seit der Diskussion um Java 7 bzw. 8 recht große Bekanntheit genießen. Scala bietet sozusagen das beste aus beiden Welten und das heute schon und nicht erst irgendwann in der ungewissen Java-Zukunft. Abschließend noch das aus unserer Sicht definitiv wichtigste Kriterium für den Erfolg von Scala: Wir können aus Scala heraus jeglichen Java-Code nutzen, egal ob unsere liebgewonnen Frameworks für Logging, Persistenz etc. oder unsere eigenen Java-Projekte. Durch diese Abwärtskompatibilität müssen wir nicht bei Null anfangen, sondern können unsere bestehenden „Java-Assets“ ver- und aufwerten.
1.2
Warum Scala statt Java?
Das führt uns direkt zur Frage, warum wir uns mit Scala beschäftigen sollten, wo es doch (die Sprache) Java gibt. Zum Glück fällt uns die Antwort sehr leicht und wir können sie sehr deutlich formulieren: Im Vergleich zu Java können wir mit Scala sowohl produktiver arbeiten, als auch die Qualität steigern. Zugegebenermaßen benötigen wir zunächst ein wenig Zeit, um mit Scala durchzustarten, aber die langfristigen Vorteile überwiegen mit Sicherheit den Nachteil des Lern-Aufwands, den jede neue Technologie mit sich bringt. Gerade Java-Programmierer sollten hierfür ein offenes Ohr haben, denn auch Java ist noch recht jung, weshalb viele von uns zuvor von anderen Programmiersprachen auf Java umgestiegen sind. Welches sind nun die Gründe dafür, dass Scala Produktivität und Qualität im Vergleich zu Java zu steigern vermag? Wir wollen hierfür die folgenden ins Feld führen: Weniger Code und höheres Abstraktionsniveau. Wie wir im weiteren Verlauf dieses Buches sehen werden, benötigen wir mit Scala signifikant weniger Code als mit Java, um ein Programm zu realisieren, das dieselben Anforderungen erfüllt. Mit signifikant meinen wir mindestens eine Reduktion von 50%, möglicherweise bzw. situativ sogar bis zu 80%. Es ist zwar richtig, dass moderne JavaEntwicklungsumgebungen beim Schreiben eines Teils des eingesparten Codes sehr gut unterstützen können; man denke nur an die Funktion „Generate Getters and Setters” bei Eclipse4 oder einer anderen Integrierten Entwicklungsumgebung (IDE). Somit sind wir beim Code-Schreiben nicht unbedingt bedeutend schneller. Aber wenn wir an unseren Programmierer-Alltag denken, dann werden die meisten von uns feststellen, dass wir insgesamt mehr Zeit darauf verwenden, Code zu lesen und zu verstehen, als zu schreiben. Das betrifft nicht nur fremden Code, den wir übernehmen oder warten dürfen, sondern 3
http://de.wikipedia.org/wiki/Funktionale_Programmierung
4
http://www.eclipse.org/
12
Warum Scala statt Java? auch eigenen, den wir vor Wochen oder gar Monaten geschrieben haben und dessen Verständnis uns heute einiges abverlangt. Es gibt Untersuchungen5, die belegen, dass die Geschwindigkeit, wie schnell wir Code lesen und verstehen können, ganz entscheidend von der Code-Menge bestimmt wird. Wenn wir zum Beispiel an eine typische JavaBean denken, die quasi nur einen DatenContainer für eine Handvoll Properties darstellt, dann sehen wir uns einer gewaltigen Menge an „semantisch wertlosem” Code gegenüber: Getter, die nur ein privates Feld zurückgeben. Meist auch Setter, die nur ein privates Feld verändern. Vermutlich auch mehrere Konstruktoren, mit denen alle oder zumindest einige der Felder initialisiert werden können. Und nicht selten auch noch Implementierungen der Methoden equals, hashCode und toString. Wenn wir nun diesen Code lesen und verstehen wollen, dann müssen wir im Verstand einen Filter aktivieren, der all den überflüssigen Kitt ausblendet und nur die eigentliche Intention durchlässt. Ein explizites Beispiel gefällig? Wie wäre es mit einer Klasse für eine Person, die Vor- und Nachname haben soll. Im Vorgriff auf die Prinzipien der funktionalen Programmierung soll diese Person unveränderlich sein, d.h. einmal erzeugt können deren Attribute nicht mehr verändert werden. Dieses Prinzip des Immutable Object6 ist natürlich auch in Java bekannt, nicht erst seit dem Buch Effective Java7 von Joshua Bloch. Zunächst zeigen wir den Java-Code, bei dem insbesondere in Methoden equals und hashCode von der Entwicklungsumgebung generiert wurden: public class Person { private final String firstName; private final String lastName; public Person(String firstName, String lastName) { this.firstName = firstName; this.lastName = lastName; } public String getFirstName() { return firstName; } public String getLastName() { return lastName; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Person person = (Person) o; if (firstName != null ?
5
http://infoscience.epfl.ch/record/138586/files/dubochet2009coco.pdf
6
http://en.wikipedia.org/wiki/Immutable_object
7
http://java.sun.com/docs/books/effective/
Durchstarten mit Scala
13
1 – Warum Scala? !firstName.equals(person.firstName) : person.firstName != null) return false; if (lastName != null ? !lastName.equals(person.lastName) : person.lastName != null) return false; return true; } @Override public int hashCode() { int result = firstName != null ? firstName.hashCode() : 0; result = 31 * result + (lastName != null ? lastName.hashCode() : 0); return result; } }
Und nun im Vergleich der äquivalente Scala-Code, der wirklich vollständig vergleichbar ist, wie wir im Verlauf dieses Buches noch sehen werden: case class Person(firstName: String, lastName: String)
Nur eine einzige Code-Zeile in Scala im Vergleich zu – je nachdem wie wir zählen – 20 oder mehr in Java! Das liegt daran, dass wir nicht all die kleinen Details wie zum Beispiel die Zugriffsmethoden oder equals und hashCode ausprogrammieren müssen, sondern der Scala-Compiler diese Arbeit für uns macht. Zwar haben gerade erfahrene Java-Entwickler einen besonders gut geschulten kognitiven Filter für irrelevante Details, sodass wir beim Geschwindigkeitsvergleich für Lesen und Verstehen natürlich nicht von einem Verhältnis 1:20 ausgehen dürfen. Aber sicher kann niemand ernsthaft bestreiten, dass dieser ScalaCode nicht um Faktoren rascher aufgenommen werden kann, als der entsprechende JavaCode. Und nun stellen wir uns einmal ein komplexeres Beispiel vor, bei dem wir den Java-Code ohne mehrfaches Blättern bzw. Scrollen gar nicht mehr lesen können. Es gibt Aussagen8, dass zwischen der Code-Menge und der Anzahl von Fehlern ein direkter Zusammenhang bestünde. Oder anders ausgedrückt: Je mehr Code, desto mehr Fehler. Unabhängig davon, ob das im Allgemeinen tatsächlich zutrifft, leuchtet das zumindest für unser Beispiel sofort ein. Denn der Java-Code ist sehr „low-level“, d.h. geht stark ins Detail, und da versteckt sich bekanntlich gerne der eine oder andere Fehler. Natürlich kann gerade der Code für dieses Beispiel mit Hilfe der Entwicklungsumgebung generiert werden, sodass die Aussage erst einmal doch nicht zutrifft. Aber wenn wir an unsere Java-Projekte denken, dann erkennen wir schnell, dass wir „low-level“ Code ganz oft auch mit der Hand schreiben, sodass Scala mit seinem größeren Anteil an „high-level“ Code zur besseren Qualität beiträgt.
8
14
http://en.wikipedia.org/wiki/Source_lines_of_code
Warum Scala statt Java? Nachdem wir nun erklärt haben, warum Scala durch weniger Code im Vergleich zu Java die Produktivität und Qualität zu steigern vermag, wollen wir unseren Blick auf unser zweites Argument richten, auf das höhere Abstraktionsniveau. Wie wir sehen werden, führt dieses auch zu weniger Code. Aber im Gegensatz zu obigem Beispiel, bei dem es im wesentlichen darum ging, semantisch wertlosen Kitt wegzulassen, betrachten wir nun neue Ausdrucksformen, die im Vergleich zu Java mehr „high-level“ sind. Als Beispiel betrachten wir zunächst eine Liste von Personen, die wir anhand ihrer Nachnamen sortieren möchten. In Java könnten würden wir wohl – ganz im guten alten imperativen Programmierstil – Hilfsvariablen anlegen und Schleifen programmieren, innerhalb derer wir die Hilfsvariablen verändern. Wir würden uns also im Detail darum kümmern, wie die Anforderung umzusetzen ist. In Scala hingegen machen wir das folgendermaßen, wobei wir davon ausgehen, dass wir eine Variable persons haben, die eine Liste von Personen repräsentiert. persons sortWith { (p1, p2) => p1.lastName < p2.lastName }
Bitte nicht erschrecken! Hier geht es nicht darum, diesen Scala-Code exakt zu verstehen. Vielmehr wollen wir zwei Dinge deutlich machen. Erstens gibt es offenbar für Scala-Collections eine Methode sortWith, deren Bedeutung sich hoffentlich direkt aus dem Namen erschließt. Und zweitens übergeben wir dieser Methode ein Stück Code, welches offenbar die Sortier-Logik enthält. Im Vorgriff auf spätere Kapitel: Es handelt sich hier um etwas, das es in Java nicht gibt, und zwar um eine Funktion, die als Parameter zwei Personen erwartet, deren Nachnamen vergleicht und true oder false zurückgibt, je nachdem welcher der beiden „kleiner“ ist. Auch wenn wir diesen Code jetzt noch nicht im Detail verstehen, können wir erkennen, dass ganz klar zum Ausdruck gebracht wird, was wir erreichen wollen; nämlich die Liste von Personen anhand der Nachnamen sortieren. Um das „Wie?” hingegen, das uns eigentlich gar nicht interessiert, brauchen wir uns auch nicht kümmern, denn das steckt in der Implementierung der Methode sortWith verborgen. Durch diese mächtige Methode in Verbindung mit dem für Java neuen Sprachkonstrukt von Funktionen erreichen wir in Scala eine höhere Abstraktionsebene, auf der wir uns nur noch um das „Was?” kümmern müssen. Dadurch, dass wir die Details des „Wie?” weglassen können, sind wir natürlich schneller und machen weniger Fehler, denn gerade die Details kosten Zeit und bergen hohes Risiko, etwas falsch zu machen.
Durchstarten mit Scala
15
1 – Warum Scala?
1.3
Warum Scala statt Groovy, JRuby & Co?
So weit, so gut, Scala bietet also Vorteile gegenüber Java. Aber wie sieht es denn mit den anderen Programmiersprachen für die JVM aus, die in den letzten Jahren entstanden sind? Deren populärste Vertreter sind wohl Groovy9, JRuby10 und – zumindest für die LISP-Freunde unter uns – Clojure11. All diese Sprachen haben ihre Stärken und setzen das eine oder andere innovative Konzept um, sodass wir volles Verständnis haben, eine solche Sprache anstatt Java zu verwenden. Wobei, es gibt da schon einen Punkt, bei dem wir skeptisch sind. Und zwar handelt es sich bei all den genannten Sprachen um dynamisch typisierte Sprachen. Mit anderen Worten gibt es keine Überprüfung durch den Compiler, ob wir unsere Objekte oder Funktionen korrekt verwenden. Das wird zwar manchmal sogar als Vorteil verkauft, aber aus unserer Sicht ist es in den allermeisten Fällen genau anders herum: Für „echte“ Software-Projekte möchten wir auf keinen Fall eine Programmiersprache verwenden, die nicht statisch typisiert ist. Wir möchten nämlich Fehler bereits beim Compilieren erkennen und vermeiden, anstatt diese zur Laufzeit um die Ohren geworfen zu bekommen. Scala ist eine statisch typisierte Sprache. Das bedeutet, dass wir einer Methode, die ein Argument vom Typ String erwartet, kein Date übergeben können. Darum geben wir Scala ganz klar den Vorzug vor Groovy, JRuby und Clojure. Natürlich gibt es Szenarien, in denen dynamische Typisierung tatsächlich einen so gravierenden Vorteil bringt, dass daran kein Weg vorbei führt. Aber zumindest in unserem Programmier-Alltag sind diese Szenarien rar gesät, sodass Scala für uns fast immer die beste Wahl darstellt.
9
http://groovy.codehaus.org/
10 http://www.jruby.org/ 11 http://clojure.org/
16
2 2
Entwicklungsumgebung
In diesem Kapitel kümmern wir uns um unser Handwerkszeug, das wir für die Programmierung mit Scala benötigen. Wie wir sehen werden, stehen uns verschiedene Möglichkeiten zur Verfügung, vom der minimalistischen Kommandozeile bis hin zur komfortablen IDE. Wir werden für die Beispiele in diesem Buch eine Konstellation wählen, wie wir sie auch regelmäßig in unseren „echten“ Scala-Projekten einsetzen. Ganz konkret werden wir meist mit einer Kombination aus dem Build-Werkzeug SBT und der IDE IntelliJ IDEA arbeiten und manchmal auch – für schnelle Experimente – mit der von der Kommandozeile aus gestarteten REPL; die Details zu diesen Werkzeugen folgen unten. Allerdings ermutigen wir den Leser ausdrücklich, eigene Experimente anzustellen, um die für ihn am besten geeignete Wahl zu treffen. Selbstverständlich wollen wir nicht nur Trockenschwimmen üben, sondern die verschiedenen Möglichkeiten anhand des Klassikers schlechthin demonstrieren, also am guten alten „Hello World“. Dabei werden uns natürlich die ersten Scala-Sprachkonstrukte begegnen, die wir jedoch erst in späteren Kapiteln im Detail erläutern werden.
2.1
Kommandozeilen-Werkzeuge
Wer bisher ausschließlich auf den Komfort einer IDE gesetzt hat und daher jetzt geneigt ist, dieses Kapitel zu überspringen, dem sei schon an dieser Stelle vorweg gesagt, dass Scala – anders als Java – über einen interaktive Konsole verfügt, die sich für Experimente und zum Testen ganz hervorragend eignet. Da dieses kleine aber feine Werkzeug aus dem Alltag eines Scala-Entwicklers quasi nicht wegzudenken ist, empfehlen wir dringend, sich zumindest das Kapitel 2.1.2 über scala zu Gemüte zu führen. Bevor wir die einzelnen Scala-Werkzeuge für die Kommandozeile betrachten können, müssen wir diese installieren. Dazu laden wir uns von der Scala-Website1 die aktuelle Scala-Distribution als Archiv herunter, je nach Plattform im tgz- oder zip-Format. Zum Zeitpunkt, da wir dieses Buch schreiben, handelt es sich dabei um die Version 2.8.1. Spätere 2.8-Versionen sollten ohne Einschränkungen verwendbar sein und hoffentlich ebenso zukünftige 2.9-Versionen. Nun entpacken wir das Archiv und fügen unserem Pfad das bin-Verzeichnis der Scala-Distribution hinzu. Alternativ zum eben beschriebenen Vorge-
1
http://www.scala-lang.org
Durchstarten mit Scala
17
2 – Entwicklungsumgebung hen stellen manche Paket-Manager, zum Beispiel Homebrew für Mac OS X, Installationspakete für Scala zur Verfügung. Im Resultat sollten wir jedenfalls, unabhängig vom Weg dorthin, in der Lage sein, das Folgende auf der Kommandozeile nachzuvollziehen: tmp$ scala -version Scala code runner version 2.8.1.final -- Copyright 2002-2010, LAMP/EPFL
2.1.1
scalac und fsc
Als erstes wollen wir unsere Aufmerksamkeit auf den Scala-Compiler richten. Dafür benötigen wir natürlich „ein Stück“ Scala-Code zum Compilieren und das soll – wie oben angekündigt – der Klassiker „Hello World“ sein: object Hello { def main(args: Array[String]) { println("Hello World") } }
Wenn wir diesen Code betrachten, dann kommt uns einiges von Java bekannt vor, zum Beispiel die main-Methode mit ihren Argumenten oder die Ausgabe mittels println. Anderes ist komplett neu, zum Beispiel die Schlüsselworte object und def oder die fehlenden Semikolons. Wir werden – wie versprochen – alle Scala-Sprachkonstrukte in aller nötigen Tiefe erläutern, aber an dieser Stelle bleiben wir an der Oberfläche gehen wir davon aus, dass die Intention des Beispiels klar ist. Also, wir öffnen einen Texteditor unserer Wahl, geben obigen Code ein und speichern das Ganze in der Quelldatei Hello.scala. Tatsächlich ist es dem Scala-Compiler egal, wie wir die Datei benennen, denn im Unterschied zu Java können wir anders heißende und sogar mehrere Compilation Units in einer Datei haben. Aber in den meisten Fällen bietet sich die von her Java bekannte Konvention an und hier wollen wir es ebenso handhaben. Nun kommt der Scala-Compiler ins Spiel. Um genau zu sein, gibt es derer sogar zwei: Einen „normalen“ und einen Compiler-Dämon. Wir verwenden erst einmal den „normalen“, der sich – analog zum Java-Compiler javac – hinter dem Programm scalac (im bin-Verzeichnis der Scala-Distribution) verbirgt: tmp$ scalac Hello.scala tmp$
Wenn wir uns nicht vertippt haben, dann sehen wir nach dem Compilieren – erst einmal nichts, d.h. der Compiler beendet seine Arbeit ohne Fehlermeldung. Aber wenn wir einen Blick in das Dateisystem werfen, dann werden wir feststellen, dass die Quelldatei Hello. scala nicht mehr alleine ist, sondern Gesellschaft in Gestalt von Hello.class und Hello$.class bekommen hat. Offenbar war der Compiler fleißig und hat aus einer Quelldatei bzw. der
18
Kommandozeilen-Werkzeuge darin enthaltenen einen Compilation Unit – unser object Hello – zwei class-Dateien erzeugt. Dieser Arbeitseifer liegt in unserem Fall in der speziellen Behandlung von objects begründet, doch dazu mehr in Kapitel 5.4. Was wir ohne Zweifel erkennen können: Offenbar erzeugt der Scala-Compiler Java-Bytecode. Das war auch nicht anders zu erwarten, denn Scala ist ja eine Sprache für die JVM. Aber es tut doch ganz gut, sich mit eigenen Augen davon zu überzeugen. Wem das Indiz in Form der Dateiendung auf .class nicht ausreicht, der möge zum Standard-Java-Decompiler javap greifen. Hier das Resultat für die Datei Hello.class: tmp$ javap Hello Compiled from "Hello.scala" public final class Hello extends java.lang.Object{ public static final void main(java.lang.String[]); }
Wie wir sehen, handelt es sich bei Hello.class um gültigen Java-Bytecode, der in eine JavaSource-Darstellung zurückgeführt werden kann. Wir können auch erkennen, dass die Übersetzung unseres objects Hello eine main-Methode enthält, wie wir sie für ein ausführbares Java-Objekt benötigen. Ohne in dieses zugegebenermaßen etwas spezielle Thema zu tief einsteigen zu wollen, sei erwähnt, dass es auch einen Scala-Decompiler gibt, der erwartungsgemäß scalap heißt: tmp$ scalap -classpath . Hello object Hello extends java.lang.Object with scala.ScalaObject { def this() = { /* compiled code */ } def main(args : scala.Array[scala.Predef.String]) : scala.Unit = ... }
Zurück zum Scala-Compiler! Je nach Leistung des verwendeten Rechners müsste aufgefallen sein, dass das Übersetzen unseres trivialen Scala-Codes schon eine Weile gedauert hat. Wer das tatsächlich nicht so wahrgenommen hat, der möge ein vergleichbares Java-Beispiel mit javac übersetzen. Natürlich gibt es gute Gründe dafür, warum der ScalaCompiler langsamer ist, als der Java-Compiler, auf die wir hier gar nicht eingehen wollen. Dennoch ist es schlicht und ergreifend in der Praxis mindestens störend, wenn wir immer wieder lange warten müssen, während scalac sich abmüht. Aus diesem Grund gibt es den Compiler-Dämon fsc, was für Fast Scala-Compiler steht. Zwar dauert das erste Compilieren mit fsc sogar länger als mit scalac, aber dafür gehen weitere Compile-Vorgänge wesentlich schneller vonstatten. Das liegt daran, dass fsc eben ein Dämon ist, d.h. im Hintergrund aktiv bleibt, und auf diese Weise wertvolle Startzeit spart sowie gewisse Dinge im Cache halten kann. Damit wollen wir es in Hinblick auf den Kommandozeilen-Compiler belassen, denn in der Praxis bzw. in „echten Projekten“ werden wir scalac und fsc wohl kaum verwenden, sondern eher eine IDE oder ein Build-Werkzeug.
Durchstarten mit Scala
19
2 – Entwicklungsumgebung
2.1.2
scala
Nachdem wir nun wissen, wie wir unser „Hello World“ compilieren können, stellt sich die Frage, wie es denn ausgeführt werden kann. Wenn wir uns ins Gedächtnis rufen, dass der Scala-Compiler Java-Bytecode erzeugt, dann liegt die Vermutung nahe, dass wir einfach java verwenden können. Und das ist korrekt, wobei wir darauf achten müssen, zusätzlich zum aktuellen Verzeichnis, das unser „Hello World“ enthält, die Scala-Standardbibliothek in den Klassenpfad zu geben: tmp$ java -cp .:$SCALA_LIBS/scala-library.jar Hello Hello World
Hier enthält die Shell-Variable SCALA_LIBS den Pfad zum lib-Verzeichnis der Scala-Distribution. Wir können uns das Leben aber ein bisschen einfacher machen und statt java einfach das Scala-Pendent scala aufrufen. Dann passiert im Endeffekt dasselbe, aber wir müssen uns nicht mehr um die Scala-Standardbibliothek kümmern: tmp$ scala Hello Hello World
Mit scala können wir also Scala-Programme ausführen. So weit, so gut. Aber scala kann noch mehr: Wenn wir auf die auszuführende Klasse verzichten, dann gelangen wir in die bereits angekündigte interaktive Konsole, die auch mit Read Evaluate Print Loop (REPL) bezeichnet wird. Mit dieser werden wir uns noch ausführlich in fast allen folgenden Kapiteln beschäftigen, daher hier nur ein ganz kurzer Einblick in Form von zwei wichtigen Aspekten. Erstens können wir in der REPL zeilenweise Ausdrücke eingeben, die sofort evaluiert werden. Das können so einfache Dinge wie die Addition zweier ganzer Zahlen sein oder das Erzeugen von Objekten: scala> 1 + 1 res0: Int = 2 scala> new java.util.Date res1: java.util.Date = Thu Dec 30 20:53:25 CET 2010
Unsere Ausdrücke geben wir nach dem scala> Prompt ein und die REPL antwortet uns mit dem Ergebnis in eine neuen Zeile. Diese beginnt mit einem Variablennamen, gefolgt von deren Typ und – nach dem Gleichheitszeichen – dem Ergebnis des Aufrufes der toStringMethode. Wir sehen hier in Form der Verwendung von java.util.Date auch schon ein einfaches Beispiel für die Integration von Java in Scala, aber auch dazu später noch mehr. Zweitens können wir nicht nur „isolierte“ Ausdrücke eingeben, sondern uns auf alles beziehen, was im Klassenpfad liegt. In unserem Fall bedeutet das, dass wir die mainMethode unseres objects Hello aufrufen können, wobei wir aus Bequemlichkeit einfach null als Argument übergeben.
20
Build-Werkzeuge scala> Hello.main(null) Hello World
Selbstverständlich verfügt scala über zahlreiche Optionen, zum Beispiel können wir mittels ‑classpath den Klassenpfad angeben oder mittels –D= eine System Property setzen. Interessanterweise sind die Optionen identisch mit denen von scalac, sodass die Ausgabe von scala ‑help auf scalac ‑help verweist.
2.1.3
scaladoc
Nach so viel Ähnlichkeit mit Kommandozeilen-Werkzeugen für Java liegt es auf der Hand, dass es auch das Programm scaladoc gibt, um Dokumentation auf Basis von Kommentaren im Scala-Code zu erzeugen. Natürlich ist die Scala-Standardbibliothek selbst mit Hilfe von Scaladoc dokumentiert. Da diese Dokumentation nicht Bestandteil der Scala-Distribution ist, empfehlen wir, diese zusätzlich von der Scala-Website2 herunterzuladen und schon einmal einen Blick hinein zu werfen. Die Funktionsweise von scaladoc ist sehr ähnlich wie die von javadoc, ergänzt um einige zusätzliche Aspekte wie zum Beispiel eine Wiki-Syntax. Wir wollen dieses Thema hier jedoch nicht weiter vertiefen, sondern verweisen auf die Online-Dokumentation zu Scaladoc3. Das heißt selbstverständlich nicht, dass wir der Dokumentation keinen besonderen Stellenwert zurechnen. Aber bevor wir Programme dokumentieren können, müssen wir sie erst einmal programmieren können, und genau das steht ja im Fokus dieses Buches.
2.2
Build-Werkzeuge
Kaum ein „echtes Projekt“ kommt ohne Build-Werkzeug aus. Wir wollen hier drei Kandidaten betrachten, und zwar die aus der Java-Welt altbekannten Vertreter Ant und Maven sowie den Scala-Neuling Simple Build Tool (SBT). Schon anhand der Länge der jeweiligen Unterkapitel kann abgelesen werden, dass wir deutliche Präferenzen für SBT hegen und Ant und Maven nur der Vollständigkeit halber erwähnen: Wir empfehlen für neue Projekte oder solche, in denen ein Umstieg möglich ist, unbedingt die Verwendung von SBT. Zur Begründung siehe Kapitel 2.2.3.
2
http://www.scala-lang.org/downloads
3
http://lampsvn.epfl.ch/trac/scala/wiki/Scaladoc
Durchstarten mit Scala
21
2 – Entwicklungsumgebung
2.2.1
Ant
Ant4 sollte in der Java-Welt derart bekannt sein, dass wir dieses Werkzeug hier nicht explizit vorstellen werden. Falls jemand eine Auffrischung seines Wissens benötigt, dann sei auf die Online-Dokumentation5 verwiesen. Die Scala-Distribution enthält im lib-Verzeichnis unter anderem die Compiler-Library scala-compiler.jar, welche wiederum verschiedene Ant-Tasks enthält. Wir wollen hier kurz vorstellen, wie wir mit dem scalac-Task unser „Hello World“ übersetzen können. Dazu verwenden wir das folgende Ant-Buildfile, wobei wir voraussetzen, dass die Datei build.properties die Property scala.home definiert, sodass diese auf das Installationsverzeichnis der Scala-Distribution zeigt:
Besonders hervorzuheben sind die Task-Definition, bei der wir den Klassenpfad für den Compiler setzen, sowie das compile-Target, in dem wir den scalac-Task verwenden. Selbstverständlich haben wir hier eine stark vereinfachte Projektstruktur zugrunde gelegt, indem wir die Quell- und Zielverzeichnisse zusammen auf das Projektverzeichnis legten. Aber für die Komplexität unseres „Hello World“ halten wir diese Vereinfachung für angemessen. Die Scala-Distribution stellt mit fsc und scaladoc weitere Tasks zur Verfügung, für Details sei auf die Online-Dokumentation6 verwiesen.
4
http://ant.apache.org
5
http://ant.apache.org/manual
6
http://www.scala-lang.org/node/98
22
Build-Werkzeuge
2.2.2
Maven
Auch Maven7 ist in der Java-Welt sehr prominent und wird daher hier nicht weiter vorgestellt. Um Scala-Projekte mit Maven bauen zu können, benötigen wir das Maven-ScalaPlugin8, das sowohl im zentralen Maven Repository, also auch im Scala-Tools Repository9 zur Verfügung steht. Mit der folgenden POM-Datei können wir unser „Hello World“ zu einem Maven-Projekt machen, wenn wir zusätzlich noch die Quelldatei Hello.scala in das Standard-Verzeichnis src/main/scala für Scala-Quelldateien verschieben: 4.0.0 localhost hello 0.1-SNAPSHOT org.scala-tools maven-scala-plugin 2.15.0 2.8.1 compile testCompile
Nun können wir mit mvn compile unser Projekt übersetzen. Die Ergebnisse kommen wie erwartet unter target/classes zu liegen.
7
http://maven.apache.org
8
http://scala-tools.org/mvnsites/maven-scala-plugin/
9
http://scala-tools.org/repo-releases/
Durchstarten mit Scala
23
2 – Entwicklungsumgebung Im Gegensatz zu Ant bietet Maven mit dem Scala-Plugin einige sehr nützliche Funktionen, von denen wir zwei kurz vorstellen wollen. Zum einen können wir mittels mvn scala:cc ein kontinuierliches Compilieren einschalten, sodass Änderungen von Quelldateien sofort im Hintergrund übersetzt werden. Das ist gerade bei Web-Projekten sehr hilfreich, weil in Verbindung mit Werkzeugen wie JRebel10, die geänderte Klassen zur Laufzeit nachladen können, das zeitintensive Re-Deployment entfallen kann. Zum anderen kann man mittels mvn scala:console die REPL starten, wobei automatisch der Klassenpfad für das komplette Projekt einschließlich aller Dependencies korrekt gesetzt wird. So kann man auch in Projekten mit zahlreichen Dependencies die REPL auf einfache Weise zum Experimentieren und Testen verwenden. Für weitere Details sei auf die leider oft nicht aktuelle OnlineDokumentation11 verwiesen.
2.2.3
SBT
Wie schon angekündigt, kommen wir nun zu unserem Favoriten, dem Simple Build Tool12 (SBT). Kurz gesagt könnte man SBT als „wie Maven, aber richtig gut gemacht“ bezeichnen. Darüber hinaus berücksichtigt SBT, das in Scala und für Scala geschrieben wurde, zusätzlich etliche Besonderheiten von Scala. Aktuell liegt SBT in der produktiven Version 0.7.5 vor. Trotz dieser niedrigen Versionsnummer ist SBT reif für den produktiven Einsatz und wird von zahlreichen Open Source Scala-Projekten wie zum Beispiel Lift13 und Akka14 verwendet. Die 0-er Version soll wohl eher andeuten, dass die SBT-Entwickler sich in Hinblick auf das API und die Funktionalität noch eine gewisse Flexibilität offen halten wollen. Das bedeutet, dass wir möglicherweise beim Umstieg auf 0.9 oder 1.0 vor inkompatible Änderungen gestellt werden und unseren Build anpassen müssen. Allerdings erachten wir das damit verbundene Risiko als nicht gravierend, weil die meisten Anpassungen wohl nicht auf SBT-Nutzer zukommen werden, sondern eher auf Entwickler von SBTPlugins. SBT ist denkbar einfach zu installieren, weil es erst einmal nur aus dem JAR-Archiv sbtlaunch-0.7.5.jar besteht, dem sogenannten SBT-Launcher. Alles, was SBT darüber hinaus benötigt, lädt dieser aus dem Internet herunter, u.a. auch SBT selbst. Wir laden uns also den SBT-Launcher von der SBT-Website herunter und erstellen uns das folgende Startskript. Für Unix: java -Xmx512M -jar /sbt-launch-0.7.5.jar "$@"
10 http://www.zeroturnaround.com/jrebel/ 11 http://scala-tools.org/mvnsites/maven-scala-plugin 12 http://code.google.com/p/simple-build-tool 13 http://www.liftweb.com 14 http://akkasource.org
24
Build-Werkzeuge Oder analog für Windows: java -Xmx512M -jar /sbt-launch-0.7.5.jar" %*
Dabei steht für das Verzeichnis, in welchem wir den SBT-Launcher abgelegt haben. Da SBT eigene und Projekt-Abhängigkeiten aus dem Internet herunterlädt, müssen wir gegebenenfalls einen Proxy konfigurieren. Für diese und weitere Details sei auf die Online-Dokumentation15 verwiesen. Nun sind wir soweit und können unser „Hello World“ als SBT-Projekt anlegen. Dazu erstellen wir ein frisches Projektverzeichnis, zum Beispiel hello, und rufen dort unser SBTStartskript auf. tmp$ mkdir hello tmp$ cd hello hello$ sbt Project does not exist, create new project? (y/N/s) y Name: hello Organization: localhost Version [1.0]: 0.1-SNAPSHOT Scala version [2.7.7]: 2.8.1 sbt version [0.7.5]: ...
Zunächst fragt uns SBT, ob wir ein neues Projekt anlegen wollen, was wir mit „y“ bestätigen. Anschließend geben wir Werte für „Name“ und „Organization“ ein. Wer sich mit dem Dependency Manager Ivy16 auskennt, den SBT optional verwendet, dem dürften diese Attribute bekannt vorkommen, die „artifactId“ und „groupId“ von Maven entsprechen. Anschließend geben wir noch die Version unseres Projekts sowie die für unser Projekt zu verwendende Scala-Version ein. Bei der SBT-Version bestätigen wir in diesem Fall einfach den vorgeschlagenen Wert. SBT wird nun einige Dateien aus dem Internet herunterladen, u.a. Scala 2.7.7 für SBT selbst und Scala 2.8.1 für unser Projekt. Das bedeutet, dass wir für SBT-Projekte die ScalaDistribution gar nicht benötigen. Trotzdem schadet es natürlich nicht, diese installiert zu haben, zum Beispiel um Skripte in Scala zu schreiben und auzuführen. Danach startet die interaktive SBT-Konsole und erwartet unsere Eingaben. Wenn wir SBT mit exit oder quit beenden und erneut starten, dann landen wir wieder in der SBT-Konsole, diesmal ohne Warten auf das Herunterladen von Dateien:
15 http://code.google.com/p/simple-build-tool/wiki/Setup 16 http://ant.apache.org/ivy
Durchstarten mit Scala
25
2 – Entwicklungsumgebung hello$ sbt [info] Building project hello 0.1-SNAPSHOT against Scala 2.8.1 [info] using sbt.DefaultProject with sbt 0.7.5 and Scala 2.7.7 >
Die wichtigsten Befehle, die wir dort absetzen können, sind die sogenannten Actions compile, test, run, console und exit, die hoffentlich selbsterklärend sind, mit Ausnahme von console. Dieser Befehl startet analog zu mvn scala:console die REPL, wobei automatisch der Klassenpfad für das komplette Projekt einschließlich aller Dependencies korrekt gesetzt wird; eine ungeheuer nützliche Funktion für Projekte mit zahlreichen Dependencies.
Abbildung 2.1: SBT-Projektstruktur Doch noch ist unser Projekt leer, sodass diese Actions quasi ins Leere laufen. Ein Blick auf die Projektstruktur in Abbildung 1 lässt erahnen, wie wir Abhilfe schaffen können. SBT orientiert sich in Bezug auf die Projektstruktur an den Maven-Konventionen. Wir kopieren also einfach unsere Quelldatei Hello.scala in das Verzeichnis src/main/scala. Anschließend führen wir in der SBT-Konsole die run-Action aus: > run [info] [info] == copy-resources == ... [info] == compile == ... [info] == run == [info] Running Hello Hello World [info] == run == [success] Successful. [info] [info] Total time: 5 s, completed Jan 1, 2011 7:52:04 PM
26
IDEs Wie wir sehen können, werden zunächst noch andere Actions ausgeführt, insbesondere wird das Projekt compiliert. Anschließend sucht die run-Action ein ausführbares Objekt und findet natürlich unser object Hello mit der main-Methode, die sofort ausgeführt wird. Wir werden im Verlauf der kommenden Kapitel noch viel über SBT lernen, zum Beispiel das Verwenden von Managed Dependencies, sodass wir hier nur noch eine besonders nützliche Funktion vorstellen werden, die sogenannten Triggered Actions: Durch Voranstellen des Tilde-Zeichens „~“ kann eine Action in einem kontinuierlichen Modus ausgeführt werden, in dem Änderungen an Quelldateien dazu führen, dass die Action ausgeführt wird. Wenn wir ~ run eingeben, dann wird zunächst nochmals Hello.main ausgeführt. Danach wartet SBT auf Änderungen an Quelldateien, sodass eine Änderung der Hello-Nachricht zu einer erneuten Ausführung führt: > ~run ... [info] == run == [info] Running Hello Hello World [info] == run == [success] Successful. [info] [info] Total time: 0 s, completed Jan 1, 2011 1. Waiting for source changes... (press enter ... [info] == run == [info] Running Hello Good-bye [info] == run == [success] Successful. [info] [info] Total time: 2 s, completed Jan 1, 2011 2. Waiting for source changes... (press enter
2.3
9:00:32 PM to interrupt)
9:01:25 PM to interrupt)
IDEs
Zu guter Letzt kommen wir zu einem Thema, das oft mit sehr viel Herzblut diskutiert wird. Fragen wie „Brauchen wir überhaupt eine IDE?“ oder „Welche IDE ist die beste?“ haben das Zeug dazu, die Emotionen hochkochen zu lassen. Trotz dieser Gefahr werden wir uns auf dieses Thema einlassen, weil wir es für ein sehr wichtiges halten: Ja, wir meinen sogar, dass wir dringend eine IDE brauchen! Syntax Highlighting, Auto Completion, Code Navigation, Refactorings und Debugging sind für ernsthafte Software-Entwicklung aus unserer Sicht unerlässlich. Vielleicht nicht für jeden, aber doch für viele und zumindest für uns.
Durchstarten mit Scala
27
2 – Entwicklungsumgebung Im Folgenden werden wir mit Eclipse17 und IntelliJ IDEA18 zwei IDEs vorstellen, die Unterstützung für Scala anbieten. Natürlich gibt es weitere, auf die wir hier nicht eingehen werden, sowohl IDEs im klassischen Sinn wie zum Beispiel NetBeans19, als auch Lösungen auf Basis von „mächtigen Texteditoren“ wie zum Beispiel Emacs20 oder TextMate21. Der Grund für unsere Wahl ist ganz einfach: Eclipse wird von Scala Solutions, der Firma hinter Scala, unterstützt und IntelliJ IDEA bietet aktuell das beste produktive Scala-Plug in, wenngleich die letzte Beta-Version des Eclipse-Plugins für Scala 2.9 einen sehr guten Eindruck macht.
2.3.1
Scala IDE for Eclipse
Für uns ist die Geschichte des Eclipse-Plugins, das offiziell den Namen Scala IDE for Eclipse trägt, eine lange Berg- und Talfahrt, die nun endlich zu einem offenbar guten Ende führt. Wir wollen hier nur kurz erwähnen, dass das Eclipse-Plugin weder für Scala 2.7, noch für Scala 2.8 brauchbar war, und ansonsten die nicht so erfreuliche Vergangenheit hinter uns lassen. Für Scala 2.9 gibt es eine Update Site22, von der das Scala-Plugin für Eclipse 3.6.x (Helios) über „Help | Install New Software“ installiert werden kann. Nach dem erforderlichen Neustart steht uns die Scala-Perspektive zur Verfügung und die Möglichkeit, über den „New-Wizard“ ein neues Scala-Projekt anzulegen. Das wollen wir gleich einmal machen, wozu wir im Wizard nur einen Projekt-Namen eingeben, zum Beispiel „hello“. Anschließend bemühen wir wiederum den „New-Wizard“, um ein neues „Scala Object“ anzulegen. Das benennen wir zum Beispiel mit „Hello“ und kopieren anschließend den Code von unserem „Hello World“ hinein. Wenn wir speichern, wird der inkrementelle Eclipse-Compiler aktiv, was wir als besonderen Vorteil des Eclipse-Plugin bzw. von Eclipse selbst hervorheben möchten. Um unser „Hello World“ auszuführen, öffnen wir, entweder im Package Explorer oder im Editor, das Kontextmenü zu unserem Objekt und wählen dort „Run as“ und dann „Scala Application“. Der einzige Wermutstropfen, den es aktuell noch zu bemängeln gibt, ist, dass es noch keine produktionsreife Integration mit SBT gibt. Daher, und weil wir uns aktuell bei Scala noch auf 2.8 befinden, verwenden wir im Folgenden IntelliJ IDEA. Aber wir können uns sehr gut vorstellen, dass Eclipse ab Scala 2.9 wieder in der gleichen Liga mitspielen wird.
17 http://www.eclipse.org/ 18 http://www.jetbrains.com/idea/ 19 http://netbeans.org/ 20 http://www.gnu.org/software/emacs/ 21 http://macromates.com/ 22 http://downloads.typesafe.com/eclipse/milestones
28
IDEs
2.3.2
IntelliJ IDEA
Die kostenlose Community Edition von IntelliJ IDEA 10, die für die Scala-Entwicklung völlig ausreicht, kann von der JetBrains-Website23 heruntergeladen werden. Nach der Installation von IDEA müssen wir noch das Scala-Plugin hinzufügen. Dazu öffnen wir den Plugin Manager, entweder über das Menü „File | Settings“ oder über den Link „Open Plugin Manager“ auf der Seite „Quick Start“. Der Reiter „Available“ enthält eine Vielzahl an Plugins, von denen wir „Scala“ auswählen und „Download and Install“ aus dem Kontextmenü ausführen. Nach einem Neustart kann es losgehen.
Scala-Projekt erstellen Wenngleich wir, wie erwähnt, in unseren Scala-Projekten stets SBT einsetzen und daher Projekte wie im nächsten Kapitel beschrieben mit SBT anlegen und in IDEA integrieren, wollen wir dennoch kurz zeigen, wie wir „eigenständige“ Scala-Projekte mit IDEA anlegen können. Dazu öffnen wir den Wizard für ein neues Projekt, entweder über das Menü „File | New Project...“ oder über den „Quick Start“ Link „Create New Project“. Zunächst wählen wir „Create project from stratch“, dann geben wir auf der nächsten Seite Namen und Verzeichnis ein, bestätigen die kommende Seite ohne Änderungen und wählen auf der letzten Seite „Scala“ aus den zur Verfügung stehenden Technologien. Unter „Scala Settings“ stellen wir den Pfad zur Scala-Distribution ein und beenden den Wizard. Nun können wir unsere Quelldatei Hello.scala in das Quellverzeichnis src kopieren oder einfach mit IDEA eine neue erstellen. Anschließend führen wir unser „Hello World“ aus, indem wir im Kontextmenü von Hello „Run Hello.main()“ auswählen.
Integration mit SBT In der Praxis werden wir kaum ohne Build-Werkzeug, d.h. SBT, auskommen und daher führt eigentlich kein Weg daran vorbei, SBT und IDEA zu integrieren. Wir könnten das manuell tun, indem wir zunächst mit SBT ein Projekt anlegen und anschließen ein IDEAProjekt so konfigurieren, dass sämtliche Einstellungen „passen“. Aber zum Glück gibt es ein Plugin für SBT, mit dem wir diese mühsame Arbeit automatisieren können. Das Open Source Werkzeug sbt-idea24 erweitert SBT durch einen sogenannten Prozessor um das Kommando idea, mit dem wir ein IDEA-Projekt erzeugen können. Dabei werden auf Basis der SBT-Konfiguration die Projektdateien für IDEA generiert. Wir begeben uns zunächst wieder in die SBT-Konsole. Dort geben wir nun einmalig die folgenden beiden Zeilen, die mit einem Stern „*“ beginnen, ein:
23 http://www.jetbrains.com/ 24 https://github.com/mpeltonen/sbt-idea
Durchstarten mit Scala
29
2 – Entwicklungsumgebung > *sbtIdeaRepo at http://mpeltonen.github.com/maven/ ... > *idea is com.github.mpeltonen sbt-idea-processor 0.3.0 ... [info] Defined new processor 'idea is com.github.mpeltonen sbt-idea-processor 0.3.0'
Anschließend steht uns in jedem SBT-Projekt das Kommando idea zur Verfügung. Das wollen wir gleich einmal ausprobieren: > idea ... [info] Created .../tmp/hello/.idea [info] Created .../tmp/hello/project/project.iml [info] Created .../tmp/hello/hello.iml
Nun können wir IDEA starten und unser hello-Projekt öffnen, entweder über das Menü „File | Open Project...“ oder über den „Quick Start“ Link „Open Project“. Wann immer wir unser SBT-Projekt verändern, zum Beispiel wenn wir die Scala-Version ändern oder Managed Dependencies hinzufügen, dann können wir mit dem SBT-Kommando idea das IDEA-Projekt anpassen. Das funktioniert sogar, während IDEA läuft, denn IDEA erkennt die Änderungen und fordert uns zur Aktualisierung auf.
30
3 3
Das Fallbeispiel „ScalaTrain“
Wie kann man eigentlich am besten eine neue Programmiersprache lernen? Vermutlich gibt es darauf keine eindeutige Antwort. Aber wir sind der Überzeugung, dass es von großem Vorteil ist, möglichst von Anfang an praktische Erfahrungen zu sammeln. Zumindest dürften wir auf diese Weise am schnellsten Erfolge in Form von lauffähiger Software vorweisen könne. Und mit Sicherheit macht es eine Menge Spaß, frühzeitig selbst in die Tasten greifen zu dürfen. Daher haben wir uns entschieden, dieses Buch in Form eines Tutoriums zu schreiben, bei dem wir Schritt für Schritt ein Fallbeispiel entwickeln werden. Das ist zwar kein „echtes“ Projekt, aber mit ein wenig Phantasie stellt es dennoch einen Kontext dar, um die Scala nicht auf dem Trockenen, sondern eben anhand einer konkreten Aufgabe zu erlernen.
Bei der Suche nach einem passenden Fallbeispiel half die eisige Kälte des Winters 2010/2011 und die damit verbundenen Schwierigkeiten im Zugverkehr der Deutschen Bahn: Wir werden ScalaTrain entwickeln, ein System zur Reiseplanung mit der Bahn. In der „Endausbaustufe“ werden wir damit in der Lage sein, in das Formular einer Web-Applikation, die wir übrigens mit dem Lift1 Web-Framework entwickeln werden, eine Suchanfrage einzutragen. Dabei werden wir mit einem Start- und Ziel-Bahnhof sowie mit einer gewünschten Abfahrtszeit nach verfügbaren Zugverbindungen suchen können. Abbildung 1 zeigt exemplarisch, wie das aussehen wird. Zugegebenermaßen handelt es sich hierbei nicht um ein real einsetzbares System, denn dazu ist es viel zu simpel ausgelegt. Auch bemühen wir uns erst gar nicht, einen Preis für das Design der Oberfläche zu gewinnen. Unser Ziel ist es vielmehr, die meisten wichtigen Sprach-Features sowie grundlegende Werkzeug- und Prozessfragen anhand eines stimmigen Fallbeispiels in der Praxis kennenzulernen. Mit Ausnahme des folgenden Kapitels, in dem wir erste Gehversuche mit Scala in der REPL unternehmen werden, wird uns ScalaTrain in allen weiteren Kapiteln begleiten. In Kapitel 5 lernen wir die objektorientierte Seite von Scala kennen und entwickeln erste Klassen wir zum Beispiel Train. Kapitel 6 bringt mit Testen bzw. testgetriebener Entwicklung gleich ein wichtiges Prozess-Thema. Mit Kapitel 7 beginnen wir einen sanften Einstieg in die Welt der funktionalen Programmierung anhand von Scala-Collections, die wir zum Beispiel in Form einer Menge von Zügen für den Reiseplaner brauchen. Anschließend vertiefen wir in Kapitel 8 wieder die objektorientierten Features, wobei wir aus Java-Sicht neue Dinge wie Traits kennenlernen werden. In Kapitel 9 kommt mit Pattern Matching ein wunderbares SprachFeature, mit dem wir unter anderem den Fahrplan einzelner Züge untersuchen können. 1
http://liftweb.net/
Durchstarten mit Scala
31
3 – Das Fallbeispiel „ScalaTrain“ Hier könnten wir eigentlich schon aufhören, denn die bis hierhin vermittelten Kenntnisse bilden bereits eine Grundlage, die breit genug ist, um damit „echte“ Projekte anzugehen. Aber weil es so viel Spaß macht, gehen wir natürlich noch ein wenig in Richtung fortgeschrittener Themen. Kapitel 10 zeigt die hervorragenden Möglichkeiten, die Scala für XML bietet. Kapitel 11 bringt dann Implicits, vielleicht DIE Wunderwaffe von Scala überhaupt. In Kapitel 12 bringen wir dann verschiedene fortgeschrittene Konzepte wie zum Beispiel Rekursion und vertiefen anhand einer besonders schwierigen Aufgabe aus dem Fallbeispiel die idiomatische objekt-funktionale Programmierung mit Scala. Zum Abschluss zeigen wir in Kapitel 13 dann die Verwendung zweier wichtiger Scala-Frameworks, um damit einen einfachen Kommandozeilen-Client sowie die in Abbildung 1 exemplarisch dargestellte Web-Applikation zu entwickeln.
Abbildung 3.1: ScalaTrain Web-Applikation Am Ende jedes Kapitels zeigen wir stets die kompletten Änderungen oder Ergänzungen, die wir innerhalb eines Kapitels am Falleispiel vornehmen. Aber mit abgedrucktem Source-Code ist es ja bekanntlich so eine Sache. Daher steht der komplette Source-Code in einem Git-Repository2 zur Verfügung. Wer Git verwendet, kann anhand der CommitHistorie sogar die einzelnen Schritte nachvollziehen. Ansonsten besteht über die WebOberfäche die Möglichkeit, einfach den finalen Stand als Archiv herunterzuladen. 2
32
https://github.com/weiglewilczek/scalatrain
4 4
Erste Gehversuche in der REPL
Bevor wir uns unserem Fallbeispiel, also einem „richtigen“ Scala-Projekt, zuwenden, wollen wir in diesem Kapitel ein paar grundlegende Scala-Sprachkonstrukte spielerisch kennenlernen. Dazu verwenden wir die REPL, mit der wir so herrlich experimentieren können, weil wir stets unmittelbar das Ergebnis unserer Eingaben sehen. Im Folgenden gehen wir also davon aus, dass die REPL gestartet ist, sei es über die Kommandozeile und scala oder mittels SBT und console.
4.1
Variablen
Scala kennt zwei unterschiedliche Arten von Variablen: Unveränderliche und veränderliche. Das ist in Java grundsätzlich genauso, denn dort brauchen wir bloß das Schlüsselwort final vor eine Variablendefinition zu schreiben, um diese unveränderlich zu machen. Aber in Scala sieht das ein wenig anders aus, und zwar definieren wir unveränderliche Variablen mit dem Schlüsselwort val und veränderliche mit dem Schlüsselwort var.
4.1.1
Unveränderliche Variablen
Wir wollen mit vals beginnen, denn Scala ermutigt uns, im Sinne der funktionalen Programmierung so viel wie möglich mit unveränderlichem Zustand zu arbeiten. Der Grund hierfür ist ganz einfach: Wenn wir keine sogenannten Seiteneffekte haben, wenn wir also keinen Zustand verändern, dann hängt das Ergebnis einer Berechnung ausschließlich von deren Parametern ab. Das verbessert nicht nur die Testbarkeit, weil erwartete Ergebnisse nur von den Eingangswerten abhängen, sondern erleichtert auch die Lösungsfindung und die Programmierung, weil mit den fehlenden Seiteneffekten eine ganze Komplexitätsdimension gar nicht erst existiert. Ein weiterer Vorteil von unveränderlichem Zustand ist, dass rein lesender Zugriff im Rahmen von Nebenläufigkeit überhaupt kein Problem darstellt. Natürlich gibt es immer wieder Situationen, wo wir ohne veränderlichen Zustand nicht auskommen und mit Scala brauchen wir dafür keine Verrenkungen zu betreiben, denn es gibt ja auch vars. Aber es ist erstaunlich, wie oft man als Java-Entwickler zunächst im gewohnten imperativen Stil mit vars beginnen möchte und dann später erkennt, dass eine Lösung mit vals einfacher und verständlicher ist. Das müssen wir jedoch erst lernen und daher wollen wir in diesem Buch auch fast ausschließlich mit unveränderlichem Zustand arbeiten.
Durchstarten mit Scala
33
4 – Erste Gehversuche in der REPL scala> val hello = "Hello World" hello: java.lang.String = Hello World
Was sehen wir hier? In der ersten Zeile haben wir nach dem scala> Prompt unseren val definiert. In der zweiten Zeile antwortet die REPL mit dem Ergebnis in Form von Variablenname, Typ und Ergebnis der toString-Methode; in diesem Fall nicht besonders bedeutungsvoll, aber wir werden bald auch noch andere Fälle sehen. Java-Entwicklern fallen sicher zwei Details auf. Erstens haben wir am Zeilenende keinen Semikolon geschrieben. Der wird in Scala in den meisten Fällen nicht benötigt, denn der Compiler kann mit wenigen Ausnahmen erkennen, wenn ein Zeilenende das Ende eines Ausdrucks bedeutet. Dieses Feature wird mit Semicolon Inference bezeichnet und macht den Code schon ein klein wenig leichtgewichtiger. Das zweite und gewichtigere Detail ist die fehlende Typangabe. Wir schreiben links vom Gleichheitszeichen „=“ nur hello, ohne wie in Java üblich den Typ anzugeben. Dennoch hat die Variable einen wohldefinierten Typ, denn schließlich ist Scala eine statisch typisierte Programmiersprache. Diesen Typ sehen wir auch in der Antwortzeile der REPL, in unserem Fall ein java.lang.String, was übrigens auch wieder ein schönes Indiz für die Integration von Scala und Java ist. Der Scala-Compiler kann, ähnlich wie beim Semikolon, in vielen Fällen erkennen, um was für einen Typ es sich bei Zuweisungen im Speziellen und Ausdrücken im Allgemeinen handelt. In unserem Fall steht rechts vom „=“ ein String und daher behandelt der Compiler die linke Seite, also unsere Variable hello, auch als String. Dieses Feature, das mit Type Inference bezeichnet wird, lässt Scala so leichtgewichtig wie eine dynamische Programmiersprache erscheinen, bewahrt jedoch alle Vorteile der statischen Typisierung. Was, wenn wir nun den Typ explizit angeben möchten? Dem steht nichts im Weg: scala> val hello: String = "Hello World" hello: String = Hello World
Wie wir sehen, schreiben wir in Scala die Typangabe durch einen Doppelpunkt getrennt hinter den Variablennamen, genauso wie in der Antwort der REPL. Nun noch ein kleiner Beweis, dass Scala tatsächlich statisch typisiert ist: scala> val hello: Int = "Hello World" :5: error: type mismatch; found : java.lang.String("Hello World") required: Int val hello: Int = "Hello World"
Offenbar lässt es der Compiler nicht zu, dass wir einen String-Wert einer Int-Variablen zuweisen. Und nun ein weiterer Beweis, und zwar für die Tatsache, dass vals unveränderlich sind, also nur einmalig bei der Definition zugewiesen werden können:
34
Methoden scala> hello = "Good-bye" :6: error: reassignment to val hello = "Good-bye"
4.1.2
Veränderliche Variablen
Aber brauchen wir nicht auch veränderliche Variablen? Diese Frage, die Scala pragmatisch mit „Ja, warum nicht?“ beantwortet, führt uns direkt zu vars: scala> var year = 2010 year: Int = 2010
Wir erkennen, dass die Definition grundsätzlich identisch zu vals funktioniert: Wir müssen nur das Schlüsselwort val durch var ersetzen, können aber genauso auf Semikolon und Typangabe verzichten. Hier sehen wir übrigens mit Int einen Scala-Typ, den es in Java nicht gibt. Dabei handelt es sich um ein „echtes“ Objekt, das vom Scala-Compiler auf den primitiven Java-Typ int „abgebildet“ wird. Nun wollen wir aber noch zeigen, dass wir unsere veränderliche Variable auch tatsächlich verändern können: scala> year = 2011 year: Int = 2011
4.2
Methoden
Selbst wenn wir uns bis jetzt noch gar nicht mit der Objektorientierung in Scala befasst haben, wollen wir dennoch schon einmal Methoden unter die Lupe nehmen. In der REPL können wir diese „einfach so“ ohne Klasse definieren, aber das ist natürlich eine Besonderheit der REPL: Wie wir später noch lernen werden, ist in Scala alles ein Objekt, mit einer Ausnahme, den Methoden, die zu Objekten gehören.
4.2.1
Alles hat ein Ergebnis
Wir beginnen mit einer minimalistischen Methode ohne Parameter, die einen festen Wert zurückgibt: scala> def goodBye = "Good-bye" goodBye: java.lang.String
Als erstes bemerken wir das Schlüsselwort def, das wir in Scala verwenden, um Methoden zu definieren. Wenn wir den Rest der Methodendefinition sehen, dann wird rasch klar, warum wir hier wie auch bei den Variablen ein Schlüsselwort brauchen: Ohne val, var und def sehen diese Definitionen gleich aus und nur anhand der Schlüsselworte kann der
Durchstarten mit Scala
35
4 – Erste Gehversuche in der REPL Scala-Compiler die Unterscheidung treffen. Aber wo sind denn die runden Klammern, der Rückgabetyp, die geschweiften Klammern und die return-Anweisung? Das können wir uns in diesem Fall alles sparen! Zunächst einmal haben wir keine Parameter, sodass wir die leere Parameterliste, d.h. die runden Klammern „ohne Inhalt“, weglassen können. An der Antwort der REPL können wir erkennen, dass sich unsere goodBye-Methode strukturell gar nicht von einer Variable, zum Beispiel von hello, unterscheidet. Das bedeutet, dass Scala das sogenannte Uniform Access Principle1 beherzigt, welches besagt, dass der Zugriff auf Werte gleichförmig sein soll, unabhängig ob diese berechnet werden oder gespeichert sind. Weiter können wir uns dank Type Inference auch den Rückgabetyp sparen, wenngleich wir das nur dann tun sollten, wenn die Methode so trivial ist, dass man auf einen Blick den Typ erkennen kann. In unserem Fall trifft das sicher zu. Wenn wir dennoch den Rückgabetyp hinschreiben wollen, dann können wir das so wie bei den Variablen tun: Durch einen Doppelpunkt vom Methodennamen und der hier nicht vorhandenen Parameterliste getrennt: scala> def goodBye: String = "Good-bye" goodBye: java.lang.String
Das Gleichheitszeichen „=“ trennt die Methodensignatur, die links steht, von der Implementierung, die sich rechts befindet. Wenn die Implementierung nur ein Einzeiler ist, dann können wir die geschweiften Klammern weglassen, für mehrzeilige Code-Blöcke bräuchten wir sie natürlich schon. In Scala hat alles ein Ergebnis. Bei einem Code-Block ist das die letzte Zeile bzw. das Ergebnis des letzten Ausdrucks. Daher benötigen wir auch kein return, weil die Rückgabe einer Methode eben das Ergebnis der letzten Zeile der Methoden-Implementierung ist. Als nächstes wollen wir eine Methode mit Parametern unter die Lupe nehmen. Dazu betrachten wir die Addition von zwei Int-Werten: scala> def add(x: Int, y: Int) = x + y add: (x: Int, y: Int)Int
Wieder sehen wir einen Einzeiler ohne Typangabe und ohne geschweifte Klammern. Die Parameterliste steht wie in Java in runden Klammern und die Parameter werden durch Komma voneinander getrennt, wobei die Typangaben denen für Variablen entsprechen. Selbstverständlich können wir bei den Parametern die Typangaben nicht weglassen, denn dann müsste der Scala-Compiler ja fast schon hellseherische Fähigkeiten2 besitzen, um deren Typen zu inferieren. 1
http://en.wikipedia.org/wiki/Uniform_access_principle
2
In der Tat gibt es sogar Sprachen mit einem solch mächtigen Type Inferencer, z.B. Haskell.
36
Methoden
4.2.2
Unit-Methoden
Schließlich wollen wir noch Methoden betrachten, die nichts zurückgeben, also void in Java entsprechen. Rein technisch betrachtet gibt es das in Scala gar nicht, weil alles ein Ergebnis hat. Daher gibt es in Scala den Typ Unit, der für ein „belangloses“ Ergebnis steht, also für einen Wert, für den wir uns nicht interessieren. Zu diesem Typ gibt es genau einen Wert, der mit dem Literal () bezeichnet wird, den wir jedoch in der Regel nicht benötigen. Schreiben wir also eine Methode, die nichts (interessantes) zurückgibt: scala> def print(s: String): Unit = println(s) print: (s: String)Unit
Während das eine korrekte Schreibweise ist, existiert eine alternative Möglichkeit, die sich als Standard-Konvention etabliert hat: Wenn wir die Typangabe und das Gleichheitszeichen „=“ weglassen, dann betrachtet der Scala-Compiler die Methode automatisch als Unit-Methode. Allerdings müssen wir nun, damit der Compiler die Grenze zwischen Signatur und Implementierung erkennen kann, die Implementierung in geschweifte Klammern setzen, auch wenn es sich nur um einen Einzeiler handelt. scala> def print(s: String) { | println(s) | } print: (s: String)Unit
Wir sehen hier zum ersten mal eine mehrzeilige Eingabe in der REPL. Sobald die REPL erkennt, dass unser Code nach einem Zeilenumbruch noch nicht fertig sein kann, wird der neuen Zeile ein vertikaler Strich „|“ vorangestellt und wir können mit der Eingabe weiter fortfahren. Dieser Strich ist natürlich eine Besonderheit der REPL und nicht Bestandteil des Scala-Codes. scala> print("Hello") Hello
Wenn wir diese Methode mit einem passenden Argument aufrufen, erhalten wir das erwartete Ergebnis. Und zwar kein Ergebnis im Sinne der REPL, denn die print-Methode gibt ja Unit zurück. Daher erscheint auch keine Antwortzeile, sondern einfach nur die Ausgabe, die wir durch den Aufruf der println-Methode erzeugen.
Durchstarten mit Scala
37
4 – Erste Gehversuche in der REPL
4.3
Funktionen
Nun werden wir mit Funktionen bzw. funktionaler Programmierung ein Thema betrachten, das es heute noch nicht in Java gibt und uns nach aktuellem Stand der Closure-Diskussion frühestens in Java 8 zur Verfügung stehen wird. Um funktionale Programmierung zu erkunden, bietet sich ein Blick auf die mächtige Collection Library von Scala an. Hier wollen wir zunächst eine Liste mit Int-Werten betrachten. Wie können wir eine solche erzeugen? Ganz einfach so: scala> val numbers = List(1, 2, 3, 4) numbers: List[Int] = List(1, 2, 3, 4)
Hier sehen wir übrigens ein sehr schönes Beispiel für die Leichtigkeit der Ausdrucksweise, die uns die Type Inference ermöglicht. Weder haben wir angegeben, dass unsere Liste mit Int typisiert sein soll, noch haben wir den Typ von numbers definiert. Dennoch erkennt der Scala-Compiler, dass die Variable vom Typ List[Int] sein soll, wobei die eckigen Klammern den Typ-Parameter enthalten, analog zu den spitzen Klammern bei den Java-Generics. Doch zurück zum eigentlichen Thema. Typische Aufgabenstellungen, mit denen wir in Bezug auf Collections häufig konfrontiert werden, sind zum Beispiel Sortieren, Filtern und Transformieren. In Java oder einer anderen imperativen Programmiersprache würden wir solche Aufgaben üblicherweise so angehen, dass wir eine neue leere Collection erzeugen, dann über die eigentliche Collection iterieren und dabei die neue Collection entsprechend der Anforderungen füllen. Mit anderen Worten: Wir würden mit Schleifen und veränderlichen Variablen bzw. Objekten arbeiten und dabei sehr detailliert vorgeben, wie das Ganze zu geschehen hat. Scala bietet uns mit sogenannten Higher Order Functions eine viel einfachere Alternative: Die Scala-Collections verfügen über zahlreiche Methoden, die als Parameter nicht ein „gewöhnliches“ Objekt erwarten, sondern eine Funktion. Am besten betrachten wir das im Folgenden anhand einiger Beispiele. Zunächst wollen wir unsere Liste von Int-Werten sortieren. Da wir sie schon aufsteigend sortiert hingeschrieben haben, müssen wir sie nun absteigend sortieren, um den Effekt zu sehen: scala> numbers.sortWith((x, y) => x > y) res0: List[Int] = List(4, 3, 2, 1)
Als erstes fällt auf, dass wir nur eine kurze Code-Zeile benötigt haben und weder eine Schleife, noch Details über das „Wie?“ programmieren mussten. Wir haben anschaulich gesprochen den numbers einfach gesagt, dass sie sich sortieren sollen, wobei wir die Vorschrift als Parameter übergeben haben. Das führt uns direkt zur nächsten und vermutlich „schwerwiegendsten“ Auffälligkeit: Der Parameter für die sortWith-Methode ist eine
38
Funktionen Funktion. Genauer gesagt handelt es sich um ein sogenanntes Funktionsliteral, also einen „hingeschriebene“ Funktionswert, ähnlich dem Literal 1 für einen Int-Wert oder dem String-Literal „Hello“. Wir werden in Kapitel 7 die Grundlagen der Funktionale Programmierung mit Scala unter die Lupe nehmen und in diesem Zuge detailliert die Syntax von Funktionsliteralen erläutern. An dieser Stelle sei nur so viel gesagt: Links vom Pfeil „=>“ steht eine Parameterliste, genau wie bei einer Methode und rechts davon der Körper der Funktion, ebenfalls analog zur Implementierung einer Methode. Dank Type Inference können wir in unserem Beispiel bei den Parametern auf die Angabe der Typen verzichten, denn die sortWith-Methode erwartet zwei Parameter vom gleichen Typ wie derjenige, mit dem die Collection parametrisiert ist. In unserem Fall ist numbers mit Int parametrisiert, sodass der Scala-Compiler weiß, dass die beiden Parameter x und y der Funktion ebenfalls vom Typ Int sein müssen. Natürlich könnten wir die Typen auch explizit angeben: scala> numbers.sortWith((x: Int, y: Int) => x > y) res1: List[Int] = List(4, 3, 2, 1)
Und nun noch ein Beleg dafür, dass wir nicht irgendwelche Typen verwenden dürfen, sondern dass sortWith in unserem Fall zwei Ints erwartet: scala> numbers.sortWith((x: String, y: Int) => x > y) :7: error: type mismatch; found : Int required: String numbers.sortWith((x: String, y: Int) => x > y)
Noch ein paar Beispiele gefällig für die Möglichkeiten, die uns die funktionalen ScalaCollections bieten? Gerne! Wie wäre es mit der Anwendung eines Filters? Wir wollen zum Beispiel nur die geraden Zahlen aus unserer Int-Liste übernehmen: scala> numbers.filter(x => x % 2 == 0) res2: List[Int] = List(2, 4)
Oder wie wäre es mit einer Transformation? Wir wollen zum Beispiel zu jedem Element unserer Int-Liste Eins dazuzählen: scala> numbers.map(x => x + 1) res3: List[Int] = List(2, 3, 4, 5)
Diese Beispiele könnten wir schier endlos fortsetzen, da die Scala-Collections sehr mächtig sind und eine Vielzahl an Methoden bieten, die Funktionen als Parameter haben. Allen gemeinsam ist eine weitreichende Vereinfachung für uns Programmierer: Wir können uns auf das „Was?“ konzentrieren und brauchen uns um die Details des „Wie?“ keine Gedanken machen.
Durchstarten mit Scala
39
5 5
OO-Grundlagen
Wie wir in den vorherigen Kapiteln schon sehen konnten, ist Scala eine hybride Programmiersprache, die sowohl objektorientierte, also als auch funktionale Programmierung ermöglicht. In diesem Kapitel werden wir uns Scala aus der Richtung der Objektorientierung nähern, was zumindest für Java-Entwickler ein vertrautes Terrain darstellt und somit einen leichten Einstieg ermöglichen sollte. Wer bisher keinerlei Kenntnisse über objektorientierte Programmierung hat, dem sei zunächst eine überblicksartige Einführung empfohlen, wie zum Beispiel in Wikipedia1. Anschließend sollten die grundlegenden Konzepte hoffentlich soweit klar sein, dass es möglich ist, hier zu folgen. Wir werden in diesem Kapitel auch endlich mit unserem Fallbeispiel ScalaTrain beginnen, das wir in Kapitel 3 vorgestellt haben. So können wir die erlernten objektorientierten Konzepte von Scala gleich in der Praxis anwenden. Dabei beschränken wir uns noch auf die absoluten Grundlagen, wohingegen fortgeschrittene Themen wie zum Beispiel Vererbung, OO-Sicht auf die funktionale Programmierung oder Mixin-Komposition mit Traits in späteren Kapiteln behandelt werden.
5.1
Vorbereitung: Projekt initialisieren
Um das Fallbeispiel zu bearbeiten, müssen wir in der Entwicklungsumgebung unserer Wahl ein neues Projekt initialisieren. Wir werden, wie in Kapitel 2 erläutert, mit einer Kombination aus SBT und IDEA arbeiten. Selbstverständlich können Sie auch eine andere Entwicklungsumgebung wie zum Beispiel Eclipse verwenden, müssten dann aber an manchen Stellen ein wenig Transferdenken aufbringen. Als erstes legen wir ein neues SBT-Projekt an. Dazu erstellen wir an einem Ort unserer Wahl das neues Verzeichnis scalatrain und wechseln auf der Kommandozeile dorthin. Dann rufen wir SBT auf und bestätigen den erscheinenden Dialog auf folgende Weise: scalatrain$ sbt Project does not exist, create new project? (y/N/s) y Name: scalatrain Organization: org.scalatrain Version [1.0]: 0.1-SNAPSHOT Scala version [2.7.7]: 2.8.1 sbt version [0.7.5]: 1
http://de.wikipedia.org/wiki/Objektorientierte_Programmierung
Durchstarten mit Scala
41
5 – OO-Grundlagen Wir empfehlen, dass Sie für Ihre Übungen dieselben Werte verwenden, um gegebenenfalls beim Nachvollziehen reibungslos auf die Musterlösung zurückgreifen zu können. Ansonsten können Sie natürlich auch Werte Ihrer Wahl eingeben, wobei bei anderen Versionen von Scala oder SBT „Überraschungen“ nicht auszuschließen sind. Mit Hilfe der SBT-IDEA-Integration erzeugen wir nun ein IDEA-Projekt, indem wir das idea-Kommando ausführen: > idea [info] [info] [info] [info]
Created /Users/hseeberger/projects/scalatrain/.idea Created /Users/hseeberger/projects/scalatrain/project/project.iml Excluding folder target Created /Users/hseeberger/projects/scalatrain/scalatrain.iml
Nun führen wir noch compile als Triggered Action aus, damit unser Projekt bei Änderungen an den Quelldateien sofort automatisch übersetzt wird: > ~ compile ... 1. Waiting for source changes... (press enter to interrupt)
Und zu guter Letzt öffnen wir unser Projekt in IDEA. Dann sind wir bereit, um loszulegen.
5.2
Klassen
Objekte fassen Daten und Operationen, die auf diesen Daten arbeiten, zu einer Einheit zusammen. Scala lässt uns strukturell gleichartige Objekte in Klassen einteilen, wobei die Daten durch Felder und die Operationen durch Methoden definiert werden. Dazu verwenden wir das Schlüsselwort class: class Train
Dies ist eine gültige Klassendefinition in Scala; da wir noch keine Felder und Methoden definiert haben, brauchen wir erst einmal keine geschweiften Klammern. Und im Gegensatz zu Java gibt es in Scala keinen Access Modifier public, weil öffentlicher Zugriff der Standardfall ist. Mehr zu Access Modifiers bzw. Sichtbarkeit folgt in Kapitel 5.3. Wir könnten diese Klassendefinition direkt in der REPL eingeben, aber wie der Name schon erahnen lässt, handelt es sich hier um ein Objekt unseres Fallbeispiels. Daher legen wir diese Klasse in einer Quelldatei unseres Projekts an. Wie in Kapitel 2 erläutert orientiert sich SBT für die Projektstruktur an den Maven-Konventionen, sodass wir unsere Quelldatei im Verzeichnis src/main/scala anlegen. Mit IDEA können wir dies dadurch erreichen, dass wir im Kontextmenü dieses Verzeichnisses „New | Scala Class“ auswählen und im anschließenden Dialog „Train“ als Namen angeben. Da wir compile als Triggered Action
42
Klassen ausführen, wird unsere neue Klasse sofort übersetzt. Das sehen wir, wenn wir einen Blick auf die Ausgabe in der SBT-Konsole werfen. Offenbar erkennt SBT, dass eine Klasse neu hinzugekommen bzw. modifiziert wurde und übersetzt diese: [info] == compile == [info] Source analysis: 1 new/modified, ... ... 2. Waiting for source changes... (press enter to interrupt)
5.2.1
Klassenparameter und Konstruktoren
Wie können wir nun eine Instanz unserer Train-Klasse anlegen? Das funktioniert in Scala genauso wie in Java mit dem Schlüsselwort new, was wir gleich einmal in der REPL ausprobieren wollen. Dazu öffnen wir noch eine zweite SBT-Konsole – in der ersten läuft ja bereits ~ compile – und führen dort console aus, um die REPL zu starten: scala> val train = new Train train: Train = Train@77aa89eb
Die Antwort der REPL ist zwar etwas unschön, aber genau das, was wir erwarten, und zwar – nach Name und Typ – das Ergebnis der toString-Methode, die auf der Train-Instanz aufgerufen wird. Da noch wir diese nicht überschrieben haben, erhalten wir das bekannte Standard-Ergebnis von java.lang.Object.toString.
Primary Constructor Jetzt wollen wir einmal ganz genau sein: Was haben wir denn nach dem Schlüsselwort new geschrieben? Was aussieht wie der Klassenname ist in Wirklichkeit der Aufruf des Konstruktors, in diesem Fall ohne Parameterliste, also ohne runde Klammern. In Scala gibt es also auch so etwas ähnliches wie einen Default Constructor, aber es verhält sich doch etwas anders als in Java. Denn der sogenannte Primary Constructor ergibt sich implizit aus der Klassendefinition. Das bedeutet, dass wir gleich bei der Klassendefinition sogenannte Klassenparameter definieren können, die dann als Parameter des Primary Constructor fungieren. Diese Klassenparameter werden genauso wie Parameter von Methoden geschrieben, d.h. als Parameterliste in runden Klammern und die einzelnen Parameter voneinander durch Komma getrennt und im Format „Name gefolgt von Doppelpunkt gefolgt von Typ“. Um das zu zeigen, erweitern wir unseren Train erst einmal um den Klassenparameter number: class Train(number: String)
Wenn wir nun mit dem Kommando :replay in der REPL den obigen Scala-Code, also das erzeugen einer neuen Train-Instanz ohne Klassenparameter, erneut ausführen wollen, dann funktioniert das nicht mehr:
Durchstarten mit Scala
43
5 – OO-Grundlagen scala> :replay Replaying: val train = new Train :5: error: not enough arguments for constructor Train: (number: String)Train. Unspecified value parameter number. val train = new Train
Wie wir der Fehlermeldung des Scala-Compilers entnehmen können, gibt es jetzt keinen Konstruktor ohne Parameter mehr, sondern offenbar nur noch den mit dem number-Parameter. Dementsprechend können wir nur noch neue Train-Instanzen erzeugen, indem wir einen String als Argument übergeben: scala> val train = new Train("Nighttrain") train: Train = Train@22930462
Schön, nun wissen wir also, wie wir Instanzen von Klassen mit Klassenparametern anlegen können, aber wir wissen noch nicht, wozu wir die Klassenparameter verwenden können. Im Vorgriff auf Kapitel 5.2.2 über Felder sei an dieser Stelle so viel verraten, dass Klassenparameter quasi private Felder einer Klasse sind. Das bedeutet, dass wir sie zwar innerhalb der Klasse verwenden, jedoch nicht von außen auf sie zugreifen können.
Auxiliary Constructors Wenn wir keine weiteren Konstruktoren definieren, dann hat eine Scala-Klasse genau einen Konstruktor, nämlich den Primary Constructor. Wir zeigen nun kurz, wie wir zusätzliche Konstruktoren, sogenannte Auxiliary Constructors, anlegen können, wenngleich das inzwischen durch die Named and Default Arguments, die wir in Kapitel 5.2.4 behandeln werden, von untergeordneter Bedeutung ist. Im Folgenden ergänzen wir unsern Train vorübergehend um zwei Auxiliary Constructors von zugegebenermaßen minderer fachlicher Bedeutung: class Train(number: String) { def this() = this("Default") def this(number1: String, number2: String) = this(number1 + number2) }
Zunächst erkennen wir, dass Auxiliary Constructors als Methoden definiert werden, d.h. mit dem Schlüsselwort def. Weiter sehen wir, dass diese Methoden offenbar this heißen und den Primary Constructor aufrufen. In der Tat muss der Aufruf des Primary Constructors sogar die erste Aktivität in einem Auxiliary Constructor sein. Wenn wir ehrlich sind, dann machen diese beiden zusätzlichen Konstruktoren wenig Sinn. Insbesondere der zweite dient hier nur Demonstrationszwecken. Der erste macht zwar für unsere Domäne wenig Sinn, da Züge bestimmt keine Default-Nummer haben
44
Klassen sollen. Aber für andere Anwendungsfälle sind Default-Werte natürlich durchaus denkbar. Genau hier kommen aber wie schon gesagt die Named and Default Arguments ins Spiel. Daher löschen wir jetzt die beiden überflüssigen Auxiliary Constructors, sodass die Train-Klasse wieder so aussieht: class Train(number: String)
5.2.2
Felder
Nun haben wir also eine Klasse Train mit einem Klassenparameter number vom Typ String. So richtig viel können wir damit jedoch noch nicht anfangen. Das können wir uns auch vor Augen führen, indem wir in der REPL die Autovervollständigung nutzen, um alle Felder und Methoden eines Train anzuzeigen. Dazu geben wir die Variable train ein, die wir bereits definiert haben, gefolgt von einem Punkt „.“ und dann der Tab-Taste: scala> train. asInstanceOf equals toString wait
getClass
hashCode
isInstanceOf
notify
notifyAll
Wie wir sehen können, handelt es sich um viele gute Bekannte aus der Java-Welt, wie zum Beispiel toString, equals oder hashCode. Das liegt daran, dass jede Scala-Klasse letztendlich ein Nachfolger von java.lang.Object ist. Insgesamt sehen wir aber nichts, was speziell mit einem Zug zu tun hätte, insbesondere nicht den Klassenparameter number, denn der ist ja wie oben erläutert, nur innerhalb von Train sichtbar. Daher wollen wir jetzt ein öffentliches Feld hinzufügen, das den Typ des Zugs angibt, also zum Beispiel „ICE“, „IR“ etc. Da sich ein Zug wohl kaum im Laufe seines „ZugLebens“ von einem Typ zum anderen wandeln kann, verwenden wir hier ein unveränderliches Feld. Wie machen wir das wohl? Ganz einfach genauso wie bei den Variablen in Kapitel 4, d.h. mit einem val. Dummerweise können wir das Feld nicht mit type bezeichnen, weil das ein reserviertes Schlüsselwort ist. Daher verwenden wir hier als Variablenname kind: class Train(number: String) { val kind = "ICE" }
Ein unveränderliches Feld muss, wie auch eine unveränderliche Variable, sofort initialisiert werden. Ansonsten wäre es ein abstraktes Feld und die zugehörige Klasse eine abstrakte Klasse, aber dazu kommen wir erst noch in Kapitel 8. Daher müssen wir unser kind-Feld auch sofort mit einem Wert versehen, auch wenn das fachlich nicht besonders sinnvoll erscheint. Aber darum kümmern wir uns gleich noch. Jetzt wollen wir erst einmal sehen, wie sich unser erweiterter Train verhält:
Durchstarten mit Scala
45
5 – OO-Grundlagen scala> train.kind res0: java.lang.String = ICE
Wie erwartet können wir nun auf das kind-Feld lesend zugreifen, denn wie bei Klassen gilt grundsätzlich überall, dass die Standard-Sichtbarkeit öffentlich ist. Schreibender Zugriff funktioniert natürlich nicht, weil wir ja ein val verwendet haben, also ein unveränderliches Feld definiert haben. scala> train.kind = "IR" :6: error: reassignment to val train.kind = "IR"
Klassenparameter als öffentliche Felder Wenn wir unsere momentane Train-Klasse betrachten, dann sind zwei Probleme offensichtlich. Erstens können wir nicht auf den number-Klassenparameter zugreifen und zweitens ist ein Train immer ein „ICE“. Idealerweise sollten beide Attribute öffentliche Felder sein, deren Werte wir beim Erzeugen einer Instanz übergeben. Die von Java bekannte Lösung würde in Scala auch funktionieren, nämlich beide Attribute als vals definieren und deren Initialisierung mit entsprechenden Klassenparametern vornehmen: class Train(_kind: String, _number: String) { val kind = _kind val number = _number }
Allerdings ist diese Lösung nicht nur unschön, weil wir die Klassenparameter anders benennen müssen als die öffentlichen Felder. Sie ist auch unnötig redundant: Wozu brauchen wir einen Klassenparameter und ein Feld, wo wir doch nur ein Feld parametrisiert initialisieren wollen? Scala bietet zum Glück eine viel bessere Lösung: Wir schreiben einfach vor den Klassenparameter das Schlüsselwort val, und schon macht der Scala-Compiler aus dem Klassenparameter ein öffentliches Feld: class Train(val kind: String, val number: String)
Ist das nicht einfach und elegant? Nun können wir Instanzen von Train (nur noch) mit zwei Argumenten erzeugen und anschließend auf beide Felder zugreifen: scala> val train = new Train("ICE", "722") train: Train = Train@5f02f3f7 scala> train.kind res0: String = ICE scala> train.number res1: String = 722
46
Klassen
5.2.3
Methoden
Jetzt wissen wir also, wie wir Klassen mit Feldern anlegen können. Zu „echten“ Objekten fehlen uns noch Methoden, denen wir uns jetzt widmen wollen. Im Rahmen unserer Fallstudie haben wir aktuell leider keinen Bedarf für Methoden in der Klasse Train. Daher legen wir zunächst eine neue Klasse an, die eng mit Zügen bzw. Zugfahrplänen verbunden ist, und zwar die Klasse Time. Diese bekommt zuerst einmal zwei öffentliche Felder für Stunden und Minuten; genauer brauchen wir es für die Bahn schließlich nicht. Also legen wir wieder im Verzeichnis src/main/scala die neue Quelldatei Time.scala mit der Klasse Time an: class Time(val hours: Int, val minutes: Int) { // TODO Check preconditions! }
An dieser Stelle kümmern wir uns noch nicht darum, dass die Werte für hours und minutes nicht beliebige Int-Werte sein dürfen, sondern innerhalb wohldefinierter Wertebereiche liegen müssen; diese Übung heben wir uns für das Kapitel 6 über Testen auf und fügen hier nur einen Kommentar ein, damit wir das nicht vergessen.
Eine einfache Methode Jetzt fügen wir erst einmal eine Methode hinzu, mit der wir von einer Time-Instanz eine andere abziehen können. Auf diese Weise können wir dann zum Beispiel die Zeitdauer zwischen zwei Halten ermitteln. Wir können zwar noch nicht testgetrieben entwickeln, weil wir dazu erst später kommen werden. Dennoch wollen wir hier ähnlich vorgehen und erst einmal eine komplette Methodendefinition hinschreiben, wobei die vorläufige Implementierung noch „Unsinn“ zurückgibt. Wir können uns ja vorstellen, dass wir dann die Testfälle implementieren und erst danach die korrekte Implementierung. def minus(that: Time): Int = 0
Wie schon in Kapitel 4 erläutert beginnt eine Methodendefinition mit dem Schlüsselwort def. Daran schließt sich die Methodensignatur an, die aus dem Namen, der Parameterliste und einer meist optionalen Angabe des Rückgabetyps besteht. Danach leitet das Gleichheitszeichen „=“ die Implementierung der Methode ein. Diese kann ein einzelner trivialer Ausdruck sein, so wie hier. Oder die Implementierung ist ein beliebig langer Code-Block in geschweiften Klammern, wobei gilt, dass das Ergebnis des letzte Ausdrucks den Rückgabewert der Methode bildet. Wir hätten hier die Angabe des Rückgabetyps weglassen können, weil der Scala-Compiler in diesem Fall klar erkennen kann, dass die Rückgabe vom Typ Int ist. Es gibt nur wenige Fälle, in denen man dem Scala-Compiler auf die Sprünge helfen muss, zum Beispiel bei rekursiven Methoden, mit denen wir uns in Kapitel 12 befassen werden. Dennoch ist es
Durchstarten mit Scala
47
5 – OO-Grundlagen guter Stil, bei allen nicht-trivialen Methoden den Rückgabetyp anzugeben, weil dadurch eine wichtige Eigenschaft dokumentiert wird. Ansonsten müsste man sich erst einmal durch die Implementierung einer Methode kämpfen, um den Rückgabetyp zu erkennen. In unserem Fall ist die vorläufige Implementierung zwar äußerst trivial und wäre ein heißer Kandidat für das Weglassen des Rückgabetyps. Aber wir können uns schon jetzt vorstellen, dass das Abziehen einer Time von einer anderen durchaus eine gewisse Komplexität besitzen wird, sodass wir lieber die ausführliche Variante wählen. Bevor wir uns an die Implementierung machen, überlegen wir uns kurz, wie die minusMethode funktionieren soll. Was soll passieren, wenn wir null – ja, das gibt es auch in Scala, siehe Kapitel 8 – als Argument übergeben? Wir plädieren dafür, dass das nicht zulässig ist, ignorieren das aber erst einmal bis auf einen Kommentar und schieben das bis zum Kapitel 6 über Testen auf. Ansonsten berechnen wir erst die Darstellungen der beiden Time-Instanzen this und that in Minuten, subtrahieren dann diese beiden Werte voneinander und geben das Ergebnis zurück. Das bedeutet, dass die Zeitdifferenz in Minuten berechnet wird. Hier kommt unser erster Versuch: def minus(that: Time): Int = { // TODO Check preconditions! val thisAsMinutes = minutes + 60 * hours val thatAsMinutes = that.minutes + 60 * that.hours thisAsMinutes - thatAsMinutes }
Geübte Programmierer werden sicher gleich Potential für Refactorings erkennen, aber bevor wir uns diesen annehmen, wollen wir unsere Methode ausprobieren: scala> val time1 = new Time(2, 20) time1: Time = Time@3681e09f scala> val time2 = new Time(1, 30) time2: Time = Time@6fad97bb scala> time1.minus(time2) res0: Int = 50
Einschub: lazy vals Das sieht doch vom Ergebnis her schon ganz gut aus, oder? Daher wollen wir nun unser Augenmerk wieder auf den Code richten. Ganz offensichtlich können wir die zweimalige Berechnung der Minuten-Darstellung abstrahieren in – ja, in was eigentlich? Eine Möglichkeit wäre eine neue Methode für Time, welche die Zeit in Minuten zurückgibt. Aber da unsere Time-Klasse unveränderlich ist, können wir genauso gut ein Feld verwenden. Der Vorteil wäre, dass dieses nur einmal berechnet würde, der Nachteil jedoch, dass es in jedem Fall berechnet und gespeichert wird, auch wenn es möglicherweise nie benötigt wird. Natürlich sind in unserem Fall diese Überlegungen irrelevant, aber wenn Berechnungen lange dauern oder der Speicherbedarf eines Wertes groß ist, dann gewinnen sie an
48
Klassen Bedeutung. Scala bietet als Ausweg noch eine weitere Möglichkeit: vals, denen das Schlüsselwort lazy vorangestellt wird, werden erst dann berechnet, wenn sie benötigt werden. Genau das wollen wir hier verwenden, wie gesagt nicht wegen der Performance, sondern aus didaktischen Gründen: lazy val asMinutes = minutes + 60 * hours def minus(that: Time): Int = { // TODO Check preconditions! this.asMinutes - that.asMinutes }
Operatoren und Operator-Notation Scala nimmt es mit der Objektorientierung sehr ernst: Wie wir noch im Detail in Kapitel 8 sehen werden ist alles ein Objekt. Und Operatoren sind nichts anderes als Methoden, deren Namen aus speziellen Zeichen bestehen. Das wohl beste Beispiel ist die Addition von zwei Int-Werten, wobei wir im Folgenden voraussetzen, dass x und y zwei Int-Variablen mit Wert 1 sein sollen: scala> x.+(y) res0: Int = 3
Diese Zeile zeigt einen Methodenaufruf, notiert mit Punkt und Klammern, wie er einem Java-Programmierer geläufig sein sollte, wenn man dabei außer Acht lässt, dass der Methodenname ungewöhnlich ist. Natürlich ist diese Notation nicht intuitiv bzw. schlecht lesbar, denn wir erwarten schlicht und einfach, dass Operatoren in Operator-Notation, d.h. ohne Punkt und Klammern geschrieben werden. Und genau das dürfen wir in Scala tun: scala> x + y res1: Int = 3
Wenn Operatoren schlicht Methoden sind, dann liegt die Vermutung nahe, dass wir auch „normale“ Methoden in Operator-Notation schreiben können. Ein Versuch mit unserem obigen Time-Beispiel bestätigt das: scala> time1 minus time2 res0: Int = 50
Technisch betrachtet könnten wir die Operator-Notation in vielen Fällen anwenden, aber wir sollten sie nur dann benützen, wenn eine Methode genau einen Parameter hat. Und auch dann nur, wenn die Methode frei von Seiteneffekten ist und ein Ergebnis hat, also nicht Unit zurückgibt. Dazu rät zumindest der Scala Style Guide2, den wir generell
2
http://davetron5000.github.com/scala-style/
Durchstarten mit Scala
49
5 – OO-Grundlagen wärmstens empfehlen, und wir stimmen in dieser speziellen Frage ganz ausdrücklich zu. Nun liegt natürlich die Frage auf der Hand, ob wir nicht für unsere minus-Methode einen Operator-Alias definieren sollten. Meist ist man gut beraten, es mit den Operatoren nicht zu übertreiben, denn diese machen nur dann Sinn, wenn die Bedeutung intuitiv klar wird. In unserem Fall ist diese Voraussetzung natürlich gegeben, sodass wir die Time-Klasse um eine zusätzliche Methode ergänzen: def -(that: Time): Int = minus(that)
Nun können wir das Time-Beispiel von oben auch folgendermaßen schreiben: scala> time1 - time2 res0: Int = 50
Abschließend wollen wir noch zwei Themen kurz ansprechen. Die Frage nach Operator Overloading können wir rasch und einfach mit Vererbung beantworten. Da Operatoren Methoden sind, können wir diese in Sub-Klassen überschreiben, sofern sie nicht als final definiert wurden. Etwas kniffeliger wird es bei der Operator-Präzedenz. Für „normale“ Methoden gilt natürlich, dass diese alle gleichwertig sind und von links nach rechts ausgewertet werden. Für Operatoren jedoch, also Methoden mit „speziellen“ Zeichen im Namen, gilt per Definition eine Reihenfolge. So ist zum Beispiel „*“ höherwertig als „+“. Für Details sei auf die Scala-Sprachspezifikation3 verwiesen.
5.2.4
Named and Default Arguments
Wir wollen unser Kapitel über Klassen mit einem recht neuen Feature beschließen. Seit Scala 2.8 können wir Parametern von Methoden und Konstruktoren Default-Werte zuweisen. Diese gelangen dann zur Anwendung, wenn wir beim Aufruf die entsprechenden Argumente weglassen. Das wollen wir gleich anhand unserer Time-Klasse demonstrieren: class Time(val hours: Int = 0, val minutes: Int = 0) { ...
Wir schreiben einfach hinter der Typangabe der Parameter ein Gleichheitszeichen und einen Default-Wert. Nun können wir beim Anlegen von Time-Instanzen die Minuten weglassen: scala> new Time(1) res0: Time = Time@55858df1
Wir können sogar die Minuten und die Stunden weglassen, also eine Leere Liste von Argumenten übergeben:
3
50
http://www.scala-lang.org/docu/files/ScalaReference.pdf
Packages und Sichtbarkeit scala> new Time() res0: Time = Time@6fc33c70
Aber wie soll es funktionieren, wenn wir die Stunden, die ja als erstes Argument erwartet werden, weglassen, die Minuten jedoch angeben wollen. Dann müssen wir dem ScalaCompiler ein bisschen helfen, indem wir ihm genau sagen, welchen Parameter wir meinen. Dazu scheiben wir vor die Argumente einfach die Namen der jeweiligen Parameter gefolgt von einem Gleichheitszeichen. In unserem Beispiel sieht das folgendermaßen aus: scala> new Time(minutes = 10) res0: Time = Time@40ad48a1
Mit diesen sogenannten Named and Default Arguments können wir uns in der Regel Auxiliary Constructors sparen, weil diese meist den Primary Constructor mit einem DefaultWert für einen bestimmten Parameter aufrufen. So können wir die Code-Menge reduzieren und wichtige Informationen an einer Stelle „zusammenhalten“.
5.3
Packages und Sichtbarkeit
Solange wir nur wenige Klassen haben, kommen wir ganz gut ohne Packages aus. Aber irgendwie fühlt es sich komisch an, ganz ohne Packages zu arbeiten und alle Klassen in das Root Package zu geben. Schließlich haben wir uns inzwischen alle an die Konvention gewöhnt, für jedes Projekt ein eigenes Top Level Package zu verwenden, das gemäß der Reverse Domain Naming Convention benannt wird, also zum Beispiel org.scalatrain. Daher ist es jetzt an der Zeit, dass wir uns mit Packages beschäftigen. Zunächst einmal sieht es so aus, als gäbe es keinen Unterschied zu Java. Denn auch in Scala können wir unsere Quelldateien mit einer Package-Definition beginnen, die genauso aussieht wie in Java. Die folgende Zeile, beginnend mit dem Schlüsselwort package gefolgt vom Package-Namen, fügen wir unseren beiden bisherigen und ebenso allen künftigen Quelldateien hinzu: package org.scalatrain
Java-Programmierer werden nun möglicherweise denken, dass unser Projekt nicht mehr übersetzt werden kann. Schließlich befinden sich die Quelldateien nach wie vor im Wurzel-Verzeichnis src/main/scala, sodass die Verzeichnisstruktur nicht mehr der PackageStruktur entspricht. Allerdings gilt in Scala nicht die aus Java bekannte Einschränkung, dass diese beiden Strukturen übereinstimmen müssen. Auf diese Weise können wir uns die eigentlich überflüssigen weil immer gleichen Verzeichnisse für die Namenselemente des Top Level Packages sparen und gelangen zu einer nicht so tief verschachtelten Verzeichnisstruktur. Dennoch ist es guter Stil, die eigentlichen Packages eines Projektes, also die unterhalb des Top Level Packages, in der Verzeichnisstruktur nachzubilden. Wenn wir
Durchstarten mit Scala
51
5 – OO-Grundlagen zum Beispiel unterhalb des Packages org.scalatrain noch die Sub-Packages lift und darunter snippet hätten, dann würden wir diese Verzeichnisstruktur verwenden: src/main/scala/ lift/snippet. Selbstverständlich können wir uns auch an die Java-Regeln halten und die Verzeichnisse 1:1 den Packages nachempfinden, das ist letztendlich Geschmackssache.
5.3.1
Verschachtelte Packages
Wenn wir diese Art der Package-Definition verwenden, dann entspricht das resultierende Verhalten tatsächlich dem, das wir von Java kennen. Dennoch gibt es einen feinen Unterschied: In Scala sind Sub-Packages Bestandteil des Packages, in dem sie sich befinden. Das bedeutet, dass Klassen aus einem Package grundsätzlich auf alle Klassen aus allen SuperPackages zugreifen können. Das führt zur Frage, wie wir in Scala ein Sub-Package definieren können. Das lässt sich am besten in einer alternativen und recht selten angewendeten Notation sehen. Dabei greifen wir dem Kapitel 9 über Vererbung ein wenig vor uns verwenden bereits das Schlüsselwort extends, mit dem wir die Klasse B die Klasse A erweitern lassen: package a { class A package b { class B extends A } }
Hier verwenden wir sogenannte Nested Package Clauses, bei denen der Inhalt eines Packages in geschweiften Klammern geschrieben wird. Wie wir sehen, enthält das Package a nicht nur die Klasse A, sondern auch das Package b, womit b ein Sub-Package von a ist. Daher können wir im Package b auch die Klasse A verwenden, ohne diese importieren zu müssen. Wenn wir jedoch die verschachtelten Packages auseinanderreißen, dann kann der Code nicht mehr compiliert werden: package class } package class }
a { A a.b { B extends A // Won’t compile!
Obwohl wir das untere Package a.b genannt haben, es somit also ein Sub-Package von a ist, können wir nicht auf die Member von a zugreifen. Das bedeutet, dass die Benennung eines Packages mit vorangestelltem und durch einen Punkt getrennten Namen eines anderen Packages nicht automatisch alle Super-Packages in den zugreifbaren Scope bringt. Kommen wir zurück auf unsere ursprüngliche Schreibweise. Damit definieren wir in unserem Beispiel das Package org.scalatrain, aus dem wir nicht auf die Member von
52
Packages und Sichtbarkeit org zugreifen können. Das ist übrigens erst seit Scala 2.8 so! Gründe und Details für diese Änderung können der Online-Dokumentation4 entnommen werden. Falls wir aber doch auf die Member der Super-Packages zugreifen wollen, aber nicht die Nested Package Clauses verwenden wollen, dann kommen die sogenannten Chained Package Clauses ins Spiel, die ebenfalls in Scala 2.8 eingeführt wurden. Wenn wir zum Beispiel in Train wirklich auf alles aus dem org-Package zugreifen wollten, könnten wir das so schreiben: package org package scalatrain
Allerdings macht es wenig Sinn, sich einen so allgemeinen Namensraum wie org zu „öffnen“. Dagegen ist es durchaus üblich und in vielen Fällen sehr praktisch, als ersten Package Clause das Top Level Package für das Projekt „komplett hinzuschreiben“ und darunter die einzelnen Sub-Packages je als eigenen Package Clause. Wenn wir zum Beispiel die Sub-Packages lift und darunter snippet hätten, dann könnte das so aussehen: package org.scalatrain package lift package snippet
5.3.2
Imports
Wenn wir uns nicht in einem Sub-Package befinden und mit Chained Package Clauses arbeiten, oder wenn wir Klassen aus ganz anderen Packages nutzen wollen, dann müssen wir diese importieren, um sie verwenden zu können. Dazu verwenden wir das Schlüsselwort import, gefolgt von einer der folgenden Möglichkeiten:
•• Fully qualified class name (FQCN) •• Wildcard •• Import Selector Clause Die erste Möglichkeit sieht aus wie in Java; wir schreiben einfach den vollständigen Klassennamen hin: import org.scalatrain.Time
Damit importieren wir genau die Train-Klasse. Wenn wir alle Member eines Packages importieren möchten, dann verwenden wir den Unterstrich „_“: import org.scalatrain._
4
http://www.scala-lang.org/docu/files/package-clauses/packageclauses.html
Durchstarten mit Scala
53
5 – OO-Grundlagen So könnten wir zum Beispiel stets in die REPL einsteigen, um weiterhin Instanzen von Train oder Time anzulegen. Diese befinden sich ja jetzt im Package org.scalatrain und sind daher nicht „einfach so“ sichtbar. scala> new Time(1, 30) :6: error: not found: type Time new Time(1, 30) scala> import org.scalatrain._ import org.scalatrain._ scala> new Time(1, 30) res1: org.scalatrain.Time = org.scalatrain.Time@7c39aa6
Mit Wildcard-Importen sollte man bekanntlich vorsichtig sein, weil man rasch unüberschaubar viele Klassen importiert und möglicherweise Namenskollisionen riskiert. Scala bietet mit den sogenannten Import Selector Clauses eine weitere Möglichkeit, mit der wir in einer Zeile mehrere Klassen aus einem Package importieren können. Dazu schreiben wir einfach mehrere Klassen, durch Komma getrennt, in geschweifte Klammern: import org.scalatrain.{ Time, Train }
Aber Import Selector Clauses sind nicht nur ein Mittelding zwischen FQCN- und Wildcard-Import, sondern können auch verwendet werden, um Namenskonflikte zu vermeiden, indem Klassen beim Import umbenannt werden. So können wir bei zwei eigentlich gleichnamigen Klassen darauf verzichten, die eine immer voll qualifiziert verwenden zu müssen. Im folgenden Beispiel können wir im Code Date bzw. SqlDate für java.util.Date bzw. java.sql.Date verwenden: scala> import scala> import
import java.util.Date java.util.Date import java.sql.{ Date => SqlDate } java.sql.{Date=>SqlDate}
Abschließend sei zu Imports noch bemerkt, dass wir diese in Scala überall im laufenden Code verwenden können und nicht nur „oben“ in einer Datei. Das macht insbesondere dann Sinn, wenn wir damit die Imports auf einen bestimmten Scope, zum Beispiel auf die Implementierung einer Methode, beschränken wollen.
5.3.3
Sichtbarkeit
Wir wissen schon, dass es in Scala kein public gibt, weil einfach alles öffentlich sichtbar ist, was nicht als protected oder private definiert ist. Diese beiden Schlüsselworte können wir wie in Java verwenden, wobei private exakt dieselbe Bedeutung hat, protected hingegen etwas strenger ist, als in Java. Denn protected in Scala erlaubt ausschließlich Zugriff aus SubKlassen heraus, wohingegen in Java auch Zugriff aus demselben Package möglich ist.
54
Singleton Objects Zusätzlich bieten beide Schlüsselworte die Möglichkeit, ihren Wirkungsbereich feingranular einzuschränken. Dazu können wir in Form eines sogenannten Qualifiers in eckigen Klammern eine Super-Klasse oder ein einschließendes Package angeben, bis zu dem der Zugriff öffentlich sei soll. Ja, wir könnten sogar durch private[this] den Zugriff auf das aktuelle Objekt beschränken. In größeren Projekten kommen die Vorzüge dieser qualifizierten Access Modifiers rasch zur Geltung. Zum Beispiel könnten wir Implementierungsklassen, die wir nicht öffentlich machen wollen, aus allen unseren (denkbaren) Sub-Packages verwenden, die ja alle mit org.scalatrain beginnen, wenn wir diese mit private[scalatrain] kennzeichnen würden.
5.4
Singleton Objects
Bisher haben wir uns in Sachen Objektorientierung auf von Java her vertrautem Terrain bewegt, aber nun werden wir eine Neuerung kennen lernen, die es so in Java nicht gibt. Mit dem Schlüsselwort object können wir sogenannt Singleton Objects definieren. Diese sind sozusagen Klasse und einzige Instanz davon in einem. Am besten schauen wir uns gleich ein Beispiel an. Dazu ergänzen wir unsere Quelldatei Time.scala erst einmal um folgende Definition. object Time
Dabei spielt es für den Scala-Compiler keine Rolle, ob wir dies Zeile oberhalb oder unterhalb der Time-Klasse schreiben, da das Singleton Object Time nichts mit der gleichnamigen Klasse zu tun hat. Insbesondere gibt es auch keinen Namenskonflikt für den Compiler, weil dieser Klassen und Singleton Objects als grundlegend verschiedene Dinge betrachtet und daher unterschiedliche Namensräume verwendet. Mit diesem Singleton Object können wir noch nicht allzu viel anfangen, aber immerhin können wir die Quelldatei übersetzten. Das liegt daran, dass eine Quelldatei in Scala beliebig viele Compilation Units – also Klassen, Singleton Objects und Traits, die wir in Kapitel 9 kennenlernen werden – enthalten darf. Das führt zu der Frage, wann wir separate Quelldateien verwenden und wann nicht. Es gibt technische Gründe, auf die wir noch zu sprechen kommen werden, dass wir in bestimmten Fällen eine einzelne Quelldatei benötigen. Abgesehen davon gilt, dass die Aufteilung in separate Quelldateien meist die Übersichtlichkeit erhöht. Manchmal jedoch, insbesondere wenn Klassen eng zusammenhängen, zum Beispiel aufgrund von Vererbungsbeziehungen, und wenn diese Klassen vom Code-Umfang her sehr klein sind, dann gilt genau das Gegenteil, d.h. dann fördert das Zusammenlegen die Übersicht.
Durchstarten mit Scala
55
5 – OO-Grundlagen
5.4.1
Companion Objects
Unser Time-Beispiel ist sogar ein besonderer Fall: Ein Singleton Object wird als Companion Object zu einer Klasse oder zu einem Trait bezeichnet, wenn es denselben Namen trägt und in derselben Quelldatei und im selben Package liegt. In diesem Fall gilt dann die Besonderheit, dass wir aus der Klasse oder dem Trait auf die privaten Member des Companion Objects zugreifen können. Überdies werden Implicits vom Compiler stets in den Companion Objects gesucht, doch dazu mehr in Kapitel 10. Doch jetzt genug der Theorie! Wir wollen unserem neuen Companion Object ein wenig Leben einhauchen, und zwar in Form einer Methode, die aus einem Int-Wert, der für eine Anzahl von Minuten stehen soll, eine Time-Instanz erzeugt. In Java wäre das übrigens ein klassischer Fall für static, aber das gibt es in Scala nicht. Dafür gibt es eben Singleton Objects, die „echte“ Objekte darstellen und mehr können als statische Member, zum Beispiel auch als Parameter übergeben werden. def fromMinutes(minutes: Int): Time = { // TODO Check preconditions! new Time(minutes / 60, minutes % 60) }
Nun sind wir soweit, dass wir unser Singleton Object in der REPL ausprobieren können. Dazu nennen wir einfach das Kind beim Namen, d.h. wir referenzieren das Singleton Object genauso, wie wir es definiert haben. Ohne new oder dergleichen, denn es ist ja quasi schon eine Instanz: scala> Time.fromMinutes(100) res0: org.scalatrain.Time = org.scalatrain.Time@6998e338
Dieser Methodenaufruf ist übrigens ein Kandidat für die Operator-Notation, denn wir haben eine Methode mit genau einem Parameter, die etwas zurückgibt und keinen Seiteneffekt hat: scala> Time fromMinutes 100 res0: org.scalatrain.Time = org.scalatrain.Time@7271601f
5.4.2
Predef
Es gibt in der Scala-Standardbibliothek ein ganz besonderes Singleton Object: scala.Predef. Dessen Member werden automatisch importiert, sodass wir diese jederzeit verwenden können, ohne sie explizit importieren zu müssen. Als ein prominentes Beispiel wollen wir jetzt die Methode Predef.require betrachten, die wir verwenden können, um Preconditions zu überprüfen. Diese Methode gibt es in zwei Ausprägungen: Beide Signaturen erwarten als erstes Argument einen Boolean-Ausdruck und die eine zusätzlich eine String-Nach-
56
Case Classes richt. Im Endeffekt wird überprüft, ob die Precondition zutrifft, und wenn nicht, dann wird eine IllegalArgumentException geworfen. Wir haben zwar bereits einige Stellen identifiziert, an denen wir Preconditions überprüfen wollen, aber wie schon angedeutet, sparen wir uns diese Stellen noch bis Kapitel 6 über Testen auf. Hier nehmen wir uns unsere allererste Klasse vor, bei der wir uns bisher noch keine Gedanken über Preconditions gemacht haben. Ein Blick auf die Klasse Train legt nahe, dass die beiden Klassenparameter vom Typ String beide nicht null sein dürfen: class Train(val kind: String, val number: String) { require(kind != null, "kind must not be null!") require(number != null, "number must not be null!") }
Hier sei noch einmal explizit erwähnt, dass wir keinen Import benötigen, um require benützen zu können. Nun wird sich bestimmt der eine oder andere fragen, wann die beiden require-Aufrufe ausgeführt werden, denn sie stehen „einfach so“ innerhalb der Klassendefinition. Die Antwort ist erstaunlich einfach: Aller Code mit Ausnahme von Methodendefinitionen wird im Primary Constructor „von oben nach unten“ ausgeführt. In unserem Fall also werden die beiden Preconditions geprüft, wenn ein neuer Train angelegt wird. Das bedeutet im Umkehrschluss, dass wir Preconditions in der Regel immer direkt nach der Klassendefinition, also sozusagen nach der Signatur des Primary Constructors, schreiben sollten.
5.5
Case Classes
Bevor wir dieses einführende Kapitel über Objektorientierung beenden, richten wir unser Augenmerk auf ein kleines aber feines Sprach-Feature, die sogenannten Case Classes. Indem wir einer Klassendefinition das winzige Schlüsselworte case voranstellen, können wir einer gewöhnlichen Klasse, wie wir sie bisher kennen gelernt haben, eine Menge an nützlicher Funktionalität hinzufügen. Das wollen wir zunächst an einem ganz einfachen Beispiel in der REPL veranschaulichen: scala> case class Person(firstName: String, lastName: String) // (1) defined class Person scala> val person = Person("John", "Doe") // (2) person: Person = Person(John,Doe) scala> Person("John", "Doe") == person // (3) res0: Boolean = true scala> person.firstName // (4) res1: String = John scala> person.copy(lastName = "Toe") // (5) res2: Person = Person(John,Toe)
Durchstarten mit Scala
57
5 – OO-Grundlagen Zuerst legen wir in (1) die Case Class Person an. Hierbei sei ausdrücklich darauf hingewiesen, dass wir den Klassenparameter name nicht explizit zum val machen. Dann legen wir in (2) eine neue Instanz von Person an, wobei wir das Schlüsselwort new weglassen können. Wie funktioniert denn das? Indem der Scala-Compiler zu jeder Case Class ein Companion Objekt erzeugt, welches eine apply-Methode hat, die eine neue Instanz erzeugt. Und indem der Scala-Compiler Person(“John“) zu Person.apply(“John“) umwandelt. D.h. wir rufen tatsächlich eine Factory-Methode auf, um eine neue Instanz zu erzeugen. Wie wir sehen können, bewegen wir uns zu 100 Prozent auf dem Weg der „normalen“ Objektorientierung und sehen nur ein wenig „syntaktischen Zucker“ in Aktion. Die Antwort der REPL auf das Anlegen einer neuen Person weist uns gleich auf ein weiteres Feature von Case Classes hin: Offenbar überschreiben diese die toString-Methode derart, dass der Klassenname ausgegeben wird und in Klammern die Ergebnisse des Aufrufes von toString auf den Klassenparametern. Das ist sicher ein „sinnvoller“ Standard, jedenfalls bedeutend ausdrucksstärker, als die Standard-Implementierung von java.lang. Object, die nur die kryptische Objektreferenz zurückgibt. Aber Case Classes überschreiben nicht nur die toString-Methode sinnvoll, sondern auch noch equals und hashCode, wobei in beiden Fällen sämtliche Klassenparameter zur Berechnung des Rückgabewertes herangezogen werden. Darum können wir nun auch in (3) zwei Instanzen von Person miteinander vergleichen und erhalten true, wenn die Klassenparameter identisch sind. Wie wir in (4) sehen, können wir auf Person.firstName zugreifen, obwohl wir firstName nur als „normalen“ Klassenparameter definiert haben, d.h. ohne val voranzustellen. Das liegt daran, dass der Scala-Compiler für Case Classes automatisch alle Klassenparameter zu vals macht. Anders ausgedrückt sind Case Classes also unveränderliche Objekte mit öffentlichen Feldern. Abschließend sei noch die copy-Methode erwähnt, mir der wir ganz einfach neue Instanzen einer Case Class erzeugen können, die mit den Werten einer existierenden Instanz vorbelegt sind. Dank Named and Default Arguments (siehe Kapitel 5.2.4) können wir dabei einzelne Klassenparameter ändern, zum Beispiel lastName in (5). Zusammengefasst bieten uns also Case Classes diese Features:
•• Instanzen können mittels Factory-Methode erzeugt werden, also ohne new •• toString, equals und hashCode sind sinnvoll überschrieben •• Klassenparameter sind automatisch vals •• Kopien können mit der copy-Methode sehr einfach erzeugt werden Wir werden in Kapitel 9 mit dem sogenannten Pattern Matching noch ein weiteres sehr mächtiges Feature von Case Classes kennenlernen, aber jetzt wollen wir erst einmal die gerade aufgezählten Vorteile für unser Fallbeispiel nutzen. Das ist fast schon zu einfach, denn wir müssen nur ein case vor die Klassendefinitionen von Time und Train schreiben und die überflüssigen val-Definitionen für die Klassenparameter entfernen.
58
Case Classes case class Train(kind: String, number: String) { ... case class Time(hours: Int = 0, minutes: Int = 0) { ...
Ein kleiner Test in der REPL zeigt, dass Case Classes auch wunderbar mit Named and Default Arguments zusammenspielen: scala> val highNoon = Time(12) highNoon: org.scalatrain.Time = Time(12,0) scala> val halfPastNoon = highNoon.copy(minutes = 30) halfPastNoon: org.scalatrain.Time = Time(12,30)
Bei all den Vorzügen, die Case Classes bieten, stellt sich die Frage, warum wir diese nicht immer verwenden oder warum der Scala-Compiler nicht gleich bei „normalen“ Klassen diese Features hinzufügt. Das liegt vor allem an zwei Gründen. Zunächst einmal führen Case Classes zu etwas mehr Code als Ihre „normalen Geschwister“. Schließlich werden nicht nur Implementierungen für toString, equals und hashCode hinzugefügt, sondern auch noch ein Singleton Object. Das wird aber für die meisten Fälle wohl kaum eine signifikante Rolle spielen, wohingegen das folgende Problem schwerer wiegen dürfte: Case Classes und Vererbung vertragen sich nicht gut. Konkret sollte man davon Abstand nehmen, dass eine Case Class eine andere Case Class erweitert. Das hat vor allem damit zu tun, dass die copy-Methode dann nicht mehr korrekt funktioniert, wie wir gleich sehen werden. Zum Glück macht uns der Scala-Compiler bzw. die REPL mittels einer Deprecation-Warnung darauf aufmerksam, wenn wir so etwas versuchen: scala> case class A(s: String) defined class A scala> case class B(i: Int) extends A("a") warning: there were deprecation warnings; re-run with -deprecation for details defined class B scala> B(1).copy() res0: A = A(a)
Nun wissen wir also, dass wir keine Vererbungshierarchien mit Case Classes aufbauen können. Aber das lässt immer noch Spielraum offen. Wann sollten wir also Case Classen verwenden und wann nicht? Eine typische Verwendung ist die als Value Object bzw. Data Transfer Object5, also als Objekt, das überwiegend wegen seines Zustandes und nicht wegen seines Verhaltens verwendet wird. Gerade bei solchen Objekten benötigt man häufig genau die Art der Implementierung von equals und hashCode, die Case Classes anbieten, oder auch die einfache Möglichkeit zum Kopieren, welche die copy-Methode zur Verfügung stellt. Dagegen machen Case Classes eher wenig Sinn, um Services zu realisieren, also Objekte, die überwiegend wegen ihres Verhaltens genutzt werden. Wenn wir auf un-
5
http://en.wikipedia.org/wiki/Data_transfer_object
Durchstarten mit Scala
59
5 – OO-Grundlagen ser Fallbeispiel schauen, dann stellen wir fest, dass Time und Train voll und ganz in die Kategorie der Value Objects fallen, also hervorragend für Case Classes geeignet sind. Andere Klassen hingegen, die wir noch entwickeln werden, wie zum Beispiel der JourneyPlanner aus Kapitel 7, stellen Services bereit und kommen daher besser ohne case aus.
5.6
Projekt-Code: aktueller Stand
Nach den Arbeiten in diesem Kapitel sieht unser Projekt aktuell folgendermaßen aus: package org.scalatrain object Time { def fromMinutes(minutes: Int): Time = { // TODO Check preconditions! new Time(minutes / 60, minutes % 60) } } case class Time(hours: Int = 0, minutes: Int = 0) { // TODO Check preconditions! lazy val asMinutes asMinutes: Int = minutes + 60 * hours def -(that: Time): Int = minus(that) def minus(that: Time): Int = { // TODO Check preconditions! this.asMinutes - that.asMinutes } }
Listing 5.1: Time.scala package org.scalatrain case class Train(kind: String, number: String) { require(kind != null, "kind must not be null!") require(number != null, "number must not be null!") }
Listing 5.2: Train.scala
60
6 6
Testen von ScalaProgrammen
Auch für Scala gilt, zumindest nach unserer Auffassung, dass nur gut getesteter Code guter Code ist. Da das Testen bekanntlich mit Unit-Tests beginnt, werden wir in diesem Kapitel vor allem ein Scala-Framework für Unit-Tests betrachten, mit dem wir unser Fallbeispiel testgetrieben weiterentwickeln werden. Natürlich könnten wir auch mit JUnit arbeiten, da wir dank der Interoperabilität mit Java jegliche Java-Library verwenden können. Aber wie wir sehen werden, können wir mit Scala nicht nur unseren eigentlichen Code verbessern, sondern auch viel bessere Testfälle schreiben. Weiter werden wir auch noch auf ein eng verwandtes Thema eingehen und zeigen, wie wir in Scala-Projekten die Test Coverage messen können.
6.1
Unit-Tests mit specs
Aktuell gibt es zwei Scala-Frameworks für Unit-Tests, die sich großer Beliebtheit erfreuen: ScalaTest1 und specs2. Aus unserer Sicht sind beide sehr gut, sodass wir tatsächlich vor der Qual der Wahl stehen. Bei ganz genauer Betrachtung ergeben sich zwei Unterschiede, die für uns das Pendel zugunsten von specs ausschlagen lassen. Erstens bietet ScalaTest verschiedene Möglichkeiten bzw. Stilrichtungen, die Tests zu schreiben. Wir fühlen uns aber mit einer einzigen wohler, weil dann alle Tests denselben Stil haben. Zweitens geht specs in Sachen Integration von Mocking-Frameworks weiter. Insbesondere gibt es für Mockito3, das Mocking-Framework unserer Wahl, eine sehr elegante Integration.
6.1.1
Vorbereitung: Dependencies verwalten mit SBT
Bevor wir loslegen können, müssen wir die specs-Library unserem Projekt hinzufügen. Dafür bietet SBT zwei Möglichkeiten. Der einfachste Ansatz wäre, dass wir uns die JARDatei herunter laden und diese einfach im lib-Verzeichnis unseres Projekts ablegen. Dabei müssen wir aber darauf achten, dass wir eine Version verwenden, die gegen unsere ScalaVersion compiliert wurde, d.h. gegen 2.8.1. Scala ist nämlich in Sachen Binär-Kompatibilität sehr empfindlich: In den meisten Fällen muss Software, die mit einer gewissen Scala-
1
http://www.scalatest.org/
2
http://code.google.com/p/specs/
3
http://mockito.org/
Durchstarten mit Scala
61
6 – Testen von Scala-Programmen Version verwendet werden soll, auch gegen diese compiliert worden sein4. Die andere Möglichkeit lautet, dass wir – ähnlich wie in Maven – unsere Dependencies deklarieren und SBT sich dann darum kümmert, die richtigen Versionen herunterzuladen und uns zur Verfügung zu stellen. Aus unserer Sicht sind diese sogenannten Managed Dependencies für „echte“ Projekte der bessere Weg, weil auch automatisch transitive Dependencies heruntergeladen werden, sodass wir diesen hier einschlagen werden. Bisher hat uns SBT nur rudimentäre Informationen über unser Projekt abverlangt: Beim Anlegen mussten wir Name, Version etc. angeben. Diese Daten finden wir im projectVerzeichnis in der Datei build.properties. Nun geht es darum, Dependencies zu deklarieren, sodass sich die Frage auftut, wo wir dies tun können. Ant und Maven verwenden XML-Dateien und da Scala auch sehr gute Unterstützung für XML bietet, liegt die Vermutung nahe, dass SBT genauso funktioniert. Aber Scala eignet sich auch hervorragend zum Erstellen von internen Domain Specific Languages5, also solchen, die in die Sprache eingebettet sind. Daher ist es nur konsequent, dass SBT mit Hilfe einer Projektdefinition in Scala konfiguriert wird. Diese muss im Verzeichnis project/build liegen und kann einen beliebigen Namen haben, wobei sich entweder Project.scala oder der Projektname gefolgt von „Project“ eingebürgert hat, also in unserem Fall ScalaTrainProject.scala. import sbt._ class ScalaTrainProject(info: ProjectInfo) extends DefaultProject(info)
In dieser SBT-Projektdefinition importieren wir zunächst alles aus dem sbt-Package und definieren dann unser Projekt als Klasse, welche die Basisklasse DefaultProject erweitert. Keine Sorge, wir kommen in Kapitel 8 noch zu den Details der Vererbung in Scala. Bisher haben wir noch nichts gewonnen, denn unser ScalaTrainProject ist ja noch leer. Wir könnten nun zahlreiche Eigenschaften unseres Projekts konfigurieren, indem wir einfach Methoden überschreiben oder neue Felder hinzufügen, die von SBT automatisch erkannt werden. Zum Beispiel könnten wir Sub-Projekte definieren oder die Standard-Projektstruktur ändern. Was uns jedoch nun interessiert, sind die Managed Dependencies: Ganz konkret wollen wir specs und Mockito einbinden. Dazu definieren wir in der ScalaTrainProjectKlasse einfach zwei vals, welche die beiden Dependencies konkret beschreiben: val mockito = "org.mockito" % "mockito-all" % "1.8.5" % "test" val specs = "org.scala-tools.testing" %% "specs" % "1.6.7" % "test" withSources
Was wir hier vorliegen haben, ist gültiger Scala-Code! Dabei kommen fortgeschrittene Sprach-Features wie zum Beispiel Implicit Conversions zum Einsatz, deren Erklärung
4
http://stackoverflow.com/questions/2053265/scala-binary-incompatibility-between-releases
5
http://de.wikipedia.org/wiki/Domänenspezifische_Sprache
62
Unit-Tests mit specs hier zu weit führen würde, aber in Kapitel 11 kommt. Wichtig ist, dass im Resultat der Code gar nicht mehr wie „allgemeiner“ Scala-Code aussieht, sondern ganz speziell auf eine bestimmte Problemstellung zugeschnitten ist. In diesem Fall dürfte sich jeder gut zurechtfinden, der schon einmal mit Maven gearbeitet hat, denn SBT basiert auf Ivy und ist in der Lage, Dependencies aus Maven Repositories herunterzuladen. Neben dem zentralen Maven Standard-Repository wird dabei auch stets das Scala-Tools.org Repository6 eingebunden. Nun zu den Details der obigen Notation: Wir geben zunächst die organization bzw. die groupId (Maven-Jargon) an, gefolgt vom Operator % oder %%. Den ersten verwenden wir, wenn wir „normale“ Java-Libraries nutzen und den zweiten für Scala-Libraries. SBT ist dann nämlich in der Lage, die zu unserer Scala-Version passende Version der Library herunterzuladen. Für Details sei auf die SBT-Online-Dokumentation7 verwiesen. Danach schreiben wir den name bzw. die artifactId (Maven-Jargon), dann den %-Operator, dann die version und schließlich, wieder nach dem %-Operator, die configuration bzw. den scope (Maven-Jargon). Durch den abschließenden Aufruf von withSources erreichen wir, dass für specs auch der Quelltext heruntergeladen wird, sodass wir die ScalaDoc-Kommentare zur Verfügung haben und bei Bedarf in die Tiefen der Implementierung abtauchen könnten. Nachdem wir die SBT-Projektdefinition wie oben mit den Managed Dependencies erstellt haben, müssen wir SBT mittels reload zuerst mitteilen, dass die SBT-Projektdefinition neu geladen werden soll: > reload [info] Recompiling project definition... [info] Source analysis: 1 new/modified, ...
Anschließend müssen wir mit Hilfe von update dafür sorgen, dass SBT die Managed Dependencies aktualisiert, denn im Unterschied zu Maven geschieht das nicht automatisch beim Compilieren, sondern nur „auf Kommando“. > update ... [info]
3 artifacts copied, 0 already retrieved (4528kB/29ms)
Wir können sehen, dass drei Quelldateien kopiert wurden. Doch wohin? In das Verzeichnis lib_managed. Dort legt SBT für jede Scala-Version, also in unserem Fall nur Scala 2.8.1, ein Verzeichnis an, welches in Unterverzeichnissen compile, test, provided etc. für die verschiedenen Configurations bzw. Scopes die Libraries enthält. Das bedeutet, dass SBT alle Dependencies lokal im Projektverzeichnis ablegt. Auf diese Weise können wir mit SBT
6
http://scala-tools.org/repo-releases/
7
http://code.google.com/p/simple-build-tool/wiki/LibraryManagement
Durchstarten mit Scala
63
6 – Testen von Scala-Programmen jederzeit autark arbeiten, unabhängig ob online oder offline und vom Zustand eines projektübergreifenden Repositories, wie es in Maven verwendet wird. Nachdem wir SBT-seitig nun fertig sind, müssen wir noch dafür sorgen, dass auch IDEA die neuen Libraries kennt. Dazu geben wir wieder das Kommando idea ein, welches das IDEA-Projekt neu erzeugt. Wenn wir nun zu IDEA wechseln, dann erscheint, gegebenenfalls mit einer kurzen zeitlichen Verzögerung, ein Dialog mit der Aufforderung, das Projekt neu zu laden. Das bestätigen wir natürlich und sind nun soweit, dass wir mit specs und Mockito entwickeln können.
6.1.2
Testfälle einfach gemacht
Wenn wir specs verwenden, um Testfälle zu schreiben, dann müssen wir uns an einer wohldefinierten Struktur ausrichten: Die eigentlichen Tests werden Examples genannt. Mehrere Examples werden zu einem sogenannten System under Specification (SUS) zusammengefasst, das einen gemeinsamen Kontext für die Examples zur Verfügung stellt. Ganz oben steht die sogenannte Specification, die wiederum mehrere SUS zusammenfasst. Das eigentlich interessante hierbei ist die Art und Weise, wie wir SUS und Examples programmieren können. Denn specs ist ein weiteres Beispiel für eine Scala-DSL, die es uns ermöglicht, mit ganz wenig „Glue Code“ aussagekräftige und sich selbst dokumentierende Testfälle zu schreiben.
TrainSpec Wir beginnen zunächst mit einer Klasse, welche die Testfälle für Train enthalten soll. Bei specs lautet die Konvention, dass solche Klassen den Namen der zu testenden Klasse ergänzt um das Wörtchen „Spec“ tragen. Daher legen wir jetzt die Klasse TrainSpec an, jedoch nicht wie bisher im Verzeichnis src/main/scala, sondern unter src/test/scala. package org.scalatrain import org.specs.Specification class TrainSpec extends Specification
Wir erweitern die specs-Basisklasse org.specs.Specification, um die Features an die Hand zu bekommen, die wir benötigen, um unsere SUS und Examples folgendermaßen zu schreiben: Ein SUS beginnt mit einem String, der eine knappe Beschreibung enthält, gefolgt vom Aufruf der Methode should. Natürlich ist should keine Methode von String, aber wie schon angedeutet können über mächtige Sprachmittel wie zum Beispiel Implicit Conversions solche Konstrukte ermöglicht werden. Das Argument für should ist einfach ein Code-Block in geschweiften Klammern. Dieser könnte zunächst gemeinsame Variablen für die Examples definieren, die für jedes Example neu initialisiert würden. Anschließend werden die Examples selbst definiert und zwar so ähnlich wie das SUS selbst: Ein String zur Beschreibung, gefolgt vom Aufruf der Methode in und deren Argument in
64
Unit-Tests mit specs einem Code-Block, der den eigentlichen Test enthält. Am besten veranschaulichen wir uns das anhand eines konkreten Beispiels, nicht jedoch, ohne zuvor in der SBT-Konsole ~ test-compile ausgeführt zu haben, wodurch wir ein stetiges Compilieren des eigentlichen Codes und der Tests erzielen. class TrainSpec extends Specification { "Creating a Train" should { "throw an IllegalArgumentException for a null kind" in { new Train(null, "number") must throwA[IllegalArgumentException] } "throw an IllegalArgumentException for a null number" in { new Train("kind", null) must throwA[IllegalArgumentException] } } }
Was wir hier sehen, ist nicht nur gültiger Scala-Code, sondern sind auch gut lesbare und dokumentierte Tests. Und das ist noch nicht alles! Wenn wir mit SBT dieses Tests ausführen, dann sind die Beschreibungen von SUS und Examples im Testprotokoll enthalten. Um das zu sehen, beenden wir in der SBT-Konsole die Triggered Action test-compile und starten ~ test. Das hätten wir eigentlich auch gleich machen können, denn test hängt natürlich von test-compile ab, so wie test-compile von compile abhängt. Hier ein Auszug aus der Ausgabe in der SBT-Konsole: [info] + Creating a Train should [info] + throw an IllegalArgumentException for a null kind [info] + throw an IllegalArgumentException for a null number
Offenbar gibt SBT die Beschreibungen „schön“ formatiert und – hier nicht zu erkennen – farbig aus. Für erfolgreiche Tests ist das vielleicht gar nicht so interessant; daher wollen wir vorübergehend in der Klasse Train die zweite require-Zeile auskommentieren. Da wir test als Triggered Action ausführen, werden die Tests sofort nach dem Speichern erneut ausgeführt: [error] x Creating a Train should [info] + throw an IllegalArgumentException for a null kind [error] x throw an IllegalArgumentException for a null number [error] java.lang.IllegalArgumentException should have been thrown (TrainSpec.scala:27)
Und schon sehen wir einen informativ aufbereiteten Fehler: Worum geht es? Was hätte passieren sollen? Was ist tatsächlich passiert? Natürlich können wir mit viel Disziplin auch mit JUnit ähnlich weit kommen, aber mit specs ist das viel einfacher. Und für etwas komplexere Testfälle lohnen sich die Beschreibungen für SUS und Examples doppelt, weil sie zusätzlich die Testfälle dokumentieren.
Durchstarten mit Scala
65
6 – Testen von Scala-Programmen Eine Sache haben wir noch gar nicht besprochen. Im Code-Block des Examples haben wir einen sogenannten Matcher verwendet, um auszudrücken, dass wir eine IllegalArgumentException erwarten. specs bietet zahlreiche Matcher, zum Beispiel um auf Gleichheit (mustEqual) oder Ungleichheit zu prüfen, zu prüfen, ob eine Collection leer (must beEmpty) oder von bestimmter Größe ist oder ein bestimmtes Element enthält etc. Einige davon werden wir jetzt kennenlernen, wenn wir uns daran machen die „TODO“-Kommentare für fehlende Precondition Checks zu bearbeiten und überhaupt den bisherigen Code mit Testfällen abzudecken. Dazu legen wir die Klasse TimeSpec an, um die Klasse Time und deren Companion Object zu testen. Dabei halten wir uns an die Reihenfolge in Time.scala, sodass wir also zunächst das Companion Object bzw. dessen einzige Methode fromMinutes unter die Lupe nehmen.
6.1.3
Testdaten einfach gemacht
Ein Blick auf die Methodensignatur macht rasch klar, dass wir zwei grundlegende Fälle untersuchen müssen: Zum einen dürfen wir fromMinutes keine negativen Werte übergeben bzw. soll in diesem Fall eine IllegalArgumentException geworfen werden. Zum anderen müssen wir prüfen, ob für nicht-negative Werte eine korrekt initialisierte Time-Instanz zurückgegeben wird. Für beide Fälle brauchen wir geeignete Testdaten. Natürlich könnten wir uns ein paar – hoffentlich geschickt gewählte – „Stichproben“ überlegen. Aber hier wollen wir einen anderen Weg beschreiten und die Testdaten generieren lassen. Dazu verwenden wir mit ScalaCheck8 ein weiteres Test-Framework, für das es zum Glück eine specs-Integration gibt. Zunächst einmal müssen wir unsere SBT-Projektdefinition um die folgende Dependency erweitern: val scalaCheck = "org.scala-tools.testing" %% "scalacheck" % "1.8" % "test"
Danach führen wir wieder reload, update und idea in der SBT-Konsole aus und lassen IDEA das Projekt neu laden. Nun können wir ScalaCheck verwenden und unsere erste Aufgabe besteht darin, in die TimeSpec-Klasse den Trait org.specs.ScalaCheck hinein zu mixen. Was sind eigentlich Traits? Wir bitten um ein wenig Geduld, denn dazu werden wir im Detail in Kapitel 9 kommen. Für hier uns jetzt stellen wir uns das als eine Art von Mehrfachvererbung vor, sodass wir nun zusätzliche Möglichkeiten haben, unsere Examples zu programmieren. Damit können wir erst einmal die Struktur unserer Test-Spezifikation programmieren: class TimeSpec extends Specification with ScalaCheck { "Calling fromMinutes" should { "throw an IllegalArgumentException for negative minutes" in { // TODO Implement example! 8
66
http://code.google.com/p/scalacheck/
Unit-Tests mit specs } "return a correctly initialized Time instance for minutes within [0, 24 * 60 - 1)" in { // TODO Implement example! }
} }
Um die Examples fertig zu programmieren, müssen wir sogenannte ScalaCheck Properties definieren und diese mit Hilfe eines speziellen specs Matchers ausführen. Zunächst zu den Properties. Dazu verwenden wir die Methode forAll des Singleton Objects org.specs. Prop. Diese erwartet als ersten Parameter einen Generator für die Testdaten. In unserem Fall, wo es um Int-Werte geht, können wir die Methode choose des Singleton Objects org. scalacheck.Gen verwenden, der wir einen Minimal- und Maximalwert geben. Der zweite Parameter für forAll ist eine Funktion, deren Parameterliste zum Generator passen muss, in unserem Fall also in Int-Parameter. Weiter muss diese Funktion einen Boolean-Wert zurückgeben, welcher den für Erfolg oder Misserfolg des Tests steht. Nachdem wir die Properties definiert haben, brauchen wir nur noch den Matcher must pass anfügen. Ein bisschen viel auf einmal? Dann hilft bestimmt ein Blick auf den Code: "Calling fromMinutes" should { "throw an IllegalArgumentException for negative minutes" in { forAll(choose(Int.MinValue, -1)) { (minutes: Int) => Time fromMinutes minutes must throwA[IllegalArgumentException] } must pass } "return a correctly initialized Time instance for minutes within [0, 24 * 60 - 1)" in { forAll(choose(0, 24 * 60 - 1)) { (minutes: Int) => val result = Time fromMinutes minutes result.hours mustEqual minutes / 60 result.minutes mustEqual minutes % 60 } must pass } }
Hier haben wir vorausgesetzt, dass wir alle Member oder zumindest die beiden benötigten Methoden von Prop und Gen importiert haben. Wenn wir nun unsere Test-Spezifikation speichern und unsere Tests wieder ausgeführt werden, dann werden wir einen Fehler erhalten, da wir ja noch gar nicht auf Preconditions prüfen: [error] x Calling fromMinutes should [error] x throw an IllegalArgumentException for negative minutes [error] A counter-example is '0': java.lang.IllegalArgumentException should have been thrown (after 0 tries) (TimeSpec.scala:31)
Durchstarten mit Scala
67
6 – Testen von Scala-Programmen Dem kann rasch Abhilfe geschaffen werden, indem wir ein passendes require in Time. fromMinutes einfügen: require(minutes >= 0, "minutes must not be negative!")
Nachdem wir nun das Singleton Object Time getestet haben, steht noch die Time-Klasse aus. Aber bevor wir uns daran machen, wollen wir erst einmal sehen, wie gut wir bisher beim Testen waren, d.h. wir wollen nun die Test Coverage ermitteln.
6.2
Test Coverage mit scct
Eines vorweg: Wir werden das Thema Test Coverage nur anreißen. Zum einen, weil diese interessante Methode nach unserer Erfahrung sogar in vielen etablierten Java-Projekten gar nicht zum Einsatz kommt und daher eine vertiefte Darstellung hier zu weit führen würde. Und zum anderen, weil die für Scala verfügbaren Werkzeuge noch einen gewissen Weg vor sich haben. Wir werden hier scct9 vorstellen, weil es im Gegensatz zu anderen Werkzeugen wie zum Beispiel Undercover10 oder Cobertura11 wirklich gut mit den Besonderheiten von Scala umgehen kann und weil es als SBT-Plugin vorliegt. Allerdings liegt scct aktuell nur als Beta-Version vor und wir haben es noch nicht in einem „echten“ Projekt eingesetzt, sodass dieses Kapitel eher stellvertretend dafür stehen soll, was grundsätzlich möglich ist. scct bietet zumindest derzeit noch keinen Prozessor wie zum Beispiel sbt-idea, sodass wir hier ein sogenanntes Plugin verwenden müssen. „Müssen“ deswegen, weil es ein wenig mehr Aufwand und einen kleinen Eingriff in unsere SBT-Projektdefinition bedeutet. Zunächst erstellen wir das Verzeichnis project/plugins und darin die Datei Plugins.scala mit dem folgenden Inhalt: import sbt._ class Plugins(info: ProjectInfo) extends PluginDefinition(info) { val scctRepo = "scct-repo" at "http://mtkopone.github.com/scct/maven-repo/" val scctPlugin = "reaktor" % "sbt-scct-for-2.8" % "0.1-SNAPSHOT" }
Plugins werden von SBT auf Quellcode-Ebene in das SBT-Projekt „hineingemixt“, wie wir gleich im Vorgriff auf das Kapitel 9 sehen werden, wo wir Traits behandeln. Die obige
9
http://mtkopone.github.com/scct
10 http://code.google.com/p/undercover/ 11 http://cobertura.sourceforge.net/
68
Test Coverage mit scct Plugin-Definition dient nur dazu die benötigten Dependencies zu deklarieren. Nun passen wir unsere SBT-Projektdefinition an, d.h. die Datei ScalaTrainProject.scala: import sbt._ import reaktor.scct.ScctProject class ScalaTrainProject(info: ProjectInfo) extends DefaultProject(info) with ScctProject { ...
Mit dem Schlüsselwort with mixen wir den Trait ScctProject in unser Projekt. Wir kümmern uns hier nur um die Auswirkungen, alles weitere zu Traits folgt in Kapitel 9. Nach dem Ausführen des SBT-Kommandos reload steht uns unter anderem das Kommando testcoverage zur Verfügung, das wir gleich einmal ausprobieren wollen. Als Ergebnis erhalten wir unter target/scala_2.8.1/coverage-report/index.html einen HTML-Report, aus dem Abbildung 1 einen Ausschnitt zeigt. Wie wir sehen können, ist bei Time einiges rot markiert.
Abbildung 6.1: Test Coverage vor der Erweiterung von TimeSpec Das können wir ändern, indem wir TimeSpec um die folgenden SUS ergänzen: "Creating a Time" should { "throw an IllegalArgumentException for negative hours" in { forAll(choose(Int.MinValue, -1)) { (hours: Int) => new Time(hours, 0) must throwA[IllegalArgumentException] } must pass } "throw an IllegalArgumentException for hours >= 24" in { forAll(choose(24, Int.MaxValue)) { (hours: Int) => new Time(hours, 0) must throwA[IllegalArgumentException] } must pass
Durchstarten mit Scala
69
6 – Testen von Scala-Programmen } "throw an IllegalArgumentException for negative minutes" in { forAll(choose(Int.MinValue, -1)) { (minutes: Int) => new Time(0, minutes) must throwA[IllegalArgumentException] } must pass } "throw an IllegalArgumentException for minutes >= 60" in { forAll(choose(60, Int.MaxValue)) { (minutes: Int) => new Time(0, minutes) must throwA[IllegalArgumentException] } must pass } "return an instance with correct defaults" in { val time = new Time time.hours mustEqual 0 time.minutes mustEqual 0 } } "Calling minus or -" should { val time1 = new Time(2, 20) val time2 = new Time(1, 10) "throw an IllegalArgumentException for a null that" in { time1 minus null must throwA[IllegalArgumentException] } "return the correct time difference" in { time1 - time2 mustEqual 70 time2 - time1 mustEqual -70 } } "Calling asMinutes" should { "return the correct value" in { new Time(0, 10).asMinutes mustEqual 10 new Time(1, 10).asMinutes mustEqual 70 } }
Mit dieser Erweiterung der Test-Spezifikation gelangen wir zu einer 100%-igen Test Coverage. Allerdings müssen wir zuerst die noch ausstehenden TODOs bearbeiten und die entsprechenden Precondition Checks in der Time-Klasse implementieren: class Time(val hours: Int = 0, val minutes: Int = 0) { require(hours >= 0, "hours must not be negative!") require(hours < 24, "hours must be less than 24!") require(minutes >= 0, "minutes must not be negative!") require(minutes < 60, "minutes must be less than 60!") ... def minus(that: Time): Int = { require(that != null, "that must not be null!") ...
Damit sieht die Test Coverage nun wie in Abbildung 2 aus.
70
Projekt-Code: aktueller Stand
Abbildung 6.2: Test Coverage nach der Erweiterung von TimeSpec
6.3
Projekt-Code: aktueller Stand
Nach den Arbeiten in diesem Kapitel sieht unser Projekt aktuell folgendermaßen aus, wobei wir nur die Klassen darstellen, die sich verändert haben: package org.scalatrain object Time { def fromMinutes(minutes: Int): Time = { require(minutes >= 0, "minutes must not be negative!") new Time(minutes / 60, minutes % 60) } } case class Time(hours: Int = 0, minutes: Int = 0) { require(hours >= 0, "hours must not be negative!") require(hours < 24, "hours must be less than 24!") require(minutes >= 0, "minutes must not be negative!") require(minutes < 60, "minutes must be less than 60!") lazy val asMinutes = minutes + 60 * hours def -(that: Time): Int = minus(that) def minus(that: Time): Int = { require(that != null, "that must not be null!") this.asMinutes - that.asMinutes } }
Listing 6.1: Time.scala
Durchstarten mit Scala
71
7 7
Erste Schritte mit FP
Laut Runar Oli Bjarnason1 ist funktionale Programmierung das Programmieren mit Funktionen. Diese zwar korrekte, aber für sich alleine nicht besonders hilfreiche Definition aus seinem sehr empfehlenswerten Video „Functional Programming for Beginners“2, bei dem übrigens Scala zum Einsatz kommt, führt uns zur Frage, was Funktionen sind. Die „reinen“ Funktionen der funktionalen Programmierung haben große Ähnlichkeit zu mathematischen Funktionen und sind auf keinen Fall mit Unterprogrammen zu verwechseln. Im Prinzip sind Funktionen ein recht einfaches Konstrukt: Sie werden auf Argumente angewendet und liefern ein Ergebnis zurück. Sonst tun sie nichts, insbesondere ändern sie keinen Zustand wie zum Beispiel globale Variablen, was auch mit „frei von Seiteneffekten“ bezeichnet wird. Dadurch führen wiederholte Funktionsaufrufe, die mit den gleichen Argumenten ausgeführt werden, stets zum gleichen Ergebnis. So betrachtet können wir auch mit Java funktional programmieren, denn Methoden sind per Definition Funktionen, solange sie ein Ergebnis haben und keine Seiteneffekte ausüben. Allerdings fehlt bei unserer bisherigen Definition noch ein sehr wichtiges Feature, das funktionale Programmiersprachen üblicherweise anbieten: Funktionen höherer Ordnung. Das bedeutet, dass Funktionen anderen Funktionen als Argument übergeben werden können. Anders ausgedrückt sind Funktionen vollwertige Typen wie zum Beispiel numerische Typen oder Strings. Spätestens hier enden die Möglichkeiten von Java, weil Methoden bekanntlich keine „echten“ Objekte sind, die an andere Methoden übergeben werden können. Scala hingegen unterstützt die funktionale Programmierung mit all ihren zentralen Features, insbesondere mit Funktionen höherer Ordnung. Allerdings wissen wir ja inzwischen schon, dass Scala durchaus veränderlichen Zustand erlaubt, und zwar in Form von vars. Daher ist Scala keine „reine“ funktionale Sprache, sondern eine objekt-funktionale oder post-funktionale3. Was das in der Praxis bedeutet, das wollen wir in diesem Kapitel kennenlernen. Dabei werden wir zunächst auf die Scala Collection Library eingehen, weil Collections auf einfache Weise höchst anschauliche Einblicke in die funktionale Programmierung ermöglichen. Anschließend werden wir viel anhand von Beispielen experimentieren und ebenso unser Fallbeispiel um wichtige Funktionen erweitern.
1
http://apocalisp.wordpress.com/author/apocalisp/
2
http://vimeo.com/18554216
3
http://www.scala-lang.org/node/4960
Durchstarten mit Scala
73
7 – Erste Schritte mit FP
7.1
Scala-Collections
Die überaus umfangreiche und sehr mächtige Scala Collection Library wäre an sich schon fast ein eigenes Buch wert. Zum Glück gibt es eine recht umfangreiche Online-Dokumentation4, die wir sehr empfehlen können. Daher werden wir hier nur einen groben Überblick geben, um genügend zu wissen, damit wir unser Fallbeispiel bearbeiten können und – wie schon gesagt – um die funktionale Programmierung anhand anschaulicher Beispiele kennenzulernen.
7.1.1
Klassenhierarchie
Im Vorgriff auf Kapitel 9, wo wir Vererbung behandeln werden, stellen wir zunächst die Vererbungshierarchie der Scala-Collections vor. Abbildung 1 zeigt einen Auszug besonders wichtiger Vertreter, wobei kursiv gedruckte Namen für abstrakte Vertreter stehen und in normalem Schriftgrad gedruckte für konkrete. Alle Collections erben von Traversable, wo schon zahlreiche konkrete Methoden definiert werden, die letztendlich alle auf die einzige abstrakte Methode foreach zugreifen. Iterable ergänzt die Möglichkeit, einen Iterator zu beziehen, also seitens des Nutzers aktiv über die Elemente zu iterieren. Unterhalb davon stehen die drei klassischen Arten von Collections: Seq(ence), Set und Map. Diese erweitern Iterable um jeweils spezifische Features und überschreiben manche allgemeinen Methoden auf optimierte Art und Weise.
Abbildung 7.1: Wichtige Scala-Collections
4
74
http://www.scala-lang.org/docu/files/collections-api/collections.html
Scala-Collections Seqs haben eine wohldefinierte Reihenfolge, Sets enthalten keine zwei gleichen Elemente und Maps bilden Schlüssel auf Werte ab. Für alle drei Arten gibt es Spezialisierungen, aber wir erwähnen hier nur exemplarisch spezielle Seq-Vertreter. Diese unterscheiden sich in lineare, d.h. verkettete, die darauf optimiert sind, „vom Kopf her“ manipuliert zu werden, und indizierte, d.h. solche mit wahlfreiem Zugriff. Für jede Art ist eine konkrete Implementierung dargestellt, und zwar List als das Paradebeispiel einer linearen Seq und Vector für Seqs mit wahlfreien Zugriff.
7.1.2
Collection-Instanzen erzeugen
Wenn wir eine Collection-Instanz erzeugen wollen, dann können wir das auf die denkbar einfachste Art und Weise bewerkstelligen. Wir schreiben den Namen der Collection, also zum Beispiel List, und in runden Klammern und mit Komma getrennt die Elemente, welche die Collection enthalten soll. Dabei spielt es auch keine Rolle, ob wir eine abstrakte Seq oder eine konkrete List erzeugen wollen: scala> Seq(1, 2, 3) res0: Seq[Int] = List(1, 2, 3) scala> IndexedSeq(1, 2, 3) res1: IndexedSeq[Int] = Vector(1, 2, 3) scala> List("a", "b", "c") res2: List[java.lang.String] = List(a, b, c)
Wie funktioniert das? Wieso können wir hinter einen Namen wie zum Beispiel Seq eine Liste von Argumenten schreiben, also quasi einen Aufruf machen. So etwas kennen wir bisher nur von Methoden! Da Scala eine „schlanke“ Sprache ist, müssen wir gar nichts Neues lernen. Denn letztendlich führt Seq(1, 2, 3) zu einem ganz normaler Methodenaufruf. Der Scala-Compiler macht nämlich daraus folgendes: Seq.apply(1, 2, 3). Zum Beweis schauen wir uns das in der REPL an: scala> Seq(1, 2, 3) res0: Seq[Int] = List(1, 2, 3) scala> Seq.apply(1, 2, 3) res1: Seq[Int] = List(1, 2, 3)
Das sieht vom Ergebnis her identisch aus und ist in der Tat auch dasselbe. Mit anderen Worten ist die erste Variante nur syntaktischer Zucker für die zweite. Das funktioniert übrigens überall: Sobald wir ein Objekt behandeln, als sei es eine Methode, d.h. wir schreiben hinter dem Objekt eine Liste von Argumenten, dann übersetzt der Scala-Compiler dies in einen Aufruf der apply-Methode. Zur Erinnerung sei an die Case Classes verwiesen, bei denen die apply-Methode des Companion Object zur Erzeugung einer Instanz dient. So weit, so gut, aber eine Frage bleibt noch offen. Was ist denn dieses Seq? Offenbar müssen wir es direkt ansprechen können und es muss eine apply-Methode haben. Um es direkt
Durchstarten mit Scala
75
7 – Erste Schritte mit FP ansprechen zu können, muss es ein Singleton Object sein. Ein Blick in die ScalaDoc-Dokumentation der Scala-Standardbibliothek zeigt uns, dass es tatsächlich ein solches gibt und dass dieses eine apply-Methode hat: def apply[A](elems: A*): Seq[A]
Hier sehen wir zwar eine Menge an Dingen, die wir noch gar nicht besprochen haben, zum Beispiel Typ-Parameter und Repeated Parameters. Aber wenn wir uns davon nicht verwirren lassen, dann erkennen wir, dass diese Methode offenbar Argumente eines bestimmten Typs erwartet und dann eine Seq zurückgibt. Und genau das passiert ja, wenn wir Seq(1, 2, 3) ausführen: Wir erhalten eine Seq-Instanz.
7.1.3
Typ-Parameter
Alle Collections in Scala sind parametrisiert, d.h. es gibt keine Raw Types wie in Java, sondern sozusagen nur Generics. Die Notation ist zunächst ähnlich wie in Java, nur dass der oder die Typ-Parameter in eckigen Klammern geschrieben werden, statt in spitzen. Das bedeutet, dass Traversable, Iterable, Seq etc. gar keine „richtigen“ Typen sind, sondern sogenannte Typ-Konstruktoren, denn erst durch die Anwendung eines Typ-Arguments wird daraus ein Typ. Wie wir oben gesehen haben, müssen wir beim Erzeugen von Collections das Typ-Argument gar nicht explizit angeben, denn der Scala-Compiler kann diesen inferieren. Das funktioniert nicht nur, wenn alle Elemente denselben Typ haben, sondern auch mit unterschiedlichen. Dann wird einfach der spezifischste Super-Typ verwendet: scala> List(1, "a") res0: List[Any] = List(1, a)
Der Typ Any stellt hierbei die Wurzel der Scala-Typhierarchie dar, doch dazu mehr in Kapitel 8. Wenn wir möchten, dann können wir den Typ-Parameter auch explizit angeben, indem wir das Typ-Argument in eckigen Klammern hinter den Typ-Konstruktor schreiben. Das ist insbesondere dann erforderlich, wenn wir eine leere Collection anlegen und der Scala-Compiler keine sonstigen Informationen über den Typ-Parameter hat. Die zweite und dritte Liste im folgenden Beispiel sind leer. Dabei legen wir bei der zweiten den Typ explizit fest, wohingegen der Compiler bei der dritten den Typ Nothing inferiert, der ein Sub-Typ aller anderen Typen ist, aber auch dazu noch mehr in Kapitel 8. scala> List[Int](1, 2, 3) res0: List[Int] = List(1, 2, 3) scala> List[Int]() res1: List[Int] = List() scala> List() res2: List[Nothing] = List()
76
Scala-Collections Nicht nur Collections sind parametrisiert, sondern auch viele andere Vertreter aus der Scala-Standardbibliothek. Wir können natürlich auch eigene parametrisierte Typen schreiben und auch parametrisierte Methoden, doch dazu mehr in Kapitel 12.
7.1.4
Tupel
Nun betrachten wir mit den Tupeln ganz spezielle Vertreter aus der Scala-Standardbibliothek. Diese sind gar keine Collections, ähneln diesen jedoch zu einem gewissen Grad. Darüber hinaus benötigen wir sie unter anderem auch für die Initialisierung von Maps. Tupel fassen eine feste Anzahl von Werten bzw. Objekten zusammen, wobei die jeweiligen Werte zwar wohldefinierte, aber unterschiedliche Typen haben dürfen. Darüber hinaus gibt es noch eine vereinfachte Schreibweise, um Tupel anzulegen und deren Typ zu bezeichnen, also quasi wiederum syntaktischen Zucker. Ein Beispiel: scala> val pair = (1, "a") pair: (Int, java.lang.String) = (1,a) scala> Tuple2(1, "a") res1: (Int, java.lang.String) = (1,a)
In der oberen Zeile schreiben wir in Klammern und mit Komma getrennt die einzelnen Werte, aus denen unser Tupel, in diesem Fall ein Tuple2, bestehen soll. Die Antwort der REPL enthält eine Typangabe, die ganz ähnlich aussieht wie die Initialisierung. Diese enthält nämlich in Klammern und mit Komma getrennt die einzelnen Typen. Anstelle dieser Notation können wir auch, ähnlich wie bei den Collections, ein „Tupel Singleton Object“ schreiben, gefolgt von der Argumente-Liste. Dies sehen wir am Beispiel von Tuple2 in der unteren Zeile. Als Typangabe könnte die REPL hier auch Tuple2[Int, String] ausgeben, was gleichbedeutend zum (Int, String) ist, aber zum Glück verwendet die REPL die intuitivere Klammer-Schreibweise. Wozu dienen eigentlich Tupel, die es in der Scala-Standardbibliothek von Tuple2 bis Tuple22 gibt? Zum Beispiel kann eine Methode damit mehrere Werte zurückgeben. Dafür verwendet man zwar oft eine extra Klasse, aber manchmal ist ein Tupel durchaus angebracht. Wir werden das gleich anhand unseres Fallbeispiels praktisch zeigen. Um auf die einzelnen Werte eines Tupels zuzugreifen, verwenden wir wie bei Feldern oder Methoden einen Punkt „.“ gefolgt von einem Unterstrich „_“ und dem bei Eins (!) beginnenden Index des Wertes innerhalb des Tupels. Das sieht in unserem Beispiel so aus: scala> pair._1 res2: Int = 1 scala> pair._2 res3: java.lang.String = a
Durchstarten mit Scala
77
7 – Erste Schritte mit FP Eine andere wichtige Bedeutung kommt Tupeln bei den Maps zu, denn diese sind ja Abbildungen von Schlüsseln auf Werte oder mit anderen Worten Collections von Tuple2s. Daher können wir Maps folgendermaßen anlegen: scala> Map((1, "a"), (2, "b")) res0: scala...Map[Int,java.lang.String] = Map((1,a), (2,b))
Tuple2s können wir auch noch einfacher schreiben. Dazu kommen wieder einmal die mächtigen Implicit Conversions ins Spiel, auf die wir in Kapitel 11 eingehen werden. Hier sei bloß so viel verraten, dass dadurch auf beliebigen Objekten der Operator -> mit einem beliebigen Argument aufgerufen werden kann, der ein Tuple2 erzeugt. Damit ergibt sich für obiges Beispiel die sehr eingängige Schreibweise: scala> Map(1 -> "a", 2 -> "b") res0: scala...Map[Int,java.lang.String] = Map((1,a), (2,b))
7.1.5
Unveränderliche und veränderliche Collections
Die Scala-Collections spalten sich letztendlich in unveränderliche und veränderliche auf, was wir auch anhand der Package-Struktur der Scala-Standardbibliothek erkennen können. Dort gibt es unter anderem die folgenden Packages:
•• scala.collection •• scala.collection.immutable •• scala.collection.mutable Das oberste Package enthält abstrakte Basis-Collections, die in den beiden Sub-Packages entweder unveränderlich oder veränderlich spezialisiert werden. Das führt zu der Frage, was genau (un)veränderlich bedeutet. Die einfache Antwort lautet, dass unveränderliche Collections zwar durchaus Methoden zum Verändern bieten, diese aber eine neue Collection zurückgeben und die alte unverändert lassen. Ein Beispiel: scala> val numbers = Vector(1, 2, 3) numbers: ...Vector[Int] = Vector(1, 2, 3) scala> numbers :+ 4 res0: ...Vector[Int] = Vector(1, 2, 3, 4) scala> numbers res1: ...Vector[Int] = Vector(1, 2, 3)
Hier haben wir den Operator :+ verwendet, der ein Element an eine Seq hinten anfügt. Wie wir sehen, bleibt die ursprüngliche Liste numbers unverändert und wir bekommen eine neue Liste res0 mit dem zusätzlichen Element.
78
Scala-Collections
Einschub: Assoziativität von Operatoren Wie könnten wir an unsere numbers ein Element vorne anfügen? Dafür gibt es den Operator +:, der zum gerade verwendeten spiegelverkehrt geschrieben wird. Wäre es nicht gut verständlich, wenn wir erst das vorne anzufügende Element schreiben könnten und dann den Operator gefolgt von der Seq? Da der Operator +: mit einem Doppelpunkt „:“ endet, können bzw. müssen wir das tun. Denn in Scala, wo grundsätzlich alles linksassoziativ ist, sind Operatoren, die mit einem Doppelpunkt enden, rechtsassoziativ. Damit können wir obiges Beispiel leicht abwandeln: scala> val numbers = Vector(1, 2, 3) numbers: ...Vector[Int] = Vector(1, 2, 3) scala> 0 +: numbers res0: ...Vector[Int] = Vector(0, 1, 2, 3)
Unveränderlich ist der Standard Wir können wichtige Collections nutzen, ohne diese explizit zu importieren. Das haben wir uns die ganze Zeit schon zunutze gemacht, schließlich haben wir bisher nirgendwo in diesem Kapitel einen Import verwendet oder einen voll qualifizierten Namen. Wie funktioniert das? Auch hier gilt, dass Scala „schlank“ bleibt und sich vorhandener Mechanismen bedient. Wir haben bereits über das Singleton Objekt Predef gesprochen, dessen Member vom Compiler automatisch importiert werden. Dort finden wir zwei sogenannte Typ-Aliase für Set und Map, die auf die jeweiligen unveränderlichen Collections verweisen. Da wir im Rahmen dieses Buches selbst keine Typ-Aliase verwenden werden, zeigen wir den entsprechenden Auszug ohne weiteren Kommentar: type Map[A, +B] = collection.immutable.Map[A, B] type Set[A] = collection.immutable.Set[A]
Dann kommt noch ein weitere Mechanismus zur Anwendung, der mit Scala 2.8 eingeführt wurde. Im sogenannten Package Object scala befinden sich weitere Typ-Aliase. Wir werden in Kapitel 13 auf Package Objects eingeben, sodass wir diese hier einfach nur als Möglichkeit begreifen wollen, unter anderem Typ-Aliase zur Verfügung zu stellen. Da alle Member aus dem scala-Package überall sichtbar sind, können wir die dort definierten Typ-Aliase ohne Import nutzen. Hier ein kleiner Auszug: type Seq[+A] = scala.collection.Seq[A] type List[+A] = scala.collection.immutable.List[A] type Vector[+A] = scala.collection.immutable.Vector[A]
Da Scala uns dazu ermutigen möchte, funktional zu Programmieren, sind diese „frei verfügbaren“ Collections allesamt unveränderlich. Als einzige Ausnahme referenziert Seq das allgemeine Collection-Package, um eine möglichst reibungslose Integration mit Ar-
Durchstarten mit Scala
79
7 – Erste Schritte mit FP rays zu ermöglichen. Aber jedenfalls ist auch das „frei verfügbare“ Seq keine veränderliche Collection. Dennoch werden wir in den meisten Fällen stets explizit Seq aus dem Package scala.collection.immutable importieren.
7.1.6
Collections in ScalaTrain
Nachdem wir jetzt die wichtigsten Grundlagen über Scala-Collections kennen, wollen wir an unserem Fallbeispiel weiter arbeiten. Als nächsten Schritt werden wir unsere Züge um einen Fahrplan erweitern. Dieser soll zunächst einfach eine Collection von Bahnhöfen sein, um die Abfahrtszeiten kümmern wir uns in Kapitel 7.2.2. Also brauchen wir als erstes die Klasse Station, die wir wegen der engen Verbindung zu Train und der geringen Größe der beiden Klassen in dieselbe Quelldatei Train.scala geben. Wir verwenden wieder eine Case Class und geben einer Station den Parameter name, der nicht null sein darf. Somit haben wir: case class Station(name: String) { require(name != null, "name must not be null!") }
In der Quelldatei TrainSpec.scala schreiben wir die dazu gehörige Test-Spezifikation für Station: class StationSpec extends Specification { "Creating a Station" should { "throw an IllegalArgumentException for a null name" in { Station(null) must throwA[IllegalArgumentException] ...
Da Züge im Rahmen eines Fahrplans die Bahnhöfe in wohldefinierter Reihenfolge anfahren, ergänzen wir die Train-Klasse um den Parameter schedule vom Typ Seq[Station]. Selbstverständlich darf auch dieser Wert nicht null sein. Weiterhin macht ein Fahrplan nur dann Sinn, wenn er aus mindestens zwei Bahnhöfen besteht. Daher müssen wir die Größe von schedule ermitteln, was wir mit der Methode size bewerkstelligen können, die es für jede Collection gibt. case class Train(kind: String, number: String, schedule: Seq[Station]) { require(kind != null, "kind must not be null!") require(number != null, "number must not be null!") require(schedule != null, "schedule must not be null!") require(schedule.size >= 2, "schedule must have at least two stops!") }
Nun müssen wir natürlich TrainSpec an die geänderte Signatur des Konstruktors anpassen und die neuen Preconditions überprüfen, was wir hier jedoch nicht zeigen. Vielmehr wollen wir nun in die Welt der funktionalen Programmierung eintauchen.
80
Funktionale Collections
7.2
Funktionale Collections
Um zu verstehen, was Funktionen sind, eignen sich die Scala-Collections besonders gut, weil sie eine Vielzahl an Funktionen höherer Ordnung definieren. Das bedeutet, dass wir Funktionen als Argumente übergeben müssen, wenn wir diese aufrufen wollen. Als erstes Beispiel wollen wir die Methode map betrachten, mit der wir eine Collection elementweise in eine neue Collection transformieren können: scala> val numbers = List(1, 2, 3) numbers: List[Int] = List(1, 2, 3) scala> numbers map { x => x + 1 } res0: List[Int] = List(2, 3, 4)
Was sehen wir hier? Zunächst legen wir – wie schon zuvor – eine List[Int] an. Auf dieser rufen wir dann die map-Methode auf, und zwar in Operator-Notation, d.h. ohne Punkt. Das Argument des Aufrufs schreiben wir in geschweiften Klammern, weil es sich nicht um ein „normales“ Argument handelt, sondern um ein sogenanntes Funktionsliteral. Wir dürfen in Scala grundsätzlich geschweifte Klammern beim Methodenaufruf verwenden, wenn die Methode nur ein Argument erwartet. Selbstverständlich könnten wir auch runde Klammern verwenden und wir könnten ebenso anstelle der Operator-Notation den Methodenaufruf mit einem Punkt „.“ einleiten. Aber die übliche Konvention lautet, dass wir das für Funktionstypen so tun, wie oben gezeigt. Bevor wir uns im Detail dem Funktionsliteral zuwenden, werfen wir einen Blick auf das Ergebnis. Daraus wird rasch klar, was map bewirkt: Die übergebene Funktion wird offensichtlich auf jedes Element angewendet und aus den resultierenden Elementen wird eine neue Collection erzeugt. Getreu dem Paradigma der Unveränderlichkeit passiert mit der ursprüngliche Collection nichts, sie bleibt unverändert. Das bedeutet also, dass eine Collection in eine neue transformiert wird.
7.2.1
Funktionsliterale
Mit Literalen können wir bekanntlich Werte für bestimmte Typen „hinschreiben“. So ist zum Beispiel „Hello World“ ein String-Literal. Da in der objektorientierten Programmierung Werte Objekte sind, erzeugen wir mit Literalen sozusagen vereinfacht Objekte. Das gilt auch für Funktionsliterale: Sie dienen dazu, einen sogenannten Function Value „hinzuschreiben“, der nichts anderes darstellt, als eine Instanz eines bestimmten Funktionstyps. Ja, Funktionen sind auch Objekte, wie wir gleich in Kapitel 7.2.2 sehen werden. Die Syntax für Funktionsliterale ähnelt der von Methoden: Es gibt eine Parameterliste und einen Körper, welcher der Implementierung einer Methode entspricht. Dazwischen steht anstelle eines Gleichheitszeichens ein Pfeil „=>“, der die Parameterliste vom Körper trennt. Wenn der Compiler die Typen der Parameter nicht inferieren kann, dann müssen
Durchstarten mit Scala
81
7 – Erste Schritte mit FP wir diese, wie auch bei den Methoden, angeben. Der Körper kann ein einfacher Einzeiler sein oder ein kompletter Block, dessen letzter Ausdruck den Rückgabewert und -typ bestimmt. Am besten schauen wir uns das anhand einiger Beispiele an: scala> numbers map { x => x + 1 } res0: List[Int] = List(2, 3, 4) scala> numbers map { (x: Int) => x + 1 } res1: List[Int] = List(2, 3, 4)
Die erste Schreibweise ist sehr kompakt und verzichtet auf die Typangabe der Parameter, wohingegen die zweite explizit diese Typangaben enthält. Das ist hier nicht nötig, denn der Scala-Compiler weiß durch die Signatur der map-Methode, dass die übergebene Funktion einen Parameter vom gleichen Typ haben muss, mit dem die Collection parametrisiert ist. In unserem Fall also handelt es sich also um einen Parameter vom Typ Int. Als nächstes betrachten wir mit filter eine weitere Collection-Methode, deren Sinn und Zweck sich schon aus dem Namen ableitet und spätestens durch die Beispiele klar werden sollte. Die filter-Methode erwartet eine Funktion, die für jedes Element der Collection aufgerufen wird und durch den Rückgabewert vom Typ Boolean steuert, ob das jeweilige Element in die neue Collection übernommen wird oder nicht: scala> numbers filter { x => x > 2 } res0: List[Int] = List(3) scala> numbers filter { _ > 2 } res1: List[Int] = List(3)
Wie bei map kann der Scala-Compiler auch bei filter den Typ des Parameters inferieren, sodass wir die kompakte Schreibweise verwenden können. Wie wir sehen, geht es dann sogar noch kompakter: Wir können sogar die komplette Parameterliste und den Pfeil weglassen und nur den Körper des Funktionsliterals schreiben. Dabei ersetzen wir den Parameter durch den Unterstrich „_“, der quasi einen Platzhalter für einen Parameter darstellt. Diese Kurzschreibweise ist anfangs vielleicht etwas gewöhnungsbedürftig, fördert aber in bestimmten Situationen durchaus die Lesbarkeit. Letztendlich ist es persönliche Geschmackssache, welche Notation verwendet wird; wir werden im Folgenden jedenfalls oft diese Kurzschreibeweise verwenden. Um das Bild zu komplettieren, zeigen wir abschließend mit sortWith noch eine Methode, die eine Funktion mit zwei Parametern erwartet. Die übergebene Funktion wird im Beispiel mit je zwei Elementen einer Seq aufgerufen und zeigt anhand des Rückgabewertes vom Typ Boolean, ob das erste Element vor dem zweiten einsortiert werden soll oder umgekehrt: scala> numbers sortWith { res0: List[Int] = List(3, scala> numbers sortWith { res1: List[Int] = List(3,
82
(x, y) => x > y } 2, 1) _ > _ } 2, 1)
Funktionale Collections
7.2.2
Funktions-Typen
Wie schon angedeutet, gilt „Alles ist ein Objekt“ auch für Funktionen. Daher gibt es in der Scala-Standardbibliothek natürlich auch Typen für Funktionen. Hierbei handelt es sich um Traits, die wir erst im Kapitel 8 behandeln werden. Aber keine Angst, wir steigen hier gar nicht allzu tief ein, sondern zeigen gleich einmal ein Beispiel. Wenn Funktionen bzw. Function Values Objekte sind, dann müssten wir diese Variablen zuweisen können: scala> val f = x => x + 1 :5: error: missing parameter type val f = x => x + 1
Leider schlägt unser erster naiver Versuch, bei dem wir exakt das Funktionsliteral aus obigem map-Beispiel verwenden, fehl. Wenn wir ein wenig nachdenken, dann ist die Fehlermeldung des Scala-Compilers nicht weiter verwunderlich. Schließlich haben wir das Funktionsliteral im map-Beispiel in einem gewissen Kontext verwendet: Der ScalaCompiler konnte den Typ des Parameters als Int inferieren, weil wir map auf einer List[Int] aufgerufen haben. Nun fehlt uns dieser Kontext jedoch, sodass der Scala-Compiler nicht wissen kann, dass der x-Parameter vom Typ Int sein soll. Es könnte ja auch ein String sein oder ein anderer Typ, auf dem wir den Operator + aufrufen können. Also müssen wir den Typ angeben: scala> val f = (x: Int) => x + 1 f: (Int) => Int =
Und schon haben wir unsere Funktion der Variable f zugewiesen, was beweist, dass Funktionen Objekte sind. Welchen Typ hat diese Funktion eigentlich? Die REPL sagt (Int) => Int, aber das ist, ähnlich wie bei den Tupeln, nur eine intuitivere Schreibweise für Function1[Int, Int]. Ein Blick in die ScalaDoc-Dokumentation zeigt, dass es diesen Typ tatsächlich gibt. Und wir können das auch in der REPL beweisen, indem wir, statt auf TypInferenz zu bauen, diesen Typ explizit angeben: scala> val f: Function1[Int, Int] = x => x + 1 f: (Int) => Int =
In diesem Fall können wir die Typ-Angabe für den x-Parameter wieder weglassen, weil wir ja explizit vorgeben, dass der Parameter vom Typ Int sein soll. Was hat es eigentlich mit der „1“ bei Function1 auf sich? Ganz einfach, dieser Wert steht für die Anzahl der Parameter der Funktion. Demensprechend gibt es auch Function0, Function2 etc. Wieder Ähnlich wie bei den Tupeln geht das bis Function22. Natürlich handelt es sich dabei wie bei Collections um parametrisierte Typen, also um Typ-Konstruktoren. Zum Beispiel bedeutet Function1[A, B], oder in Kurzschreibweise A => B, eine Funktion, die einen Parameter vom Typ A hat und ein Ergebnis vom Typ B zurückgibt. Und ebenso
Durchstarten mit Scala
83
7 – Erste Schritte mit FP wie bei Collections bzw. deren Companion Objects besitzen Funktionen eine apply-Methode, weswegen wir sie aufrufen können, was ja schließlich ihr Sinn und Zweck ist: scala> f(1) res0: Int = 2 scala> f.apply(1) res1: Int = 2
Offenbar haben Funktionen einiges mit Methoden gemeinsam, aber es gibt dennoch einen großen Unterschied: Methoden sind wie schon gesagt keine Objekte, sondern bloß ein Bestandteil von solchen. Funktionen hingegen sind vollwertige Objekte, weshalb sie auch als Argument an Methode oder andere Funktionen übergeben werden können. Dennoch ist es in Scala üblich, „Funktionalität“ in Methoden zu packen und nicht, wie oben, eine Funktion einem val zuzuweisen. Es ist nämlich ganz einfach möglich, aus einer Methode eine Funktion zu machen. Im Vorgriff auf Kapitel 11 zeigen wir, wie wir aus einer Methode eine Partially Applied Function machen können: scala> def addOne(x: Int) = x + 1 addOne: (x: Int)Int scala> val f = addOne _ f: (Int) => Int = scala> f(2) res0: Int = 3
Durch Hintenanstellen des Unterstrichs „_“ machen wir aus der addOne-Methode eine Funktion. Wir sehen das anhand der Antwort der REPL, die als Typ Int => Int ausgibt. Und wir sehen das daran, dass wir die so erzeugte Funktion aufrufen können. Wenn wir so eine zur Funktion gemacht Methode verwenden wollen, müssen wir natürlich nicht den Umweg über ein Variable gehen: scala> numbers map addOne _ res0: List[Int] = List(2, 3, 4) scala> numbers map addOne res1: List[Int] = List(2, 3, 4)
Wir können die mit addOne _ erzeugte Funktion natürlich direkt als Argument für die map-Methode verwenden. In diesem Fall können wir sogar den Unterstrich weglassen, weil der Scala-Compiler aufgrund der Signatur der map-Methode weiß, dass das Argument eine Function1 sein muss, sodass er die Methode automatisch zu einer Funktion transformiert. Das sieht zwar so aus, als könnten wir die Methode als Parameter übergeben, aber in Wirklichkeit ist das wieder nur eine Nettigkeit des Scala-Compilers. Für uns Nutzer verschwimmt dadurch die Grenze zwischen Methode und Funktion noch ein wenig mehr, was wir persönlich aber als Vorteil betrachten: Das ist idiomatischer und pragmatischer Scala-Code!
84
Funktionale Collections
7.2.3
Funktionale Collections in ScalaTrain
Mit unserem frisch erworbenen Wissen über die funktionale Seite der Scala-Collections wollen wir uns nun an verschiedene Aufgaben machen, die zum Ziel haben, unser Fallbeispiel ein wenig zu erweitern. Dabei werden wir unter anderem die besonders wichtigen Collection-Methoden map, flatMap und filter kennenlernen bzw. vertiefen, die wir im anschließenden Kapitel dann noch genauer betrachten werden.
Fahrplan um Abfahrtzeiten ergänzen Bisher besteht der Fahrplan eines Zuges nur aus Bahnhöfen. Nun wollen wir die Abfahrtszeiten ergänzen. Daher machen wir aus dem Klassenparameter schedule, bisher vom Typ Seq[Station], nun eine Seq[(Time, Station)]. Das bedeutet, dass die Elemente der Collection Tuple2s sind, die unsere schon beinahe vergessene Time-Klasse enthalten. Warum verwenden wir eigentlich nicht gleich eine Map[Time, Station], die wir ja auch mit Tuple2s erzeugen können? Ganz einfach: Eine Map hat im Gegensatz zu einer Seq keine wohldefinierte Reihenfolge, aber die brauchen wir natürlich für den Fahrplan nach wie vor. case class Train(... schedule: Seq[(Time, Station)]) ...
Wieder müssen wir TrainSpec anpassen und dabei müssten wir eigentlich nicht nur prüfen, ob schedule aus mindestens zwei Elementen besteht, sondern wir müssten auch sicherstellen, dass die Abfahrtzeiten monoton steigen und dass kein Bahnhof mehrfach angefahren wird. Aber das heben wir uns für später auf, weil wir dafür etwas mächtigere Methoden benötigen.
Bahnhöfe eines Zuges mit map ermitteln Nachdem wir die nötigen Änderungen in TrainSpec nachgezogen haben, was wir hier nicht zeigen, wollen wir nun alle Bahnhöfe ermitteln, die ein Zug in seinem Fahrplan enthält, und zwar genau in der Reihenfolge, in der sie angefahren werden. Im Prinzip haben wir das Ergebnis schon vorliegen, nur leider in den Tuple2s des schedule versteckt. Wir müssen also nur den schedule transformieren. Hier hilft eine der wohl wichtigsten Collection-Methoden, map, indem wir eine Funktion übergeben, die jedes Element vom Typ (Time, Station) auf den zweiten Wert vom Typ Station abbilden. Das Ergebnis wollen wir uns gleich als val in der Train-Klasse „merken“. Da wir testgetrieben vorgehen, initialisieren wir stations erst einmal mit einer leeren Seq, wozu wir das Singleton Object Nil verwenden, das eine leere Liste darstellt. val stations: Seq[Station] = Nil // TODO Not yet implemented
Nun ergänzen wir TrainSpec um das entsprechende SUS:
Durchstarten mit Scala
85
7 – Erste Schritte mit FP "Getting stations" should { "return the correct stations in correct sequence" in { val train = Train("kind", "number", List(Time(0, 0) -> Station("0"), Time(1, 1) -> Station("1"))) train.stations mustEqual List(Station("0"), Station("1")) } }
Um diesen Test zu verstehen, müssen wir folgendes wissen: Zwei Scala-Collections sind gleich, wenn sie von der gleichen Art sind, also in unserem Fall beide eine Seq und wenn sie gleiche Elemente enthalten, bei Seqs notwendigerweise in gleicher Reihenfolge. Da Station eine Case Class ist, gibt es eine „vernünftige“ Implementierung der equals-Methode, sodass der Test korrekt formuliert ist. Natürlich schlägt er noch fehl, denn wir sind ja noch die korrekte Implementierung schuldig: val stations: Seq[Station] = schedule map { _._2 }
Das Funktionsliteral, das die Kurznotation mit dem Unterstrich „_“ für den Parameter verwendet, bildet wie gesagt ein Tupel (Time, Station) auf eine Station ab, indem einfach auf den zweiten Wert aus dem Tupel zugegriffen wird. Da der Name stations sprechend ist – der Plural führt zu einer Assoziation mit einer Collection – und der Code zur Initialisierung kurz und nach unserer Auffassung verständlich, könnten wir die Typangabe weglassen. Wir pflegen aber einen Stil, alle öffentlichen Member mit einer Typangabe zu versehen, um die öffentliche Schnittstelle gut zu dokumentieren.
Reise-Planer anlegen Bisher haben wir uns nur mit einzelnen Zügen befasst, aber unser eigentliches Ziel ist ja, wie in Kapitel 3 beschrieben, ein Planer für Bahnreisen. Also legen wir jetzt die neue Klasse JourneyPlanner an, und zwar in einer neuen Quelldatei JourneyPlanner.scala. Um Funktionen wie zum Beispiel die Berechnung von Reisen von einem Start- zu einem ZielBahnhof oder die Ermittlung aller Abfahrten von einem bestimmten Bahnhof zu ermöglichen, muss unser Reise-Planer alle verfügbaren Züge kennen, sodass wir ihn mit einem Set[Train] initialisieren: class JourneyPlanner(trains: Set[Train]) { require(trains != null, "trains must not be null!") }
Warum gerade ein Set? Weil wir sichergehen wollen, dass wir keine Duplikate verwenden, die fachlich betrachtet ja überhaupt keinen Sinn machen. Natürlich wollen wir auch prüfen, dass für der Klassenparameter trains nicht null als Argument übergeben wird. Das
86
Funktionale Collections machen wir natürlich wie üblich mit require und prüfen das, ohne es hier abzudrucken, in der neu anzulegenden Test-Spezifikation JourneyPlannerSpec.
Alle Bahnhöfe mit flatMap ermitteln Jetzt haben wir einen Reise-Planer, der eine Collection von Zügen hat, von denen jeder wiederum eine Collection von Bahnhöfen (und Abfahrtszeiten) hat. Da stellt sich die Frage, wie wir am besten alle Bahnhöfe ermitteln können. Konkret wollen wir zum JourneyPlanner den val stations vom Typ Set[Station] hinzufügen, wobei wir zur Initialisierung erst einmal ein leeres Set verwenden, damit der Code compiliert. Dazu benützen wir die Methode empty, die es für jede Collection gibt. val stations: Set[Station] = Set.empty // TODO Not yet implemented
Natürlich schreiben wir jetzt erst einmal ein SUS für unser neues Feld. Dazu benötigen wir einige Testdaten, die wir als private Felder anlegen, um sie für weitere Tests verwenden zu können: "Calling stations" should { val journeyPlanner = new JourneyPlanner(Set(train1, train2)) "return the correct stations" in { journeyPlanner.stations mustEqual Set(stationA, stationB, stationC, stationD) } } private lazy val train1 = new Train("k1", "n1", schedule1) private lazy val train2 = new Train("k2", "n2", schedule2) private lazy val schedule1 = List( Time(0, 0) -> stationA, Time(1, 1) -> stationB, Time(2, 2) -> stationC) private lazy val schedule2 = List( Time(0, 0) -> stationD, Time(1, 1) -> stationB, Time(2, 2) -> stationA) private lazy val stationA = Station("A") private lazy val stationB = Station("B") private lazy val stationC = Station("C") private lazy val stationD = Station("D")
Nun könnten wir erst einmal ganz naiv versuchen, für die Initialisierung von stations genauso vorzugehen wie zuvor, als wir die Bahnhöfe eines Zuges bestimmt haben, also unter Verwendung der map-Methode:
Durchstarten mit Scala
87
7 – Erste Schritte mit FP val stations: Set[Station] = trains map { _.stations } // Won’t compile!
In diesem Fall ist es nicht nur zu Dokumentationszwecken nützlich, den Typ anzugeben, denn so werden wir gleich vom Scala-Compiler darauf aufmerksam gemacht, dass unser Versuch fehlschlägt: [error] [error] [error]
found : ...Set[...Seq[org.scalatrain.Station]] required: Set[org.scalatrain.Station] val stations: Set[Station] = trains map { _.stations }
Offenbar erhalten wir kein Set[Station], sondern ein Set[Seq[Station]], also verschachtelte Collections. Das ist auch einleuchtend, denn mit map transformieren wir hier jeden Train aus dem ursprünglichen Set[Train] in eine Seq[Station]. Wir müssen also nach einer anderen Lösung suchen, bei der die verschachtelten Collections ausgepackt oder anders ausgedrückt flachgeklopft werden. Und genau das macht die Collection-Methode flatMap, die eine Funktion erwartet, welche ein Element der Collection als Argument entgegennimmt und eine neue Collection zurückgibt, deren Elemente dann direkt in die ErgebnisCollection eingefügt werden. Um den Unterschied zwischen map und flatMap greifbar zu machen, betrachten wir ein einfaches Beispiel: scala> val nestedNumbers = List(List(1, 2), List(3, 4)) nestedNumbers: List[List[Int]] = List(List(1, 2), List(3, 4)) scala> nestedNumbers map { _.reverse } res0: List[List[Int]] = List(List(2, 1), List(4, 3)) scala> nestedNumbers flatMap { _.reverse } res1: List[Int] = List(2, 1, 4, 3)
Hier verwenden wir die Methode reverse, mit der wir die Reihenfolge einer Seq umkehren können. Im Fall von map erhalten wir als Ergebnis zwei umgekehrte Listen, die in einer äußeren „eingepackt“ sind. Im Fall von flatMap hingegen werden die Elemente der umgekehrten Listen direkt in die äußere eingefügt. Genau dieses Verhalten wollen wir nun auf unser Beispiel anwenden. Dazu müssen wir nur map durch flatMap ersetzen: val stations: Set[Station] = trains flatMap { _.stations }
Züge eines bestimmten Bahnhofs ermitteln Als letzte Aufgabe wollen wir noch ermitteln, welche Züge einen bestimmten Bahnhof anfahren. Dazu schreiben wir die Methode trains, die ein Argument vom Typ Station erwartet. Wir haben zwar schon ein Feld trains, aber weil wir die neue Methode mit einer Parameterliste versehen, ist das zulässig. Allerdings könnten wir keine Methode ohne Parameterliste hinzufügen, die denselben Namen wie ein val trägt. Das liegt daran, dass der Scala-Compiler letztendlich aus vals Methoden macht, sodass wir dann zwei gleichna-
88
Funktionale Collections mige Methoden hätten. Das können wir uns ganz einfach mit Java-Bordmitteln ansehen, indem wir den Standard-Decompiler javap einsetzen, der Bestandteil des JDKs ist. Dazu geben wir auf der Kommandozeile folgendes ein: scalatrain$ javap -classpath target/scala_2.8.1/classes org.scalatrain.JourneyPlanner Compiled from "JourneyPlanner.scala" public class org.scalatrain.JourneyPlanner extends java.lang.Object implements scala.ScalaObject{ public scala.collection.immutable.Set stations(); public org.scalatrain.JourneyPlanner(scala.collection.immutable.Set); }
Wir können erkennen, dass der val stations vom Scala-Compiler zur Methode stations() übersetzt wurde. Der Grund hierfür ist das in Kapitel 4 erwähnte Uniform Access Principle, welches den gleichförmigen Zugriff auf gespeicherte und berechnete Werte fordert, so dass es für den Nutzer letztendlich unerheblich ist, ob er ein Feld oder eine Methode verwendet. Doch zurück zu unserem eigentlichen Vorhaben, zur Methode trains mit einem Parameter: def trains(station: Station): Set[Train] = Set.empty // TODO Not yet implemented
Auf Basis unserer oben eingeführten Testdaten schreiben wir nun folgende SUS: "Calling trains" should { val journeyPlanner = new JourneyPlanner(Set(train1, train2)) "throw an IllegalArgumentException for a null station" in { journeyPlanner trains null must throwA[IllegalArgumentException] } "return the correct result" in { journeyPlanner trains stationA mustEqual Set(train1, train2) journeyPlanner trains stationB mustEqual Set(train1, train2) journeyPlanner trains stationC mustEqual Set(train1) journeyPlanner trains stationD mustEqual Set(train2) journeyPlanner trains Station("") mustEqual Set.empty } }
Nun bleibt die eigentliche Arbeit zu tun. Die Precondition prüfen wir wie üblich mit require. Aber wie packen wir die Aufgabe an, die Züge zu ermitteln, die einen bestimmten Bahnhof anfahren? Ein naheliegende Ansatz wäre, einfach alle trains zu nehmen und diejenigen herauszufiltern, die nicht den übergebenen Bahnhof anfahren. Und tatsächlich bieten alle Scala-Collections die Methode filter an, die eine Funktion erwartet, welche über das Ergebnis von true oder false bestimmt, ob einzelne Elemente in die Ergebnis-Collec-
Durchstarten mit Scala
89
7 – Erste Schritte mit FP tion aufgenommen werden oder nicht. Aber wie prüfen wir konkret, ob ein Train drin bleibt oder rausfällt? Dazu können wir uns des zuvor eingeführten Feldes Train.stations bedienen und der Methode contains, die ebenfalls für alle Scala-Collections zur Verfügung steht und genau dann true zurückgibt, wenn das übergebene Element in der Collection enthalten ist: def trains(station: Station): Set[Train] = { require(station != null, "station must not be null!") trains filter { _.stations contains station } }
Das Funktionsliteral, das wir an die filter-Methode übergeben, ist wiederum in der Kurzschreibweise notiert. Dabei steht der Unterstrich „_“ für einen Parameter vom Typ mit dem die Collection parametrisiert ist, hier also ein Train. Auf dem Ergebnis von Train. stations rufen wir contains in Operator-Notation, also ohne Punkt und runde Klammern, mit dem Argument station auf. Davon erhalten wir ein true oder false zurück, sodass der Scala-Compiler zufrieden ist und wir ebenso, weil die Test-Spezifikation erfolgreich durch läuft.
7.2.4
map, flatMap und filter im Detail
Nachdem wir gerade die drei besonders wichtigen Funktionen (eigentlich Methoden) höherer Ordnung map, flatMap und filter in der Praxis kennengelernt haben, wollen wir diese noch einmal genauer unter die Lupe nehmen und dabei ebenfalls die Notation für Funktionsliterale vertiefen.
map Der folgende Quellcode zeigt – etwas vereinfacht dargestellt – die Signatur von map anhand des Beispiels Traversable: trait Traversable[A] { def map[B](f: A => B): Traversable[B] ...
Dabei steht A für den Typ, mit dem Traversable parametrisiert ist und B ist ein Typ-Parameter der map-Methode. Das bedeutet, dass wir mit map den Typ der Collection von Traversable[A] nach Traversable[B] ändern können. So könnten wir zum Beispiel aus einer List[String] eine List[Int] machen, indem wir eine Funktion übergeben, die einen String erwartet und dessen Länge zurückgibt: scala> val languages = List("Scala", "JRuby", "Java") languages: List[java.lang.String] = List(Scala, JRuby, Java) scala> languages map { _.size } res0: List[Int] = List(5, 5, 4)
90
Funktionale Collections Selbstverständlich können A und B auch identisch sein, wie wir das im einleitenden Beispiel von Kapitel 7.2 schon gesehen haben, wo wir eine List[Int] zu einer weiteren List[Int] transformiert haben: scala> numbers map { _ + 1 } res0: List[Int] = List(2, 3, 4)
Wenn wir ganz genau sein wollen, dann haben wir oben bei der Signatur geschummelt. Denn es ist zwar meist so, dass sich die Collection nicht ändert, also zum Beispiel aus einer List wieder eine List wird. Aber in manchen Fällen geht das nicht, sodass dann die am besten passende Alternative gewählt wird. Als Beispiel betrachten wir ein BitSet, das nur Int-Werte enthalten kann: scala> BitSet(1, 2, 3) map { "#" + _ } res0: ...Set[java.lang.String] = Set(#1, #2, #3)
Wie wir sehen, ergibt die map-Methode hier ein allgemeines Set, welches Strings enthalten darf. Allerdings wird uns dieses Verhalten in der Praxis eher selten begegnen bzw. nur dann, wenn wir mit solchen Collections arbeiten, deren mögliche Typ-Parameter eingeschränkt sind. Nun wollen wir unseren Blick auf die Funktion richten, die wir map übergeben müssen. Der Typ lautet A => B, was nichts anderes bedeutet, als dass die erwartete Funktion ein Argument vom Typ A entgegennimmt und ein Resultat vom Typ B zurückgibt. Aufgrund der Signatur von map ist A auf den Typ festgelegt, mit dem die Collection parametrisiert ist. Das muss ja auch so sein, denn die Funktion soll ja Elemente der Collection entgegennehmen. Allerdings ist der Rückgabetyp „frei“ und bestimmt, wie die Collection parametrisiert ist, die map zurückgibt. Wir haben bereits gesehen, dass wir Funktionsliterale auf verschiedene Arten schreiben können. Das wollen wir hier noch einmal anhand der Initialisierung von Train.schedule illustrieren: schedule map { _._2 } // (1) schedule map { ts => ts._2 } // (2) schedule map { (ts: (Time, Station)) => ts._2 } // (3)
Die dritte Variante zeigt ein Funktionsliteral mit einer vollständigen Parameterliste: In runden Klammern schreiben wir nicht nur die Parameter-Namen, sondern auch deren Typen. Hier haben wir nur einen Parameter, aber wenn es mehrere wären, dann würden wir diese mit Komma voneinander trennen. Bei der zweiten Variante verzichten wir auf die Angabe des Parameter-Typs. Das ist dank Type Inference möglich, weil das Argument ohnehin, wie oben ausgeführt, auf den Typ festgelegt ist, mit dem die Collection parametrisiert ist. Da wir nur einen Parameter haben, können wir sogar die runden Klammern weglassen. Ansonsten entspricht diese Variante der ersten, weil wir eben eine Parameter-
Durchstarten mit Scala
91
7 – Erste Schritte mit FP liste haben, die durch einen Pfeil vom Köper der Funktion getrennt ist. Hier unterscheidet sich die erste Variante, bei der die Kurzschreibweise für Funktionsliterale zum Einsatz kommt. Dabei entfallen die Parameterliste und der Pfeil und anstelle des benannten Parameters steht der Unterstrich „_“. Selbstverständlich ist auch diese Schreibwese statisch typisiert, sodass der Unterstrich für ein Element der Collection steht. Das funktioniert übrigens auch für Funktionen mit mehreren Argumenten; dann steht der jeweils nächste Unterstrich für den entsprechend nächsten Parameter. So können wir natürlich nur einmal auf jeden Parameter zugreifen.
flatMap Nun betrachten wir – wiederum etwas vereinfacht am Beispiel von Traversable dargestellt – die Signatur von flatMap: trait Traversable[A] { def flatMap[B](f: A => Traversable[B]): Traversable[B] ...
Der einzige Unterschied zur Signatur von map ist der Rückgabetyp der Funktion, die wir der flatMap-Methode übergeben. Statt A => B benötigen wir hier A => Traversable[B]. Das bedeutet, dass jedes Element der Collection zunächst von der Funktion auf eine neue Collection abgebildet wird, die einen anderen Typ haben kann, der durch den Typ-Parameter B bestimmt wird. Danach werden von flatMap alle Elemente dieser neuen Collection einzeln in die Ergebnis-Collection eingefügt, die demensprechend mit B parametrisiert ist. In unserem Fallbeispiel wenden wir flatMap an, um sämtliche Bahnhöfe aller Züge zu ermitteln. Unsere Ausgangs-Collection trains ist ein Set[Train] und die Funktion muss dementsprechend einen Train entgegennehmen. Wir geben dann Train.stations zurück, also eine Seq[Station], sodass die Ergebnis-Collection ein Set[Station] ist. Das schauen wir uns noch einmal in den unterschiedlichen Varianten der Schreibweise für Funktionsliterale an: trains flatMap { _.stations } // (1) trains flatMap { train => train.stations } // (2) trains flatMap { (train: Train) => train.stations } // (3)
filter Abschließend schauen wir uns noch die Signatur von filter an: trait Traversable[A] { def filter(f: A => Boolean): Traversable[A] ...
Im Gegensatz zu map und flatMap ist die filter-Methode nicht selbst parametrisiert, sodass der Collection-Typ unverändert bleiben muss. Die Funktion, die wir an filter übergeben, nimmt ein Element der Collection entgegen und gibt einen Boolean-Wert zurück. Mit einer
92
For Expressions und For Loops solchen Funktion, die auch Predicate genannt wird, können wir für jedes Element steuern, ob es in der Ergebnis-Collection enthalten sein soll (true) oder nicht (false). Genau das tun wir in der Methode JourneyPlanner.trains, hier wiederum in den verschiedenen Notationsmöglichkeiten für Funktionsliterale dargestellt: trains filter { _.stations contains station } // (1) trains filter { train => train.stations contains station } // (2) trains filter { (train: Train) => train.stations contains station } // (3)
7.3
For Expressions und For Loops
Wir wollen nun unser Augenmerk auf ein Sprachkonstrukt richten, das nahtlos an das eben Gezeigte anknüpft. For Expressions – auch For Comprehensions genannt – stellen gerade in Fällen, in denen verschachtelte Aufrufe von Funktionen höherer Ordnung wie map und flatMap den Code schwer verständlich machen, eine einfachere und eingängigere Alternative dar. Um zu verstehen, wie ein solcher komplexer Fall aussehen könnte, packen wir erst einmal die nächste Aufgabe unseres Fallbeispiels an: Wir wollen die Klasse JourneyPlanner um die Methode departures erweitern, die als Argument eine Station erwartet und als Ergebnis alle Abfahrten von diesem Bahnhof zurückgibt, und zwar in Form eines Set[(Time, Train)]: def departures(station: Station): Set[(Time, Train)] = Set.empty // TODO Not yet implemented
Diese Aufgabe ist in der Tat nicht so einfach zu lösen wie die bisherigen. Nicht nur deshalb ergänzen wir erst einmal unsere Test-Spezifikation um das folgende SUS, das auf Basis der Testdaten die Erwartung an die Implementierung zum Ausdruck bringt: "Calling departures" should { val journeyPlanner = new JourneyPlanner(Set(train1, train2)) "throw an IllegalArgumentException for a null station" in { journeyPlanner departures null must throwA[IllegalArgumentException] } "return the correct result" in { journeyPlanner departures stationA mustEqual Set(Time(0, 0) -> train1, Time(2, 2) -> train2) journeyPlanner departures stationB mustEqual Set(Time(1, 1) -> train1, Time(1, 1) -> train2) journeyPlanner departures stationC mustEqual Set(Time(2, 2) -> train1) journeyPlanner departures stationD mustEqual Set(Time(0, 0) -> train2) journeyPlanner departures Station("") mustEqual Set.empty } }
Durchstarten mit Scala
93
7 – Erste Schritte mit FP Wie nähern wir uns nun der Lösung? Ausgangspunkt ist als einziges Feld von JourneyPlanner zwangsläufig trains, also können wir es mit map oder flatMap versuchen. Nach der Erfahrung bei der Initialisierung von JourneyPlanner.stations wollen wir gleich flatMap verwenden: tains flatMap { train => ??? }
So weit, so gut. Was machen wir nun mit einem Train? Da wir auf Abfahrten abzielen, also auf Tupel aus Time und Train, haben wir schon die halbe Miete, nämlich das zweite Element. tains flatMap { train => (???, train) }
Aber wie kommen wir zu einer Collection von Tupel aus Time und Train? Denn wir brauchen für flatMap ja eine Collection als Ergebnis der Funktion. Nun, eine Collection von Abfahrtszeiten für einen Zug bekommen wir von Train.schedule. Zwar nicht direkt, aber aus dem Tupel aus Time und Station können wir mit map zumindest vom Typ her das machen, was wir brauchen, und zwar ein Tupel aus Time und Train. trains flatMap { train => train.schedule map { _._1 -> train } }
Wir sehen hier zum ersten mal, dass sich ein Funktionsliteral auch über mehrere Zeilen erstrecken kann. Das funktioniert allerdings nur, wenn wir geschweifte Klammern verwenden. Wenn wir das Funktionsliteral hingegen in runde Klammern schreiben würden, dann wäre der Zeilenumbruch nicht erlaubt. Das ist übrigens auch der Grund, weshalb der Scala Style Guide empfiehlt, geschweifte Klammern zu verwenden, weil es dann einfacher ist, eine zu lange Zeile auf mehrere umzubrechen. Diese Lösung ist zwar für den Scala-Compiler in Ordnung, erfüllt aber noch nicht unsere Test-Spezifikation. Wir wollen ja nur die Abfahrten für einen bestimmten Bahnhof ermitteln und nicht alle, die es überhaupt gibt. Also müssen wir noch nach der übergebenen Station filtern und haben damit die korrekte Lösung: def departures(station: Station): Set[(Time, Train)] = { require(station != null, "station must not be null!") trains flatMap { train => train.schedule filter { _._2 == station } map { _._1 -> train } } }
Wenngleich wir oft die Kurzschreibweise für Funktionsliterale verwenden, so führt diese hier nach unserer Meinung zu nicht ganz einfach verständlichem Code. Daher refaktorieren wir den Code, sodass die Funktionsliterale benannte Parameter haben:
94
For Expressions und For Loops trains flatMap { train => train.schedule filter { timeAndStation => timeAndStation._2 == station } map { timeAndStation => timeAndStation._1 -> train } }
Nun sind die einzelnen Aufrufe zwar gut verständlich, aber die gesamte Lösung wirkt „schwergewichtig“ und ist nicht besonders gut auf einen Blick zu erfassen. Nun stellen wir eine For Expression vor, die exakt dasselbe bewirkt, aber nach unserer Meinung besser verständlich ist. Wir zeigen diese Alternative jetzt sofort, bevor wir die Syntax zu erläutern, weil wir überzeugt sind, dass sich die Bedeutung weitgehend von selbst erschließt. for { train for (i for { i for { i val times = List(Time(12), Time(13), Time(14)) times: List[...Time] = List(Time(12,0), Time(13,0), Time(14,0)) scala> for { | time for { | time 12) | } yield (hours – 12) + "pm" res0: List[java.lang.String] = List(1pm, 2pm)
Filter können wir natürlich auch auf Variablen aus Generatoren anwenden. Nun kennen wir mit Generatoren, Definitionen und Filtern alle Bestandteile einer For Expression. Der eigentliche Charm kommt jedoch erst dann so richtig zur Geltung, wenn die Sache ein wenig komplizierter wird und wir nicht mehr mit einem Generator auskommen. Dann schreiben wir einfach weitere, egal ob diese voneinander abhängen oder nicht. Hier zwei Beispiele, wo wir zuerst die Koordinaten eines Quadrats ermitteln und danach die eines gleichseitigen Dreiecks: scala> for { | x for { | x i + 1 } res1: scala.collection.immutable.Set[Int] = Set(2, 3, 4)
Durchstarten mit Scala
97
7 – Erste Schritte mit FP Nun nehmen wir einen Filter hinzu, um nur die ungeraden Zahlen herauszupicken. Dies übersetzt der Scala-Compiler in einen Aufruf von withFilter und einen anschließenden Aufruf von map. Wir erhalten also zwei sequentielle Aufrufe von Funktionen höherer Ordnung, was nach unserer Meinung durchaus noch gut zu verstehen ist: scala> for { i Set(1, 2, 3) withFilter { i => i % 2 res1: scala.collection.immutable.Set[Int] =
1) } yield i + 1 Set(2, 4) == 1 } map { i => i + 1 } Set(2, 4)
Nun nehmen wir noch einmal eines der obigen Beispiele mit zwei Generatoren. Das bildet der Scala-Compiler auf verschachtelte Aufrufe von flatMap und map ab. Was hier gerade noch einigermaßen verständlich erscheint, wird rasch unverständlich, wenn weitere Generatoren ins Spiel kommen, denn dann fügt der Scala-Compiler nach demselben Muster weitere Verschachtelungen hinzu bzw. Aufrufe von flatMap vorne an: scala> for { | x 1 to 3 flatMap { x => 1 to x map { y => x -> y } } res1: ... = Vector((1,1), (2,1), (2,2), (3,1), (3,2), (3,3))
Im Endeffekt lässt sich jede For Expression auf diese Weise in Aufrufe von flatMap, map und withFilter übersetzten. Für Details sei auf das ausgezeichnete Buch „Programming in Scala“5 verwiesen. Dadurch wird sofort klar, welche Anforderungen ein Typ erfüllen muss, den wir in einer For Expression verwenden wollen: Er muss midestens diese Methoden zur Verfügung stellen. Anders herum betrachtet wird es wohl nicht funktionieren, einen Typ in einer For Expression zu benützen, der dies nicht erfüllt: scala> for { i 1 to 3 foreach { i => println(i) } 1 2 3
Der aufmerksame Leser wird nun möglicherweise eine Parallele zu For Expressions und den Collection-Methoden map, flatMap und filter entdecken. Und in der Tat sind auch For Loops nichts anderes als syntaktischer Zucker und werden vom Compiler in foreachAufrufe übersetzt.
Durchstarten mit Scala
99
7 – Erste Schritte mit FP
7.4
Projekt-Code: aktueller Stand
Nach den Arbeiten in diesem Kapitel sieht unser Projekt aktuell folgendermaßen aus, wobei wir nur die Klassen darstellen, die sich verändert haben: package org.scalatrain class JourneyPlanner(trains: Set[Train]) { require(trains != null, "trains must not be null!") val stations: Set[Station] = trains flatMap { _.stations } def trains(station: Station): Set[Train] = { require(station != null, "station must not be null!") trains filter { _.stations contains station } } def departures(station: Station): Set[(Time, Train)] = { require(station != null, "station must not be null!") for { train = 2, "schedule must have at least two stops!") val stations: Seq[Station] = schedule map { _._2 } } case class Station(name: String) { require(name != null, "name must not be null!") }
Listing 7.2: Train.scala
100
8 8
Vererbung und Traits
In Kapitel 5 haben wir die OO-Grundlagen von Scala kennen gelernt, ohne dabei ein Thema zu behandeln, das die meisten von uns vermutlich als das OO-Thema schlechthin betrachten: Vererbung. Das werden wir in diesem Kapitel nun nachholen, wobei wir zunächst die „klassische“ Vererbung auf Basis von Klassen behandeln werden und danach den in der Java-Welt unbekannten Ansatz der Mixin-Komposition mit Traits.
8.1
Vererbung
8.1.1
Sub-Klassen mit extends definieren
Wir haben schon an der einen oder anderen Stelle vorgegriffen und Vererbung benützt. Dabei haben wir gesehen, dass wir in Scala genauso wie in Java das Schlüsselwort extends verwenden, um eine Sub-Klasse zu definieren: scala> class Parent defined class Parent scala> class Child extends Parent defined class Child
Ebenso wie in Java hat grundsätzlich jede Scala-Klasse eine Super-Klasse. Wenn wir wie hier bei Parent darauf verzichten, eine solche explizit anzugeben, dann wird implizit AnyRef als Super-Klasse verwendet, was mit java.lang.Object gleichbedeutend1 ist. Ebenfalls analog zu Java erbt eine Sub-Klasse sämtliche Member, also Felder und Methoden, die entweder öffentlich sichtbar sind, weil sie keinen Access Modifier haben, oder die protected sind. Weiterhin muss der Primary Constructor einer Sub-Klasse den Primary Constructor der Super-Klasse aufrufen. Oder anders ausgedrückt müssen alle Klassenparameter der Super-Klasse übergeben werden, sofern diese nicht Default-Werte haben: scala> class Parent(val name: String) defined class Parent scala> class Child(name: String) extends Parent(name) defined class Child
1
Genauer müssten wir sagen: AnyRef ist auf der Java-Plattform mit Object gleichbedeutend, aber wir betrachten in diesem Buch ohnehin nur die Java-Plattform.
Durchstarten mit Scala
101
8 – Vererbung und Traits scala> val child = new Child("Scala Johansson") child: Child = Child@417d7c01 scala> child.name res0: String = Scala Johansson
Wir sehen hier, dass die Child-Klasse den Klassenparameter name hat, der direkt als Argument für den Primary Constructor der Parent-Klasse verwendet wird. Es ist nicht nötig, dem name-Klassenparameter von Child nochmals val voranzustellen, weil die Parent-Klasse name ja bereits als öffentliches Feld definiert, sodass dieses wegen der Vererbung auch ein öffentliches Feld von Child ist. Im Gegenteil: Es wäre nicht einmal ohne weiteres möglich, weil dadurch prinzipiell das Feld in der Super-Klasse überschrieben würde, was in Scala nur mit dem override-Modifier funktioniert. Doch dazu später mehr. Wir könnten natürlich bei Child auch auf den name-Klassenparameter verzichten, aber wir müssten dennoch ein Argument an den Primary Constructor von Parent übergeben. Ohne Klassenparameter kommt dafür nur etwas „Globales“ in Frage, zum Beispiel eine Konstante. Das macht in diesem Beispiel „fachlich“ betrachtet wenig Sinn, weil wohl jede Instanz der Child-Klasse einen eigenen Namen haben sollte. Aber wir können anstelle einer Klasse ein Singleton Object – ja, auch Singleton Objects haben eine Super-Klasse – verwenden, von dem es ja nur eine einzige Instanz gibt: scala> object FutureOfJava extends Parent("Scala") defined module FutureOfJava scala> FutureOfJava.name res0: String = Scala
Wenn wir verhindern wollen, dass eine Klasse erweitert werden kann, dann brauchen wir – genau wie in Java – bei der Definition bloß das Schlüsselwort final hinzufügen: scala> final class Parent defined class Parent scala> class Child extends Parent :6: error: illegal inheritance from final class Parent
Manchmal möchten wir einerseits eine Klasse erweitern, aber andererseits verhindern, dass andere das tun können. Mit einer final-Definition kommen wir nicht weit, weil auch wir selbst die Klassen dann nicht mehr erweitern können. Hier schafft Scala Abhilfe mit dem Schlüsselwort sealed: Wenn wir eine Klasse damit definieren, dann können wir diese ausschließlich innerhalb derselben Quelldatei erweitern. Das wird insbesondere im Zusammenhang mit Pattern Matching interessant, doch dazu später in Kapitel 9. Nun wollen wir Vererbung auf unser Fallbeispiel anwenden. Dafür eignet sich das Feld Train.kind, denn die bisherige Implementierung als String macht uns ein wenig Sorgen,
102
Vererbung weil wir prinzipiell irgendetwas hineinschreiben könnten, wobei es doch eine feste Menge von Zug-Typen gibt, zum Beispiel ICE, RE, BRB etc. Daher werden wir hier nun eine Enumeration einführen. Dazu ergänzen wir in der Quelldatei Train.scala das Singleton Object TrainKind, wobei wir von der Klasse Enumeration erben, deren ScalaDoc-Dokumentation2 übrigens recht anschaulich ist: object TrainKind extends Enumeration { val Ice = Value("ICE") val Re = Value("RE") val Brb = Value("BRB") }
Enumerations sind in Scala also ganz normale Objekte und daher definieren wir die einzelnen Werte auch als ganz normale vals, die wir mit Hilfe der Value-Methode (Ja, diese Methode ist tatsächlich groß geschrieben!) initialisieren. Bei Bedarf können wir auf unterschiedliche Signaturen von Value zugreifen, aber in den meisten Fällen wollen wir genau diese hier verwenden, die einen String entgegennimmt, mit dem die Werte der Enumeration bezeichnet werden. Dann erhalten wir nämlich Werte mit aufsteigender Int-ID und dem übergebenen Bezeichner: scala> TrainKind.values map { value => value.id -> value } res0: ...Set[(Int, org.scalatrain.TrainKind.Value)] = Set((0,ICE), (1,RE), (2,BRB))
Wir sehen, dass die Werte den Typ TrainKind.Value haben. Das bedeutet, dass jede Instanz einer Enumeration, und unser Singleton Object ist ja eine Instanz, eine innere Klasse Value hat. Da der komplette Typ die äußere Klasse mit einschließt, unterscheiden sich die Values verschiedener Enumerations natürlich vom Typ her. Für Details über diese sogenannten Path Dependent Types sei wieder auf „Programming in Scala“3 verwiesen. Nun müssen wir noch die Train-Klasse anpassen, indem wir den Typ des kind-Klassenparameters von String zu TrainKind.Value ändern. Natürlich müssen wir dann auch alle Test-Spezifikationen anpassen, in denen Train verwendet wird, aber das überspringen wir hier: case class Train(kind: TrainKind.Value, number: String, schedule: Seq[(Time, Station)]) { ...
2
http://www.scala-lang.org/api/current/index.html
3
Artima Press; Programming in Scala; Second Edition; Odersky, Venners, Spoon; Kapitel 20.7
Durchstarten mit Scala
103
8 – Vererbung und Traits
8.1.2
Member überschreiben
Sub-Klassen können nicht nur neue Member definieren, sondern auch vererbte überschreiben. Das bedeutet, dass in der Super-Klasse bereits ein konkretes Member vorliegt, also ein initialisiertes Feld oder eine implementierte Methode4. Über abstrakte Member werden wir in Kapitel 8.1.3 sprechen. Wenn wir das Überschreiben so versuchen, wie in Java, dann kommen wir nicht weit: scala> class Animal { | val name = "Animal" | } defined class Animal scala> class Bird extends Animal { | val name = "Bird" | } :7: error: overriding value name in class Animal ... value name needs `override' modifier
Das liegt daran, dass der Scala-Compiler beim Überschreiben auf Nummer sicher geht und von uns verlangt, dass wir das Schlüsselwort override voranstellen. In Java gibt es mit der @Override-Annotation ein ähnliches, aber optionales Konstrukt, wohingegen die Verwendung von override in Scala obligatorisch ist: scala> class Bird extends Animal { | override val name = "Bird" | } defined class Bird
Damit wird verhindert, dass wir unbeabsichtigt ein Member zu überschreiben und dadurch das von der Super-Klasse vererbte Verhalten in der Sub-Klasse ungewollt ändern, bloß weil wir aus Versehen einen schon existierenden Namen verwenden. Umgekehrt bewahrt uns der Scala-Compiler auch davor, dass wir durch einen Schreibfehler aus Versehen ein neues Member hinzufügen statt eines zu überschreiben, wenn wir das tatsächlich tun wollen: scala> class Bird extends Animal { | override val nme = "Bird" | } :7: error: value nme overrides nothing
Wir können beim Überschreiben eines Members auch dessen Typ ändern, wobei das natürlich nur ein Subtyp des Typs sein darf, den wir überschreiben. Im folgenden Beispiel benützen wir explizite Typangaben, um diesen Sachverhalt deutlich zu machen. Wir verwenden dabei den Typ AnyRef für die Super-Klasse A, der zwar ein Super-Typ für String ist, aber nicht für Int. Für Details über die Scala-Typhierarchie siehe Kapitel 8.1.4: 4
104
Scala kennt auch noch sogenannte Type Members, die wir jedoch im Rahmen dieses Buches nicht betrachten.
Vererbung scala> class A { | val v: AnyRef = "A" | } defined class A scala> class B extends A { | override val v: String = "B" | } defined class B scala> class C extends A { | override val v: Int = 0 | } :7: error: overriding value v in class A of type AnyRef; value v has incompatible type
Für das Überschreiben von Methoden gilt grundsätzlich dasselbe, d.h. wir können insbesondere einen Sub-Typ zurückgeben. Allerdings sind wir bei den Parametern exakt an die Typen der zu überschreibenden Methode gebunden, da eine andere Parameterliste zu einer neuen Methode führen würde anstatt die ursprüngliche zu überschreiben. Wir wollen hier wieder in unser Fallbeispiel einsteigen und die toString-Methode von Time überschreiben. Zwar ist diese dank Case Class schon automatisch ganz ordentlich implementiert, aber wir wollen jetzt die Zeit noch ansprechender ausgeben, und zwar im typischen Zeitformat hh:mm anstatt in runden Klammern und mit Komma getrennt. Zuerst ergänzen wir unsere Test-Spezifikation in TimeSpec um das folgende SUS: "Calling toString" should { "return a string formatted like hh:mm" in { Time().toString mustEqual "00:00" Time(1, 1).toString mustEqual "01:01" Time(20, 20).toString mustEqual "20:20" } }
Anschließend machen wir uns an die Umsetzung. Klar ist, dass wir override verwenden müssen, weil wir eine konkrete Methode überschreiben: override def toString = "" // TODO Not yet implemented
Für die Implementierung verwenden wir die Tatsache, dass wir in Scala dank einer weiteren Implicit Conversion auf Strings die Methode format zur Verfügung haben. Dabei handelt es sich letztendlich um einen Aufruf der statischen Methode String.format, die es seit Java 5 gibt. Als erstes Argument erwartet die format-Methode einen „Format String“, dessen Details in der API Documentation von Java5 nachgelesen werden können. Für unsere Zwecke tut es "%02d:%02d". Die weiteren Argumente sind dann die Werte, mit welchen die Platzhalter gefüllt werden sollen, hier also hours und minutes: 5
http://download.oracle.com/javase/6/docs/api/java/util/Formatter.html
Durchstarten mit Scala
105
8 – Vererbung und Traits override def toString = "%02d:%02d".format(hours, minutes)
Damit läuft unsere aktualisierte Test-Spezifikation durch, aber wir wollen es dennoch auch auf der Konsole sehen: scala> Time(12, 34) res0: org.scalatrain.Time = 12:34
Zum Überschreiben von Methoden gibt es noch einen sehr erwähnenswerten Punkt. Es ist nämlich zulässig, eine Methode mit einem unveränderlichen Feld zu überschreiben. Natürlich muss es sich dabei um eine Methode ohne Parameter handeln. Dann kann das durchaus Sinn machen, und zwar genau dann, wenn die Methode derart implementiert ist, dass dabei nur unveränderliche Werte verwendet werden, sodass sie immer dasselbe zurückgibt. In diesem Fall gilt es abzuwägen, ob der zusätzliche Speicherbedarf, den ein val mit sich bringt, in Kauf genommen werden soll, um Performance zu gewinnen, weil die Berechnung des Wertes nur einmal stattfindet. Eine zusätzliche Option ist dann noch ein lazy val, der noch mehr Speicherbedarf hat, aber die Berechnung eben nur auf Bedarf und auch dann nur einmalig durchführt. Wie auch immer, hier wollen wir nun dieses Feature direkt in unserem Fallbeispiel verwenden: Da Time eine unveränderliche Klasse ist, ergibt die gerade überschriebene toString-Methode immer denselben Wert und ist damit ein heißer Kandidat. Ob in der Realität tatsächlich so häufig toString aufgerufen würde und das auch noch in Situationen, wo die Performance kritisch ist, das sei einmal dahingestellt; wir machen es dennoch: override val toString: String = "%02d:%02d".format(hours, minutes)
Wie wir sehen, funktioniert das, d.h. sowohl das Compilieren, als auch die Tests laufen durch. Abschließend sei erwähnt, dass wir selbstverständlich – wie auch in Java – dem Überschreiben einen Riegel vorschieben können. Dazu definieren wir ein Member einfach als final: scala> class Animal { | final val name = "Animal" | } defined class Animal scala> class Bird extends Animal { | override val name = "Bird" | } :7: error: overriding value name in class Animal ... value name cannot override final member
106
Vererbung
8.1.3
Abstrakte Klassen und Member
Konkrete Felder werden im Rahmen ihrer Definition sofort initialisiert und konkrete Methoden haben stets eine Implementierung. Im Gegensatz dazu können wir auch abstrakt Member deklarieren, indem wir die Initialisierung bzw. die Implementierung einfach weglassen. Im Gegensatz zu Java benötigen wir also in Scala kein Schlüsselwort, um ein abstraktes Feld oder eine abstrakte Methode zu deklarieren: scala> abstract class Animal { | val name: String | def sayHello: String | } defined class Animal
Allerdings wird eine Klasse, welche mindestens ein abstraktes Member enthält, zu einer abstrakten Klasse und diese muss explizit mit dem Schlüsselwort abstract als solche gekennzeichnet werden. Ähnlich wie bei der obligatorischen Verwendung von override verhindert der Scala-Compiler dadurch, dass wir eine Klasse unbeabsichtigt abstrakt machen. scala> class Bird extends Animal { | override def sayHello = "Beep" | } :6: error: class Bird needs to be abstract, since value name in class Animal of type String is not defined
Abstrakte Klassen können nicht instanziiert werden. Dazu benötigen wir eine Sub-Klasse, in der alle abstrakten Member initialisiert bzw. implementiert werden. Eine solche SubKlasse kann eine „gewöhnliche“ benannte Klasse sein oder auch eine anonyme. Wenn wir ein abstraktes Member in einer Sub-Klasse initialisieren oder implementieren, dann können wir optional das Schlüsselwort override benützen. Im folgenden Beispiel zeigen wir, dass beide Varianten möglich sind, einmal mit override und einmal ohne: scala> class Bird extends Animal { | override val name = "Bob" | override def sayHello = "Beep" | } defined class Bird
Wir empfehlen, override aus demselben Grund wie beim Überschreiben immer zu verwenden, weil wir damit verhindern, dass wir zwar beabsichtigen, ein Member zu implementieren, dies aber aufgrund eines Schreibfehlers gar nicht tun. Bei konkreten Sub-Klassen kann das zwar gar nicht vorkommen, aber sehr wohl bei abstrakten Sub-Klassen oder bei Traits:
Durchstarten mit Scala
107
8 – Vererbung und Traits scala> abstract class AbstractBird extends Animal { | val nam = "Bird" | override def sayHello = "Beep" | } defined class AbstractBird scala> abstract class AbstractBird extends Animal { | override val nam = "Bird" | override def sayHello = "Beep" | } :7: error: value nam overrides nothing
Mit diesem Wissen wenden wir uns jetzt wieder unserem Fallbeispiel zu. Bisher hat ein Train die öffentlichen Felder kind und number. Das sind zwar die wichtigsten Informationen über einen Zug, aber wir können uns durchaus weitere und je nach Zug-Typ unterschiedliche vorstellen. Zum Beispiel ob es ein Bord-Restaurant oder einen WLANZugang gibt, ob die Mitnahme von Fahrrädern erlaubt ist oder ob Abteile der 1. Klasse zur Verfügung stehen. Daher werden wir nun die Informationen über einen Zug in einer abstrakten Klasse und davon abgeleiteten konkreten Klassen kapseln. Dazu fügen wir zunächst zur Quelldatei Train.scala die abstrakte Klasse TrainInfo hinzu, die zwei abstrakte Methoden definiert, eine für den Zug-Typ, wobei wir gleich den Typ für die Werte unserer TrainKind-Enumeration verwenden, und eine für den Namen. sealed abstract class TrainInfo { def kind: TrainKind.Value def number: String }
Wir machen TrainInfo zu einer Sealed Class, weil wir ja nur eine begrenzte Menge an Werten für den Zug-Typ zur Verfügung haben. Daher macht es keinen Sinn, wenn wir zulassen, dass TrainInfo öffentlich erweitert wird. Vielmehr wollen wir selbst für jeden Zug-Typ eine Sub-Kasse erstellen. Das müssen wir wegen des Schlüsselwortes sealed in derselben Quelldatei tun. Wem diese Datei langsam zu vollgepackt erscheint, der darf sie gerne aufspalten. Allerdings denken wir, dass eine Quelldatei, die komplett auf einen „normalen“ Monitor passt, keinesfalls zu groß ist. Und die gerade hinzugefügten Klassen sind sehr eng mit Train verwandt, sodass wir die Quelldatei Train.scala als den optimalen Ort betrachten: case class Ice(number: String, hasWifi: Boolean = false) extends TrainInfo { override val kind = TrainKind.Ice } case class Re(number: String) extends TrainInfo { override val kind = TrainKind.Re } case class Brb(number: String) extends TrainInfo { override val kind = TrainKind.Brb }
108
Vererbung Hier sehen wir noch einmal ein schönes Beispiel dafür, dass wir defs mit vals überschreiben bzw. in diesem Fall implementierten können. Denn die abstrakte Super-Klasse Train Info definiert ja zwei parameterlose Methoden, die wir in jeder Sub-Klasse mit vals implementieren: kind wird jeweils ganz explizit im Körper der Klasse als val definiert und number, eigentlich nur als Klassenparameter definiert, wird dank Case Classes automatisch zum val. Wenn wir das Prinzip, dass wir auch beim Initialisieren oder Implementieren abstrakter Member stets override verwenden wollen, auch hier anwenden würden, dann sähe das am Beispiel von Re folgendermaßen aus: case class Re(override val number: String) extends TrainInfo { override val kind = TrainKind.Re }
Ja, wir können in Case Classes auch das Schlüsselwort val verwenden, um Klassenparameter zu definieren. Nur macht das keinen Unterschied, denn der Scala-Compiler macht für Case Classes die Klassenparameter ohnehin zu vals. Wir könnten sogar var verwenden, um veränderliche Felder zu erhalten, wobei das der Grundidee von unveränderlichen Value Objects schon sehr widerspricht und daher wohl nur in sehr seltenen Ausnahmen zum Einsatz kommen sollte. Im obigen Fall wäre val erforderlich, um override verwenden zu können. Insgesamt erscheint uns das zu schwergewichtig, sodass wir davon Abstand nehmen. Außerdem wissen wir schon, dass sich Case Classes ohnehin nicht als Super-Klassen eignen, sodass sie wohl niemals abstrakt sein werden. Das bedeutet, dass der Scala-Compiler ohnehin feststellen wird, wenn wir uns vertippen und ein abstraktes Member einer Super-Klasse nicht initialisieren oder implementieren, sodass der eigentliche Grund für das Verwenden von override entfällt. Nun müssen wir noch Train so anpassen, dass an die Stelle der Klassenparameter kind und number der Klassenparameter info vom Typ TrainInfo tritt. Die entsprechenden Anpassungen der Test-Spezifikationen überspringen wir hier wieder: case class Train(info: TrainInfo, schedule: Seq[(Time, Station)]) { require(info != null, "info must not be null!") ...
Der aufmerksame Leser wird möglicherweise bemerkt haben, dass die Information über den Zug-Typ nun redundant vorliegt. Zum einen haben wir die Enumeration TrainKind und zum anderen steckt diese Information in den verschiedenen Sub-Typen von TrainInfo, also in Ice, Re und Brb. Von diesen kann es ja wegen sealed auch keine weiteren geben, sodass TrainInfo quasi eine Enumeration auf Typ-Ebene darstellt. Einen solchen Typ bezeichnet man auch als algebraischen Datentypen; für weitere Details insbesondere in Bezug auf Scala, sei auf den Blog Post „Functional Scala: Algebraic Datatypes“6 und nachfolgende
6
http://gleichmann.wordpress.com/2011/01/30/functional-scala-algebraic-datatypes-enumerated-types/
Durchstarten mit Scala
109
8 – Vererbung und Traits von Mario Gleichmann verwiesen. Redundanzen sind natürlich stets kritisch zu überprüfen und in diesem Fall sind sie tatsächlich überflüssig. Daher werden wir nun unsere TrainKind-Enumeration wieder entfernen, sodass der entsprechende Teil der Quelldatei Train.scala nun folgendermaßen aussieht: sealed abstract class TrainInfo { def number: String } case class Ice(number: String, hasWifi: Boolean = false) extends TrainInfo case class Re(number: String) extends TrainInfo case class Brb(number: String) extends TrainInfo
8.1.4
Scala-Typhierarchie
Abbildung 1 zeigt sozusagen die Essenz der Scala-Typhierarchie. Dabei fallen insbesondere zwei Aspekte auf:
•• Aufteilung in Sup-Typen von AnyVal und AnyRef •• Bottom Types Null und Nothing
Abbildung 8.1: Scala-Typhierarchie
110
Vererbung In Scala ist alles ein Objekt. Das bedeutet unter anderem, dass es keine primitiven Datentypen gibt. Daher haben alle Klassen die gemeinsame Super-Klasse Any. Dort sind unter anderem die Methoden == und equals definiert. Dabei delegiert == an equals7, sodass wir in Scala – ganz anders als in Java – alle Objekte stets mit == auf Gleichheit prüfen können. Da == final ist, können wir das „Gleichheits-Verhalten“ dadurch modifizieren, dass wir die equals-Methode überschreiben. Unterhalb von Any teilt sich die Typhierarchie in Value Types und Reference Types. Die Value Types, repräsentiert durch deren Super-Klasse AnyVal, sind im wesentlichen das, was wir in Java als primitive Datentypen kennen. In Scala sind Int, Long, Float und Konsorten jedoch echte Objekte. Aber wirkt sich das nicht äußerst schädlich auf die Performance aus, wenn statt int, long, float etc. Objekte verwendet werden? Nein, denn der Scala-Compiler bildet die Verwendung dieser AnyVal-Objekte auf die primitiven Datentypen ab. Daher haben wir Programmierer ein einfacheres Leben und gleichzeitig dieselbe Performance. Übrigens befindet sich auch der Typ Unit, der ja für uninteressante Ergebnisse verwendet wird, unterhalb von AnyVal. Die Reference Types haben allesamt die gemeinsame Super-Klasse AnyRef. Wie schon erwähnt entspricht das auf der Java-Plattform genau java.lang.Object. AnyRef definiert unter anderem die Methode eq, mit der wir zwei Objekte auf referentielle Gleichheit prüfen könnten, also sozusagen der Ersatz für das „verloren gegangene“ ==. Allerdings werden wir das in der Praxis selten benötigen. Hier dennoch ein einfaches Beispiel, das zeigt, dass zwei inhaltlich gleiche Listen bei Vergleich mit == true ergeben und mit eq false: scala> List(1) == List(1) res0: Boolean = true scala> List(1) eq List(1) res1: Boolean = false
Unter AnyRef befinden sich alle Java-Klassen, also zum Beispiel java.lang.String und Klassen aus Java-Libraries und ebenso alle Scala Reference Types. Letztere wurden vom ScalaCompiler übersetzt und haben dabei zusätzlich das Marker Interface ScalaObject verpasst bekommen. Eine Besonderheit, die es in Java nicht gibt, sind die beiden Bottom Types Null und Nothing. Wie wir sehen können ist Null Sub-Klasse jeglicher Reference Types und Nothing SubKlasse jeder beliebigen Klasse. Für Null gibt es genau einen Wert und das ist das allseits beliebte null. Für Nothing gibt es gar keinen Wert, aber dieser Typ liefert dennoch einen wertvollen Beitrag. So ist zum Beispiel das Singleton Object Nil vom Typ List[Nothing]. Da eine List kovariant (siehe Kapitel 12.2.2) ist, also aus einer Vererbungsbeziehung zwischen zwei Typ-Parametern eine gleichgerichtete Vererbungsbeziehung zwischen entsprechend 7
Außer für boxed numerics aus Java.
Durchstarten mit Scala
111
8 – Vererbung und Traits parametrisierten Listen folgt, kann Nil überall verwendet werden, wo eine Liste benötigt wird. Denn Nothing ist ja Sub-Typ jedes anderen Typs und daher List[Nothing] Sup-Typ jeder beliebigen Liste. Ein Beispiel: scala> val xs: List[Int] = Nil xs: List[Int] = List() scala> val xs: List[String] = Nil xs: List[String] = List() scala> val xs: List[String] = List[Any]() :5: error: type mismatch; found : List[Any] required: scala.List[String] val xs: List[String] = List[Any]()
Ohne jetzt im Detail auf das zugegebenermaßen nicht einfache Thema der Varianz einzugehen, das wir in Kapitel 12.2.2 kurz vorstellen, sehen wir hier, dass Nil tatsächlich als beliebig typisierte Liste verwendet werden kann, wohingegen das zum Beispiel mit einer List[Any] nicht funktioniert.
8.2
Traits
Wir haben schon an der einen oder anderen Stelle Traits erwähnt und wollen uns nun endlich diesem hochinteressanten Sprach-Feature widmen. Dazu knüpfen wir an unser Animal-Beispiel aus dem Kapitel 8.1 an. Dieses erweitern wir um ein weiteres Tier, und zwar um einen Fisch. Dann fügen wir den beiden Tieren typische Methoden hinzu. Der Vogel kann natürlich fliegen und der Fisch schwimmen: scala> class Bird(override val name: String) extends Animal { | override def sayHello = "Beep" | def fly = "I am flying." | } defined class Bird scala> class Fish(override val name: String) extends Animal { | override def sayHello = "Bubble" | def swim = "I am swimming." | } defined class Fish
So weit, so gut. Nun wollen wir eine Ente hinzufügen. Diese ist aus Sicht der biologischen Systematik mit Sicherheit ein Vogel, sodass wir von Bird ableiten müssen. Andererseits kann eine Ente auch schwimmen. Wir wollen hier der Einfachheit halber annehmen, dass eine Ente genauso schwimmt wie ein Fisch. Dann haben wir das Problem, dass wir die Implementierung der swim-Methode für die Ente duplizieren
112
Traits müssen. Zumindest müssten wir das in Java tun8, weil dort bekanntlich keine Mehrfachvererbung möglich ist. In Scala auch nicht, denn wie in Kapitel 8.1.1 erläutert hat jede Klasse genau eine Super-Klasse. Aber in Scala gibt es Traits, mit denen wir „so etwas“ wie Mehrfachvererbung realisieren können, zumindest vom Effekt her. Bevor wir die Details erläutern, zeigen wir einfach, wie wir mit Traits unser Animal-Problem lösen können: scala> trait Swimmer { | def swim = "I am swimming." | } defined trait Swimmer scala> class Fish(override val name: String) | extends Animal with Swimmer { | override def sayHello = "Bubble" | } defined class Fish scala> class Duck(name: String) extends Bird(name) with Swimmer defined class Duck
Zunächst definieren wir mit dem Schlüsselwort trait den Trait Swimmer. Wir können Traits entweder als Interfaces verstehen mit der zusätzlichen Möglichkeit, auch konkrete Member zu definieren, so wie wir das hier mit der swim-Methode tun. Wie gesagt, wir können hier beliebige Member definieren, also nicht nur Methoden! Oder wir fassen sie als abstrakte Klassen ohne Klassenparameter auf, d.h. wir können mit ihnen alles tun, was wir auch mit Klassen tun können. Einzig instanziieren bzw. mit Parametern initialisieren können wir sie nicht. Dann löschen wir bei Fish die swim-Methode und mixen dafür den Swimmer-Trait hinein, indem wir das Schlüsselwort with verwenden. Ja genau, Traits werden nicht implementiert wie Java-Interfaces, sondern hinein gemixt. Schließlich machen wir dasselbe bei der neunen Klasse Duck, d.h. wir mixen auch dort Swimmer hinein. Das Ergebnis ist dann wie gewünscht, dass eine Ente sowohl fliegen kann, als auch schwimmen, ohne dabei Code zu duplizieren: scala> val donald = new Duck("Donald") donald: Duck = Duck@6154283a scala> donald.fly res0: java.lang.String = I am flying. scala> donald.swim res1: java.lang.String = I am swimming.
8
Oder einen Umweg über eine Utility-Klasse gehen, was dennoch einen redundanten Aufruf bedingt und eine wenig intuitive Indirektion bedeutet.
Durchstarten mit Scala
113
8 – Vererbung und Traits
8.2.1
Traits hinein mixen
Wir haben gerade das Schlüsselwort with verwendet, um Swimmer in Fish und Duck hinein zu mixen. Das legt die Vermutung nahe, dass wir im Zusammenhang mit Traits immer with verwenden müssen. Aber das ist nicht so! Wenn wir einen Trait in eine Klasse hinein mixen wollen, die nicht explizit von einer bestimmten Super-Klasse erbt, dann müssen wir extends verwenden: scala> trait T defined trait T scala> class C with T :1: error: ';' expected but 'with' found. class C with T ^ scala> class C extends T defined class C
Dieser Sachverhalt führt gerade beim Lernen von Scala immer wieder zu Unverständnis, wenngleich es eine gute Erklärung dafür gibt. Diese wollen wir gleich nachliefern, aber zuerst einen Faustregel an die Hand geben: „Zuerst muss immer extends stehen, egal ob Trait oder Klasse, danach kommen beliebig viele Traits mit with“. Aber nun zur Erklärung: Jeder Trait hat, genau wie jede Klasse, genau eine Super-Klasse. Wenn wir einen Trait so definieren, wie wir es für Swimmer getan haben, also ohne explizit eine Super-Klasse mit extends anzugeben, dann erhalten wir, wie bei Klassen, implizit die Super-Klasse AnyRef. Wenn wir nun einen Trait „direkt“ in eine Klasse hinein mixen, indem wir extends verwenden, dann erbt damit die Klasse automatisch von der Super-Klasse des Traits. Das verdeutlichen wir am besten anhand eines Beispiels: scala> class A defined class A scala> trait T extends A defined trait T scala> class B extends T defined class B scala> classOf[A].isAssignableFrom(classOf[B]) res0: Boolean = true
Hier haben wir die Methode classOf verwendet, die einen Typ-Parameter erwartet und eine entsprechende Class-Instanz zurückgibt, was exakt A.class bzw. B.class in Java entspricht. Wir können sehen, dass B zwar nicht direkt von A erbt, aber dennoch eine Sub-Klasse von A ist. Diese Klassen-Vererbung kommt wie oben ausgeführt durch die Super-Klasse des Traits T. Dieser Sachverhalt, dass Traits eine Super-Klasse haben, kann dazu führen, dass wir einen Trait nicht in eine Klasse hinein mixen können, und zwar genau dann, wenn die Klasse und der Traits „unverträgliche“ Super-Typen ha-
114
Traits ben. Im einfachsten Fall, so wie in unserem Animal-Beispiel, tritt dieses Problem nicht zutage. Entweder haben die Klasse und der Trait beide die Super-Klasse AnyRef, sodass es natürlich keinen Konflikt gibt, oder zumindest einer der beiden hat AnyRef als Super-Klasse, sodass auch dann kein Problem entsteht. Dies passiert jedoch, wenn sowohl die Klasse, als auch der Trait explizite Super-Klassen haben, die nicht in einer Vererbungsbeziehung stehen: scala> class A defined class A scala> class B defined class B scala> trait T extends B defined trait T scala> class X extends A with T :8: error: illegal inheritance; superclass A is not a subclass of the superclass B of the mixin trait T class X extends A with T
Zugegebenermaßen treten solche Probleme in der Praxis bestimmt nicht gleich zu Beginn der Scala-Karriere auf, sodass wir uns wieder auf die eigentliche Botschaft konzentrieren können, die wir hier noch einmal wiederholen: Immer zuerst extends verwenden, auch wenn es sich um einen Trait handelt!
8.2.2
Linearisierung
Wir haben schon angedeutet, dass wir in eine Klasse nicht nur einen Trait hinein mixen können, sondern sogar mehrere. Das ist auch nicht weiter verwunderlich, denn schließlich bekommen wir ja schon bei einem einzelnen Trait so etwas wie Mehrfachvererbung, wie wir im Animal-Beispiel gesehen haben: Unsere Ente konnte ja fliegen und schwimmen, ohne dass wir eine Zeile Code dafür extra geschrieben haben. Solange die verschiedenen Traits lauter unterschiedliche Member beisteuern, ist das Ganze auch noch recht einfach zu durchschauen. Aber was passiert eigentlich, wenn mehrere Traits dieselbe Methode haben? Oder wenn ein Trait und die Klasse, in die er hinein gemixt werden soll, dasselbe Feld definieren? Wer gewinnt dann? Keine Sorge, das Ganz ist im Prinzip ganz einfach und auf alle Fälle deterministisch. Das Schlagwort lautet „Linearisierung“. Der Scala-Compiler bringt alle Elemente aus der Vererbungshierarchie, egal ob Klassen oder Traits, in eine lineare Reihenfolge. Ganz vorne – also am Anfang der Aufrufhierarchie – steht natürlich die Klasse, in die hinein gemixt wird. Dann kommen die diversen Traits und deren Super-Klassen sowie die Super-Klasse der Klasse, um die es geht. Da alles von Any erbt, steht ganz hinten auf alle Fälle Any.
Durchstarten mit Scala
115
8 – Vererbung und Traits Für weitere Details sei wiederum auf „Programming in Scala“9 verwiesen. Im Gegensatz zur klassischen Mehrfachvererbung von Klassen gibt es hier keine Schwierigkeiten wie zum Beispiel das Diamond Problem10. Das funktioniert deshalb so reibungslos, weil Traits eben keine Klassen sind und nicht initialisiert werden können. Dank der Linearisierung ist jederzeit bzw. überall klar, was super bedeutet, wobei super dieselbe Bedeutung wie in Java hat und auf die Instanz des Super-Typs verweist. Wir sehen uns das jetzt einfach an unserem Animal-Beispiel an. Allerdings führen wir zuvor noch einen weiteren Trait ein, der die Begrüßung übernimmt: scala> trait Hello extends Animal { | override def sayHello = "Hello." | } defined trait Hello
Wir erweitern Animal, damit wir die dort abstrakt deklarierte Methode sayHello implementieren können, was wir durch das Schlüsselwort override zum Ausdruck bringen. Mit diesem Hello-Trait vereinfachen wir unseren Fisch, indem wir dort die sayHello-Methode löschen: scala> class Fish(override val name: String) | extends Animal with Swimmer with Hello defined class Fish
Zusätzlich mixen wir Hello auch in Bird hinein, wenngleich wir dort die sayHello-Methode unverändert stehen lassen: scala> class Bird(override val name: String) extends Animal with Hello { | override def sayHello = "Beep" | def fly = "I am flying." | } defined class Bird
Insgesamt ergibt sich daraus die in Abbildung 2 dargestellte Typ-Hierarchie, wobei wir aus Gründen der Einfachheit Any außen vorlassen.
9
Artima Press; Programming in Scala; Second Edition; Odersky, Venners, Spoon; Kapitel 12.6
10 http://de.wikipedia.org/wiki/Diamond-Problem
116
Traits
Abbildung 8.2: Typ-Hierarchie des Animal-Beispiels (ohne Any) Wir wollen nun anhand von Fish betrachten, wie der Scala-Compiler dieses „Durcheinander“ linearisiert. Ganz hinten steht die Linearisierung der Super-Klasse von Fish, also die von Animal. Da in Animal keine Traits hinein gemixt werden und die Super-Klasse AnyRef lautet, ergibt sich für das Ende der Linearisierung die Abfolge: Animal > AnyRef > Any Als nächstes kommt die Linearisierung des ersten Traits, der hinein gemixt wurde, also die von Swimmer, wobei bereits existierende Typen ausgelassen werden, sodass nur noch Swimmer selbst übrig bleibt: Swimmer > Animal > AnyRef > Any Nun ist der zweite Trait an der Reihe, also Hello. Zwar erbt Hello von Animal, aber Animal haben wir ja schon in unserer Linearisierung, sodass wieder nur Hello selbst übrig bleibt: Hello > Swimmer > Animal > AnyRef > Any Zu guter Letzt kommt dann die Klasse selbst, in die wir Traits hinein mixen, also Fish: Damit sieht die komplette Linearisierung folgendermaßen aus: Fish > Hello > Swimmer > Animal > AnyRef > Any Für Duck sieht die Linearisierung gemäß demselben Vorgehen so aus: Duck > Swimmer > Bird > Hello > Animal > AnyRef > Any
Durchstarten mit Scala
117
8 – Vererbung und Traits Wenn wir uns das ansehen, dann können wir sofort die Frage beantworten, wie ein Fisch und eine Ente „Hallo“ sagen. Beide mixen den Hello-Trait hinein. Allerdings überschreibt die Bird-Klasse die sayHello-Methode und Bird kommt vor Hello, sodass wir also das folgende Ergebnis erhalten: scala> new Fish("Freddy").sayHello res0: java.lang.String = Hello. scala> new Duck("Donald").sayHello res1: java.lang.String = Beep
Dabei wird offenkundig, dass wir sayHello für Duck noch einmal überschreiben sollten, denn Enten quaken bekanntlich. Das wollen wir aber nicht weiter vertiefen, sondern folgende Faustregel formulieren: „Je weiter rechts ein Trait beim Hinein-Mixen steht, desto vorrangiger ist er. Den höchsten Vorrang hat jedoch die Klasse selbst, in die hinein gemixt wird.“
8.2.3
Beispiel: Ordered implementieren
Wir wollen nun dieses mächtige Sprach-Feature für unser Fallbeispiel nutzen. Dazu mixen wir den Ordered-Trait aus der Scala-Standardbibliothek in die Time-Klasse hinein. Dadurch gewinnen wir die Möglichkeit, Time-Instanzen auf größer, kleiner etc. zu vergleichen. Ordered deklariert die abstrakte Methode compare, die wir nun implementieren müssen. Dabei müssen wir einen negativen bzw. positiven Int-Wert zurückgeben, wenn die Instanz, auf der compare aufgerufen wird, kleiner bzw. größer als die Instanz ist, mit der verglichen werden soll. Und bei Gleichheit soll natürlich 0 zurückgeliefert werden. Praktischerweise haben wir schon eine Methode zur Hand, die genau das erwartete Verhalten besitzt: Time.minus bzw. die Operator-Variante davon. Daher sieht unsere Lösung folgendermaßen aus: case class Time(hours: Int = 0, minutes: Int = 0) extends Ordered[Time] { ... override def compare(that: Time): Int = this - that }
Wir stellen hier nicht die Erweiterung der Test-Spezifikation dar, sondern zeigen gleich die neuen Möglichkeiten, die wir nach unserer Änderungen haben, in der REPL: scala> Time(1) < Time(2) res0: Boolean = true scala> Time(1) >= Time(2) res1: Boolean = false
118
Traits
8.2.4
Einschub: By-Name Parameters
Als nächste Aufgabe wollen wir einen eigenen Trait schreiben, der sich als dünner Wrapper über die sehr weit verbreitete Logging Library SLF4J11 legt, und uns die folgenden Vorteile bringt:
•• Logger-Instanz wird über Hinein-Mixen eines Traits verfügbar gemacht •• Log-Nachrichten werden nur ausgewertet, wenn der entsprechende Log-Level aktiv ist Der letzte Punkt bezieht sich darauf, dass das Erzeugen von Log-Nachrichten durchaus „teuer“ sein kann. Gerade wenn die Log-Nachricht aus mehreren Strings zusammengesetzt oder gar mittels String.format aufbereitet wird, dann kann sich das Erzeugen von Objekten und der Verbrauch von Rechenleistung negativ auf Speicherverbrauch und Performance auswirken. Daher bietet SLF4J, wie die meisten Logging Libraries, die Möglichkeit, zu überprüfen, ob ein Log-Level aktiv ist. Eine typische Verwendung sieht damit so aus: if (logger.isInfoEnabled) { logger.info("Trying to login user %s".format(user)) }
Leider ist dieses Pattern doch recht schwergewichtig, sodass die Überprüfung auf den Log-Level in der Praxis oft unter den Tisch fällt. Hier kann Scala helfen, weil wir eine Möglichkeit bekommen, das Auswerten der Log-Nachrichten, also das Zusammensetzen bzw. Formatieren der Strings, „hinauszuzögern“. Eigentlich gibt es sogar zwei Möglichkeiten und eine davon kennen wir bereits: Funktionen. Wieso? Ganz einfach, wir müssten nur die Methodensignaturen so gestalten, dass diese keine Nachricht in Form eines Strings entgegennehmen, sondern Funktionen mit leerer Parameterliste, die einen String zurückgeben. Diese Funktionen werden erst dann ausgeführt, wenn wir sie aufrufen. Und das können wir natürlich davon abhängig machen, ob der entsprechende Log-Level aktiv ist. Wir führen uns das an einem einfachen Beispiel in der REPL vor Augen: scala> var isActive = true isActive: Boolean = true scala> def log(message: () => String) { | if (isActive) println(message()) | } log: (message: () => String)Unit scala> log { () => println("Evaluated now!"); "Hello" } Evaluated now! Hello scala> isActive = false isActive: Boolean = false scala> log(() => { println("Evaluated now!"); "Hello" })
11 http://www.slf4j.org/
Durchstarten mit Scala
119
8 – Vererbung und Traits Die log-Methode erwartet eine Funktion () => String und ruft diese nur dann auf, wenn der Wert von isActive true ist. Die Funktion, die wir übergeben, gibt zuerst nur zu Testzwecken eine Meldung aus, sodass wir sehen, ob sie ausgeführt wird, und gibt dann mit „Hello“ die eigentliche Log-Meldung zurück. Im ersten Aufruf ist isActive true, sodass die Funktion ausgeführt wird. Aber im zweiten, wenn ist isActive false ist, erhalten wir keinerlei Ausgaben, was belegt, dass die Ausführung der Funktion nicht stattfand. Natürlich ist diese Verwendung auch nicht das gelbe vom Ei, denn anstatt „Hello“ müssen wir () => „Hello“ übergeben. Hier kommen die sogenannten By-Name Parameters ins Spiel. Wir können wie Methode fast wie ursprünglich definieren, mit dem feinen Unterschied, dass wir vor den Parameter-Typ einen Pfeil „=>“ schreiben. Dann können wir die Methode wie gewohnt aufrufen, nur dass das Argument erst dann ausgewertet wird, wenn es verwendet wird. Wir übergeben hier noch einmal einen ganzen Block, der wie vorher zunächst eine Test-Meldung ausgibt: scala> def log(message: => String) { | if (isActive) println(message) | } log: (message: => String)Unit scala> log({ println("Evaluated now!"); "Hello" }) scala> isActive = true isActive: Boolean = true scala> log { println("Evaluated now!"); "Hello" } Evaluated now! Hello
Wir sehen, dass das übergebene Argument auch hier erst bei der Verwendung ausgewertet wird. Dasselbe gilt für einen Ausdruck, der einen String zusammensetzt oder formatiert. Auch hier wird der komplette Ausdruck nicht schon beim Aufruf ausgewertet, sondern erst bei der Verwendung: scala> val user = "John Doe" user: java.lang.String = John Doe scala> log("Trying to login user %s".format(user)) Trying to login user John Doe
Mit By-Name Parameters haben wir also einen Mechanismus zur Hand, mit dem wir die Auswertung von Parametern verzögern können. Doch Vorsicht! Es handelt sich nicht um eine Verzögerung im Sinne von lazy vals, welche nur einmal initialisiert werden, sondern es handelt sich letztendlich um Funktionen, die jedes mal ausgeführt werden, wenn sie aufgerufen werden. Daher macht es in den meisten Fällen, in denen der Wert eines ByName Parameters mehrfach benötigt wird, Sinn, diesen einer lokalen Variable zuzuweisen und dann diese weiter zu verwenden.
120
Traits
8.2.5
Self Types
Mit diesem Wissen machen wir uns nun daran, den SLF4J-Wrapper zu programmieren. Dabei werden wir nicht jedes mögliche Detail ausprogrammieren, weil es bereits entsprechende Libraries gibt, zum Beispiel SLF4S12, und sogar SLF4J selbst bald ein Scala-API13 bekommen wird. Wir beschränken uns hier im Wesentlichen darauf, die Grundidee umzusetzen und dabei unser Wissen über Traits anzuwenden und zu vertiefen. Für den SLF4J-Wrapper benötigen wir natürlich erst einmal die SLF4J-Library im Projekt. Genau genommen handelt es sich bei SLF4J um mehrere Libraries, wovon eine das API bereitstellt und andere Adapter zu sogenannten Logging Backends, zum Beispiel für Java Logging, Log4j14 oder Logback15. Wir verwenden hier letzteres weil Logback das SLF4J API selbst implementiert, sodass wir uns den Adapter sparen können. Wir ergänzen also in unserer SBT-Projektdefinition die folgenden Dependencies: val slf4jApi = "org.slf4j" % "slf4j-api" % "1.6.1" withSources val logback = "ch.qos.logback" % "logback-classic" % "0.9.28"
Anschließend führen wir in der SBT-Konsole erst reload, dann update und schließlich idea aus, um das SBT-Projekt zu aktualisieren, die Dependencies herunterzuladen und das IDEA-Projekt zu aktualisieren. Als erstes programmieren wir den Trait Logger in der neuen Quelldatei Logger.scala. Dieser Trait ist ein Wrapper um einen SLF4J-Logger und stellt diverse Log-Methoden zur Verfügung, die Log-Nachrichten als By-Name Parameter entgegen nehmen. Wir zeigen hier nur die info-Methoden, um Platz zu sparen; den kompletten Code gibt es wie üblich am Schluss in Kapitel 8.3: import org.slf4j.{ Logger => SLF4JLogger } trait Logger { def info(message: => String) { if (slf4jLogger.isInfoEnabled) slf4jLogger.info(message) } def info(message: => String, t: Throwable) { if (slf4jLogger.isInfoEnabled) slf4jLogger.info(message, t) } protected val slf4jLogger: SLF4JLogger }
Zu Beginn der Quelldatei sehen wir eine Import Selector Clause, mit der wir den SLF4JLogger umbenennen, damit wir keinen Namenskonflikt bekommen. Die Log-Methoden prüfen, ob der jeweilige Log-Level aktiv ist und rufen nur dann die passende Log-Me12 https://github.com/weiglewilczek/slf4s/ 13 https://github.com/ceki/slf4j/tree/master/slf4j-scala-api/ 14 http://logging.apache.org/log4j/ 15 http://logback.qos.ch/
Durchstarten mit Scala
121
8 – Vererbung und Traits thode des gewrappten SLF4-Loggers auf, wobei erst dann die Log-Nachricht ausgewertet wird. Der gewrappte SLF4J-Logger ist als abstraktes Feld deklariert, sodass wir hier einen Trait haben, der nicht „einfach so“ in eine Klasse hinein gemixt werden kann, sondern der die Anforderung mitbringt, dass das abstrakte Feld initialisiert wird. Das bedeutet, dass wir diesen Trait zwar in beliebige Klassen hinein mixen könnten, aber dort müssten wir dann SLF4J-Klassen importieren und eine SLF4J-Logger-Instanz erzeugen, um das slf4jLogger zu initialisieren. Wir haben aber etwas eleganteres vor: Da LoggerInstanzen meist mit dem voll qualifizierten Klassennamen erzeugt werden, erstellen wir einen weiteren Trait, der jeder Klasse, in die er hinein gemixt wird, automatisch einen fertig initialisierten Logger zur Verfügung stellt: trait Logging { self => // (1) protected lazy val logger = new Logger { // (2) override protected val slf4jLogger = LoggerFactory getLogger self.getClass.getName // (3) } }
Bis auf die mit (1) kommentierte Zeile, die eine sogenannte Self Type Annotation darstellt, sollte dieser Code mit dem bisher gelernten recht einfach zu verstehen sein: Das Feld logger ist lazy, weil wir es vielleicht gar nicht brauchen. Es ist protected, weil es nur innerhalb der Klasse, in die es hinein gemixt wird, sichtbar sein soll. Und es wird durch eine anonyme Logger-Implementierung initialisiert. Was hat es aber mit dem self => auf sich? Um diese Frage zu beantworten, richten wir unseren Blick auf die Stelle, an der wir self verwenden: In der mit (3) kommentierten Zeile geht es darum, den Klassennamen zu ermitteln, mit deren Name der Logger initialisiert werden soll. Würden wir this verwenden, dann bekämen wir den Namen der anonymen Logger-Implementierung aus der mit (2) kommentierten Zeile. Das macht natürlich keinen Sinn, denn wir wollen ja den Namen der Klasse verwenden, in die der Logging-Trait hinein gemixt wird, also zum Beispiel JourneyPlanner bzw. dessen voll qualifizierte Variante. Genau um das zu erreichen, verwenden wir die Self Type Annotation in der mit (1) kommentierten Zeile. Damit definieren wir den Bezeichner self für die Instanz, in die der Trait hinein gemixt wird. Wir könnten hier übrigens jeden beliebigen Bezeichner verwenden. Weiterhin könnten wir auch noch einen Typ angeben, der bestimmt, dass dieser Trait nur in diesen Typ hinein gemixt werden darf. Aber das werden wir hier nicht verwenden, sodass wir dafür wieder einmal auf „Programming in Scala“16 verweisen. Nun haben wir das Rüstzeug, um effektiv zu loggen und Logger auch noch elegant und einfach einzubinden. Das wollen wir gleich einmal ausprobieren, und zwar bei der Klasse
16 Artima Press; Programming in Scala; Second Edition; Odersky, Venners, Spoon; Kapitel 29.4
122
Abschluss – Aktueller Stand JourneyPlanner. Da wäre es doch interessant zu wissen, mit wie vielen und welchen Trains diese initialisiert wird. Dazu mixen wir einfach den Logging-Trait hinein und nutzen dann innerhalb vom Primary Constructor die Gelegenheit zur Log-Ausgabe. class JourneyPlanner(trains: Set[Train]) extends Logging { require(trains != null, "trains must not be null!") logger.debug("Initialized with the following %s trains:\n%s". format(trains.size, trains)) ...
Damit erhalten wir das folgende Ergebnis in der REPL: scala> val (a, b) = (Station("A"), Station("B")) a: org.scalatrain.Station = Station(A) b: org.scalatrain.Station = Station(B) scala> val t1 = Train(Re("123"), List(Time(1) -> a, Time(2) -> b)) t1: ... = Train(Re(123),List((01:00,Station(A)), (02:00,Station(B)))) scala> val t2 = Train(Re("999"), List(Time(3) -> b, Time(4) -> a)) t2: ... = Train(Re(999),List((03:00,Station(B)), (04:00,Station(A)))) scala> val journeyPlanner = new JourneyPlanner(Set(t1, t2)) 19:43:46.180 [run-main] DEBUG org.scalatrain.JourneyPlanner Initialized with the following 2 trains: Set(Train(Re(123),List((01:00,Station(A)), (02:00,Station(B)))), Train(Re(999),List((03:00,Station(B)), (04:00,Station(A)))))
8.3
Abschluss – Aktueller Stand
Nach den Arbeiten in diesem Kapitel sieht unser Projekt aktuell folgendermaßen aus, wobei wir nur die Klassen darstellen, die sich verändert haben: package org.scalatrain class JourneyPlanner(trains: Set[Train]) extends Logging { require(trains != null, "trains must not be null!") logger.debug("Initialized with the following %s trains:\n%s". format(trains.size, trains)) val stations: Set[Station] = trains flatMap { _.stations } def trains(station: Station): Set[Train] = { require(station != null, "station must not be null!") trains filter { _.stations contains station } } def departures(station: Station): Set[(Time, Train)] = { require(station != null, "station must not be null!")
Durchstarten mit Scala
123
8 – Vererbung und Traits for { train SLF4JLogger, LoggerFactory } trait Logger { def error(message: => String) { if (slf4jLogger.isErrorEnabled) slf4jLogger.error(message) } def error(message: => String, t: Throwable) { if (slf4jLogger.isErrorEnabled) slf4jLogger.error(message, t) } def warn(message: => String) { if (slf4jLogger.isErrorEnabled) slf4jLogger.warn(message) } def warn(message: => String, t: Throwable) { if (slf4jLogger.isErrorEnabled) slf4jLogger.warn(message, t) } def info(message: => String) { if (slf4jLogger.isInfoEnabled) slf4jLogger.info(message) } def info(message: => String, t: Throwable) { if (slf4jLogger.isInfoEnabled) slf4jLogger.info(message, t) } def debug(message: => String) { if (slf4jLogger.isInfoEnabled) slf4jLogger.debug(message) } def debug(message: => String, t: Throwable) { if (slf4jLogger.isInfoEnabled) slf4jLogger.debug(message, t) } def trace(message: => String) { if (slf4jLogger.isInfoEnabled) slf4jLogger.trace(message) }
124
Abschluss – Aktueller Stand def trace(message: => String, t: Throwable) { if (slf4jLogger.isInfoEnabled) slf4jLogger.trace(message, t) } protected val slf4jLogger: SLF4JLogger } trait Logging { self => protected lazy val logger = new Logger { override protected val slf4jLogger = LoggerFactory getLogger self.getClass.getName } }
Listing 8.2: Logger.scala package org.scalatrain import scala.collection.immutable.Seq case class Train(info: TrainInfo, schedule: Seq[(Time, Station)]) { require(info != null, "info must not be null!") require(schedule != null, "schedule must not be null!") require(schedule.size >= 2, "schedule must have at least two stops!") val stations: Seq[Station] = schedule map { _._2 } } case class Station(name: String) { require(name != null, "name must not be null!") } sealed abstract class TrainInfo { def number: String } case class Ice(number: String, hasWifi: Boolean = false) extends TrainInfo case class Re(number: String) extends TrainInfo case class Brb(number: String) extends TrainInfo
Listing 8.3: Train.scala
Durchstarten mit Scala
125
8 – Vererbung und Traits package org.scalatrain object Time { def fromMinutes(minutes: Int): Time = { require(minutes >= 0, "minutes must not be negative!") new Time(minutes / 60, minutes % 60) } } case class Time(hours: Int = 0, minutes: Int = 0) extends Ordered[Time] { require(hours >= 0, "hours must not be negative!") require(hours < 24, "hours must be less than 24!") require(minutes >= 0, "minutes must not be negative!") require(minutes < 60, "minutes must be less than 60!") lazy val asMinutes: Int = minutes + 60 * hours def -(that: Time): Int = minus(that) def minus(that: Time): Int = { require(that != null, "that must not be null!") this.asMinutes - that.asMinutes } override val toString: String = "%02d:%02d".format(hours, minutes) override def compare(that: Time): Int = this - that }
Listing 8.4: Time.scala
126
9 9
Pattern Matching
In diesem Kapitel betrachten wir mit dem sogenannten Pattern Matching ein besonders feines Sprach-Feature, das es in dieser Form in Java leider gar nicht gibt, auch wenn es auf den ersten Blick von der Struktur her an die switch-Anweisung erinnert. Wir werden auch sehen, dass es besonders schön mit den bereits kennengelernten Case Classes harmoniert.
9.1
match-Ausdrücke
Beim Pattern Matching geht es um sogenannte match-Ausdrücke, die prinzipiell die folgende Struktur aufweisen: expression match { case pattern1 => result1 case pattern2 => result2 ... }
Der gesamte Ausdruck hat natürlich, wie fast alles in Scala, ein Ergebnis. Vor dem Schlüsselwort match steht ein Ausdruck, dessen Ergebnis gegen die verschiedenen Alternativen „gematched“ wird, die in dem folgenden Block stehen. Jede Alternative wird durch das Schlüsselwort case eingeleitet, gefolgt von einem Pattern, einem Pfeil „=>“ und schließlich einem Ausdruck als Ergebnis dieser Alternative. Die verschiedenen Alternativen werden von oben beginnend der Reihe nach „durchprobiert“, solange bis die erste passt. Wenn eine Alternative passt, dann wird deren Ergebnis zurückgeliefert und die Auswertung des match-Ausdrucks wird beendet, d.h. die weiteren Alternativen kommen nicht mehr zum Zug. Wenn jedoch gar keine passt, dann gibt es einen MatchError. Um das Ganz in der Praxis zu betrachten, schreiben wir eine Methode, die ein Argument vom Typ Any entgegen nimmt und dieses dann mittels Pattern Matching analysiert, um einen String zurückzugeben. Da wir wie gesagt auch Case Classes betrachten werden, wollen wir hier unsere bereits bewährte Ente in etwas vereinfachter Form wieder zum Einsatz bringen: case class Duck(name: String) def whoIs(any: Any): String = any match { case ...
Durchstarten mit Scala
127
9 – Pattern Matching Bevor wir hier weiter machen können, müssen wir erst einmal kennen lernen, wie ein Pattern im Detail aussieht. Ganz konkret gibt es eine Handvoll verschiedener Pattern-Arten, die wir uns jetzt anschauen.
9.2
Welche Pattern gibt es?
9.2.1
Wildcard Pattern
Das einfachste und vielleicht sogar wichtigste Pattern ist das sogenannte Wildcard Pattern, das schlicht auf alles passt: case _ => "Unknown creature"
Wir sehen hier wieder einmal den Unterstrich „_“ in Aktion, der in Scala in einigen verschiedenen Funktionen vorkommt. Hier steht er als Stellvertreter für ein beliebiges Objekt. Dieses Wildcard Pattern ist deswegen so wichtig, weil wir es als letztes für die letzte Alternative in einem match-Ausdruck verwenden können, um dadurch MatchErrors zu verhindern.
9.2.2
Constant Pattern
Ein weiteres einfaches Pattern ist das sogenannte Constant Pattern, bei dem wir gegen ein „festes“ Objekt vergleichen: case "Donald" => "A name for a stupid duck"
Wir verwenden hier ein String-Literal, könnten aber genauso gut auch ein Int-Literal, ein Singleton Object oder sogar einen val verwenden. Bei den beiden zuletzt genannten Möglichkeiten müssen wir eine etwas eigenwillige Konvention beachten: Der Bezeichner für das Singleton Object bzw. für den val muss mit einem großen Buchstaben beginnen. Nur dann wird das Constant Pattern angewandt, sonst das Variable Pattern.
9.2.3
Variable Pattern und Typed Pattern
Wenn wir einen klein geschriebenen Bezeichner als Pattern verwenden, dann passt dieser, wie das Wildcard Pattern, auf alle Objekte. Zusätzlich jedoch können wir den Bezeichner auf der rechten Seite vom Pfeil verwenden, also im Rahmen des Ergebnis-Ausdrucks der Alternative. Wir „capturen“ damit sozusagen eine Variable, daher der Name Variable Pattern. Wenn wir den Bezeichner noch mit einer Type-Angabe versehen, dann machen
128
Welche Pattern gibt es? wir das Pattern zu einem sogenannten Typed Pattern und dann passt die Alternative nur noch auf Objekte vom entsprechenden Typ: case name: String => "Something named %s" format name
9.2.4
Tuple Pattern
Mit dem Tuple Pattern können wir auch Tupel auseinander nehmen: case (name, _) => "Two things, the first named %s" format name
Hier geben wir vor, dass wir uns für ein Tupel aus zwei Werten interessieren und lassen den ersten an eine Variable binden, wohingegen uns der zweite nicht interessiert.
9.2.5
Constructor Pattern
Doch nicht nur Tupel können „dekonstruiert“ werden, sondern auch Case Classes. Dazu schreiben wir das Pattern einfach mit dem Primary Constructor der Case Class, daher der Name Constructor Pattern, wobei wir die einzelnen Argumente an Variablen binden lassen oder mit dem Unterstrich auslassen können. case Duck(name) => "A duck named %s" format name
Das wirklich Mächtige an diesem Pattern ist, dass es auch für verschachtelte Case Classes oder in Kombination mit anderen Patterns funktioniert. Mit anderen Worten findet ein „Deep Matching“ statt. Übrigens können wir diese „Dekonstruktion“ auch wieder außerhalb von Patterns verwenden. Im folgenden Beispiel sehen wir sehr schön, wie die Duck einmal verwendet wird, um eine Instanz zu erzeugen, und einmal, um eine Instanz in ihre „Bestandteile“ zu zerlegen: scala> val donald = Duck("Donald") donald: Duck = Duck(Donald) scala> val Duck(name) = donald name: String = Donald
Jetzt wissen wir bereits genug, um Pattern Matching in unser Fallbeispiel einzubauen. Die Aufgabe, die wir damit in Angriff nehmen wollen, lautet, die toString-Methode von Train zu überschreiben. Und zwar soll ein Zug eine typische Bezeichnung wie zum Beispiel „ICE 720“ haben. Hier die entsprechende Test-Spezifikation, mit der wir TrainSpec erweitern:
Durchstarten mit Scala
129
9 – Pattern Matching "Calling toString" should { "return the correct result" in { val schedule = List(Time(0, 0) -> Station("0"), Time(1, 1) -> Station("1")) Train(Ice("123", true), schedule).toString mustEqual "ICE 123 (WIFI)" Train(Ice("123"), schedule).toString mustEqual "ICE 123" Train(Re("123"), schedule).toString mustEqual "RE 123" Train(Brb("123"), schedule).toString mustEqual "BRB 123" } }
An dieser Stelle weichen wir ausnahmsweise einmalig vom Prinzip ab, dass wir für das Fallbeispiel grundsätzlich immer die „beste“ Designvariante anwenden. Wie wir gleich sehen werden, lassen wir einen Train etwas tun, was vermutlich besser bei den jeweiligen Sub-Klassen von TrainInfo aufgehoben wäre. Aber aus didaktischen Gründen – eben um Pattern Matching einzusetzen – begehen wir diesen Stilbruch. Wir fügen also die folgende Methode zur Train-Klasse hinzu: override def toString = info match { case Ice(number, true) => "ICE %s (WIFI)" format number case Ice(number, _) => "ICE %s" format number case Re(number) => "RE %s" format number case Brb(number) => "BRB %s" format number }
Wir matchen also das Feld Train.info vom Typ TrainInfo gegen alle möglichen Sub-Klassen. Da TrainInfo sealed ist, wissen wir auch, dass wir selbst die Sache in der Hand haben und es keine weiteren Sub-Klassen geben kann. Ebenso weiß das der Scala-Compiler, sodass wir keine Warnung über ein unvollständiges Matching erhalten. Das können wir provozieren, wenn wir zum Beispiel die letzte Zeile auskommentieren: [warn] .../Train.scala:28: match is not exhaustive! [warn] missing combination Brb
Beim Matching gegen die Case Class Ice (Constructor Pattern) legen wir zuerst den hasWifi-Parameter auf true fest (Constant Pattern), wohingegen wir den number-Parameter als Variable binden (Variable Pattern). Anschließend ignorieren wir den hasWifi-Parameter, weil er dann nur noch false sein kann. Die beiden anderen Case Classes behandeln wir analog in Bezug auf den number-Parameter.
130
Welche Pattern gibt es?
9.2.6
Sequence Pattern
Doch hier ist noch nicht Schluss, denn es gibt mit dem Sequence Pattern noch ein weiteres sehr mächtiges Pattern. Wir können beliebige Seqs und Arrays in Patterns verwenden und diese dekonstruieren. Natürlich können wir auch hier wieder andere Pattern einbetten. case Seq(Duck(name), _*) => "Some things, the first a duck named %s" format name
Hier vergleichen wir mit einer Seq, deren erstes Element eine Ente sein muss und die anschließend beliebig viele Elemente beliebigen Typs haben darf. Etwas Ähnliches wollen wir nun auch verwenden, um unser Fallbeispiel weiter auszubauen. Wir wollen unsere JourneyPlanner-Klasse um die Methode isShortTrip erweitern, die einen Start- und einen Ziel-Bahnhof entgegennimmt und genau dann true zurückgibt, wenn wir mit einem Zug nur eine oder zwei Stationen fahren. Zunächst erweitern wir JourneyPlannerSpec um das folgende SUS: "Calling isShortTrip" should { val journeyPlanner = new JourneyPlanner(Set(train1, train2)) "throw an IllegalArgumentException for a null from" in { journeyPlanner.isShortTrip(null, stationA) must throwA[IllegalArgumentException] } "throw an IllegalArgumentException for a null to" in { journeyPlanner.isShortTrip(stationD, null) must throwA[IllegalArgumentException] } "return the correct result" in { journeyPlanner.isShortTrip(stationA, stationB) mustEqual true journeyPlanner.isShortTrip(stationA, stationC) mustEqual true journeyPlanner.isShortTrip(stationC, stationA) mustEqual false journeyPlanner.isShortTrip(Station("X"), stationA) mustEqual false journeyPlanner.isShortTrip(stationA, Station("X")) mustEqual false } }
Dann machen wir uns an die Implementierung. Wir müssen also ermitteln, ob mindestens für einen Zug der Start- und Ziel-Bahnhof im Fahrplan enthalten ist, und zwar entweder benachbart oder mit höchstens einem anderen Bahnhof dazwischen. Dazu verwenden wir die Collection-Methode exists, die ein Prädikat erwartet, also eine Funktion, welche ein Collection-Element entgegennimmt und ein Ergebnis vom Typ Boolean zurückliefert. Sobald für ein Element der Collection das Prädikat true ergibt, liefert die exists-Methode true zurück. In unserem Fall rufen wir exists auf JourneyPlanner.trains auf, sodass das Prädikat einen Train erhält.
Durchstarten mit Scala
131
9 – Pattern Matching Wie sieht nun dessen Logik aus? Eigentlich ganz einfach: Wir haben ja für einen Zug bereits die Abfolge aller Bahnhöfe im Feld Train.stations. Nun müssen wir nur noch mit Pattern Matching ermitteln, ob diese Abfolge den Start- und Ziel-Bahnhof wir gefordert enthält. Dazu verwenden wir erst einmal die Collection-Methode dropWhile, um alle Stations „wegzuwerfen“, die nicht mit dem Start-Bahnhof übereinstimmen. Das Ergebnis matchen wir dann mit zwei Sequence Patterns, welche die Bedingungen für die Kurzstreckenfahrt wiederspiegeln und true zurückliefern und natürlich mit einem abschließenden Wildcard Pattern, das für alle anderen Fälle false ergibt. trains exists { _.stations dropWhile { from != } match { case Seq(`from`, _, `to`, _*) => true case Seq(`from`, `to`, _*) => true case _ => false } }
Eine Anmerkung zum Funktionsliteral, das wir der dropWhile-Methode übergeben. Wie wir später in Kapitel 11 über Currying noch kurz ansprechen werden, ist from != eine sogenannte Partially Applied Function und entspricht der etwas längeren alternativen Notation from != _ bzw. station => from != station.
9.3
Pattern Guards und Variable Binding
Durch die Kombination der diversen Patterns können wir bereits sehr komplexe Bedingungen definieren, wann eine Alternative passt und wann nicht. Zusätzlich können wir auch noch mit sogenannten Guards weitere Bedingungen formulieren. Dazu schreiben wir die Bedingung einfach mit if hinter das Pattern. Dabei können wir auf Variablen zugreifen, die wir im Pattern definiert haben: case class Person(name: String, age: Int) def name(person: Person) = person match { case Person(name, age) if (age >= 18) => name case Person(name, _) => name + " (Child)" }
Eine Alternative zu Guards sind verschachtelte Patterns, die an Variablen gebunden werden. Dazu schreiben wir nach dem Variablennamen ein „@“ und danach das Pattern. Im folgenden Beispiel möchten wir eine passende Phone-Instanz an die Variable phone binden. Passend bedeutet in diesem Fall, dass nur solche Phone-Instanzen passen sollen, die eine deutsche Vorwahl haben:
132
Pattern Matching außerhalb von match-Ausdrücken case class Phone(countryCode: String, number: String) case class Person(name: String, phone: Phone) def printGermanPhone(person: Person) { person match { case Person(_, phone @ Phone("+49", _)) => println(phone) case _ => println("No German phone number!") } }
9.4
Pattern Matching außerhalb von matchAusdrücken
Wir können Pattern Matching nicht nur innerhalb von match-Ausdrücken verwenden, sondern auch zur Definition von Variablen. Im folgenden Beispiel zerlegen wir ein Tuple2 in seine Bestandteile und weisen diese Variablen zu: scala> val timeAndStation = Time(1) -> Station("A") timeAndStation: (...Time, ...Station) = (01:00,Station(A)) scala> val (time, station) = timeAndStation time: org.scalatrain.Time = 01:00 station: org.scalatrain.Station = Station(A) scala> val (time, _) = timeAndStation time: org.scalatrain.Time = 01:00
Wie wir sehen, haben wir auch hier die Möglichkeit, mit dem Unterstrich bestimmte Werte auszulassen und dementsprechend keine Variable zu definieren. Diese Form des Pattern Matching können wir sogar innerhalb von For Expressions verwenden. Dafür bietet sich in unserem Beispiel die Klasse JourneyPlanner an, wo wir einen Generator verwenden, der ein Tuple2 zurückgibt: for { train false } } } }
Listing 9.1: JourneyPlanner.scala
134
Projekt-Code: aktueller Stand package org.scalatrain import scala.collection.immutable.Seq case class Train(info: TrainInfo, schedule: Seq[(Time, Station)]) { require(info != null, "info must not be null!") require(schedule != null, "schedule must not be null!") require(schedule.size >= 2, "schedule must have at least two stops!") val stations: Seq[Station] = schedule map { _._2 } override def toString = info match { case Ice(number, true) => "ICE %s (WIFI)" format number case Ice(number, _) => "ICE %s" format number case Re(number) => "RE %s" format number case Brb(number) => "BRB %s" format number } } case class Station(name: String) { require(name != null, "name must not be null!") } sealed abstract class TrainInfo { def number: String } case class Ice(number: String, hasWifi: Boolean = false) extends TrainInfo case class Re(number: String) extends TrainInfo case class Brb(number: String) extends TrainInfo
Listing 9.2: Train.scala
Durchstarten mit Scala
135
10 10
Scala und XML
Scala bietet eine besondere Unterstützung für XML, die wir in diesem Kapitel ganz kurz vorstellen werden. Warum gerade XML? Und warum nicht zum Beispiel auch JSON? Warum überhaupt eine besondere Behandlung für ein spezielles Datenformat? Aus „puristischer“ Sichtweise durchaus berechtigte Fragen. Hier zeigt sich, dass Scala eine pragmatische Sprache ist. XML hatte zumindest zum Zeitpunkt der Entstehung von Scala eine besondere Bedeutung bzw. wurde viel verwendet, sodass die XML-Unterstützung eben in die Sprache aufgenommen wurde. Wir wollen in diese Diskussion gar nicht erst einsteigen, sondern zeigen im Folgenden, was es mit der XML-Unterstützung auf sich hat. Dabei müssen wir zwischen zwei wesentlichen Aspekten unterscheiden: XML-Literale sind direkt in die Sprache eingebaut, wohingegen die XML-Verarbeitung mit einer Library umgesetzt ist.
10.1 XML-Literale
Scala kennt neben den üblichen Literalen, zum Beispiel für Zahlen und Strings, auch solche für XML. Konkret bedeutet das, dass wir „einfach so“ wohlgeformtes XML innerhalb von Scala-Code schreiben können: scala> res0: scala.xml.Elem =
Umgekehrt macht uns der Scala-Compiler sofort mit einer Fehlermeldung darauf aufmerksam, wenn wir nicht wohlgeformtes, also fehlerhaftes XML schreiben: scala> :1: error: in XML literal: expected closing tag of b :1: error: start tag was here: b>
Selbstverständlich gilt auch für XML die Regel, dass in Scala alles ein Objekt ist. Wie wir anhand des obigen korrekten Beispiel sehen können, wird das wohlgeformte XML-Literal zu einem Objekt vom Typ scala.xml.Elem. Wir werden in Kapitel 10.2 gleich noch im Detail auf die verschiedenen Typen und Möglichkeiten der XML Library eingehen.
Durchstarten mit Scala
137
10 – Scala und XML Natürlich wären die XML-Literale nicht besonders nützlich, wenn wir diese nur als Konstanten verwenden könnten. In der Praxis ist es oft erforderlich, gewisse Teile einer ansonsten statischen XML-Struktur dynamisch zu halten. Zum Beispiel fußt das Template-System des Lift-Webframework1 genau auf diesem Konzept: Die Templates sind HTML-5- oder XHTML-Seiten, die neben statischem Content „Anker“ für dynamischen Inhalt enthalten. Scala bietet uns daher die Möglichkeit, Scala-Code in XML-Literale einzubetten. Dieser wird zur Laufzeit ausgewertet und das Ergebnis wird in die endgültige XML-Struktur eingefügt. So können wir zum Beispiel ein XML-Element mit zufälligem Inhalt erzeugen: scala> def randomXml = { Random.nextInt } randomXml: scala.xml.Elem scala> randomXml res0: scala.xml.Elem = 578422901 scala> randomXml res1: scala.xml.Elem = 796032224
Wie wir sehen, wir der Scala-Code mittels geschweifter Klammern in das XML-Literal eingebettet. Hier bedienen wir uns übrigens des Singleton Objects scala.util.Random, mit dem wir ganz einfach Zufallswerte verschiedenen Typs erzeugen können. Es spielt keine Rolle, zu welchem Typ der eingebettete Scala-Code letztendlich evaluiert: Wenn es kein scala.xml.Node ist, dann wird der Ausdruck zunächst als String interpretiert und dann als Text-Knoten vom Typ scala.xml.Text eingefügt. Ähnlich verhält es sich mit Attributen, wenngleich wir hier explizit einen passenden Typ zurückgeben müssen, zum Beispiel einen String: scala> def randomXml = randomXml: scala.xml.Elem scala> randomXml res0: scala.xml.Elem =
10.2 XML-Verarbeitung Bevor wir betrachten, wie wir „XML-Objekte“ verarbeiten können, müssen wir zunächst die wichtigsten XML-Typen kennenlernen, die sich allesamt im Package scala. xml befinden. Wir gehen hier nicht allzu detailliert auf die zum Teil etwas eigenwillige API ein, sondern betrachten nur etwas oberflächlich die allerwichtigsten Typen. Die Basis bilden die beiden abstrakten Klassen Node und NodeSeq, die in einer interessanten Vererbungsbeziehung stehen: NodeSeq erweitert Seq[Node] und Node erweitert NodeSeq, d.h. wir haben es hier mit einer zirkulären Typdefinition zu tun. Bevor wir davon einen
1
138
http://www.liftweb.net/
XML-Verarbeitung Knoten in unsere Gedanken bekommen, gehen wir lieber zu zwei einfacheren Sub-Klassen weiter, und zwar zu Elem und Text. Elem repräsentiert einen Element-Knoten, also ein XML-Element, das unter anderem optionale Attribute und optionalen Sub-Knoten besitzt. Text steht für einen Text-Knoten, also für den „typischen Inhalt“ eines XMLElementes. Da alle XML-Typen durch die Vererbung letztendlich Seqs sind, können wir alle Seq-Methoden zur Verarbeitung nutzen: scala> ++ res0: scala.xml.NodeSeq = NodeSeq(, ) scala> .child.head res1: scala.xml.Node =
Hier haben wir die Methode child verwendet, die für ein Elem die Sub-Knoten zurückgibt. Neben diesen „normalen“ Collection-Methoden bietet die Scala XML Library XPath-artige Möglichkeiten, um XML-Objekte zu analysieren: scala> val xml = | b1b2 xml: scala.xml.Elem = ... scala> xml \ "a" res0: scala.xml.NodeSeq = NodeSeq(b1, b2) scala> xml \ "b" res1: scala.xml.NodeSeq = NodeSeq() scala> xml \\ "b" res2: scala.xml.NodeSeq = NodeSeq(b1, b2)
Mit dem „\“-Operator können wir die direkten Sub-Elemente eines XML-Knotens mit einem bestimmten Namen extrahieren und mit dem „\\“-Operator erhalten wir alle entsprechend benannten Sub-Elemente, egal wie tief verschachtelt diese sind. Falls wir nicht Elemente, sondern Attribute extrahieren möchten, dann müssen wir nur das „@“-Zeichen vor den Namen schreiben: scala> xml \ "@val" res0: scala.xml.NodeSeq = NodeSeq() scala> xml \\ "@val" res1: scala.xml.NodeSeq = NodeSeq(a1, a2)
Der Vollständigkeit halber erwähnen wir hier noch, dass das Singleton Object scala.xml. XML diverse Methoden zum Laden uns Speichern von XML bietet. Aber wir wenden uns jetzt wieder unserem Fallbeispiel zu und reichern dieses um (punktuelle) XML-Unterstützung an.
Durchstarten mit Scala
139
10 – Scala und XML
10.3 XML für ScalaTrain Wo können wir XML für ScalaTrain einse, dannMit ein wenig Phantasie können wir uns vorstellen, dass die Zugfahrpläne als XML vorliegen und eingelesen werden müssen, oder dass wir bestimmte Ergebnisse als XML ausgeben müssen. Daher wollen wir im Folgenden eine Serialisierung von und nach XML umsetzen, wobei wir uns mit Time auf eine einzige Klasse beschränken, bevor wir das in Kapitel 11 verallgemeinern. Als erstes widmen wir unsere Aufmerksamkeit der Serialisierung nach XML. Diese Aufgabe werden wir umsetzen, indem wir die Time-Klasse um eine zusätzlichen Methode toXml erweitern. Das ist zwar schlechtes Design, denn Time hat eigentlich nichts mit Serialisierung zu tun, sodass diese weitere Methode das API „verschmutzt“. Aber wir werden das in Kapitel 11 wieder korrigieren, sodass wir das hier verschmerzen können. Dann gilt es, die DeSerialisierung von XML zu realisieren. Das setzen wir mit einer neuen Methode fromXML beim Companion Object von Time um. Als erstes schreiben wir die Methodensignaturen und implementieren nur so weit mit Dummy-Werten, dass wir complieren können: object Time { ... def fromXml(xml: NodeSeq): Time = Time() // Dummy } case class Time(hours: Int = 0, minutes: Int = 0) ... { ... def toXml: NodeSeq = NodeSeq.Empty // Dummy }
Dann machen wir uns an die Test-Spezifikation. Dazu erweitern wir TimeSpec um die folgenden SUS: "Calling fromXml" should { "throw an IllegalArgumentException for a null xml" in { Time fromXml null must throwA[IllegalArgumentException] } "return a correctly initialized Time instance for a valid xml" in { Time fromXml mustEqual Time(1, 2) } } "Calling toXml" should { "return a correct XML representation" in { Time(1, 2).toXml mustEqual } "compose to identy with Time.fromXml" in { Time fromXml Time(1, 2).toXml mustEqual Time(1, 2) } }
140
Abschluss – Aktueller Stand Wegen der besseren Lesbarkeit und Einheitlichkeit mit toString geben wir die Werte für Stunden und Minuten mit zwei Stellen aus. Nun geht es an die Umsetzung: def fromXml(xml: NodeSeq): Time = { require(xml != null, "xml must not be null!") Time((xml \ "@hours").text.toInt, (xml \ "@minutes").text.toInt) } def toXml: NodeSeq =
Bei der Implementierung der fromXml-Methode verwenden wir zweimal den „\“-Operator, um die Attribute für Stunden und Minuten zu extrahieren. Der Rückgabewert ist vom Typ Node, weil wir den „\“-Operator auch auf Elemente „loslassen“ können, sodass wir mit der text-Methode zunächst die String-Repräsentation abfragen müssen, die wir dann mittels der toInt-Methode nach Int umwandeln. Bei der Implementierung der Methode toXml verwenden wir die geschweiften Klammern, um dem statischen XML-Gerüst, das wir durch die XML-Literale aufbauen, dynamisch die Werte für die Attribute für Stunden und Minuten zuzuweisen. Dabei bemühen wir wieder die format-Methode, damit die Werte auf jeden Fall zwei Stellen haben, ggf. mit führenden Nullen.
10.4 Abschluss – Aktueller Stand Nach den Arbeiten in diesem Kapitel sieht unser Projekt aktuell folgendermaßen aus, wobei wir nur die Klassen darstellen, die sich verändert haben: package org.scalatrain import scala.xml.NodeSeq object Time { def fromMinutes(minutes: Int): Time = { require(minutes >= 0, "minutes must not be negative!") new Time(minutes / 60, minutes % 60) } def fromXml(xml: NodeSeq): Time = { require(xml != null, "xml must not be null!") Time((xml \ "@hours").text.toInt, (xml \ "@minutes").text.toInt) } } case class Time(hours: Int = 0, minutes: Int = 0) extends Ordered[Time] { require(hours >= 0, "hours must not be negative!") require(hours < 24, "hours must be less than 24!")
Durchstarten mit Scala
141
10 – Scala und XML require(minutes >= 0, "minutes must not be negative!") require(minutes < 60, "minutes must be less than 60!") lazy val asMinutes: Int = minutes + 60 * hours def -(that: Time): Int = minus(that) def minus(that: Time): Int = { require(that != null, "that must not be null!") this.asMinutes - that.asMinutes } def toXml: NodeSeq = override val toString: String = "%02d:%02d".format(hours, minutes) override def compare(that: Time): Int = this - that }
Listing 10.1: Time.scala
142
11 11
Implicits
Die bisherigen Kapitel haben uns die wesentlichen Grundlagen von Scala vermittelt, sozusagen das Rüstzeug. Mit diesem Wissen können wir uns nun an fortgeschrittene Themen wagen. In diesem Kapitel werden wir Implicit Conversions und Parameters sowie Type Classes als Anwendung dieser beiden sehr mächtigen Sprach-Features kennenlernen. Ein Wort der Warnung ist angebracht: Mit Implicits kann man einiges anstellen, sodass wir uns gründlich überlegen sollten, wann und wo wir diese einsetzen. Ansonsten besteht die Gefahr, dass wir mehr Verwirrung als Nutzen stiften. Aber wenn mit Bedacht eingesetzt, sind Implicits das Mittel schlechthin, um Scala zu skalieren, d.h. die Sprache den individuellen Bedürfnissen anzupassen.
11.1 Implicit Conversions
Wir haben in vergangenen Kapiteln schon öfter darauf hingewiesen, dass gewisse „Dinge“ nur aufgrund von Implicit Conversions funktionieren. Zum Beispiel können wir beliebige Tuple2-Instanzen mit Hilfe des Operators -> erzeugen, ohne dass dieser auf jedem Typ definiert ist: scala> 1 -> 'a' res0: (Int, Char) = (1,a)
Wenn wir in die ScalaDoc-Dokumentation zu Int schauen, dann werden wir feststellen, dass dort kein Operator -> definiert ist. Ebenso können wir in der SBT-Projektdefinition die Operatoren % und %% auf Strings anwenden, ohne dass diese Methoden in der Klasse String existieren: val mockito = "org.mockito" % "mockito-all" % "1.8.5" % "test" val specs = "org.scala-tools.testing" %% "specs" % "1.6.7" % "test" withSources
Als letztes Beispiel seien unsere Test-Spezifikationen aufgeführt, wo wir wiederum auf Strings die Methoden should und in aufrufen, die es dort auch nicht gibt: class TrainSpec extends Specification { "Creating a Train" should { "throw an IllegalArgumentException for a null kind" in {
Durchstarten mit Scala
143
11 – Implicits All diesen Beispielen ist gemeinsam, dass wir versuchen, mit einem Objekt etwas zu tun, was es eigentlich gar nicht kann. Damit das trotzdem funktioniert, kommen sogenannte Implicit Conversions des Receivers ins Spiel. Es gibt aber noch eine zweite Art von Implicit Conversions: Implicit Conversions zum Expected Type. Diese sind ein wenig leichter zu erklären, sodass wir mit diesen beginnen wollen.
11.1.1 Implicit Conversions zum Expected Type Wir stellen uns vor, dass wir an einer bestimmten Stelle im Code einen bestimmten Typ erwarten, zum Beispiel in Form eines Arguments beim Aufruf einer Methode. Als Beispiel können wir die Klasse JourneyPlanner heranziehen, die gleich mehrere Methoden definiert, die Parameter vom Typ Station haben: def trains(station: Station): Set[Train] = ... def departures(station: Station): Set[(Time, Train)] = ... def isShortTrip(from: Station, to: Station): Boolean = ...
Da Scala eine statisch typisierte Sprache ist, müssen wir natürlich beim Aufruf einer solchen Methode eine Station als Argument übergeben. Wenn wir nun ein wenig weiter denken und uns vorstellen, dass wir unseren JourneyPlanner vielleicht innerhalb einer WebApplikation einsetzen wollen, dann wird rasch klar, dass wir vermutlich oft mit Strings umgehen müssen, die Bahnhöfe repräsentieren. Das ist kein großes Problem, denn Station ist eine Case Class und hat einen Konstruktor, der einen String als Namen des Bahnhofs entgegen nimmt. Aber es wäre dennoch schön, wenn wir auf diesen zusätzlichen Schritt, nämlich den String in eine Station einzupacken, verzichten können, und obige Methoden direkt mit einem String aufrufen könnten: scala> val planner = new JourneyPlanner(Set.empty) 09:26:55.675 [run-main] DEBUG org.scalatrain.JourneyPlanner ... planner: org.scalatrain.JourneyPlanner = ... scala> planner trains "Stuttgart" :10: error: type mismatch; found : java.lang.String("Stuttgart") required: org.scalatrain.Station planner trains "Stuttgart"
Wie schon gesagt, kann das nicht funktionieren, sodass wir die entsprechende Fehlermeldung des Scala-Compilers erhalten. Allerdings gibt dieser nicht so schnell auf, wenn er einen anderen Typen als den erwarteten entdeckt: Bevor der Scala-Compiler tatsächlich einen Fehler meldet, sucht er sozusagen noch nach dem Hammer, der das passend macht, was noch nicht passt. So ein Hammer ist nichts anderes als eine Methode, die den vorliegenden „falschen“ Ausgangstyp entgegennimmt und den erwarteten „korrekten“ Zieltyp zurückgibt.
144
Implicit Conversions Damit das tatsächlich funktioniert, muss diese Methode jedoch mit dem Schlüsselwort implicit gekennzeichnet werden und im aktuellen Scope zur Verfügung stehen. Dadurch wird sehr genau gesteuert, wie bzw. wann Implicit Conversions ins Spiel kommen. Oder anders gesagt, wird verhindert, dass „irgendetwas“ passiert, bloß weil „irgendwo“ im Code eine Methode mit passender Signatur existiert. Um eine Implicit Conversion in den aktuellen Scope zu bringen, gibt es verschiedene Möglichkeiten:
•• Single Identifier •• Companion Object des Ausgangstyps •• Companion Object des Zieltyps Wir können jederzeit eine Implicit Conversion „aktivieren“, indem wir eine passende implicit-Methode als Single Identifier im aktuellen Scope zur Verfügung stellen. Dabei bedeutet Single Identifier, dass die Methode direkt sichtbar ist und nicht zum Beispiel über den Umweg als Member eines sichtbaren Objektes. Um obiges Beispiel zum Laufen zu bringen, könnten wir also das folgende tun: scala> implicit def stringToStation(s: String): Station = Station(s) stringToStation: (s: String)org.scalatrain.Station scala> planner trains "Stuttgart" res1: Set[org.scalatrain.Train] = Set()
Wir haben hier die Methode stringToStation direkt in den Scope des folgenden Aufrufs von JourneyPlanner.trains gebracht. Da wir die Methode mit implicit gekennzeichnet haben und die Signatur passt, weil sie einen String in eine Station umwandelt, zieht der Scala-Compiler diese heran und macht damit passend, was vorher nicht passte. Der Name der Methode spielt dabei keine Rolle und sollte lediglich so gewählt werden, dass sich der Zweck der Implicit Conversion direkt erschließt. Außerhalb der REPL stehen uns mehrere mögliche Orte zur Verfügung, wo wir eine solche implicit-Methode definieren könnten. Eine wäre in einem Trait, den wir überall dort hinein mixen, wo wir die Implicit Conversion benötigen. So funktioniert das zum Beispiel bei specs, wobei es sich bei Specification um eine abstrakte Klasse handelt. Eine andere wäre in einem Singleton Object, dessen Member wir entweder alle importieren oder zumindest eben die benötigte Implicit Conversion. Allerdings gibt es noch einen wesentlich eleganteren Weg, bei dem wir uns gar nicht explizit darum kümmern müssen, die Implicit Conversion in den Scope zu bringen. Wenn der Scala-Compiler keine Implicit Conversion als Single Identifier finden kann, dann gibt er immer noch nicht auf, sondern sucht auch noch bei den Companion Objects des Ausgangstyps und des Zieltyps. Sofern wir einen von diesen selbst unter Kontrolle haben, und das ist zum Glück in der Praxis häufig der Fall, dann sollten wir diesen Weg beschreiten und die Implicit Conversion dort platzieren. In unserem Beispiel ist der Ausgangstyp ein String, an dessen Companion Object wir natürlich nicht heran kommen. Aber der
Durchstarten mit Scala
145
11 – Implicits Zieltyp ist ja unsere eigene Station-Klasse. Daher ergänzen wir nun die Datei Train.scala, in der sich ja auch die Case Class Station befindet, um den folgenden Code: object Station { implicit def stringToStation(s: String): Station = Station(s) }
Wenn wir die REPL nun neu starten, dann können wir obiges Beispiel einfach so laufen lassen, ohne irgendeine Implicit Conversion explizit in den Scope zu bringen: scala> val planner = new JourneyPlanner(Set.empty) 10:15:23.108 [run-main] DEBUG org.scalatrain.JourneyPlanner ... planner: org.scalatrain.JourneyPlanner = ... scala> planner trains "Stuttgart" res0: Set[org.scalatrain.Train] = Set()
11.1.2 Implicit Conversions des Receivers Nun kehren wir zurück zu den Beispielen aus der Einleitung dieses Kapitels. Dort haben wir stets Methoden aufgerufen, die es auf den Receivern, also den Objekten, auf denen sie aufgerufen worden sind, gar nicht gibt. In diesen Fällen muss der Scala-Compiler nach einer Implicit Conversion suchen, die den Receiver in einen Typ umwandelt, der die aufgerufenen Methoden zur Verfügung stellt. Wir wollen das anhand unserer TimeKlasse nachvollziehen: Wäre es nicht schön, wenn wir statt Time(10, 30) - Time(8, 0) einfach "10:30" - "08:00" schreiben könnten? Zugegeben, wir werden das wohl in der Praxis selten brauchen, aber schön wäre es dennoch! Natürlich hat ein String keinen „-“-Operator, sodass wir eine Implicit Conversion von String zu Time definieren müssen. Wenn wir diese erst einmal haben, dann ist auch der zweite String, der nach dem „-“-Operator kommt, kein Problem mehr, denn er wird dann mittels Implicit Conversion zum Expected Type gemacht.
Einschub: Regular Expressions und Pattern Matching Um aus einem String wie zum Beispiel "10:30" eine Time-Instanz zu machen, müssen wir den String analysieren und die Werte für die Stunden und Minuten extrahieren. Dazu eignen sich Regular Expressions1 ganz hervorragend. Scala nutzt die Regular Expressions von Java2 und setzt einige nette Features oben drauf. So können wir zum Beispiel jeden String einfach zu einer Instanz von scala.util.matching.Regex machen, die im Prinzip ein kompiliertes java.util.regex.Pattern kapselt, indem wir die Methode r (Ja, diese Methode hat nur einen Buchstaben!) aufrufen: 1
http://en.wikipedia.org/wiki/Regular_expression
2
http://download.oracle.com/javase/6/docs/api/
146
Implicit Conversions scala> val regex = ",".r regex: scala.util.matching.Regex = , scala> regex split "a,b,c" res0: Array[String] = Array(a, b, c)
Auf einer Regex-Instanz können wir nicht nur, wie hier exemplarisch gezeigt, die splitMethode aufrufen, sondern etliche weitere wie zum Beispiel findFirstIn und replaceAllIn. Wir interessieren uns hier jedoch für ein weiteres Feature, und zwar die Tatsache, dass wir Regex-Instanzen auch zum Pattern Matching verwenden können. Dazu müssen wir nur im Pattern mit Hilfe runder Klammern sogenannte Capturing Groups definieren, die wir dann beim Pattern Matching als Variablen binden können. Das folgende Beispiel enthält eine Capturing Group, die ein einzelnes beliebiges Zeichen bindet: scala> val regex = "#(.)#".r regex: scala.util.matching.Regex = #(.)# scala> val regex(s) = "#1#" s: String = 1 scala> val regex(s) = "#11#" scala.MatchError: #11#
Wie wir sehen, erhalten wir einen MatchError, wenn der zu matchende String nicht dem Pattern entspricht. Für unser Fallbeispiel benötigen wir ein Pattern, das zunächst zwei Ziffern für die Stunden erwartet, dann einen Doppelpunkt und danach wieder zwei Ziffern für die Minuten. Stunden und Minuten müssen natürlich jeweils eine Capturing Group darstellen, sodass wir zum folgenden Pattern gelangen: val timePattern = """(\d{2})\:(\d{2})""".r
Die dreifachen Anführungszeichen bei der Angabe eines String-Literals bewirken, dass alle Zeichen so interpretiert werden, wie sie innerhalb des Literals stehen, sodass wir Sonderzeichen nicht mit dem Backslash „\“ versehen müssen. Da innerhalb von Regular Expressions der Backslash zum Escapen verwendet wird, bietet sich diese Notation an, um nicht ständig „\\“ schreiben zu müssen. Ohne hier zu tief auf das gewählte Pattern eingehen zu wollen bedeutet „\d“ eine Ziffer, die geschweiften Klammern definieren die Kardinalität, d.h. wie oft ein Zeichen erwartet wird, und der Doppelpunkt muss escaped werden, weil er selbst ein Sonderzeichen innerhalb von Regular Expressions darstellt. Zum Abschluss dieses Einschubs wollen wir noch unser timePattern in Aktion sehen: scala> val timePattern(h, m) = "01:23" h: String = 01 m: String = 23 scala> val timePattern(h, m) = "1:23" scala.MatchError: 1:23
Durchstarten mit Scala
147
11 – Implicits
Fortsetzung: Implicit Conversions des Receivers Mit diesem neuen Wissen im Rucksack können wir uns an die Arbeit machen und die Implicit Conversion von String nach Time implementieren. Da wir inzwischen wissen, dass der Scala-Compiler bei den Companion Object von „betroffenen“ Typen nach Implicit Conversions sucht, fügen wir diese zum Companion Object von Time hinzu. Zunächst jedoch lassen wir die Implementierung noch offen, da wir natürlich zuerst die Tests schreiben wollen: implicit def stringToTime(s: String): Time = null
Nun erweitern wir erst einmal die Test-Spezifikation, indem wir die folgenden SUS zu TimeSpec hinzufügen: "Calling stringToTime" should { "throw an IllegalArgumentException for a null String" in { Time stringToTime null must throwA[IllegalArgumentException] } "throw an IllegalArgumentException for a String not matching the time pattern" in { Time stringToTime "abc" must throwA[IllegalArgumentException] Time stringToTime "25:00" must throwA[IllegalArgumentException] } "return the correct results" in { Time stringToTime "00:00" mustEqual Time() Time stringToTime "00:01" mustEqual Time(minutes = 1) Time stringToTime "22:22" mustEqual Time(22, 22) } } "A String" should { "be implicitly converted into a Time" in { import Time._ "10:30" - "08:00" mustEqual 150 } }
Das erste SUS ruft die implicit-Methode explizit auf und prüft „nur“ auf Korrektheit. Erst in der zweiten SUS verwenden wir diese als Implicit Conversion. Dabei fällt auf, dass wir Time._ importieren, also unter anderem auch unsere Implicit Conversion. Warum ist das überhaupt nötig? Der Scala-Compiler sucht doch bei den Companion Objects von Ausgangs- und Zieltyp. Um diese Frage zu beantworten müssen wir diese Typen genau betrachten. Was ist in diesem Fall der Ausgangstyp? String, also nicht unter unserer Kontrolle. Was ist der Zieltyp? „Irgendetwas“, das den „-“-Operator definiert, also eindeutig nicht unsere Time-Klasse. Daher kann der Scala-Compiler in diesem Fall nicht auf das Companion Object zur Time-Klasse zurückgreifen und wir müssen die Implicit Conversi-
148
Implicit Parameters on mittels Import als Singele Identifiers in den Scope bringen. Nun können wir uns an die noch ausstehende Implementierung machen: implicit def stringToTime(s: String): Time = { require(s != null, "time must not be null!") try { val timePattern(hours, minutes) = s Time(hours.toInt, minutes.toInt) } catch { case e: MatchError => throw new IllegalArgumentException( "Cannot convert String %s to Time!" format s, e) } }
Wie wir sehen können, werden die Werte für Stunden und Minuten wie im obigen Einschub mittels Pattern Matching ermittelt, wobei wir für fehlgeschlagene Matches eine IllegalArgumentException werfen.
11.2 Implicit Parameters Wenn wir mit Implicit Conversions quasi passend machen können, was nicht passt, dann stellen Implicit Parameters sozusagen das Diplomatengepäck dar, mit dem wir, ohne es vorzeigen zu müssen, dennoch passieren dürfen. Im Prinzip sind diese rasch erklärt: Wir schreiben einfach als erstes das Schlüsselwort implicit in die Parameterliste, womit wir alle Parameter implizit machen. Das bedeutet, dass wir die gesamte Argumente-Liste beim Aufruf einfach weglassen können, falls der Scala-Compiler passende Werte findet, die er als Argumente verwenden kann. Der aufmerksame Leser wird schon ahnen, wann Werte als passend betrachtet werden: Wenn sie mit implicit definiert werden und wenn sie sich im Scope befinden. Ein Beispiel: scala> def addOne(implicit i: Int) = i + 1 addOne: (implicit i: Int)Int scala> addOne :8: error: could not find implicit value for parameter i: Int addOne scala> implicit val default = 0 default: Int = 0 scala> addOne res0: Int = 1
Hier haben wir die einfache Methode addOne mit einem einzelnen impliziten Parameter. Natürlich könnten wir diese auch mit einem expliziten Argument aufrufen, aber hier wollen wir das unterlassen, um uns mit den Implicit Parameters vertraut zu machen. Wenn
Durchstarten mit Scala
149
11 – Implicits wir zunächst versuchen, die addOne-Methode ohne Argument aufzurufen, dann erhalten wir einen Fehler vom Scala-Compiler, weil sich kein geeigneter impliziter Wert im Scope befindet. Anschließend definieren wir einen solchen, wobei wie bei den Implicit Conversions der Name für das Funktionieren unerheblich ist, wenngleich ein sprechender Name natürlich das Verständnis fördert. Letztendlich zählt aber nur der Typ und das Schlüsselwort implicit. Nun können wir addOne einfach ohne Argument aufrufen. So weit, so gut, aber wie sollen wir es anstellen, wenn wir nicht alle Parameter implizit machen wollen, sondern nur einige oder sogar nur einen? Gerade letzteres kommt in der Praxis sehr häufig vor. Da das Schlüsselwort implicit auf die gesamte Parameterliste wirkt, können wir nur alle oder gar keinen Parameter implizit machen. Das gilt zumindest, solange wir nur eine Parameterliste verwenden.
Einschub: Currying Warum sollen Methoden eigentlich nur eine Parameterliste haben dürfen? In Scala besteht diese Einschränkung nicht, sodass wir ohne weiteres mehrere definieren dürfen, was nach dem Mathematiker Haskell Curry3 mit Currying bezeichnet wird. Ein einfaches Beispiel: scala> def add(x: Int)(y: Int) = x + y add: (x: Int)(y: Int)Int scala> add(1)(2) res0: Int = 3
Wir sehen hier die Methode add mit zwei Parameterlisten, die jeweils einen Parameter haben. Im Körper können wir auf die Parameter wie gewohnt zugreifen, da besteht überhaupt kein Unterschied zur gewohnten Alternative mit einer einzigen Parameterliste, die zwei Parameter hat. Natürlich müssen wir eine curried Methode mit mehreren Listen von Argumenten aufrufen, in unserem Fall also mit zwei, wobei jede ein Argument enthält. Die eigentliche Bedeutung von Currying besteht darin, dass wir durch das Weglassen von Argumente-Listen von einer „vollständig“ aufgerufenen bzw. angewendeten Methode zu einer „teilweise“ angewendeten gelangen können, was auch mit „Partially Applied Functions“ bezeichnet wird. scala> val addOne = add(1) _ addOne: (Int) => Int =
Hier ersetzen wir die zweite Argumente-Liste durch den Unterstrich und gelangen so auf anderem Weg zur Funktion addOne. Wenn wir ganz genau sind, dann war das weiter oben definierte addOne ja eine Methode, aber diese hätten wir auch durch Weglassen der Argumente-Liste zu einer Funktion machen können.
3
150
http://de.wikipedia.org/wiki/Haskell_Brooks_Curry
Implicit Parameters
Fortsetzung: Implizite Parameter Mit Currying bzw. mehreren Parameterlisten lösen wir ganz einfach unser obiges Problem, dass wir nur einige oder nur einen Parameter implizit machen wollen: Wir packen alle nicht-impliziten Parameter in die erste Parameterliste und fügen eine zweite an, die wir implizit machen: scala> def add(x: Int)(implicit y: Int) = x + y add: (x: Int)(implicit y: Int)Int scala> add(1) :7: error: could not find implicit value for parameter y: Int add(1) scala> implicit val default = 1 default: Int = 1 scala> add(1) res1: Int = 2
Hier erweitern wir unser obiges Beispiel für Currying, indem wir die zweite Parameterliste implizit machen. Wenn wir keinen passenden impliziten Wert im Scope haben, dann können wir die add-Methode nicht ohne die zweite Argumente-Liste aufrufen, aber sobald wir einen solchen impliziten Wert definieren, funktioniert das Weglassen. Die Regeln, gemäß derer Scala-Compiler nach Implicit Parameters sucht, entsprechen weitgehend denen von Implicit Conversions zum Expected Type. Das heißt, dass wir einen impliziten Wert als Single Identifier in den Scope bringen können, zum Beispiel durch einen Import. Besser jedoch sind die impliziten Werte bei den Companion Objects aufgehoben, die zum Typ des impliziten Parameters gehören, was auch mit dem Implicit Scope eines Typs bezeichnet wird. Dieser umfasst nicht nur den Typ selbst, also wie in unserem Beispiel Int, sondern auch alle Typen, die mit diesem zusammenhängen. Wir wollen hier nicht allzu sehr in die Details gehen, sondern nur ein wichtiges Beispiel bringen: Wenn ein impliziter Parameter einen parametrisierten Typ hat, dann gehören auch die Singleton Objects der Werte für die Typparameter zum Implicit Scope. Am besten veranschaulichen wir uns das anhand eines einfachen Beispiels: case class Person(name: String) object Person { implicit val f: Person => Unit = println(_) // (1) } object Main { def main(args: Array[String]) { apply(List(Person("Alpha"), Person("Beta"))) // (2) } def apply[A](xs: Seq[A])(implicit f: A => Unit) { // (3) xs foreach f // (4) } }
Durchstarten mit Scala
151
11 – Implicits Zuerst richten wir unsere Aufmerksamkeit auf die Signatur der apply-Methode (3). Darin definieren wir in der zweiten Parameterliste einen impliziten Parameter vom Typ A => Unit. Dabei ist A ein Typ-Parameter der apply-Methode ist und bestimmt den Typ der Seq, die als Parameter in der ersten Parameterliste übergeben wird. In der zweiten Parameterliste, die implizit ist, erwarten wir eine Funktion A => Unit. Mit anderen Worten erwarten wir eine Funktion, die zum Typ der übergebenen Seq passt, sodass wir sie auf alle deren Elemente „loslassen“ können. Nun blicken wir auf den Aufruf der apply-Methode (2), wo wir als erstes Argument eine List[Person] übergeben und die zweite Argumenten-Liste weglassen. Da kein passender impliziter Wert im lokalen Scope vorliegt, sucht der Scala-Compiler bei den Companion Objects der Typen, die mit dem Typ des impliziten Parameters zusammenhängen. Dieser lautet Person => Unit oder auch Function1[Person, Unit]. Selbstverständlich wird er bei Function1 nicht fündig, weil dieser Trait ja aus der Scala-Standardbibliothek stammt. Aber wie schon gesagt gehören auch die Typen, mit denen der eigentliche Typ parametrisiert ist, zum Implicit Scope. Daher sucht der Scala-Compiler in unserem konkreten Fall auch beim Companion Object von Person. Dort haben wir eine passende Funktion Person => Unit als impliziten Wert definiert (1), sodass der Beispiel-Code erfolgreich compiliert. Wenn wir diese Zeile auskommentieren, dann erhalten wir natürlich eine entsprechende Fehlermeldung.
11.3 Type Classes Nun kommen wir zu einem besonders spannenden Thema. Type Classes stammen eigentlich von Haskell4, einer rein funktionalen Programmiersprache. Dort dienen sie dazu, Polymorphismus ohne Vererbung, die es dort natürlich nicht gibt, zu realisieren. In Scala haben wir keine direkte Unterstützung für Type Classes durch Sprach-Features wie in Haskell, aber mittels Traits und Implicits können wir konzeptionell dasselbe erreichen. Damit diese fortgeschrittene Thematik nicht zu kompliziert wird, erarbeiten wir sie uns anhand unseres Fallbeispiels. In Kapitel 10 haben wir die Time-Klasse um die Methode toXml erweitert. Dies stellt – wie schon gesagt – kein gutes Design das, weil Serialisierung nicht zur eigentlichen Aufgabe dieser Klasse gehört. Darüber hinaus ist die XML-Serialisierung bisher auf die Klasse Time beschränkt, es fehlt also an einer Generalisierung für andere Klassen wie zum Beispiel Train, Station etc. Unser Ziel ist es daher, eine generische XML-Serialisierung non-invasiv zur Verfügung zu stellen, d.h. ohne dabei unsere Klassenstruktur mit domänenfremden Methoden zu „verschmutzen“. Zusammengefasst verfolgen wir zwei Ziele:
4
152
http://www.haskell.org/haskellwiki/Haskell
Type Classes 1. XML-Serialisierung soll non-invasiv sein 2. XML-Serialisierung soll generisch sein Um das erste Ziel zu erreichen, können wir eine Implicit Conversion einsetzen, die aus einer Time-Instanz „etwas“ macht, das die Methode toXml enthält. Daher erweitern wir das Companion Object zur Klasse Time folgendermaßen: implicit def toToXml(time: Time): ToXml = { require(time != null, "time must not be null!") new ToXml(time) } private[scalatrain] class ToXml(time: Time) { def toXml: NodeSeq = }
Die neu hinzugefügte implizite Methode toToXml nimmt eine Time-Instanz entgegen und gibt ein Objekt vom Typ ToXml zurück. Die ToXml-Klasse definiert die toXml-Methode exakt so, wie zuvor die Time-Klasse. Die einzige Ausnahme ist, dass die ToXml-Klasse eine Time-Instanz als Parameter übergeben bekommt, welche in der Methode toXml verwendet wird. Durch diese Änderung können wir die toXml-Methode der Time-Klasse löschen und dennoch auf einer Time-Instanz nach wie vor toXml aufrufen, obwohl diese Methode dort gar nicht mehr definiert ist: scala> val time = Time(1, 2) time: org.scalatrain.Time = 01:02 scala> time.toXml res0: scala.xml.NodeSeq =
Wie wir sehen, funktioniert das sogar, obwohl die ToXml-Klasse privat bis auf das Package scalatrain ist. Das ist auch gut so, denn diese Hilfsklasse hat im öffentlichen API nichts verloren. Nun können wir uns das zweite Ziel vornehmen, d.h. wir wollen nun die XML-Serialisierung von der Time-Klasse abstrahieren. Dazu definieren wir den Trait XmlSerializable in der neuen Datei XmlSerializable.scala und verschieben die Methoden fromXml und toToXml vom Singleton Object Time dorthin. Dabei ersetzen wir den konkreten Typ Time durch den TypParameter A, mit welchem wir den Trait XmlSerializable parametrisieren. Schließlich wollen wir ja beliebige Typen, hier ausgedrückt durch den Typ-Parameter A, serialisieren können: trait XmlSerializable[A] { implicit def fromXml(xml: NodeSeq): A = null // TODO (1) implicit def toToXml(a: A): ToXml[A] = { require(a != null, "a must not be null!") new ToXml(a)
Durchstarten mit Scala
153
11 – Implicits } } private[util] class ToXml[A](a: A) { def toXml: NodeSeq = null // TODO (2) }
Wir verschieben natürlich auch die Klasse ToXml und ersetzen auch dort den Typ Time durch einen Typ-Parameter. Dann mixen wir noch den neuen Trait in das Singleton Object Time hinein: object Time extends XmlSerializable[Time] ...
Zumindest compiliert unser Projekt, aber es stellt sich die Frage, wie wir die beiden offenen Implementierungen (1) und (2) umsetzen sollen. Wir haben mit A ja nur einen Typ-Parameter „in der Hand“ und keinen konkreten Typ. Wie sollen wir da zum Beispiel Time serialisieren? Dazu definieren wir zunächst einen weiteren parametrisierten Trait, den wir XmlFormat nennen und an den wir diese Aufgaben delegieren. Und dann erweitern wir die Signaturen der Methoden für (1) und (2) um einen impliziten Parameter von genau diesem Typ: trait XmlSerializable[A] { implicit def fromXml(xml: NodeSeq) (implicit format: XmlFormat[A]): A = { require(xml != null, "xml must not be null!") require(format != null, "format must not be null!") format fromXml xml } ... private[util] class ToXml[A](a: A) { def toXml(implicit format: XmlFormat[A]): NodeSeq = format toXml a } trait XmlFormat[A] { def toXml(a: A): NodeSeq def fromXml(xml: NodeSeq): A }
Was haben wir dadurch erreicht? Auf jeden Fall ist die Implementierung von XmlSerializable und ToXml nun komplett, weil dort einfach an den impliziten Parameter vom Typ XmlFormat delegiert wird. Dieser Trait stellt im Übrigen eine sogenannte Type Class dar, d.h. er definiert bestimmte Methoden in Abhängigkeit vom Typ-Parameter. Allerdings fehlt immer noch die konkrete Implementierung der Serialisierung, schließlich haben wir ja auch keine Implementierung des XmlFormat-Traits. Daher compiliert unser Projekt zwar, nicht jedoch die Test-Spezifikation TimeSpec. Dort erhalten wir die Fehlermeldung, dass der Scala-Compiler keinen impliziten Wert für die impliziten Parameter finden kann. Also müssen wir noch diesen letzten Schritt umsetzen, d.h. eine Implementierung von XmlFormat für Time erstellen, also eine Type Class Instance. Nach dem, was wir in154
Projekt-Code: aktueller Stand zwischen über implizite Parameter wissen, müssen wir diese in das Companion Object von Time packen, damit sie vom Scala-Compiler ohne Imports gefunden werden kann: implicit object TimeXmlFormat extends XmlFormat[Time] { override def fromXml(xml: NodeSeq): Time = { require(xml != null, "xml must not be null!") "%s:%s".format(xml \ "@hours", xml \ "@minutes") } override def toXml(time: Time): NodeSeq = { require(time != null, "time must not be null!") } }
Bei der Implementierung von fromXml nutzen wir gleich die Tatsache, dass wir einen String implizit in eine Time-Instanz konvertieren können. Ansonsten funktioniert TimeXmlFormat im Prinzip genauso, wie wir das zuvor ohne Type Classes hatten. Wir könnten nun mit diesem sehr allgemeinen Mechanismus auf Basis von Type Classes auch die anderen Klassen wie zum Beispiel Train, Station etc. serialisierbar machen. Da wir in unserem Fallbeispiel davon keinen konkreten Nutzen haben, sparen wir uns diese Fleißarbeit jedoch. Aber uns war wichtig, dieses wichtige fortgeschrittene Konzept zu behandeln, das zwar bei der normalen Anwendungsentwicklung eher selten zum Einsatz kommen dürfte, jedoch beim Erstellen von Libraries oder DSLs von großer Bedeutung sein kann.
11.4 Projekt-Code: aktueller Stand Nach den Arbeiten in diesem Kapitel sieht unser Projekt aktuell folgendermaßen aus, wobei wir nur die Klassen darstellen, die sich verändert haben: package org.scalatrain import util.{ XmlFormat, XmlSerializable } import scala.xml.NodeSeq object Time extends XmlSerializable[Time] { implicit object TimeXmlFormat extends XmlFormat[Time] { override def fromXml(xml: NodeSeq): Time = { require(xml != null, "xml must not be null!") "%s:%s".format(xml \ "@hours", xml \ "@minutes") } override def toXml(time: Time): NodeSeq = {
Durchstarten mit Scala
155
11 – Implicits require(time != null, "time must not be null!") } } implicit def stringToTime(s: String): Time = { require(s != null, "time must not be null!") try { val timePattern(hours, minutes) = s Time(hours.toInt, minutes.toInt) } catch { case e: MatchError => throw new IllegalArgumentException( "Cannot convert String %s to Time!" format s, e) } } def fromMinutes(minutes: Int): Time = { require(minutes >= 0, "minutes must not be negative!") new Time(minutes / 60, minutes % 60) } private val timePattern = """(\d{2})\:(\d{2})""".r } case class Time(hours: Int = 0, minutes: Int = 0) extends Ordered[Time] { require(hours >= 0, "hours must not be negative!") require(hours < 24, "hours must be less than 24!") require(minutes >= 0, "minutes must not be negative!") require(minutes < 60, "minutes must be less than 60!") lazy val asMinutes: Int = minutes + 60 * hours def -(that: Time): Int = minus(that) def minus(that: Time): Int = { require(that != null, "that must not be null!") this.asMinutes - that.asMinutes } override val toString: String = "%02d:%02d".format(hours, minutes) override def compare(that: Time): Int = this - that }
Listing 11.1: Time.scala
156
Projekt-Code: aktueller Stand package org.scalatrain import scala.collection.immutable.Seq case class Train(info: TrainInfo, schedule: Seq[(Time, Station)]) { require(info != null, "info must not be null!") require(schedule != null, "schedule must not be null!") require(schedule.size >= 2, "schedule must have at least two stops!") val stations: Seq[Station] = schedule map { _._2 } override def toString = info match { case Ice(number, true) => "ICE %s (WIFI)" format number case Ice(number, _) => "ICE %s" format number case Re(number) => "RE %s" format number case Brb(number) => "BRB %s" format number } } object Station { implicit def stringToStation(s: String): Station = Station(s) } case class Station(name: String) { require(name != null, "name must not be null!") } sealed abstract class TrainInfo { def number: String } case class Ice(number: String, hasWifi: Boolean = false) extends TrainInfo case class Re(number: String) extends TrainInfo case class Brb(number: String) extends TrainInfo
Listing 11.2: Train.scala
Durchstarten mit Scala
157
11 – Implicits package org.scalatrain package util import scala.xml.NodeSeq trait XmlSerializable[A] { implicit def fromXml(xml: NodeSeq) (implicit format: XmlFormat[A]): A = { require(xml != null, "xml must not be null!") require(format != null, "format must not be null!") format fromXml xml } implicit def toToXml(a: A): ToXml[A] = { require(a != null, "a must not be null!") new ToXml(a) } } private[util] class ToXml[A](a: A) { def toXml(implicit format: XmlFormat[A]): NodeSeq = format toXml a } trait XmlFormat[A] { def toXml(a: A): NodeSeq def fromXml(xml: NodeSeq): A }
Listing 11.3: XmlSerializable.scala
158
12 12
Fortgeschrittene Konzepte
In diesem Kapitel werden wir verschiedene Themen behandeln, die meist fortgeschrittener Natur sind. Zum einen Teil greifen wir dabei bereits Besprochenes auf und vertiefen es, wie zum Beispiel die objekt-funktionale Programmierung in Kapitel 12.4. Zum anderen Teil bringen wir neue Aspekte ins Spiel, wie zum Beispiel die Rekursion in Kapitel 12.1 oder Spezialitäten des Typsystems in Kapitel 12.2 und 12.3. Auf jeden Fall werden wir all diese Themen nutzen, um unser Fallbeispiel auszubauen und endlich seiner wahren Bestimmung zuzuführen, und zwar der Ermittlung von Zugverbindungen.
12.1 Rekursion
Manche Aufgaben lassen sich besonders elegant und einfach mittels Rekursion1 lösen, also mit Methoden, die sich selbst aufrufen. Ein Paradebeispiel hierfür ist die Berechnung der Fakultät einer natürlichen Zahl: scala> def factorial(n: Int): Int = | if (n == 0) 1 else n * factorial(n - 1) factorial: (n: Int)Int
Diese rekursive Implementierung entspricht nicht nur strukturell exakt den Anforderungen, nämlich der mathematischen Definition der Fakultätsfunktion, sondern sie ist auch sehr eingängig. Würden wir stattdessen eine imperative Variante mit einer veränderlichen Variable und einer Schleife implementieren, dann erhielten wir nicht nur mehr, sondern auch schwerer verständlichen Code. Letzteres liegt zwar im Auge des Betrachters, aber ganz objektiv enthält der folgende imperative Code mehr Details und ist daher prinzipiell weniger eingängig als die rekursive Variante: scala> def factorial2(n: Int): Int = { | var acc = 1 | for (i def factorial(n: Int) = | if (n == 0) 1 else n * factorial(n - 1) :7: error: recursive method factorial needs result type
Die zweite Besonderheit dreht sich um das sehr wichtige Thema der Tail Call Optimization2. Rekursive Methodenaufrufe bergen nämlich im Gegensatz zu Schleifen die Gefahr eines Stack Overflow, wenn die Rekursion zu „tief“ gerät. Der Scala-Compiler bietet einen Ausweg aus diesem Dilemma: Wenn eine Methode tail recursive ist, d.h. der rekursive Aufruf den letzten Ausführungsschritt darstellt, dann wandelt der Scala-Compiler die Rekursion in eine Schleife um. Daher sollten wir, zumindest wenn wir nicht definitiv ausschließen können, dass die Rekursionstiefe gering bleibt, stets versuchen, unsere rekursiven Methoden tail recursive zu machen. Weil das gar nicht so einfach ist, gibt es seit Scala 2.8 die Annotation @tailrec, mit der wir sicherstellen können, dass damit annotierte Methoden tail recursive sind. Der Scala-Compiler bringt nämlich eine Fehlermeldung, wenn dem nicht so ist. Ist unsere factorial-Implementierung tail recursive? Es sieht zunächst danach aus, denn der rekursive Aufruf steht ja ganz am Ende. Also probieren wir es einfach einmal: scala> import scala.annotation.tailrec import scala.annotation.tailrec scala> @tailrec def factorial(n: Int): Int = | if (n == 0) 1 else n * factorial(n - 1) :7: error: could not optimize @tailrec annotated method: it contains a recursive call not in tail position
Offenbar ist diese factorial-Implementierung doch nicht tail recursive. Wenn wir genau hinschauen, dann wird das auch sofort klar: Zwar steht der rekursive Aufruf am Ende der Zeile, aber im Programmablauf kommt danach natürlich noch die Multiplikation. Das führt unmittelbar zur Frage, wie wir zu einer Implementierung kommen, die tail recursive ist. Die Antwort lautet, dass wir das Zwischenergebnis der Multiplikation als Paramater übergeben müssen : scala> @tailrec def factorial(n: Int, acc: Int): Int = | if (n == 0) acc else factorial(n - 1, acc * n) factorial: (n: Int,acc: Int)Int
2
160
http://en.wikipedia.org/wiki/Tail_call
Rekursion Das führt zu dem sehr unschönen Effekt, dass wir diese factorial-Implementierung immer mit einem Wert von 1 für den acc-Parameter aufrufen müssen. Dabei hat dieser Parameter in der öffentlichen Schnittstelle der Methode eigentlich gar nichts verloren, denn wir haben ihn nur aus technischen Gründen eingeführt. Auch aus diesem Dilemma gibt es einen Ausweg: Wir dürfen in Scala nämlich innerhalb einer Methode nicht nur lokale Variablen definieren, sondern ebenso lokale Methoden. Das nutzen wir dazu, die eigentliche factorial-Methode wieder mit nur einem Parameter zu schreiben und darin die tail recursive Variante als lokale Methode zu definieren und passend aufzurufen: scala> def factorial(n: Int): Int = { | @tailrec def factorial(n: Int, acc: Int): Int = | if (n == 0) acc else factorial(n - 1, acc * n) | factorial(n, 1) | } factorial: (n: Int)Int
Damit haben wir eine „schöne“ Methodensignatur und gleichzeitig eine tail recursive Implementierung. Dieses Wissen wollen wir nun auf unser Fallbeispiel übertragen. Wir haben ja noch nicht alle Preconditions des Fahrplans eines Zuges geprüft und unter anderem müssen wir noch sicherstellen, dass die Abfahrtszeiten monoton steigend sind. Das können wir mit den folgenden Änderungen an der Train-Klasse in Angriff nehmen: require(isIncreasing(schedule map { _._1 }), ...) @tailrec private def isIncreasing(times: Seq[Time]): Boolean = times match { case t1 :: t2 :: ts => (t1 < t2) && isIncreasing(t2 +: ts) case _ => true }
Zunächst ergänzen wir die Precondition Checks um einen weiteren Aufruf der requireMethode, wobei wir die Prüfung der Bedingung in die Methode isIncreasing auslagern. Diese erwartet eine Seq[Time], sodass wir den schedule mittels map erst einmal in die passende Form bringen müssen, indem wir den ersten Wert jedes Tuple2[Time, Station] herauspicken. Die isIncreasing-Methode implementieren wir rekursiv und verwenden Pattern Matching mit mehreren Sequence Patterns. Dabei verwenden wir die Case Class :: (ja, das ist ein gültiger Klassenname!) aus der Scala-Standardbibliothek, deren Konstruktor wir in Operator-Notation verwenden können. :: erweitert List, sodass wir mit der Notation x :: xs eine Liste in ihr erstes Element und den Rest zerlegen können. Übrigens gibt es für Listen auch den Operator ::, der wie +: ein Element vorne anfügt. Nun zu den verwendeten Patterns: Eine Seq[Time] mit mindestens zwei Elementen ist genau dann steigend, wenn die ersten beiden Elemente „passend“ angeordent sind und
Durchstarten mit Scala
161
12 – Fortgeschrittene Konzepte ebenso der Rest. Was den Rest anbelangt, gehen wir in eine Rekursion, die offensichtlich einen Tail Call darstellt. Für den Fall, dass das doch nicht offensichtlich sein sollte, kennzeichnen wir die Methode mit @tailrec und vertrauen dem Scala-Compiler. Alle anderen Seqs, und das wird durch das vierte Pattern ausgedrückt, sind monoton steigend, weil sie höchstens ein Element enthalten können.
12.2 Upper Bounds und View Bounds Der aufmerksame Leser wird vielleicht gemerkt haben, dass es eigentlich keine Notwendigkeit gibt, die gerade implementierte Methode auf Seq[Time] einzuschränken. Vielmehr könnten wir doch eine beliebige Seq auf ihre Monotonie hin untersuchen, solange die Elemente mit dem Operator < verglichen werden können. Wir wissen auch schon, dass für diesen Vergleich der Ordered-Trait zuständig ist, den wir in die Time-Klasse hinein gemixt haben. Also liegt es nahe, die Methode zu verallgemeinern, indem wir sie aus dem Companion Object zur Time-Klasse an einen „allgemeinen“ Ort verschieben und die Signatur so ändern, dass Time nicht mehr auftaucht.
12.2.1 Einschub: Package Objects Ein solcher allgemeiner Ort könnte ein „Utility Singleton Object“ sein, zum Beispiel Seq Helpers im Package org.scalatrain.util. Seit Scala 2.8 haben wir jedoch auch eine Alternative, und zwar sogenannte Package Objects. Diese stellen nicht nur logische, sondern sozusagen manifestierte Packages dar und lassen uns auch gewöhnliche Members wie zum Beispiel Felder und Methoden unterbringen: package org.scalatrain package object util { @tailrec def isIncreasing ... ...
Package Objects werden in der Regel in der Datei package.scala definiert, die in einem mit dem zu definierenden Package gleichnamigen Verzeichnis liegt, in unserem Fall also im util-Verzeichnis. Selbstverständlich ist das wieder nur Konvention, aber aus Gründen der Übersichtlichkeit zu empfehlen. Die Definition wird mit den Schlüssenworten package object wird die Definition eingeleitet, daran schließt sich der Name des Package Object an und danach – wie bei Klassen – in geschweiften Klammern der Körper, der Felder, Methoden, innere Klassen etc. definieren kann. In unserem Beispiel definieren wir das Package Object util, welches sich selbst im Package org.scalatrain befindet. Wie greifen wir eigentlich auf Member eines Package Object zu? Entweder befinden sich diese bereits im Scope, nämlich genau dann, wenn wir uns in einem Sub-Package befinden 162
Upper Bounds und View Bounds und Chained Package Clauses verwenden, sodass wir uns im Scope des Super-Packages befinden. Oder wir importieren die benötigten Members eben, wobei wir das genauso wir zum Beispiel bei Klassen tun. In unserem Fallbeispiel müssen wir den folgenden Import zu Train.scala hinzufügen: import util.isIncreasing
Ein gutes Beispiel für die Umstellung von Singleton Objects auf Package Objects bietet die Scala-Standardbibliothek. Vor Scala 2.8 gab es das Singleton Object scala.Math, welches heute deprecated ist und durch das Package Object scala.math ersetzt wurde. Ein weiteres wichtiges Package Object ist scala, das sozusagen Predef flankiert und etliche Definitionen zur Verfügung stellt, die immer verfügbar sind. Zum Beispiel wird dort der Typ-Alias List definiert, der auf scala.collection.immutable.List zeigt, sodass wir List ohne Import verwenden können.
12.2.2 Einschub: Varianz Nun wissen wir also, wohin wir unsere isIncreasing-Methode verschieben können. Als nächstes müssen wir Time in der Signatur loswerden, die wir zur Erinnerung hier nochmals aufführen: @tailrec def isIncreasing(times: Seq[Time]): Boolean
Wir haben schon festgestellt, dass wir anstatt Time den Ordered-Trait verwenden wollen. Dieser ist jedoch parametrisiert, d.h. wir müssen „Ordered von Irgendwas“ schreiben, zum Beispiel Ordered[Time]. Wobei uns das natürlich keinen Schritt weiter bringt, weil wir ja Time loswerden wollen. Da wir Seqs von beliebigen Objekten, die mittels < vergleichbar sind, auf Monotonie analysieren wollen, erscheint es naheliegend, dass wir Ordered[Any] verwenden: @tailrec def isIncreasing(as: Seq[Ordered[Any]]): Boolean = ...
Wenn wir nun versuchen, unser Fallbeispiel zu übersetzen, dann bekommen wir vom Scala-Compiler den folgenden Fehler: [error] /Users/hseeberger/projects/scalatrain/src/main/scala/Train. scala:25: type mismatch; [error] found : scala.collection.immutable.Seq[org.scalatrain.Time] [error] required: scala.Seq[Ordered[Any]] [error] require(isIncreasing(schedule map { _._1 }), ...)
Was bedeutet das im Klartext? Eine Seq[Time] ist keine Seq[Ordered[Any]], was gleichbedeutend damit ist, dass eine Seq[Ordered[Time]] keine Seq[Ordered[Any]] ist. Das können
Durchstarten mit Scala
163
12 – Fortgeschrittene Konzepte wir darauf zurückführen, dass Ordered[Time] kein Sub-Typ von Ordered[Any] ist, obwohl Time ein Sub-Typ von Any ist. Oha! Das klingt überraschend. Aber diese sogenannte Invarianz ist in Scala – wie übrigens auch in Java – der Standard für parametrisierte Typen: Aus einer Vererbungsbeziehung zwischen zwei Typen A und B folgt keine Vererbungsbeziehung zweier Ausprägungen eines generischen Typen, der einmal mit A und einmal mit B parametrisiert wird. Was zunächst vielleicht befremdlich klingt, hat gute jedoch Gründe. Und außerdem gibt es in Scala Möglichkeiten, unter bestimmten Umständen, die hier nicht vorliegen, Vererbungsbeziehungen zu definieren. Und zwar entweder gleichgerichtete, was mit Kovarianz bezeichnet wird, oder sogar entgegengesetzte, wofür der Begriff der Kontravarianz steht. Dieses Thema ist hochinteressant, jedoch zu fortgeschritten, als dass wir es hier vertiefen könnten. Für Interessierte sei als Einstieg der anschauliche Online-Artikel „Advanced Scala – Varianz“3 empfohlen oder wieder einmal das Standardwerk „Programming in Scala“4. Für uns bedeutet das, dass wir die isIncreasing-Methode nicht wie oben dargestellt programmieren dürfen, weil sie dann quasi nutzlos wäre. Daher müssen wir uns nach einer Alternative umsehen.
12.2.3 Upper Bounds Eine solche bietet uns Scala in Form von sogenannten Upper Bounds. Diese geben uns die Möglichkeit, Typ-Parameter in Bezug auf ihre Super-Typen einzuschränken. Dazu betrachten wir ein einfaches Beispiel mit einer parametrisierten Methode: scala> def m[A](a: A) { | println(a) | } m: [A](a: A)Unit scala> m(1) 1 scala> m("Hello") Hello
Diese Methode hat sowohl einen Typ-Parameter, als auch einen „normalen“ Parameter, dessen Typ durch den Typ-Parameter festgelegt wird. Da dieser Typ-Parameter keinerlei Beschränkungen unterliegt, können wir die Methode mit Parametern beliebigen Typs aufrufen, also wie im Beispiel mit einem Int oder einem String. Nun werden wir den Typ-Parameter mit einem Upper Bound versehen. Dazu schreiben wir hinter den TypParameter das Symbol def m[A