Apuntes · Capítulo 10Programar con tipos

Genéricos: clases, métodos, wildcards y type erasure

Genéricos: clases, métodos, wildcards y type erasure

En la semana 4 usamos ArrayList<Song> sin preguntarnos mucho qué significa ese <Song>. Sabíamos que la lista solo aceptaba canciones y que get devolvía una Song sin casting. En la semana 9 vimos que un error que el compilador detecta es mucho más barato que una excepción que aparece frente al usuario. Este capítulo junta las dos ideas: los genéricos (generics) son la forma en que Java deja escribir una clase o un método una sola vez para muchos tipos, sin perder la verificación del compilador.

La primera parte cuenta el problema que resuelven, cómo se escriben clases y métodos genéricos y qué pasa con los tipos primitivos. La segunda responde preguntas que aparecen apenas se usan en serio: por qué una List<Integer> no se puede pasar donde se pide una List<Number>, para qué sirven los wildcards ? extends y ? super, y qué es el type erasure, la decisión de diseño que explica casi todas las restricciones de los genéricos en Java.

Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9), subsecciones 10.1.1 a 10.1.3 (la programación genérica y cómo la hace Java), 10.1.7 (Generics and Wrapper Classes) y la sección 10.5 Writing Generic Classes and Methods (10.5.1 a 10.5.4). Como complemento, la serie Generics de dev.java. El código del capítulo está en code/week-10 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): El problema: una caja de Object y la introducción de Clases genéricas.
  • En clase: en la sesión A, Varios parámetros: Pair<A, B>, ArrayList<E> por dentro, Métodos genéricos, Genéricos y tipos primitivos y Raw types: el error que todavía compila; en la sesión B, Invarianza, Bounded types, Wildcards con La regla PECS y Type erasure. En el laboratorio, El laboratorio: una colección genérica propia.
  • Después de clase: Lo que el type erasure no permite, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.

El problema: una caja de Object

Supongamos que queremos una clase que guarde un valor, el que sea: un nombre, un número, un producto. Antes de que existieran los genéricos (Java 5, 2004) la única forma de escribirla una sola vez era usar Object, el supertipo de todas las clases, como en ObjectBox.java:

public class ObjectBox {
    private Object content;

    public void put(Object content) {
        this.content = content;
    }

    public Object get() {
        return content;
    }
}

Funciona con cualquier cosa, y ese es justamente el problema. RawProblem.java la usa:

ObjectBox box = new ObjectBox();
box.put("Lima");                       // compila: un String es un Object
String city = (String) box.get();      // hay que hacer casting al sacar
System.out.println("ciudad: " + city);

box.put(42);                           // también compila: nadie lo impide
String other = (String) box.get();     // el error aparece aquí, en ejecución
$ java -cp build RawProblem
ciudad: Lima
ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (...)

Hay dos defectos. El primero es de comodidad: cada get necesita un casting, porque el compilador solo sabe que sale un Object. El segundo es grave: el compilador no puede saber qué hay dentro de la caja, así que acepta box.put(42) aunque el programa espera textos. El error se descubre recién al ejecutar, y no en la línea equivocada (put(42)) sino más tarde, en el get, que puede estar en otro archivo. Es la ClassCastException de la semana 6.

Clases genéricas

Una clase genérica declara uno o más type parameters entre < y > después de su nombre: nombres que representan un tipo que todavía no se conoce. Dentro de la clase, el type parameter se usa como cualquier otro tipo. Box.java:

public class Box<T> {
    private T content;          // null si la caja está vacía

    public void put(T content) {
        this.content = content;
    }

    public T get() {
        return content;
    }

    public boolean isEmpty() {
        return content == null;
    }
}

T no es una clase: es un nombre que se reemplaza por un tipo concreto cada vez que se usa la clase. Box<String> es una caja cuyo put recibe un String y cuyo get devuelve un String; Box<Integer>, una de enteros. Lo que va entre <> al usar la clase se llama type argument.

Box<String> city = new Box<>();     // diamante: T = String se infiere
city.put("Lima");
String s = city.get();              // sin casting

Y el error de antes ahora no compila (el archivo está en errores/WrongType.java; make errores compila cada ejemplo de esa carpeta y comprueba que falle):

$ javac -Xlint:all -cp build errores/WrongType.java
errores/WrongType.java:5: error: incompatible types: int cannot be converted to String
        city.put(42);
                 ^
1 error
Arriba, con ObjectBox, el código box.put(42) seguido de String s = (String) box.get() compila sin problemas y falla al ejecutar con ClassCastException. Abajo, con Box<String>, la misma línea box.put(42) es un error de compilación, y el programa no llega a ejecutarseArriba, con ObjectBox, el código box.put(42) seguido de String s = (String) box.get() compila sin problemas y falla al ejecutar con ClassCastException. Abajo, con Box<String>, la misma línea box.put(42) es un error de compilación, y el programa no llega a ejecutarse
Con genéricos, el mismo error se descubre al compilar, en la línea donde se comete, y no al ejecutar.

Idea clave. Un type parameter le dice al compilador qué hay dentro. Con esa información puede rechazar lo que no corresponde al meterlo y evitar el casting al sacarlo. El código se escribe una sola vez y sirve para todos los tipos.

Varios parámetros: Pair<A, B>

Una clase puede tener varios type parameters. Los records también pueden ser genéricos. Pair.java es un par inmutable de dos valores de tipos posiblemente distintos:

public record Pair<A, B>(A first, B second) {

    /** Devuelve un par nuevo con los componentes intercambiados. */
    public Pair<B, A> swap() {
        return new Pair<>(second, first);
    }
}
Pair<String, Integer> stock = new Pair<>("arroz", 40);
System.out.println(stock + " -> " + stock.swap());
Pair[first=arroz, second=40] -> Pair[first=40, second=arroz]

Fíjense en el tipo de retorno de swap: Pair<B, A>. El compilador sabe que stock.swap().first() es un Integer y no un String, sin que nadie lo escriba.

ArrayList<E> por dentro

Ahora podemos leer la documentación de ArrayList de otra forma. La clase se declara ArrayList<E> (E de element), y sus métodos usan E: add(E e), E get(int index), E remove(int index). Cuando en la semana 4 escribimos ArrayList<Song>, todos esos E pasaron a ser Song. No hay nada mágico en las colecciones de la biblioteca: son clases genéricas como Box.

En Java. Los nombres de los type parameters son, por convención, una sola letra mayúscula, para distinguirlos a simple vista de las clases: T (type) para un tipo cualquiera, E (element) para el elemento de una colección, K y V (key, value) para las claves y valores de un mapa, y A, B o S, U cuando hay varios.

Métodos genéricos

A veces no hace falta una clase genérica entera, sino un solo método que funcione para cualquier tipo. Un método genérico declara sus propios type parameters antes del tipo de retorno. ListUtils.java:

/** El primer elemento de la lista, o fallback si está vacía. */
public static <T> T firstOrDefault(List<T> list, T fallback) {
    return list.isEmpty() ? fallback : list.get(0);
}

List es la interfaz que implementa ArrayList; por ahora basta con saber que un ArrayList<String> se puede pasar donde se pide un List<String> (las interfaces de colecciones son el tema de la semana 11). El <T> antes de T dice “este método funciona para cualquier tipo T”, y la firma dice que lo que devuelve es del mismo tipo que los elementos de la lista y que el valor por defecto.

Al llamarlo, no hace falta decir cuánto vale T: el compilador lo deduce de los argumentos. Eso se llama inferencia de tipos (type inference).

List<String> names = List.of("alice", "bob");
List<String> empty = new ArrayList<>();
System.out.println(ListUtils.firstOrDefault(names, "nadie"));   // alice
System.out.println(ListUtils.firstOrDefault(empty, "nadie"));   // nadie

