Archive for Dezember 2011|Monthly archive page

Teil 14.1: Varianzen

Mit Varianzen können Aussagen darüber gemacht werden ob die Vererbungshierarchie eines parametrisierten Typs auf eine generische Klasse übertragen werden soll. Das Hauptaugenmerk liegt dabei auf der Frage ob eine generische Klasse mit einem Typ Foo, der Untertyp von Bar ist (Foo <: Bar), auch ein Untertyp der selben generischen Klasse mit einem Typ Bar ist (GenClass[Foo] <: GenClass[Bar]).

Gehen wir einmal davon aus, dass obige Aussage zutreffen würde, dann müsste folgender Code korrekt kompilieren:

val xs: Array[Any] = Array[Int](18)
xs(0) = "hello"

Int ist ein Untertyp von Any (Int <: Any), also trifft dies auch auf die Arrays zu (Array[Int] <: Array[Any]). Da String <: Any gilt wird folglich auch die Zuweisung in der nächsten Zeile korrekt kompilieren. Sobald dieser Code jedoch ausgeführt werden würde, würden wir eine Exception bekommen, da ein String nicht in einem Int-Array abgespeichert werden kann (type-erasure gilt nicht für Arrays!).

Dies ist ein gefährlicher Fehler, vor dem uns der Scala-Compiler aber vortrefflich schützt – obiger Code würde nicht kompilieren:

scala> val xs: Array[Any] = Array[Int](18)
<console>:7: error: type mismatch;
 found   : Array[Int]
 required: Array[Any]
Note: Int <: Any, but class Array is invariant in type T.
You may wish to investigate a wildcard type such as `_ <: Any`. (SLS 3.2.10)
       val xs: Array[Any] = Array[Int](18)
                                      ^

Bei List bekommen wir diesen Fehler nicht:

scala> val xs: List[Any] = List[Int](18)
xs: List[Any] = List(18)

Der Grund, warum die Varianz der Liste nicht so funktioniert wie bei Arrays, liegt in der Unveränderlichkeit. Die Elemente einer List lassen sich nach ihrer Erstellung nicht mehr verändern, die von Array dagegen schon. Der Compiler schützt uns also davor, Schabernack mit veränderlichen Objekten zu machen.

Invarianz

Es gibt drei verschiedene Arten von Varianzen. Die Invarianz, die die Standardvarianz in Scala ist, wollen wir uns als erstes anschauen. Immer dann, wenn wir einen generischen Parameter ohne weitere Kennzeichnung erstellen, erstellen wir einen invarianten parametrisierten Typ.

Invarianz bedeutet, dass sich der Beziehungsstatus von parametrisierten Typen nicht auf die generischen Klassen überträgt. Es gilt:

A <: B => keine Beziehung zwischen C[A] und C[B]

Arrays sind in Scala wegen ihrer Veränderlichkeit invariant. Es ist uns also nicht möglich ein Array vom Typ Int auf ein Array vom Typ Any zu mappen. obwohl Int <: Any.

Wir können das invariante Verhalten von Array leicht nachbauen:

scala> case class Box[A](var a: A)
defined class Box

scala> val b1 = Box(9)
b1: Box[Int] = Box(9)

scala> val b2: Box[Any] = b1
:13: error: type mismatch;
 found   : Box[Int]
 required: Box[Any]
Note: Int You may wish to define A as +A instead. (SLS 4.5)
       val b2: Box[Any] = b1
                          ^

Kovarianz

Die zweite mögliche Form ist die Kovarianz, die es uns erlaubt den Beziehungsstatus von Typen, ganz im Gegensatz zur Invarianz, auf die generischen Klassen zu übertragen. Es gilt:

A <: B => C[A] <: C[B]

Ein Beispiel für eine invariante Datenstruktur in Scala ist List. Die Beziehung lautet List[Int] <: List[Any], da Int <: Any. Wir können unsere eigenen invarianten Datentypen erstellen, indem wir der Typisierung ein + voranstellen:

scala> case class Box[+A](a: A)
defined class Box

scala> val b: Box[Any] = Box[Int](8)
b: Box[Any] = Box(8)

