Sun 24 August 2014

Generic Heterogenous Containers Using Super Type Tokens

Wie man am Titel bereits leicht erraten kann: heute geht es (zum ersten Mal in diesem Blog?) um Java. Genauer: Wie man heterogene Container in Java umsetzen kann.

Zunächst zur Idee: Wir wollen eine Klasse Favorites implementieren, in die man seine Lieblingsobjekte speichern und auch wieder abrufen kann. Damit man nicht einfach irgendein beliebiges Objekt zurückbekommt, sondern ein Objekt von einem bestimmten Typ, übergibt man beim Speichern und abrufen die Klasse des Objekts.

Die API dafür sieht so aus:

public class Favorites {
    public <T> void putFavorite(Class<T> type, T instance);
    public <T> getFavorite(Class<T> type);
}

Note

Der geneigte Java-erfahrene Leser wird direkt erkennen, dass das Beispiel aus Effective Java von Joshua Bloch ist. Das ist absolut richtig und auch Teile des hier gezeigten Codes stammen aus diesem Buch, das durchaus lesenswert ist (auch für Nicht-Java-Programmierer).

Die erste Idee, das zu implementieren, könnte etwa so aussehen:

public class Favorites {
    private final Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void putFavorite(Class<T> type, T instance) {
        favorites.put(checkNotNull(type), instance);
    }

    public <T> T getFavorite(Class<T> type) {
        return type.cast(favorites.get(type));
    }
}

Man nimmt also einfach das Klassenobjekt des Werts als Schlüssel in einer Map, um den Wert zu speichern. Beim Auslesen wird dann auch wieder das Klassenobjekt übergeben, womit man an den Wert kommt. Wurde für den Typ kein Wert hinterlegt, wird einfach nichts gefunden.

An sich scheint das auch ganz gut zu funktionieren:

Favorites f = new Favorites();
f.putFavorite(String.class, "Some string");
f.putFavorite(Integer.class, 1234);
System.out.printf("%s %d%n", f.getFavorite(String.class), f.getFavorite(Integer.class));

Auch der Fall, dass kein Wert hinterlegt wurde für den Typ, funktioniert einfach: favorites.get(type) gibt dann null zurück und null kann zu allem gecastet werden.

Und hätte Java jetzt nicht Type Erasure, wäre der Blogpost auch schon zu Ende. Da Java allerdings Type Erasure hat, stößt man recht bald auf ein Problem, wenn man auf versucht, ein Generic in den Container zu packen: List<Integer>.class ist ein Syntaxfehler. Das liegt daran, dass List<Integer> und List<String> dasselbe Klassenobjekt haben, nämlich List.class. Das bedeutet aber auch, dass man eine Liste von Strings in Favorites packen kann und danach als Liste von Integern auslesen kann (bzw. man kann es zumindest versuchen):

f.putFavorite(List.class, Arrays.asList("foo", "bar"));
List<Integer> values = f.getFavorite(List.class);

Wenn man das jetzt ausführt, scheint es sogar zu funktionieren. Jedenfalls läuft es anstandslos durch. Allerdings nur bis man dann den Wert tatsächlich einmal versucht als den angegebenen Wert zu verwenden:

Integer first = values.get(0);

Bei einer erneuten Ausführung wird jetzt eine Ausnahme geworfen:

Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer

Bei genauem Hinschauen erkennt man auch bereits beim Kompilieren, dass hier potenziell zur Laufzeit etwas kaputt gehen könnte:

Favorites.java: warning: [unchecked] unchecked conversion
      List<Integer> values = f.getFavorite(List.class);
                                          ^
required: List<Integer>
found:    List

Ein gutes Beispiel dafür, dass man Compiler-Warnungen nicht einfach ignorieren sollte.

Das ist natürlich wenig zufriedenstellend. Ein Ausweg daraus sind die sogenannten Super Type Tokens, die man wie folgt verwenden kann:

TypeReference<List<String>> x = new TypeReference<List<String>>() {};

TypeReference kann jetzt zur Laufzeit nachsehen, welcher Wert als Typvariable übergeben wurde. Das wiederum kann dann Favorite für sich benutzen.

Die Implementierung von TypeReference:

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

public abstract class TypeReference<T> {
    private final Type type;

    protected TypeReference() {
        Type superClass = getClass().getGenericSuperclass();
        if (superClass instanceof Class<?>) {
            throw new RuntimeException("Missing type parameter");
        }

        type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
    }

    public Type getType() {
        return type;
    }
}

Und das angepasste Favorites:

import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Favorites {
    private final Map<Type, Object> favorites = new HashMap<>();

    public <T> void putFavorite(TypeReference<T> typeReference, T instance) {
        favorites.put(typeReference.getType(), instance);
    }

    @SuppressWarnings("unchecked")
    public <T> T getFavorite(TypeReference<T> typeReference) {
        return (T) favorites.get(typeReference.getType());
    }

    public static void main(String[] args) {
        Favorites f = new Favorites();
        f.putFavorite(new TypeReference<String>() {}, "Some string");
        f.putFavorite(new TypeReference<Integer>() {}, 1234);
        f.putFavorite(new TypeReference<List<String>>() {}, Arrays.asList("foo", "bar"));
        System.out.printf("%s %d %s%n",
                          f.getFavorite(new TypeReference<String>() {}),
                          f.getFavorite(new TypeReference<Integer>() {}),
                          f.getFavorite(new TypeReference<List<Integer>>() {}));
    }
}

Die Ausgabe ist wie erwartet Some string 1234 null. Fertig ist er also, unser typsicherer heterogener Container, der auch mit Generics funktioniert (wenn auch etwas umständlich).

Oder fast. Wenn da dieses @SuppressWarnings("unchecked") nicht wäre. Immerhin haben wir vor einem Augenblick erst gesehen, dass man Compiler-Warnungen nicht ignorieren sollte. Und tatsächlich kann man auch für die neue Favorites einen Fall konstruieren, bei dem zur Laufzeit eine Ausnahme fliegt:

static <T> List<T> favoriteList(Favorites f) {
    TypeReference<List<T>> typeReference = new TypeReference<List<T>>(){};
    List<T> result = f.getFavorite(typeReference);
    if (result == null) {
        result = new ArrayList<T>();
        f.putFavorite(typeReference, result);
    }
    return result;
}

public static void main(String[] args) {
    Favorites f = new Favorites();
    List<String> listOfStrings = favoriteList(f);
    List<Integer> listOfIntegers = favoriteList(f);
    listOfIntegers.add(42);
    String booooom = listOfStrings.get(0);
}

// java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

Um diese Ausnahme zu vermeiden, müsste man die Typ-Argumente durchgehen und schauen, ob noch irgendwo eine TypeVariable vorkommt. Ein Beispiel, in dem das gemacht wird, ist GenericType<T> aus JAX-RS (Quelltext). Das würde aber noch immer zur Laufzeit eine Exception werfen und nicht zur Compile-Zeit einen Fehler produzieren.