Programmazione Generica in Java

La programmazione generica rappresenta una delle innovazioni più significative introdotte in Java 5, permettendo di scrivere codice type-safe e riutilizzabile attraverso l’uso di type parameters. Questo meccanismo elimina la necessità di cast espliciti, riduce gli errori runtime e migliora la leggibilità del codice, fornendo garanzie di type safety a compile-time.
Concetti Fondamentali
I generics permettono di parametrizzare classi, interfacce e metodi con tipi, creando codice che può lavorare con diversi tipi mantenendo la type safety. Il compilatore utilizza queste informazioni per verificare la correttezza dei tipi e generare cast automatici dove necessario.
Motivazione e Vantaggi
Prima dei generics, le collezioni Java utilizzavano Object come tipo comune, richiedendo cast espliciti e aprendo la possibilità a ClassCastException runtime. I generics risolvono questi problemi fornendo:
Type Safety a Compile-Time: Il compilatore verifica che i tipi siano utilizzati correttamente, eliminando molti errori runtime.
Eliminazione dei Cast: Non è più necessario fare cast espliciti quando si estraggono elementi da collezioni generiche.
Migliore Documentazione: Il codice diventa autodocumentante riguardo ai tipi che accetta e restituisce.
Performance Migliorate: Eliminazione dell’overhead dei cast runtime e possibili ottimizzazioni del compilatore.
// Prima dei generics - problematico
List lista = new ArrayList();
lista.add("stringa");
lista.add(42); // Nessun errore di compilazione!
String s = (String) lista.get(0); // Cast necessario
String s2 = (String) lista.get(1); // ClassCastException runtime!
// Con i generics - type-safe
List<String> listaGenerica = new ArrayList<>();
listaGenerica.add("stringa");
// listaGenerica.add(42); // Errore di compilazione
String s = listaGenerica.get(0); // Nessun cast necessario
Sintassi e Dichiarazioni
Type Parameters per Classi
I type parameters sono dichiarati tra parentesi angolari e possono essere utilizzati come tipi all’interno della classe. Le convenzioni di naming prevedono lettere maiuscole singole: T per Type, E per Element, K per Key, V per Value.
public class Container<T> {
private T content;
public Container(T content) {
this.content = content;
}
public T getContent() {
return content;
}
public void setContent(T content) {
this.content = content;
}
public boolean isEmpty() {
return content == null;
}
}
// Utilizzo
Container<String> stringContainer = new Container<>("Hello");
Container<Integer> intContainer = new Container<>(42);
String value = stringContainer.getContent(); // Nessun cast
Multiple Type Parameters
Le classi possono avere multipli type parameters per gestire scenari più complessi come mappe, tuple o relazioni tra tipi.
public class Pair<T, U> {
private final T first;
private final U second;
public Pair(T first, U second) {
this.first = first;
this.second = second;
}
public T getFirst() { return first; }
public U getSecond() { return second; }
// Metodo generico per creare coppie
public static <A, B> Pair<A, B> of(A first, B second) {
return new Pair<>(first, second);
}
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
}
// Utilizzo
Pair<String, Integer> nameAge = new Pair<>("Alice", 30);
Pair<String, String> coordinates = Pair.of("40.7128", "-74.0060");
Metodi Generici
I metodi possono essere generici indipendentemente dalla classe che li contiene, con type parameters dichiarati prima del tipo di ritorno.
public class Utilities {
// Metodo generico per scambiare elementi in un array
public static <T> void swap(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// Metodo generico con bound
public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) > 0 ? a : b;
}
// Metodo generico che restituisce una lista
public static <T> List<T> createList(T... elements) {
return new ArrayList<>(Arrays.asList(elements));
}
}
// Utilizzo
String[] names = {"Alice", "Bob", "Charlie"};
Utilities.swap(names, 0, 2); // Type inference automatico
String maxName = Utilities.max("Alice", "Bob"); // "Bob"
List<Integer> numbers = Utilities.createList(1, 2, 3, 4, 5);
Bounds e Constraints
I bounded type parameters permettono di limitare i tipi che possono essere utilizzati come argomenti, fornendo accesso ai metodi della classe/interfaccia bound.
Upper Bounds
L’upper bound (extends) limita il type parameter a essere un sottotipo del bound specificato.
// Bound con classe
public class NumberContainer<T extends Number> {
private T value;
public NumberContainer(T value) {
this.value = value;
}
// Possiamo chiamare metodi di Number
public double getDoubleValue() {
return value.doubleValue();
}
public boolean isPositive() {
return value.doubleValue() > 0;
}
}
// Bound con interfaccia
public class ComparableContainer<T extends Comparable<T>> {
private T value;
public ComparableContainer(T value) {
this.value = value;
}
public boolean isGreaterThan(T other) {
return value.compareTo(other) > 0;
}
}
// Multiple bounds
public class AdvancedContainer<T extends Number & Comparable<T> & Serializable> {
private T value;
// Può usare metodi di Number, Comparable e Serializable
public T getMinValue(T other) {
return value.compareTo(other) <= 0 ? value : other;
}
}
Lower Bounds nei Wildcards
I lower bounds sono utilizzabili solo con wildcards e permettono di specificare che il tipo deve essere un supertipo del bound.
public static void addNumbers(List<? super Integer> list) {
list.add(42); // OK: Integer è sottotipo di ? super Integer
list.add(100); // OK
// Object obj = list.get(0); // Solo Object è garantito
}
// Utile per metodi che aggiungono elementi
public static <T> void copy(List<? extends T> source, List<? super T> destination) {
for (T item : source) {
destination.add(item);
}
}
Wildcards
I wildcards (?) permettono di specificare tipi unknown, fornendo flessibilità quando il tipo esatto non è importante o quando si lavora con gerarchie di tipi.
Unbounded Wildcards
L’unbounded wildcard (?) indica un tipo sconosciuto, utile quando si vuole lavorare con qualsiasi tipo parametrizzato.
public static void printList(List<?> list) {
for (Object item : list) { // Ogni elemento è almeno Object
System.out.println(item);
}
}
public static int getSize(Collection<?> collection) {
return collection.size(); // Operazioni che non dipendono dal tipo
}
// Controllo del tipo runtime
public static boolean containsString(List<?> list, String target) {
for (Object item : list) {
if (item instanceof String && item.equals(target)) {
return true;
}
}
return false;
}
Bounded Wildcards - Producer/Consumer Pattern
I bounded wildcards seguono il principio PECS (Producer Extends, Consumer Super) per determinare quando usare upper o lower bounds.
// Producer - use extends (legge dal parametro)
public static double sumNumbers(List<? extends Number> numbers) {
double sum = 0;
for (Number num : numbers) { // Può leggere Number e sottotipi
sum += num.doubleValue();
}
return sum;
}
// Consumer - use super (scrive nel parametro)
public static void addIntegers(List<? super Integer> list) {
list.add(42); // Può aggiungere Integer
list.add(100); // Può aggiungere Integer
// Non può assumere nulla sui tipi già presenti
}
// Utilizzo del pattern PECS
List<Integer> integers = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
List<Number> numbers = new ArrayList<>();
double sum1 = sumNumbers(integers); // OK: Integer extends Number
double sum2 = sumNumbers(doubles); // OK: Double extends Number
addIntegers(numbers); // OK: Number super Integer
Type Erasure
Java implementa i generics attraverso type erasure, un meccanismo dove le informazioni sui type parameters vengono rimosse durante la compilazione per mantenere compatibilità con codice pre-generics.
Conseguenze del Type Erasure
Runtime Type Information: I type parameters non sono disponibili a runtime, limitando alcune operazioni come instanceof con tipi parametrizzati.
Bridge Methods: Il compilatore genera bridge methods per mantenere compatibilità quando si fa override di metodi generici.
Array Creation: Non è possibile creare array di tipi parametrizzati direttamente.
public class TypeErasureExamples {
public static <T> void demonstrateErasure(List<T> list) {
// Non funziona - type erasure
// if (list instanceof List<String>) { }
// Funziona - tipo raw
if (list instanceof List) {
System.out.println("È una List");
}
// Non funziona - non si può creare array di tipo parametrizzato
// T[] array = new T[10];
// Workaround per array
@SuppressWarnings("unchecked")
T[] array = (T[]) new Object[10];
}
// Reflection per ottenere informazioni sui generics
public static void getGenericInfo(List<String> stringList) {
Type genericType = stringList.getClass().getGenericSuperclass();
if (genericType instanceof ParameterizedType) {
ParameterizedType pt = (ParameterizedType) genericType;
Type[] actualTypes = pt.getActualTypeArguments();
// Informazioni limitate disponibili
}
}
}
Workarounds per Type Erasure
// Type Token pattern per preservare informazioni sui tipi
public class TypeToken<T> {
private final Class<T> type;
@SuppressWarnings("unchecked")
public TypeToken() {
Type superclass = getClass().getGenericSuperclass();
ParameterizedType parameterized = (ParameterizedType) superclass;
this.type = (Class<T>) parameterized.getActualTypeArguments()[0];
}
public Class<T> getType() {
return type;
}
}
// Utilizzo del Type Token
TypeToken<String> stringToken = new TypeToken<String>() {};
Class<String> stringClass = stringToken.getType();
Design Patterns con Generics
Builder Pattern Generico
public class GenericBuilder<T> {
private final Class<T> clazz;
private final Map<String, Object> properties = new HashMap<>();
public GenericBuilder(Class<T> clazz) {
this.clazz = clazz;
}
public GenericBuilder<T> set(String property, Object value) {
properties.put(property, value);
return this;
}
public T build() throws Exception {
T instance = clazz.getDeclaredConstructor().newInstance();
for (Map.Entry<String, Object> entry : properties.entrySet()) {
Field field = clazz.getDeclaredField(entry.getKey());
field.setAccessible(true);
field.set(instance, entry.getValue());
}
return instance;
}
}
Factory Pattern Generico
public interface Factory<T> {
T create();
}
public class FactoryRegistry {
private final Map<Class<?>, Factory<?>> factories = new HashMap<>();
public <T> void register(Class<T> type, Factory<T> factory) {
factories.put(type, factory);
}
@SuppressWarnings("unchecked")
public <T> T create(Class<T> type) {
Factory<T> factory = (Factory<T>) factories.get(type);
if (factory == null) {
throw new IllegalArgumentException("No factory for " + type);
}
return factory.create();
}
}
Visitor Pattern Generico
public interface Visitor<T> {
T visit(Element element);
}
public interface Element {
<T> T accept(Visitor<T> visitor);
}
public class StringElement implements Element {
private final String value;
public StringElement(String value) {
this.value = value;
}
@Override
public <T> T accept(Visitor<T> visitor) {
return visitor.visit(this);
}
public String getValue() { return value; }
}
// Visitor specifico
public class LengthVisitor implements Visitor<Integer> {
@Override
public Integer visit(Element element) {
if (element instanceof StringElement) {
return ((StringElement) element).getValue().length();
}
return 0;
}
}
Best Practices e Linee Guida
Naming Conventions
Type Parameters: Usa lettere maiuscole singole: T (Type), E (Element), K (Key), V (Value), N (Number).
Meaningful Names: Per type parameters complessi, usa nomi descrittivi: <RequestType>, <ResponseType>.
Design Guidelines
Favor Composition: Preferisci composizione con generics piuttosto che ereditarietà complessa.
Use Bounds Appropriately: Usa bounds per abilitare operazioni specifiche sui type parameters.
Follow PECS: Producer Extends, Consumer Super per wildcards.
Avoid Raw Types: Non usare mai tipi raw in nuovo codice.
// Buone pratiche
public class GoodExample<T extends Comparable<T>> {
// Metodo con bound appropriato
public T findMax(Collection<? extends T> items) {
return items.stream().max(T::compareTo).orElse(null);
}
// Factory method con inference
public static <K, V> Map<K, V> createMap() {
return new HashMap<>();
}
// Uso di bounds multipli quando necessario
public static <T extends Number & Comparable<T>>
boolean isInRange(T value, T min, T max) {
return value.compareTo(min) >= 0 && value.compareTo(max) <= 0;
}
}
Common Pitfalls
Array Creation: Non cercare di creare array di tipi parametrizzati.
Static Context: I type parameters della classe non sono disponibili in contesto statico.
Overloading: Attenzione all’overloading con generics a causa del type erasure.
// Problemi comuni da evitare
public class AvoidThese<T> {
// SBAGLIATO - non funziona
// private T[] array = new T[10];
// CORRETTO - usa lista o Object array con cast
private List<T> list = new ArrayList<>();
// SBAGLIATO - type parameter non disponibile in static
// public static T createInstance() { return new T(); }
// CORRETTO - usa Class parameter
public static <T> T createInstance(Class<T> clazz) throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
}
Conclusione
La programmazione generica è un pilastro fondamentale del Java moderno, fornendo type safety, performance migliorate e codice più espressivo. La comprensione dei concepts come bounds, wildcards e type erasure è essenziale per sfruttare appieno il potenziale dei generics.
L’utilizzo corretto dei generics porta a codice più sicuro, manutenibile e riutilizzabile. Sebbene la sintassi possa sembrare complessa inizialmente, i benefici in termini di correttezza del codice e developer experience sono sostanziali.
La padronanza dei design patterns generici e delle best practices permette di creare API eleganti e type-safe che riducono significativamente gli errori runtime e migliorano la produttività dello sviluppo.