Versuchen wir nun unseren Typ veränderlich zu machen, beschwert sich sofort wieder der Compiler:

scala> case class Box[+A](var a: A)
:10: error: covariant type A occurs in contravariant position in type A of value a_=
       case class Box[+A](var a: A)
                  ^

Falls dieser Code kompilieren würde, wäre nicht sicher gestellt, dass Box auch nur den Typ aufnimmt, für den es bestimmt wurde:

// does not compile
val b: Box[Any] = Box[Int](9)
b.a = "hello"

Kontravarianz

Die letzte mögliche Variante ist die Kontravarianz. Sie funktioniert genau verkehrt herum wie die Kovarianz. Es gilt:

A <: B => C[B] <: C[A]

Kontravarianz wird sehr selten eingesetzt, da es im Grunde nicht viele Anwendungsfälle dafür gibt. Ein Beispiel wäre z.B. das Folgende:

class Foo[-A] {
  def bar(a: A): Int =
    sys.error("unimplemented")
}

scala> val base = new Foo[Any]
base: Foo[Any] = Foo@e18a9e8

scala> val derived: Foo[Int] = base
derived: Foo[Int] = Foo@e18a9e8

Zu Recht stellt man sich am Anfang die Frage warum ein Foo[Any] als Foo[Int] angesprochen werden kann, schließlich ist Int <: Any. Schauen wir uns also ein paar weitere Beispiele an um die Unterschiede zwischen den einzelnen Varianzen stärker hervorzuheben.

Anwendungsfälle

Zur Invarianz sollte es nichts mehr zu sagen geben. Alle veränderlichen parametrisierten Typen sind invariant, da es sonst möglich wäre sie mit einem ungültigen Typ zu überschreiben. Bitte beachtet, dass es einen Unterschied zwischen

val xs: Array[Any] = Array(3)

und

val xs: Array[Any] = Array[Int](3)

gibt. Nur die erste Variante würde erfolgreich kompilieren, die zweite würde aufgrund des invarianten Arrays eine Fehlermeldung verursachen. Dank Scalas Typinferenz erstellt der Compiler im ersten Beispiel ein Array[Any], weist diesem aber ein Int zu. Dies funktioniert ohne Probleme, da Int <: Any. Im zweiten Beispiel würde ein Array[Int] erzeugt werden, das als Array[Any] betrachtet werden soll – das ist verboten.

Mit der Kovarianz können wir schon deutlich mehr anstellen. Das Beispiel mit List[Int] <: List[Any] wurde schon genannt. Die Frage ist jetzt nur was für einen konkreten Vorteil man davon hat eine List[Int] als List[Any] anzusprechen. Um diese Frage zu klären schauen wir uns folgenden Code an:

// does not compile

abstract class Animal
class Cow extends Animal
class Pig extends Animal

class Farm {
  
  private val cows = new Array[Cow](3)
  
  cows(0) = new Cow
  cows(1) = new Cow
  
  def animals: Array[Animal] = cows // type mismatch
}

Auf einem Bauernhof werden verschiedenen Tiere verwaltet. Intern können die Tiere korrekt angesprochen werden. Von außerhalb des Hofes wollen wir aber nicht, dass irgendjemand eines der Tiere direkt ansprechen kann, weshalb wir nur die Information weitergeben, dass zwar Tiere vorhanden sind, aber nicht welche genau. Falls dieser Code kompilieren würde, könnte jetzt jemand von außerhalb einfach so ein Schwein auf einen Bauernhof mit lauter Kühen platzieren, was aber nicht funktionieren darf, da z.B. keine Nahrung für Schweine vorhanden ist.

Ändern wir das Array zu einer List würde der Code erfolgreich kompilieren.

class Farm {
  
  private var cows = List.empty[Cow]
  
  cows :+= new Cow
  cows :+= new Cow
  
  def animals: List[Animal] = cows
}

Da List nicht veränderlich ist, würde unserem Kühe-Bauernhof auch nichts passieren wenn er durch Schweine ergänzt werden würde. Schließlich würden dadurch alle Tiere geklont werden und könnten auf einem neuen Bauernhof platziert werden – der alte Bauernhof bleibt unangetastet.

