Java Generics: Mehr Schein als Sein ...

von Hubert Schmid vom 2012-06-10

Generische Typen wirken in Java auf den ersten Blick sehr einfach und intuitiv. Bei genauerer Betrachtung offenbaren sich allerdings zahlreiche Kuriositäten. Im Folgenden beschreibe ich einen solchen Fall anhand einer Klasse, die ich vor Kurzem geschrieben habe.

Es geht um eine Map, deren get-Methode im Gegensatz zu java.util.Map ermöglicht, zwischen einem nicht-existierenden Eintrag und einem Eintrag mit null zu unterscheiden. Der minimale, öffentliche Teil der Klasse sieht also ungefähr so aus:

public final class SafeMap<K, V> { public boolean containsKey(K key); public V put(K key, V value); public V get(K key) throws NoSuchKeyException; }

Die Implementierung ist denkbar einfach. Alle Aufrufe werden an eine java.util.HashMap delegiert, und in der get-Methode wird mit containsKey im Falle von null zwischen existierenden und nicht-existierenden Einträgen unterschieden. Im folgenden Code-Fragment sind die wesentlichen Teile enthalten:

public final class SafeMap<K, V> { private final Map<K, V> map = new HashMap<>(); public boolean containsKey(K key) { return map.containsKey(key); } public V put(K key, V value) { return map.put(key, value); } public V get(K key) throws NoSuchKeyException { V value = map.get(key); if (value != null) { return value; } else if (map.containsKey(key)) { return null; } else { throw new NoSuchKeyException("..."); } } }

Der Code ist einfach und für die meisten Anwendungen hinreichend gut. Störend finde ich lediglich die unnötig schlechte Performance der get-Methode. Denn die beiden teuersten Operationen werden im null-Fall doppelt ausgeführt – nämlich die Berechnung des Hash sowie die lineare Suche innerhalb des Hash-Buckets.

Die meisten Implementierungen von Hash-Containern kombinieren aus diesem Grund die Methoden containsKey und get in einer Methode. In Java lässt sich diese Kombination nachbilden, indem man beim Einfügen null durch einen Platzhalter ersetzt, der sich von allen anderen Werten unterscheidet. Beim Auslesen muss der Platzhalter dann wieder zurück transformiert werden. Das hört sich komplizierter an als es tatsächlich ist. Im folgenden Code ist diese Variante bis auf die Implementierung der Methode getPlaceholder zu sehen.

public final class SafeMap<K, V> { private final Map<K, V> map = new HashMap<>(); public boolean containsKey(K key) { return map.containsKey(key); } public V put(K key, V value) { if (value == null) { return map.put(key, getPlaceholder()); } else { return map.put(key, value); } } public V get(K key) throws NoSuchKeyException { V value = map.get(key); if (value == null) { throw new NoSuchKeyException("..."); } else if (value == getPlaceholder()) { return null; } else { return value; } } private V getPlaceholder(); }

Jetzt kommt der eigentlich interessante Teil: Wie kommt man in generischem Code an einen geeigneten Platzhalter vom Typ V, der sich von allen anderen Werten unterscheidet, die der Nutzer der Klasse einfügen kann?

Anstatt lange darauf hinzuführen mache ich gleich einen Vorschlag:

private static final Object PLACEHOLDER = new Object(); @SuppressWarnings("unchecked") private V getPlaceholder() { return (V) PLACEHOLDER; }

Was passiert hier? Anstatt ein eindeutiges Objekt vom Typ V zu erzeugen, erstelle ich ein Objekt vom Typ Object. Die Referenz konvertiere ich auf den Untertyp V und gebe sie so zurück. Schließlich wird das Objekt noch in einer HashMap<K,V> gespeichert.

Ist dieser Code korrekt? Wird beim Konvertieren in eine V-Referenz nicht eine ClassCastException geworfen? Und wieso kann ich das Objekt überhaupt in einer HashMap<K,V> speichern, obwohl der dynamische Typ überhaupt nicht passt?

Der Code ist absolut korrekt. Er wirkt lediglich ein wenig ungewöhnlich, was weniger am Code als vielmehr an den Java-Generics liegt. Denn diese sind nicht immer das wonach sie aussehen.

Ich bin mir nicht sicher, wie ich das in einem Satz beschreiben soll. Doch was passiert in obigen Code denn tatsächlich? Natürlich gibt es in dem obigen Code einen Cast. Dabei handelt es sich aber nicht um einen Cast auf V sondern in Wirklichkeit um einen (trivialen) Cast auf Object. Und bezüglich der Map: Auch hier handelt es sich in Wirklichkeit um eine HashMap<Object,Object> und nicht um eine HashMap<K,V>. Damit gibt es die beiden erwähnten Probleme nicht.

Vielleicht ist es hilfreich die folgende Aussage im Hinterkopf zu behalten: In Java haben Typparameter innerhalb von generischen Klassen und generischen Methoden keinen Einfluss auf das Verhalten – weder zur Übersetzungszeit noch zur Laufzeit.