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.

Advertisements

5 comments so far

  1. Smisery on

    Wunderschön erklärt! Eine Mini-Frage dennoch: Angenommen, ich deklariere einen Datentyp in Haskell wie folgt:

    data FooNumeric = Int | Float

    wie sähe das entsprechende ADT Pendant in Scala aus? Ich möchte also semantisch ausdrücken, dass mein case object „FooNumeric“, die Typen Int oder Float annehmen kann. Also allgemeiner: Wie kann ich über die case .. extends .. Konstrukte, wie von dir beschrieben, erreichen, dass ein von mir neu definierter Datentyp auch bereits vorhandene Typen annehmen kann (z.B. um Pattern Matching durchzuführen)?

  2. Smisery on

    Tut mir Leid, ich hatte das Beispiel etwas kurz gehalten (auch, weil ich nicht mehr so in Haskell drin bin, und mir nur dein data Bool Bsp. angeschaut hatte, aber langsam erinnere mich, von wegen nullary constructors und so, gell? 🙂 )

    Ich versuche eine generische Möglichkeit zu finden, in Scala so sauber („auf-den-Punkt-gebracht“) wie möglich ADTs, die in Haskell bereits implementiert sind, in Scala zu re-implementieren (nicht der ADTs wegen, sondern Konzepten die darauf basieren).

    Vielleicht als anderes Beispiel:

    data Foo = Nil | Left Int Foo | Right Foo Int deriving (Eq, Show)

    Ich möchte also neue Typ Konstruktoren einführen mit Daten Konstruktoren die auch bereits vorhandene Datentypen übergeben bekommen kann (ohne diese vorhandenen „anzufassen“). Wahrscheinlich habe ich mich mit meinem ersten Beispiel damit sehr verzettelt, und könnte einfach Parameter in die Class/Object Constructors in Scala hinzufügen. Verbessere mich ansonsten gerne! 🙂

    Danke für den Hinweis mit den Union-Types das sieht wiedermal sehr interessant aus!

    • antoras on

      Genau, dein Code-Beispiel kann man so 1:1 nach Scala übersetzen, indem man die Parameter einfach über den ctor mitgibt. Falls dir das nicht reicht solltest du mir ein umfangreicheres Code-Beispiel geben, mit der einen Zeile kann ich zu wenig anfangen.

  3. Dippy on

    Wenn man ’sealed‘ benutzt verhindert man auch weitere direkten Vererbungen außerhalb der Datei (Source File).
    Es lässt sich also garantieren, das keine weiteren direkten SubTypen in einer anderen Datei deklariert werden.

    Am Beispiel der ADTs für Bool würe dies bedeuten, dass wir nur True und False erstellen und nicht noch ein weiteres X. Somit kann man dann auch den MatchError „def determine(b: Bool) ……“ vermeiden

    File1:
    sealed abstract class Bool
    case object True extends Bool
    case object False extends Bool

    File2:
    case object X extends Bool //hier nicht mehr erlaubt

    //keine matchErrors möglich weil b ’sealed‘
    def determine(b: Bool) = b match {
    case True => „true“
    case False => „false“
    }

    //matchError möglich wenn b != Bool
    def determine(b: Any) = b match {
    case True => „true“
    case False => „false“
    }

    Es ist allerdings möglich SubTyps von SubTyps zu erstellen. Dies lässt sich dann mit ‚final‘ unterdrücken

    File1:
    sealed abstract class Bool
    case class True extends Bool
    final case False extends Bool

    File2:
    case object X extends True
    case object Y extends False //hier nicht erlaubt, da final False ‚final‘


Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s

%d Bloggern gefällt das: