Java: Typsicherheit bei generischen Datentypen

Veröffentlicht am 22. May 2010

Ab Java 1.5 unterstützt die Programmiersprache Java die Verwendung von sogenannten “Generics” (auch generische Typen, parametrisierte Typen).

Die Idee dahinter ist es, dass sich viele Datenstrukturen, die Objekte von bestimmten Typen enthalten, für alle diese Typen gleich (oder zumindest ähnlich) verhalten.
Als Beispiel kann man hier die Listen ansehen: Egal von welchem Typ die Objekte sind, die in der Liste enthalten sind, die Liste verhält sich immer gleich (Operationen: Hinzufügen, Löschen, Iterieren, Größe bestimmen, etc.)

Früher mussten diese Datentypen daher Objekte vom Typ Object (“kleinster gemeinsammer Nenner”) enthalten, die beim Auslesen wieder manuell hochkonvertiert werden mussten.

ArrayList list = new ArrayList();
list.add(new Integer(22));
list.add(new Integer(32));
list.add(new Integer(42));
		
Iterator iterator = list.iterator();
while (iterator.hasNext()) {
	Integer myInt = (Integer) iterator.next();
	System.out.println(myInt);
}

Um den Programmierern (und Benutzern von Java-Programmbibliotheken) mehr Kontrolle und Informationen darüber zu geben, welchen Typ genau diese Datenstrukturen verarbeiten, wurde das Konzept der Generics entwickelt:

Man kann Klassen (und Methoden) mit einem oder mehreren Typen-Platzhaltern versehen.
Diese Platzhalter werden dann vor der Verwendung mit einem konkreten Typ ausgeprägt.
So kann der Compiler sicherstellen, dass der Datentyp anstelle der Platzhalter immer den korrekten Typ verwendet.

Beispiel: Eine ArrayList wird mit dem Typ String ausgeprägt (in spitzen Klammern).
Dadurch können nur noch Strings hinzugefügt werden und die Liste enthält nur Objekte vom Typ String:

ArrayList<String> list = new ArrayList<String>();
list.add("foo");
list.add("bar");
list.add("foobar");
		
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
	String myString = iterator.next();
	System.out.println(myString);
}

Dadurch stellt der Compiler sicher, dass der Liste list nur Objekte vom Typ String hinzugefügt werden.
Außerdem ist der Upcast von Object auf String beim Auslesen nicht mehr nötig, da der Compiler weiss, dass die next()-Methode von Iterator<String> nur String zurückgibt.

Das sieht nach einem tollen Feature aus!
Der Programmierer kann ohne Probleme parametrieiserte Datenstrukturen entwerfen, die mit beliebigen Typen klarkommen. Und wer die Datenstrukturen benutzt, weiß jederzeit, welche Typen die Methoden zurückgeben; es muss kein Cast mehr durchgeführt werden.

Aber wie implementiert Java die Generics?

Hier wird es leider nicht mehr so schön.
Es ist nämlich so, dass Java die Generics überhaupt nicht implementiert - sondern den Quellcode vor dem Bauen des Bytecodes verändert, um die Generics durch alte Techniken zu ersetzen; und zwar genau so, wie es oben beschrieben ist:

  1. Zunächst entfernt der Compiler alle Typparameter aus dem Code und ersetzt alle diese Typen durch Object. Dadurch entsteht der sog. Raw Type (Roher Typ).
    Dieser Raw Type ist es auch, den man implizit benutzt, wenn man eine parametrisierte Klasse ohne Typparameter benutzt.
  2. Als nächstes werden an allen Stellen, an denen parametrisierte Typen nach außen übergeben werden, Upcasts auf den Parametertyp eingefügt.
  3. Alle Stellen, die von außen einen Parametertyp übergeben bekommen, führen vorher eine Typprüfung durch. Falls der übergebene Typ nicht mit dem Parametertyp übereinstimmt, meldet der Compiler einen Fehler

Es ist also nicht so, dass Java tatsächlich intern mit den Parameter-Typen arbeitet (wie dies beispielsweise C++ bei den Templates tut), sondern es sind vielmehr Beschränkungen, die der Programmierer definiert und die der Compiler einhält.

