Interfacce Generiche in Java
Impara a progettare interfacce generiche in Java che parametrizzano le firme dei metodi su un tipo.
Un'interfaccia generica è un'interfaccia la cui dichiarazione accetta uno o più parametri di tipo, proprio come una classe generica. È il terzo posto in cui i parametri di tipo possono vivere in Java, insieme alle classi generiche e ai metodi generici, ed è quello più significativo — perché quasi ogni contratto riutilizzabile nella libreria standard è un'interfaccia generica. List<E>, Map<K, V>, Comparator<T>, Function<T, R>, Supplier<T>, Iterable<T>, Iterator<T> — sono la spina dorsale del Java moderno.
La sintassi
La lista dei parametri di tipo si trova tra il nome dell'interfaccia e il corpo:
public interface Container<T> {
void add(T item);
T get(int index);
int size();
}Leggilo come "un Container parametrizzato su un certo tipo di elemento T." All'interno dell'interfaccia, T può comparire nei parametri dei metodi, nei tipi di ritorno e in qualsiasi altra posizione in cui può andare un tipo. I metodi default (Java 8+) e i metodi privati di interfaccia (Java 9+) possono usare T anche loro.
Quando implementi l'interfaccia, devi fare una scelta — e la scelta è l'intera decisione architetturale:
// 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(); }
}Entrambi sono validi. Il primo è "un Container che contiene String, specificamente." Il secondo è "un Container parametrizzato sullo stesso E che sceglie il chiamante." La maggior parte dei container riutilizzabili usa la seconda forma; quelli specializzati (un JsonObject è un "container di JsonValues, nient'altro") usano la prima.
Parametri di tipo multipli
La struttura si generalizza direttamente a due o più parametri. Guarda java.util.Map:
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();
...
}La dichiarazione Map<K, V> dice "due parametri: K per le chiavi, V per i valori." Le implementazioni li fissano o li passano attraverso:
public class StringIntMap implements Map<String, Integer> { ... } // pinned
public class HashMap<K, V> implements Map<K, V> { ... } // passed throughIl get(Object key) nella firma di Map è una scelta deliberata di design dell'API — accetta qualsiasi object come chiave di ricerca per ragioni storiche. Torneremo su questo nella parte Collections; non è una regola dei generics, solo un compromesso specifico di Map.
Le interfacce funzionali sono interfacce generiche
Le interfacce in java.util.function — Function, Predicate, Consumer, Supplier, BiFunction, e così via — sono tutte interfacce generiche con un unico metodo astratto, il che le rende obiettivi per le lambda:
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);
}Quando scrivi s -> s.length(), il compilatore deduce un Function<String, Integer> dal contesto. I due parametri di tipo di Function<T, R> vengono riempiti dal codice circostante — di solito un'operazione su stream o un parametro di metodo:
List<String> names = List.of("Ada", "Grace", "Linus");
List<Integer> lengths = names.stream()
.map(s -> s.length()) // Function<String, Integer> — both inferred
.toList();Questo è un'interfaccia generica e un metodo generico (Stream.map) che cooperano. La firma del metodo è approssimativamente <R> Stream<R> map(Function<? super T, ? extends R> mapper) — wildcard che incontreremo in Wildcards, e un parametro di tipo che seleziona R in base alla funzione passata.
Interfacce auto-referenziali — Comparable<T>
Uno dei pattern più utili nella libreria standard è l'interfaccia generica auto-referenziale, in cui l'argomento di tipo è la stessa classe che implementa l'interfaccia:
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);
}
}Leggi class Money implements Comparable<Money> come "Money sa come confrontarsi con altri Money." Questo è ciò che fa funzionare Collections.sort(List<Money> list) senza un Comparator — ogni elemento porta già un compareTo(Money) ereditato dal contratto dell'interfaccia, e il sistema di tipi assicura che l'argomento abbia lo stesso tipo del ricevitore.
Comparable<T> è l'esempio canonico di questa struttura — ogni tipo di valore nel JDK che ha un ordine naturale lo implementa: Integer implements Comparable<Integer>, String implements Comparable<String>, LocalDate implements Comparable<LocalDate>, e così via.
Ereditare da un'interfaccia generica
Le stesse tre scelte compaiono per l'ereditarietà interfaccia-interfaccia — extends invece di implements, ma le regole sono le stesse:
// 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); }Stessa idea che per le classi — il parametro del genitore deve essere fornito (con un tipo reale o uno inoltrato), e il figlio può aggiungere i propri parametri in aggiunta.
I metodi default possono usare il parametro di tipo
Java 8 ha aggiunto i metodi default alle interfacce. Possono usare il parametro di tipo dell'interfaccia esattamente come qualsiasi metodo astratto:
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);
}
}Il metodo default addAll funziona per ogni implementatore, indipendentemente da quale T ha scelto. È così che Collection<E> fornisce forEach, removeIf, stream e simili — un unico corpo default, ogni implementazione lo ottiene.
Un esempio pratico: un'interfaccia Repository generica
Una piccola astrazione repository — interfaccia e due implementazioni. La prima implementazione fissa il tipo di entità (UserRepo contiene solo utenti); la seconda resta generica (InMemoryRepo<E> contiene qualsiasi cosa il chiamante richieda). Entrambe soddisfano lo stesso contratto dal lato del chiamante.
InMemoryRepo<E> è la forma riutilizzabile — il parametro di tipo viene inoltrato dall'interfaccia alla classe, quindi lo stesso corpo funziona per User, String o qualsiasi altra cosa. UserRepo è la forma specializzata — fissa E a User e poi aggiunge metodi che hanno senso solo per gli utenti. Entrambi rispettano lo stesso contratto Repository<E>, e entrambi ereditano isEmpty() gratuitamente dal metodo default.
Cosa c'è dopo
Finora ogni parametro di tipo è stato completamente illimitato — T poteva essere qualsiasi cosa. In pratica spesso vuoi dire "T deve essere un Number" o "T deve implementare Comparable," in modo da poter chiamare effettivamente dei metodi su di esso nel corpo. È a questo che servono i parametri di tipo limitati, e sono il capitolo successivo. Continua con Java Bounded Type Parameters.