//

Das Scala-Typsystem: Parametrisierte Typen und Varianzen, Teil 1

6.3.2015 | 5 Minuten Lesezeit

Scala wurde 2004 veröffentlicht und wird an der EPFL und von Typesafe weiterentwickelt. Das passiert einerseits gefördert durch Forschungsgelder der europäischen Union und andererseits unterstützt durch industrielle Investoren . Scala hat in den letzten Jahren deutlich an Bedeutung gewonnen, und wird mehr und mehr produktiv eingesetzt – auch bei codecentric AG. Diese Blog-Serie befasst sich mit einem Aspekt des Scala Typsystems, nämlich mit Ko- und Kontravarianten Typ-Parametern (d.h. diese ominösen Plus- und Minuszeichen in der Kopfzeile einer Klasse, z.B. in class Box[+A]).

Einleitung

Dieser Artikel ist keine Einführung in Scala, denn diese findet man im web schon reichlich. Auch CheatSheets und Kurzreferenzen gibt es einige. Stattdessen werde ich beleuchten, wie Ko- und Kontravariante Typ-Parameter in Scala funktionieren, und warum die Regeln, nach denen sie funktionieren, sinnvoll sind. Mit Ko- und Kontravarianz wird allgemein beschrieben, wie ein Aspekt mit der Vererbungshierarchie variiert: falls er mit der Vererbungsrichtung variiert, ist der Aspekt kovariant. Falls der Aspekt der Vererbungsrichtung entegengesetzt variiert, ist der Aspekt kontravariant (der Kategorien-theoretische Ursprung für diese Begriffe ist übrigens in diesem Atlassian-Blogeintrag sehr gut beschrieben). Spannend an dem Thema ist für mich, dass sich die Regeln, nach der Ko- und Kontravariante Typ-Parameter in Scala funktionieren, aus dem Prinzip des Subtyp-Polimorphismus ableiten. Das heißt, dass das recht ungewohnte Konzept von Ko- und Kontravarianz, das bei Typ-Parametern existiert, auf ein vertrautes und allgemein akzeptiertes Prinzip zurückführbar ist.

Um bis dorthin zu kommen, werde ich in diesem Post die Grundlagen schaffen, und Kovariante und Kontravariante Typ-Parameter einführen. Wie das Typ-Checking der Typ-Parameter funktioniert, und wie sich diese Checks aus dem Subtyp-Polimorphismus heraus motivieren, werde ich erst im nächsten Post beleuchten. Im dritten Teil werde ich dann diskutieren, unter welchen Bedingungen es mir sinnvoll erscheint, Klassen mit Typ-Parametern und Typ-Varianz auszustatten.

TL;DR

Keine Zeit? Die Ergebnisse sind am Ende des Artikels zusammengefasst.

Parametrisierte Typen

Betrachten wir folgende Klassenhierarchie von Kisten und Früchten.

1abstract class Fruit { def name: String }
2 
3class Orange extends Fruit { def name = "Orange" }
4 
5class Apple extends Fruit { def name = "Apple" }
6 
7abstract class Box {
8 
9  def fruit: Fruit
10 
11  def contains(aFruit: Fruit) = fruit.name.equals(aFruit.name)
12}
13 
14class OrangeBox(orange: Orange) extends Box {
15  def fruit: Orange = orange
16}
17 
18class AppleBox(apple: Apple) extends Box {
19  def fruit: Apple = apple
20}
21

Die Definition der zwei Klassen OrangeBox und AppleBox bietet zusätzliche Typsicherheit, denn der Rückgabetyp der Methode fruit ist in diesen Klassen zu jeweils Orange und Apple konkretisiert. Bei dieser Hierarchie stellt gleichzeitig recht schnell die Frage, ob die Klassen überhaupt diesen Mehrwert an Typsicherheit wert sind.

Um solche Konflikte zu vermeiden können in Scala wie in Java Typen parametrisiert werden. Das bedeutet, dass anstelle eines konkreten Typs ein Typ-Parameter angegeben werden kann. Solche Typ-Parameter müssen in der Definition einer Klasse deklariert werden, und sind bei Verwendung der Klasse an einen konkreten Typ zu binden. Dies ist analog zur Verwendung von Methoden-Parametern anstelle von konkreten Werten in Methodenrümpfen: während ein Methoden-Parameter ein Name ist, der beim Aufruf der zugehörigen Methode an ein Wert gebunden ist, ist ein Typ-Parameter ein Name, der bei Verwendung der Klasse an einen konkreten Typ gebunden ist. Konkrete Typen können dabei von Typ-Parameter zu Typ-Parameter weitergereicht werden, genau wie Werte von Variable zu Variable weitergereicht werden können.

Ersetzen wir den Konkreten Rückgabetyp Fruit von Box.fruit durch einen Typ-Parameter F, den wir noch zusätzlich einschränken auf eine Subklasse von Fruit oder Fruit selbst (durch F <: Fruit), dann sieht die Klasse Box wie folgt aus.

1class Box[F <: Fruit](aFruit: F) {
2 
3  def fruit: F = aFruit
4 
5  def contains(aFruit: Fruit) = fruit.name == aFruit.name
6}
7 
8var appleBox = new Box[Apple](new Apple)
9 
10var orangeBox = new Box[Orange](new Orange)
11

Durch die Parametrisierung von Box wurden implizit mindestens zwei neue Typen definiert, die wir im Code-Beispiel verwendet haben: Box[Orange] und Box[Apple]. In welchem Verhältnis diese Typen zueinander stehen, muss durch Varianz-Annotationen festgelegt werden.

Varianz-Annotationen

Die beiden Klassen Box[Fruit] und Box[Apple] stehen im Beispiel-Code oben zunächst in keinem Zusammenhang zueinander – das ist die Grundannahme, die der Scala-Compiler trifft, falls keine Varianz-Annotation vorhanden ist. Das heißt, dass die Zuweisung einer Instanz vom Typ Box[Apple] an eine Box[Fruit]-getypte Variable zunächst unzulässig sind:

1// Unzulässig: Box[Apple] ist kein Subtyp von Box[Fruit]. 
2var box: Box[Fruit] = new Box[Apple](new Apple)
3

Varianz-Annotationen werden in Scala durch den Präfix + (für Kovarianz) oder - (für Kontravarianz) bei der Definition eines Typ-Parameters angefügt. Die Kopfzeile der Klasse Box lässt sich also verändern, um die Zuweisung oben zu ermöglichen:

1abstract class Box[+F <: Fruit] {
2

Die Zuweisung von Box[Apple] zu einer Variablen vom Typ Box[Fruit] wird jetzt möglich, da Box[Apple] durch die Kovarianz-Annotation +F ein Subtyp von Box[Fruit] geworden ist.

Parametrisierte Typen sind invariant, falls keine Varianz-Annotation angegeben ist. Durch eine Varianz-Annotation lässt sich eine Subtyp-Beziehung zwischen parametrisierten Typen definieren, die sich aus der Vererbungshierarchie der eingesetzten Typen ableitet. Folgendes Klassendiagramm stellt die Vererbungsbeziehungen zwischen Box[Fruit] und Box[Apple] bei Invarianz, Kovarianz und Kontravarianz grafisch dar.

Bei Kovarianz wird die Vererbungshierarchie der eingesetzten Typen übernommen, und bei Kontravarianz wird sie umgedreht. Bei Invarianz wird die Vererbungshierarchie ignoriert.

Welcher Zusammenhang zwischen den Instanzen des parametrisierten Typs sinnvoll ist, und ob ein Zusammenhang überhaupt sinnvoll ist, muss der Entwickler bei der Definition des Typs festlegen. Dabei muss er berücksichtigen, dass nicht immer alle Varianz-Annotationen erlaubt sind. Wie das Checken der Varianz-Annotationen funktioniert, werde ich im nächsten Post beleuchten.

Zusammenfassung

Kurz zusammengefasst sind Ko- und Kontravariante Typ-Parameter ein Werkzeug, um die Reichweite des Typ-Checkers bei der Entwicklung und Nutzung generischer Klassen zu erweitern. Sie bieten dadurch zusätzliche Typsicherheit, was gleichzeitig bedeutet, dass dieses Konzept dem Entwickler mehr Möglichkeiten im Umgang mit Typen und Typhierarchien gibt, ohne dass er die Typsicherheit dadurch aufgeben muss. Während in anderen Programmiersprachen auf Kommentare und Konventionen zurückgegriffen werden muss, weil das Typsystem nicht in der Lage ist, die Typsicherheit eines Programms zu garantieren, kommt man mit dem Scala-Typsystem sehr weit. Im nächsten Beitrag werde ich beleuchten, wie Ko- und Kontravariante Typ-Parameter vom Typ-Checker geprüft werden, und wie sich die Regeln aus dem Prinzip des Subtyp-Polimorphismus ableiten.

Die Ergebnisse in Kurzform

Angenommen, class Orange extends Fruit gilt. Falls class Box[A] deklariert ist, kann A mit Präfix + oder - annotiert werden.

  • A ohne Annotation ist invariant, d.h.:
    • Box[Orange] steht in keinem Zusammenhang zu Box[Fruit].
  • +A ist kovariant, d.h.:
    • Box[Orange] ist eine Subklasse von Box[Fruit].
    • var f: Box[Fruit] = new Box[Orange]() ist erlaubt.
  • -A ist kontravariant, d.h.:
    • Box[Fruit] ist eine Subklasse von Box[Orange].
    • var f: Box[Orange] = new Box[Fruit]() ist erlaubt.

Beitrag teilen

Gefällt mir

0

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen

Wir helfen Deinem Unternehmen

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.