Generics en Java: wildcard ? extends y ? super
1. Descripción general
Los Generics ayudan a que tu código Java sea seguro de tipos en tiempo de compilación. Pero cuando escribes un método que recibe List<Product>, verás que no acepta List<Book>, aunque Book sea una subclase de Product. Este artículo explica por qué es así. Luego, te enseña cómo usar los wildcard ? extends y ? super para escribir APIs más flexibles. Después de leerlo, sabrás elegir qué wildcard usar según el principio PECS y evitarás errores de compilación confusos.
2. Problema: los Generics son invariantes
Usaremos una jerarquía de clases simple para todo el artículo:
public class Product {
private final String name;
private final double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
public String name() { return name; }
public double price() { return price; }
}
public class Book extends Product {
public Book(String name, double price) {
super(name, price);
}
}
Book es un Product. Entonces, ¿List<Book> es un List<Product>?
List<Book> books = new ArrayList<>();
List<Product> products = books; // Error de compilación: tipos incompatibles
La respuesta es no. Los Generics en Java son invariantes. Con A siendo subclase de B, List<A> y List<B> siguen siendo dos tipos no relacionados.
La razón está en la seguridad de tipos. Supongamos que la línea anterior compilara, podrías escribir:
products.add(new Product("Bolígrafo", 5_000)); // añadir Product a una lista "que solo contiene libros"
Book first = books.get(0); // ¡ClassCastException!
Los arrays son diferentes. Los arrays en Java son covariantes, así que errores similares solo se descubren en tiempo de ejecución:
Object[] items = new String[2];
items[0] = 42; // compila OK, en tiempo de ejecución lanza ArrayStoreException
Los Generics eligen bloquear el error en tiempo de compilación. El precio a pagar es menos flexibilidad. Los wildcard fueron creados para recuperar esa flexibilidad.
3. Wildcard con límite superior: ? extends T
3.1. Leer datos de una colección
Supongamos que necesitas calcular el precio total de una lista de productos. La primera versión generalmente se ve así:
static double totalPrice(List<Product> products) { ... }
Este método no acepta List<Book>. Lo arreglamos con ? extends:
public final class CatalogUtils {
public static double totalPrice(List<? extends Product> products) {
double total = 0;
for (Product p : products) {
total += p.price();
}
return total;
}
}
List<? extends Product> significa “una lista que contiene elementos de algún tipo que es Product o una subclase de Product”. Gracias a esto:
- Puedes pasar
List<Product>,List<Book>, oList<Ebook>si existe. - Cada elemento leído es definitivamente un
Product, así que el buclefor (Product p : products)es válido.
List<Book> books = List.of(
new Book("Clean Code", 300_000),
new Book("Effective Java", 450_000));
System.out.println(CatalogUtils.totalPrice(books));
750000.0
3.2. ¿Por qué no puedo añadir elementos?
Intenta añadir un elemento a una lista con ? extends:
static void addBook(List<? extends Product> products) {
products.add(new Book("Refactoring", 400_000)); // Error de compilación
products.add(null); // OK, pero inútil
}
El compilador no sabe qué es realmente ?. La lista pasada podría ser List<Ebook>. Añadir un Book normal la arruinaría con respecto a la seguridad de tipos. Por eso el compilador prohíbe todas las operaciones add, excepto null.
La regla fácil de recordar: ? extends T es solo para leer T. La colección juega el papel de productor, es decir, un lugar que te proporciona datos.
4. Wildcard con límite inferior: ? super T
Ahora hazlo al revés. Quieres un método que cargue libros predeterminados en cualquier lista que pueda contener Book:
public static void addDefaultBooks(List<? super Book> target) {
target.add(new Book("Clean Code", 300_000));
target.add(new Book("Effective Java", 450_000));
}
List<? super Book> significa “una lista cuyo tipo de elemento es Book o una superclase de Book”. Cualquier tipo así puede contener un Book, así que add(new Book(...)) siempre es seguro.
List<Book> books = new ArrayList<>();
List<Product> products = new ArrayList<>();
List<Object> anything = new ArrayList<>();
CatalogUtils.addDefaultBooks(books); // OK
CatalogUtils.addDefaultBooks(products); // OK
CatalogUtils.addDefaultBooks(anything); // OK
System.out.println(products.size());
2
A cambio, cuando lees, solo puedes estar seguro de que el elemento es Object:
static void printFirst(List<? super Book> target) {
Object first = target.get(0); // OK
Book book = target.get(0); // Error de compilación
}
La lista podría ser List<Object> llena de lo que sea, así que el compilador solo puede garantizar el tipo Object.
La regla: ? super T es para escribir T dentro. La colección es un consumidor, es decir, un lugar que consume los datos que le proporcionas.
5. El principio PECS
Las dos reglas anteriores se combinan en PECS: Producer Extends, Consumer Super.
- Un parámetro que proporciona
Tusa? extends T. - Un parámetro que recibe
Tde ti usa? super T. - Un parámetro que tanto lee como escribe no usa wildcard; declara el tipo exacto
T.
5.1. Ejemplo: copiar entre dos listas
public static <T> void copy(List<? extends T> source, List<? super T> target) {
for (T item : source) {
target.add(item);
}
}
source es productor así que usa extends. target es consumidor así que usa super. El resultado es un método muy flexible:
List<Book> books = List.of(new Book("DDD", 500_000));
List<Product> cart = new ArrayList<>();
CatalogUtils.<Book>copy(books, cart); // T = Book: lee Book, escribe en lista superclase
CatalogUtils.<Product>copy(books, cart); // T = Product también es válido
El JDK aplica este principio correctamente. La firma de Collections.copy es:
public static <T> void copy(List<? super T> dest, List<? extends T> src)
5.2. Ejemplo: Comparator reutilizable para la clase superclase
List.sort tiene la firma sort(Comparator<? super E> c). Comparator es consumidor porque recibe elementos para compararlos. Gracias a ? super, puedes usar un comparador escrito para Product para ordenar List<Book>:
Comparator<Product> byPrice = Comparator.comparingDouble(Product::price);
List<Book> books = new ArrayList<>(List.of(
new Book("Effective Java", 450_000),
new Book("Clean Code", 300_000)));
books.sort(byPrice);
books.forEach(b -> System.out.println(b.name()));
Clean Code
Effective Java
Encontrarás este patrón muy a menudo en Stream API. Por ejemplo map(Function<? super T, ? extends R> mapper): la entrada de la función es consumidor (super), la salida es productor (extends).
6. Wildcard sin límites: ?
Cuando un método no le importa el tipo de elemento, usa ?:
public static int countNonNull(List<?> items) {
int count = 0;
for (Object item : items) {
if (item != null) count++;
}
return count;
}
List<?> acepta cualquier lista: List<Book>, List<String>, List<Integer>. Los elementos leídos son Object, y no puedes añadir nada excepto null.
No confundas List<?> con List<Object>. Un método que recibe List<Object> no acepta List<String>, también por la invariancia de la sección 2.
7. Pruebas del comportamiento
Añade JUnit 5 y AssertJ a tu pom.xml:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Si tu proyecto usa Spring Boot 3.x, spring-boot-starter-test ya incluye ambas librerías.
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CatalogUtilsTest {
@Test
void totalPrice_acceptsListOfSubtype() {
List<Book> books = List.of(
new Book("Clean Code", 300_000),
new Book("Effective Java", 450_000));
assertThat(CatalogUtils.totalPrice(books)).isEqualTo(750_000.0);
}
@Test
void addDefaultBooks_worksWithSupertypeList() {
List<Object> anything = new ArrayList<>();
anything.add("no es un libro");
CatalogUtils.addDefaultBooks(anything);
assertThat(anything).hasSize(3);
assertThat(anything.get(1)).isInstanceOf(Book.class);
}
@Test
void copy_movesBooksIntoProductList() {
List<Book> books = List.of(new Book("DDD", 500_000));
List<Product> cart = new ArrayList<>();
CatalogUtils.copy(books, cart);
assertThat(cart).extracting(Product::name).containsExactly("DDD");
}
@Test
void addDefaultBooks_failsAtRuntimeOnImmutableList() {
List<Product> immutable = List.of();
assertThatThrownBy(() -> CatalogUtils.addDefaultBooks(immutable))
.isInstanceOf(UnsupportedOperationException.class);
}
}
Los primeros tres tests confirman que los métodos aceptan tipos subtipo y supertipo como se espera. El segundo test muestra que List<Object> mantiene su elemento anterior y acepta añadir Book. El test final te recuerda algo importante: los wildcard solo verifican el tipo en tiempo de compilación. No saben si la lista permite modificaciones.
8. Errores comunes y cuándo no usar
Usar wildcard en el tipo de retorno.
public static List<? extends Product> findAll() { ... } // no hagas esto
Quien llama recibirá una lista prácticamente solo lectura y tendrá que cargar con ? en todas partes. Los wildcard deben estar en los parámetros del método, no en el tipo de retorno.
Usar ? extends e intentar llamar a add. Si el método necesita escribir en la colección, extends es la opción equivocada. Repasa PECS.
Usar wildcard cuando necesitas leer y escribir con el tipo exacto. Por ejemplo, intercambiar dos elementos:
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
private static <T> void swapHelper(List<T> list, int i, int j) {
T tmp = list.get(i);
list.set(i, list.get(j));
list.set(j, tmp);
}
Llamar list.set(i, list.get(j)) directamente en List<?> causará error de compilación. El helper genérico <T> “captura” el tipo real de ? para poder operar. Esta técnica se llama wildcard capture. Si la API pública no requiere ser List<?>, declarar directamente <T> void swap(List<T> list, ...) será más simple.
Abusar de los wildcard en clases internas. Los wildcard son más útiles en APIs compartidas como librerías o utilidades. En métodos privados llamados desde un único lugar, el tipo concreto suele ser más fácil de leer.
Nota:
? extendsno hace la lista inmutable. El método aún puede llamarclear()oremove(...), porque estas operaciones no necesitan saber el tipo de elemento. Si necesitas proteger los datos, usaList.copyOf(...)oCollections.unmodifiableList(...).
Nota: No puedes usar
extendsysuperen el mismo wildcard. Tampoco puedes escribir? superen una declaración de parámetro de tipo en una clase o método (<T super Book>no es válido).
9. Conclusión
Los Generics en Java son invariantes. Por eso List<Book> no es un List<Product>, y esto ayuda a evitar errores de tipo en tiempo de ejecución. Los wildcard devuelven la flexibilidad a tu API:
? extends Tse usa en parámetros que proporcionan datos. Puedes leerTpero no puedes añadir.? super Tse usa en parámetros que reciben datos. Puedes añadirTpero solo leesObject.?se usa cuando no te importa el tipo de elemento.
Cuando dudes, solo recuerda PECS: Producer Extends, Consumer Super. Además, mantén los wildcard en los parámetros del método, no en el tipo de retorno.