Table of Contents

Laboratorul 10: Genericitate și Tipuri Parametrizate

Obiective

Scopul acestui laborator este prezentarea conceptului de genericitate și modalitățile de creare și folosire a claselor, metodelor și interfețelor generice în Java.

Aspectele urmărite sunt:

Aspectele bonus urmărite sunt:

  • În acest laborator există mai multe secțiuni marcate [Optional]. Aceste secțiuni cuprind informații bonus care vă pot fi prezentate în timpul laboratorului sau pe care le puteți aprofunda în afara acestuia, ele nefiind necesare pentru laboratoarele viitoare sau pentru teme.
  • De asemenea, veți întâlni câteva secțiuni marcate [Nice to know]. Vă recomandăm ca acestea să aibă prioritate în parcurgerea secțiunilor de tip [Optional], deoarece vă pot oferi informații bonus care să fie și foarte probabil utile pentru teme sau laboratoare viitoare.

🏷️ Genericics

Genericitatea este unul dintre cele mai elegante mecanisme introduse în Java pentru a ajuta la scrierea unui cod general, sigur și reutilizabil.

Înainte de Java 5, colecțiile acceptau orice tip de obiect:

List list = new ArrayList();
list.add(new Date());
list.add("Hello");

Din cauza acestui lucru:

Acest compromis ducea des la erori runtime.

  • Reamintim că erorile de tip (type errors) captate la runtime sunt mult mai costisitoare decât cele descoperite la compilare.
  • Generics mută 90% din probleme la compilare, acolo unde este locul lor.

Raw Types

Java este orientată pe obiecte, ceea ce înseamnă că polimorfismul permite referirea obiectelor ca tipuri generice (toate obiectele moștenesc Object). Chiar și după Java 5, este posibil să folosim o colecție fără a specifica un parametru de tip. Acest stil se numește raw type.

Dar o colecție de Object este, filosofic vorbind, atât o colecție de orice, cât și o colecție de nimic.

Costume Party Analogy:

  1. Toate obiectele intră în colecție „purtând aceeași mască” (Object).
  2. Odată masca pusă, compilatorul nu mai știe cine este cine.
  3. Programatorul trebuie să dea jos masca folosind casturi, care pot eșua.

Considerăm un ArrayList fără genericitate:

ArrayList list = new ArrayList();
list.add("Hello");
list.add(10);  // logic problematic, DAR poate fi adăugat
 
String text = (String) list.get(0);  // OK
String another = (String) list.get(1); // ClassCastException runtime

Acest stil prezintă mai multe probleme:

Compilerul îți spune că faci “unchecked operations”, dar nu te poate opri.

De ce nu putem corecta colecțiile fără generics?

Problema fundamentală nu este implementarea colecțiilor, ci contractul lor public.

Colecțiile înainte de generics acceptau orice obiect prin metoda:

public boolean add(Object o)

Să încercăm să rezolvăm această problemă fără generics.

Varianta 1: Suprascrierea metodei add() cu un tip mai specific

Mulți începători gândesc instinctiv: “Dacă vreau o listă doar de Date, suprascriu add() ca să accepte doar Date.”

Exemplu:

class DateList extends ArrayList {
    public boolean add(Date d) { ... }  // încercare de override
}

Problema: Acest lucru nu este overriding, ci overloading.

Metoda originală din ArrayList este:

public boolean add(Object o)

Semnătura nu coincide, deci metoda nu este suprascrisă. Rezultatul:

Exemplu:

DateList list = new DateList();
list.add("Hello");  // Perfect valid → deoarece add(Object) există încă

  • Polimorfismul permite doar expansiunea tipurilor, nu îngustarea lor.
  • Vom vedea mai jos că generics au fost introduse tocmai pentru a putea “îngusta” contractele în mod sigur.

Varianta 2: Scriem o clasă nouă, complet separată

Un alt instinct este: “Ok, nu pot modifica ArrayList, atunci scriu eu o clasă List doar pentru Date.”

Exemplu:

class DateList {
    private ArrayList inner = new ArrayList();
 
    public void add(Date d) { inner.add(d); }
    public Date get(int i) { return (Date) inner.get(i); }
}

Practic, am delegat aceste operații unei clase specializate. Inițial pare că funcționează, deoarece lista acceptă doar Date.

Problema: Această clasă nu mai este un List real, pentru că nu implementează List, drept consecință:

Tipuri parametrizate - baza genericității

Prin introducerea type parameters, colecțiile devin sigure:

ArrayList<String> words = new ArrayList<>();
words.add("Hello");
// words.add(10); // Eroare de compilare
String word = words.get(0); // Fără cast

Compilatorul garantează că doar obiecte de tipul String pot fi adăugate. Aceasta demonstrează că generics mută erorile de tip din runtime în compile time, un concept fundamental în design-ul limbajelor moderne.

Totodată, mai avem următoarele avantaje:

  1. o singură clasă poate fi folosită pentru orice tip,
  2. compilatorul verifică automat corectitudinea tipurilor,
  3. nu există casturi la extragere.

Definirea claselor generice

Putem crea propriile clase generice folosind operatorul diamant (<>) la definirea clasei:

// Clasa Box va lucra cu obiecte de tip "T"
public class Box<T> {
    private T value; // folosim tipul "T" ca și câmp
 
    // Folosim tipul "T" ca parametru
    public void set(T value) { this.value = value; }
    // Folosim tipul "T" ca valoare de return
    public T get() { return value; }
}

Utilizare:

// Tipul "T" va fi înlocuit cu "Integer" peste tot în clasă
Box<Integer> intBox = new Box<>();
intBox.set(10);
 
Box<String> stringBox = new Box<>();
stringBox.set("Hello");

Observăm cum o singură clasă devine reutilizabilă pentru mai multe tipuri, eliminând redundanța și crescând flexibilitatea designului.

Definirea metodelor generice

Pe lângă clase generice, Java permite definirea metodelor generice, adică metode care introduc propriii parametri de tip independent de parametrii de tip ai clasei.

Această funcționalitate este extrem de utilă atunci când:

De exemplu, putem avea o metodă generică care returnează același tip primit:

public static <T> T identity(T value) {
    return value;
}

Observați poziționarea lui <T> în semnătura metodei. Prin definirea lui în acel loc marcăm metoda ca fiind generică.

Utilizare:

String s = identity("Hello");
Integer n = identity(42);
Date d = identity(new Date());

Compilatorul deduce automat tipul T în fiecare apel.

În Java, tipurile generice nu pot fi primitive, drept urmare va trebui să folosim mereu obiecte sau clase wrapper.

Tipuri generice multiple

O clasă poate avea mai mulți parametri:

public class Pair<K, V> {
    private K key;
    private V value;
    public Pair(K key, V value) { this.key = key; this.value = value; }
}

Aceasta este baza map-urilor, dicționarelor și tuplurilor.

Interfețe generice

Interfețele pot fi la rândul lor generice:

public interface Transformer<T, R> {
    R apply(T input);
}

Interfața de mai sus este una specială, deoarece se numește interfață funcțională. Vom discuta mai multe despre acestea în laboratorul viitor.

Heritable Generics

Generics pot participa la lanțuri de moștenire exact ca orice alte clase, dar cu câteva detalii importante.

Moștenire păstrând parametrul de tip

class MyList<T> extends ArrayList<T> {
    // Moștenește întreaga funcționalitate și rămâne generică
}

Clasa derivată continuă să fie generică și poate funcționa exact ca tipul original.

Moștenire cu specializarea tipului (fixarea parametrului)

class IntegerList extends ArrayList<Integer> { }

Clasa nu mai este generică. Acceptă doar valori de tip Integer.

Acest lucru este util pentru a crea alias-uri semantice:

class UserIdList extends ArrayList<UserId> { }

Moștenire cu parametri ignorați (de evitat)

class Bad<T> extends Box<String> { }

Parametrul generic T există, dar nu mai este folosit. Asta înseamnă că următorul cod compilează:

Bad<Integer> b = new Bad<>();

Deși clasa funcționează doar cu String intern. Aceasta creează confuzie și design ineficient.

Moștenire cu limitări pe tipuri

class SortedBox<T extends Comparable<T>> extends Box<T> { }

Clasa poate fi instanțiată doar cu tipuri care sunt comparabile între ele.

Acest tip de moștenire apare des în structuri de date (liste ordonate, heap-uri, arbori etc.).

Vom vedea mai jos ce face exact extends în cadrul generics.

Tipuri generice cu limitări

Există situații în care dorim ca un parametru generic să fie restricționat la un anumit tip (sau subclase ale acestuia).

public class NumberBox<T extends Number> {
    private T value;
}

Această constrângere oferă două avantaje:

Vom vedea mai jos ce presupune exact construcția extends în cadrul generics.

De asemenea, Java permite bounded type parameters multiple:

public <T extends Number & Comparable<T>> void process(T value) { ... }

Această combinație este utilă în algoritmi de sortare sau agregare numerică.

Un alt exemplu destul de des folosit cu limitări în tipuri generice este acesta:

public static <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}

Practic, am făcut o metodă care întoarce maximul dintre două obiecte, doar dacă acel obiect implementează interfața Comparable pentru a putea folosi metoda compareTo.

Wildcards – flexibilitate în genericitate

Există momente în care nu cunoaștem exact tipul colecției, dar dorim totuși să operăm asupra ei. Pentru aceste cazuri folosim wildcard-uri (?).

Wildcard neîngrădit (?)

Folosim ? când tipul nu este relevant, ci doar existența lui:

public void printList(List<?> list) {
    for (Object o : list)
        System.out.println(o);
}

Poate primi orice listă: List<String>, List<Integer>, etc.

Bounded wildcards – PECS (Producer Extends, Consumer Super)

Acesta este unul dintre cele mai importante concepte în genericitate.

Upper bounded wildcards

? extends T – valorile permise includ tipul “T” și copiii lui.

Se mai poate spune și că lista produce valori de tip “T”. Se folosește când vrem să citim obiecte:

public void processNumbers(List<? extends Number> list) {
    Number n = list.get(0); // OK
    // list.add(10); // nerecomandat, nu știm exact subtipul
}

Lower bounded wildcards

? super T – valorile permise includ tipul “T” și părinții lui.

Se mai poate spune și că lista consumă valori de tip “T”. Se folosește când vrem să adăugăm obiecte:

public void addNumbers(List<? super Integer> list) {
    list.add(10); // OK
    // Integer x = list.get(0); // invalid, nu știm tipul exact
}

Regula PECS pe scurt:

  • Producer Extends → folosește extends când lista doar oferă valori.
  • Consumer Super → folosește super când lista primește valori.

Deși folosim extends, acesta se poate referi atât la clase cât și la interfețe (vezi exemplul de mai sus despre Comparable).

Wildcard Capture

Wildcard-urile (?) sunt utile pentru flexibilitate, dar nu pot fi folosite ca tipuri reale în operații care necesită consistență de tip.

De exemplu, următoarea metodă nu compilează:

void resetFirst(List<?> list) {
    list.set(0, list.get(0)); // Eroare
}

Motivul:

Wildcard-ul înseamnă “nu cunosc tipul”, deci nu poate fi folosit ca tip concret.

Pentru a rezolva această problemă, folosim Wildcard Capture. Definim o metodă generică auxiliară care introduce un parametru de tip real:

<T> void resetHelper(List<T> list) {
    list.set(0, list.get(0)); // Aici merge
}
 
void resetFirst(List<?> list) {
    resetHelper(list); // Compilatorul captează wildcard-ul ca tip T
}

Ce se întâmplă aici?

Un exemplu clasic din JDK

Metoda Collections.swap folosește exact acest mecanism:

static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}
 
private static <T> void swapHelper(List<T> list, int i, int j) {
    T tmp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, tmp);
}
  1. Metoda publică primește orice listă (List<?>).
  2. Metoda privată capturează wildcard-ul ca T și permite operații sigure.

Type erasure – cum funcționează generics sub capotă

Java implementează genericitatea printr-un mecanism numit type erasure, adică tipurile generice există doar în timpul compilării, fiind eliminate în codul de bytecode.

Consecințe:

  1. Nu putem instanția generic types direct:
    // T value = new T(); // invalid
  2. Nu putem crea array-uri generice:
    // T[] array = new T[10]; // invalid
  3. Nu putem folosi operatorul instanceof cu tipuri generice:
    // if (obj instanceof List<String>) { ... } // invalid
  4. Tipurile sunt transformate în Object sau limitele lor (extends).

[Nice to know] Instanțierea unui tip generic

[Nice to know] Instanțierea unui tip generic

Nu putem scrie new T(), deoarece tipul T nu există la runtime și Java nu știe dacă acest tip are un constructor fără parametrii. Drept consecință, codul următor este invalid:

public class Box<T> {
   private T value = new T(); // EROARE
}

Dar, putem folosi următorul exemplu de cod pentru a construi obiecte generice:

public class Box<T> {
   private final T value;
 
   public Box(Class<T> clazz) throws Exception {
       this.value = clazz.getDeclaredConstructor().newInstance();
   }
}


Bridge Methods

Generics funcționează prin type erasure, ceea ce înseamnă că semnătura metodelor generice dispare în bytecode. Pentru a păstra compatibilitatea cu codul pre-generic, compilatorul creează automat metode suplimentare numite bridge methods.

Pentru codul:

class MyList implements List<String> {
    public String get(int index) { ... }
}

Compilatorul generează automat:

public Object get(int index) {
    return get(index);
}

Aceste metode:

Type erasure oferă compatibilitate cu versiunile vechi ale Java, dar limitează unele scenarii care în alte limbaje (ex. C#) sunt posibile.

Puteți aprofunda diferența la Type Erasure dintre C# și Java în acest articol.

Din cauza mecanismului de type erasure, la runtime avem următoarea relație de adevăr:

new ArrayList<String>().getClass() ==
new ArrayList<Integer>().getClass()  // true

Practic, tipul fiecărui ArrayList este eliminat la runtime, deci intern ambele clase sunt identice.

Invarianță, Covarianță și Contravarianță

Java are trei moduri principale de a trata relațiile dintre tipurile generice.

Invarianță (comportamentul implicit)

Deși moștenirea pare că am putea face asta, List<String> nu este un List<Object>.

List<Object> list = new ArrayList<String>(); // Eroare

Altfel, am putea avea:

List<Object> x = new ArrayList<String>();
x.add(10); // risc major

Covarianță (arrays)

Array-urile sunt covariante:

Object[] arr = new String[10];
arr[0] = 42; // ArrayStoreException

Generics nu sunt covariante.

Contravarianță (? super T)

Pentru a defini un scenariu de moștenire ca în exemplul despre invarianță, putem folosi bounded wildcards, astfel listelor li se poate permite să accepte tipuri părinte.

List<? super Integer> list;

Aceasta permite scrierea în colecție (add(Integer)), dar nu permite citirea unui tip specific.

De ce List<String> nu este List<Object>

În mod natural, ai putea crede că dacă String este un subtip al lui Object, atunci și List<String> ar trebui să fie un subtip al lui List<Object>. Dar nu este așa.

Exemplu:

List<String> stringList = new ArrayList<>();
 
// Deși pare logic, nu este permis:
List<Object> objects = stringList;   // Eroare de compilare!

De ce? Dacă ar fi fost permis, atunci:

objects.add(new Object());   // ar fi permis...
String s = stringList.get(0); // ...dar aici am obține un Object, nu un String!

Acesta este motivul pentru care generics în Java sunt invariante.

Cast-uri în Generics

Din cauza type erasure, anumite casturi sunt permise, iar altele nu.

Permis:

List raw = new ArrayList();
List<String> s = (List<String>) raw; // unchecked, dar permis

Interzis:

List<Date> d = new ArrayList<>();
List<Object> o = (List<Object>) d; // eroare de compilare

Acest lucru se întâmplă tocmai pentru că tipurile generice sunt invariante.

Arrays și Generics

Arrays sunt reified, adică își cunosc tipul la runtime. Generics sunt erased. Aceste două modele diferite sunt incompatibile.

Consecințe importante:

Arrays verifică tipurile la runtime, în schimb generics nu o fac.

[Optional] Warning-uri de compilare

Pentru a vedea exact unde apar erorile de tip puteți rula următoarea comandă:

javac -Xlint:unchecked Program.java

Aceasta va indica toate locurile în care se folosesc raw types sau casturi riscante.

IntelliJ va rula automat această comandă pentru voi.

[Nice to know] Glosar – Litere folosite în Generics

Literă Semnificație
T Type
E Element (în special în colecții)
K Key
V Value
R Result
? wildcard, tip necunoscut

Exerciții

  • Exercițiile vor fi făcute pe platforma Devmind Code. Găsiți exercițiile din acest laborator în contestul aferent.
  • Vă recomandăm să copiați scheletul și să faceți exercițiile mai întâi în IntelliJ, deoarece acolo aveți acces la o serie de instrumente specifice unui IDE. După ce ați terminat exercițiile puteți să le copiați pe Devmind Code.

Task 1 (6p)

Implementați o structură de date de tipul MultiMapValue<K, V>, pe baza scheletului, care reprezintă un HashMap<K, ArrayList<V», unde o cheie este asociată cu mai multe valori. Modalitatea de stocare a datelor este la alegere (moștenire sau agregare) și să folosiți funcționalitățile din HashMap. În schelet aveți următoarele metode de implementat:

  1. (1 punct) add(K key, V value) - adaugă o valoare la o cheie dată (valoarea este adăugate în lista de valori asociate cheii, dacă cheia și lista nu există, atunci lista va fi creată și asociată cheii.
  2. (1 puncte) void addAll(K key, List<V> values) - adaugă valorile din lista de valori dată ca parametru la lista asociată cheii.
  3. (1 puncte) void addAll(MultiMapValue<K, V> map) - adaugă intrările din obiectul MultiMapValue dat ca parametru în obiectul curent (this).
  4. (0.5 puncte) V getFirst(K key) - întoarce prima valoare asociată cheii (dacă nu există, se întoarce null).
  5. (0.5 puncte) List<V> getValues(K key) - se întoarce lista de valori asociată cheii.
  6. (0.5 puncte) boolean containsKey(K key) - se verifică faptul dacă este prezentă cheia în MultiMapValue.
  7. (0.5 puncte) boolean isEmpty() - se verifică dacă MultiMapValue este gol.
  8. (0.5 puncte) List<V> remove(K key) - se șterge cheia, împreună cu valorile asociate ei, din MultiMapValue.
  9. (0.5 puncte) int size() - se întoarce mărimea MultiMapValue.

Task 2 (4p)

Implementați o structură de date de tipul Tree<T> (Arbore binar de căutare) pe baza scheletului. Analizați modalitatea de utilizare a bounded wildcards, explicați necesitatea lor laborantului (fie în cadrul orei de laborator, fie la nivel de comentariu în cod). În schelet aveți următoarele metode de implementat:

  1. (1 puncte) void addValue(T value) - adaugă o valoare în arborele binar de căutare.
  2. (0.5 puncte) void addAll(List<T> values) - adaugă valorile dintr-o listă în arborele binar de căutare.
  3. (1.5 puncte) HashSet<T> getValues(T inf, T sup) - colectează valorile din arbore între o limită inferioară și superioară într-o colecție de tipul HashSet.
  4. (0.5 puncte) int size() - se întoarce numărul de elemente inserate în arbore.
  5. (0.5 puncte) boolean isEmpty() - se întoarce dacă există vreun element inserat în arborele binar sau nu.

Resurse și link-uri utile