Interfaces Genéricas en Java
Aprende a diseñar interfaces genéricas en Java que parametrizan sus firmas de métodos sobre un tipo.
Una interfaz genérica es una interfaz cuya declaración toma uno o más parámetros de tipo, igual que una clase genérica. Es el tercer lugar donde pueden vivir los parámetros de tipo en Java, junto con las clases genéricas y los métodos genéricos, y es el más importante — porque casi todo contrato reutilizable en la biblioteca estándar es una interfaz genérica. List<E>, Map<K, V>, Comparator<T>, Function<T, R>, Supplier<T>, Iterable<T>, Iterator<T> — son la columna vertebral del Java moderno.
La sintaxis
La lista de parámetros de tipo se ubica entre el nombre de la interfaz y el cuerpo:
public interface Container<T> {
void add(T item);
T get(int index);
int size();
}Léelo como "un Container parametrizado sobre algún tipo de elemento T." Dentro de la interfaz, T puede aparecer en los parámetros de los métodos, en los tipos de retorno y en cualquier otra posición donde pueda ir un tipo. Los métodos por defecto (Java 8+) y los métodos privados de interfaz (Java 9+) también pueden usar T.
Cuando implementas la interfaz, tienes que tomar una decisión — y esa decisión es toda la decisión arquitectónica:
// 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(); }
}Ambas son válidas. La primera es "un Container que contiene Strings, específicamente." La segunda es "un Container parametrizado sobre el mismo E que el llamador elige." La mayoría de los contenedores reutilizables son la segunda forma; los especializados (un JsonObject es un "contenedor de JsonValues, nada más") son la primera.
Múltiples parámetros de tipo
La forma se generaliza directamente a dos o más parámetros. Mira 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 declaración Map<K, V> dice "dos parámetros: K para las claves, V para los valores." Las implementaciones los fijan o los pasan a través:
public class StringIntMap implements Map<String, Integer> { ... } // pinned
public class HashMap<K, V> implements Map<K, V> { ... } // passed throughEl get(Object key) en la firma de Map es una decisión deliberada de diseño de API — acepta cualquier objeto como clave de búsqueda por razones históricas. Volveremos a eso en la parte de Colecciones; no es una regla de los genéricos, sino un compromiso específico de Map.
Las interfaces funcionales son interfaces genéricas
Las interfaces en java.util.function — Function, Predicate, Consumer, Supplier, BiFunction, y demás — son todas interfaces genéricas con un único método abstracto, lo que las convierte en objetivos para las lambdas:
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);
}Cuando escribes s -> s.length(), el compilador infiere un Function<String, Integer> del contexto. Los dos parámetros de tipo de Function<T, R> se rellenan con el código circundante — generalmente una operación de stream o un parámetro de método:
List<String> names = List.of("Ada", "Grace", "Linus");
List<Integer> lengths = names.stream()
.map(s -> s.length()) // Function<String, Integer> — both inferred
.toList();Eso es una interfaz genérica y un método genérico (Stream.map) cooperando. La firma del método es aproximadamente <R> Stream<R> map(Function<? super T, ? extends R> mapper) — comodines que veremos en Wildcards, y un parámetro de tipo que elige R basándose en la función que pasaste.
Interfaces autorreferentes — Comparable<T>
Uno de los patrones más útiles en la biblioteca estándar es la interfaz genérica autorreferente, donde el argumento de tipo es la propia clase que la implementa:
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);
}
}Lee class Money implements Comparable<Money> como "Money sabe cómo compararse con otros Money." Esto es lo que hace que Collections.sort(List<Money> list) funcione sin un Comparator — cada elemento ya tiene un compareTo(Money) que heredó del contrato de la interfaz, y el sistema de tipos garantiza que el argumento tenga el mismo tipo que el receptor.
Comparable<T> es el ejemplo canónico de esta forma — cada tipo de valor en el JDK que tiene un orden natural lo implementa: Integer implements Comparable<Integer>, String implements Comparable<String>, LocalDate implements Comparable<LocalDate>, y así sucesivamente.
Herencia de una interfaz genérica
Las mismas tres opciones aparecen para la herencia entre interfaces — extends en lugar de implements, pero las reglas son las mismas:
// 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); }La misma idea que para las clases — el parámetro del padre debe ser suministrado (con un tipo real o uno reenviado), y el hijo puede agregar parámetros propios encima.
Los métodos por defecto pueden usar el parámetro de tipo
Java 8 añadió métodos por defecto a las interfaces. Pueden usar el parámetro de tipo de la interfaz exactamente igual que cualquier método abstracto:
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);
}
}El método por defecto addAll funciona para todo implementador, independientemente del T que hayan elegido. Así es como Collection<E> proporciona forEach, removeIf, stream y similares — un cuerpo por defecto, y cada implementación lo obtiene.
Un ejemplo completo: una interfaz genérica Repository
Una pequeña abstracción de repositorio — interfaz más dos implementaciones. La primera implementación fija el tipo de entidad (UserRepo solo contiene usuarios); la segunda permanece genérica (InMemoryRepo<E> contiene lo que el llamador solicite). Ambas satisfacen el mismo contrato desde el lado del llamador.
InMemoryRepo<E> es la forma reutilizable — el parámetro de tipo se reenvía desde la interfaz a la clase, por lo que el mismo cuerpo funciona para User, String o cualquier otra cosa. UserRepo es la forma especializada — fija E a User y luego añade métodos que solo tienen sentido para usuarios. Ambas respetan el mismo contrato Repository<E>, y ambas heredan isEmpty() de forma gratuita desde el método por defecto.
Qué sigue
Hasta ahora, cada parámetro de tipo ha sido completamente sin restricciones — T podía ser cualquier cosa. En la práctica, a menudo querrás decir "T debe ser un Number" o "T debe implementar Comparable," para poder llamar métodos sobre él dentro del cuerpo. Para eso sirven los parámetros de tipo acotados, y son el siguiente capítulo. Continúa en Parámetros de Tipo Acotados en Java.