Vereinfacht gesagt erzeugt Kovarianz read-only-Datenstrukturen. Deren Daten können zwar jederzeit abgerufen aber nie verändert werden. Die Kontravarianz dagegen macht das genaue Gegenteil. Sie erzeugt write-only-Datenstrukturen. Schauen wir uns an wofür das nützlich ist:

// P = parameter value; R = return value
abstract class AnimalTransformator[-P, +R] {
  def apply(p: P): R
}

val animal2cow = new AnimalTransformator[Animal, Cow] {
  def apply(p: Animal) = /* a lot of magic here */ new Cow
}

val pig2animal: AnimalTransformator[Pig, Animal] = /* also a lot of magic here */ animal2cow

Es existieren magische Geräte, die Tiere in andere Tiere verwandeln können. Welche Tiere dabei herauskommen ist nicht immer festzustellen. Eines dieser Geräte (animal2cow) erzeugt jedoch immer eine Kuh, egal was für ein Tier es als Eingabe erhält. Ein anderes Gerät (pig2animal) erzeugt aus einem Schwein irgendein Tier. Wenn letzteres Gerät nun sein Schwein an das erste Gerät weiterreicht und dessen Kuh entgegen nimmt, dann wäre das kein Verstoß seiner Funktionsweise, da Pig <: Animal und Cow <: Animal. Das erste Gerät kann immer ein Schwein entgegen nehmen, das zweite dagegen immer eine Kuh. In Scala funktioniert das aber nur wenn wir P als kontravariant und R als kovariant markieren.

Gut, das Beispiel war jetzt sehr an den Haaren herbeigezogen, man sollte daran aber auch die Funktionsweise des Leseschutzes erkennen können. Folgender Code würde nämlich nicht funktionieren:

// in AnimalTransformator
def clone(p: P): P

// signature in our case
// clone(Animal): Animal

Diese Methode würde ein Tier klonen, was auch funktionieren darf. Das Klonen könnte theoretisch aber auch schief gehen, was zur Folge hätte, dass z.B. ein unsichtbarer Klon erzeugt werden würde. Was machen wir dann? Wir könnten nicht herausbekommen ob das Tier geklont oder fälschlicherweise transformiert wurde. Ein Tier als Eingabe würde auch ein Tier als Ausgabe produzieren – so ist unsere Spezifikation. Wir könnten unser Tier als Tier ansprechen, aber eben nicht als irgendeine Spezialisierung – der Leseschutz offenbart sich also als Hilfe, die uns daran hindert, eine Kuh als Schwein zu betrachten.

Als kleine Merkhilfe kann die Position eines Typs dienen. Parameter von Methoden können kontravariant, jedoch nie kovariant sein. Die Rückgabewerte dürfen dagegen nie kontravariant sein. Warum das so ist solltet ihr aufgrund den obigen Erklärungen nun verstanden haben. Falls dem noch nicht so ist, geht die Beispiele noch einmal durch, baut euch eure eigene Beispiele und überlegt nochmal, was passieren würde wenn kovariante Parametertypen und kontravariante Rückgabetypen erlaubt wären.

Ausnahmefälle

Mit Collections gibt es einen schönen Anwendungsfall, bei dem wir kovariante Typen benötigen, diese aber auch als Parameter verwenden müssen. Nehmen wir als Collection-Beispiel die altbekannte List von der wir schon im Kapitel über Extraktoren eine eigene Implementierung geschrieben haben. Diese wollen wir nun zu einer generischen Liste ausbauen, damit wir sie für alle Typen nutzen können und nicht nur für Int. Damit wir keine Namensprobleme mit der Liste aus den Scala-Collections bekommen, werden wir einen anderen Namen wählen:

abstract class LinkedList[A] {
  def head: A
  def tail: LinkedList[A]
  def isEmpty: Boolean
  
  def :: (a: A): LinkedList[A] =
    new ::(a, this)
}

case class :: [A](head: A, tail: LinkedList[A]) extends LinkedList[A] {
  def isEmpty = false
}

case object LNil extends LinkedList[Nothing] {
  def head = throw new UnsupportedOperationException("nil head")
  def tail = throw new UnsupportedOperationException("nil tail")
  def isEmpty = true
}

Die einzige Neuerung sollte in diesem Codebeispiel der Typ „Nothing“ sein. Nothing ist ein Untertyp jedes anderen Typs (A => Nothing <: A) und kann somit als Typisierung der List verwendet werden. Einem object können wir keinen Typparameter mitgeben, weshalb wir auf Nothing angewiesen sind.

Einziges Problem ist jetzt die Erstellung einer geeigneten Liste:

scala> val xs = 1 :: 2 :: 3 :: LNil
<console>:13: error: type mismatch;
 found   : x$1.type (with underlying type Int)
 required: Nothing
       val xs = 1 :: 2 :: 3 :: LNil
                            ^

Unsere List ist invariant, weshalb der Compiler es nicht akzeptiert eine LinkedList[Nothing] an eine LinkedList[Int] zu hängen, obwohl Nothing <: Int. Führen wir also Kovarianz ein:

abstract class LinkedList[+A] { ... }

Leider taucht nun eine weitere Fehlermeldung auf:

<console>:19: error: covariant type A occurs in contravariant position in type A of value a
         def :: (a: A): LinkedList[A] =
                 ^

Ein kovarianter Paramertyp – das geht verständlicherweise nicht. Aber was machen wir da jetzt? Die Lösung liegt in der Benutzung eines weiteren Typparameters:

// in class LinkedList
def :: [B >: A](b: B): LinkedList[B] =
  new ::(b, this)

Die Bedeutung dieses Konstrukts zu verstehen ist jetzt auch nicht mehr viel komplizierter als der Rest von Varianzen. Unser Ausgangspunkt zur Erstellung einer LinkedList ist LNil. Dieses Objekt ist mit Nothing parametrisiert. Die Typsignatur der Methodenaufrufe würde also so aussehen:

LinkedList[Nothing].::(Int).::(Int).::(Int): LinkedList[Int]

Um von einer LinkedList[Nothing] zu einer LinkedList[Int] zu kommen benötigt es einem Lower-Bound (B >: A => Int >: Nothing). Nichts anderes sagt der zusätzliche Parameter in der Konkatenations-Methode aus, die eine LinkedList mit dem höherwertigen Typ zurück gibt.

Ich denke, dass es angebracht ist euch an dieser Stelle zu beruhigen. Falls ihr bisher noch nie mit Varianzen zu tun hattet, dann werdet ihr ihre praktische Bedeutung vermutlich nicht vollständig verstanden haben (wenn ihr überhaupt irgendetwas verstanden habt ;)). Varianzen gehören aber nun mal zur Typ-Theorie dazu, weshalb man sich damit früher oder später beschäftigen sollte. Falls euch das alles also ein wenig zu kompliziert war, dann kommt zu einem späteren Zeitpunkt darauf zurück.

Advertisements

Teil 14: Generische Typen

Dieser Artikel ist eigentlich schon längst überfällig, doch musste ich ihn immer wieder zurückstellen, da andere Themen zuerst erklärt werden wollten. Generische Typen haben wir schon zu Genüge kennen gelernt, wir finden sie z.B. bei Collections:

scala> List[Int](1, 2, 3)
res0: List[Int] = List(1, 2, 3)

scala> List[String]("foo", "bar")
res1: List[String] = List(foo, bar)

In diesem Fall wird auch von einem parametrisierten Typ gesprochen. Ein parametrisierter Typ kann ein Typ sein über den man zur Zeit der Codeentwicklung noch nicht Bescheid weiß. Er kann beliebige Typen repräsentieren und ermöglicht uns wiederverwendbaren Code zu schreiben. In obigem Beispiel können wir unsere Liste z.B. mit Ints oder mit Strings kreieren. Der Typparameter wird in Scala durch eckige Klammern repräsentiert und steht immer vor runden oder geschweiften Klammern. Nach dem Typparameter kann z.B. ein Konstruktoraufruf, eine Parameterliste oder eine Blockdefinition kommen. Dank Scalas Typinferenz müssen wir die Typparameter unserer Objekte jedoch nur selten notieren. Typen, die einen oder mehrere Typparameter besitzen, werden auch als generische Typen bezeichnet. Wie an obigem Codebeispiel zu erkennen gibt uns die REPL die Typparameter nur am statischen Typ, nicht jedoch am Runtime-Typ aus. Das liegt daran, dass Scala der „type erasure“ der JVM unterliegt, die besagt, dass alle Informationen zu Typparametern zur Laufzeit nicht mehr vorhanden sind. Die JVM ersetzt alle Typparameter durch Object, dem Typ, dem alle Objekte auf der JVM unterliegen. Zur Laufzeit ist eine List[Int] also identisch mit einer List[String], da sie beide nur noch als List[Object] repräsentiert werden. Wirklich stören tut die „type erasure“ der JVM aber selten, da der Scala-Compiler dafür sorgt, dass wir überall auch die richtigen Typen einsetzen. Natürlich gibt es auch Anwendungsfälle, bei denen es schön wäre, wenn die Typinformationen zur Laufzeit vorhanden wäre. Scala stellt uns hierfür aber ein paar „Tricks“ zur Verfügung, mit denen wir uns in einem solchen Fall weiterhelfen können, aber dazu später mehr.

Fangen wir damit an unseren eigenen Code mit Typparametern zu schreiben.

def size[A](xs: List[A]): Int = {
  def loop(xs: List[A], i: Int): Int =
    if (xs.isEmpty) i else loop(xs.tail, i+1)
  loop(xs, 0)
}

Bisher haben wir unseren Code meistens auf Basis von List[Int] geschrieben. Mit obigem Code ändert sich das. Die Methode erwartet nun eine List[A]. A steht als Platzhalter für irgendeinen Typ, der uns jetzt noch nicht bekannt ist. Da es den Typ A aber noch nicht gibt müssen wir ihn irgendwo definieren – in Scala tun wir dies indem wir ihn in eckigen Klammern nach dem Methodennamen schreiben. Damit erzeugen wir für den Gültigkeitsbereich der Methode eben jenen Typparameter und können ihn fortan verwenden. Wir müssen im Folgenden nur dafür Sorge tragen, dass wir den definierten Typ überall dort verwenden wo wir ansonsten einen konkreten Typ (wie Int oder String) notiert hätten. Haben wir alles richtig gemacht, sollte unser Code für jegliche Listen funktionieren:

scala> size(List(1,2,3))
res0: Int = 3

scala> size(List('a','b'))
res1: Int = 2

Der Compiler sucht für uns alle Vorkommnisse von A heraus und füllt die benötigten Typen ein. Das macht sich dann bemerkbar wenn wir einen generischen Rückgabetyp definieren:

def reverse[A](xs: List[A]): List[A] = {
  def loop(xs: List[A], ys: List[A]): List[A] =
    if (xs.isEmpty) ys else loop(xs.tail, xs.head :: ys)
  loop(xs, Nil)
}

scala> reverse(List(1, 2, 3))
res4: List[Int] = List(3, 2, 1)

scala> reverse(List("a", "b", "c"))
res5: List[java.lang.String] = List(c, b, a)

Wie unschwer zu erkennen, besitzt die zurückgegebene Liste immer den richtigen Typparameter. Der Name des Typparameters ist im Übrigen frei wählbar. Eingebürgert hat sich aber, dass ein ein einzelner großer Buchstabe verwendet wird, beginnend mit A. Je nach dem in welchem Kontext der Typ benutzt wird, sieht man auch oft mehrere Zeichen umfassende Symbole, wie CC für Collection oder Ret für einen Rückgabetyp. Wollen wir mehr als einen Typparameter haben, so müssen wir diese durch ein Komma trennen:

def print[A, B](a: A, b: B) {
  println(a)
  println(b)
}

scala> print(5, "hello")
5
hello

Nach der gleichen Vorgehensweise wie bei Methoden können wir auch Klassen mit Typparametern versehen:

scala> case class Box[A](a: A)
defined class Box

scala> Box(3)
res0: Box[Int] = Box(3)

scala> Box("Hello")
res1: Box[java.lang.String] = Box(Hello)

Type bounds

Manchmal wollen wir aber keinen Code für beliebige Typen schreiben, sondern Code für Typen mit einer bestimmten Eigenschaft. Die Frage ist jetzt nur was eine solche Eigenschaft sein könnte. Wie wäre es wenn wir die Objekte eines Typs miteinander vergleichen könnten? Der Compiler ist so freundlich und implementiert uns für Fallklassen die equals-Methode:

scala> Box(3) == Box(3)
res4: Boolean = true

scala> Box(3) == Box(7)
res5: Boolean = false

Bei den größer- und kleiner-Methoden sieht es da schon schlechter aus:

scala> Box(3) < Box(9)
<console>:10: error: value < is not a member of Box[Int]
              Box(3) < Box(9)
                     ^

scala> Box(3) > Box(9)
<console>:10: error: value > is not a member of Box[Int]
              Box(3) > Box(9)
                     ^

Das ist auch der Grund weshalb sich die Objekte nicht sortieren lassen, da sie einfach keine Wertigkeit besitzen:

scala> import collection.immutable._
import collection.immutable._

scala> SortedSet(Box(3), Box(9))
:13: error: No implicit Ordering defined for Box[Int].
              SortedSet(Box(3), Box(9))
                       ^

Die Fehlermeldung des Compilers gibt uns den Hinweis, dass wir eine Sortierungsmöglichkeit noch einbauen müssen. In Scala sieht die Standardvorgehensweise dafür so aus, dass die zu sortierende Klasse den Typ „Ordered“ implementieren müssen:

scala> case class IntBox(i: Int) extends Ordered[IntBox]
:12: error: class IntBox needs to be abstract, since method compare in trait Ordered of type (that: IntBox)Int is not defined
       case class IntBox(i: Int) extends Ordered[IntBox]
                  ^

Ordering definiert eine Methode namens „compare“, die wir noch implementieren müssen:

case class IntBox(i: Int) extends Ordered[IntBox] {
  def compare(x: IntBox) =
    this.i-x.i
}

scala> SortedSet(IntBox(3), IntBox(9), IntBox(1))
res18: scala.collection.immutable.SortedSet[IntBox] = TreeSet(IntBox(1), IntBox(3), IntBox(9))

scala> IntBox(7) < IntBox(3) res19: Boolean = false scala> IntBox(7) > IntBox(3)
res20: Boolean = true

scala> IntBox(3) >= IntBox(3)
res21: Boolean = true

Das Verhalten ist genau so wie wir es erwarten. Ordered definiert alle Vergleichsmethoden, die es in Scala gibt. Wir müssen uns nur noch darum kümmern, dass die Vergleichsmethoden auch richtig arbeiten was durch durch die Implementierung der compare-Methode sicher stellen können. Die Parametrisierung von Ordered bestimmt den Typ des Parameters den die compare-Methode erwartet. Wer wissen möchte wie Ordered genau funktioniert, kann in dessen Sourcen gucken.

Euch dürfte aufgefallen sein, dass ich im letzten Beispiel keine parametrisierte Box benutzt habe. Ich hätte es gerne getan, nur leider besitzt ein generischer Typ keine Subtraktionsmethode, mit deren Hilfe wir die Wertigkeit des Objekts errechnen könnten. In Scala gibt es einen weiteren Type namens „Ordering“, der es uns erlaubt eine Wertigkeit auch für parametrisierte Typen anzugeben. Um Ordering korrekt benutzen zu können benötigt ihr aber noch ein wenig mehr Wissen über Scalas implizite Typen. Da das aber Stoff für ein weiteres Kapitel ist, werden wir uns hier nicht weiter mit Ordering befassen. Die einzigen, die wir auf einen parametrisierten Typ aufrufen können, sind die, die wir auch auf den Typ Object aufrufen können. Es besteht also die Möglichkeit, dass wir den Hash-Code miteinander vergleichen:

case class Box[A](a: A) extends Ordered[A] {
  def compare(a: A) =
    this.hashCode-a.hashCode
}

Diese Lösung erfordert aber eine korrekt implementierte hashCode-Methode und sollte daher nicht immer das erste Mittel der Wahl sein.

Kommen wir lieber wieder zu den Eigenschaften zurück, die wir den parametrisierten Typen mitgeben wollen:

class Sorter[A <: Ordered[A]]

Das <:-Symbol bedeutet, dass A ein Untertyp von Ordered[A] sein muss. Versuchen wir fortan eine Instanz vor Sorter mit einem Parameter zu erzeugen, der nicht sortiert werden kann, erhalten wir eine Fehlermeldung:

scala> new Sorter[IntBox]
res17: Sorter[IntBox] = Sorter@3eadff2f

scala> val x = new Sorter[Boolean]
<console>:8: error: type arguments [Boolean] do not conform to class Sorter's type parameter bounds [A <: Ordered[A]]
       val x = new Sorter[Boolean]
           ^
<console>:8: error: type arguments [Boolean] do not conform to class Sorter's type parameter bounds [A <: Ordered[A]]
       val x = new Sorter[Boolean]
                   ^

scala> val x = new Sorter[Int]
<console>:8: error: type arguments [Int] do not conform to class Sorter's type parameter bounds [A <: Ordered[A]]
       val x = new Sorter[Int]
           ^
<console>:8: error: type arguments [Int] do not conform to class Sorter's type parameter bounds [A <: Ordered[A]]
       val x = new Sorter[Int]
                   ^

Int besitzt definitiv Vergleichsmethoden, implementiert aber nicht den Typ Ordered, weshalb unser Code auch nicht kompiliert. Tatsächlich haben wir mit Ordered ein paar Krücken gewählt, die eher für Notsituation zu gebrauchen sind. Was wir wirklich brauchen ist nicht Ordered, sondern Ordering, das wir ja aber noch nicht verwenden können. Ok ok, ich bin nachsichtig und strecke euch den benötigten Code vor, gebe aber keine Garantie darauf, dass ihr ihn auch versteht.

class Sorter[A](implicit ord: Ordering[A])

case class Box[A](a: A)
object Box {
  implicit def ord[A]: Ordering[Box[A]] =
    new Ordering[Box[A]] {
      def compare(x: Box[A], y: Box[A]) =
        y.a.hashCode-x.a.hashCode
    }
}

Der implizite Parameter stellt sicher, dass eine Sorter-Instanz auch nur für Typen erstellt werden kann, die über eine geeignete Vergleichsmöglichkeit verfügen. Wir können den Code nun erfolgreich nutzen:

scala> new Sorter[Box[Int]]
res0: Sorter[Box[Int]] = Sorter@32a88bc2

scala> new Sorter[Int]
res1: Sorter[Int] = Sorter@5e3b8219

scala> new Sorter[Boolean]
res2: Sorter[Boolean] = Sorter@37f2ae62

scala> class X
defined class X

scala> new Sorter[X]
:10: error: No implicit Ordering defined for X.
              new Sorter[X]
              ^

Falls ihr das Beispiel oben ausprobiert dabei aber nicht das gleiche Ergebnis erhaltet habt müsst ihr die implizite Methode noch importieren (je nach dem wo und wie ihr den Code definiert hab wird er automatisch vom Compiler gefunden):

import Box._

Überraschen sollte euch hier, dass der Code auch für Int und Boolean funktioniert. Weshalb er es tut will ich hier aber nicht näher erläutern – dafür müsst ihr euch noch bis zu einem der nächsten Kapitel gedulden.

Wir können einen Typparameter auch nach oben hin beschränken:

class Bar
class Baz extends Bar
class Buz extends Baz
class Boz extends Buz

class Foo[A >: Buz]

Dies führt dazu, dass nur Obertypen von Buz erlaubt sind, bei allen anderen bekommen wir eine Fehlermeldung:

scala> new Foo[Bar]
res8: Foo[Bar] = Foo@4e7f3159

scala> new Foo[Baz]
res9: Foo[Baz] = Foo@caa6635

scala> new Foo[Buz]
res10: Foo[Buz] = Foo@48b91881

scala> new Foo[Boz]
:12: error: type arguments [Boz] do not conform to class Foo's type parameter bounds [A >: Buz]
       val res11 =
           ^
:13: error: type arguments [Boz] do not conform to class Foo's type parameter bounds [A >: Buz]
              new Foo[Boz]
                  ^

