Andreas Solymosi:

Typkompatibilität in Java

Objektorientiertes Programmieren ist seit 15 Jahren selbstverständlich. Viele verstehen darunter jedoch nur das, was auch in nicht-objektorientierten Sprachen vorhanden ist – die Kapselung: Daten und Algorithmen werden in Klassen gekapselt, von denen Objekte gebildet werden. Objektorientiertheit ist jedoch wesentlich mehr: Sie beinhaltet Fähigkeiten der Laufzeitumgebung, die von vielen Programmierern aus Erfahrung benutzt werden, ohne jedoch den tieferen Sinn zu verstehen und ihn bewusst auszunutzen. Hierzu gehört Polymorphie, für die die Voraussetzung die Kompatibilität unterschiedlicher Typen gehört. Damit verbunden ist auch die Problematik der Kovarianz und Kontravarianz.

In diesem Artikel werden diese Fähigkeiten am Beispiel von Java vorgeführt; die Prinzipien sind jedoch ohne weiteres auf andere objektorientierte Sprachen wie C++, C#, Ada, Eiffel, Scala, usw. übertragbar.

1. Variablen, Werte und Objekte

Alle objektorientierte Sprachen, so auch Java unterscheiden zwischen Variablen und Objekten. In beiden werden typischerweise Daten gespeichert; in Variablen „einfache“, in Objekten „komplexe, zusammengesetzte“ Daten. Jede Variable und jedes Objekt ist von einem bestimmten Typ, der die Art, Menge und Größe der speicherbaren Daten bestimmt.

Die Daten, die in eine Variable gespeichert werden können, heißen Werte; die Menge der möglichen verschiedenen Werte in einer Variable heißt Wertebereich[1]. Seine Größe bestimmt den Speicherplatz, der von der Variable belegt wird.

In Java gibt es zwei Arten von Variablen: primitive und Referenzen. Dementsprechend gibt es primitive und Referenztypen. Die primitiven Typen wurden in der Sprache definiert:

Typ

Platzbedarf

Wertebereich

boolean

1 Bit

true und false

byte

8 Bits

-128 bis 127

short

16 Bits

-32768 bis 32767

char

16 Bits

'\u0000' bis '\uffff', d.h. 0 bis 65535

int

32 Bits

-2147483648 bis 2147483647

long

64 Bits

-9223372036854775808 bis 9223372036854775807

float

32 Bits

ca. 5×10−45 bis 3.4×1038 (7 Dezimalstellen)

double

64 Bits

ca. 5.0×10−324 bis 1.7×10308 (15-16 Dezimalstellen)

Tabelle 1: Primitive Typen in Java

Die Referenztypen bilden eine (Vererbungs-) Hierarchie, an deren Spitze der Typ java.lang.Object steht. Neben der in der Sprache und in den Standardbibliotheken definierten Typen kann jeder Programmierer dieser Hierarchie seines eigenen Typen hinzufügen.

In Java gibt es mehrere Arten Referenztypen, so wie Klassen (class), Schnittstellen (interface) und Aufzählungstypen (enum). Darüber hinaus gibt es die (von einem anderen Typ) abhängigen Typen wie generische (oder parametrisierte) Typen sowie Reihungstypen (array). Bei den generischen Typen sprechen wir von formalen Typparametern (in der Vereinbarung des generischen Typs) und von aktuellen Typparametern (in der Instanziierung des generischen Typs).

Eine Variable vom Referenztyp ist[2] 32 Bits lang und in ihr können „Adressen“ von Objekten (d.h. Referenzwerte) gespeichert werden. Jedes Objekt wird mit dem Operator new erzeugt. Dieser liefert einen Referenzwert, der in einer Referenz(variable) gespeichert werden kann. Man sagt, die Variable referenziert das Objekt. Durch eine Zuweisung kann die Referenz „verbogen“ werden, d.h. ein anderes Objekt referenzieren. Das erste Objekt kann dadurch „unerreichbar“ werden und wird dann vom automatischen Speicherbereiniger (garbage collector) entsorgt, d.h. der von ihm belegte Speicherplatz auf der Halde (heap) freigegeben. Der Typ von jedem Objekt ist entweder eine (ggf. instanziierte generische) Klasse oder eine Reihung. Von den anderen Arten von Typen (Aufzählung, Schnittstelle, Typparameter) können keine Objekte gebildet werden.

Die JVM[3] hat drei Speicherbereiche, wo sie jede Variable (je nach ihrer Vereinbarung im Programm) ablegt:

Vereinbarung

Speicherort

lokal     

Stapel (stack)

global (in Objekten)

Halde (heap)

statisch

Klasse[4]

Tabelle 2: Speicherbereiche in der JVM

Die lokalen Variablen einer Methode (inklusive Parameter) werden im dem Methodenaufruf zugeordneten Stapeleintrag abgelegt; sie leben vom Aufruf bis zur Beendigung (über return[5] oder eine Ausnahme) der Methode. Ein nächster (ggf. rekursiver) Aufruf derselben Methode bekommt einen neuen Stapeleintrag, d.h. alle Variablen werden neu angelegt; die Werte vom vorherigen Aufruf sind nicht verfügbar.

Die globalen (nichtstatischen) Variablen[6] einer Klasse (d.h. die Objektvariablen) werden in der Halde angelegt, wenn ein Objekt (mit new) erzeugt wird. Sie leben, solange das Objekt erreichbar ist. Die statischen (static) Variablen einer Klasse werden angelegt, wenn die Klasse geladen wird und leben, bis sie entladen wird.

