Andreas Solymosi

Kovarianz und Kontravarianz in Java

Abstract

In diesem Artikel werden die Java-Regeln für Kovarianz und Kontravarianz zusammengefasst. Hierzu wird Typkompatibilität definiert und die Fälle untersucht, wo abhängige Typen kompatibel sind oder nicht. Auch die Kompatibilität von Methoden wird erörtert.

Definition

Kovarianz und Kontravarianz ist die von Typen abhängige Kompatibilität. Ein von T abhängiges Element A(T) ist dann kovariant („mit-verschieden“), wenn aus der Kompatibilität von T1 zu T2 die Kompatibilität von A(T1) zu A(T2) folgt. Wenn aus der Kompatibilität von T1 zu T2 die Kompatibilität von A(T2) zu A(T1) folgt, dann ist der Typ A(T) kontravariant („gegen-verschieden“). Wenn aus der Kompatibilität von T1 zu T2 keine Kompatibilität zwischen A(T1) und A(T2) folgt, dann ist A(T) invariant.

Kovarianz und Kontravarianz

Kovarianz und Kontravarianz

In Java gibt es zwei Sprachelemente, die von Typen abhängig sind: Methoden und (abhängige) Typen. Es gibt zweierlei abhängige Typen: Reihungstypen (arrays) und generische (parametrisierte) Typen. Methoden sind von den Typen ihrer Parameter abhängig. Ein Reihungstyp T[] ist vom Typ seiner Elemente T abhängig. Ein generischer Typ G<T> ist von seinem Typparameter T abhängig.

Kompatibilität unterschiedlicher Typen

Der Typ T1 ist zu T2 implizit bzw. explizit kompatibel, wenn die Zuweisung einer Variable vom Typ T1 in eine Variable vom Typ T2 ohne bzw. mit Kennzeichnung möglich ist. Die häufigste Möglichkeit, explizite Kompatibilität zu kennzeichnen ist die Typkonvertierung (casting):

variableVomTypT2 = variableVomTypT1; // implizit kompatibel
variableVomTypT2 = (T2)variableVomTypT1; // explizit kompatibel

Beispielsweise ist int implizit kompatibel zu long und explizit kompatibel zu short:

int i = 5;
long l = i; // implizit kompatibel
short s = (short)i; // explizit kompatibel

Implizite und explizite Kompatibilität besteht nicht nur bei Zuweisungen, sondern auch bei Parameterübergabe. Neben Eingabeparameter gehört dazu auch die Übergabe eines Funktionsergebnisses (als Ausgabeparameter).

boolean ist mit keinem anderen Typ kompatibel; zwischen einem primitiven und einem Referenztyp besteht ebenfalls keine Kompatibilität. Ein Untertyp ist implizit kompatibel zum Obertyp, explizit in die andere Richtung. Referenztypen sind also nur innerhalb eines Hierarchiezweiges (aufwärts implizit und abwärts explizit) untereinander kompatibel:

referenzVomUntertyp = referenzVomObertyp; // implizit kompatibel
referenzVomObertyp = (Obertyp)referenzVomUntertyp; // explizit kompatibel

Der Java-Compiler erlaubt typischerweise implizite Kompatibilität für eine Zuweisung dort, wo zur Laufzeit keine „Verluste“ durch die Unterschiede zwischen den Typen zu befürchten sind[1]. Beispielsweise ist int implizit kompatibel zu long, weil eine long-Variable jeden int-Wert aufnehmen kann. Im Gegensatz dazu kann eine short-Variable nicht jeden int-Wert aufnehmen – deswegen wird hier nur explizite Kompatibilität erlaubt.

Implizite Kompatibilität von arithmetischen Typen in Java

Implizite Kompatibilität von arithmetischen Typen in Java[2]

Ähnlich gelingt die Zuweisung einer Referenz vom Untertyp in eine Referenz vom Obertyp immer. Die Zuweisung in die andere Richtung kann eine Ausnahme ClassCastException auslösen[3].

Kovarianz und Kontravarianz von Reihungen

Die Kovarianz von Reihungstypen bedeutet, dass wenn T zu U kompatibel ist, dann ist T[] zu U[] auch kompatibel; Kontravarianz bedeutet, dass U[] zu T[] kompatibel ist.

Reihungen aus primitiven Typen sind in Java invariant:

longReihung = intReihung; // Typfehler
shortReihung = (short[])intReihung; // Typfehler

Reihungen aus Referenztypen sind jedoch in Java implizit kovariant und explizit kontravariant:

Obertyp[] oberreihung;
Untertyp[] unterreihung;
  
oberreihung = unterreihung; // implizit kovariant
unterreihung = (Untertyp[])oberreihung; // explizit kontravariant

Hierdurch ist jedoch das sog. Kovarianzproblementstanden. Dies bedeutet, dass eine Zuweisung von Reihungskomponenten zur Laufzeit die Ausnahme ArrayStoreException auslösen kann, wenn eine Obertyp-Reihungsreferenz eine Unter­typ-­Reih­ung referiert, ihrer Komponente jedoch ein Obertyp-Objekt zugewiesen werden soll:

oberreihung[1] = new Obertyp(); // throws ArrayStoreException

Kovarianz für Reihungen

Kovarianz für Reihungen

Das eigentliche Problem ist aber gar nicht die Ausnahme (die durch Programmdisziplin vermeidbar ist), sondern dass die Virtuelle Maschine zur Laufzeit jede Zuweisung auf ein Reihungselement in Bezug auf das Kovarianzproblem prüfen muss. Dies ist eine beträchtliche Effizienzverminderung gegenüber Sprachen ohne Kovarianz (wo eine kompatible Zuweisung für Reihungsreferenzen verboten ist) oder wo Kovarianz ausgeschaltet werden kann (wie z.B. in Scala).

Ein einfaches Beispiel ist, wenn die Reihungsreferenz vom Typ Object[], das Reihungsobjekt und die Elemente jedoch von unterschiedlichen Klassen sind:

Object[] reihung; // Reihungsreferenz
reihung = new String[3]; // Reihungsobjekt; kovariante Zuweisung
reihung[0] = new Integer(5); // throws ArrayStoreException

Wegen der Kovarianz kann der Compiler die Korrektheit der Zuweisungen auf die Reihungselemente nicht überprüfen – die JVM tut dies und erbringt dabei einen beträchtlichen Zusatzaufwand. Dieser kann jedoch vom Compiler optimiert werden, wenn von der Typkompatibilität zwischen Reihungstypen kein Gebrauch gemacht wird.

Das Kovarianzproblem für Reihungen

Das Kovarianzproblem für Reihungen

Unspezifische Kovarianz für parametrisierte Typen

Generische (parametrisierte) Typen sind in Java implizit invariant, d.h. unterschiedliche Instanziierungen eines generischen Typs sind untereinander nicht kompatibel. Auch Typkonvertierung (casting) ermöglicht hier keine Kompatibilität:

Generisch<Obertyp> oberGenerisch;
Generisch<Untertyp> unterGenerisch;
unterGenerisch = (Generisch<Untertyp>)oberGenerisch; // Typfehler
oberGenerisch = (Generisch<Obertyp>)unterGenerisch; // Typfehler

Der Typfehler wird gemeldet, obwohl unterGenerisch.getClass() == oberGenerisch.getClass() – die Methode getClass() ermittelt nämlich den rohen (unparametrisierten) Typ. Dies ist der Grund, warum ein aktueller Typparameter nicht zu Signatur einer Methode gehört. Die beiden Methodenvereinbarungen

void methode(Generisch<Obertyp>);
void methode(Generisch<Untertyp>);

dürfen daher nicht in derselben Schnittstellendefinition vorkommen.

Obwohl generische Typen in Java implizit invariant sind, gibt es Variablen, die kovariant benutzt werden können. Sie werden mit Hilfe des Jokers (wildcard) ? vereinbart, der als aktueller Typparameter eingesetzt werden darf. Generisch<?> ist der abstrakter Obertyp aller Instanziierungen des generischen Typs, d.h. zu Generisch<?> sind alle Instanziierungen von Generisch kompatibel:

Generisch<?> jokerReferenz;
jokerReferenz = new Generisch<String>(); // implizit kompatibel
jokerReferenz = new Generisch<Integer>();

Da der Joker-Typ abstrakt ist, kann er nur für Referenzen und nicht für Objekte benutzt werden: new Generisch<?>() ergibt keinen Sinn und wird vom Compiler abgelehnt.

Ein Beispiel für die Verwendung des Jokers ist der Parameter einer Methode, die eine Sammlung (Collection oder Reihung) unabhängig vom Elementtyp manipuliert. Eine solche Methode für Reihungen zu schreiben ist – wegen der Kovarianz – einfach:

static void swap(Object[] reihung, int i, int j) {
  … // vertauscht die Elemente i und j
}

Der Aufruf kann für eine beliebige Reihung infolge der Kompatibilität zu Object erfolgen:

Integer[] reihung = {1, 2, 3};
swap(reihung, 0, 2); // Integer[] ist kompatibel zu Object[]

Die generische Version[4] dieser Methode ist typsicherer[5]:

static <T> void swap(T[] reihung, int i, int j) { … } // ähnlich

Eine ähnliche Lösung für ArrayList funktioniert wegen Inkompatibilität für generische Typen nicht. Der Joker bewirkt jedoch Kovarianz:

static void swap(List<?> liste, int i, int j) { … } // ähnlich

Der Aufruf ist nun mit einem beliebigen Elementtyp möglich:

List<Integer> liste = ;
swap(liste, 0, 2); // List<Integer> ist kompatibel zu List<?>

Eine solche Kompatibilität nennen wir unspezifische Kovarianz, weil hier nicht spezifiziert wird, welcher (Ober-) Typ die Kovarianz ermöglicht.

Solche Kompatibilität kann sogar auf zwei Ebenen gleichzeitig bestehen: auf der Ebene der generischen Typen (ArrayList zu List) und auf der Ebene der Kovarianz (Integer zu ?):

ArrayList<Integer> arrayListe = ; // ArrayList<T> implements List<T>
swap(arrayListe, 0, 2); // ArrayList<Integer> ist kompatibel zu List<?>

Explizite Kovarianz für parametrisierte Typen

Diese „leichte Kovarianz“ kann man verallgemeinern. Implizite Kovarianz würde bestehen, wenn Generisch<Untertyp> zu Generisch<Obertyp> kompatibel wäre. Die Einschränkung des Jokers mit extends bewirkt dasselbe Effekt explizit: Generisch<Untertyp> ist kompatibel zu Generisch<? extends Obertyp>. Von diesem eingeschränkten Joker-Typ kann nun eine Referenz vereinbart werden:

Generisch<? extends Obertyp> kovarianteReferenz;

In diese Referenz können beliebige Instanzen von Generisch eingehängt werden, deren aktueller Typparameter ein Untertyp von Obertyp ist:

kovarianteReferenz = new Generisch<Obertyp>();
kovarianteReferenz = new Generisch<Untertyp>();

Die Einschränkung des Jokers ist dann sinnvoll, wenn nicht beliebige aktuelle Typparameter zugelassen werden sollen, weil von ihnen bestimmte Eigenschaften erwartet werden – beispielsweise, wenn die Elemente der Parametersammlung manipuliert werden sollen:

static void increment(Number[] reihung) { … } // für jedes Element + 1
static void increment(Collection<? extends Number> sammlung) { … } // ähnlich

Der Aufruf der Methoden ist infolge der Kovarianz mit einem beliebigen Number-Parameter möglich:

increment(reihung); // Integer[] ist kompatibel zu Number[]
increment(liste); 
  // ArrayList<Integer> ist kompatibel zu Collection<? extends Number>

In der letzten Programmzeile wird Kompatibilität wieder gleichzeitig auf zwei Ebenen genutzt: Der parametrisierte ArrayList<T> ist ein Untertyp von Collection<T> und Integer ist ein Untertyp von Number.

Der eingeschränkte Joker ermöglicht also explizite Kovarianz unter parametrisierten Typen.

Kovarianter Typparameter als Parametertyp

Diese Kovarianz wirkt über die Typparameter, nicht aber über die Parametertypen der Methoden in der generischen Klasse. Angenommen, in der Klasse Generisch benutzen wir den Typparameter T als Typ der (Ein- und/oder Ausgabe-) Parameter von Methoden:

class Generisch<T> {
  private T t;
  void schreiben(T t) { this.t = t; } // T ist Eingabeparametertyp
  T lesen() { return t; } } // T ist Ausgabeparametertyp

Dann kann die Methode schreiben mit jokerReferenz nicht direkt (nur nach Typkonvertierung) aufgerufen werden:

jokerReferenz.schreiben(new Object()); // Typfehler
((Generisch<Object>)jokerReferenz).schreiben(new Object()); // OK