La misma inferencia permite el operador diamante <>: en Box<String> city = new Box<>(); el compilador completa new Box<String>() mirando el tipo de la variable.

Error común. Poner el <T> en el lugar equivocado. En public static T firstOrDefault(...) sin <T>, el compilador busca una clase llamada T, no la encuentra y dice cannot find symbol. Los type parameters de un método van entre los modificadores y el tipo de retorno: public static <T> T firstOrDefault(...).

Genéricos y tipos primitivos

Un type argument tiene que ser un tipo de referencia. Box<int> y ArrayList<int> no existen. Para guardar números se usa la clase wrapper correspondiente, que guarda el primitivo dentro de un objeto: Integer, Long, Double, Boolean, Character… Java convierte automáticamente entre el primitivo y su wrapper: de int a Integer (boxing) y de Integer a int (unboxing).

Box<Integer> count = new Box<>();
count.put(7);                       // boxing: 7 -> Integer.valueOf(7)
int n = count.get() + 1;            // unboxing: Integer -> int

Esa conversión es cómoda pero no es gratis: cada Integer es un objeto en el heap, con su referencia y su cabecera, donde un int ocupa 4 bytes. Una ArrayList<Integer> de un millón de números usa varias veces más memoria que un int[] del mismo tamaño. Para colecciones grandes de números, un arreglo de primitivos sigue siendo la mejor opción.

Error común. Comparar dos Integer con ==. Son objetos, así que == compara referencias (semana 2), no valores. Para que sea más confuso, Java guarda en caché los Integer de −128 a 127 (es lo mínimo que garantiza; una JVM puede guardar más), así que con números pequeños parece funcionar. BoxDemo.java:

Integer a = 127, b = 127, c = 1000, d = 1000;
System.out.println("127 == 127: " + (a == b) + ", 1000 == 1000: " + (c == d)
        + ", equals: " + c.equals(d));
127 == 127: true, 1000 == 1000: false, equals: true

Con wrappers, igual que con cualquier objeto, se compara con equals.

Raw types: el error que todavía compila

Por compatibilidad con el código anterior a Java 5, todavía se puede escribir una clase genérica sin type argument: ArrayList a secas. Eso se llama raw type, y devuelve exactamente el problema de ObjectBox. errores/RawList.java:

ArrayList names = new ArrayList();
names.add("alice");
names.add(42);
String first = (String) names.get(1);

Sin opciones, javac solo deja una nota que es fácil pasar por alto:

$ javac -d build errores/RawList.java
Note: errores/RawList.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
$ java -cp build RawList
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (...)
	at RawList.main(RawList.java:9)

Con -Xlint:all, la opción que usamos en todo el curso, aparecen las advertencias que explican el problema, y con -Werror la compilación se detiene:

$ javac -Xlint:all -Werror -d build errores/RawList.java
errores/RawList.java:6: warning: [rawtypes] found raw type: ArrayList
        ArrayList names = new ArrayList();
        ^
  missing type arguments for generic class ArrayList<E>
...
errores/RawList.java:7: warning: [unchecked] unchecked call to add(E) as a member of the raw type ArrayList
        names.add("alice");
                 ^
...
error: warnings found and -Werror specified
1 error
4 warnings

Error común. Usar raw types “porque compila”. Una advertencia unchecked significa que el compilador ya no puede garantizar que los tipos sean correctos, y que una ClassCastException puede aparecer en una línea que parece inocente. Siempre con type argument: ArrayList<String>, o new ArrayList<>() con diamante.

Invarianza: List<Integer> no es una List<Number>

Integer es subtipo de Number (también lo son Double y Long). Por eso uno esperaría que una List<Integer> se pudiera usar donde se pide una List<Number>. No se puede (errores/Invariance.java):

