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

Keine Kommentare

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.

abstract class Fruit { def name: String }
 
class Orange extends Fruit { def name = "Orange" }
 
class Apple extends Fruit { def name = "Apple" }
 
abstract class Box {
 
  def fruit: Fruit
 
  def contains(aFruit: Fruit) = fruit.name.equals(aFruit.name)
}
 
class OrangeBox(orange: Orange) extends Box {
  def fruit: Orange = orange
}
 
class AppleBox(apple: Apple) extends Box {
  def fruit: Apple = apple
}

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.

class Box[F <: Fruit](aFruit: F) {
 
  def fruit: F = aFruit
 
  def contains(aFruit: Fruit) = fruit.name == aFruit.name
}
 
var appleBox = new Box[Apple](new Apple)
 
var orangeBox = new Box[Orange](new Orange)

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:

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

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:

abstract class Box[+F <: Fruit] {

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.

Transfer of inheritance via variances

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.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.