Scala - pragmatische Kombination aus funktionaler und objektorientierter Programmierung
Seite 2: Konzept der Traits
Vererbung und Traits
Scala ist objektorientiert. Es gibt Klassen, die von anderen Klassen erben, und zwar jeweils von höchstens einer. Darin unterscheidet sich Scala nicht von vielen anderen Sprachen. Es gibt in Scala neben den Klassen sogenannte Traits (Eigenschaften). Sie können genau wie Klassen Methoden und Variablen enthalten. Traits können von Klassen erben, Klassen von Traits, Traits von Traits. Aber – und das ist das Besondere – eine Klasse kann von mehreren Traits erben. Bevor diese Mehrfach-Implementierungsvererbung reflexartige Abwehrreaktionen in Erinnerung an die Komplexität von C++ auslöst: Scala bildet Traits intern durch Delegation ab und vermeidet dadurch auf elegante Weise die altbekannten Probleme der Mehrfachvererbung. Doch dazu gleich mehr.
Zunächst können Traits im Vergleich zum Beispiel zu Java-Interfaces eine reiche Auswahl an Methoden bereitstellen, ohne dass jede Klasse sie neu implementieren muss. So braucht eine Trait Ordered, die das Sortieren von Objekten erlaubt, im Wesentlichen eine einzige Methode zum Vergleichen. Diese ist abstrakt, jede implementierende Klasse muss sie demnach zur Verfügung stellen:
// minimale Version
trait Ordered[T] {
def compare (other: T): Int
}
Das entspricht dem Comparable-Interface von Java und ist alles, was man zum Sortieren von Objekten streng genommen braucht. Das T in eckigen Klammern ist ein Typ-Parameter. In Scala kann die Trait zusätzlich noch Vergleichsoperatoren bereitstellen, die auf Basis der compare-Methode definiert sind:
// ausführlichere Version
trait Ordered[T] {
def compare (other: T): Int
def < (other: T) = (this compare other) < 0
def > (other: T) = (this compare other) > 0
def <= (other: T) = (this compare other) <= 0
def >= (other: T) = (this compare other) >= 0
}
Die Trait hat Implementierungen für die Vergleichsoperatoren, und jede Klasse, die diese implementiert, erbt sie. Ohne Traits, zum Beispiel in Java, hat man drei Optionen, die alle weniger befriedigend sind. Entweder man beschränkt sich im Interface auf die eine compare-Methode – das reduziert den Komfort für aufrufenden Code, oder man nimmt die Convenience-Methoden in das Interface auf – dann muss jede Klasse sie aufs Neue implementieren. Oder man erstellt aus dem Interface eine abstrakte Klasse, aber dann ist das Vergleichen nicht mehr mit Vererbung zu mischen. Mit Traits lässt sich dagegen Aufrufern eine breite, reiche Schnittstelle anbieten, ohne implementierenden Klassen zusätzlich Arbeit zu machen.
Kombinierbare Modifikationen mit Traits
Aber Traits können noch mehr. Eine Klasse kann schließlich von mehreren Traits erben, auf diese Weise lässt sich ihr Verhalten kombinieren. Um das zu verdeutlichen, definiert man eine einfache Queue von ganzen Zahlen [3]:
abstract class IntQueue {
def get (): Int
def put (o: Int)
}
Eine einfache Implementierung könnte wie folgt aussehen:
import scala.collection.mutable.ArrayBuffer
class SimpleIntQueue extends IntQueue {
private val buf = new ArrayBuffer[Int]
def get () = buf.remove (0)
def put (o: Int) = buf+=o
}
Nimmt man jetzt an, dass man Queues mit speziellem Verhalten haben will:
- Verdoppelung: Die Queue verdoppelt alle hineingesteckten Zahlen.
- Filterung: Negative Zahlen werden ignoriert.
- Inkrementierung: Die Queue liefert immer eine Zahl zurück, die um einen größer ist als die, die man hineingesteckt hat.
Für jeden dieser Fälle lässt sich eine spezielle Queue-Klasse schreiben. Da auch Kombinationen denkbar sind und die Reihenfolge wichtig ist, explodiert selbst bei einer so geringen Zahl an Varianten die Menge der Möglichkeiten. Stattdessen können wir Traits definieren:
trait Doubling extends IntQueue {
abstract override def put (o: Int) =
super.put (2*o)
}
Die Details der Syntax gehen weit über das hinaus, was dieser Artikel leisten kann. Deswegen konzentriert er sich nur auf das Kernstück der Traits, den Aufruf von super. Die Trait erbt von IntQueue – aber die put-Methode in IntQueue ist abstrakt. Sie aufzurufen ergibt keinen Sinn. Die Antwort liegt darin, dass Scala für jede Klasse die Traits in einer Liste anordnet (Linearisierung), an deren Ende die Klasse selbst steht. Wenn man eine Methode aufruft, ist die Implementierung der ersten Trait zu verwenden. Sie kann den Aufruf mit super an den nächsten Eintrag der Kette weiterreichen:
val queue = new SimpleIntQueue with Doubling
queue.put (10) // geht an Doubling.put
println (queue.get()) // liefert 20
Die Implementierungen der anderen beiden Traits verhalten sich analog dazu, auch sie delegieren Aufrufe mit super an den nächsten Eintrag der linearisierten Kette:
trait Incrementing extends IntQueue {
abstract override def put (o: Int) =
super.put (o+1)
}
trait Filtering extends IntQueue {
abstract override def put (o: Int) =
if (o >= 0) super.put (o)
}
Jetzt ist das Verhalten einer Queue durch Kombination der Traits zu steuern:
val queue = new SimpleIntQueue
with Filtering, Doubling, Incrementing
queue.put (-1)
queue.put (-10)
queue.put (2)
println (queue.get ()) // gibt 0 aus: (-1 + 1)*2
println (queue.get ()) // gibt 6 aus: (2+1)*2
Die Reihenfolge der Traits ist wichtig – sie sind im Wesentlichen von rechts nach links anzuwenden. Eine große Stärke der Traits ist ihre Kombinierbarkeit. Sie liegt darin begründet, dass super nicht ein für allemal festgelegt ist, sondern an das nächste Element der linearisierten Kette delegiert. Dadurch sind Traits flexibel. Und für diejenigen Leser, die Sorgen wegen der Komplexität von Mehrfachvererbung haben, hier noch eine Erklärung: Weil die Vererbung von Traits sich intern über die linearisierte Kette abwickeln lässt, kann es nicht zu Mehrdeutigkeiten kommen, wenn mehrere Traits-Implementierungen für dieselbe Methode bereitstehen.