Die Größe eines Reihungsobjekts wird in der Reihungsdefinition angegeben: Es besteht aus der angegebenen Anzahl von (primitiven oder Referenz-) Variablen.

Die Größe des vom Klassenobjekt belegten Speicherplatzes wird in der Klassendefinition bestimmt. Vereinfach gesagt, es besteht aus den globalen Variablen (Attributen) der Klasse und aller Oberklassen.

Variablen und die ggf. von ihnen referenzierten Objekte können in einem Objektdiagramm dargestellt werden:

Referenz und Objekt

Abbildung 3: Referenz und Objekt

Da das Objekt aus Variablen besteht, können diese im Objekt dargestellt werden:

Inhalt von Objekten

Abbildung 4: Inhalt von Objekten

2. Kopieren von primitiven Werten

Unter Kopieren verstehen wir den Vorgang, wenn der Inhalt eines Speicherbereichs (ein Wert, z.B. in einer Variable oder in einem Objekt) in einen anderen Speicherbereich (Variable oder Objekt, ohne oder mit adequater Veränderung) übertragen wird.

Objekte zu kopieren ist eine komplexe Angelegenheit; der Artikel „Objektdiagramme für Java“ beschäftigt sich mit einem einleuchtenden Beispiel. Werte in Variablen zu kopieren ist ein Thema dieses Artikels.

In Java gibt es zwei Sprachelemente, die durch Kopieren von Werten in Variablen implementiert werden:

1.      Zuweisung

2.      Parameter- und Ergebnisübergabe[7]

In allen Fällen wird ein Wert (eines Ausdrucks, z.B. der Inhalt einer Variable) in eine andere[8] Variable übertragen. Dieser Kopiervorgang findet bei der Zuweisung

ziel = quelle;

von der Variable[9] quelle in die Variable ziel; bei der Parameterübergabe vom aktuellen Parameter (des Aufrufers einer Methode) in den formalen Parameter (der Methode, eine lokale Variable des Methodenrumpfs); bei der Ergebnisübergabe aus dem Rumpf einer Funktion (über die return-Anweisung) zurück zum Aufrufer der Funktion.

Bei allen diesen Vorgängen müssen die zu kopierenden Werte zur aufnehmenden Variable kompatibel sein. Bei der Ergebnisübergabe besteht diese Notwendigkeit an zwei Stellen: Einmal muss der Typ des Ausdrucks in der return-Anweisung kompatibel zum Ergebnistyp der Funktion sein, und der Ergebnistyp der Funktion muss kompatibel zur Aufrufstelle [10] sein.

Im Folgenden sprechen wir nur von Zuweisung, aber die Prinzipien gelten auch für die anderen Fälle.

3. Kopieren von primitiven Werten

Wenn bei der Zuweisung die beiden Variablen vom selben Typ sind, ist das Kopieren problemlos. Probleme treten auf, wenn der Kopiervorgang zwischen Variablen unterschiedlicher Typen stattfinden soll. In vielen Fällen ist es nicht möglich. Beispielsweise ist es verboten, den Inhalt einer Referenzvariable in eine primitive Variable oder umgekehrt zu übertragen[11]. Wir sprechen in diesen Fällen von inkompatiblen Typen. boolean ist zum Beispiel zu allen anderen Typen inkompatibel.

Manche Typen (wie alle arithmetische Typen) sind aber untereinander kompatibel. Beispielsweise ist es möglich, einen Wert vom Typ byte in eine Variable vom Typ int zuzuweisen:

byte b = 5;

int i = b; // kompatible Zuweisung

Hier findet eine implizite Typkonvertierung stattfindet: Der (8 Bits lange) byte-Wert 5 wird implizit in einen (32 Bits langen) int-Wert 5 konvertiert. Weil hier der Wertebereich erweitert wird, spricht man von erweiternden Konvertierung. Die Sprache Java definiert, von welchen zu welchen primitiven Typen erweiternde Konvertierungen möglich sind:

von

nach

byte

short, int, long, float, double

short, char

int, long, float, double

int

long, float, double

long

float, double

float

double

Tab. 5: Erweiternde Konvertierungen

In die andere Richtung ist eine implizite Konvertierung nicht möglich: Der Compiler erlaubt nicht ohne weiteres, einen int-Wert in eine byte-Variable zu übertragen:

b = i; // Typfehler

Der Grund dafür ist, dass die Variable i einen Wert (z.B. 300) enthalten kann, der in der Variable b (mit Wertebereich 0 bis 255) nicht dargestellt werden kann. Der Compiler kann nicht feststellen, ob i zur Laufzeit den Wert 5 oder den Wert 300 enthalten wird – deswegen verbietet er sicherheitshalber diese Zuweisung. Wenn aber der Programmierer die Verantwortung übernimmt und sich sicher ist, dass der Wert in b hineinpasst, kann er den Compiler mit einer expliziten Typkonvertierung (casting) zur Annahme der Zuweisung „zwingen“:

b = (byte)i; // explizite Typkonvertierung

Wenn er sich geirrt hat und i zur Laufzeit doch einen unpassenden Wert (z.B. 300) enthält, bekommt er in b natürlich nicht seinen, sondern einen anderen Wert: Überschüssige Bits werden abgeschnitten. In diesem Fall würde dies den Wert 300 % Byte.MAX_BYTE, d.h. 44 ergeben.

Hier handelt es sich um eine einschränkende Typkonvertierung: Die Zielvariable kann nicht alle Werte der Quelle aufnehmen. Die Sprachdefinition regelt auch die Fälle, die nur mit expliziter Typkonvertierung abgedeckt werden können:

