Java Generic Interfaces
Generische Interfaces in Java entwerfen, die Methodensignaturen über einen Typparameter parametrisieren.
Ein generisches Interface ist ein Interface, dessen Deklaration einen oder mehrere Typparameter enthält – genau wie eine generische Klasse. Es ist der dritte Ort, an dem Typparameter in Java vorkommen können, neben generischen Klassen und generischen Methoden, und er ist der folgenreichste – denn nahezu jeder wiederverwendbare Vertrag in der Standardbibliothek ist ein generisches Interface. List<E>, Map<K, V>, Comparator<T>, Function<T, R>, Supplier<T>, Iterable<T>, Iterator<T> – sie bilden das Rückgrat des modernen Java.
Die Syntax
Die Typparameterliste steht zwischen dem Interfacenamen und dem Rumpf:
public interface Container<T> {
void add(T item);
T get(int index);
int size();
}Lesen Sie dies als „ein Container, der über einen Elementtyp T parametrisiert ist." Innerhalb des Interfaces kann T in Methodenparametern, Rückgabetypen und an jeder anderen Position erscheinen, an der ein Typ stehen kann. Default-Methoden (Java 8+) und private Interface-Methoden (Java 9+) können T ebenfalls verwenden.
Wenn Sie das Interface implementieren, müssen Sie eine Entscheidung treffen – und diese Entscheidung ist die eigentliche Architekturentscheidung:
// 1. Pick a concrete type — the implementation is specialised.
public class StringContainer implements Container<String> {
private final List<String> items = new ArrayList<>();
public void add(String s) { items.add(s); }
public String get(int i) { return items.get(i); }
public int size() { return items.size(); }
}
// 2. Stay generic — pass the parameter through to the class.
public class ListContainer<E> implements Container<E> {
private final List<E> items = new ArrayList<>();
public void add(E e) { items.add(e); }
public E get(int i) { return items.get(i); }
public int size() { return items.size(); }
}Beide sind gültig. Das erste ist „ein Container, der speziell Strings enthält." Das zweite ist „ein Container, der über dasselbe E parametrisiert ist, das der Aufrufer wählt." Die meisten wiederverwendbaren Container haben die zweite Form; spezialisierte (ein JsonObject ist ein „Container von JsonValues, nichts anderes") haben die erste.
Mehrere Typparameter
Das Schema lässt sich direkt auf zwei oder mehr Parameter erweitern. Schauen Sie sich java.util.Map an:
public interface Map<K, V> {
V put(K key, V value);
V get(Object key); // Object on purpose — see below
Set<K> keySet();
Collection<V> values();
...
}Die Deklaration Map<K, V> besagt „zwei Parameter: K für Schlüssel, V für Werte." Implementierungen legen sie fest oder leiten sie weiter:
public class StringIntMap implements Map<String, Integer> { ... } // pinned
public class HashMap<K, V> implements Map<K, V> { ... } // passed throughget(Object key) in Maps Signatur ist ein bewusstes API-Design – es akzeptiert aus historischen Gründen jedes Objekt als Suchschlüssel. Darauf kommen wir im Teil Collections zurück; das ist keine Generics-Regel, sondern ein Map-spezifischer Kompromiss.
Funktionale Interfaces sind generische Interfaces
Die Interfaces in java.util.function – Function, Predicate, Consumer, Supplier, BiFunction usw. – sind allesamt generische Interfaces mit einer einzelnen abstrakten Methode, was sie zu Zielen für Lambdas macht:
public interface Function<T, R> {
R apply(T t);
}
public interface Predicate<T> {
boolean test(T t);
}
public interface Comparator<T> {
int compare(T a, T b);
}Wenn Sie s -> s.length() schreiben, leitet der Compiler aus dem Kontext eine Function<String, Integer> ab. Die beiden Typparameter von Function<T, R> werden durch den umgebenden Code befüllt – üblicherweise eine Stream-Operation oder ein Methodenparameter:
List<String> names = List.of("Ada", "Grace", "Linus");
List<Integer> lengths = names.stream()
.map(s -> s.length()) // Function<String, Integer> — both inferred
.toList();Das ist ein generisches Interface und eine generische Methode (Stream.map), die zusammenarbeiten. Die Signatur der Methode lautet in etwa <R> Stream<R> map(Function<? super T, ? extends R> mapper) – Wildcards, denen wir in Wildcards begegnen werden, und ein Typparameter, der R anhand der übergebenen Funktion bestimmt.
Selbstreferenzielle Interfaces – Comparable<T>
Eines der nützlichsten Muster in der Standardbibliothek ist das selbstreferenzielle generische Interface, bei dem das Typargument die implementierende Klasse selbst ist:
public interface Comparable<T> {
int compareTo(T other);
}
public class Money implements Comparable<Money> {
private final long cents;
// ...
@Override public int compareTo(Money other) {
return Long.compare(this.cents, other.cents);
}
}Lesen Sie class Money implements Comparable<Money> als „Money weiß, wie es sich mit anderen Moneys vergleicht." Das ermöglicht Collections.sort(List<Money> list) ohne einen Comparator – jedes Element trägt bereits ein compareTo(Money), das es vom Interface-Vertrag geerbt hat, und das Typsystem stellt sicher, dass das Argument denselben Typ wie der Empfänger hat.
Comparable<T> ist das kanonische Beispiel dieses Musters – jeder Werttyp im JDK mit einer natürlichen Ordnung implementiert es: Integer implements Comparable<Integer>, String implements Comparable<String>, LocalDate implements Comparable<LocalDate> usw.
Von einem generischen Interface erben
Dieselben drei Wahlmöglichkeiten gibt es auch bei der Interface-zu-Interface-Vererbung – extends statt implements, aber die Regeln sind dieselben:
// Pin the parameter.
public interface StringList extends List<String> { ... }
// Pass it through.
public interface MyList<E> extends List<E> { ... }
// Add new ones.
public interface IndexedList<E, I> extends List<E> { I indexOf(E e); }Gleiche Idee wie bei Klassen – der Parameter des Eltern-Interfaces muss angegeben werden (mit einem konkreten Typ oder einem weitergeleiteten), und das Kind kann obendrauf eigene Parameter hinzufügen.
Default-Methoden können den Typparameter verwenden
Java 8 hat Default-Methoden zu Interfaces hinzugefügt. Sie können den Typparameter des Interfaces genau so verwenden wie jede abstrakte Methode:
public interface Container<T> {
void add(T item);
T get(int index);
int size();
default boolean isEmpty() { return size() == 0; }
default void addAll(Iterable<T> items) {
for (T item : items) add(item);
}
}Die Default-Methode addAll funktioniert für jeden Implementierer, unabhängig davon, welches T er gewählt hat. So stellt Collection<E> forEach, removeIf, stream und weitere bereit – ein einziger Default-Rumpf, den jede Implementierung erhält.
Ein durchgearbeitetes Beispiel: ein generisches Repository-Interface
Eine kleine Repository-Abstraktion – Interface plus zwei Implementierungen. Die erste Implementierung legt den Entitätstyp fest (UserRepo enthält nur Benutzer); die zweite bleibt generisch (InMemoryRepo<E> enthält, was der Aufrufer wünscht). Beide erfüllen denselben Vertrag aus Sicht des Aufrufers.
InMemoryRepo<E> ist die wiederverwendbare Form – der Typparameter wird vom Interface an die Klasse weitergeleitet, sodass derselbe Rumpf für User, String oder alles andere funktioniert. UserRepo ist die spezialisierte Form – sie legt E auf User fest und fügt dann Methoden hinzu, die nur für Benutzer sinnvoll sind. Beide erfüllen denselben Repository<E>-Vertrag und erben isEmpty() kostenlos von der Default-Methode.
Was kommt als Nächstes
Bisher war jeder Typparameter vollständig uneingeschränkt – T konnte alles sein. In der Praxis möchten Sie oft sagen „T muss eine Number sein" oder „T muss Comparable implementieren", damit Sie im Rumpf tatsächlich Methoden darauf aufrufen können. Dafür sind begrenzte Typparameter zuständig, und sie sind das nächste Kapitel. Weiter zu Java Bounded Type Parameters.