static double total(List<Number> numbers) { ... }

List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
List<Number> nums = ints;
System.out.println(total(ints));
errores/Invariance.java:16: error: incompatible types: List<Integer> cannot be converted to List<Number>
        List<Number> nums = ints;
                            ^
errores/Invariance.java:17: error: incompatible types: List<Integer> cannot be converted to List<Number>
        System.out.println(total(ints));
                                 ^
2 errors

La razón se ve imaginando que sí se pudiera. Si nums y ints fueran la misma lista vista con dos tipos, nums.add(2.5) sería válido (un Double es un Number), y la lista de enteros terminaría con un decimal adentro. El próximo int x = ints.get(3) fallaría en ejecución. Para que eso no pueda pasar, Java hace a los genéricos invariantes: List<A> y List<B> no tienen ninguna relación de subtipo, aunque A y B sí la tengan.

En Java. Los arreglos se diseñaron al revés, y se nota. Integer[] sí es subtipo de Number[] (los arreglos son covariantes), así que el error del párrafo anterior compila y se detecta recién al ejecutar. ErasureDemo.java:

Integer[] ints = {1, 2, 3};
Number[] nums = ints;               // compila
nums[0] = 2.5;                      // un Double en un arreglo de Integer
ArrayStoreException: java.lang.Double

Cada arreglo recuerda en ejecución el tipo de sus elementos y la JVM revisa cada asignación. Con las listas, el compilador no deja llegar hasta ahí.

Bounded types

Un método que busca el mayor elemento de una lista no puede funcionar para cualquier T: necesita poder comparar los elementos. La semana 7 presentó Comparable<T>, la interfaz con compareTo. Un bounded type exige que T la implemente:

/** El mayor elemento según su orden natural. La lista no puede estar vacía. */
public static <T extends Comparable<T>> T max(List<T> list) {
    if (list.isEmpty()) {
        throw new IllegalArgumentException("lista vacía");
    }
    T best = list.get(0);
    for (T x : list) {
        if (x.compareTo(best) > 0) {
            best = x;
        }
    }
    return best;
}

<T extends Comparable<T>> se lee “para cualquier tipo T que se pueda comparar con otro T”. Aquí extends sirve tanto para clases como para interfaces. Dentro del método, como el compilador sabe que T tiene compareTo, la llamada x.compareTo(best) compila. Afuera, el compilador revisa que el argumento cumpla el bound (la restricción extends): max acepta una lista de Integer o de String, que implementan Comparable, pero no una lista de Box.

List<Integer> marks = List.of(15, 18, 11);
List<String> words = List.of("pera", "uva", "mango");
System.out.println("max: " + ListUtils.max(marks) + ", " + ListUtils.max(words));
max: 18, uva

(Las palabras se comparan en orden alfabético, y "uva" va después de "pera" y de "mango".) Y como el resultado es un T, ListUtils.max(marks) devuelve un Integer, no un Comparable al que habría que hacer casting.

Wildcards

El parámetro de total(List<Number>) era demasiado estricto: solo acepta exactamente List<Number>. Lo que queremos decir es “una lista de Number o de algún subtipo de Number”. Eso es un wildcard:

/** Suma una lista de cualquier tipo numérico: Integer, Double, Long... */
public static double sum(List<? extends Number> numbers) {
    double total = 0;
    for (Number n : numbers) {          // leer: sale algo que es un Number
        total += n.doubleValue();
    }
    return total;
}

List<? extends Number> acepta List<Number>, List<Integer>, List<Double>… De una lista así se puede leer: lo que salga, sea lo que sea, es un Number. Pero no se puede escribir en ella, porque el compilador no sabe de qué subtipo es la lista que llegó: podría ser una List<Double> y no aceptar un Integer (errores/ExtendsReadOnly.java):

errores/ExtendsReadOnly.java:7: error: incompatible types: int cannot be converted to CAP#1
        numbers.add(1);
                    ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