Nichtsdestotrotz ist die Verwendung von Generics eine starke Erleichterung für die Programmierung mit abstrakten Datentypen und man sollte diese auch nutzen!

An dieser Stelle sollte man aber auch die Typsicherheit erwähnen.

Es ist nämlich dadurch, dass Java intern noch mit dem Typ Object arbeitet, möglich, den Compiler zu täuschen und die Typparameter zu umgehen.

Um es kurz zu sagen: Der Compiler verspricht, einen festen Rückgabetyp zu gewährleisten, kann dieses Versprechen aber nicht halten.

Schauen wir uns nur einmal folgenden Java-Code an, der das sog. “Typ-Loch” der Java Generics ausnutzt:

ArrayList<Integer> intList = new ArrayList<Integer>();
intList.add(15);

ArrayList<?> wcList = intList;

ArrayList<String> stringList = (ArrayList<String>) wcList;

stringList.add("Hallo");

for (Object o : intList) {
System.out.println(o.getClass() + ": " + o.toString());
}
// Ausgabe:
// class java.lang.Integer: 15
// class java.lang.String: Hallo

Was ist hier passiert?

Ich habe eine Liste mit dem Parametertyp Integer erstellt, diese durch ein paar geschickte Casts (diese produzieren immerhin eine Compiler-Warnung) auf eine Liste mit dem Parametertyp String umgewandelt und habe diese mit einem String befüllt.

Ich habe also eine Integer-Liste mit einem String befüllt.

Und das ist genau dann schlecht, wenn ein anderer Programmteil die Listenelemente als Integer annimmt (das verspricht der Typparameter!) und diese als Integer ausliest.
Denn dann fliegt eine (absolut unerwartete) ClassCastException, weil ein String natürlich nicht in einen Integer umgewandelt werden kann.

Ein weiteres Problem ist, dass der generische Datentyp selbst auch nicht seine Member-Variablen auf den korrekten Typ prüfen kann, weil sein Typparameter immer davon abhängt, was der Compiler gerade denkt, welchen Typparameter er hätte.

Die einzige Möglichkeit, um Typsicherheit zu gewährleisten, wäre es, dem generischen Datentyp zur Typprüfung eine Instanz (bzw. ein Class-Objekt) der zu gewährleisteten Klasse zur Verfügung zu stellen.

Um diese Sicherheit zu gewährleisten könnte man zum Einen beim Erstellen die korrekte Klasse (oder eine Instanz davon) mit übergeben und diese bei jedem Schreibvorgang auf Kompatibilität prüfen.

Zum anderen könnte man vor dem Auslesen eine Klasse (oder eine Instanz davon) übergeben und prüfen, ob der Inhalt des Datentyps mit der Klasse kompatibel ist.

Hier ein Beispiel wie sich so etwas implementieren ließe:

public class MyGenericType<T> {

	private Class<?> typpruefung;
	
	private T content;
	
	public MyGenericType(Class<?> myType, T initValue) {
		this.typpruefung = myType;
		set(initValue);
	}
	
	public void set(T newValue) {
		if (!typpruefung.isInstance(newValue)) {
			throw new IllegalArgumentException("type check: value has incompatible type.");
		}
		content = newValue;
	}
	
	public T get() {
		return content;
	}
	
	public boolean hasType(Class<?> type) {
		return type.isAssignableFrom(typpruefung);
	}
	
}

public class CheckedType {

	public static void main(String[] args) {
		
		MyGenericType<Integer> container = new MyGenericType<Integer>(Integer.class, 15);
		
		MyGenericType<Double> containerDouble = (MyGenericType<Double>) (MyGenericType<?>) container;
		
		//containerDouble.set(12.2); // Throws a type check exception

		if (containerDouble.hasType(Double.class)) {
			// False, since not Integer extends Double
			System.out.println("Container contains object of type Double");
		}
		if (containerDouble.hasType(Number.class)) {
			// True, since Integer extends Number
			System.out.println("Container contains object of type Number");
		}
	}
}
Kategorie: Java
Schlagworte: