Le basi di Java Generics

1. Introduzione

I Java Generics sono stati introdotti in JDK 5.0 con l'obiettivo di ridurre i bug e aggiungere un ulteriore livello di astrazione sui tipi.

Questo articolo è una rapida introduzione a Generics in Java, l'obiettivo dietro di loro e come possono essere utilizzati per migliorare la qualità del nostro codice.

2. La necessità di generici

Immaginiamo uno scenario in cui vogliamo creare un elenco in Java per memorizzare Integer ; possiamo essere tentati di scrivere:

List list = new LinkedList(); list.add(new Integer(1)); Integer i = list.iterator().next(); 

Sorprendentemente, il compilatore si lamenterà dell'ultima riga. Non sa quale tipo di dati viene restituito. Il compilatore richiederà un casting esplicito:

Integer i = (Integer) list.iterator.next();

Non esiste alcun contratto che possa garantire che il tipo restituito dell'elenco sia un numero intero. L'elenco definito potrebbe contenere qualsiasi oggetto. Sappiamo solo che stiamo recuperando un elenco ispezionando il contesto. Quando si esaminano i tipi, può solo garantire che si tratti di un oggetto , quindi richiede un cast esplicito per garantire che il tipo sia sicuro.

Questo cast può essere fastidioso, sappiamo che il tipo di dati in questo elenco è un numero intero . Il cast sta anche ingombrando il nostro codice. Può causare errori di runtime relativi al tipo se un programmatore commette un errore con il casting esplicito.

Sarebbe molto più semplice se i programmatori potessero esprimere la loro intenzione di utilizzare tipi specifici e il compilatore possa garantire la correttezza di tale tipo. Questa è l'idea alla base dei generici.

Modifichiamo la prima riga dello snippet di codice precedente in:

List list = new LinkedList();

Aggiungendo l'operatore rombo contenente il tipo, restringiamo la specializzazione di questa lista solo al tipo Integer, cioè specifichiamo il tipo che sarà contenuto all'interno della lista. Il compilatore può applicare il tipo in fase di compilazione.

In piccoli programmi, questa potrebbe sembrare un'aggiunta banale, tuttavia, in programmi più grandi, questo può aggiungere una notevole robustezza e rende il programma più facile da leggere.

3. Metodi generici

