Fallklassen


So, endlich einmal wieder ein regulärer Beitrag.

Fallklassen (case classes) und Fallobjekte (case objects) sind eine nützliche Sache. Einerseits haben sie gewisse Ähnlichkeiten mit enums, auf der anderen Seite kommt ihnen eine wichtige Rolle beim Pattern Matching zu. Was sind nun Fallklassen? Im Prinzip handelt es sich um ganz normale Klassen, für die der Compiler automatisch ein paar nette Extras hinzufügt.

Am auffälligsten ist, dass man Fallklassen instantiieren kann, ohne new aufzurufen. Das wird dadurch möglich, dass der Compiler im Begleitobjekt eine apply() Methode generiert, die einfach den Konstruktor-Aufruf übernimmt. Da man statt x.apply() einfach x() schreiben kann, sieht es so aus, als ob das new einfach weggefallen wäre. Hier ein kleiner Test, in dem wir das „per Hand“ nachvollziehen – denn das ist auch für normale Klassen, die oft gebraucht werden, eine nette Abkürzung.

 
//unsere Fallklasse
case class Foo1(s:String)
Foo1("hi!")
//--> res0: Foo1 = Foo1(hi!)

//das macht der Compiler für uns:
class Foo2(val s:String)

object Foo2{
  def apply(s:String) = new Foo2(s)
}
Foo2("hi!")
//--> res1: Foo2 = Foo2@127bd0e

Ein weiterer Unterschied gibt es in den Konstruktorargumenten: Es braucht kein val angegeben zu werden, damit man auf die Argumente von außen zugreifen kann (allerdings kann man var verwenden, um sie veränderbar zu machen). Eine Fallklasse sollte sich nur über ihren Namen und die Konstruktorargumente „definieren“, und diese deshalb normalerweise unveränderlich sein. Der Compiler übernimmt nämlich auch noch eine „vernünftige“ Implementierung von equals(), toString() und hashCode(). Probieren wir es aus:

Foo1("x").toString
//--> res2: String = Foo1(x)
Foo2("x").toString
//--> res3: java.lang.String = Foo2@1a7e2b2
Foo1("x") == Foo1("x")
//--> res4: Boolean = true
Foo2("x") == Foo2("x")
//--> res5: Boolean = false

Kommen wir zum letzten „Extra“, der unapply() Methode. Sie erlaubt, ein Objekt nach gewissen Regeln in seine „Einzelteile“ zu zerlegen, und sie macht auch das Pattern Matching erst richtig flexibel. Man nennt Klassen mit dieser Eigenschaft „Extraktoren“ Auch hier probieren wir einen „Nachbau“:

case class Person(name:String, age:Int)

def isDaniel(p:Person) = p match {
  case Person("Daniel", age) => 
      println("Daniel ist " + age + " Jahre alt")
      true
  case Person(name, age) => 
      println("Unbekannte Person "+name+" ist "+age+" Jahre alt")
      false
}

isDaniel(Person("Daniel", 35))
//--> Daniel ist 35 Jahre alt
//--> res0: Boolean = true
isDaniel(Person("Daniela", 17))
//--> Unbekannte Person Daniela ist 17 Jahre alt
//--> res1: Boolean = false

//der "Nachbau" mit unapply()
class Mensch(val name:String, val alter:Int) 

object Mensch {
   def unapply(m:Mensch):Option[(String, Int)] = Some((m.name, m.alter))
}

def isDaniel(m:Mensch) = m match {
  case Mensch("Daniel", age) => 
      println("Daniel ist " + age + " Jahre alt")
      true
  case Mensch(name, age) => 
      println("Unbekannte Person "+name+" ist "+age+" Jahre alt")
      false
}

isDaniel(new Mensch("Daniel", 35))
//--> Daniel ist 35 Jahre alt
//--> res2: Boolean = true
isDaniel(new Mensch("Daniela", 17))
//--> Unbekannte Person Daniela ist 17 Jahre alt
//--> res3: Boolean = false