Der Grund ist, dass zum Joker kein Typ (auch nicht Object) kompatibel ist - ? ist eigentlich gar kein Typ. Der Joker selbst ist jedoch zu Object (und zu keinem anderen Typ) kompatibel, so kann das Typparameter-Ergebnis einer Funktion in eine Object-Referenz übernommen werden:

Object o = jokerReferenz.lesen();

Für eingeschränkte Joker-Typen gelten dieselben Regeln: Eingabeparameter können nicht (nur nach casting) übergeben werden, Ausgabeparameter sind vom Typ der Schranke:

// Eingabeparameter:
kovarianteReferenz.schreiben(new Obertyp()); // Typfehler
kovarianteReferenz.schreiben(new Untertyp()); // Typfehler
((Generisch<Obertyp>)kovarianteReferenz).schreiben(new Obertyp()); // OK
((Generisch<Obertyp>)kovarianteReferenz).schreiben(new Untertyp()); // OK 
((Generisch<Untertyp>)kovarianteReferenz).schreiben(new Untertyp()); // auch OK
// Ausgabeparameter:
Object objekt = kovarianteReferenz.lesen(); // OK
Obertyp ober = kovarianteReferenz.lesen(); // OK
Untertyp unter1 = kovarianteReferenz.lesen(); // Typfehler
Untertyp unter2 = ((Generisch<Untertyp>)kovarianteReferenz).lesen(); // OK
Untertyp unter3 = (Untertyp)kovarianteReferenz.lesen(); // typunsicherer

Die Konvertierungen in den letzten beiden Zeilen können natürlich (wie immer) zur Laufzeit ClassCastException auslösen, daher sind typunsicher. Ob dies geschieht, hängt vom Typ des Objekts beim letzten schreiben(): wenn es (wie in der obigen Sequenz) new Untertyp() war, dann  wird keine Ausnahme ausgelöst. Der Unterschied zwischen den beiden letzten Zeilen ist, dass in der vorletzten die Referenz konvertiert wird, für die lesen() aufgerufen wird (von Generisch<? extends Obertyp> nach Generisch<Untertyp>), während in der darauffolgenden das Ergebnis von lesen() (von ? nach Untertyp).

Man kann das oben Gesagte so interpretieren, dass der uneingeschränkte Joker-Typ durch Object eingeschränkt ist: Generisch<?> ist gleichwertig mit Generisch<? extends Object>. So gesehen ist die unspezifische Kovarianz eine explizite Kovarianz über die Kompatibilität zu Object.

Kontravarianz für parametrisierte Typen

Kontravarianz bedeutet die Kompatibilität in die andere Richtung, nämlich abwärts. Reihungen sind explizit kontravariant; syntaktisch wird dies durch Typkonvertierung (casting) ausgedrückt:

unterreihung = (Untertyp[])oberreihung; // explizit kompatibel (kontravariant)

Zwischen unterschiedlichen Instanziierungen eines generischen Typs wird die Typkonvertierung vom Compiler abgelehnt. Aber auch generische Typen sind explizit kontravariant. Syntaktisch wird dies ausgedrückt, indem man den Joker von unten mit Hilfe von super einschränkt:

Generisch<? super Untertyp> kontravarianteReferenz;

In diese Variable können nun Instanziierungen von Generisch mit einem beliebigen Obertyp (z.B. Object) von Untertyp gehängt werden:

kontravarianteReferenz = new Generisch<Untertyp>(); // normal
kontravarianteReferenz = new Generisch<Obertyp>(); // kontravariant
kontravarianteReferenz = new Generisch<Object>(); // auch möglich

Hier findet also die Zuweisung von der Obertyp-Instanziierung (bzw. von noch weiter oben, von der Object-Instanziierung) nach unten, nämlich zur Untertyp-Instanziierung kontravarianteReferenz statt – dies heißt Kontravarianz.

Ein Beispiel für die Anwendung der Kontravarianz ist die Methode java.util.Collections.sort mit Comparator. Ihre Signatur ist

public static <T> void sort(List<T> list, Comparator<? super T> c)

Diese Methode sortiert eine beliebige Liste; die fürs Sortieren notwenige Element-Vergleichsmethode wird hier nicht im Elementtyp (etwa mit List<T extends Comparable<T>>, wie in der überladenen Version von sort) vereinbart, sondern in einem extra Comparator-Objekt. Der Vorteil hiervon ist, dass die Objekte nach verschiedenen Kriterien (z.B. einmal nach Namen, einmal nach Kundennummern) sortiert werden können: Das Sortierkriterium wird hier nicht in der Klasse der Elemente fest mit compareTo() einprogrammiert, sondern ist durch verschiedene Comparator-Implementierungen flexibel. In den meisten Fällen würde hier Comparator<T> reichen, dessen Methode int compare(T o1, T o2) zwei beliebige Elemente im List<T>-Objekt miteinander vergleichen kann:

class DateComparator implements Comparator<java.util.Date> { 
  public int compare(Date d1, Date d2) { return … } 
     // vergleicht die zwei Date-Objekte
}
List<java.util.Date> liste = … ; // Liste aus Date-Objekten
sort(liste, new DateComparator()); // sortiert die Liste

Die Methode Collection.sort() deckt aber auch zusätzliche Fälle ab. Wegen des kontravarianten Typparameters von Comparator kann mit ihr auch eine Liste vom Typ List<java.sql.Date> sortiert werden, zumal java.util.Date ein Obertyp von java.sql.Date ist:

List<java.sql.Date> sqlListe = … ;
sort(sqlListe, new DateComparator()); 

Ohne Kontravarianz (d.h. ohne <? super T> , nur mit <T> oder dem unspezifischen, typunsicheren <?> in der sort-Signatur) würde die letzte Zeile vom Compiler als Typfehler abgelehnt werden – man müsste dafür extra eine nichtssagende Klasse

class SqlDateComparator extends DateComparator 
  implements Comparator<java.sql.Date> {}

anfertigen, um

sort(sqlListe, new SqlDateComparator()); 

aufrufen zu können.

Nicht nur Collections.sort wurde mit einem kontravarianten Parameter versehen: Viele andere Methoden in Collections, wie addAll, binarySearch, copy, fill usw. können ähnlich flexibel aufgerufen werden. Andere Methoden wie max und min haben kontravariante Ergebnistypen:

public static <T extends Object & Comparable<? super T>> T max(
  Collection<? extends T> coll)

Hier ist es ersichtlich, wie einem Typparameter mehrere Bedingungen mit Hilfe von & auferlegt werden können. Das überflüssig wirkende extends Object bewirkt hier, dass max im Bytecode (wo es keine Typparameter mehr gibt) ein Ergebnis vom Typ Object und nicht Comparable zurückgibt.

Ihre überladene Comparator-Versionen

public static <T> T max(
  Collection<? extends T> coll,
  Comparator<? super T> comp)

haben je einen kovarianten und einen kontravarianten Parameter: Während die Elemente der Collection Untertypen eines (nicht explizit angegebenen) Typs sein müssen, muss Comparator für einen Obertyp desselben instanziiert worden sein. Eine hohe Intelligenz wird vom Inferenz-Algorithmus[6] des Compilers verlangt, um diesen Zwischentyp aus einem Aufruf wie

Collection<EinTyp> sammlung = … ;
Comparator<EinAndererTyp> vergleicher = … ;
max(sammlung, vergleicher);

ermitteln zu können.

Noch interessanter ist die Signatur der Methode java.util.Collections.sort mit Comparable; sie verwendet auch sowohl extends wie auch super, aber diesmal verschachtelt:

public static <T extends Comparable<? super T>> void sort(List<T> list)

Hier sprechen wir jedoch nicht von Ko- und Kontravarianz, weil es hier nicht um Kompatibilität von Referenzen geht, sondern um die Einschränkung der Instanziierung. Diese Methode sortiert also ein List-Objekt, dessen Elemente von einer Klasse sind, die Comparable implementiert. Diese generische Schnittstelle enthält die einzige Objektmethode

int compareTo(T o)

die ihr Zielobjekt (vom Typ des Typparameters T) mit dem Parameterobjekt (ebenfalls vom Typ T) vergleicht. Ohne <? super T> (also nur mit <T>) in der sort-Signatur würde das Sortieren in den meisten Fällen funktionieren:

sort(liste); // java.util.Date implements Comparable<java.util.Date>
sort(sqlListe); // java.sql.Date implements Comparable<java.sql.Date>

Die Einschränkung des Typparameters von unten erlaubt aber zusätzliche Flexibilität: Comparable muss nicht unbedingt in der Elementklasse implementiert werden; es reicht, wenn sie für eine Oberklasse implementiert ist. Beispielsweise

  class Ober implements Comparable<Ober> {
     public int compareTo(Ober ober) { … } }
  class Unter extends Ober {} // ohne Überschreiben von compareTo()
  List<Ober> oberliste = ;
  sort(oberliste);
  List<Unter> unterliste = ;
  sort(unterliste);

Die letzte Zeile wird vom Compiler mit

static <T extends Comparable<? super T>> void sort(List<T> list) { … }

akzeptiert und mit

static <T extends Comparable<T>> void sort(List<T> list) { … }

abgelehnt. Der Grund für die Ablehnung ist, dass der Typ Unter (den der Compiler aus dem Typ List<Unter> des aktuellen Parameters unterliste ermittelt[7]) nicht als aktueller Typparameter für T extends Comparable<T> geeignet ist: Unter implementiert Comparable<Unter> nämlich nicht, nur Comparable<Ober>; die beiden sind aber (mangels impliziter Kovarianz) nicht kompatibel, auch wenn Unter kompatibel zu Ober ist. Im anderen Fall (mit <? super T>) wird jedoch nicht erwartet, dass Comparable<Ober> von Unter implementiert wird; es reicht, wenn Ober das tut. Es reicht, weil die Methode compareTo auch für Unter-Objekte aufgerufen werden kann: sie wird von Ober geerbt. Dies wird mit <? super T> ausgedrückt, was also Kontravarianz bewirkt.

Die letzte Programmzeile könnte nur dann akzeptiert werden, wenn (kompliziertererweise)

class Unter extends Ober {} implements Comparable<Unter> { … }

vereinbart worden wäre.

Kontravarianter Typparameter als Parametertyp

Allerdings, die obere oder untere Schranke bezieht sich nur auf den Typparameter der Instanziierungen, die in eine ko- oder kovariante Referenz eingehängt werden. Im Falle von Generisch<? extends Obertyp> kovarianteReferenz; und Generisch<? super Untertyp> kontravarianteReferenz; können also aus verschiedenen Generisch-Instanziierungen Objekte gebildet und eingehängt werden.

Auf den Parameter- und Ergebnistyp der Methoden (also für Eingabe- und Ausgabeparametertypen) aus einem generischen Typ gelten andere Regeln. Beispielsweise kann als Parameter der Methode schreiben ein beliebiges Objekt übergeben werden, das zum Untertyp kompatibel ist:

kontravarianteReferenz.schreiben(new Untertyp()); // OK
kontravarianteReferenz.schreiben(new UnterUntertyp()); // auch OK
kontravarianteReferenz.schreiben(new Obertyp()); // Typfehler
((Generisch<? super Obertyp>)kontravarianteReferenz).schreiben(new Obertyp()); // OK

Durch die Kontravarianz wird also die Parameterübergabe an schreiben()möglich – im Gegensatz zum kovarianten (auch uneingeschränkten) Joker-Typ.

Beim Ergebnistyp verändert sich die Situation durch Einschränkung nicht: lesen() liefert nach wie vor ein Ergebnis vom Typ ?, das nur zu Object kompatibel ist:

Object o = kontravarianteReferenz.lesen();
Untertyp ut = kontravarianteReferenz.lesen();// Typfehler

Die letzte Zeile ist fehlerhaft, obwohl wir kontravarianteReferenz vom Typ Generisch<? super Untertyp> vereinbart haben. Der Typfehler kann durch explizite Typkonvertierung (casting) eliminiert werden, die keine Ausnahme auslösen wird:

Untertyp ut = (Untertyp)kontravarianteReferenz.lesen();// OK

Ob eine explizite Typkonvertierung zu einem anderen Typ zur Laufzeit erfolg haben wird, hängt vom Typ des Parameterobjekts beim letzten schreiben() ab):

OberObertyp oo = ((Generisch<OberObertyp>)kontravarianteReferenz).lesen();
oo = (OberObertyp)kontravarianteReferenz.lesen(); 
  // typunsicherere Alternative

Erzeugung von Objekten

Einerseits können von Joker-Typen (weil sie abstrakt sind) keine Objekte gebildet werden; andererseits können Reihungsobjekte nur von uneingeschränkten Joker-Typen (wie von allen abstrakten Typen), jedoch von keinen anderen generischen Instanziierungen gebildet werden:

Generisch<Object>[] generischeReihung; // OK
generischeReihung = new Generisch<Object>[20]; // Fehler
Generisch<?>[] jokerReihung = new Generisch<?>[20]; // OK
generischeReihung = (Generisch<Object>[])jokerReihung;
generischeReihung[0] = new Generisch<Object>();
generischeReihung[0] = new Generisch<String>(); // Typfehler
jokerReihung[0] = new Generisch<String>(); // OK

Wegen der Kovarianz für Reihungen ist hier der Joker-Reihungstyp der Obertyp der Reihungstypen aller Instanziierungen, deswegen ist die abwärtskompatible Zuweisung möglich.

Vom Typparameter innerhalb einer generischen Klasse können auch keine Objekte erzeugt werden. Beispielsweise im Konstruktor einer eigenen ArrayList-Implementierung muss das Reihungsobjekt vom Typ Object[] erzeugt und zum Typparameter-Reihungstyp konvertiert werden:

class MyArrayList<E> implements List<E> {
  private E[] inhalt;
  public MyArrayList(int größe) {
     inhalt = new E[größe]; // Fehler
     inhalt = (E[])new Object[größe]; // workaround
  }
  
}

Etwas typsicherer ist die Verwendung von Reflexion und der Fabrikmethode ("factory method") Array.newInstance(). Hierzu muss man jedoch im Konstruktor den aktuellen Typparameter als Class übergeben:

  public MyArrayList(int größe, Class typ) {
     inhalt = (E[])Array.newInstance(typ, größe);
  }

Mehrere Typparameter

Ein generischer Typ kann mehrere Typparameter haben. Sie verändern das Verhalten bzgl. Ko- und Kontravarianz nicht; diese können auch zusammen vorkommen:

class G<T1, T2> {}
G<? extends Obertyp, ? super Untertyp> referenz;
referenz = new G<Obertyp, Untertyp>(); // ohne Varianz
referenz = new G<Untertyp, Obertyp>(); // Ko- und Kontravarianz

Ein häufig verwendetes Beispiel für mehrere Typparameter ist die generische Schnittstelle java.util.Map mit zwei Typparametern für Schlüssel K (key) und Werte V (value). Ihre Implementierung HashMap hat einen Konstruktor, der ein beliebiges Map-Objekt in eine Assoziationstabelle umwandelt:

public HashMap(Map<? extends K,? extends V> t)

Die Typparameter des Parameterobjekts t müssen dabei auch nicht dem genauen Typparameter der Klasse K und V entsprechen, sondern können kovariant angepasst werden:

Map<Kundennummer, Kunde> kunden;
 
kontakte = new HashMap<Id, Person>(kunden); // kovariant

wobei Id ein Obertyp von Kundennummer und Person ein Obertyp von Kunde ist.

Abhängigkeit von Methoden

Eine Methode ist vom Typ seiner Parameter abhängig. Neben den Eingabeparametern zählen wir auch ihren Ergebnistyp (return type) als Ausgabeparameter hinzu. Die Signatur einer Methode bestimmen jedoch nur die Eingabeparametertypen (und das ohne ihre Typparameter).

Methoden mit unterschiedlichen Namen oder mit einer unterschiedlichen Anzahl von Parametern sind zueinander nicht kompatibel. Die Frage nach Kompatibilität stellt sich also nur bei Methoden mit demselben Namen und gleicher Anzahl von Parametern[8].

Hierbei kann ein Methodenaufruf (im Rumpf einer Klasse) zu Methodendefinitionen (in Klassen) und zu Methodenvereinbarungen (in abstrakten Klassen und Schnittstellen) kompatibel oder inkompatibel sein. Bei einer Methodendefinition wird die Frage nach Kompatibilität zu anderen Definitionen oder Vereinbarungen, bei einer Methodenvereinbarungen zu anderen Vereinbarungen gestellt, um zu entscheiden, ob es sich hier um Überschreiben oder Überladen handelt.

Abhängigkeiten von Methoden bezüglich Signatur

Abhängigkeiten von Methoden bezüglich Signatur

Bei der Kompatibilität zwischen Vereinbarungen und Definitionen gibt es in Java keine Varianz in Bezug auf die Signatur: Wenn eine Methode eine andere überschreibt, müssen die Signaturen gleich sein. Es gilt jedoch Kovarianz in Bezug auf den Ergebnistyp:

interface Ober {
  void prozedur(Obertyp parameter);
  Obertyp funktion();
 }
interface Unter extends Ober {
  void prozedur(Untertyp parameter); // überladen
  @Override
  Untertyp funktion(); // überschrieben
 }

