Programmazione Generica in Java

Edoardo Midali
Edoardo Midali

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.