Ich will an dieser Stelle nicht auf die genauen Regeln für unapply() eingehen, mehr dazu findet sich z.B. in dem Papier Matching Objects with Patterns (B. Emir, M. Odersky, J. Williams) oder im bereits erwähnten Buch Programming in Scala.

Ein netter Trick, der mir von joni im Scala-Forum verraten wurde, hilft bei der Validierung der Konstruktorargumente. Wie wir bereits gesehen haben, haben diese für Fallklassen eine viel wichtigere Bedeutung als bei normalen Klassen, die sie auch einfach „auslutschen und wegwerfen“ können. Nun möchte man eventuell gerne diese Argumente prüfen oder sogar ändern, bevor man sie wirklich an die Fallklasse übergibt. Dafür würde sich die apply()-Methode des Begleitobjekts eignen, wenn der Compiler mitspielen würde:

case class RestKlasse(wert:Int, modul:Int)

object RestKlasse {
  def apply(wert:Int, modul:Int) = {
    require(modul > 1)
    new RestKlasse(wert % modul, modul)
  }
}

//--> error: method apply is defined twice
//-->        case class RestKlasse(wert:Int, modul:Int)
//-->                   ^

Eigentlich logisch, denn das Begleitobjekt unserer Fallklasse muss ja genau so eine apply()-Methode schon besitzen. Die Lösung des Problems ist so simpel wie überraschend: Man definiere die case class zusätzlich als abstrakt, was den Compiler offenbar davon abhält, eine apply()-Methode zu generieren, aber den sonstigen Fallklassen-„Service“ intakt läßt:

abstract case class RestKlasse(wert:Int, modul:Int)

object RestKlasse {
  def apply(wert:Int, modul:Int) = {
    require(modul > 1)
    new RestKlasse(wert % modul, modul){}  //<-- die {} Klammern sind notwendig
  }
}

RestKlasse(14,5)
//--> res0: RestKlasse = RestKlasse(4,5)

Zum Schluss noch ein kleines Beispiel für die Verwendung von Fallobjekten, und zwar ternäre Logik:

sealed trait Ternary {
  def &&(that:Ternary):Ternary
  def ||(that:Ternary):Ternary
  def unary_!() :Ternary
}

case object True extends Ternary {
  def &&(that:Ternary) = that match {
    case True => True
    case False => False
    case Maybe => Maybe 
  }
  def ||(that:Ternary) = that match {
    case True => True
    case False => True
    case Maybe => True 
  }
  def unary_!() = False
}

case object False extends Ternary {
  def &&(that:Ternary) = that match {
    case True => False
    case False => False
    case Maybe => False
  }
  def ||(that:Ternary) = that match {
    case True => True
    case False => False
    case Maybe => Maybe
  }
  def unary_! = True
}

case object Maybe extends Ternary {
  def &&(that:Ternary) = that match {
    case True => Maybe
    case False => False
    case Maybe => Maybe
  }
  def ||(that:Ternary) = that match {
    case True => True
    case False => Maybe
    case Maybe => Maybe
  }
  def unary_!() = Maybe
}

defined trait Ternary
defined module True
defined module False
defined module Maybe

(True && Maybe) == (False || Maybe)
//--> res0: Boolean = true

Diese kleine Anwendung zeigt, dass sich Fallklassen oder -objekte auch gut für die Implementierung Algebraischer Datentypen eignen. Algebraische Datentypen sind sozusagen ein „Sammlung“ mehrerer (teilweise sehr verschiedener) Untertypen, ohne dass eine echte Vererbungsbeziehung (die es in vielen Sprachen wie etwa Haskell gar nicht gibt) bestehen würde.

Ich hoffe, dass dieser kleine Überblick zum besseren Verständnis der Fallklassen beigetragen hat. Darüber hinaus wollte ich zeigen, wie man die verwendete Compilermagie auch für die eigenen Zwecke nutzen kann.

Advertisements

Ein Gedanke zu “Fallklassen

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