Archive for Juli 2011|Monthly archive page
Teil 9: Klassen und Objekte
Die bisher angesprochenen Themen waren zwar alle ganz nett, erlauben uns aber noch nicht viel mehr als nur mit der REPL zu arbeiten. Wir könnten vielleicht schon ein paar kleine Skripte schreiben, aber das ist nicht Scalas Stärke. Gehen wir also ein wenig weiter zum Arbeiten mit Objekten.
Klassen
Das Erste was wir zum Erstellen von Objekten benötigen ist eine Klasse. Wie aus vielen anderen Programmiersprachen schon bekannt, benötigen wir dafür das class-Schlüsselwort:
class Person
So fertig, wir haben unser ersten Objekt. Nur können wir damit leider noch nicht viel anfangen. Fügen wir dem Objekt also ein paar Attribute hinzu:
class Person(name: String, age: Int)
Das sieht doch mal interessant aus. Und es ist sehr kompakt. Was wir hier gemacht haben ist nichts anderes, als dass wir der Klasse Person einen Konstruktor verpasst haben, der als Parameter einen String und einen Int erwartet. Der Konstruktor einer Klasse wird in Scala immer mit runden Klammern direkt hinter dem Klassennamen notiert. Falls wir keine runde Klammern erstellen kreiert der Scala-Compiler für uns einen Default-Konstruktor, der keine Parameter erwartet.
Scalas „way of life“ lautet dabei immer: Erzeuge so wenig Overhead wie möglich. Wir dürfen also sowohl Runde als auch geschweifte Klammern, die den Klassenrumpf aufnehmen, weglassen wenn sie keinen Inhalt haben.
Wir wollen jetzt aber endlich ein Objekt haben mit dem wir etwas anfangen können. Dafür müssen wir nicht mal viel ändern:
class Person(val name: String, val age: Int)
Das val vor dem Attributnamen weist den Scala-Compiler an, unser Attribut zu einem Feld der Klasse zu machen, auf das man von außerhalb zugreifen kann:
scala> class Person(val name: String, val age: Int)
defined class Person
scala> val p = new Person("Franz", 35)
p: Person = Person@2bec5408
scala> p.name
res37: String = Franz
scala> p.age
res38: Int = 35
scala> p.name = "Hugo"
:9: error: reassignment to val
p.name = "Hugo"
^
Was wir hier erzeugt haben ist ein unveränderliches Objekt. Wir können es einmal erstellen, aber nie mehr ändern. Wollen wir dagegen die Möglichkeit haben die Felder eines Objekts nach der Instanziierung noch zu ändern müssen wir lediglich das val gegen ein var eintauschen:
scala> class Person(var name: String, var age: Int)
defined class Person
scala> val p = new Person("Franz", 35)
p: Person = Person@29a430a0
scala> p.name = "Hugo"
p.name: String = Hugo
In diesem Fall wäre aber davon abzuraten die Variable veränderbar zu halten, da wir sonst mit jeder Änderung eines Feldes auch das jeweils andere Feld ändern müssten (andere Personen haben ja wohl nicht das gleiche Alter).
Was hier unter der Haube vorgeht ist eigentlich ganz einfach. Das was wir in anderen Programmiersprachen von Hand erledigen müssen erledigt für uns der Scala-Compiler. Er erzeugt für die Klasse ein Feld und je nach dem ob wir das Feld veränderlich halten wollen generiert er uns noch einen Getter und einen Setter. In Scala wird den Gettern und Settern üblicherweise kein get und kein set vorangestellt (anders als bei Java, bei dem dies die übliche Vorgehensweise wäre). Beim Getter wäre es damit zu begründen, dass in Scala sowieso alles einen Wert zurück gibt – Scala Unit funktioniert anders wie Javas void. Beim Setter dagegen wissen wir anhand der runden Klammern ja, dass eine Operation Seiteneffekte produzieren kann. Der Getter trägt deshalb auch keine Klammern und kann auch nicht mit Klammern aufgerufen werden:
scala> p.name()
:10: error: not enough arguments for method apply: (index: Int)Char in class StringOps.
Unspecified value parameter index.
p.name()
^
Was die Fehlermeldung in Verbindung mit der Methode apply genau zu bedeuten hat werde ich bald erklären. Es wird Zeit, dass wir anfangen unserer Klasse eigene Methoden zu geben, damit wir ein wenig mehr damit machen können als nur ihre Felder anzuschauen. Ich möchte von dem Personen-Beispiel weg und hin zu einem Beispiel aus der Mathematik gehen: Wir wollen mit Scala rationale Zahlen verarbeiten können, was von Haus aus nicht geht. Also erweitern wir die Sprache eben kurzerhand damit.
Rational Zahlen besitzen einen Nenner und einen Zähler:
class Rational(numerator: Int, denominator: Int)
Wenn wir ein Objekt dieser Klasse erzeugen, dann haben wir leider das Problem, dass die REPL uns nur unverständliches Zeug ausgibt:
scala> new Rational(3, 5) res42: Rational = Rational@1b1bcdee
Was wir hier sehen ist der Klassenname gefolgt von einem @-Zeichen und der hexadezimalen Schreibweise des HashCodes des Objekts.
Für alle die mit Java bzw. mit der JVM noch nichts zu tun hatten: Der HashCode gibt die Attribute eines Objektes umgewandelt zu einem Int zurück. Wie genau diese Umwandlung vonstatten geht soll jetzt irrelevant sein. Wichtig ist mehr, dass der HashCode für jedes Objekt möglichst einzigartig ist und sich nicht ändert wenn sich das Objekt auch nicht ändert.
Wir können die Ausgabe ändern indem wir die Methode toString überschreiben. Sie wird von der Methode, die für die Konsolenausgabe verantwortlich ist aufgerufen und legt also fest mit was sich unser Objekt auf der Konsole schmücken darf.
class Rational(numerator: Int, denominator: Int) {
override def toString = numerator+"/"+denominator
}
scala> new Rational(3, 5)
res44: Rational = 3/5
Die Ausgabe sieht doch gleich viel besser aus. Das override gibt an, dass unsere Methode, eine gleichnamige Methode aus dem Vaterobjekt überschreibt. Aber was ist unser Vaterobjekt? Das ist Object, eine Klasse der JVM, die oberste Klasse überhaupt. Jedes Objekt in Scala – auch Any – ist vom Typ Object. In ihm sind Methoden wie toString und hashCode definiert.
Auf das nächste, auf das wir achten wollen, ist, ob unser Konstruktor auch mit korrekten Daten initialisiert wird. Was würde z.B. passieren wenn wir im Nenner eine null angeben würden? Den Bruch `3/0` gibt es ja überhaupt nicht. Um dies zu unterbinden stellt uns Scala die require-Methoden bereit:
class Rational(numerator: Int, denominator: Int) {
require(denominator != 0)
override def toString = numerator+"/"+denominator
}
scala> new Rational(3, 0)
java.lang.IllegalArgumentException: requirement failed
In Scala gehört jeglicher Code innerhalb des Klassenrumpfes zum Konstruktor und wird deshalb ausgeführt sobald der Konstruktor aufgerufen wird. Da dies immer dann der Fall ist wenn ein neues Objekt instanziiert wird kann unser `require` auch sofort überprüfen ob die Attribute korrekt gesetzt wurden.
Als nächstes wollen wir schauen, dass wir nur gekürzte Brüche aufnehmen. Ein Bruch wie 5/9 ist doch viel einfacher zu lesen wie 1265/2277, oder? Kürzen können wir unseren Bruch mit dem größten gemeinsamen Teiler (ggT) der beiden Zahlen:
class Rational(numerator: Int, denominator: Int) {
require(denominator != 0)
val g = gcd(numerator, denominator)
val n = numerator / g
val d = denominator / g
override def toString = n+"/"+d
def gcd(a: Int, b: Int): Int = {
import scala.math.abs
def loop(a: Int, b: Int): Int = if (b == 0) a else loop(b, a%b)
loop(abs(a), abs(b))
}
}
Bevor ich erkläre was hier alles neu dazugekommen ist wollen wir uns erst anschauen ob der Code das macht was er tun soll:
scala> new Rational(1265, 2277) res50: Rational = 5/9
Da, er macht es, das ist schon mal toll. Nun zu den ganzen Erklärungen. Die Methode gcd prüft ob ein ggT existiert und gibt diesen zurück. Danach kürzen wir den Nenner und Zähler aus dem Konstruktor. Da die beiden Attribute ohne ein val oder var deklariert wurden, stehen sie außerhalb der Klasse nicht mehr zur Verfügung. Wir müssen uns also nicht mehr weiter darum kümmern. Die drei neuen Felder g, n und d dagegen wurden mit val deklariert und können nun von außerhalb der Klasse frei eingesehen werden. Das ist aber nicht weiter schlimm, da sie unveränderlich sind.
Anmerkung: Der Scala-Compiler erzeugt auch bei Feldern, die außerhalb des Konstruktors erzeugt wurden Getter und Setter. Wenn wir also den Feldnamen aufrufen greifen wir nicht direkt auf das Attribut sondern auf dessen Getter zu.
Geändert wurde noch die Methode toString. Sie gibt nun die neuen Werte zurück und nicht die, die im Konstruktor angegeben wurden. In der Methode gcd finden wir etwas, das wir so noch nicht gesehen haben: ein import-Statement in einer Methode. In Scala ist es prinzipiell möglich überall ein import-Statement anzugeben. Wenn wir es in der Methode machen, dann gilt der import auch nur für die Methode und nicht für außerhalb. Die Tailrekursive-Methode, die den ggT berechnet sollten wir bereits kennen.
Wir haben unserer Klasse jetzt unsere erste eigene Methode hinzugefügt, machen wir doch gleich weiter. Der Übersichtlichkeit wegen werde ich ab jetzt nur noch die Änderungen und Neuerungen an der Klasse Rational niederschreiben.
// in Rational def add(r: Rational) = new Rational(n*r.d + r.n*d, d*r.d) scala> val r1 = new Rational(2, 9) r1: Rational = 2/9 scala> val r2 = new Rational(4, 9) r2: Rational = 4/9 scala> r1.add(r2) res51: Rational = 2/3
Die Methode add erzeugt einen neuen Bruch, anstatt die Felder des bereits bestehenden zu ändern. Das würde auch gar nicht gehen, da sie ja mit val deklariert wurden. Besonders toll anzusehen ist, dass unser neuer Bruch auch gleich gekürzt wird. Aber Moment einmal. Können wir in Scala denn nicht jedes Zeichen als Methodenname benutzen? Wieso verwenden wir nicht +? Würde doch viel besser aussehen.
// in Rational def + (r: Rational) = new Rational(n*r.d + r.n*d, d*r.d) scala> r1 + r2 res52: Rational = 2/3
Stimmt, sieht echt gut aus.
Ob man „Operatorüberladung“ jetzt gut findet oder nicht muss man selbst entscheiden. Ich finde es ein mächtiges Werkzeug, das in den richtigen Händen sehr zum Codeverständnis beitragen kann und werde es deshalb auch ausgiebig nutzen, wenn es angebracht ist. Falls das jemand anders sieht steht es ihm frei den Methoden „normale“ Namen zu geben.
Weiterhin möchte ich noch anmerken, dass man in Scala nicht von Operatorüberladung spricht, da es keine wirklichen Operatoren gibt. Jedes Zeichen (bis auf die von der Sprache reservierten) kann in einem Identifier genutzt werden, weshalb keine Unterscheidung zwischen den „normalen“ und den Sonderzeichen gibt.
Das können wir für die anderen Operationen wiederholen:
// in Rational def - (r: Rational) = new Rational(n*r.d - r.n*d, d*r.d) def * (r: Rational) = new Rational(n*r.n, d*r.d) def / (r: Rational) = new Rational(n/r.n, d/r.d) scala> r2 - r1 res53: Rational = 2/9 scala> r1 * r2 res54: Rational = 8/81 scala> r2 / r1 res55: Rational = 2/1
Wir haben es geschafft. Wir haben unsere erste eigene Klasse erstellt, mit der wir sogar etwas anfangen können. Für den Anfang ist das schon mal gar nicht schlecht. Aber es gibt noch so viel was wir alles in unsere Klasse einbauen können.
In Scala gibt es übrigens eine Operatorpriorität, obwohl es keine Operatoren im klassischen Sinne gibt. Bei Eingabe folgender Gleichung erhalten wir auch das korrekte Ergebnis:
scala> 4+3*2 res20: Int = 10
Scala parst den Ausdruck als
scala> 4+(3*2) res21: Int = 10
Die Priorität einer Methode hängt vom ersten Zeichen der Methode ab. Wie die genaue Reihenfolge ist könnt ihr hier nachschlagen.
Hilfskonstruktoren
Was z.B. noch störend ist, ist das wir unsere Klasse immer mit einen Zähler und auch einem Nenner initialisieren müssen. Eine rationale Zahl muss aber nicht unbedingt ein Bruch sein, zumindest müssen wir sie nicht so schreiben. 123 ist leichter verständlich als 123/1. Um unsere Klasse mit nur einem Zähler zu initialisieren benötigen wir einen zweiten Konstruktor. In Scala spricht man bei mehrfach vorhandenen Konstruktoren von Hilfskonstruktoren (engl.: auxiliary constructor), da sie an sich nichts anderes machen als den Standardkonstruktor aufzurufen. Erzeugen können wir einen Hilfskonstruktor mit def this(…):
class Rational(numerator: Int, denominator: Int) {
...
def this(numerator: Int) = this(numerator, 1)
...
}
scala> val r1 = new Rational(5)
r1: Rational = 5/1
Die erste Anweisung, die in einem Hilfskonstruktor getätigt werden muss ist der Aufruf eines anderes Konstruktors. Das kann der Standardkonstruktor aber auch ein anderen Hilfskonstruktor sein. Innerhalb dieser Konstruktoren haben wir also keine Möglichkeit die Parameter vor Objekterzeugung zu überprüfen. Dies sollte innerhalb des Klassenrumpfes oder im companion object erledigt werden und sonst nirgends. Wir werden später noch auf das companion object zurückkommen.
Neben einem Hilfskonstruktor besteht auch die Möglichkeit einfach ein benanntes Argument einzuführen:
class Rational(numerator: Int, denominator: Int = 1) { ... }
scala> val r1 = new Rational(5)
r1: Rational = 5/1
Das macht den Code ein wenig kürzer und übersichtlicher. Da wir mit unserem Hilfskonstruktor sowieso nicht viel anfangen können, empfehle ich, benannte Argumente vorzuziehen.
object
Wenn wir auf das Kapitel über Collections zurückbilcken fällt uns auf, dass wir unsere Objekte stets ohne das new-Schlüsselwort instanziiert haben. Tatsächlich können wir viele Collections gar nicht mit new instanziieren:
scala> new Seq(1, 2, 3)
:8: error: trait Seq is abstract; cannot be instantiated
new Seq(1, 2, 3)
^
Wie kommt das? Was hier vor sich geht ist der Aufruf des companion objects immer dann wenn wir das new zur Objektinstanziierung auslassen.
Das companion object stellt eine Möglichkeit dar auf den Kontext einer Klasse zuzugreifen ohne von ihr ein Objekt instanziieren zu müssen. Wie das aussehen könnte sehen wir hier:
object Rational {
def apply(numerator: Int, denominator: Int = 1) = new Rational(numerator, denominator)
}
Wenn wir den Code in der REPL ausführen, bekommen wir erst mal eine Warnung:
scala> object Rational {
| def apply(numerator: Int, denominator: Int = 1) = new Rational(numerator, denominator)
| }
defined module Rational
warning: previously defined class Rational is not a companion to object Rational.
Companions must be defined together; you may wish to use :paste mode for this.
Ein object ist erst dann das companion object einer Klasse wenn es den gleichen Namen trägt und wenn es in der gleichen Datei definiert wurde wie die Klasse. Den gleichen Namen hat es, aber wir haben keine Datei in der sich die Klasse Rational befindet. Diese haben wir erst wenn wir unseren Code später mit scalac übersetzen lassen wollen. Also machen wir das was uns die REPL vorschlägt. Den Code im paste-Modus einfügen. Haben wir das getan sollten wir folgenden Code ausführen können:
scala> Rational(5) res0: Rational = 5/1
Kommen wir also nun zu den Erklärungen was da alles vor sich geht. Das object-Schlüsselwort unterscheidet sich insofern vom class-Schlüsselwort als dass wir dort alle definierten Inhalte ohne Objektinstanziierung aufrufen dürfen:
object Test {
def printHello() {
println("hello")
}
}
scala> Test.printHello
hello
Das erklärt also schon einmal weshalb wir das new bei Rational nicht mehr benötigen. Es erklärt aber noch nicht wie wir trotzdem ein Rational-Objekt erstellen können. Wenn wir uns die apply-Methode anschauen sehen wir, dass sie diese Objektinstanziierung für uns erledigt:
scala> Rational.apply(5) res13: Rational = 5/1
Aber wieso können wir die Methode auch einfach auslassen? Die Antwort darauf lautet: Compiler-Magic. Die apply-Methode kann aufgerufen werden ohne dass man ihren Namen aufrufen muss. Der Compiler prüft ob eine apply-Methode zum Namensraum von class oder object gehört und ruft diese automatisch auf wenn nach dem Namen runde Klammern folgen. Ein Beispiel:
Wir haben:
AnyClass(1, 2, 3) AnyClass()
Der Compiler macht daraus:
AnyClass.apply(1, 2, 3) AnyClass.apply()
Jetzt wissen wir auch was passiert wenn wir eine Collection ohne new erzeugen:
scala> List(1, 2, 3) res16: List[Int] = List(1, 2, 3) scala> List.apply(1, 2, 3) res17: List[Int] = List(1, 2, 3)
Das gleiche passiert übrigens wenn wir versuchen auf ein Element einer Collection zuzugreifen:
scala> res16(0) res18: Int = 1 scala> res16.apply(0) res19: Int = 1
Der einzige Unterschied ist, dass verschiedene apply-Methoden aufgerufen werden. Beim ersten Mal wird die apply-Methode des companion objects von List aufgerufen, beim zweiten Mal ist die apply-Methode der Klasse List dran. Neben der apply-Methode gibt es noch eine update- und eine unapply-Methode, die ebenfalls nicht explizit aufgerufen werden müssen. Der Compiler stellt uns für alle drei Methoden syntaktischen Zucker zur Verfügung. Was die beiden letztgenannten machen kann uns momentan egal sein, da wir sie noch nicht benötigen. Ich werde später darauf eingehen.
Falls ein object zu einem compnion object einer Klasse wird, wird die Klasse zur companion class des objects. Die beiden gehen eine Art Bindung ein, die es dem companion object erlaubt auf die nicht öffentlichen Felder der companion class zugreifen zu dürfen.
Zum Schluss noch den kompletten Code von Rational. Einzige Änderung ist, dass ich innerhalb der Klasse alle new entfernt habe:
object Rational {
def apply(numerator: Int, denominator: Int = 1) = new Rational(numerator, denominator)
}
class Rational(numerator: Int, denominator: Int = 1) {
require(denominator != 0)
val g = gcd(numerator, denominator)
val n = numerator / g
val d = denominator / g
def + (r: Rational) = Rational(n*r.d + r.n*d, d*r.d)
def - (r: Rational) = Rational(n*r.d - r.n*d, d*r.d)
def * (r: Rational) = Rational(n*r.n, d*r.d)
def / (r: Rational) = Rational(n/r.n, d/r.d)
override def toString = n+"/"+d
def gcd(a: Int, b: Int): Int = {
import scala.math.abs
def loop(a: Int, b: Int): Int = if (b == 0) a else loop(b, a%b)
loop(abs(a), abs(b))
}
}
Operatorpriorität
Die Priorität eines Operators hängt vom ersten Zeichen der Methode ab. Der Ausdruck 3+4*2 wird also nicht als (3+4)*2 geparst, sondern mathematisch korrekt als 3+(4*2).
Die genaue Reihenfolge kann in der Scala Reference (Section 6.12.3 Infix Operations) nachgeschlagen werden:
(all letters) | ^ & < > = ! : + - * / % (all other special characters)
Aufsteigende Reihenfolge, erstes Element besitzt niedrigste Priorität.
Es ist wichtig, dass ihr darauf achtet nicht durch eventuelle „Methodenvertauschungen“ Probleme zu bekommen.
Der Ausdruck 3 add 4 mul 2 wird anders ausgewertet als 3+4*2.
Schlüsselwörter in Scala
Hier eine Auflistung aller Schlüsselwörter und -symbole, die nicht regulär als eigene Identifier verwendet werden dürfen:
Schlüsselwörter (39)
abstract case catch class def do else extends false final finally for forSome if implicit import lazy match new null object override package private protected return sealed super this throw trait try true type val var while with yield
Symbole (12)
_ : = => <- <: <% >: # @
⇒ (Unicode \u21D2) equivalent zu: =>
← (Unicode \u2190) equivalent zu: <-
Teil 8: Pattern Matching
Wir haben schon viel von Scala kennen gelernt, das bisherige Wissen reicht aber noch nicht um in Scala größere Programme schreiben zu können. Bevor wir endlich zur Objektorientierung kommen möchte ich euch noch das Thema Pattern Matching näher bringen.
In der einfachsten Form kann man Pattern Matching mit Mehrfachverzweigungen aus Java vergleichen. Sie sehen ähnlich aus und verhalten sich auch ähnlich, sind aber ungemein komfortabler.
int i = ...;
switch (i) {
case 2:
break;
case 3: case 4: case 7:
break;
case 12:
break;
default:
break;
}
Javas switch-case erlaubt lediglich das testen auf Zahlen, Enums und Strings (Java7). Das ist aber oft nicht ausreichend. Was wenn ich ein Objekt auf seinen Typ überprüfen möchte? Dann muss ich auf längere und vor allem hässliche if-else-instanceof-Kaskaden zurückgreifen. Auch Mehrfachvergleiche sind nicht besonders intuitiv. Man muss sich den fall-through zu Nutze machen und jede Überprüfung von neuem mit einem `case` einleiten. Der fall-through ist sowieso total unsinnig – habt ihr den jemals gebraucht? Also ich nicht und ich wäre glücklicher wenn man sich das `break` sparen könnte.
In Scala hat man sich dessen angenommen und die alte Mehrfachverzweigung durch das viel mächtigere Pattern Matching ersetzt:
val i = ...
i match {
case 2 =>
case 3 | 4 | 7 =>
case 12 =>
case _ =>
}
Das Schlüsselwort `switch` wurde durch `match` ersetzt und wird hinter die zu überprüfende Variable gesetzt. Mit case-Statements folgen dann die Überprüfungen. Die Überprüfung ist alles was sich zwischen dem case und dem `=>`-Symbol befindet. Das Symbol, das aussieht wie ein Pfeil hat dabei die gleiche Bedeutung wie der Punkt in Javas switch-case. So weit so gut, das war es aber auch schon mit den Gemeinsamkeiten.
Pattern Matching unterstützt kein fall-through. Die Ausführung einer Verzweigung endet mit Beginn einer Neuen – es wird also auch kein spezielles Schlüsselwort benötigt um das Ende zu kennzeichnen. Anhand des zweiten case-Statement ist zu erkennen, dass mehrere Überprüfungen komfortabel mit einem Statement gemacht werden können. Die zu überprüfenden Werte werden einfach mit einem senkrechten Strich voneinander getrennt – dem branchenüblichen Oder-Zeichen. Ein weiteres Merkmal ist, dass jedes überprüfte Element auf eine Überprüfung passen muss, ist dies nicht der Fall wird ein MatchError geworfen. Wenn also die Möglichkeit besteht, dass die Überprüfungen eines match-Statements nicht alle Werte abdecken können, dann muss ein default-Fall eingefügt werden. Diesen erstellt man in dem man auf den Unterstrich matcht. Wir erinnern uns: Der Unterstrich steht für irgendwas, er kann alles sein. Er wird immer dann gematcht, wenn kein nichts anderes passt. Das Pattern Matching wird beendet sobald korrekt gematcht wurde. Nach der Abarbeitung eines erfolgreichen case werden die restlichen case also nicht mehr ausgeführt. Dies bedeutet, dass der default-Fall immer als letztes geprüft werden muss, alles was danach kommt ist nämlich unerreichbar. War doch gar nicht so schwer, oder? Wir müssen weniger Code schreiben um das gleiche zu erreichen wie Javas switch-case.
Aber das war erst der Anfang.
Pattern Matching kann auf beliebige Typen prüfen – schauen wir uns dazu gleich das nächste Beispiel an:
val any: Any = ...
val matched = any match {
case 2 | 4 | 7 => "a number"
case "hello" => "a string"
case true | false => "a boolean"
case 45.35 => "a double"
case _ => "an unknown value"
}
Wir haben irgendein Any und wollen wissen was genau es ist – kein Problem. Dadurch, dass in Scala alles ein Wert zurück gibt haben wir die Möglichkeit die letzte Anweisung innerhalb eines case-Blocks an eine Variable zu binden. Bitte beachtet, dass wir keine geschweiften Klammern benötigen um innerhalb eines match einen Block zu kennzeichnen – anhand des `=>` und des case kann der Compiler zuverlässig das Vorhandensein eines solchen Blocks erkennen:
val matched = any match {
case 2 | 4 | 7 =>
doSomething()
"a number"
case "hello" =>
doSomething()
"a string"
case true | false =>
doSomething()
"a boolean"
case 45.35 =>
doSomething()
"a double"
case _ =>
doSomething()
"an unknown value"
}
Durch die Typinferenz erkennt der Compiler, dass die Variable `matched` vom Type String ist – ich hoffe zwar, dass ihr euch das mittlerweile selbst zusammenreimen könnt, aber man kann es ja nie oft genug erwähnen.
Das war bisher ja alles ganz nett, aber besonders vom Stuhl haut es uns nicht gerade. Das kann aber noch kommen, denn match kann noch mehr. Was ist wenn wir z.B. innerhalb eines gematchten Statements auf das zugreifen wollen was gematcht wurde? Anstatt also bei einer gematchten Zahl nur zurück zu geben, dass wir eine Zahl haben wäre es doch schön auch den Wert dieser Zahl zurückzugeben. Wir könnten jetzt umständlich das zu matchende Objekt casten um dann auf dessen Methoden zugreifen zu können, aber warum sollten wir das tun wenn Scala das schon für uns erledigt?
val matched = any match {
case n: Int => "a number with value: "+n
case _: String => "a string"
case true | false => "a boolean"
case d @ 45.35 => "a double with value "+d
case d => "an unknown value "+d
}
Wir haben die Möglichkeit anstatt auf die Instanz eines bestimmten Typs auch einfach nur auf den Typ zu überprüfen. Dazu müssen wir bloß einen einen Identifier hinter das case schreiben und den Typ mit einem Doppelpunkt an den Identifier binden, wie im ersten case-Statement der Fall. Wir könnten das so lesen: Prüfe ob das gematchte Objekt vom Typ Int ist und wenn ja, dann binde das Objekt an eine Variable namens `n` und weise ihr den Typ Int zu. Es genügt nur den Namen der Variable anzugeben, wie bei den Generatoren und Definitionen der for-Expression benötigen wir kein `val` oder `var`. Die Sichtbarkeit dieser Variable beschränkt sich auf das case-Statement wir können also bei mehreren Überprüfungen auch mehrmals den gleichen Variablennamen nehmen – wie zu sehen bei den letzten beiden Statements.
Sollten wir nur auf eine beliebige Instanz eines Typs prüfen wollen, so besteht die Möglichkeit den Variablennamen wegzulassen und statt dessen einen Unterstich zu nehmen wie beim zweiten case-Statement gezeigt.
Eine weitere Besonderheit stellt das `@`-Zeichen dar. Es bedeutet so viel wie: Prüfe ob ein Objekt gleich der Instanz eines Typs ist und wenn ja, dann binde diese Instanz an einen Identifier. Im obigen Beispiel prüfen wir ob ein Double mit dem Wert 45.35 vorliegt. Sollte das der Fall sein wird dieser Wert an die Variable `d` gebunden, die wir dann benutzen können.
Um den Unterschied zwischen `@` und `:` nochmal klar und deutlich hervorzuheben: Ersteres prüft auf eine Instanz, letzteres auf einen Typ.
Neben einem Unterstich haben wir die Möglichkeit im letzten Statement auch einfach einen Identifier zu benutzen. Das können wir immer dann machen wenn wir noch auf das gematchte Objekt zugreifen wollen.
Schauen wir uns das ganze also mal in Aktion an:
def matching(any: Any) = any match {
case n: Int => "a number with value: "+n
case _: String => "a string"
case true | false => "a boolean"
case d @ 45.35 => "a double with value 45.35"
case d => "an unknown value "+d
}
scala> matching(734)
res0: java.lang.String = a number with value: 734
scala> matching("hello")
res1: java.lang.String = a string
scala> matching(true)
res2: java.lang.String = a boolean
scala> matching(45.35)
res3: java.lang.String = a double with value 45.35
scala> matching(Nil)
res4: java.lang.String = an unknown value List()
Funktioniert alles. Wenn wir die bisher kennen gelernte Möglichkeiten miteinander kombinieren, können wir richtig tolle Objekte überprüfen:
def matching(xs: List[Int]) = xs match {
case 5 :: 3 :: Nil => "List contains 5 and 3"
case _ :: 7 :: _ => "Second element of List is 7"
case List(1, tail @ _*) => "first element is 1 and tail is: "+tail
case Nil => "Nil"
}
Im ersten Fall prüfen wir ob die Liste zwei Elemente besitzt (Nil wird nicht als Element angesehen, es ist einfach nur das Ende), im zweiten Fall ob das zweite Element 7 ist. Mit dem `::`-Symbol können wir die Liste einfach zusammen bauen, das ist uns bereits bekannt.
Pattern Matching erlaubt uns Objekte beliebiger Komplexitätsstufe zusammenzubauen indem wir einfach den Konstruktor mit den Elementen, die uns interessieren, aufrufen. So geschehen im dritten Fall. Das erste Element dort soll 1 sein, der Rest wird an die Variable `tail` gebunden. Die dort auftauchende Symbole sollten uns nicht unbekannt sein. Wir haben eine so ähnliche Schreibweise schon mal in Verbindung mit varargs gesehen. Hier heißt es so viel wie: Binde beliebig viele Objekte an die Variable `tail`.
Diese Schreibweise leuchtet euch nicht ein? Gut, mir auch nicht. 😉
Einfacher wäre es `List(1, tail*)` zu schreiben, dies funktioniert aber aufgrund eines Bugs im Compiler nicht. Ein Bug-Report dazu ist schon vorhanden, jetzt muss er nur noch umgesetzt werden. Bis es soweit ist müssen wir uns mit der etwas umständlicheren Variante abfinden.
Guards
Eine weitere Eigenschaft des Pattern Matching sind die sogenannten Guards. Sie funktionieren ähnlich wie `filter` aus den for-Expressions:
def matching(any: Any) = any match {
case n: Int if n > 10 && n < 100 => "int"
case d: Double if scala.math.round(d) == 20 => "double"
case s: String => if (s.size != 6) "string" else s
case _ => "unknown"
}
Mit Hilfe der Guards können wir überprüfen die Überprüfungen noch weiter einschränken. Bitte beachtet, dass es einen Unterschied macht ob wir einen Guard (linke Seite von `=>`) oder eine if-Expression (rechte Seite von `=>`) verwenden. Die rechte Seite wird erst ausgeführt wenn das Muster auf der linken Seite passt. Das bedeutet, dass die rechte Seite auf jeden Fall einen else-Zweig besitzen muss, auf der linken Seite dagegen wird bei einem else-Fall einfach zum nächsten Muster weiter gesprungen:
scala> matching(56)
res20: java.lang.String = int
scala> matching(128)
res21: java.lang.String = unknown
scala> matching(20.345)
res22: java.lang.String = double
scala> matching("myname")
res23: java.lang.String = myname
scala> matching("hello")
res24: java.lang.String = string
Ich bin groß und du bist klein
In Verbindung mit Pattern Matching gibt es doch tatsächlich etwas wichtiges zu beachten: Es ist nicht egal ob wir einen gematchten Wert groß oder klein schreiben. Das Einhalten der Naming-Conventions (Variablen „lowerCamelCase“, Objekte „UpperCamelCase“) ist dient also nicht nur dem Verständnis des Codes, es dient auch zum Vorbeugen von Fehlern.
Schauen wir uns das mal genauer an:
scala> 5 match { case a: Int => "int" case _ => "unknown" }
res26: java.lang.String = int
scala> 5 match { case A: Int => "int" case _ => "unknown" }
<console>:1: error: '=>' expected but ':' found.
5 match { case A: Int => "int" case _ => "unknown" }
^
Da der Compiler den Anfang und das Ende eines Blocks bei den case-Statements selbst erkennen kann müssen wir die einzelnen case-Statements nicht mit einem Strichpunkt voneinander trennen.
Beim Code mit einem großgeschriebenen Variablennamen beschwert sich der Compier. Der Grund ist, dass der Compiler durch den großen Anfangsbuchstaben nun keine Variable mehr erwartet, sondern einen Extraktor! Er versucht den Extraktor der Klasse A aufzurufen. Da A ja aber schon ein Typ ist dürfen wir keinen weiteren Typ mehr angeben (was wir durch das `: Int` aber tun). So kommt es zur Fehlermeldung. Es ist also unerlässlich, dass Variablen immer klein geschrieben werden.
Auf ein weiteres Problem mit den Naming-Conventions stoßen wir wenn wir versuchen auf den Inhalt einer bereits existierenden Variable zu matchen:
scala> val a = 5
a: Int = 5
scala> 7 match { case a => "5" case _ => "unknown" }
<console>:8: error: unreachable code
7 match { case a => "5" case _ => "unknown" }
^
Der Compiler beschwert sich wegen unerreichbarem Code, warum? Wir glauben, dass wir den Inhalt der Variable `a` prüfen, das stimmt aber nicht. Das Pattern Matching Konstrukt erzeugt eine neue Variable a und bindet an diese jeden Wert, den wir eingeben. Dadurch kann das zweite Muster nie erreicht werden, da schon das erste true ergibt. Wollen wir auf den Wert einer Variable prüfen müssen wir diese in Backticks schreiben:
scala> 7 match { case `a` => "5" case _ => "unknown" }
res32: java.lang.String = unknown
scala> 5 match { case `a` => "5" case _ => "unknown" }
res33: java.lang.String = 5
Damit teilen wir dem Compiler mit, dass er keine neuen Variable mehr erzeugen, sondern stattdessen eine schon vorhandene dereferenzieren soll.
Backticks kommen auch zum Einsatz wenn wir z.B. eine Methode aus einer Java-Lib aufrufen wollen, die gleich heißt wie ein Schlüsselwort in Scala. So können wir nicht
obj.match(param)schreiben, da der Compiler die Methode match nicht als Methode sondern als Schlüsselwort erkennen würde. Die Schreibweise
obj.`match`(param)funktioniert dagegen.
Pattern Matching everywhere
Eine der größten stärken von Pattern Matching ist sicherlich, dass wir es überall anwenden können, nicht nur innerhalb eines match-Blocks:
scala> val head :: tail = List(1, 2, 3) head: Int = 1 tail: List[Int] = List(2, 3)
Was wir hier machen ist eigentlich ganz einfach. Wir haben eine Liste und binden dessen Elemente an verschiedene Variablen. Das macht besonders viel Sinn, wenn wir eine Methode haben, die komplexe Objekte zurück gibt und wir deren Werte an eine Variable binden wollen.
Um nochmal das Beispiel mit den Tuplen aus einem vorherigen Artikel aufzugreifen:
def heavyCalculation() = {
val memoryUsage = 50
val cpuUsage = 91
val networkUsage = 31
(memoryUsage, cpuUsage, networkUsage)
}
Die naheliegendste Version unseres Ziels würden wir jetzt ungefähr so erreichen:
scala> val usage = heavyCalculation() usage: (Int, Int, Int) = (50,91,31) scala> val memoryUsage = usage._1 memoryUsage: Int = 50 scala> val cpuUsage = usage._2 cpuUsage: Int = 91 scala> val networkUsage = usage._3 networkUsage: Int = 31
Aber warum nicht einfach folgendes schreiben?
scala> val (memoryUsage, cpuUsage, networkUsage) = heavyCalculation() memoryUsage: Int = 50 cpuUsage: Int = 91 networkUsage: Int = 31
Was ist wohl einfacher und übersichtlicher? Wir erzeugen einfach einen Tuple3 und prüfen mit Pattern Matching ob wir der Rückgabewert der Methode an eben diesen Tuple3 binden können. Ist dies der Fall werden die Werte dann an die Variablen gebunden und wir können schön unkompliziert weiter programmieren.
Das können wir uns auch innerhalb von for-Expressions zu nutze machen:
scala> val m = Map(1 -> "a", 2 -> "b", 3 -> "c")
m: scala.collection.immutable.Map[Int,java.lang.String] = Map(1 -> a, 2 -> b, 3 -> c)
scala> for ((pos, letter) <- m) println("the letter '"+letter+"' has the "+pos+". position in the alphabet")
the letter 'a' has the 1. position in the alphabet
the letter 'b' has the 2. position in the alphabet
the letter 'c' has the 3. position in the alphabet
Da die Map uns über lauter Tuple2 iterieren lässt können wir diese auch einfach auspacken und dann innerhalb der for-Expression damit arbeiten.
Das Prinzip funktioniert auch mit anderen Objekten gleich wie beim Tuple:
scala> val seq = Seq(Seq(1, 2, 3), Seq(4, 5, 6), Seq(7, 8, 9)) seq: Seq[Seq[Int]] = List(List(1, 2, 3), List(4, 5, 6), List(7, 8, 9)) scala> val Seq(Seq(_*), middle @ Seq(4, 5, 6), Seq(_*)) = seq middle: Seq[Int] = List(4, 5, 6)
Wir können die Seq so weit auseinanderbauen wie wir wollen und uns die Daten herausgreifen, die wir brauchen.
Wenn wir nur `_*` benutzen funktioniert das Matching, ein `x*` dagegen verurscht den Compilerfehler. Hier müssen wir also wieder zu `x @ _*` greifen.
Man kann mit Pattern Matching noch ein paar andere Sachen machen. Welche das sind und vor allem wie wir uns selbst eigene Extraktoren bauen können werden ich euch später erklären, nachdem wir die Objektorientierung hinter uns haben. Eigene Extraktoren würden uns z.B. erlauben so etwas zu schreiben:
val Person(name, address @ Address(city, zip, street)) = ...
Hier haben wir die zwei Klassen Person und Address und wollen an deren Inhalt rankommen. Sieht interessant aus, oder?
Teil 7: for-Expressions
Wir haben mit Schleifen und Iteratoren bereits eine Möglichkeit kennen gelernt um über Collections zu iterieren. Die for-Expression geht aber weit über die bereits kennen gelernten Möglichkeiten hinaus weshalb sie in keinem Repertoire eines Scala-Entwicklers fehlen sollte.
Die for-Expressions wird manchmal auch als for-Comprehension bezeichnet – sie wird aber nie als for-Schleife bezeichnet, denn sie ist keine Schleife. Sie mag vielleicht wie eine Schleife funktionieren, intern iteriert sie aber über Collections und wendet verschiedene Operationen darauf an. Weiterhin gibt sie – wie alles in Scala – ein Ergebnis zurück mit dem man später weiterarbeiten kann.
In Verbindung mit der for-Expression begegnen wir tatsächlich einem Operator, der keine Methode ist. Stattdessen ist er ein Schlüsselwort der Sprache:
scala> val xs = Seq(1, 2, 3) xs: Seq[Int] = List(1, 2, 3) scala> for (x <- xs) println(x) 1 2 3
Das `<-` bindet die Variable `x` an die Collection `xs`. Dass `<-` keine Methode ist erkennt man wenn man versucht es in `object notation` zu schreiben:
scala> for (x.<-(xs)) println(x)
<console>:1: error: identifier expected but '<-' found.
for (x.<-(xs)) println(x)
^
Man kann die Zuweisung in etwa so lesen: Iteriere über die Collection `xs` und binde jedes Element nacheinander an die Variable `x`, damit sie zur weiteren Verarbeitung zur Verfügung steht.
Die deklarierte Variable ist nur innerhalb der for-Expression gültig, von außerhalb kann nicht auf sie zugegriffen werden. Es ist außerdem nicht nötig dem Compiler mit `val` or `var` mitzuteilen, dass wir eine Variable erzeugen wollen – das erkennt er selbst. Um noch ein wenig tiefer ins Detail zu gehen: Eigentlich haben wir hier gar keine Variablendeklaration. Man spricht mehr von Generator. Der Grund warum dies so genannt wird liegt in der Implementierung der for-Expression, aber dazu später mehr.
Wenn unsere for-Expression nur eine Anweisung im Körper besitzt, können die geschweiften Klammern ausgelassen werden – aber das sollte soweit ja schon bekannt sein. Diese Standardversion der for-Expression arbeitet ein wenig wie die foreach-Schleife aus Java und sieht auch so ähnlich aus. Scalas for-Expression kann aber noch weitaus mehr als Javas Pendant.
Wir haben z.B. eine Liste mit Ints und wollen alle Elemente verdoppeln. In Java könnte das so aussehen:
List<Integer> doubleValues(List<Integer> list) {
List<Integer> ret = new ArrayList<>();
for (Integer i : list) {
ret.add(i*2);
}
return ret;
}
doubleValues(Arrays.asList(1, 2, 3, 4, 5));
Kompliziert oder? In Scala geht es einfacher:
scala> val xs = (1 to 5).toList xs: List[Int] = List(1, 2, 3, 4, 5) scala> val doubleValues = for (x <- xs) yield x*2 doubleValues: List[Int] = List(2, 4, 6, 8, 10)
Zwei Zeilen Code dank Scalas `yield`. `yield` macht an sich nichts anderes als die berechneten Werte zurückzugeben. Da es nur in Verbindung mit `for` auftauchen kann, bedarf es auch sonst keiner weiteren Syntax um die Rückgabewerte direkt an eine Variable zu binden. Besonders toll ist auch, dass wir Range dazu nutzen können um uns eine List mit Zahlen zurückzugeben, in diesem Fall 1-5.
Anmerkung:
yield arbeitet nicht wie return. Es gibt eine Expression zurück und kein Statement. Folgendes Codestück würde also nicht funktionieren:// does not work for(i <- 0 to 10) if (i % 2 == 0) yield i else yield -iKorrekt wäre dies hier:
for(i <- 0 to 10) yield if (i % 2 == 0) i else -iEine for-Expression kann nur ein einziges yield besitzen, welches dann aber auch immer einen Wert zurückgeben muss (falls keiner angegeben ist wird Unit zurückgegeben).
Indexbasierte Zugriffe
Javas foreach-Schleife hat den Nachteil, dass der Index des momentan benutzten Elementes nicht zur Verfügung steht. Benötigen wir diesen um z.B. ein Array aufzufüllen müssen wir wieder zur „normalen“ for-Schleife wechseln. Ein kurzes Codestück um zu verdeutlichen was ich meine:
int[] doubleValues(int[] ints) {
int[] res = new int[ints.length];
for (int i = 0; i < ints.length; i++) {
res[i] = ints[i]*2;
}
return res;
}
doubleValues(new int[] {1, 2, 3, 4, 5});
Wenn wir in Scala die for-Expression mit Ranges verbinden, dann braucht uns auch das nicht stören:
scala> val xs = (1 to 5).toArray xs: Array[Int] = Array(1, 2, 3, 4, 5) scala> val doubleValues = for (i <- (0 until xs.length)) yield xs(i)*2 doubleValues: scala.collection.immutable.IndexedSeq[Int] = Vector(2, 4, 6, 8, 10)
Einziger Wermutstropfen ist, dass der Typ von `doubleValues` Vector und nicht Array ist. Das kommt daher, weil die for-Expression den Typ der zu iterierenden Collection wählt, bei uns wäre das Range. Da Range aber nicht zur Verfügung steht um so `on the fly` aufgebaut zu werden wird innerhalb der Vererbungs-Hierarchie zum nächsthöheren Collection-Typ gegriffen, was die Standardimplementierung von IndexedSeq wäre. Wollen wir aber ein Array haben können wir es dies ganz einfach mit dem Aufruf von `toArray` auf doubleValues erzeugen.
Dank Scalas Typinferenz spielt es aber oft keine Rolle was für ein Typ die Collection hat mit der wir gerade arbeiten. Solange wir keine spezielle Eigenschaften der Implementierungen benötigen müssen wir die Typen nirgends angeben. Solange wir nur den statischen Ober-Typ einer Collection im Programm herumreichen (z.B. Seq) können uns die Runtime-Typen herzlich egal sein.
Auswahlen
Was machen wir wenn wir nur bestimmte Elemente aus einer Liste auswählen wollen? In Java würden wir eine if-Abfrage in die Schleife einbauen:
List<Integer> getEvenValues(List<Integer> list) {
List<Integer> ret = new ArrayList<>();
for (Integer i : list) {
if (i%2 == 0) {
ret.add(i);
}
}
return ret;
}
Und in Scala? Da machen wir es genauso:
scala> val evenValues = for (x <- (0 to 10)) if (x%2 == 0) yield x
<console>:1: error: illegal start of simple expression
val evenValues = for (x <- (0 to 10)) if (x%2 == 0) yield x
^
Oder doch nicht. Was hat nicht funktioniert? Zu erst einmal zu letzterer Eingabe. Da wir kein `yield` werden auch keine Werte zurückgegeben. Das ist soweit logisch. Aber bei der ersten Eingabe nutzen wir es doch? Das Problem hier ist, dass in Scala alles immer einen Wert zurückgeben muss. Wir würden jetzt aber immer nur einen Wert zurückgeben wenn die Abfrage true ergeben würde. Wie lösen wir das Problem jetzt? Die erste Lösungsidee könnte sein, dass wir das yield einfach umdrehen, aber dann fehlt uns immer noch der else-Teil der if-Abfrage:
scala> val evenValues = for (x <- (0 to 10)) yield if (x%2 == 0) x evenValues: scala.collection.immutable.IndexedSeq[AnyVal] = Vector(0, (), 2, (), 4, (), 6, (), 8, (), 10)
Bei allen ungeraden Zahlen gibt yield ein Unit zurück, repräsentiert durch die leeren Klammern. Wir haben jetzt also keine Seq aus Ints mehr, sondern eine Seq aus Ints und Units bzw. ihrem Obertyp AnyVal. Lösen können wir das Problem in dem wir die if-Abfrage in den Expression-Kopf verschieben:
scala> val evenValues = for (x <- (0 to 10) if x%2 == 0) yield x evenValues: scala.collection.immutable.IndexedSeq[Int] = Vector(0, 2, 4, 6, 8, 10)
Warum funktioniert das jetzt? Die Antwort ist leider ein wenig komplexer und ich glaube wenn ich sie euch jetzt beantworten würde, würdet ihr es wahrscheinlich nicht vollständig verstehen. Ich bitte euch mir also zu verzeihen wenn ich die Antwort in ein späteres Kapitel verschiebe.
Lenkt eure Aufmerksamkeit lieber auf das if. Wie ihr sehen könnt fehlen die Runden klammern. Der Grund dafür ist, dass das kein wirkliches if-ist, sondern ein `filter`. Hm, jetzt hab ich euch z.T. ja doch eine Antwort auf die Frage gegeben warum der Code funktioniert. Nun gut, dann kann ich euch auch gleich eine klein wenig ausführlichere Antwort geben: Die for-Expression iteriert über eine Collection. Mittels `filter`, einer Operation auf Collections, werden nur die Objekte herausgesucht, auf die eine Bedingung zutrifft. Diese herausgefilterten Daten werden dann zurückgeben. Implementierungsdetails will ich euch aber jetzt nicht geben, die hebe ich mir auf später auf, ich muss ja noch mehr zum Schreiben haben. 😉
Für jetzt könnt ihr euch merken, dass ihr innerhalb der for-Expression die Klammern um das if immer weglassen dürft, sie sonst aber überall hinschreiben müsst (na gut, fast überall ;)).
for-Expressions in depth
for-Expressions werden euch als kompliziert erscheinen wenn wir uns ihre Implementierung anschauen. Dies wollen wir zum jetzigen Zeitpunkt aber noch nicht machen und uns stattdessen auf die leicht verständliche Syntax und auf all die Möglichkeiten konzentrieren, die uns die Expression bietet. Wir versuchen einmal mehrere Generatoren miteinander zu verschachteln:
val arr = Array(
Array(1, 2, 3),
Array(4, 5, 6),
Array(7, 8, 9)
)
val values =
for (x <- (0 until 3)) yield
for (y <- (0 until 3)) yield
arr(x)(y)*3
Wir erhalten als Ausgabe:
Vector(Vector(3, 6, 9), Vector(12, 15, 18), Vector(21, 24, 27))
Anmerkung:
Wir erhalten mehrdimensionale Arrays indem wir ein Array in einem Array erzeugen. Im Gegensatz zu Java oder C bietet uns Scala keine spezielle Syntax an mit der wir diese Erzeugung „verkürzen“ können. Der Grund dafür ist, dass dies nicht gewollt ist. Die Syntax von Scala soll möglichst allgemein gehalten sein, also ohne dass irgendwelche Ausnahmen in der Syntax vorhanden wären, die ansonsten nicht zum Erscheinungsbild der Sprache passen würden. Aus dem gleichen Grund werden Arrays in Scala mit runden und nicht mit eckigen Klammern adressiert. Runde Klammern hat man einfach überall, die eckigen existieren aber nur in Zusammenhang mit Arrays. Auf ineinander verschachtelte Collections können wir zugreifen indem wir uns ganz einfach die innere Collection holen und dann mit gleicher Syntax deren Elemente.
Das Prinzip entspricht einer verschachtelten for-Schleife in Java. Wir müssen nur schauen, dass die erste Expression die Werte der zweiten Expression mittels `yield` zurück gibt. Der Code hat nur zwei Mankos. Erstens schaut er nicht besonders schön aus, da er redundant ist (zwei Mal ein for und zwei Mal ein yield) und zweitens gibt er uns eine Collection in einer Collection zurück, vielleicht wollen wir aber nur eine Collection, die dafür alle Werte beinhaltet. Auch dafür bietet Scala eine Lösung:
scala> val values = for (x <- (0 until 3); y <- (0 until 3)) yield arr(x)(y)*3 values: scala.collection.immutable.IndexedSeq[Int] = Vector(3, 6, 9, 12, 15, 18, 21, 24, 27)
Es ist möglich mehrere Generatoren einfach hintereinander zu schreiben wenn man sie mit einem Strichpunkt trennt. Dadurch können wir geschachtelte Abarbeitungen unserer Collections erreichen. Natürlich ist es weiterhin möglich einen `filter` zu benutzen:
scala> val values = for (x <- (1 to 3); y <- (1 to 3) if x*y%2 == 0) yield x*y values: scala.collection.immutable.IndexedSeq[Int] = Vector(2, 2, 4, 6, 6)
Blöd nur, dass wir den Ausdruck `x*y` zwei Mal berechnen müssen. Da gibt es doch bestimmt auch eine Möglichkeit das zu umgehen? Natürlich!
scala> val values = for (x <- (1 to 3); y <- (1 to 3); z = x*y if z%2 == 0) yield z values: scala.collection.immutable.IndexedSeq[Int] = Vector(2, 2, 4, 6, 6)
Die Zuweisung innerhalb der for-Expression nennt man eine Definition. Diese erlaubt uns, uns Zwischenergebnisse zu „merken“. Das ist ein nützliches Feature wie man anhand des oberen Codes gut erkennen kann. So, jetzt wird es nur langsam ein wenig unübersichtlich. Wir haben einfach zu viel Information auf einer einzigen Zeile Code, teilen wir das also mal auf:
val values = for ( x <- (1 to 3); y <- (1 to 3); z = x*y if z%2 == 0 ) yield z
Sieht doch schon ein bisschen besser aus. Jetzt sind nur noch die Regeln für das Setzen des Strichpunktes ein bisschen verwirrend. Einmal braucht man es, einmal nicht. Die Regel ist eigentlich einfach, denn sie besagt, dass man den Strichpunkt immer benötigt wenn mehrere Anweisungen hintereinander stehen. Die einzige Ausnahme bei dieser Regel ist der filter, der mit einem if eingeleitet wird. Wir können also anstatt
scala> def x = {
| println("hello")
| 5
| }
x: Int
auch
scala> def x = { println("hello"); 5 }
x: Int
schreiben. Beim zweiten Beispiel müssen wir die Anweisungen mit einem Strichpunkt trennen, ansonsten kann der Compiler nicht erkennen wann die Anweisung zu Ende ist. Wenn wir die Anweisungen hingegen auf mehrere Zeilen auftrennen, dann hat der Compiler die Möglichkeit anhand des Zeilensprung-Zeichens das Ende einer Anweisung zu erkennen. Wieso geht das dann bei unserer for-Expression nicht? Die Antwort darauf ist eigentlich ganz einfach, wir müssen uns nur darauf besinnen was ich schon mal erklärt habe: Mehrere Anweisungen müssen innerhalb eines Blocks stehen. Und wie deklariert man einen Block? Genau, mit geschweiften Klammern.
val values = for {
x <- (1 to 3)
y <- (1 to 3)
z = x*y
if z%2 == 0
} yield z
Man kann mit for-Expressions noch ein bisschen tiefer in die Materie von Scala einsteigen. Die schon ein paar Mal angesprochene Implementierung der for-Expression ist z.B. etwas was man erst versteht wenn man schon sehr tief in Scala bzw. in der funktionalen Programmierung steckt. Irgendwann werdet ihr so weit sein, dass ich euch erklären kann was da intern so alles abläuft.
REPL transcript mode
Seit Scala 2.9.1 ist mir der transcript mode der REPL erst so richtig bewusst geworden:
scala> scala> 4+1 // Detected repl transcript paste: ctrl-D to finish. // Replaying 1 commands from transcript. scala> 4+1 res48: Int = 5
In den früheren Versionen hat er zwar schon existiert, da bei Eingabe von `scala>` aber kein Kommentar erschien hab ich gedacht die REPL hätte sich aufgehängt. Auf die Idee, dass man den Modus mit STRG+D beenden kann bin ich nicht gekommen.
Der transcript mode ist besonders nützlich wenn man REPL output irgendwo gefunden hat (z.B. in diesem Blog), aber nicht jeden Befehl von Hand eingeben möchte.
Ein Beispiel:
scala> val xs = List(1, 2, 3)
xs: List[Int] = List(1, 2, 3)
scala> xs map { _+1 }
res51: List[Int] = List(2, 3, 4)
scala> xs filter { _ < 3 }
res52: List[Int] = List(1, 2)
scala> xs.size
res53: Int = 3
Einfach den kompletten Inhalt in die REPL kopieren und mit STRG+D parsen lassen.
Teil 6.3: Typkonvertierungen
Es kommt oft vor, dass wir einen Typ haben aber für eine bestimmte Operation einen ganz anderen brauchen. So erwartet eine Methode z.B. einen Double-Wert, wir haben aber nur einen Int. In diesem Fall wäre es kein Problem, da Scala sogenannte `widening-operations` besitzt. D.h. alle Zahlentypen werden automatisch in einen Typ konvertiert, der den Zahlentyp aufnehmen kann.
Die Wichtigsten davon sind:
Int -> Long -> Float -> Double
Dass das funktioniert sehen wir hier:
scala> val l: Long = 12345 l: Long = 12345 scala> val f: Float = l f: Float = 12345.0 scala> val d: Double = f d: Double = 12345.0
Wenn wir mit anderen Typen arbeiten finden keine solchen automatischen Konvertierungen statt, wir müssen sie also von Hand vornehmen. Das geht in Scala aber denkbar einfach. Alle Konvertierungsmethoden fangen mit `to` an und enden mit dem Typ in den sie konvertiert werden sollen:
scala> val xs = Seq(1, 2, 3) xs: Seq[Int] = List(1, 2, 3) scala> xs.toList res22: List[Int] = List(1, 2, 3) scala> xs.toSet res23: scala.collection.immutable.Set[Int] = Set(1, 2, 3) scala> xs.toString res24: String = List(1, 2, 3)
Natürlich können wir nicht jeden Typ in jeden konvertieren. Es ist z.B. nicht möglich eine Seq mit Int in eine Map zu wandeln. Was soll auch das Ergebnis sein? Die Map benötigt sowohl einen Schlüssel als auch einen dazugehörenden Wert:
scala> xs.toMap
<console>:10: error: Cannot prove that Int <:< (T, U).
xs.toMap
^
scala> val xs = Seq(1 -> "a", 2 -> "b", 3 -> "c")
xs: Seq[(Int, java.lang.String)] = List((1,a), (2,b), (3,c))
scala> xs.toMap
res26: scala.collection.immutable.Map[Int,java.lang.String] = Map(1 -> a, 2 -> b, 3 -> c)
Falls unsere Seq aber schon einen Tuple2 beinhaltet ist die Konvertierung gar kein Problem.
Neben den `to`-Methoden besteht auch noch die Möglichkeit mit Hilfe der `++`-Methode die Elemente einfach an eine andere Collection dranzuhängen:
scala> val xs = Seq(1 -> "a", 2 -> "b", 3 -> "c") xs: Seq[(Int, java.lang.String)] = List((1,a), (2,b), (3,c)) scala> Set.empty ++ xs res29: scala.collection.immutable.Set[(Int, java.lang.String)] = Set((1,a), (2,b), (3,c)) scala> Nil ++ xs res30: List[(Int, java.lang.String)] = List((1,a), (2,b), (3,c)) scala> Map.empty ++ xs res31: scala.collection.immutable.Map[Int,java.lang.String] = Map(1 -> a, 2 -> b, 3 -> c)
Die Methode `empty` erzeugt eine leere Collection, an die die Elemente angefügt werden können. Anstatt `empty` könnten wir auch einfach einen leeren Konstruktor aufrufen (z.B. `Set() ++ xs`). Wir müssen aber keinen leeren Konstruktor aufrufen sondern können ihm direkt unsere Collection übergeben:
scala> Set(xs) res33: scala.collection.immutable.Set[Seq[(Int, java.lang.String)]] = Set(List((1,a), (2,b), (3,c))) scala> Set(xs: _*) res34: scala.collection.immutable.Set[(Int, java.lang.String)] = Set((1,a), (2,b), (3,c))
Wir erhalten zwei verschiedene Ausgaben. Beim ersten Mal erhalten wir ein Set, das eine List mit Tuple2 beinhaltet. Beim zweiten Mal erhalten wir ein Set, das die Tuple2 ohne eine weitere List beinhaltet. Das letztere Ergebnis ist das das wir haben wollen, aber was ist der Unterschied?
Nun, der Konstruktor von Set erwartet ein Objekt, das er in das Set einfügen soll. Er kann ja nicht wissen ob wir die Seq oder die Tuple2 aufnehmen wollen. Wir müssen es ihm also irgendwie mitteilen.
Dies erreichen wir mit dem am Anfang etwas ungewöhnlich ausschauenden `: _*`. Die Bedeutung des Punktes kennen wir bereits. Damit wird ein Typ an eine Variable gebunden. Die beiden anderen Zeichen sind uns dagegen noch unbekannt. Der Stern kennzeichnet in Scala `varargs`. Wir können damit festlegen, dass beliebig viele Argumente an eine Variable gebunden werden können:
scala> def deliverVarArgs(i: Int*) { println(i) }
deliverVarArgs: (i: Int*)Unit
scala> deliverVarArgs(1, 2, 3, 4)
WrappedArray(1, 2, 3, 4)
Der Compiler erzeugt für uns ein WrappedArray[Int], das an die Variable `i` gebunden wird.
Das letzte unbekannte Symbol ist der Unterstrich. Dieser kann in Scala viele Bedeutungen haben. Die Wichtigste ist, dass er einen Platzhalter bzw. ein Wildcard-Symbol darstellt. In Verbindung mit dem Stern bedeutet es wandle etwas in ein varargs-Argument um. Das „etwas“ ist in diesem Fall die Variable `xs`. Wir können damit also angeben ob die Elemente der Seq oder die Seq selbst an den Konstruktor übergeben werden.
Zusammenfassend haben wir jetzt also drei Möglichkeiten kennen gelernt eine Collection in eine andere zu wandeln:
scala> Set.empty ++ xs res38: scala.collection.immutable.Set[(Int, java.lang.String)] = Set((1,a), (2,b), (3,c)) scala> Set(xs: _*) res39: scala.collection.immutable.Set[(Int, java.lang.String)] = Set((1,a), (2,b), (3,c)) scala> xs.toSet res40: scala.collection.immutable.Set[(Int, java.lang.String)] = Set((1,a), (2,b), (3,c))
Welche ihr anwenden wollt bleibt vollkommen euch überlassen. Es gibt sogar noch weitere Möglichkeiten, da diese aber nur in Spezialfällen angewendet werden werde ich erst später darauf eingehen.
Casts
Manchmal wollen wir aber gar nicht konvertieren, sondern casten. Wir haben z.B. eine Implementierung und wollen aber nur mit der Schnittstelle arbeiten. Oder aber es ist genau anders herum – wir haben die Schnittstelle wollen aber an die Implementierung. In Scala gibt es dafür `asInstanceOf`:
scala> val xs = List(1, 2, 3) xs: List[Int] = List(1, 2, 3) scala> xs.asInstanceOf[Seq[Int]] res42: Seq[Int] = List(1, 2, 3)
Bein Upcasten ist es aber auch ausreichend einer Variable einfach einen höheren Typ zu geben:
scala> val seq: Seq[Int] = xs seq: Seq[Int] = List(1, 2, 3)
Beim Downcasten müssen wir aufpassen ob der Cast zur Laufzeit auch funktionieren wird. Prüfen können wir dies mit `isInstanceOf`:
scala> val xs = Seq(1, 2, 3)
xs: Seq[Int] = List(1, 2, 3)
scala> xs.isInstanceOf[List[Int]]
<console>:9: warning: non variable type-argument Int in type List[Int] is unchecked since it is eliminated by erasure
xs.isInstanceOf[List[Int]]
^
res0: Boolean = true
scala> xs.asInstanceOf[List[Int]]
res1: List[Int] = List(1, 2, 3)
Scala unterliegt leider den gleichen Einschränkungen was Generics betrifft wie Java auch: type erasure. Die Typprüfung kann also nicht prüfen ob die Liste tatsächlich aus lauter Ints besteht, wir sollten also gar nicht erst auf einen genauen Typ prüfen:
scala> xs.isInstanceOf[List[String]]
<console>:9: warning: non variable type-argument String in type List[String] is unchecked since it is eliminated by erasure
xs.isInstanceOf[List[String]]
^
res3: Boolean = true
scala> xs.isInstanceOf[List[_]]
res4: Boolean = true
Der Unterstrich steht hier wieder für einen unbekannten Typ oder ein Wildcard. Durch den Cast können wir den Typinferenz-Checker überlisten und ihm falsche Daten vorgaukeln. Versucht also wenn es nur irgendwie möglich ist einen parametisierten Cast zu vermeiden:
scala> val ys: List[String] = xs.asInstanceOf[List[String]] // don't do that! ys: List[String] = List(1, 2, 3) scala> val ys: List[Any] = xs.asInstanceOf[List[Any]] // ok ys: List[Any] = List(1, 2, 3)
Any entspricht dem Object von Java. Es ist der Obertyp aller Objekte in Scala, in ihn kann also immer gecastet werden. Grundsätzlich sollte man Casts aber immer vermeiden, da sie fehleranfällig sind. Es gibt in Scala andere, bessere Wege um auf korrekte Typen zu überprüfen. Von diesen werdet ihr noch früh genug etwas hören. Zum Schluss noch eine Graphik, die Scalas wichtigste Typen auflistet:

Teil 6.2: Set, Tuple und Map
Sequenzen von Daten reichen für manche Anwendungsfälle nicht aus. Wenn wir Beispielsweise Daten sortiert halten wollen ist eine Seq nicht die beste Wahl, da das sortierte Einfügen ein Flaschenhals darstellen kann. Das gleiche gilt wenn wir Daten suchen wollen. Die Seq zu durchlaufen und jedes Element zu überprüfen ob es auf unsere Anforderungen passt kann bei vielen Elementen viel zu lange dauern. Aber dafür haben wir ja Sets und Maps, die dafür genau das Richtige sind.
Da beide Collection-Typen vom gleichen Obertyp erben wie Seq können wir auf die gleichen Methoden zurückgreifen um die Datenstrukturen anzusprechen.
Set
Testen wir die Methoden von Set doch gleich einmal:
scala> val set = Set(1, 2, 3) set: scala.collection.immutable.Set[Int] = Set(1, 2, 3) scala> set contains 3 res116: Boolean = true scala> set contains 5 res117: Boolean = false scala> set ++ List(3, 4, 5) res118: scala.collection.immutable.Set[Int] = Set(5, 1, 2, 3, 4)
Wie zu erkennen speichert das Set nur Unikate. Wenn wir also eine Liste aus Daten haben und nur die Anzahl der verschiedenen Elemente haben wollen genügt es diese einfach in ein Set einzufügen und dann die Größe des Sets zu ermitteln:
scala> val xs = List(2, 5, 1, 7, 3, 35, 72, 23, 6, 1, 73, 35, 76, 86, 17) xs: List[Int] = List(2, 5, 1, 7, 3, 35, 72, 23, 6, 1, 73, 35, 76, 86, 17) scala> val set = Set() ++ xs set: scala.collection.immutable.Set[Int] = Set(5, 1, 6, 73, 2, 17, 86, 76, 7, 3, 35, 72, 23) scala> set.size res119: Int = 13
Ein weiteres Merkmal ist, dass die Elemente im Set nicht sortiert sind, wie wir an der String-Repräsentation erkennen können. Um dies zu ändern müssen wir anstatt eines Sets einfach ein SortedSet referenzieren:
scala> import scala.collection.immutable.SortedSet import scala.collection.immutable.SortedSet scala> val set = SortedSet[Int]() ++ xs set: scala.collection.immutable.SortedSet[Int] = TreeSet(1, 2, 3, 5, 6, 7, 17, 23, 35, 72, 73, 76, 86)
Es gibt in scala.Predef keine Typreimplementierung von SortedSet weshalb wir es von Hand importieren müssen. Dies erreichen wir ganz einfach mit dem import-Statement. Bitte beachtet, dass wir das SortedSet von Hand typisieren müssen, die die Typinferenz des Compilers hier nicht mehr ausreicht.
Tuple
Der Konstruktor von Map erwartet sogenannte Tuple von Daten. Um also eine Map zu erstellen müssen wir zuerst einen Tuple erstellen. Aber was ist ein Tupel? Ein Tupel ist eine Datenstruktur, die beliebige Daten aufnehmen und mit einander „verbinden“ kann. Die einfachste Möglichkeit einen Tuple zu kreieren ist einfach irgendwelche Daten mit einem Komma voneinander zu trennen und diese dann einzuklammern:
scala> ("de", "Germany")
res125: (java.lang.String, java.lang.String) = (de,Germany)
scala> (1, "hello", 5.34)
res126: (Int, java.lang.String, Double) = (1,hello,5.34)
In Scala können bis zu 22 solcher Daten „vertupelt“ werden. Tuple stellt aber keine Collection dar. Die Methoden, die wir bisher kennen gelernt haben sind größtenteils deshalb auch nicht darauf anzuwenden. Ein Tuple soll auch nicht als Collection dienen. Statt dessen soll er die Möglichkeit bieten mehrere zusammenhänge Daten innerhalb des Codes hin und her zu reichen, ohne dass man sich dafür eine eigene Datenstruktur bauen muss. Brauchen können wir dies bspw. bei einer Methode, die mehrere Daten zurückgeben soll:
def heavyCalculation(): (Int, Int) = {
val memoryUsage = 30
val cpuUsage = 91
(cpuUsage, memoryUsage)
}
Stellen wir uns vor, dass diese Methode große Berechnungen machen müsste. Nachdem die Berechnungen abgeschlossen sind würden wir gerne Wissen wie viel Systemressourcen sie benötigt haben. Wir geben also die Speicher- und CPU-Auslastung in Prozent zurück. Der Rückgabewert wird ebenfalls in Klammern geschrieben um dem Compiler zu verdeutlichen, dass ein Tuple zurückgegeben werden soll.
Tuple haben nur einen kleinen Schönheitsfehler wenn man versucht auf ihre Elemente zuzugreifen:
scala> heavyCalculation() res130: (Int, Int) = (91,30) scala> res130._1 res131: Int = 91 scala> res130._2 res132: Int = 30
Da ein Tuple beliebige Daten aufnehmen kann gab es bei ihrer Implementierung keine Möglichkeit einen geeigneten Namen zu definieren der zu jedem Rückgabewert passt. Man hat sich deshalb auf die Schreibweise tuple._N entschieden wobei N ein Wert zwischen 1 und 22 ist. Je nach Größe des Tuples kann man dann damit an dessen Elemente gelangen. Wird ein Feld adressiert, das bei der momentanen Größe des Tuples nicht existieren kann bekommen wir eine Fehlermeldung:
scala> res130._3
<console>:11: error: value _3 is not a member of (Int, Int)
res130._3
^
Wenn ein Tuple die Größe 2 hat wird auch von einem Tuple2 gesprochen, bei der Größe 3 von einem Tuple3 usw. Um einen Tuple2 zu erstellen gibt es noch eine spezielle Methode:
scala> 1 -> "hello"
res141: (Int, java.lang.String) = (1,hello)
scala> (1).->("hello")
res142: (Int, java.lang.String) = (1,hello)
Dies ist syntaktischer Zucker für die wohl am meisten erstellte Tuple-Art. Vor allem in Verbindung mit Maps werden wir regen Gebrauch davon machen. Um an alle Elemente eines Tuples zu kommen bieten sich die Method `productIterator` an:
scala> val t = (6, "hello", 3.245, true, 'h') t: (Int, java.lang.String, Double, Boolean, Char) = (6,hello,3.245,true,h) scala> val i = t.productIterator i: Iterator[Any] = non-empty iterator scala> while (i.hasNext) println(i.next) 6 hello 3.245 true h
Wir erhalten einen Iterator, über den wir nach Lust und Laune iterieren können.
Map
Maps besitzen die tolle Eigenschaft, dass sie zu einem bestimmten Schlüssel einen Wert abspeichern. Das könnte z.B. so aussehen:
scala> val m = Map("de" -> "Germany", "en" -> "England", "fr" -> "France")
m: scala.collection.immutable.Map[java.lang.String,java.lang.String] = Map(de -> Germany, en -> England, fr -> France)
Um einen Wert aus einer Map zu erhalten gibt es zwei Möglichkeiten:
scala> m("de")
res144: java.lang.String = Germany
scala> m.get("de")
res145: Option[java.lang.String] = Some(Germany)
Die erste Schreibweise kennen wir bereits. Die Zweite ist auch nicht viel komplizierter gibt aber einen komischen Typ namens Some zurück. Some ist ein Untertyp vom Typ Option. Wofür dieser gut ist sehen wir wenn wir einen Wert anfordern der nicht existiert.
scala> m("ch")
java.util.NoSuchElementException: key not found: ch
<stacktrace>
scala> m.get("ch")
res147: Option[java.lang.String] = None
Im ersten Fall erhalten wir eine Exception, die wir natürlich nicht erhalten wollen. Die Vorgehensweise in Java wäre jetzt zuerst zu prüfen ob der Schlüssel existiert und nur dann seinen Wert anzufordern. Es würde auch die Möglichkeit bestehen einfach die Exception abzufangen und zu verarbeiten. Die Vorgehensweisen haben aber ein Problem: Sie erfordern viel unnützen Code, der uns nicht direkt bei der Bewältigung unserer Aufgabe weiterbringt. Hier eine Beispielimplementierung der ersten Vorgehensweise:
def mapValue[A, B](m: Map[A, B], s: A) = if (m contains s) m(s) else null scala> mapValue(m, "de") res151: String = Germany scala> mapValue(m, "ch") res152: String = null
Die erste Implementierung ist an sich sehr kurz, wir müssen aber null zurückgeben wenn der Wert nicht existiert. Der große Nachteil des Codes ist weiterhin, dass er nicht generisch ist. Würden wir das auch noch einbauen wollen müssten wir nochmal ein bisschen mehr schreiben. Die Implementierung mit Exceptions würde ich gerne auslassen, da sie wohl sowieso niemand praktisch anwendet. Sie würde sowieso mehr Codezeilen beanspruchen.
Scala macht es sich da einfacher und gibt ein Option zurück, das entweder Some oder None sein kann, je nach dem ob ein Wert existiert oder nicht. Was man mit Option so alles machen und wie es uns vor NullPointerExceptions beschützen kann möchte ich nochmal gesondert besprechen, das soll nicht Inhalt dieses Artikels sein.
Um an die Schlüssel oder Werte einer Map zu gelangen werden uns die folgenden Methoden bereitgestellt:
scala> m.keys res158: Iterable[java.lang.String] = Set(de, en, fr) scala> m.values res159: Iterable[java.lang.String] = MapLike(Germany, England, France)
Mehr brauchen wir momentan eigentlich nicht über Map zu wissen.
Rechts-assoziative Methoden
Rechts-assoziative Methoden dürften allgemein bekannt sein. Darunter fallen alle Methoden, die mit einem Doppelpunkt enden und in Infix- bzw. Operator-Position stehen:
scala> val xs = Seq(1, 2, 3) xs: Seq[Int] = List(1, 2, 3) scala> 4 +: xs res98: Seq[Int] = List(4, 1, 2, 3)
So weit so gut. Was allgemein weniger bekannt sein dürfte ist, dass das nur für Methoden mit einem Parameter funktioniert:
scala> class X { def -: (i: Int, s: String) {} }
defined class X
scala> new X
res20: X = X@6a2a9a0e
scala> (0, "") -: res20
<console>:11: error: not enough arguments for method -:: (i: Int, s: String)Unit.
Unspecified value parameter s.
(0, "") -: res20
^
Der Grund warum dies nicht funktioniert finden wir in der specin Section 6.12.3:
A left-associative binary operation e1 op e2 is interpreted as e 1.op(e2). If op is right-associative, the same operation is interpreted as { val x =e1; e2.op(x) }, where x is a fresh name.
Dies bedeutet, dass unser Code in etwa zu diesem transformiert wird:
{
val x = (0, "")
res99.-:(x)
}
Wie zu erkennen haben wir plötzlich keine zwei Parameter mehr, sondern einen Tuple2. Das passt nicht mehr. Anders herum ist es aber kein Problem:
scala> println(1, "hello") (1,hello)
Wir können einen Tuple erstellen, ohne dass wir ihn in extra Klammern schreiben müssen, stattdessen reicht das Klammerpaar der Parameterliste aus.
Comments (4)