von

nach

byte 

char

short

char, byte

char

byte, short

int

byte, short, char

long

byte, short, char, int

float

byte, short, char, int, long

double

byte, short, char, int, long, float

Tab. 6: Einschränkende Konvertierungen

Die folgende Tabelle gibt einen Überblick über die impliziten (+) und expliziten (-) Konvertierungen. Das Zeichen ! bedeutet eine implizite Konvertierung, bei der Genauigkeit[12] verloren gehen kann; bei 0 ist keine Konvertierung nötig:

byte

char

short

int

long

float

double

byte

0

-

+

+

+

+

+

char

-

0

-

+

+

+

+

short

-

-

0

+

+

+

+

int

-

-

-

0

+

!

+

long

-

-

-

-

0

!

!

float

-

-

-

-

-

0

+

double

-

-

-

-

-

-

0

Tab. 7: Arithmetische Konvertierungen

Dieselben Regeln gelten natürlich nicht nur bei Zuweisungen sondern auch bei Parameterübergabe.

4. Kopieren von Referenzwerten

Referenztypen sind untereinander kompatibel, wenn sie sich an einem Hierarchiezweig befinden. Beispielsweise ist es nicht möglich, einen String-Wert in eine Integer-Variable zu kopieren oder umgekehrt. Die Umwandlung kann nur durch (ggf. aufwändiges) Rechnen, ggf. mit geeigneten Funktionen durchgeführt werden:

String s = i.toString(); // Umwandeln von Integer nach String

Integer i = Integer.parseInt(s); // Umwandeln von String nach Integer

Wenn sich die zwei Typen an einem Hierarchiezweig befinden, d.h. der eine der Obertyp des anderen ist, dann sind sie untereinander kompatibel. Beispielsweise kann eine Object-Variable die Referenz auf ein beliebiges Objekt aufnehmen[13]:

Object o = new Klasse();

Hier findet also eine implizite Konvertierung statt: Der vom Operator new gelieferter Referenzwert vom Typ Klasse wird in einen Referenzwert vom Typ Object konvertiert, bevor er in die Variable o geschrieben wird.

Für die Konvertierung von Referenztypen gelten ähnliche Regeln wie für primitive Typen: „sichere“ Konvertierungen können implizit, „gefährliche“ nur explizit vorgenommen werden.

5. Aufwärtskompatibilität

Aufwärtskompatibilität findet statt, wenn die Konvertierung von einem Untertyp zu einem Obertyp erfolgt. Sie kann implizit durchgeführt werden (wie beispielsweise oben von Klasse nach Object). Das heißt, der Compiler übernimmt die Verantwortung, dass durch die Konvertierung z.B. keine unerlaubten Methodenaufrufe stattfinden:

class Oberklasse {

  public void methode() { }

  … }

class Unterklasse extends Oberklasse { … }

class Programm {

   public static void prozedur(Oberklasse p) {

     p.methode(); }

   public static void main(String[] kzp) {

     Unterklasse unter = new Unterklasse();

     Oberklasse ober = unter; // explizite Typkonvertierung

     ober.methode();

     unter.methode(); // methode aus Oberklasse für referenz der Unterklasse

     prozedur(unter); // referenz der Unterklasse für parameter der Oberklasse

}

Hier finden in der main-Methode zwei explizite Typkonvertierungen statt. Durch die Zuweisung ist es möglich, methode() sowohl für die Referenz ober wie auch für unter aufzurufen, zumal sie nach Unterklasse vererbt wird. Bei der Parameterübergabe von u (vom Typ Unterklasse) in den Parameter p (vom Typ Oberklasse) läuft auch alles glatt: in prozedur kann methode sowohl für ein Oberklasse-Objekt wie auch für ein Unterklasse-Objekt aufgerufen werden.

Wir sprechen von einer aufwärtskompatiblen Zuweisung (bzw. Parameterübergabe):

Unterklasse quelle = // Referenz

   new Unterklasse(); // Objekt

Oberklasse ziel; // Referenz

ziel = quelle; // Referenz der Oberklasse, Objekt der Unterklasse

Als ein Objektdiagramm kann dies folgendermaßen dargestellt werden:

 Zuweisung mit Aufwärtskompatibilität

Abb. 8: Zuweisung mit Aufwärtskompatibilität

Die Zuweisung findet vom Untertyp zum Obertyp, also aufwärts statt.

Generell gilt also, dass eine Variable vom Obertyp (Schnittstelle oder Klasse) ein Objekt vom beliebigen Untertyp (Klasse[14]) referenzieren kann. Ein Aufzählungstyp kann nur Enum erweitern und eine Schnittstelle implementieren; ein Aufzählungswert ist also aufwärtskompatibel zu einem solchen Obertyp. Für Reihungstypen und generische (parametrisierte) Typen besteht die Problematik der Kovarianz und Kontravarianz; diese wird weiter unten behandelt.

Die Kompatibilität in die andere Richtung ist nicht so einfach.

6. Abwärtskompatibilität

Ein Kopiervorgang von einem Obertypen in eine Untertypvariable ist implizit nicht erlaubt – der Compiler meldet Typfehler:

public static void prozedur(Unterklasse param) {

   param.untermethode(); } // eine Methode aus Unterklasse

public static void main(String[] kzp) {

   Oberklasse ober = new Oberklasse();

   ober.untermethode(); // Typfehler

   prozedur(ober); // Typfehler

   Unterklasse unter = ober; // Typfehler

   … }

Es ist richtig, dass der Compiler diese Konvertierungen ablehnt: Für ein Objekt von Oberklasse wäre es falsch, untermethode (aus Unterklasse) aufrufen zu dürfen. untermethode() kann nämlich neben (geerbten) Objektvariablen von Oberklasse auch auf Objektvariablen von Unterklasse zugreifen, die im Objekt der Oberklasse gar nicht vorhanden sind. Aus demselben Grund ist ober als Parameter für prozedur ungeeignet: Darin wird untermethode() aufgerufen, was nur für ein Objekt von Unterklasse erlaubt ist. Deswegen ist auch die letzte Zuweisung fehlerhaft.

Es gibt jedoch Situationen, wo diese Verbote lästig sind. Auch im obigen Programmstück: Wenn die Variable ober anstelle des Oberklasse-Objekts ein Unterklasse-Objekt referenziert, dann gelten die obigen Argumente nicht:

   Oberklasse ober = new Unterklasse(); // aufwärtskompatibel

Für dieses Objekt könnte untermethode() aufgerufen werden, es wäre als Parameter für prozedur geeignet und auch die letzte Zuweisung sollte stattfinden dürfen.

Ähnlich wie bei primitiven Typen, besteht auch hier die Möglichkeit, den Compiler zu solchen Konvertierungen – zu „zwingen“. Hierzu dient die explizite Typkonvertierung (casting):

   Oberklasse ober = new Unterklasse();

   ((Unterklasse)ober).untermethode(); // explizite Typkonvertierung

   prozedur((Unterklasse)ober); // explizite Typkonvertierung

   Unterklasse unter = (Unterklasse)ober; // explizite Typkonvertierung

In der letzten Zeile findet also Abwärtskompatibilität statt: Der Inhalt der Referenzvariable (ober) vom Obertyp (Oberklasse) wird in eine Variable (unter) vom Untertyp (Unterklasse) übertragen. Auch bei dem Aufruf von prozedur: der aktuelle Parameter vom Obertyp wird in einen formalen Parameter vom Untertyp „hineingezwungen“. In der zweiten Zeile findet kein Kopiervorgang statt, wohl aber auch eine explizite Typkonvertierung: Hier wird der Inhalt der Obertypvariable wie ein Referenzwert vom Untertyp benutzt.

Bei einer Zuweisung gilt also zusammengefasst:

Oberklasse ziel = // Referenz

   new Unterklasse(); // Objekt

Unterklasse quelle; // Referenz

ziel = (Oberklasse)quelle; // explizite Typkonvertierung

Im Objektdiagramm kann die Abwärtskompatibilität so dargestellt werden:

Erfolgreiche Zuweisung mit Abwärtskompatibilität

 Abb. 9: Erfolgreiche Zuweisung mit Abwärtskompatibilität

Warum erlaubt der Compiler Abwärtskompatibilität nicht ohne explizite Typkonvertierung? Aus demselben Grund wie bei primitiven Typen: Er kann den aktuellen Wert der Quelle zur Laufzeit nicht überprüfen. Auch hier kann er einen „falscher“ Wert, nämlich die Referenz auf ein „falsches“ Objekt enthalten.

Oberklasse ziel = // Referenz

   new Oberklasse(); // Objekt

Unterklasse quelle; // Referenz

ziel = (Oberklasse)quelle; // Objekt der Oberklasse

ziel.untermethode();

Wäre diese Zuweisung ohne Typkonvertierung erlaubt, dann würde untermethode() für ein Oberklasse-Objekt aufgerufen und auf nicht existente Variablen – mit verheerenden Folgen – zugreifen. Der Compiler kann nicht überprüfen, ob quelle zur Laufzeit ein „richtiges“ (Unterklasse-) oder ein „falsches“ (Oberklasse-) Objekt referenziert. Der Programmier kann jedoch die Verantwortung übernehmen und die Typprüfung des Compilers an dieser Stelle – mit explizitier Typkonvertierung – abschalten. Wenn er sich jedoch geirrt hat und zur Laufzeit doch ein „falsches“ Objekt vorliegt, verhindert die JVM (Java Virtuelle Maschine, also das Laufzeitsystem) die „verheerenden Folgen“: eine ClassCastException wird ausgeworfen, die Ausführung des Blocks unterbrochen und die weiteren (unausführbaren) Anweisungen werden nicht mehr ausgeführt.

Ein Objektdiagramm stellt diese Situation folgendermaßen dar:

 Erfolglose Zuweisung mit Abwärtskompatibilität

Abb. 10: Erfolglose Zuweisung mit Abwärtskompatibilität

Ob eine explizite Typkonvertierung zwischen Referenztypen zur Laufzeit erfolgreich durchgeführt werden kann, hängt vom Objekt ab: Ein Objekt vom Zieltyp (oder zu diesem implizit kompatiblen Typ) wird angenommen; ein Objekt von einem zum Zieltyp implizit nicht kompatiblen Typ löst die Ausnahme ClassCastException aus.

Dies ist ein wichtiger Aspekt der „Objektorientiertheit“. Das Eigentliche wird aber im nächsten Abschnitt erörtert.

7. Polymorphie

Polymorphie bedeutet so viel wie „Vielgestaltigkeit“ und ist ein Hinweis darauf, dass sich der Inhalt einer Variable in „verschiedenen Gestalten“ erscheinen kann – in Abhängigkeit vom referenzierten Objekt. Bei der Polymorphie wird die Kompatibilität von Referenztypen ausgenutzt.

Für die Polymorphie sind also eine Typhierarchie (also mindestens eine Oberklasse und eine Unterklasse), sowie eine überschriebene Methode notwendig. Die Frage, die sich dabei stellt, welche Version der Methode wohl aufgerufen wird:

class Oberklasse {