1 error

CAP#1 es el nombre que el compilador le pone a “el tipo desconocido que llegó”. El mensaje dice: no sé qué tipo es, solo que extiende Number, así que no puedo garantizar que un int quepa.

El caso contrario es ? super. Un método que escribe enteros en una lista necesita una lista que acepte Integer: una List<Integer>, pero también una List<Number> o una List<Object>.

/** Agrega 1, 2, ..., n a una lista que acepte Integer. */
public static void addCounting(List<? super Integer> out, int n) {
    for (int i = 1; i <= n; i++) {
        out.add(i);                     // escribir: entra un Integer
    }
}

En una List<? super Integer> se puede escribir un Integer, pero al leer solo se sabe que sale un Object.

A la izquierda, Integer es subtipo de Number. Al centro, List<Integer> y List<Number> no tienen relación: una línea punteada tachada. A la derecha, List<Number> y List<Integer> son ambos subtipos de List<? extends Number>, que sirve para leer, y de List<? super Integer>, que sirve para escribirA la izquierda, Integer es subtipo de Number. Al centro, List<Integer> y List<Number> no tienen relación: una línea punteada tachada. A la derecha, List<Number> y List<Integer> son ambos subtipos de List<? extends Number>, que sirve para leer, y de List<? super Integer>, que sirve para escribir
Las listas no heredan la relación de sus elementos. Los wildcards la recuperan: ? extends para leer, ? super para escribir.

La regla PECS

Las dos formas se juntan en un método que copia de una lista a otra:

/** Copia src al final de dst. src produce T; dst consume T (PECS). */
public static <T> void copy(List<? super T> dst, List<? extends T> src) {
    for (T x : src) {
        dst.add(x);
    }
}

La regla para elegir se resume en PECS: producer extends, consumer super. Si de un parámetro se sacan valores de tipo T (es un productor), va con ? extends T. Si se meten valores de tipo T (es un consumidor), va con ? super T. Si se hacen las dos cosas, sin wildcard: List<T>.

WildcardDemo.java copia las notas, una List<Integer>, al final de una List<Object> que ya tenía un texto:

List<Object> all = new ArrayList<>(List.of("inicio"));
ListUtils.copy(all, marks);
System.out.println("all: " + all);
all: [inicio, 15, 18, 11]

El compilador infiere T = Integer: marks es una List<Integer>, que cumple ? extends Integer, y all es una List<Object>, que cumple ? super Integer. Sin wildcards, con copy(List<T> dst, List<T> src), esta llamada no compilaría: no hay un único T que sea a la vez Integer y Object.

Animación: arriba, la firma de copy y la llamada ListUtils.copy(all, marks) con T = Integer. A la izquierda, src = marks, una List<Integer> con 15, 18 y 11, marcada como productor (? extends T). A la derecha, dst = all, una List<Object> con "inicio" y tres casillas vacías, marcada como consumidor (? super T). Cada número sale de src y entra en la siguiente casilla de dstAnimación: arriba, la firma de copy y la llamada ListUtils.copy(all, marks) con T = Integer. A la izquierda, src = marks, una List<Integer> con 15, 18 y 11, marcada como productor (? extends T). A la derecha, dst = all, una List<Object> con "inicio" y tres casillas vacías, marcada como consumidor (? super T). Cada número sale de src y entra en la siguiente casilla de dst
PECS: de src solo se lee (productor, ? extends T) y en dst solo se escribe (consumidor, ? super T).

Error común. Llenar una API de wildcards “por si acaso”. Los wildcards hacen las firmas más difíciles de leer. Empiecen con tipos simples (List<T>), y agreguen ? extends o ? super cuando una llamada real, o una prueba, muestre que hace falta aceptar listas de subtipos o de supertipos. Y nunca en el tipo de retorno: un método que devuelve List<? extends Number> obliga a quien lo llama a lidiar con el wildcard.