Nach den strengen Java-Regeln für Signaturen wird hier prozedur überladen, nicht überschrieben: Eine Annotation @Override für prozedur würde eine Fehlermeldung des Compilers auslösen, weil der Parametertyp von prozedur in den beiden Schnittstellen nicht gleich ist. In anderen Sprachen (wie Ada oder Scala) wird Überschreiben auch mit ko- oder kontravarianten Methodenparametern ermöglicht.

Im Gegensatz dazu gehört der Ergebnistyp einer Funktion nicht zu Signatur; somit kann er beim Überschreiben (wie oben) erweitert (aber nicht anders verändert) werden: Methodenvereinbarungen und -definitionen bzgl. Ergebnistypen sind in Java kovariant.

Ob nun ein Aufruf zu einer Vereinbarung oder Definition kompatibel ist, wird aufgrund der Signatur entschieden. Hier gilt Kovarianz: Aus der Typkompatibilität der Parameter nach oben folgt die Kompatibilität des Aufrufs. Im Bezug auf das Zielobjekt eines Aufrufs sprechen wir jedoch nicht von Varianz sondern von Polymorphie:

oberReferenz.prozedur(untertypParameter); 
  // Aufruf ist kovariant bezüglich Signatur
unterReferenz.prozedur(obertypParameter); // polymorph

Bezüglich Funktionsergebnisse beim Aufruf sprechen wir nicht von Varianz sondern einfach von Aufwärtskompatibilität:

Obertyp ergebnis = unterReferenz.funktion(); // aufwärtskompatibel
Untertyp ergebnis = oberReferenz.funktion(); // Typfehler: nicht abwärtskompatibel

Manchmal wird diese Aufwärtskompatibilität jedoch als Kovarianz bezeichnet.

Der Zugriffschutz und die Ausnahmespezifikation gehören – wie das Funktionsergebnis – nicht zur Signatur. Diese können beim Überschreiben erweitert werden. Auch final kann hinzugefügt werden, das weiteres Überschreiben unterbindet:

class Oberklasse {
  void methode() throws OberException { … }
}
class Unterklasse extends Oberklasse {
  @Override
  public final void methode() throws UnterException { … }
}

Zusammenfassung

Kovarianz und Kontravarianz ist die Kompatibilität typabhängiger Sprachelemente. In Java sind Reihungstypen (arrays) implizit kovariant und explizit (durch Typkonvertierung, casting) kontravariant. Parametrisierte (generische) Typen sind implizit invariant. Auch durch Typkonvertierung wird keine Ko- und Kontravarianz ermöglicht. Ko- und Kontravarianz kann für parametrisierte Typen jedoch durch Einschränkung des Jokers vereinbart werden: Kovarianz durch Einschränkung von oben (<? extends Schranke>), Kontravarianz durch Einschränkung von unten (<? super Schranke>). Joker-Typen (Instanziierungen eines parametrisierten Typs mit dem Joker) sind abstrakt, daher können mit ihnen nur Referenzen vereinbart werden.

Methodenaufrufe zu Definitionen und Vereinbarungen sind kovariant bezüglich Signatur. Methodendefinitionen und -vereinbarungen untereinander sind in Java invariant.


[1] Diese Regel gilt nicht für Genauigkeitsverluste, beispielsweise bei einer Zuweisung von int nach float.

[2] Mit rotem Pfeil wurde der potentielle Genauigkeitsverlust dargestellt.

[3] s. Typkompatibilität in Java unter http://public.beuth-hochschule.de/~solymosi/veroeff/typkompatibilitaet/Typkompatibilitaet.html

[4] Die beiden können aber nicht in derselben Klasse vereinbart werden, weil sie keine unterscheidbare Signatur haben.

[5] Weil die Inferenz beim Aufruf erfolgt nicht duch Kompatibilität sondern durch generische Instanziierung: Der Compiler identifiziert die aufzurufende Methode nicht aufgrund implizierter Konvertierung der Parametertypen, sondern durch Einsetzen der Typparameter.

[6] leitet geeignete unbekannte Typen aus bekannten Typen ab

[7] durch Inferenz

[8] wobei der letzte Parameter auch eine variable Anzahl ermöglicht.


Version: 26. April 2014

© Prof. Solymosi, 2010-2014, Beuth-Hochschule für Technik Berlin, FB VI (Informatik und Medien)

solymosibeuth-hochschule.de