Archive for August 2011|Monthly archive page
Teil 13: Fallklassen
Im letzten Kapitel haben wir Extraktoren kennen gelernt. Ich habe euch bereits ein paar Möglichkeiten gezeigt wie man sie einsetzen kann, tatsächlich gibt es aber noch viel mehr Möglichkeiten wie man sie noch einsetzen kann. Durch ihre Hilfe ist es uns z.B. möglich komplexe Ausdrücke komfortabel auszuwerten, typsichere Aufzählungen zu kreieren oder Objekte miteinander kommunizieren zu lassen. Durch die Vielzahl an Möglichkeiten wie man sie einsetzen kann wurde Scala durch sogenannte Fallklassen (engl.: case class) aufgewertet, die „normale“ Klassen durch syntaktischen Zucker erweitern und uns die Arbeit erleichtern.
Schauen wir uns noch einmal das Personen-Beispiel an:
class Person(val name: String, val age: Int, val address: Address)
object Person {
def unapply(p: Person) = Some(p.name, p.age, p.address)
}
class Address(val city: String, val zip: Int, val street: String)
object Address {
def unapply(a: Address) = Some(a.city, a.zip, a.street)
}
Wir mussten wir für unsere Klassen die Extraktoren erzeugen. Wenn wir wollten, könnten wir auch noch eine apply-Methode erstellen. Es gibt jedoch viel zu viele Anwendungsfälle bei denen wir von Extraktoren Gebrauch machen können, folglich haben wir unnötig viel Code-Duplikation. Genau aus diesem Grund gibt es Fallklassen, die denkbar einfach zu erstellen sind.
case class Person(name: String, age: Int, address: Address) case class Address(city: String, zip: Int, street: String)
Es genügt das Schlüsselwort case vor die Klasse zu schreiben. Schauen wir uns gleich mal an, was uns das bringt:
scala> val p = Person("franz", 43, Address("gornau", 12345, "rabenweg"))
p: Person = Person(franz,43,Address(gornau,12345,rabenweg))
scala> val Person(name, age, address @ Address(city, zip, street)) = p
name: String = franz
age: Int = 43
address: Address = Address(gornau,12345,rabenweg)
city: String = gornau
zip: Int = 12345
street: String = rabenweg
scala> p.name
res0: String = franz
scala> p.address.zip
res1: Int = 12345
Wie zu erkennen entfällt die Erstellung einer apply- und unapply-Methode. Der Compiler erstellt für uns ein Companion-Objekt und erzeugt die genannten Methoden. Weiterhin implementiert er noch eine Reihe weiterer Methoden für unsere Fallklassen. Dazu gehören z.B. toString(), equals() und hashCode(). Erstere gibt uns eine ansehnliche Stringrepräsentation unserer Klasse zurück, die beiden anderen ermöglichen uns Objekte miteinander zu vergleichen.
Weiterhin erzeugt uns der Compiler auch noch Getter für unsere Attribute ohne dass wir dies explizit durch ein val angeben müssten.
Schauen wir uns noch an wie wir all diese Methoden nutzen können.
scala> class IntHolder(i: Int)
defined class IntHolder
scala> new IntHolder(17) == new IntHolder(17)
<console>:9: warning: comparing a fresh object using `==' will always yield false
new IntHolder(17) == new IntHolder(17)
^
res0: Boolean = false
scala> case class IntHolder(i: Int)
defined class IntHolder
scala> IntHolder(17) == IntHolder(17)
res1: Boolean = true
scala> IntHolder(17) == IntHolder(3)
res2: Boolean = false
Das Vergleichen unserer Objekte funktioniert perfekt. Der Compiler sorgt dafür, dass wir keine Fehler bei den Vergleichsmethoden machen können. So können wir Fallklassen auch in ein Set einfügen und sicher gehen, dass auch wirklich keine Duplikate enthalten sind:
scala> class IntHolder(i: Int) defined class IntHolder scala> Set(new IntHolder(23), new IntHolder(23)) res6: scala.collection.immutable.Set[IntHolder] = Set(IntHolder@b3f451d, IntHolder@66d278af) scala> case class IntHolder(i: Int) defined class IntHolder scala> Set(IntHolder(23), IntHolder(23)) res7: scala.collection.immutable.Set[IntHolder] = Set(IntHolder(23))
Sehr schön ist auch die Erstellung einer copy-Methode, die es uns erlaubt unveränderliche Objekte komfortabel zu kopieren:
scala> case class X(i: Int, s: String) defined class X scala> val x1 = X(8, "hello") x1: X = X(8,hello) scala> val x2 = x1.copy(s = "world") x2: X = X(8,world)
Durch benannte Argumente können wir nur das Attribut angeben, das geändert werden soll. Manchmal ganz nützlich ist eine Methode namens productIterator, mit der wir über alle Elemente unserer Klasse iterieren können:
scala> for (x <- x1.productIterator) println(x) 8 hello
Wenn das alles so einfach geht, warum habe ich euch dann im letzten Kapitel mit Extraktoren gequält? Der Grund ist keinesfalls meine Boshaftigkeit 😉 oder weil ich nach dem Motto „Warum einfach wenn es auch kompliziert geht?“ lebe, sondern einfach damit ihr es mal gesehen habt. In den meisten Anwendungsfällen werden Fallklassen vollkommen ausreichen, manchmal muss man aber eben doch noch selbst Hand anlegen und dann ist es nicht schlecht wenn man weiß was man machen muss.
Da es zu Fallklassen eigentlich nicht mehr viel zu sagen gibt wollen wir uns gleich ein paar Anwendugsgebiete anschauen.
Algebraische Datentypen (ADT)
Den Anfang macht erst einmal eine Erklärung zu ADTs. Oh Gott, das hört sich wieder kompliziert an, was ist das wieder für eine neue Teufelei? Ich kann euch beruhigen, ADT ist nur der Name eines Pattern, das ihr sogar schon kennen gelernt habt. Von einem ADT wird immer dann gesprochen wenn ein Datentyp die Gestalt eines Typs aus einer Menge von mehreren zusammengehörenden Typen ist. Hört sich kompliziert an? Für den Anfang ja, aber schauen wir uns ein Beispiel an und quälen uns nicht mit der Theorie herum:
data Bool = True | False
Betrachten wir den Typ, der mit data spezifiziert wird (hier Bool) als ein Typ, der die Formen True oder False annehmen kann (wobei der senkrechte Strich ein Oder symbolisiert). In Scala ist es leider nicht möglich ADTs so kurz und praktisch wie oben gezeigt zu notieren (obiger Code wäre valider Haskell-Code), durch Fallklassen hält sich der Overhead an Code aber in Grenzen:
abstract class Bool case object True extends Bool case object False extends Bool
Durch die Repräsentation des ADT mit einer geeigneten Vererbungshierarchie dürftet ihr es leichter haben euch das Typensystem vorzustellen. Wir haben einen abstrakten Obertyp, der durch mehrere Unterobjekte repräsentiert werden kann. In Scala besitzen wir die Möglichkeit durch ein object zu spezifizieren, dass ein Typ nur einmal existieren darf bzw. soll. Mehrere unterschiedliche True- oder False-Werte würden keinen Sinn machen, wir unterbinden also die Möglichkeit sie mehrfach zu erstellen. Den List-ADT habt ihr bereits kennen gelernt:
data IntList = Cons Int IntList | IntNil abstract class IntList case class Cons(head: Int, tail: IntList) extends IntList case object IntNil extends IntList
ADTs sind in Scala lang nicht so schön zu implementieren wie z.B. in Haskell. Das liegt nicht nur daran, dass der syntaktische Overhead größer ist, sondern auch daran, dass wir jederzeit die Möglichkeit haben weitere Typen zu ergänzen. In Scala hindert uns niemand daran noch weitere Klassen zu erstellen, die von Bool erben. In Haskell wäre dies nach der Spezifizierung von Bool nicht mehr möglich. Der Grund warum wir nicht möchten, dass weitere Typen ergänzt werden ist Typsicherheit. Um zu verdeutlichen was für Probleme entstehen könnten schauen wir uns am besten folgendes Beispiel an:
def determine(b: Bool) = b match {
case True => "true"
case False => "false"
}
scala> determine(True)
res0: java.lang.String = true
scala> determine(False)
res1: java.lang.String = false
Durch Pattern Matching können wir bestimmen was für ein genauer Typ Bool zur Laufzeit hat. Wie zu erkennen besitzt die Methode keinen Default-Wert. Würden wir also einen anderen Typ als True oder False übergeben bekämen wir einen MatchError:
scala> case object X extends Bool defined module X scala> determine(X) scala.MatchError: X (of class X$) <stack trace>
In Haskell könnte der Code niemals fehl schlagen, da es ja keine Möglichkeit gibt nachträglich noch Typen zu ergänzen. In Scala müssen wir damit leben, dass uns der Compiler diese Typsicherheit nicht bieten kann. Falls also die Möglichkeit besteht, dass eines unserer Programme durch dieses Manko instabil bzw. manipuliert werden könnte, dann müssen wir wohl oder übel Default-Fälle einbauen.
Ein weiteres Beispiel für einen ADT wäre Int:
data Int = -2147483648 | ... | -1 | 0 | 1 | ... | 2147483647
Int kann als minimaler Wert -2³¹ und als maximaler Wert 2³¹-1 annehmen. Int wäre also immer ein Typ aus der Menge dieser Int-Literale.
Wenn wir die theoretische Seite der ADTs noch ein wenig genauer betrachten, dann stellen wir fest, dass ein ADT nur durch andere Typen der selben Menge aufgebaut werden kann und dass dessen Werte durch Pattern Matching extrahiert werden können. Was genau das bedeutet, soll uns wieder ein Beispiel erklären:
abstract class Shape
case class Point(x: Float, y: Float) extends Shape
case class Circle(p: Point, r: Float) extends Shape
case class Rectangle(p: Point, h: Float, w: Float) extends Shape
import math._
def area(s: Shape) = s match {
case Point(_, _) => 0
case Circle(_, r) => Pi*r*r
case Rectangle(_, h, w) => h*w
}
def pointIntersectWith(p: Point, s: Shape) = p -> s match {
case (Point(x1, y1), Point(x2, y2)) =>
x1 == x2 && y1 == y2
case (Point(x1, y1), Circle(Point(x2, y2), r)) =>
(x1 >= x2-r && x1 <= x2+r) && (y1 >= y2-r && y1 <= y2+r)
case (Point(x1, y1), Rectangle(Point(x2, y2), h, w)) =>
(x1 >= x2 && x1 <= x2+w) && (y1 >= y2 && y1 <= y2+h)
}
In diesem Beispiel haben wir mehrere zweidimensional Figuren. Innerhalb der Methoden bauen wir uns die ADTs mit Hilfe der Extraktoren auseinander. Für alle Werte, die wir nicht benötigen, setzen wir den Unterstrich ein. Es ist sehr gut zu erkennen, dass einzelne Figuren auf anderen basieren. So benötigt ein Rechteck einen Punkt, der dessen Position im Koordinatensystem angibt. Testen wir den Code auf seine Funktionsweise:
scala> val c = Circle(Point(6, 7), 2) c: Circle = Circle(Point(6.0,7.0),2.0) scala> val r = Rectangle(Point(1, 2), 5, 3) r: Rectangle = Rectangle(Point(1.0,2.0),5.0,3.0) scala> area(c) res5: Double = 12.566370614359172 scala> area(r) res6: Double = 15.0 scala> pointIntersectWith(Point(1, 2), r) res7: Boolean = true scala> pointIntersectWith(Point(8, 8), c) res8: Boolean = true scala> pointIntersectWith(Point(8, 12), c) res9: Boolean = false
Konstruktion und Extraktion erfordern eine nahezu identische Syntax, aber das ist uns bereits bekannt. Der Code funktioniert zwar, ist aber nicht besonders schön. Dank Scalas Objektorientierung können wir die benötigten Methoden auch in die ADTs verschieben:
abstract class Shape {
def area: Float
def intersectWith(s: Shape): Boolean
}
case class Point(x: Float, y: Float) extends Shape {
def area = 0
def intersectWith(s: Shape)= s match {
case Point(x, y) =>
this.x == x && this.y == y
case Circle(Point(x, y), r) =>
(this.x >= x-r && this.x <= x+r) && (this.y >= y-r && this.y <= y+r)
case Rectangle(Point(x, y), h, w) =>
(this.x >= x && this.x <= x+w) && (this.y >= y && this.y <= y+h)
}
}
case class Circle(p: Point, r: Float) extends Shape {
import math._
def area = (Pi*r*r).toFloat
def intersectWith(s: Shape) =
throw new UnsupportedOperationException("circle.intersectWith")
}
case class Rectangle(p: Point, h: Float, w: Float) extends Shape {
def area = h*w
def intersectWith(s: Shape) =
throw new UnsupportedOperationException("rectangle.intersectWith")
}
Die Objektorientierung erlaubt uns die Operator-Notation zu gebrauchen und den Code, der zu einem Objekt gehört, einfacher zu verwalten. Weiterhin gewinnen wir ein wenig Performance, da dank reduziertem Pattern Matching weniger Überprüfungen zur Laufzeit stattfinden müssen. Zwei der Methoden hab ich noch nicht implementiert und deshalb mit einer Exception versehen. Ich werde in einem späteren Artikel noch genauer auf Exceptions eingehen, momentan reicht es zu wissen, dass der String, der an die Exception übergeben wird, später im Stack-Trace stehen wird und dass die Exception mit dem Schlüsselwort throw aus der Methode geworfen wird. Exceptions sind in Scala Untertypen eines jeden Typs weshalb kein weiterer Rückgabetyp für die Methode angegeben werden muss.
scala> val r = Rectangle(Point(1, -2), 6, 3) r: Rectangle = Rectangle(Point(1.0,-2.0),6.0,3.0) scala> Point(3, 4) intersectWith r res16: Boolean = true scala> val c = Circle(Point(7, 3), 5) c: Circle = Circle(Point(7.0,3.0),5.0) scala> c.area res17: Float = 78.53982 scala> r intersectWith c java.lang.UnsupportedOperationException: rectangle.intersectWith
Der Code funktioniert wie erwartet und sollte keine weiteren Erklärungen mehr benötigen.
Ein Problem auf das man immer wieder stoßen kann, ist dass man vergisst auf bestimmte Typen zu prüfen. Die Wahrscheinlichkeit dafür steigt, umso größer die Menge an verfügbaren Typen ist. Scala stellt uns deswegen das Schlüsselwort sealed zur Verfügung, das den Compiler anweist zu überprüfen ob wir innerhalb der Pattern auch auf alle Typen überprüfen:
sealed abstract class Weekday
case object Mo extends Weekday
case object Tu extends Weekday
case object We extends Weekday
case object Th extends Weekday
case object Fr extends Weekday
case object Sa extends Weekday
case object Su extends Weekday
def isWeekend(w: Weekday) = w match {
case Sa | Su => true
case _ => false
}
def doTask(w: Weekday) = w match {
case Sa | Su => "sleep very long"
case Mo | Fr => "work less"
case We | Th => "work hard"
}
Bei der Methode doTask erhalten wir vom Compiler eine Warnung:
<console>:17: warning: match is not exhaustive!
missing combination Tu
def doTask(w: Weekday) = w match {
^
doTask: (w: Weekday)java.lang.String
Diese verschwindet erst nachdem wir für den Dienstag eine entsprechende Tätigkeit definiert haben. Obiges Beispiel ist eine Aufzählung, die wir in Scala anstatt von Enums verwenden können. Scala selbst bietet sowohl die Möglichkeit ADTs oder die klassischen Enums als Aufzählungstyp zu verwenden. Einen Enum erstellen wir indem wir ein Objekt von der Klasse Enumeration erben lassen und allen Aufzählungstypen den Value-Wert zuweisen:
object Weekday extends Enumeration {
val Mo, Tu, We, Th, Fr, Sa, Su = Value
}
import Weekday._
def isWeekend(w: Weekday.Value) = w match {
case Sa | Su => true
case _ => false
}
scala> isWeekend(Tu)
res19: Boolean = false
scala> for(w <- Weekday.values) println(w)
Mo
Tu
We
Th
Fr
Sa
Su
scala> Weekday withName "Mo"
res25: Weekday.Value = Mo
Ob man sich nun für Enums oder für ADTs entscheidet hängt ganz vom Anwendungsfall ab. Enums besitzen ein paar nützliche Methoden, die es ermöglichen ein Enum anhand eines Strings zu bestimmen oder mit denen man über alle Werte iterieren kann. Dafür lassen sie sich nicht objektorientiert nutzen, da sie nicht durch Klassen, sondern durch eine Variable repräsentiert werden. Werden also Aufzählungen mit verschiedenen Verhaltensweisen benötigt empfiehlt es sich auf ADTs zu setzen.
Hinweis:
Wir müssen nicht unbedingt ein ‚case‘ vor ein ‚object‘ setzten wenn wir es innerhalb von Pattern Matchings benutzen wollen. Das ‚case‘ hat tatsächlich nur ein geringen Nutzen, da für ein ‚object‘ keine apply-, unapply- und all die anderen Methoden generiert werden müssen. Die einzige Methode, die uns durch das ‚case‘ geschenkt wird und die auch von Nutzen ist, ist toString(), die den Namen des ‚object‘ zurück gibt.
Syntaxbäume
Eine weitere Möglichkeit ADTs einzusetzen ergibt sich bei der Erstellung von Syntaxbäumen. Ein Syntaxbaum besteht aus lauter Expressions, die sich in Terme, Faktoren, Literale und andere Symbole gliedern lassen. Versuchen wir dies in Scala abzubilden:
sealed abstract class Exp
case class Lit(v: BigDecimal) extends Exp
case class Add(n1: Exp, n2: Exp) extends Exp
case class Sub(n1: Exp, n2: Exp) extends Exp
case class Mul(n1: Exp, n2: Exp) extends Exp
case class Div(n1: Exp, n2: Exp) extends Exp
def eval(n: Exp): BigDecimal = n match {
case Lit(v) => v
case Add(n1, n2) => eval(n1)+eval(n2)
case Sub(n1, n2) => eval(n1)-eval(n2)
case Mul(n1, n2) => eval(n1)*eval(n2)
case Div(n1, n2) => eval(n1)/eval(n2)
}
Anhand der dargestellten Datentypen können wir bequem einen Syntaxbaum aufbauen und durch die eval-Methode evaluieren:
scala> val e1 = Mul(Add(Lit(3), Lit(4)), Sub(Lit(9), Lit(2)))
e1: Mul = Mul(Add(Lit(3),Lit(4)),Sub(Lit(9),Lit(2)))
scala> eval(e1)
res10: BigDecimal = 49
scala> (3+4)*(9-2)
res11: Int = 49
scala> val e2 = Div(Lit(7), Sub(Add(Lit(3), Lit(4)), Lit(3)))
e2: Div = Div(Lit(7),Sub(Add(Lit(3),Lit(4)),Lit(3)))
scala> eval(e2)
res12: BigDecimal = 1.75
scala> val e3 = Mul(Lit(BigDecimal("8643356779865464.5")), Lit(BigDecimal("78953642.159865456879")))
e3: Mul = Mul(Lit(8643356779865464.5),Lit(78953642.159865456879))
scala> eval(e3)
res14: BigDecimal = 682424498257544872878103.7104460553
Durch den Einsatz von BigDecimal als Datentyp können wir Zahlen beliebiger Genauigkeit nutzen. Möchten wir maximale Präzision beim errechnen der Zahlen, müssen wir BigDecimal mit einem String konstruieren. Bei der Eingabe von Int-Literalen müssen wir die apply-Methode von BigDecimal nicht explizit aufrufen. Die eval-Methode könnten wir dank Scalas Syntaxzucker auch so schreiben:
def eval(n: Exp): BigDecimal = n match {
case Lit(v) => v
case n1 Add n2 => eval(n1)+eval(n2)
case n1 Sub n2 => eval(n1)-eval(n2)
case n1 Mul n2 => eval(n1)*eval(n2)
case n1 Div n2 => eval(n1)/eval(n2)
}
Das mag der ein oder andere ein wenig schöner finden als der normale Aufruf der Extraktoren. Ich möchte noch einmal daran erinnern, dass Scala objektorientiert ist, es steht uns also frei die eval-Methode direkt an den ADT zu heften:
sealed abstract class Exp {
def eval: BigDecimal
}
case class Lit(v: BigDecimal) extends Exp {
def eval = v
}
case class Add(n1: Exp, n2: Exp) extends Exp {
def eval = n1.eval+n2.eval
}
case class Sub(n1: Exp, n2: Exp) extends Exp {
def eval = n1.eval-n2.eval
}
case class Mul(n1: Exp, n2: Exp) extends Exp {
def eval = n1.eval*n2.eval
}
case class Div(n1: Exp, n2: Exp) extends Exp {
def eval = n1.eval/n2.eval
}
Wieder entfällt der Runtime-Overhead durch das Pattern Matching weil die Methoden polymorph aufgerufen werden können:
scala> Mul(Add(Lit(3), Lit(4)), Sub(Lit(9), Lit(2))).eval res16: scala.math.BigDecimal = 49 scala> Div(Lit(7), Sub(Add(Lit(3), Lit(4)), Lit(3))).eval res17: scala.math.BigDecimal = 1.75
Hörst du mich?
Das letzte Einsatzgebiet für Fallklassen, das ich euch zeigen möchte, ist die Objektkommunikation. Die einfachste Möglichkeit um Objekte miteinander kommunizieren zu lassen ist über dessen Methoden.
case class Person(name: String)
case class Computer(name: String) {
private var isRunning = false
def sayHello(p: Person) {
if (isRunning)
println("hello '%s'" format p.name)
}
def start() {
println("starting up '%s'" format name)
isRunning = true
}
def stop() {
println("'%s' is now sleeping" format name)
isRunning = false
}
}
An diesem Code ist nichts Besonderes. Die Klasse Computer stellt verschiedene Methoden bereit über die man mit der Klasse kommunizieren kann. Das Vorgehen ist bekannt und funktioniert auch prächtig:
scala> val p = Person("lara")
p: Person = Person(lara)
scala> val c = Computer("wudiwutz")
c: Computer = Computer(wudiwutz)
scala> c.start()
starting up 'wudiwutz'
scala> c sayHello p
hello 'lara'
scala> c.stop()
'wudiwutz' is now sleeping
Komplizierter wird dieses Vorgehen dann, wenn mehrere Parameter an eine Methode übergeben werden sollen. Der Einfachheit halber würde man versuchen aus diesen Parametern ein Objekt zu bilden, das an die Methode übergeben werden kann. Dies hätte weiterhin den Vorteil, dass dem Objekt wiederum Verhalten mitgegeben werden kann. Durch Fallklassen besteht in Scala die Möglichkeit die Kommunikation über Objekte nicht nur zu wählen wenn einzelne Parameter unnötiger Aufwand wären. Schauen wir uns das an:
sealed abstract class Msg case class Greeting(p: Person) extends Msg case object Start extends Msg case object Stop extends Msg
Für alle Methoden, die Computer bereitstellt, wurde ein dazugehöriges Objekt definiert. Start- und Stop-Signale soll es nur ein Mal geben, die Begrüßung soll beliebig oft erfolgen können – wir benutzen für sie also eine Klasse und kein object. Alle unsere Objekte sind zu einem ADT zusammengefasst, damit wir später auch nur mit diesem arbeiten müssen. Damit unser Computer nun mit den Nachrichten etwas anfangen kann müssen wir ihn ein wenig anpassen:
case class Computer(name: String) {
private var isRunning = false
def send(msg: Msg) = msg match {
case Start => start()
case Stop => stop()
case Greeting(p) => sayHello(p)
}
private def sayHello(p: Person) {
if (isRunning)
println("hello '%s'" format p.name)
}
private def start() {
println("starting up '%s'" format name)
isRunning = true
}
private def stop() {
println("'%s' is now sleeping" format name)
isRunning = false
}
}
Unsere bisherigen Methoden haben wir auf private gesetzt, damit sie von außen nicht mehr erreichbar sind. Sie sollen fortan nur noch zur Übersicht des Codes dienen. Die wichtigste Methode ist nun send, die eine Nachricht erwartet. Sie prüft welche Nachricht genau vorliegt und wählt dann die entsprechenden Aktionen aus.
scala> val p = Person("lara")
p: Person = Person(lara)
scala> val c = Computer("wudiwutz")
c: Computer = Computer(wudiwutz)
scala> c send Start
starting up 'wudiwutz'
scala> c send Greeting(p)
hello 'lara'
scala> c send Stop
'wudiwutz' is now sleeping
Wie unschwer zu erkennen hat sich die API unseres Objektes deutlich verkleinert. Das ganze System wirkt nun auch ein wenig objektorientierter, da Nachrichten nun nicht mehr nur Methoden, sondern ebenfalls Objekte sind. Dies ist mit ein klein wenig Laufzeit-Overhead verbunden, über den wir uns aber nicht groß zu kümmern brauchen, sofern wir keine zeitkritische Anwendungen schreiben müssen. Wollen wir, dass unser Computer auch antworten kann, benötigen wir weiter Objekte zum verschicken und eine Methode um diese zu empfangen:
case class ThrottleCpuPower(percent: Int) extends Msg sealed abstract class Response extends Msg case class CpuPower(power: Option[Int]) extends Response case object NoResponse extends Response
Mit dem ADT Response können wir die Antworten des Computers spezialisieren um später nicht auf alle Nachrichten matchen zu müssen.
case class Person(name: String) {
def reply(res: Response) = res match {
case CpuPower(power) =>
if (power.isDefined) println("new cpu power is %d%%" format power.get)
else println("nothing changed")
case NoResponse =>
}
}
Unsere Personen-Klasse erhält eine Methode über die sie mit Nachrichten versorgt werden kann. Wir müssen auch unseren Computer anpassen, da dieser ja mit Nachrichten antworten soll:
// in class Computer
private var cpuPower = 80
def send(msg: Msg) = msg match {
case Start => start()
case Stop => stop()
case Greeting(p) => sayHello(p)
case _ =>
}
def sendAndResponse(msg: Msg) = msg match {
case ThrottleCpuPower(percent) => owner reply CpuPower(throttle(percent))
case _ => owner reply NoResponse
}
private def throttle(percent: Int) =
if (cpuPower-percent < 20) None
else {
cpuPower -= percent
Some(cpuPower)
}
Es gibt eine neue öffentliche Methode namens sendAndResponse, an die alle Nachrichten geschickt werden, auf die geantwortet werden soll. In unserem Fall wollen wir nach einer Drosselung der CPU-Geschwindigkeit eine Rückmeldung mit der neuen Geschwindigkeit erhalten. Als Antwort wird ein Option gesendet. Damit haben wir die Möglichkeit das Signal über den Änderungszustand weiter anzupassen. Unser Computer soll bspw. immer erreichbar sein, was wir dadurch erreichen, dass eine Mindestmarke nie unterschritten werden darf. Ist dies der Fall soll nichts geändert werden. Die Antwort geht nun an einen mysteriösen owner, aber woher kommt dieser? Die einfachste Möglichkeit ist sicherlich ihn einfach über den Konstruktor zu injizieren:
case class Computer(name: String, owner: Person) {...}
Beachtet bitte, dass die send-Methode nun ein Default-Fall benötigt, andernfalls erhaltet ihr eine Warnung vom Compiler.
scala> val p = Person("lara")
p: Person = Person(lara)
scala> val c = Computer("wudiwutz", p)
c: Computer = Computer(wudiwutz,Person(lara))
scala> c send Start
starting up 'wudiwutz'
scala> c send Greeting(p)
hello 'lara'
scala> c sendAndResponse ThrottleCpuPower(30)
new cpu power is 50%
scala> c sendAndResponse ThrottleCpuPower(60)
nothing changed
scala> c send Stop
'wudiwutz' is now sleeping
Der Code funktioniert so wie erwartet. Wir erhalten unterschiedliche Ausgaben nachdem unser Computer eine Antwort verschickt hat.
Die Frage ist nun: Haben wir mit dieser Vorgehensweise im Vergleich zum kommunizieren mit Methoden etwas erreicht? In obigem Code halten sich die Vorteile in Grenzen. Unser System ist ein wenig objektorientierter, vielleicht auch ein wenig übersichtlicher. Das war es aber auch schon. Die wahren Stärken dieser Vorgehensweise können sich erst durch ein Framework entfalten, wie z.B. Akka:
case class Computer(name: String) extends Actor {
def receive = {
case Msg => self reply Response
}
}
Akka ist ein Actor-Framework und ermöglicht eine parallele Abarbeitung unserer Nachrichten. Actoren sind nichts besonderes, man kann sie sich als Menschen vorstellen, die Nachrichten (z.B. gesprochenen Wörter) empfangen („hören“) und auch verschicken („sprechen“) können – und das alle auf einmal. Sie können dabei nicht wissen was für einen Zustand die Anderen gerade besitzen („Gedanken“) und es steht ihnen frei wie sie auf Nachrichten reagieren. Akka stellt so ein Actoren-Modell zur Verfügung und lässt uns damit komfortabel arbeiten. Wenn wir uns die receive-Methode angucken stellen wir fest, dass kein Default-Fall mehr angegeben ist und auch das match-Statement, das eine Nachricht matcht, ist nirgends zu erblicken. Weiterhin kann auf ein self-Objekt geantwortet werden, dessen Definition für uns verborgen bleibt. Wir haben hier schönen kurzen Code, der jedoch weitaus komplexer als unser obiges Beispiel ist. Dafür ist er aber auch einfacher zu benutzen und zu verstehen – sofern man die benutzten Konzepte verstanden hat.
Ich werde im Zuge dieses Tutorials näher auf Actoren und auch auf Akka eingeben und euch all das Wissen vermitteln, das ihr braucht um obigen Code ebenfalls korrekt anwenden zu können.
Lösungen: Extraktoren
- Wenn wir den Extraktor über eine Klasse erstellen können wir sein Verhalten anpassen.
class Nth(n: Int) { def unapply(xs: Seq[Int]) = if (n < xs.size) Some(xs(n)) else None } val isSecond_? = new Nth(1) val isThird_? = new Nth(2) Vector(9, 13, 3, 73, 52) match { case isSecond_?(12) => println("second is twelve") case isThird_?(3) => println("third is three") case _ => println("nothing special found") } - Der &-Extraktor ist sehr einfach. Es genügt, einfach einen Tuple2 mit dem Eingabestring zurückzugeben.
object & { def unapply(a: String) = Some(a, a) } object StartsWith { def unapply(s: String) = s.headOption } object EndsWith { def unapply(s: String) = s.lastOption } "Hello World" match { case StartsWith('H') & EndsWith('d') => "yes" case _ => "no" } "Hello World" match { case StartsWith('g') | EndsWith('d') => "yes" case _ => "no" } -
Die Baumrepräsentation ähnelt der von IntList. Die Extraktoren sollten kein Problem mehr sein.
abstract class IntTree class Node(val left: IntTree, val right: IntTree) extends IntTree object Node { def apply(left: IntTree, right: IntTree) = new Node(left, right) def unapply(n: Node) = Some(n.left, n.right) } class Leaf(val i: Int) extends IntTree object Leaf { def apply(i: Int) = new Leaf(i) def unapply(l: Leaf) = Some(l.i) } object Empty extends IntTree val tree: IntTree = Node(Leaf(8), Node(Node(Leaf(9), Empty), Node(Leaf(9), Empty))) tree match { case Node(Leaf(i @ 8), Node(Node(Leaf(j @ 9), Empty), _)) => "found:"+i+","+j case _ => "nothing found" }
Übungen: Extraktoren
- Erzeuge einen Extraktor, der es erlaubt mit Hilfe eines Indexes auf ein Element einer Seq zuzugreifen. Der Code
Vector(8,2,5) match { case isSecond_?(2) => "yes" case _ => "no" }soll beispielsweise „yes“ ausgeben. Tipp: Benutze eine Klasse als Extraktor
- Die match-Expression erlaubt es mehrere Fälle zu „verodern“, sie aber nicht zu „verunden“. Folgendes ist möglich:
"Hello World" match { case StartsWith('g') | EndsWith('d') => "yes" case _ => "no" }Dies hier aber nicht:
"Hello World" match { case StartsWith('H') & EndsWith('d') => "yes" case _ => "no" }Finde einen Weg bei einer Eingabe von Strings, mehrere Extraktoren (hier: StartsWith und EndsWith) miteinander zu „verunden“. Tipp: & ist ebenfalls ein Extraktor.
- Schreibe Extraktoren für eine Baumrepräsentation:
val tree: IntTree = Node(Leaf(8), Node(Node(Leaf(9), Empty), Node(Leaf(9), Empty))) tree match { case Node(Leaf(i @ 8), Node(Node(Leaf(j @ 9), Empty), _)) => "found:"+i+","+j case _ => "nothing found" }Entnehme die benötigten Klassen dem obigen Code.
Hier geht es zu den Lösungen.
Teil 12: Extraktoren
Wir haben in einem früheren Artikel bereits das Pattern Matching kennen gelernt. In dem dortigen Artikel musste ich euch für die Erklärungen, wie genau Pattern Matching nun funktioniert, auf einen späteren Zeitpunkt verweisen. In diesem Artikel werde ich euch die nötigen Erklärungen geben und nebenbei noch viele Beispiele bringen was wir mit Pattern Matching noch so alles machen können.
Einfache Extraktoren
Ein Beispiel, das ich schon gebracht habe, gab uns die Möglichkeit durch Pattern Matching einen Wert an Variablen zu binden:
def heavyCalculation() = {
val memoryUsage = 50
val cpuUsage = 91
val networkUsage = 31
(memoryUsage, cpuUsage, networkUsage)
}
scala> val (memoryUsage, cpuUsage, networkUsage) = heavyCalculation()
memoryUsage: Int = 50
cpuUsage: Int = 91
networkUsage:
Int = 31
Die Funktionsweise des obigen Codes obliegt keinesfalls nur den Fähigkeiten des Compilers daraus Variablenzuweisungen zu generieren. Wir können aktiv in diesen Prozess eingreifen und festlegen was wir haben wollen. Hierfür benötigen wir nur einen sogenannten Extraktor. Ein Extraktor ist syntaktischer Zucker des Compilers – wir können ihn durch eine unapply-Methode innerhalb eines object erstellen und dann auf ihn zugreifen:
object StringExtractor {
def unapply(s: String): Option[String] = s(0) match {
case 'u' => Some(s.substring(1).toUpperCase)
case 'l' => Some(s.substring(1).toLowerCase)
case _ => None
}
}
Wir können den Extraktor bequem aufrufen indem wir in Klammern das zu extrahierende Objekt übergeben:
scala> val StringExtractor(s) = "uHello" s: String = HELLO
Der Compiler sorgt dann dafür, dass die unapply-Methode aufgerufen wird. Auf welche Typen ein Extraktor angewendet werden kann hängt vom Typ des Parameters der unapply-Methode ab – in unserem Beispiel wäre es ein String. Der Typ unserer Variable, die durch den Extraktor erzeugt wird hängt vom Rückgabetyp der unapply-Methode ab. Zum besseren Verständnis hier der Code, den der Compiler aus dem Extraktor erzeugen würde:
scala> val s = StringExtractor.unapply("uHello") match {
| case Some(s) => s
| case None => throw new MatchError
| }
s: String = HELLO
Versuchen wir unseren Extraktor mit einem anderen Typ zu füttern, bekommen wir vom Compiler direkt eine Fehlermeldung:
scala> val StringExtractor(s) = 5
<console>:13: error: scrutinee is incompatible with pattern type;
found : String
required: Int
val StringExtractor(s) = 5
^
Der Extraktor selbst funktioniert sehr einfach. Er prüft ob der erste Buchstaben eines Strings ein u oder ein l ist und wenn ja wird der restliche Stringinhalt in lauter Groß- oder Kleinbuchstaben umgewandelt. Die unapply-Methode gibt dann das extrahierte Objekt in einem Option verpackt zurück. Option haben wir schon früher kennen gelernt. Es gibt unserem Compiler die Möglichkeit zu erkennen ob ein Extraktor erfolgreich war oder nicht. Geben wir ein Some mit Inhalt zurück, so war der Extrahiervoragng erfolgreich. Haben wir nichts zu extrahieren müssen wir ein None zurückgeben. Würden wir nur das extrahierte Objekt zurück geben hätte der Compiler keine Möglichkeit festzustellen ob der Extraktionsvorgang erfolgreich war. Das ist insofern problematisch, da nach einem fehlgeschlagenen Pattern direkt zum nächsten Pattern gesprungen wird. Wenn also die Möglichkeit besteht, dass unser Extraktor fehlschlagen kann, dann müssen wir einen Default-Fall festlegen:
def extract(s: String) = s match {
case StringExtractor(s) => s
case _ => s
}
scala> extract("lHello World")
res4: String = hello world
scala> extract("uHello World")
res5: String = HELLO WORLD
scala> extract("hello")
res6: String = hello
Hätten wir keinen Default-Fall würde unser Code zur Laufzeit einen MatchError werfen:
scala> val StringExtractor(s) = "hello" scala.MatchError: hello (of class java.lang.String) <stack trace>
Anmerkung:
Der Extraktor muss mit einem Großbuchstaben anfangen, bei einem Kleinbuchstaben würde er nicht erkannt werden. Warum das so ist hab ich im Artikel über Pattern Matching bereits geschrieben.
Der Compiler hat hier keine Möglichkeit zu überprüfen ob der Code zur Laufzeit auch funktionieren wird. Er könnte zwar herausfinden was der Extraktor genau macht, er kann aber nicht wissen was für Eingabewerte er erhält. In obigem Beispiel haben wir einen statisch festgelegten String, aber was wenn der String durch eine Benutzereingabe erzeugt wird oder aus einer Datenbank kommt? Um zu vermeiden, dass unser Code hier einen potenziellen Fehler erzeugt, müssen wir unseren Extraktor ändern:
object StringExtractor {
def unapply(s: String) = s(0) match {
case 'u' => Some(s.substring(1).toUpperCase)
case 'l' => Some(s.substring(1).toLowerCase)
case _ => Some(s)
}
}
Wir haben den Rückgabewert der unapply-Methode geändert. Anstatt ein Option erhalten wir nun nur noch ein Some. Dadurch funktioniert unser Code immer und wir können uns die Hilfsmethode zum Extrahieren ersparen:
scala> val StringExtractor(s) = "hello" s: java.lang.String = hello scala> val StringExtractor(s) = "uhello" s: java.lang.String = HELLO
Mehrfache Extraktoren
Unser Code funktioniert jetzt für einen einzigen Parameter, wenn wir uns aber an das Tuple-Beispiel zurück erinnern, dann hatten wir dort aber mehrere Parameter:
scala> val (a, b) = (5, "hello") a: Int = 5 b: java.lang.String = hello
Es ist gar nicht schwer dieses Verhalten selbst nachzubauen, wir müssen nur die Signatur der unapply-Methode ändern:
class Pair(val a: Int, val b: Int)
object Pair {
def unapply(p: Pair): Option[(Int, Int)] = Some((p.a, p.b))
}
scala> val Pair(a, b) = new Pair(8, 12)
a: Int = 8
b: Int = 12
Die unapply-Methode erwartet jetzt ein Pair-Objekt. Zurückgeben tut sie dann alle zu extrahierenden Parameter in Tuple-Form (wieder gepackt in ein Option). Unsere Pair-Klasse können wir hier leider nur mit zwei Ints aufrufen. Wollen wir sie lieber mit einem String aufrufen wie beim Tuple Beispiel gezeigt, müssten wir den Parametertyp ändern, was jedoch sehr umständlich ist. In einem späteren Kapitel über parametrisierte Typen werde ich euch aber zeigen wie man dieses Problem geschickt lösen kann.
Anmerkung:
Wir können beim Erzeugen eines Tuples die runden Klammern weglassen wenn die Parameter schon in runden Klammern stehen. Das CodestückSome((p.a, p.b))können wir also auch
Some(p.a, p.b)schreiben. Ihr glaubt es nicht? Dann probiert es aus!
Im Artikel über Pattern Matching habe ich euch versprochen folgenden Code zum Laufen zu bekommen:
val Person(name, address @ Address(city, zip, street)) = ...
Die Implementierungen sind wieder nicht besonders schwer:
class Person(val name: String, val age: Int, val address: Address)
object Person {
def unapply(p: Person) = Some(p.name, p.age, p.address)
}
class Address(val city: String, val zip: Int, val street: String)
object Address {
def unapply(a: Address) = Some(a.city, a.zip, a.street)
}
Das Extrahieren beschränkt sich dann auf bereits bekannte Sachen:
scala> val p = new Person("helen", 31, new Address("musterstadt", 12345, "musterstraße"))
p: Person = Person@22f49424
scala> val Person(name, age, address @ Address(city, zip, street)) = p
name: String = helen
age: Int = 31
address: Address = Address@68e2cd6f
city: String = musterstadt
zip: Int = 12345
street: String = musterstraße
Mit Hilfe des @-Zeichen können wir gleichzeitig die Adresse extrahieren aber auch deren Instanz an eine Variable binden.
Unsere Extraktor müssen wir übrigens nicht zwingend an ein object binden, wir können sie auch durch Klassen erzeugen:
class StringExtractor(u: Char = 'u', l: Char = 'l') {
def unapply(s: String) = s(0) match {
case `u` => Some(s.substring(1).toUpperCase)
case `l` => Some(s.substring(1).toLowerCase)
case _ => Some(s)
}
}
Dies erlaubt uns unsere Extraktoren ein wenig anzupassen. Wir können bei der Objekterzeugung nämlich angeben auf welche Buchstaben der Extraktor prüfen soll.
scala> val SE1 = new StringExtractor()
SE1: StringExtractor = StringExtractor@7137f424
scala> val SE1(s) = "uhello"
s: java.lang.String = HELLO
scala> val SE2 = new StringExtractor('a', 'b')
SE2: StringExtractor = StringExtractor@1752b8b
scala> val SE2(s) = "ahello"
s: java.lang.String = HELLO
scala> val SE2(s) = "uhello"
s: java.lang.String = uhello
Beachtet hier bitte wieder dass, die Extraktoren innerhalb einer match-Expression groß geschrieben sein müssen und dass die Variablen mit Backticks referenziert werden müssen. Die mit val definierten Extraktoren dürfen wir auch kein schreiben.
Sequenzielle Extraktoren
Wir wissen jetzt wie wir einen Extraktor mit einem Parameter und auch mit mehreren Parametern erstellen, aber was ist wenn wir die Anzahl der Parameter gar nicht wissen? Das ist bspw. dann der Fall wenn wir eine Liste haben:
scala> val List(head, tail @ _*) = List(1, 2, 3) head: Int = 1 tail: Seq[Int] = List(2, 3) scala> val head :: tail = List(1, 2, 3) head: Int = 1 tail: List[Int] = List(2, 3)
Die Liste kann eine beliebige Anzahl an Elementen besitzen, unsere unapply-Methode müsste also ein Option mit einer Seq zurückgeben. Schreiben wir den dazugehörigen Code:
class Container(val x: Int*)
object Container {
def unapply(p: Container): Option[Seq[Int]] = Some(p.x)
}
Varargs sind in Scala nichts anderes als eine Seq, wir können sie also direkt zurückgeben. Testen wir den Code gleich noch:
scala> val Container(x1, x2, x3) = new Container(45, 32, 107)
<console>:9: error: wrong number of arguments for object Container
val Container(x1, x2, x3) = new Container(45, 32, 107)
^
<console>:9: error: recursive value x$1 needs type
val Container(x1, x2, x3) = new Container(45, 32, 107)
^
scala> val Container(x) = new Container(45, 32, 107)
x: Seq[Int] = WrappedArray(45, 32, 107)
Hm, das ist aber nicht das was wir erwartet haben. Aber es ist das spezifizierte Verhalten. Was haben wir denn genau hingeschrieben? Unsere unapply-Methode gibt eine Seq zurück. So weit so gut, aber woher soll der Compiler wissen ob wir durch das Pattern Matching eine Seq oder aber die Elemente der Seq erhalten wollen? Er kann es nicht wissen, deshalb können wir auch nur auf eine Seq matchen und nicht auf einzelne Elemente. Und da wir nur ein Element zurückgeben – nämlich die Seq – erhalten wir auch eine Fehlermeldung wenn wir versuchen den Extraktor mit mehreren Parametern aufzurufen. Bei List funktioniert es aber doch auch. Was ist dort anders? Tatsächlich kennt der Scala Compiler nicht nur eine unapply-Methode, sondern deren zwei. Die zweite nennt sich unapplySeqund kann als Ersatz zur normalen unapply-Methode genutzt werden. Wie der Name schon suggeriert ermöglicht sie uns nicht nur eine Seq zurückzugeben, sondern auch deren Elemente.
class Container(val x: Int*)
object Container {
def unapplySeq(p: Container): Option[Seq[Int]] = Some(p.x)
}
scala> val Container(x) = new Container(45, 32, 107)
scala.MatchError: Container@16b5d4e1 (of class Container)
<stack trace>
scala> val Container(x @ _*) = new Container(45, 32, 107)
x: Seq[Int] = WrappedArray(45, 32, 107)
scala> val Container(head, tail @ _*) = new Container(45, 32, 107)
head: Int = 45
tail: Seq[Int] = WrappedArray(32, 107)
scala> val Container(x1, x2, x3) = new Container(45, 32, 107)
x1: Int = 45
x2: Int = 32
x3: Int = 107
Toll, nicht wahr? Es funktioniert alles so wie erwartet. Die unapplySeq-Methode unterliegt nur einer kleinen Einschränkung: Wir dürfen sie nicht zusammen mit einer unapply-Methode bereitstellen. Sollten beide Methoden existieren, so wird der Compiler nur die unapply-Methode auswählen und die andere nicht weiter beachten.
Jetzt haben wir für den Schluss aber noch etwas, das gerne besprochen werden möchte:
scala> val head :: tail = List(1, 2, 3) head: Int = 1 tail: List[Int] = List(2, 3)
Diese Schreibweise unterscheidet sich von den Vorherigen. Wir müssen hier nicht mehr umständlich unseren Extraktor definieren. Stattdessen sieht es eher so aus wie wenn wir auf die Prepend-Methode von List zurückgreifen würden:
scala> val head :: tail = 1 :: 2 :: 3 :: Nil head: Int = 1 tail: List[Int] = List(2, 3)
Der einzige Unterschied ist, dass das Nil am Schluss fehlt. Aber warum fehlt es? Die Antwort darauf wird ein wenig klarerer wenn wir unseren Code ein wenig umändern:
scala> val ::(head, tail) = 1 :: 2 :: 3 :: Nil head: Int = 1 tail: List[Int] = List(2, 3)
Huch, was war das? Es war mal wieder syntaktischer Zucker des Compilers, der uns hier das Leben erleichtert. Immer dann wenn eine Klasse zwei Typparameter erwartet (die wir später im Detail kennen lernen werden) oder einen Konstruktor mit zwei Parametern besitzt, besteht die Möglichkeit, dass wir sie nicht in der Form
Class(obj1, obj2)
sondern als
obj1 Class obj2
aufrufen können. Das Gleiche haben wir oben beim extrahieren der List-Elemente gemacht. Diese Schreibweise wird uns aber nur bei Typparameter und Konstruktoren erlaubt und sonst nirgends. Aber wieso funktioniert unser Code dann? Das Symbol :: ist doch eine Methode in List? Ja, es ist eine Methode aber auch der Aufruf eines Extraktors. Genau genommen existiert :: zwei Mal – einmal als Methode und einmal als Klasse. Die Klasse :: stellt einen geeigneten Konstruktor bereit, der uns diese Schreibweise erlaubt. Hier die Klassendefinition von scala.collection.immutable.:::
final case class ::[B](
private var hd: B,
private[scala] var tl: List[B])
extends List[B] {...}
Die Klassendefinition ist für den Anfang ein wenig verwirrend und genau deshalb werden wir uns jetzt eine eigene List schreiben. Das hilft uns nicht nur zu verstehen wann genau die Methode :: und wann das Objekt :: aufgerufen wird – es hilft uns vor allem auch die Stärken und Schwächen von List kennen zu lernen. Fangen wir damit also gleich an. Und danach gibt es noch ein paar Übungsaufgaben bei denen ihr testen könnt ob ihr auch alles verstanden habt und ohne meine Hilfe zurechtkommt.
Praxisbeispiel: Implementierung von List
Eine List ist eine einfach verkette Liste. Jedes Stück der List besitzt neben dem Element, das es aufnimmt noch eine Referenz auf das nächste Stück der Liste. Daraus folgt ein einfacher Konstruktor:
class IntList(val head: Int, val tail: IntList)
object IntList {
def apply(head: Int, tail: IntList) = new IntList(head, tail)
}
Wir beschränken unsere Listimplementierung darauf, dass sie nur Ints aufnehmen kann, dann müssen wir uns noch nicht mit parametrisierten Typen herumschlagen. Durch die Implementierung einer apply-Methode können wir uns fortan gleich noch das new sparen. Wir können nun schon eine List erstellen:
scala> val xs = IntList(1, IntList(2, IntList(3, null))) xs: IntList = IntList@7137f424
Das null ist uns jetzt noch ein Dorn im Auge. Es ist nicht typsicher und kann zu NullPointerExceptions führen. Versuchen wir es also zu umgehen:
class IntNil extends IntList(0, null)
object IntNil {
def apply(): IntList = new IntNil
}
scala> val xs = IntList(1, IntList(2, IntList(3, IntNil())))
xs: IntList = IntList@57a68215
Aber wirklich besser ist das auch nicht. Wir haben das null jetzt nur vom Anwendungscode in die Bibliothek verlagert. Außerdem erzeugen wir bei jedem Aufruf von IntNil ein neues Objekt. Daraus folgt:
scala> IntNil() != IntNil() res4: Boolean = true
Anmerkung:
Achtet darauf, dass ihr IntNil() aufruft und nicht nur IntNil. Wenn ihr die Klammern weg lässt referenziert ihr nicht die apply-Methode sondern den Typ IntNil. Dessen genaue Bedeutung kann uns im Moment egal sein, es muss nur klar sein, dass er existiert.
Wir dürfen also nur eine Instanz von IntNil besitzen. Wir erreichen das am besten wenn wir unseren Code ein wenig umbauen:
abstract class IntList {
def head: Int
def tail: IntList
}
class Cons(val head: Int, val tail: IntList) extends IntList
object Cons {
def apply(head: Int, tail: IntList) = new Cons(head, tail)
}
object IntNil extends IntList {
def head = throw new UnsupportedOperationException("nil head")
def tail = throw new UnsupportedOperationException("nil tail")
}
Anstatt Verhalten durch IntNil auszutauschen haben wir nun eine polymorphe Datenstruktur, deren genaues Verhalten von den Subklassen abhängen. Scala erlaubt uns das Überschreiben von Methoden durch Attribute (so geschehen in Cons), da der Compiler für die Attribute entsprechende Zugriffsmethoden erzeugt.
Das null ist einer Exception gewichen, welche direkt durch die Methoden head und tail zurückgegeben wird. Die Codeerzeugung unterscheidet sich nicht groß von der vorherigen Version:
scala> val xs = Cons(1, Cons(2, Cons(3, IntNil))) xs: Cons = Cons@4f86f5f
Ergänzen wir unseren Code durch eine vernünftige String-Repräsentation:
abstract class IntList {
def head: Int
def tail: IntList
def isEmpty: Boolean
override final def toString = {
val sb = StringBuilder.newBuilder
sb append "IntList("
sb append head
var xs = tail
while (!xs.isEmpty) {
sb append ", "
sb append xs.head
xs = xs.tail
}
sb append ")"
sb.toString
}
}
class Cons(val head: Int, val tail: IntList) extends IntList {
def isEmpty = false
}
object Cons {
def apply(head: Int, tail: IntList) = new Cons(head, tail)
}
object IntNil extends IntList {
def head = throw new UnsupportedOperationException("nil head")
def tail = throw new UnsupportedOperationException("nil tail")
def isEmpty = true
}
Die isEpmty-Methode spart uns einen Vergleich auf IntNil, welchen man jetzt aber durchaus machen könnte, da es nur eine Instanz davon gibt. Die Ausgabe ist gleich zufriedenstellender.
scala> val xs = Cons(1, Cons(2, Cons(3, IntNil))) xs: Cons = IntList(1, 2, 3)
Die Erzeugung der List sieht noch nicht besonders elegant aus. Ändern wir das:
// in IntList def :: (i: Int) = new Cons(i, this) scala> val xs = 1 :: 2 :: 3 :: IntNil xs: Cons = IntList(1, 2, 3)
Als nächstes wollen wir auf die tolle Konkatenationsschreibweise zurückgreifen:
// in object Cons def unapply(c: Cons) = Some(c.head, c.tail) scala> val x1 Cons (x2 Cons x3) = 1 :: 2 :: 3 :: IntNil x1: Int = 1 x2: Int = 2 x3: IntList = IntList(3)
Die Klammern werden leider benötigt da der Compiler sonst durch die Auswertungsreihenfolge (von links nach rechts) durcheinander kommt. Wir können das ändern indem wir der Cons-Klasse einen Namen geben, der mit einem Doppelpunkt endet.
class :: (val head: Int, val tail: IntList) extends IntList {
def isEmpty = false
}
object :: {
def apply(head: Int, tail: IntList) = new ::(head, tail)
def unapply(c: ::) = Some(c.head, c.tail)
}
Wenn wir alle Vorkommen von Cons durch :: ersetzen, dann können wir durch die umgekehrte Auswertungsreihenfolge die Klammern weglassen:
scala> val head :: tail = 1 :: 2 :: 3 :: IntNil head: Int = 1 tail: IntList = IntList(2, 3) scala> val x1 :: x2 :: x3 :: IntNil = 1 :: 2 :: 3 :: IntNil x1: Int = 1 x2: Int = 2 x3: Int = 3
Das war sie schon. Die ganze „Magie“ der Extraktoren. Zu Schluss noch eine Extraktor-Implementierung für unsere IntList:
object IntList {
def apply(a: Int*) = {
def loop(xs: Seq[Int], ys: IntList): IntList =
if (xs.isEmpty) ys else loop(xs.tail, xs.head :: ys)
loop(a, IntNil).reverse
}
def unapplySeq(a: IntList) = {
def loop(xs: IntList, ys: List[Int]): List[Int] =
if (xs.isEmpty) ys else loop(xs.tail, xs.head :: ys)
Some(loop(a, Nil).reverse)
}
}
// in IntList
def reverse: IntList = {
def loop(xs: IntList, ys: IntList): IntList =
if (xs.isEmpty) ys else loop(xs.tail, xs.head :: ys)
loop(this, IntNil)
}
Der Code ist ein wenig umständlich. Wir müssen zuerst die Listen aufbauen und sie dann umdrehen. Wir haben leider keine Möglichkeit eine Liste direkt rückwärts aufzubauen, da sie nur einfach verkettet ist. Aber zumindest funktioniert der Code:
scala> val xs = IntList(1,2,3) xs: IntList = IntList(1, 2, 3) scala> val xs = IntList(1,2,3) xs: IntList = IntList(1, 2, 3) scala> val IntList(head, tail @ _*) = IntList(1, 2, 3) head: Int = 1 tail: Seq[Int] = List(2, 3) scala> val IntList(head, tail @ _*) = IntList(1 to 3: _*) head: Int = 1 tail: Seq[Int] = List(2, 3)
Ihr erinnert euch doch hoffentlich noch an Ranges, die man im dritten Beispiel bewundern kann.
Die apply-Methode könnte man übrigens auch so schreiben:
// in object IntList def apply(a: Int*) = (a(IntNil: IntList)) { _ :: _ }
Das wäre die funktionale Herangehensweise an die Erzeugung einer geeigneten Liste. Das will ich aber nicht erklären, sonder mal nur so in den Raum werfen, damit ihr wisst was euch erwartet wenn ihr mir treu bleibt und fleißig weiter lest. 😉
Zum Abschluss noch die komplette Implementierung von IntList:
object IntList {
def apply(a: Int*) = {
def loop(xs: Seq[Int], ys: IntList): IntList =
if (xs.isEmpty) ys else loop(xs.tail, xs.head :: ys)
loop(a, IntNil).reverse
}
def unapplySeq(a: IntList) = {
def loop(xs: IntList, ys: List[Int]): List[Int] =
if (xs.isEmpty) ys else loop(xs.tail, xs.head :: ys)
Some(loop(a, Nil).reverse)
}
}
abstract class IntList {
def head: Int
def tail: IntList
def isEmpty: Boolean
def :: (i: Int) = new ::(i, this)
def reverse: IntList = {
def loop(xs: IntList, ys: IntList): IntList =
if (xs.isEmpty) ys else loop(xs.tail, xs.head :: ys)
loop(this, IntNil)
}
override final def toString = {
val sb = StringBuilder.newBuilder
sb append "IntList("
sb append head
var xs = tail
while (!xs.isEmpty) {
sb append ", "
sb append xs.head
xs = xs.tail
}
sb append ")"
sb.toString
}
}
class :: (val head: Int, val tail: IntList) extends IntList {
def isEmpty = false
}
object :: {
def apply(head: Int, tail: IntList) = new ::(head, tail)
def unapply(c: ::) = Some(c.head, c.tail)
}
object IntNil extends IntList {
def head = throw new UnsupportedOperationException("nil head")
def tail = throw new UnsupportedOperationException("nil tail")
def isEmpty = true
}
Übungen: Pattern Matching
Für den geneigten Leser hier einige Übungen zu Pattern Matching. Versucht alle Aufgaben möglichst mit dem „functional way“ zu lösen, d.h. verwendet unveränderliche Objekte. Schaut, dass ihr also anstelle von Schleifen die gute alte Rekursion benutzt, wenn möglich sogar Tail-Rekursion. U.u. könnt ihr auch mit der for-Expression auf eine Lösung kommen. Für die meisten Aufgaben gibt es eine entsprechende Methode in den Collections, versucht aber möglichst ohne diese auszukommen (ausgenommen sind davon die Methoden um Objekte zu einer Liste hinzuzufügen). Ihr könnt mit Hilfe der vorgefertigten Methoden aber eure eigene Lösungen vergleichen.
- Ermittle aus einer List[Int] die Anzahl der Elemente. (xs.size)
- Ermittle aus einer List[Int] die Summe aller Elemente. (xs.sum)
- Spiegle eine List[Int] so, dass ein Palindrom entsteht. Eine List(1, 2, 3) soll also List(1, 2, 3, 3, 2, 1) ergeben.
- Füge zwischen jedes Element aus einer List[String] einen weiteren String ein, was z.B. ein Trennzungszeichen sein kann. Eine List(„foo“, „bar“, „hello“, „world“) soll mit den Trennungszeichen “ : “ den String „foo : bar : hello : world“ ergeben. Beachte, dass keine Trennungszeichen am Anfang und zum Schluss kommen (xs mkString “ : „).
- Ebne verschachtelte Listen zu einer einzigen Liste. Aus dem Konstrukt „List(List(1, 2, 3), 4, 5, List(6), Nil, List(7, List(8, 9)))“ soll „List(1, 2, 3, 4, 5, 6, 7, 8, 9)“ entstehen (xs.flatten).
- Vervielfältige ein Element aus einer Liste eine bestimmte Anzahl mal. Aus „List(1, 5, 5, 3, 9)“ soll mit dem Multiplikator 3 die Liste „List(1, 1, 1, 5, 5, 5, 5, 5, 5, 3, 3, 3, 9, 9, 9)“ entstehen.
- Gruppiere die Elemente einer Liste zu Unterlisten. Eine Liste mit den Elementen 1 bis 14 und der Gruppengröße 4 soll „List(List(1, 2, 3, 4), List(5, 6, 7, 8), List(9, 10, 11, 12), List(13, 14))“ ergeben. Falls die Elemente nicht aufgehen, werden sie einfach so zum Schluss hinzugefügt (xs grouped n).
- Lösche alle Duplikate aus einer Liste. Schaue, dass dabei die Reihenfolge der Element nicht vertauscht wird. Eine Liste mit den Elementen „1, 1, 1, 2, 4, 7, 2, 8, 3, 6, 2“ soll „List(1, 2, 4, 7, 8, 3, 6)“ ergeben.
Hier geht es zu den Lösungen.
Lösungen: Pattern Matching
- Die wohl einfachste Lösung:
def size(xs: List[Int]): Int = if (xs.isEmpty) 0 else 1+size(xs.tail)
Mit Pattern Matching:
def size(xs: List[Int]): Int = xs match { case Nil => 0 case _ :: tail => 1+size(tail) }Bei diesen Lösungen kann es aber zu einem StackOverflowError kommen wenn die Listen nur lang genug sind. Deshalb sollte die Tail-rekursive Lösung bevorzugt werden:
def size(xs: List[Int], acc: Int = 0): Int = xs match { case Nil => acc case _ :: tail => size(tail, acc+1) } - Gleiche Lösungsstrategie wie bei der vorherigen Aufgabe:
def sum(xs: List[Int], acc: Int = 0): Int = xs match { case Nil => acc case head :: tail => sum(tail, acc+head) } - Wenn wir die Liste umdrehen und dann an das Original hängen haben wir es einfach:
def toPalindrome(xs: List[Int]): List[Int] = { def reverse(xs: List[Int], ys: List[Int]): List[Int] = if (xs.isEmpty) ys else reverse(xs.tail, xs.head :: ys) xs ++ reverse(xs, Nil) } - Eine Lösung mit einer lokalen Methode um über die Liste zu iterieren:
def intersperse(sep: String, xs: List[String]): String = { def loop(xs: List[String], s: String): String = xs match { case Nil => s case head :: Nil => s+head case head :: tail => loop(tail, s+head+sep) } loop(xs, "") } - Wir benötigen eine List[Any], da wir durch die Verschachtelungen nicht wissen können was für Elemente in der Liste vorliegen.
def flatten(xs: List[Any]): List[Any] = xs match { case Nil => Nil case (head: List[_]) :: tail => flatten(head) ::: flatten(tail) case head :: tail => head :: flatten(tail) } - Hier müssen wir uns sogar zweier lokalen Methoden bedienen. Eine, die über die Ausgangsliste iteriert und eine Andere, die die Elemente vervielfältigt.
def multiplicateElems(n: Int, xs: List[Int]) = { def mul(i: Int, elem: Int, xs: List[Int]): List[Int] = if (i == 0) xs else mul(i-1, elem, elem :: xs) def loop(xs: List[Int], ys: List[Int]): List[Int] = if (xs.isEmpty) ys else loop(xs.tail, mul(n, xs.head, Nil) ::: ys) loop(xs, Nil).reverse } - Wir müssen hier aufpassen, dass wir alle gruppierten Elemente wieder umdrehen, da die Listen falsch herum aufgebaut werden.
def group(n: Int, xs: List[Int]): List[List[Int]] = { def loop(i: Int, xs: List[Int], ys: List[Int], zs: List[List[Int]]): List[List[Int]] = if (xs.isEmpty) ys.reverse :: zs else if (i == 0) loop(n, xs, Nil, ys.reverse :: zs) else loop(i-1, xs.tail, xs.head :: ys, zs) loop(n, xs, Nil, Nil).reverse } - Das ist nicht schwer. Wir müssen nur schauen ob ein Element in der neuen Liste schon vorhanden ist.
def compress(xs: List[Int]): List[Int] = { def loop(xs: List[Int], ys: List[Int]): List[Int] = if (xs.isEmpty) ys else if (ys contains xs.head) loop(xs.tail, ys) else loop(xs.tail, xs.head :: ys) loop(xs, Nil).reverse }
Noch eine Anmerkung zum Schluss: Das ständige Umdrehen der Liste mag nicht besonders schön sein, aber es ist die einfachste Möglichkeit mit der wir unsere Listen korrekt aufbauen können. Die List-Implementierung von Scala benutzt intern ein veränderliches letzten Element, das es erlaubt neue Elemente effizient ans Ende zu hängen.
Da wir ja aber das funktionale Programmieren lernen wollen, bestehen wir lieber auf eine unveränderliche Liste und drehen sie am Schluss einfach um.
Teil 11: Vererbung
Wir verfügen bereits über die Grundkenntnisse in Scalas Objektorientierung, gehen wir also noch ein wenig tiefer hinein in den Kaninchenbau.
Schauen wir uns noch einmal eine der einfachsten Klassen an:
class Person(val name: String, val age: Int)
Der Compiler generiert uns für die beiden Attribute name und age Getter und Setter. Das ist praktisch, was machen wir aber wenn wir in den Gettern oder Settern noch gerne etwas anderes erledigen wollen? Vielleicht müssen die an den Setter übergebenen Werte erst auf Gültigkeit geprüft werden? Oder aber wir wollen alle Zugriffe auf ein Attribut mitloggen.
Für diese Anwendungsfälle dürfen wir dem Compiler nicht die vollständige Codegenerierung überlassen, wir müssen selbst Hand anlegen. Sobald wir das val vor dem Attributnamen weglassen generiert uns der Compiler keine Access-Methoden mehr – wir können sie nun von Hand erstellen.
class Person(nameOfBirth: String, val age: Int = 0) {
def name: String = {
println("someone wants to know information of: "+nameOfBirth)
nameOfBirth
}
}
Da sowohl die Namen der Methoden und der Variablen vom Compiler innerhalb des gleichen Sichtbarkeitsbereichs verwaltet werden, können wir nicht die gleichen Namen vergeben. Wir müssen einen der beiden Namen ändern, in diesem Fall hat es den Konstruktorparameter getroffen. Das Ändern der öffentlichen Felder sollte man wenn möglich vermeiden, da es u.U. noch anderen Code gibt, der auf die Member unserer Klasse zugreift. Würden wir die Schnittstelle zu unserer Klasse ändern, müsste auch aller Code geändert werden, der auf die Schnittstelle zugreift. Hier kommt auch das als „Uniform Access Principle“ genannte Prinzip zum tragen, das besagt, dass die öffentlichen Member einer Schnittstelle mit möglichst der gleichen Notation angesprochen werden können. Für unsere Klasse heißt das, dass es egal ist ob unser Feld im Konstruktor oder innerhalb des Klassenkörpers deklariert wurde. Wir können immer mit dem Identifier „name“ auf das Feld zugreifen wobei der Initialisierungswert der Variable nach außen hin unsichtbar bleibt. Hier findet sich ein ganz brauchbarer Artikel zu diesem Thema.
scala> val p = new Person("max")
p: Person = Person@41cc5b64
scala> p.name
someone wants to know information of: max
res4: String = max
scala> p.nameOfBirth
:10: error: value nameOfBirth is not a member of Person
p.nameOfBirth
^
Getter sind sehr intuitiv zu erstellen, bei Settern sieht das etwas anders aus:
class Person(val name: String, private var initAge: Int) {
def age = initAge
def age_=(age: Int) {
if (age >= 0)
initAge = age
}
}
Das Problem hier ist, dass wir nicht einfach so das Attribut als veränderlich kennzeichnen können, da wir keine Methode gleichen Namens erstellen können mit der wir auf die Variable zugreifen können. Wir benötigen also eine interne Variable, die den Zustand speichert und auch einen Getter, wenn wir wollen, dass man auf die Variable Zugriff erhält. Die Syntax für den Methodenkopf eines Setters lautet:
def <def_name>_=(<param>)
Nach dem Namen der Methode folgt ein Unterstrich und ein Gleichheitszeichen bevor dann der Parameter notiert werden kann. Sieht komisch aus? Ja, aber ist es auch komisch in der Handhabung?
scala> val p = new Person("markus", 45)
p: Person = Person@5d17bf94
scala> p.age_=(46)
scala> p.age
res10: Int = 46
scala> p.age = 47
p.age: Int = 47
scala> p.age
res11: Int = 47
Der erste Methodenaufruf sieht so aus wie wir es erwartet haben. Beim zweiten hingegen begegnen wir wieder ein wenig syntaktischem Zucker. Der Compiler erlaubt uns den Unterstrich und die Klammern wegzulassen. Der Grund ist wieder der Uniform Access Modifier: Von außen soll nicht erkenntlich sein ob wir auf eine Methode oder auf eine Variable zugreifen.
Anmerkung:
Die Schreibweisedef <def_name>_<operator>(<param>)erlaubt uns Alphabets- und Sonderzeichen zusammen in einem Identifier zu benutzen.
Wir können also Namen wie hello_+-* oder isset_? erstellen. Besonders letztere Schreibweise findet man des Öfteren, da es nochmal verdeutlichen kann, dass ein Boolean zurückgeben wird. Im Gegensatz zum Setter erlaubt uns der Compiler aber nicht den Unterstrich beim Aufruf des Identifiers wegzulassen. Von diesem Syntaxzucker kann man ausschließlich beim Setter Gebrauch machen. Die Kombination der Zeichen ist auch nur erlaubt wenn die Sonderzeichen zum Schluss kommen. Namen wie +_hello oder !_x_! sind ungültig.
Abstrakte Member
Manchmal haben wir Objekte, die im Grunde zusammengehören, da sie fast gleich funktionieren und sich nur in wenigen Punkten unterscheiden. Damit wir jetzt nicht bei allen solch zusammengehörenden Objekten fast den gleichen Code schreiben müssen besteht die Möglichkeit, das Verhalten, das für alle Objekte identisch ist, nur in einem Objekt zu definieren und es dann an andere Objekte zu vererben. Das Objekt, das Verhalten an ein anderes weitergibt wird Ober- oder Vaterobjekt genannt – bei der Klasse in der das Verhalten definiert wurde spricht man neben Oberklasse auch von Superklasse.
abstract class Person {
def sayHello() { println("hello") }
def doWork()
}
class Manager extends Person {
def doWork() { println("rake in money") }
}
class Programmer extends Person {
def doWork() { println("write code") }
}
Unsere Superklasse wäre hier Person, die zwei Methoden besitzt, eine mit einem Verhalten und eine ohne eins. Mit dem Schlüsselwort extends erstellen wir eine Vererbungshierarchie und weisen den Klassen Manager und Programmer das Verhalten von Person zu. Ins Auge dürfte uns gleich noch das abstract fallen. Was bedeutet es? Deklarieren wir eine Klasse als abstrakt heißt das, dass wir nicht wollen, dass irgendjemand davon irgendwann mal ein Objekt erstellt. Falls es dennoch mal jemand versuchen sollte bekommt er eine Fehlermeldung:
scala> new Person
:9: error: class Person is abstract; cannot be instantiated
new Person
^
Dies schützt uns davor, dass ein Objekt erstellt wird, dessen Verhaltensweisen noch gar nicht festgelegt wurden, was bei Person mit der Methode doWork genau der Fall ist. Die Methode ist ebenfalls abstrakt, wir müssen dies aber nicht extra angeben. Es reicht einfach den Methodenkörper wegzulassen – der Compiler erkennt dann selbstständig, dass die Methode noch eine Implementierung benötigt. Wenn eine Klasse abstrakte Member besitzt, dann muss sie selbst als abstrakt gekennzeichnet werden. Tun wir das nicht, dann dürfen wir uns mal wieder die Klagen des Compilers anhören:
scala> class NotAbstract {
| def missingImplementation
| }
:7: error: class NotAbstract needs to be abstract, since method missingImplementation is not defined
class NotAbstract {
^
Die Methodendeklaration sieht auf den ersten Blick etwas merkwürdig aus, das liegt aber einzig allein daran, das der Rückgabetyp nicht angegeben wurde. Wir könnten auch def missingImplementation: Unit schreiben um deutlicher zu machen, dass der Rückgabewert der Methode nicht von Belang ist. Neben abstrakten Methoden kann eine Klasse auch abstrakte Variablen aufnehmen:
class Abstract {
val someInt: Int
}
class Concrete extends Abstract {
val someInt = 10
}
In der erbenden Klasse müssen wir die Variable einfach nochmal deklarieren und auch mit einem Wert initialisieren.
Aber kommen wir zu unserem Personen-Beispiel zurück. In den beiden Unterklassen definieren wir die Körper unserer abstrakten Klasse. Je nach dem welche Klasse wir nun instanziieren erhalten wir unterschiedliche Ausgaben:
scala> val m = new Manager m: Manager = Manager@138524a1 scala> m.doWork() rake in money scala> val p = new Programmer p: Programmer = Programmer@14d0fd23 scala> p.doWork() write code
Nun hatte unsere Person aber einen Konstruktor, fügen wir diesen also gleich wieder hinzu:
scala> abstract class Person(val name: String, val age: Int)
defined class Person
scala> class Manager extends Person
<console>:8: error: not enough arguments for constructor Person: (name: String, age: Int)Person.
Unspecified value parameters name, age.
class Manager extends Person
^
scala> class Programmer extends Person
<console>:8: error: not enough arguments for constructor Person: (name: String, age: Int)Person.
Unspecified value parameters name, age.
class Programmer extends Person
^
In Scala werden Konstruktoren nicht mitvererbt. Das bedeutet, dass unsere beiden Unterklassen nur mit einem Konstruktor ohne Parameter erzeugt werden können. Da die Oberklasse aber zur Instanziierung zwei Parameter erwartet, die in den Unterklassen nirgendwo angegeben werden, erhalten wir die betreffenden Fehlermeldungen. Bleibt uns also nichts anderes übrig als auch den Unterklassen einen passenden Konstruktor zu verpassen:
scala> class Manager(val name: String, val age: Int) extends Person
<console>:8: error: not enough arguments for constructor Person: (name: String, age: Int)Person.
Unspecified value parameters name, age.
class Manager(val name: String, val age: Int) extends Person
^
Hm, die Fehlermeldung ist aber immer noch die Gleiche. Um zu erklären woher sie kommet hilft es vielleicht wenn wir uns folgendes Beispiel anschauen:
scala> class X { println("x") }
defined class X
scala> class Y extends X { println("y") }
defined class Y
scala> new X
x
res8: X = X@1f5e8c9d
scala> new Y
x
y
res9: Y = Y@42674cc4
Wir haben zwei Objekte: X und Y. Y erbt zwar von X, das heißt aber nicht, dass die Unterklasse Y die Oberklasse X komplett ersetzt. Wenn wir ein Y erzeugen wollen, dann müssen wir auch ein X erzeugen. Wir können das deutlich an der Ausgabe erkennen. Bei der Instanziierung von Y wird als erstes die Oberklasse X erzeugt, erst dann kommt Y an die Reihe. Dies ändert sich nicht wenn wir eine Klasse als abstrakt kennzeichnen. Der Compiler unterbindet uns dann zwar die Möglichkeit, dass wir die Oberklasse direkt erzeugen können – das heißt aber nicht, dass sie gar nicht erzeugt wird. Stattdessen wird sie genau dann erzeugt wenn eine der Unterklassen instanziiert wird.
Bezogen auf unser vorheriges Beispiel heißt das, dass wir als ersten eine Person erzeugen müssen bevor wir uns dem Manager oder dem Programmer zuwenden können. Die Initialisierung einer Oberklasse durch eines seiner Kinder ist in Scala denkbar einfach. Wie bei einer ganz normalen Erzeugung eines Objekts reicht es, die Initialisierungswerte in runden Klammern hinter die Oberklasse zu schreiben:
scala> class Manager(val name: String, val age: Int) extends Person(name, age)
<console>:8: error: overriding value name in class Person of type String;
value name needs `override' modifier
class Manager(val name: String, val age: Int) extends Person(name, age)
^
<console>:8: error: overriding value age in class Person of type Int;
value age needs `override' modifier
class Manager(val name: String, val age: Int) extends Person(name, age)
^
Das sieht doch schon einmal ganz gut aus: Wir erhalten eine andere Fehlermeldung. Das bedeutet, dass wir der Sache also ein wenig näher kommen. Der Compiler möchte, dass wir unsere beiden Attribute mit einem override kennzeichnen. Tun wir dies hört er auf zu meckern:
scala> class Manager(override val name: String, override val age: Int) extends Person(name, age) defined class Manager
Aber warum will er plötzlich ein override haben? Zuvor konnten wir abstrakte Methoden ja auch überschreiben, ohne dass er sich beschwert hat. Die Antwort darauf ist, dass das override immer dann gebraucht wird wenn wir bestehendes Verhalten tatsächlich auch überschreiben und nicht nur neu definieren. Bei dem Beispiel mit der Methode hatte diese keinen Körper, sie war also abstrakt und musste erst noch definiert werden. Bei dem Personen-Beispiel erzeugt uns der Compiler aber schon Getter und Setter. Die Felder wurden also schon in der Oberklasse definiert und wir wollen sie in der Unterklasse noch einmal definieren? Das geht zu recht nicht. Wir wollen sie eigentlich ja auch nicht neu definieren, sondern überschreiben. Deshalb fordert der Compiler auch das override, das ihm signalisiert, dass er das Verhalten der Oberklasse mit Verhalten aus der Unterklasse austauschen soll. Genau genommen wollen wir das ja aber eigentlich auch nicht. Wir wollen ja nur unsere Unterklassen instanziieren können ohne irgendetwas zu überschreiben.
Anmerkung:
Eine abstrakte Methode, die erst in einer Unterklasse definiert wird kann dort mit override gekennzeichnet werden, sie muss es aber nicht. Ob man es hinschreibt oder nicht bleibt einem selbst überlassen. Ich empfehle aber es zu unterlassen, da override das Überschreiben eines Members kennzeichnet und ein abstrakter Member wird ja eigentlich nicht überschrieben sondern erst einmal definiert.
Was machen wir jetzt also dagegen? Die Antwort ist ziemlich einfach, vielleicht könnt ihr sie euch schon denken:
scala> class Manager(name: String, age: Int) extends Person(name, age) defined class Manager scala> class Programmer(name: String, age: Int) extends Person(name, age) defined class Programmer
Durch das Weglassen des val vor dem Attributnamen signalisieren wir dem Compiler, dass er keine neuen Felder erzeugen soll, sondern einfach nur einen Konstruktor mit Parameter. Aber das hatten wir schon alles.
scala> val m = new Manager("heinrich", 55)
m: Manager = Manager@75305547
scala> m.name
res10: String = heinrich
scala> m.age
res11: Int = 55
Wir können nun einen Manager erstellen und erben dabei die Getter aus der Oberklasse. Besonders toll beim Arbeiten mit Oberklassen ist, dass wir nicht wissen müssen mit welcher Kind-Klasse sie instanziiert wurde. Es wird uns erlaubt nur auf Schnittstellen-Basis zu arbeiten und konkretes Verhalten einzufordern obwohl der konkrete Typ nicht bekannt ist.
abstract class Person(val name: String, val age: Int) {
def work()
}
class Manager(name: String, age: Int) extends Person(name, age) {
def work() { manage() }
def manage() { println(name+" manages his company") }
}
class Programmer(name: String, age: Int) extends Person(name, age) {
def work() { program() }
def program() { println(name+" programs software") }
}
Bei diesem Beispiel besitzen unsere Unterklassen je eine spezielle Methode, die die Oberklasse nicht kennt. Diese Methoden werden über die gemeinsame Schnittstelle, die Methode work, aufgerufen:
scala> val xs = List(new Manager("peter", 35), new Programmer("hugo", 42), new Manager("susie", 41))
xs: List[Person] = List(Manager@45637b37, Programmer@62e7b78, Manager@55ac0673)
scala> for (x <- xs) x.work()
peter manages his company
hugo programs software
susie manages his company
scala> xs(0).manage()
<console>:12: error: value manage is not a member of Person
xs(0).manage()
^
Wir haben mehrere Personen in einer List[Person] (deren gemeinsamer Obertyp Person vom Compiler korrekt erkannt wurde) und lassen diese alle arbeiten. Das funktioniert auch wie erwartet. Je nach dem ob wir einen Manager oder einen Programmierer erstellt haben wird die entsprechende Methode aufgerufen. Versuchen wir aber auf eine der konkreten Methoden zuzugreifen erhalten wir eine Fehlermeldung, da der Klasse Person diese ja nicht bekannt sind.
Innerhalb einer abstrakten Klasse besteht auch noch die Möglichkeit, dass wir einen bereits definierten Member nachträglich auf abstrakt setzen um dessen Überschreibung in den Unterklassen zu erzwingen:
scala> abstract class Printable {
| override def toString: String
| }
defined class Printable
scala> class Test extends Printable
<console>:8: error: class Test needs to be abstract, since there is a deferred declaration of method toString in class Printable of type ()String which is not implemented in a subclass
class Test extends Printable
^
Mit Hilfe der abstrakten Klasse Printable wollen wir erreichen, dass eine Klasse auf jeden Fall eine spezifische Stringrepräsentation besitzt. Durch erweitern der Klasse gehen wir sicher, dass die Methode toString auch tatsächlich überschrieben und nicht vergessen wird.
Zugriff auf die Superklassen
Wenn wir uns noch einmal das Beispiel von Setterdefinitionen anschauen stellen wir fest, dass wir unterschiedliche Namen für unsere Variablen benötigen:
class Person(val name: String, private var initAge: Int) {
def age = initAge
def age_=(age: Int) {
if (age >= 0)
initAge = age
}
}
Wir haben hier im Konstruktor eine Variable namens initAge und im Setter eine namens age. Das ist ein wenig unhandlich. Schöner wäre es doch wenn wir beiden Variablen den gleichen Namen geben könnten. In obigem Beispiel ist das leider nicht möglich, da der Getter schon den Namen für sich beansprucht. Wir können aber die Parameternamen ändern:
class Counter {
private var c = 0
def count = c
def count_=(c: Int) {
this.c = c
}
}
Wenn wir nun innerhalb des Setters c = c schreiben beschwert sich der Compiler weil wir den Wert der Variable sich selbst zuweisen wollen:
scala> var c = 0
c: Int = 0
scala> def change(c: Int) { c = c }
<console>:8: error: reassignment to val
def change(c: Int) { c = c }
^
Da alle Parameter in Scala als val erstellt werden, bekommen wir einen Zuweisungsfehler. Der Compiler kann ja nicht wissen, dass wir den Wert der äußeren Variable zuweisen wollen. Innerhalb einer Klasse besteht die Möglichkeit mit der this-Referenz auf den Scope der Klasse zuzugreifen und die äußere Variable also direkt anzusprechen, wie wir es beim Counter-Beispiel sehen können. Da this aber nicht auf den nächstäußeren Scope, sondern auf den der momentanen Klasse zeigt können wir damit nicht eine Variable gleichen Namens ansprechen, die sich auf einer äußeren Ebene befindet:
def x {
var a = 0
def y {
var a = 3
def z() {
a = 5
}
z()
println(a)
}
y
println(a)
}
scala> x
5
0
Die erste Definition von a können wir in der innersten Methode z nicht ansprechen, da sie von dem a in Methode y verdeckt wird.
Manchmal wollen wir in einer Unterklasse eine Methode überschreiben aber gleichzeitig auch auf die Methode der Oberklasse zugreifen. Dafür gibt es dann die super-Referenz:
class Foo {
def x() {
println("foo")
}
def y(i: Int) = i*2
}
class Bar extends Foo {
override def x() {
super.x
println("bar")
}
override def y(i: Int) = super.y(i)+10
}
Beim Aufruf der Methoden erhalten wir das erwartete Ergebnis:
scala> bar.x foo bar scala> bar.y(5) res6: Int = 20
Finale Member
Es ist nicht immer erwünscht, dass Teile unseres Codes durch Unterklassen erweitert oder sogar überschrieben werden. Nehmen wir an, eine Klasse besitzt eine Methode, die einen Parameter auf Gültigkeit überprüft. Wir wollen nicht, dass diese Überprüfung irgendjemand durch Überschreiben der Methode umgeht. Um dies nun also zu verhindern gibt es das Schlüsselwort final.
scala> class Tester {
| final def isValid(i: Int) = i < 5
| }
defined class Tester
scala> class Cracker extends Tester {
| override def isValid(i: Int) = true
| }
<console>:9: error: overriding method isValid in class Tester of type (i: Int)Boolean;
method isValid cannot override final member
override def isValid(i: Int) = true
^
Deklarieren wir eine Klasse als final können wir sogar verhindern, dass die Klasse erweitert wird:
scala> final class Tester
defined class Tester
scala> class Cracker extends Tester
<console>:8: error: illegal inheritance from final class Tester
class Cracker extends Tester
^
Installationsanleitung: Scala mit Eclipse
Ich möchte hier erklären wie ihr unter Eclipse das Scala Plugin zum Laufen bekommt. Diese Anleitung richtet sich in erster Linie an alle, die bisher noch nie mit Eclipse gearbeitet haben.
Als erstes benötigt ihr Eclipse. Ihr könnt euch die IDE unter eclipse.org/downloads besorgen. Am besten wählt ihr die „Eclipse IDE for Java Developers“ aus, die ist im Vergleich zu den anderen Versionen am leichtgewichtigsten.
Entpackt das Archiv und startet Eclipse. Es sollte nach kurzer Zeit ein Fenster mit dem Titel „Workspace Launcher“ erscheinen. Der Workspace (dt.: Arbeitsplatz) ist ein Verzeichnis auf eurem Rechner, in dem Eclipse alle anfallenden Daten abspeichert (Sourcen, Konfigurationsdatein etc.). Tragt hier irgendetwas ein und bestätigt dann den Ok-Button.
Nachdem sich das Hauptfenster geöffnet hat seht ihr erst einmal einen Willkommensdialog. Diesen könnt ihr schließen indem ihr auf das Kreuzchen im Tab klickt.
Über Windows „Help -> Install New Software“ gelangt ihr zum Install-Dialog:
Betätigt dort den „Add“-Button oben rechts. Gebt nun bei Name „Scala IDE“ und bei Location folgende Adresse ein: http://download.scala-ide.org/releases/2.0.0-beta
Wählt die ersten beiden zu installierende Pakete an und entfernt den Haken bei „Contact all update sites during install …“:
Klickt auf „Next“, dann gleich nochmal auf „Next“, akzeptiert die Lizenzen und bestätigt „Finish“. Wartet bis alle Daten heruntergeladen würden, bestätigt die Warnung und startet Eclipse neu.
Nach dem ersten Start des Scala-Plugins erscheint ein Dialog mit der Aufforderung „Setup Diagnostics“ durchlaufen zu lassen um die richtigen Einstellungen auszuwählen. Wählt in etwa dies aus:
Das Scala-Plugin benötigt ziemlich viel Speicher. Dem Plugin mehr als 1GB zuzuweisen ist nicht verkehrt. Falls ihr genügend Speicher zur Verfügung habt, dann könnt ihr diese Einstellungen in der „eclipse.ini“ vornhemen, die ihr im Eclipse-Verzeichnis vorfindet. Welche Einstellungen gesetzt werden sollen verrät euch diese Setup Seite. Sucht einfach die jeweiligen Einträge heraus und ändert sie wie beschrieben. Vergesst aber nicht zu Beginn noch eine Sicherungskopie anzufertigen. Damit neue Einstellungen aktiv werden müsst ihr Eclipse neu starten.
Nachdem ihr alle Einstellungen vorgenommen habt solltet ihr zuerst die „Scala Perspective“ auswählen, dies geschieht über „Window -> Open Perspective -> Other -> Scala“
Nun könnt ihr einen Rechtsklick auf den „Package Explorer“ machen (an der linken Seite) und „New -> Scala Project“ auswählen. Eclipse verwaltet der Übersichtlichkeit wegen euren Scala-Code in je einem eigenen Projekt. Gebt dem Projekt einen Namen und bestätigt „Finish“.
Rechtsklickt nun auf „src“ in eurem Projekt und wählt „New -> Scala Object“ aus.
Gebt dem Object einen Namen und betätigt „Finish“.
Es sollte sich nun ein Tab mit der Überschrift „HelloWorld.scala“ geöffnet haben. Fügt folgenden Quelltext ein:
object HelloWorld extends App {
println("HelloWorld")
}
Wenn ihr nun die Tastenkombination „STRG+F11“ drückt oder den großen grünen Button (mit dem Titel Run) in der Symbolleiste betätigt sollte in der Konsole am unteren Rand der IDE der Text „HelloWorld“ erscheinen.
Glückwunsch! Ihr habt soeben euer ersten Scala-Programm geschrieben.
Und jetzt? Geht am besten gleich weiter zum Inhaltsverzeichnis um mit dem Scala-Tutorial beginnen zu können.
Comments (6)