Es ist sogar möglich beide Beschränkungen miteinander zu kombinieren:

class Foo[A >: Bar <: Baz]

Hier darf Foo nur mit Obertypen von Bar instanziiert werden, die gleichzeitig auch Untertypen von Baz sind.

Abstrakte Typen

Neben parametrisierten Typen bietet Scala auch sogenannte abstrakte Typen, die eine weitere Möglichkeit darstellen wie man generisch programmieren kann.

abstract class Box {
  type A
  val value: A
}

scala> new Box { type A = Int; val value = 13 }
res0: Box{type A = Int} = $anon$1@61735602

scala> new Box { type A = String; val value = "hello" }
res1: Box{type A = String} = $anon$1@20dbd794

Zur Deklaration eines abstrakten Typs wird das type-Schlüsselwort verwendet, das wir auch zum späteren Binden eines konkreten Typs benötigen.

Genau wie die parametrisierten Typen können auch die abstrakten Typen mit Beschränkungen versehen werden:

type A <: B
type A >: B
type A >: B <: C

Die semantische Bedeutung der Symbole habe ich weiter oben schon erklärt und kann dort nachgeschlagen werden falls ihr sie noch nicht verinnerlicht haben solltet.

Kleine Bemerkung am Rande: ein gebundener abstrakter Typ ist immer ein Untertyp des jeweiligen deklarierten Typs. Es gilt also:

C { type A = SomeType } <: C

Abstrakte Typen unterscheiden sich auf den ersten Blick nicht sonderlich von parametrisierten Typen. Tatsächlich wird man in den meisten Fällen die parametrisierten Typen auch bevorzugen, da sie nicht so langatmig zu notieren sind und sie dank Typinferenz oft auch gar nicht erst notiert werden müssen. Der große Vorteil von abstrakten Typen ist, dass sie vererbt werden können und somit typisierte Ableitungen erlauben.

trait Suite {
  type A
}
trait StringBuilderSuite extends Suite {
  type A = StringBuilder
}
class DefSuite extends Suite with StringBuilderSuite

Dieser Code bietet zuerst einmal etwas Neues: Traits. Was sie genau machen und wie sie sich von Klassen unterscheiden möchte ich euch im nächsten Kapitel beschreiben. Für den Rest dieses Kapitels reicht es zu wissen, dass man mit ihnen (und mit dem with-Schlüsselwort) eine Art Mehrfachvererbung erzeugen kann und dass sie abstrakte Typen sind und deshalb nicht so ohne weiteres instanziiert werden können:

scala> new Suite
<console>:9: error: trait Suite is abstract; cannot be instantiated
              new Suite
              ^

In obigem Code haben wir uns die Vererbung der abstrakten Typen zu Nutze gemacht und uns so ein wenig Schreibarbeit erspart. Wenn wir parametrisierte Typen benutzen fällt der Unterschied sofort auf:

trait Suite[A]
trait StringBuilderSuite extends Suite[StringBuilder]
class DefSuite extends Suite[StringBuilder] with StringBuilderSuite

Die Klasse DefSuite muss nun zwangsläufig noch einmal parametrisiert werden, obwohl die Parametrisierung schon im Trait StringBuilderSuite festgelegt wurde. Ohne die erneute Parametrisierung würde der Code nicht kompilieren:

scala> class DefSuite extends Suite with StringBuilderSuite
<console>:9: error: trait Suite takes type parameters
       class DefSuite extends Suite with StringBuilderSuite
                              ^

Abstrakte Typen bieten keine großen Vorteile gegenüber den Parametrisierten, runden Scalas Typsystem aber noch ein wenig ab – einen Anwendungsfall für sie kann man immer mal wieder finden.

Scalas generische Typen bieten noch ein paar Feinheiten, die ich hier noch nicht genannt habe und auch nicht mehr nennen möchte. Manches werde ich – wenn es angebracht ist – im weiteren Verlauf des Tutorials irgendwo einpflegen, manch anderem – wie z.B. Varianzen – gebe ich noch ein eigenes Kapitel, damit sie in der Menge von Scalas Möglichkeiten nicht untergehen.