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>, o List<Ebook> si existe.
  • Cada elemento leído es definitivamente un Product, así que el bucle for (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 T usa ? extends T.
  • Un parámetro que recibe T de 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: ? extends no hace la lista inmutable. El método aún puede llamar clear() o remove(...), porque estas operaciones no necesitan saber el tipo de elemento. Si necesitas proteger los datos, usa List.copyOf(...) o Collections.unmodifiableList(...).

Nota: No puedes usar extends y super en el mismo wildcard. Tampoco puedes escribir ? super en 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 T se usa en parámetros que proporcionan datos. Puedes leer T pero no puedes añadir.
  • ? super T se usa en parámetros que reciben datos. Puedes añadir T pero solo lees Object.
  • ? 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.

javagenericswildcardpecscollections