Type erasure

¿Cómo implementa la JVM los genéricos? La respuesta sorprende: casi todo el trabajo lo hace el compilador. javac revisa todos los tipos usando los type parameters, y después los borra: en el bytecode, Box<String> y Box<Integer> son la misma clase Box, cuyo campo y cuyos métodos usan Object (o el bound, si el type parameter tiene uno). Eso es el type erasure. Un objeto Box no sabe con qué type argument se creó: en ejecución, los type arguments no existen como tipos.

Se ve en ejecución (ErasureDemo.java):

Box<String> a = new Box<>();
Box<Integer> b = new Box<>();
System.out.println(a.getClass() + " == " + b.getClass() + ": "
        + (a.getClass() == b.getClass()));
class Box == class Box: true

Y se ve en el bytecode. Si desensamblamos Box con javap -c, get devuelve un Object; y en BoxDemo, donde se usa city.get() como String, el compilador agregó una instrucción checkcast: el casting que con ObjectBox escribíamos a mano.

$ javap -c -cp build Box
...
  public T get();
    Code:
         0: aload_0
         1: getfield      #7                  // Field content:Ljava/lang/Object;
         4: areturn
$ javap -c -cp build BoxDemo
...
        14: aload_1
        15: invokevirtual #16                 // Method Box.get:()Ljava/lang/Object;
        18: checkcast     #20                 // class java/lang/String
        21: astore_2
...

En max, el type parameter tiene bound, y T se borra a ese bound: javap -s muestra que su descriptor es (Ljava/util/List;)Ljava/lang/Comparable;, es decir, recibe una List y devuelve un Comparable.

Animación: arriba a la izquierda, Box<String> con String get() y Box<Integer> con Integer get(). javac borra T, y las dos se juntan en una sola clase Box con Object get(). Abajo, la línea String s = a.get(); se compila a aload_1, invokevirtual Box.get, que devuelve Object, y checkcast StringAnimación: arriba a la izquierda, Box<String> con String get() y Box<Integer> con Integer get(). javac borra T, y las dos se juntan en una sola clase Box con Object get(). Abajo, la línea String s = a.get(); se compila a aload_1, invokevirtual Box.get, que devuelve Object, y checkcast String
El compilador verifica con T, lo borra y agrega el casting donde se usa el valor. En el bytecode solo queda una clase Box.

