Genèrics
- Classes genèriques
- Interfícies genèriques
- Mètodes genèrics
- Paràmetres acotats
- Comodins
- Esborrat de tipus
- Referències
Aquesta llista compila sense cap queixa:
List llista = new ArrayList();
llista.add("hola");
llista.add(42); // ningú no s'hi oposa
String s = (String) llista.get(1); // ClassCastException en execució
El compilador no hi pot fer res, perquè per a ell la llista conté Object. L’error només apareix quan el programa ja s’està executant, potser davant de l’usuari, i el cast que l’ha provocat pot ser lluny del add() que va introduir el problema.
Si diem de quin tipus són els elements, l’error es detecta en compilar i el cast desapareix:
List<String> llista = new ArrayList<>();
llista.add("hola");
llista.add(42); // error de compilació
String s = llista.get(0); // sense cast
Els genèrics (generics) permeten que un tipus sigui un paràmetre. En declarar la variable decidim quin tipus real ocupa aquest paràmetre, i a partir d’aquí el compilador ho comprova tot per nosaltres. Aquests són els dos guanys: errors en temps de compilació en lloc de temps d’execució, i codi sense casts, que és més curt i més fàcil de llegir.
Classes genèriques
Una classe genèrica es declara amb un o més paràmetres de tipus entre angles, just després del nom:
public class Caixa<T> {
private T contingut;
public void desa(T contingut) {
this.contingut = contingut;
}
public T recupera() {
return contingut;
}
}
T no és cap tipus concret: és un buit que s’omple en fer servir la classe. Cada ús decideix el seu:
Caixa<String> caixaText = new Caixa<>();
caixaText.desa("hola");
String text = caixaText.recupera(); // el compilador ja sap que és String
Caixa<Integer> caixaNum = new Caixa<>();
caixaNum.desa(42);
Hi ha una convenció de noms per als paràmetres de tipus que trobaràs a tota la biblioteca estàndard:
| Nom | Significat |
|---|---|
E | Element, a les col·leccions |
K, V | Clau (key) i valor (value), als mapes |
T | Un tipus qualsevol |
N | Un número |
S, U | Segon i tercer tipus, quan en calen més |
El diamant
En construir l’objecte no cal repetir el tipus: amb els angles buits (<>, l’operador diamant) el compilador el dedueix de la declaració.
Map<String, List<Integer>> notes = new HashMap<>(); // millor que new HashMap<String, List<Integer>>()
Interfícies genèriques
Una interfície també pot tenir paràmetres de tipus, i qui la implementa tria si els manté oberts o els fixa.
interface Contenidor<T> {
T getValor();
}
La implementació genèrica deixa el paràmetre obert, i qui la faci servir decidirà el tipus:
public class ContenidorSimple<T> implements Contenidor<T> {
private final T valor;
public ContenidorSimple(T valor) {
this.valor = valor;
}
@Override
public T getValor() {
return valor;
}
}
La implementació concreta el fixa, i ja no és negociable:
public class ContenidorLong implements Contenidor<Long> {
private final Long valor;
public ContenidorLong(Long valor) {
this.valor = valor;
}
@Override
public Long getValor() {
return valor;
}
}
Des de fora, totes dues s’usen igual, a través de la interfície:
Contenidor<String> c1 = new ContenidorSimple<>("test");
System.out.println(c1.getValor().toUpperCase());
Contenidor<Long> c2 = new ContenidorLong(12L);
System.out.println(c2.getValor() * 2);
Mètodes genèrics
Un mètode pot tenir el seu propi paràmetre de tipus, encara que la classe no en tingui. Es declara entre angles abans del tipus de retorn:
public static <E> void mostraArray(E[] elements) {
for (E element : elements) {
System.out.printf("%s ", element);
}
System.out.println();
}
En cridar-lo no cal indicar el tipus, perquè el compilador l’infereix dels arguments:
Integer[] nums = { 1, 2, 3 };
String[] noms = { "Aina", "Bernat" };
mostraArray(nums); // E és Integer
mostraArray(noms); // E és String
Paràmetres acotats
Dins d’un mètode genèric, el compilador no sap res de T més enllà que és un objecte. Per tant, això no compila:
public static <T> T maxim(T a, T b) {
return a.compareTo(b) > 0 ? a : b; // error: Object no té compareTo()
}
Cal acotar el paràmetre (bounded type parameter) amb extends, dient quin mínim ha de complir el tipus:
public static <T extends Comparable<T>> T maxim(T a, T b) {
return a.compareTo(b) > 0 ? a : b;
}
Ara el compilador sap que qualsevol T té compareTo(), i alhora rebutja les crides amb tipus que no siguin comparables. La cota serveix per a dues coses a la vegada: permet fer servir mètodes del tipus acotat i restringeix qui pot cridar el mètode.
Es pot exigir més d’una cosa alhora amb &, encara que és poc habitual:
<T extends Serializable & Comparable<T>>
Compte: aquí extends vol dir “és un subtipus de”, tant si es tracta d’una classe com d’una interfície.
Comodins
Aquest mètode sembla que hauria de servir per a qualsevol llista de números:
public static double suma(List<Number> llista) {
double total = 0;
for (Number n : llista) {
total += n.doubleValue();
}
return total;
}
suma(new ArrayList<Integer>()); // error de compilació
No funciona, i la raó és important: encara que Integer sigui un subtipus de Number, List<Integer> no és un subtipus de List<Number>. Els genèrics són invariants. Té sentit, perquè si ho fossin podríem passar una List<Integer> a un mètode que hi afegís un Double.
La solució és el comodí (wildcard) ?, que representa un tipus desconegut:
public static double suma(List<? extends Number> llista) { ... }
Ara List<Integer>, List<Double> i List<Number> són totes acceptables. Hi ha tres formes:
| Forma | Significat | Què hi pots fer |
|---|---|---|
? extends Tipus | Un subtipus desconegut de Tipus | Llegir elements com a Tipus. No hi pots afegir res |
? super Tipus | Un supertipus desconegut de Tipus | Afegir elements de tipus Tipus. En llegir només obtens Object |
? | Qualsevol tipus | Poc més que recórrer i comptar |
La restricció de ? extends sorprèn al principi. Si el paràmetre és una List<? extends Number>, el compilador no sap si per sota hi ha una List<Integer> o una List<Double>, i per tant no pot deixar-te afegir cap número concret sense arriscar-se a corrompre-la.
D’aquí surt la regla que es coneix com a PECS, producer extends, consumer super:
- Si el paràmetre només produeix dades que llegiràs, fes servir
? extends. - Si el paràmetre només consumeix dades que hi posaràs, fes servir
? super. - Si fa les dues coses, no hi posis comodí.
Esborrat de tipus
Els genèrics només existeixen durant la compilació. Un cop comprovat que tot encaixa, el compilador els esborra (type erasure) i genera bytecode amb Object i els casts necessaris. En execució, una List<String> i una List<Integer> són la mateixa cosa: una List.
Això explica uns quants errors que d’entrada semblen arbitraris:
if (obj instanceof List<String>) { } // error: en execució no es pot distingir
T[] array = new T[10]; // error: no es pot instanciar un tipus esborrat
void processa(List<String> l) { } // error: totes dues signatures
void processa(List<Integer> l) { } // acaben sent processa(List)
També explica per què els genèrics no admeten tipus primitius: List<int> no és vàlid, perquè el que queda després de l’esborrat ha de ser un Object. Cal fer servir les classes embolcall (Integer, Double, Boolean), amb l’autoboxing convertint automàticament en els dos sentits.