I metodi generici sono quei metodi scritti con una singola dichiarazione di metodo e possono essere chiamati con argomenti di diversi tipi. Il compilatore garantirà la correttezza del tipo utilizzato. Queste sono alcune proprietà dei metodi generici:

  • I metodi generici hanno un parametro di tipo (l'operatore rombo che racchiude il tipo) prima del tipo restituito dalla dichiarazione del metodo
  • I parametri di tipo possono essere limitati (i limiti sono spiegati più avanti nell'articolo)
  • I metodi generici possono avere parametri di tipo diversi separati da virgole nella firma del metodo
  • Il corpo del metodo per un metodo generico è proprio come un metodo normale

Un esempio di definizione di un metodo generico per convertire un array in un elenco:

public  List fromArrayToList(T[] a) { return Arrays.stream(a).collect(Collectors.toList()); }

Nell'esempio precedente, il nella firma del metodo implica che il metodo si occuperà tipo generico T . Ciò è necessario anche se il metodo restituisce void.

Come accennato in precedenza, il metodo può trattare più di un tipo generico, in questo caso, tutti i tipi generici devono essere aggiunti alla firma del metodo, ad esempio, se si desidera modificare il metodo sopra per gestire il tipo T e il tipo G , dovrebbe essere scritto così:

public static  List fromArrayToList(T[] a, Function mapperFunction) { return Arrays.stream(a) .map(mapperFunction) .collect(Collectors.toList()); }

Stiamo passando una funzione che converte un array con gli elementi di tipo T in un elenco con elementi di tipo G. Un esempio potrebbe essere convertire Integer nella sua rappresentazione String :

@Test public void givenArrayOfIntegers_thanListOfStringReturnedOK() { Integer[] intArray = {1, 2, 3, 4, 5}; List stringList = Generics.fromArrayToList(intArray, Object::toString); assertThat(stringList, hasItems("1", "2", "3", "4", "5")); }

Vale la pena notare che Oracle consiglia di utilizzare una lettera maiuscola per rappresentare un tipo generico e di scegliere una lettera più descrittiva per rappresentare i tipi formali, ad esempio nelle collezioni Java T è utilizzato per il tipo, K per la chiave, V per il valore.

3.1. Generici limitati

Come accennato in precedenza, i parametri di tipo possono essere limitati. Limitato significa " limitato ", possiamo limitare i tipi che possono essere accettati da un metodo.

Ad esempio, possiamo specificare che un metodo accetta un tipo e tutte le sue sottoclassi (limite superiore) o un tipo tutte le sue superclassi (limite inferiore).

Per dichiarare un tipo con limite superiore usiamo la parola chiave extends dopo il tipo seguito dal limite superiore che vogliamo usare. Per esempio:

public  List fromArrayToList(T[] a) { ... } 

La parola chiave extends è qui usata per indicare che il tipo T estende il limite superiore nel caso di una classe o implementa un limite superiore nel caso di un'interfaccia.

3.2. Limiti multipli

Un tipo può anche avere più limiti superiori come segue:

Se uno dei tipi estesi da T è una classe (cioè Numero ), deve essere inserito per primo nell'elenco dei limiti. In caso contrario, causerà un errore in fase di compilazione.

4. Utilizzo di caratteri jolly con i generici

I caratteri jolly sono rappresentati dal punto interrogativo in Java “ ? "E sono usati per fare riferimento a un tipo sconosciuto. I caratteri jolly sono particolarmente utili quando si usano generici e possono essere usati come tipo di parametro, ma prima c'è una nota importante da considerare.

È noto che Object è il supertipo di tutte le classi Java, tuttavia, una raccolta di Object non è il supertipo di alcuna raccolta.

Ad esempio, un List non è il supertipo di List e l'assegnazione di una variabile di tipo List a una variabile di tipo List causerà un errore del compilatore. Questo per evitare possibili conflitti che possono verificarsi se aggiungiamo tipi eterogenei alla stessa raccolta.

La stessa regola si applica a qualsiasi raccolta di un tipo e dei suoi sottotipi. Considera questo esempio:

public static void paintAllBuildings(List buildings) { buildings.forEach(Building::paint); }

if we imagine a subtype of Building, for example, a House, we can't use this method with a list of House, even though House is a subtype of Building. If we need to use this method with type Building and all its subtypes, then the bounded wildcard can do the magic:

public static void paintAllBuildings(List buildings) { ... } 

Now, this method will work with type Building and all its subtypes. This is called an upper bounded wildcard where type Building is the upper bound.

Wildcards can also be specified with a lower bound, where the unknown type has to be a supertype of the specified type. Lower bounds can be specified using the super keyword followed by the specific type, for example, means unknown type that is a superclass of T (= T and all its parents).

5. Type Erasure

Generics were added to Java to ensure type safety and to ensure that generics wouldn't cause overhead at runtime, the compiler applies a process called type erasure on generics at compile time.

Type erasure removes all type parameters and replaces it with their bounds or with Object if the type parameter is unbounded. Thus the bytecode after compilation contains only normal classes, interfaces and methods thus ensuring that no new types are produced. Proper casting is applied as well to the Object type at compile time.

This is an example of type erasure:

public  List genericMethod(List list) { return list.stream().collect(Collectors.toList()); } 

With type erasure, the unbounded type T is replaced with Object as follows:

// for illustration public List withErasure(List list) { return list.stream().collect(Collectors.toList()); } // which in practice results in public List withErasure(List list) { return list.stream().collect(Collectors.toList()); } 

If the type is bounded, then the type will be replaced by the bound at compile time:

public  void genericMethod(T t) { ... } 

would change after compilation:

public void genericMethod(Building t) { ... }

6. Generics and Primitive Data Types

A restriction of generics in Java is that the type parameter cannot be a primitive type.

For example, the following doesn't compile:

List list = new ArrayList(); list.add(17);

To understand why primitive data types don't work, let's remember that generics are a compile-time feature, meaning the type parameter is erased and all generic types are implemented as type Object.

As an example, let's look at the add method of a list:

List list = new ArrayList(); list.add(17);

The signature of the add method is:

boolean add(E e);

And will be compiled to:

boolean add(Object e);

Therefore, type parameters must be convertible to Object. Since primitive types don't extend Object, we can't use them as type parameters.

However, Java provides boxed types for primitives, along with autoboxing and unboxing to unwrap them:

Integer a = 17; int b = a; 

So, if we want to create a list which can hold integers, we can use the wrapper:

List list = new ArrayList(); list.add(17); int first = list.get(0); 

The compiled code will be the equivalent of:

List list = new ArrayList(); list.add(Integer.valueOf(17)); int first = ((Integer) list.get(0)).intValue(); 

Future versions of Java might allow primitive data types for generics. Project Valhalla aims at improving the way generics are handled. The idea is to implement generics specialization as described in JEP 218.

7. Conclusion

Java Generics è una potente aggiunta al linguaggio Java in quanto rende il lavoro del programmatore più semplice e meno soggetto a errori. I generici impongono la correttezza del tipo in fase di compilazione e, soprattutto, abilitano l'implementazione di algoritmi generici senza causare alcun sovraccarico aggiuntivo alle nostre applicazioni.

Il codice sorgente che accompagna l'articolo è disponibile su GitHub.