¿Por qué Java lo hizo así? Por compatibilidad: cuando llegaron los genéricos, en 2004, había millones de líneas de código y bibliotecas compiladas que usaban ArrayList sin parámetros. Con el type erasure, el bytecode de una ArrayList<String> es el mismo de siempre, y el código viejo y el nuevo pueden mezclarse. (Otros lenguajes, como C#, eligieron lo contrario y guardan el type argument en ejecución.)

Idea clave. Los genéricos de Java son una verificación en compilación. En ejecución hay una sola clase, con Object, y castings que el compilador agregó. En código sin warnings de unchecked, esos castings no fallan.

En Java. Dos precisiones. Primero, T no desaparece del todo: el .class guarda la declaración genérica como metadato (por eso javap muestra public T get()), y la reflexión la puede leer. Lo que no existe es el type argument de cada objeto. Segundo, la garantía de los castings vale si todo el código respeta los tipos. Un raw type, un cast unchecked o una biblioteca que los use pueden meter un Integer en una List<String> (eso se llama heap pollution), y entonces el checkcast sí lanza ClassCastException, lejos de donde estuvo el error.

Lo que el type erasure no permite

Como en ejecución T no existe, todo lo que necesitaría saber cuánto vale T mientras el programa corre está prohibido. Los cuatro casos típicos están en errores/ErasureLimits.java:

public class ErasureLimits<T> {
    T make() {
        return new T();                     // ¿qué constructor llamar?
    }

    T[] array(int n) {
        return new T[n];                    // ¿de qué tipo es el arreglo?
    }

    boolean isStrings(Object o) {
        return o instanceof List<String>;   // en ejecución solo hay List
    }

    void print(List<String> list) { }

    void print(List<Integer> list) { }      // los dos se borran a print(List)
}
errores/ErasureLimits.java:19: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
    void print(List<Integer> list) { }
         ^
errores/ErasureLimits.java:6: error: unexpected type
        return new T();
                   ^
  required: class
  found:    type parameter T
...
errores/ErasureLimits.java:10: error: generic array creation
        return new T[n];
               ^
errores/ErasureLimits.java:14: error: Object cannot be safely cast to List<String>
        return o instanceof List<String>;
               ^
4 errors
  • new T(): la JVM tendría que saber qué clase construir, y en ejecución T es Object.
  • new T[n]: un arreglo recuerda el tipo de sus elementos (por eso existe ArrayStoreException), y T no se conoce. Es la restricción que más se nota al escribir una colección propia: el arreglo interno se declara como Object[] y se hace casting a T al sacar, o se crea un Object[] y se convierte a T[] una sola vez, en el constructor. En ambos casos el compilador da una advertencia unchecked, que se silencia con @SuppressWarnings("unchecked") solo en ese método, y solo después de convencerse de que únicamente entran objetos de tipo T. Es lo que harán en el laboratorio.
  • instanceof List<String>: en ejecución hay una List, sin saber de qué. Sí se puede preguntar o instanceof List<?>.
  • Overloading que solo cambia el type argument: print(List<String>) y print(List<Integer>) quedarían las dos como print(List) en el bytecode. Hay que darles nombres distintos.

El laboratorio: una colección genérica propia

En el laboratorio escriben BoundedStack<E>, un stack con capacidad máxima, guardado en un arreglo. En code/week-10 está el esqueleto, con los métodos y su contrato. Al escribirlo aparecen todos los temas del capítulo:

  • el type parameter E en la clase y en sus métodos;
  • el arreglo interno y el new E[n] que no compila;
  • un invariante (0 <= size <= capacity) y las excepciones de la semana 9 (IllegalStateException al sacar de un stack vacío o al hacer push en uno lleno);
  • pruebas JUnit con dos tipos de elemento distintos;
  • un método con wildcard, pushAll(List<? extends E> items), que tiene que aceptar una lista de un subtipo de E;
  • y, con javap -c, el checkcast que agregó el compilador donde se usa lo que devuelve pop.

El código de la semana y make test

$ cd code/week-10
$ make test
...
== RawProblem
ciudad: Lima
ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (...)
== BoxDemo
caja: Lima, vacía: false
Pair[first=arroz, second=40] -> Pair[first=40, second=arroz]
alice
nadie
n = 8
127 == 127: true, 1000 == 1000: false, equals: true
== WildcardDemo
max: 18, uva
sum: 44.0, 6.5
numbers: [1, 2, 3, 0.5]
all: [inicio, 15, 18, 11]
== ErasureDemo
class Box == class Box: true
ArrayStoreException: java.lang.Double
list: [1, 2, 3]
...
[        13 tests successful      ]
[         0 tests failed          ]

Las pruebas están en BoxTest.java, PairTest.java y ListUtilsTest.java. make errores compila uno por uno los archivos de errores/ y verifica que ninguno compile con -Xlint:all -Werror: es la forma de dejar escritos ejemplos de lo que el compilador rechaza.

Resumen

  • Una colección de Object acepta cualquier cosa y obliga a hacer casting al sacar; el error de tipo aparece en ejecución (ClassCastException).
  • Una clase genérica (Box<T>, Pair<A, B>) declara type parameters; al usarla, cada uso fija los type arguments, y el compilador rechaza lo que no corresponde.
  • Un método genérico declara sus parámetros antes del tipo de retorno (static <T> T firstOrDefault(...)). El compilador infiere T; el diamante <> usa la misma inferencia.
  • No hay genéricos de primitivos: se usan los wrappers, con boxing y unboxing automáticos, que cuestan memoria. Los Integer se comparan con equals.
  • Los raw types desactivan la verificación; -Xlint:all los delata.
  • Los genéricos son invariantes: List<Integer> no es List<Number>. Los arreglos son covariantes y lo pagan con ArrayStoreException.
  • Un bounded type (<T extends Comparable<T>>) permite usar los métodos del bound dentro del método genérico.
  • Wildcards: ? extends T para leer (productor), ? super T para escribir (consumidor): PECS.
  • Type erasure: en el bytecode T se reemplaza por Object (o por su bound) y el compilador inserta checkcast. Por eso no hay new T(), ni new T[n], ni instanceof List<String>, ni overloading que solo cambie el type argument.

Ejercicios

  1. Escriban una clase ObjectStack que guarde Object en un ArrayList y un programa que meta un String y luego un Integer, y saque el primero como String. Luego cámbienla a Stack<T>. ¿En qué línea aparece el error en cada versión, y en qué momento (al compilar o al ejecutar)?

  2. Agreguen a Pair<A, B> un método withFirst que reciba un nuevo primer componente, posiblemente de otro tipo, y devuelva un par nuevo. Escriban su firma completa antes de implementarlo. ¿Por qué el método necesita su propio type parameter?

  3. En jshell, evalúen Integer x = 100, y = 100; y luego x == y. Repitan con 200. Después evalúen Long.valueOf(5).equals(5). Expliquen cada resultado.

  4. ¿Cuáles de estas líneas compilan? Para las que no, digan qué error da el compilador y por qué. Verifíquenlo después.

    List<Number> a = new ArrayList<Integer>();
    List<? extends Number> b = new ArrayList<Integer>();
    b.add(3);
    Number n = b.get(0);
    List<? super Integer> c = new ArrayList<Number>();
    c.add(3);
    Integer i = c.get(0);
  5. Escriban un método genérico static <T> void swap(T[] a, int i, int j) que intercambie dos elementos de un arreglo, y sus pruebas con un String[] y un Integer[]. ¿Se puede llamar con un int[]? ¿Por qué?

  6. Escriban static <T extends Comparable<T>> T min(List<T> list) y sus pruebas, incluida la de una lista vacía. Luego intenten llamarlo con una List<Box<String>>. Copien el mensaje del compilador y expliquen qué dice.

  7. Escriban un método countGreater(List<? extends Number> list, double limit) que cuente cuántos elementos son mayores que limit. Pruébenlo con una List<Integer> y una List<Double>. ¿Qué pasa si quitan el wildcard?

  8. Para cada uno de estos métodos, digan si el parámetro es productor, consumidor o ambos, y escriban su firma con los wildcards adecuados: (a) imprimir todos los elementos de una lista; (b) agregar a una lista los números pares hasta n; (c) ordenar una lista en su lugar; (d) pasar todos los elementos de un stack a una lista.

  9. Compilen RawProblem.java y BoxDemo.java y desensamblen ambos con javap -c. Comparen las instrucciones que siguen a cada get. ¿Qué diferencia hay entre el casting de uno y el del otro, en el bytecode y en quién garantiza que no falle?

  10. ¿Por qué Java no permite new T[n] pero sí permite new ArrayList<T>()? Piensen en qué tiene que saber la JVM en ejecución en cada caso.

  11. Un compañero escribe void add(Box<String> b) y void add(Box<Integer> b) en la misma clase, para tratar cada caja de forma distinta. Expliquen por qué no compila y propongan dos formas de resolverlo.

  12. Lean la subsección 10.5.3 de Eck sobre wildcards. Escriban con sus palabras la diferencia entre List<?>, List<Object> y el raw type List. ¿Qué se puede meter en cada una y qué sale al leer?