   public void methode() { System.out.println("Version 0"); } }

class ErsteUnterklasse extends Oberklasse {

   public void methode() { System.out.println("Version 1"); } }
class ZweiteUnterklasse extends Oberklasse {

   public void methode() { System.out.println("Version 2"); } }

Hier wurde also methode() in zwei Unterklassen überschrieben, die jeweils unterschiedliche Konsolausgaben bewerkstelligen. Im folgenden Programm wird illustriert, dass unterschiedliche Version von methode() aufgerufen werden können:

public class Polymorph {

  public static void prozedur(Oberklasse parameter) {

     parameter.methode(); } // welche Version?

  public static void main(String[] kzp) {

     Oberklasse objektOber = new Oberklasse();

     ErsteUnterklasse objektErste = new ErsteUnterklasse();

     ZweiteUnterklasse objektZweite = new ZweiteUnterklasse();

     objektOber.methode(); // Version 0

     objektErste.methode(); // Version 1

     objektZweite.methode(); // Version 2

    

In nicht-objektorientierten Sprachen[15] ist es einfach: Der Typ der Referenz bestimmt die Version der Methode. So wie bei den Aufrufen im Rumpf der main-Methode, würde auch in prozedur die Version aus Oberklasse, also Version 0 aufgerufen.

Objektorientierte Sprachen nutzen jedoch Polymorphie: Hier bestimmt nicht der Typ der Referenz, sondern der Typ des Objekts, welche Version aufgerufen wird:

     prozedur(objektOber); // Version 0

     prozedur(objektErste); // Version 1

     prozedur(objektZweite); // Version 2

Obwohl in prozedur der Aufruf von methode() immer mit der Referenz parameter vom Typ Oberklasse erfolgt, weil sie Objekte unterschiedlicher Typen referenziert, werden unterschiedliche Versionen aufgerufen: Der Aufruf parameter.methode(); erweist sich als „vielgestaltig“. Dies ist ein Kernbegriff der „Objektorientiertheit“.

Der Mechanismus greift nicht nur bei Parameterübergabe sondern auch bei Zuweisung:

Oberklasse referenz;

referenz.methode(); // Version 0

referenz = objektErste; // aufwärtskompatibel

referenz.methode(); // Version 1, nicht Version 0!

referenz = objektZweite; // aufwärtskompatibel

referenz.methode(); // Version 2, nicht Version 0!

Der Compiler kann also – im Gegensatz zu nicht-objektorientierten Sprachen – nicht immer entscheiden, welche Methode aufgerufen werden soll. Man sagt, er bindet den Aufruf nicht an die Methode. Deswegen heißt dieser Mechanismus späte Bindung. Die Bindung erfolg in diesen Fällen vom Laufzeitsystem, bei Java also von der JVM. Dies führt zu leichten Laufzeiteinbußen gegenüber der frühen Bindung; die Vorteile bei der Programmentwicklung überwiegen aber enorm: Das objektorientierte Paradigma (also die Orientierung beim Methodenaufruf am Typ des Objekts, nicht am Typ der Referenz) hat die Softwaretechnologie in den letzten Jahrzehnten entscheidend geprägt.

8. Typkompatibilität beim Überladen und Überschreiben

Die Frage nach Typkompatibilität stellt sich nicht nur bei Kopiervorgängen wie Zuweisung oder Parameterübergabe. Der Compiler überprüft die Kompatibilität von Typen auch beim Überladen und Überschreiben von Methoden.

Man sagt, eine Methode wird überladen, wenn in derselben Klasse (oder in einer Unterklasse) eine andere Methode mit demselben Namen aber mit einer anderen Parameterliste (Signatur) vereinbart wird[16]. Eine Methode wird überschrieben, wenn in einer Unterklasse eine Methode mit demselben Namen und demselben Signatur vereinbart wird. Es sei also vermerkt, dass weder der Ergebnistyp noch die Namen der Parameter zum Signatur gehören.

Hier stellt sich die Frage nach Typkompatibilität: In welchen Fällen wird die Parameterliste als gleich oder unterschiedlich angesehen?

Die Java-Regeln[17] sind an dieser Stelle streng: Es wird keine Typkompatibilität gewährt. Fürs Überladen heißt es, wenn eine Methode mit einem short-Parameter vereinbart wurde, kann sie mit einem long-Parameter überladen werden. Beim Aufruf wird – aufgrund von wohldefinierten Inferenzregeln – die Methode aufgerufen, in deren formalen Parameter der aktuelle „schneller“ oder „leichter“ oder „in weniger Zwischenschritten“ konvertiert werden kann:

static void methode(short x) { System.out.println("short"); }

static void methode(long x) { System.out.println("long"); }

  

methode(1); // "long"

methode((byte)1); // "short"

 

Beim Überschreiben ist es ähnlich: Es gibt keine Typkompatibilität. Wenn die Parametertypen nicht genau passen, wird dies als Überladen und nicht als Überschreiben verstanden. Um Fehler an dieser Stelle zu vermeiden, wird empfohlen, die Annotation @Override zu verwenden:

class Ober {

  void methode(short x) { } }

class Unter extends Ober {

   @Override void methode(long x) { } } // Fehler

In der letzten Zeile meldet der Compiler den Fehler: methode(long) überschreibt metho­de(short) nicht.

9. Kovarianz für Reihungen

Unter Kovarianz und Kontravarianz verstehen wir Auf- und Abwärtskompatibilität abhängiger Typen. Ein Reihungstyp ist vom Typ der Elemente der Reihung abhängig, ein generischer Typ ist vom Typparameter abhängig. Wenn zwei (ungleiche) abhängige Typen kompatibel sind, dann besteht Ko- bzw. Kontravarianz.

Reihungen (arrays) gehören ähnlich wie Klassen zu den Referenztypen und die Frage nach Kompatibilität zwischen unterschiedlichen Reihungstypen stellt sich ebenso.

Wenn zwei Reihungsreferenzen vom selben Typ sind, sind Kopiervorgänge problemlos:

Klasse[] quelle; … // quelle Wert zuweisen

Klasse[] ziel;

ziel = quelle;

Es sei jedoch zu bemerken, dass hier nur ein Referenzwert kopiert wird, also nicht das Reihungsobjekt selbst. Das Reihungsobjekt enthält Referenzen auf Objekte (hier vom Typ Klasse). In diese können Objekte eingehängt werden, deren Typen kompatibel zu Klasse sind, d.h. von ihren Unterklassen gebildet wurden.

Nach der obigen Zuweisung wird also ziel dasselbe Reihungsobjekt wie quelle referenzieren.

 

Reihung

Abb. 11: Reihung

Die Frage nach Kompatibilität stellt sich, wenn der Elementtyp der beiden Reihungen unterschiedlich ist, jedoch innerhalb eines Hierarchiezweiges liegt – ansonsten besteht keine Kompatibilität. Die Entwickler der Sprache Java haben sich entschieden, auch hier Aufwärts­kom­pa­ti­bi­lität zu gewähren:

Obertyp[] oberreihung;

Untertyp[] unterreihung;

  

oberreihung = unterreihung;

Reihungen sind also in Java kovariant. Dadurch ist jedoch das sog. Kovarianzproblem entstanden. 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

Das Unangenehmste ist allerdings gar nicht die Gefahr der Ausnahme (sie kann man durch Programmdisziplin vermeiden), sondern dass der Interpreter zur Laufzeit jede Zuweisung auf das Kovarianzproblem überprüfen muss. Dies ist eine beträchtliche Effizienzverminderung gegenüber Sprachen, die das Kovarianzproblem nicht haben 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 unterschiedlicher Klassen sind:

Object[] reihung; // Reihungsreferenz

reihung = new String[3]; // Reihungsobjekt

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

Abb. 12: Das Kovarianzproblem für Reihungen

10. Generische Typen

Ein generischer (oder parametrisierter) Typ ist abhängig von seinem Typparameter, die Frage nach Kovarianz stellt sich also auch hier. Neben Kopiervorgängen spielt bei der generischen Instanziierung die Typkompatibilität eine Rolle: Welche aktuelle Typparameter sind zum formalen Typparameter kompatibel?

Ein allgemeiner Typparameter wird wie Object gehandelt, zu dem alle Typen kompatibel sind. So kann eine generische Klasse

class Generisch<Typparameter> { … }

für alle Typen instanziiert werden:

new Generisch<Object>()

new Generisch<Integer>()

new Generisch<String>()

Im Rumpf der generischen Klasse (oder Methode) können für den Typparameter alle Object-Methoden (wie toString(), usw.) aufgerufen werden. Sollte es nötig sein, auch andere Methoden aufzurufen, kann der Typparameter von oben eingeschränkt werden:

class Generisch <Typparameter extends Obertyp> { … }

Solche generischen Einheiten sind nur für Untertypen von Obertyp instanziierbar. Im Klassenrumpf sind dann alle Obertyp-Methoden aufrufbar.

Ein klassisches Beispiel hierfür ist das Sortieren. Dies ist nur möglich, wenn die sortierbaren Elemente miteinander vergleichbar sind. Hierzu dient die (generische) Schnittstelle Comparable mit der einzigen Methode compareTo() – sie vergleicht zwei Objekte miteinander:

interface Comparable<T> { int compareTo(T o); }

Durch sie kann der Typparameter einer generischen Sortiermethode von oben eingeschränkt werden:

static <E extends Comparable<E>> void sort(E[] sammlung) { … }

Im Rumpf von sort() steht dann für die Elemente von sammlung die Methode compareTo() zur Verfügung. Die „geschachtelte Generizität“ ist nötig, weil Comparable selbst generisch ist: Sie muss mit demselben Typparameter instanziiert werden, mit dem sort instanziiert wurde. Dadurch wird sichergestellt, dass der Parameter o von compareTo() denselben Typ hat wie ihr Zielobjekt – diese zwei Objekte werden miteinander verglichen. Dieser Vergleichalgorithmus muss nun in der Klasse ausprogrammiert werden, deren Objekte sortiert werden sollen[18]. Wegen der Aufwärtskompatiblität für Typparameter (und auch für Reihungstypen) kann sort also für Reihungen mit allen Elementtypen aufgerufen werden, die Comparable implementieren. Zu diesen gehören alle arithmetische Hüllenklassen (wrapper classes), String, usw.:

sort(new String[]{"Müller", "Mayer", "Hinz", "Kunz"});

sort(new Integer[]{25, -123, 0, 1239876}); // Autoboxing

String (weil er Comparable implementiert) ist aufwärtskompatibel zum Typparameter E und String[]ist (wegen der Kovarianz) aufwärtskompatibel zu E[] (wie dies im vorigen Kapitel erörtert wurde).

Von einem Typparameter können keine Objekte, auch keine Reihungsobjekte erzeugt werden. Eine Konvertierung zum Typparameter ist möglich, wird aber zur Laufzeit – im Gegensatz zu anderen Typkonvertierungen – nicht überprüft (daher wird auch keine ClassCastException ausgelöst):

class Behälter<T> {

   private T[] inhalt;

   public Behälter(int größe) {

     inhalt= new T[größe]; // Fehler

     inhalt= (T[])new Object[größe]; // OK

} ... }

11. Joker

Im Gegensatz zu Reihungen sind unterschiedliche Instanziierungen eines generischen Typs untereinander nicht (auch nicht explizit) kompatibel:

Generisch<Obertyp> oberGenerisch;

Generisch<Untertyp> unterGenerisch;

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

oberGenerisch = (Generisch<Obertyp>)unterGenerisch; // Typfehler[19]

Diese Inkompatibilität kann jedoch mit dem Joker ? aufgeweicht werden – bei der Vereinbarung von Referenzen kann ? für einen aktuellen Typparameter eingesetzt werden:

Generisch<?> jokerReferenz;

Der Joker kann nur für Referenzen, nicht für Objekte eingesetzt werden: new Generisch<?>() ergibt keinen Sinn und wird vom Compiler abgelehnt. Der Sinn einer solcher Referenz ist, dass zu ihr beliebige Instanziierungen von Generisch kompatibel sind:

jokerReferenz = new Generisch<String>();

jokerReferenz = new Generisch<Integer>();

Das heißt, die Joker-Instanziierung ist der Obertyp aller Instanziierungen des generischen Typs.

Generische Typen sind (wie oben erläutert,  im Gegensatz zu Reihungstypen) nicht kovariant: Generisch<Untertyp> ist nicht kompatibel zu Generisch<Obertyp>. Zu Generisch<?> sind jedoch alle Instanziierungen von Generisch kompatibel.

Für den Joker kann ein beliebiger Typ eingesetzt werden; er wird wie Object gehandhabt. Angenommen, wir haben Generisch folgendermaßen vereinbart:

class Generisch<T> {

   private T t;

   void schreiben(T t) { this.t = t; }

   T lesen() { return t; } }

Dann können wir für die Referenz vom Typ Generisch<?> die lesen()-Methode aufrufen und das Ergebnis vom Typ ? in eine Referenz vom Typ Object ablegen:

Object o = jokerReferenz.lesen();

Allerdings in die andere Richtung, zum Joker-Typ ? ist kein anderer Typ, nicht einmal Object kompatibel:

jokerReferenz.schreiben(new Object()); // Typfehler

Nur nach einer expliziten Konvertierung des generischen Typs können Objekte als Parameter der Methode schreiben() übergeben werden:

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

((Generisch<Obertyp>)jokerReferenz).schreiben(new Untertyp()); // aufwärtskomptb.

Der Joker ist typischerweise in Parametern sinnvoll:

void methode(List<?> liste) { … } // aus liste kann gelesen werden

Der Joker kann eingeschränkt werden, wenn man zur Referenz nicht beliebige Instanziierungen kompatibel halten möchte:

Generisch<? extends Obertyp> vonObenEingeschränkteReferenz;

In diese Referenz kann nun eine Instanz von Generisch eingehängt werden, dessen aktueller Typparameter ein Untertyp von Obertyp ist:

vonObenEingeschränkteReferenz = new Generisch<Untertyp>();

Für Parameter der Methodenaufrufe wie lesen() und schreiben() gelten hier dieselben Einschränkungen wie für den allgemeinen Joker-Typ.

Allerdings, bei der Einschränkung von unten ist die Situation anders:

Generisch<? super Untertyp> vonUntenEingeschränkteReferenz;

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

vonUntenEingeschränkteReferenz = new Generisch<Obertyp>();

vonUntenEingeschränkteReferenz = new Generisch<Object>(); // auch möglich

Allerdings, diese Schranke bezieht sich nur auf den Parametertyp der Generisch-Objekte, die in diese Referenz eingehängt werden, nicht auf den Parameter- und Ergebnistyp der Methoden. Beispielsweise kann als Parameter der Methode schreiben aufwärtskompatibel ein beliebiges Objekt übergeben werden, das zum Untertyp kompatibel ist; dieser ist also (wie jeder Parameter) „von oben“ (also durch die Aufwärtskompatibilität) eingeschränkt:

vonUntenEingeschränkteReferenz.schreiben(new Untertyp()); // OK

vonUntenEingeschränkteReferenz.schreiben(new Unteruntertyp()); // auch OK

vonUntenEingeschränkteReferenz.schreiben(new Obertyp()); // Typfehler

((Generisch<? super Obertyp>)vonUntenEingeschränkteReferenz).schreiben(

   new Obertyp()); // OK

Durch die Einschränkung von unten ermöglichen wir also die Parameterübergabe an schreiben(), die beim uneingeschränkten oder von oben eingeschränkten Jokertyp ? nicht möglich ist.

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 = vonUntenEingeschränkteReferenz.lesen();

Zu einem anderen Typ ist der Ergebnistyp nur nach expliziter Typkonvertierung der Referenz kompatibel:

Oberobertyp oo = ((Generisch<Oberobertyp>)vonUntenEingeschränkteReferenz).    lesen();

oo = (Oberobertyp)vonUntenEingeschränkteReferenz.lesen(); // typunsichere Alternative

Einerseits können von Joker-Typen keine Objekte gebildet werden; andererseits können Reihungsobjekte nur von uneingeschränkten Joker-Typen und 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; // abwärtskompatibel

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.

12. Kovarianz für generische Typen

Für parametrisierte (generische) Typen besteht also – im Gegensatz zu Reihungstypen – keine implizite Kovarianz (und daher keine implizite Aufwärtskompatibilität):

Generisch<Oberklasse> ober = … ;

Generisch<Unterklasse> unter;

ober = unter; // Typfehler

Die explizite Kovarianz kann bei der Vereinbarung der Referenz als obere Schranke angegeben werden:

Generisch<? extends Oberklasse> ober = … ;

Generisch<Unterklasse> unter;

ober = unter; // aufwärtskompatibel wegen expliziter Kovarianz

13. Kontravarianz

Kontravarianz ist die Abwärtskompatibilität für abhängige Typen. Für Reihungstypen besteht explizite Kontravarianz  – mit Hilfe von Typkonvertierung:

Obertyp[] oberreihung;

Untertyp[] unterreihung;

  

oberreihung = unterreihung; // kovariant (daher aufwärtskompatibel)

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

Hier besteht kein ähnliches Problem wie bei Kovarianz, weil der Compiler implizit nur Aufwärtskompatibilität erlaubt:

unterreihung[0] = new Obertyp(); // Typfehler

Für generische Typen besteht ebenfalls explizite Kontravarianz: Sie kann als untere Schranke vereinbart werden:

Generisch<Obertyp> obergenerisch = new Generisch<Obertyp>();

Generisch<? super Untertyp> untergenerisch = new Generisch<Untertyp>();

untergenerisch = obergenerisch; // explizit kontravariant

14. Zusammenfassung

Die Frage nach Typkompatibilität stellt sich also in folgenden Situationen:

1.      Kopieren (Zuweisung und Parameterübergabe, inkl. Ergebniszurückgabe) zwischen

a.      primitiven (nur arithmetischen) Typen

b.     Klassen- und Schnittstellentypen

c.      Reihungstypen

d.     generische Typen

2.      Überladen und Überschreiben

3.      Generische Instanziierung

Beim Kopieren zwischen arithmetischen Typen wird erweiternde Konvertierung implizit, einschränkende Konvertierung nur explizit durchgeführt.

Im Falle von Kopieren zwischen Referenztypen sprechen wir von Aufwärts- und Abwärtskompatibilität in Abhängigkeit davon, in welche Richtung (in eine Referenz des Ober- oder des Untertyps) das Kopieren stattfindet. Bei Klassen- und Schnittstellentypen wird Aufwärtskompatibilität implizit, Abwärtskompatibilität nur explizit gewährt. Bei Reihungstypen besteht implizite Kovarianz (d.h. Aufwärtskompatibilität entsprechend dem Elementtyp) und explizite Kontravarianz (Abwärtskompatibilität durch explizite Typkonvertierung). Bei generischen Typen besteht explizite Ko- und Kontravarianz mit Hilfe von oberen und unteren Schranken. Eine implizite Kovarianz (Aufwärtskompatibilität) kann mit dem Joker gewährt werden.

Operation

Typ

Aufwärts

Abwärts

 

Kopieren

Klasse/Schnittstelle

implizit

explizit

Reihung

implizit

explizit

Generisch

explizit

explizit

Überladen/Überschreiben

 

keine

keine

Generische Instanziierung

 

implizit

keine

Tabelle 13: Typkompatibilität

Beim Überladen und Überschreiben von Methoden besteht für die Parameter keine Typkompatibilität.

Bei der generischen Instanziierung besteht implizite Aufwärtskompatibilität zum formalen Typparameter bis auf die obere oder untere Schranke.



Version: 1. Februar 2011

© Prof. Solymosi, 2010, Beuth-Hochschule für Technik Berlin, Faculty for Computer Science and Media

solymosibht-berlin.de


[1] eine diskrete Menge – im Gegensatz zur Mathematik, wo sie (z.B. bei reellen Zahlen) auch stetig sein kann

[2] in der Sprachversion 7

[3] Java Virtual Machine, die Laufzeitumgebung eines Java-Programms

[4] In der JVM-Spezifikation heißt dieser Speicherbereich method area; in Java werden hier statische Variablen gespeichert.

[5] ggf. implizitem return am Ende der Methode

[6] sie heißen auch Attribute

[7] Parameterübergabe bei allen parametrisierten Methoden, Ergebnisübergabe nur bei Funktionen

[8] ggf. vom Compiler erzeugte temporäre (z.B. innerhalb von Ausdrücken)

[9] Einfachheitshalber betrachten wir quelle als Variable, obwohl sie mit einem Ausdruck ersetzt werden kann.

[10] Zuweisung, Operand oder aktueller Parameter

[11] Bei der Übertragung Integer i = 5; findet keine Typkonvertierung, sondern automatisches Einhüllen (autoboxing) statt: Der Compiler übersetzt dies implizit wie Integer i = new Integer(5);

[12] Beispielsweise ist das Ergebnis bei int i5 = 1234567890; float f5 = i5; ungenau: 1234567936.0 ist um 46.0 (0,05‰) zu groß.

[13] Object ist der Obertyp aller Referenztypen.

[14] Objekte können nur von Klassen gebildet werden.

[15] oder in C++ und C# bei nicht-virtuellen Methoden

[16] Etwas korrekter wird also nicht die Methode, sondern nur ihr Name überladen.

[17] Andere Sprachen gehen mit dieser Frage anders um.

[18] Die Alternative hierzu ist, mit einem Comparator-Objekt zu sortieren.

[19] allerdings, unterGenerisch.getClass() == oberGenerisch.getClass()