Bazele genericelor Java

1. Introducere

Java Generics au fost introduse în JDK 5.0 cu scopul de a reduce bug-urile și de a adăuga un strat suplimentar de abstractizare pe tipuri.

Acest articol este o introducere rapidă la Generics în Java, obiectivul din spatele lor și modul în care pot fi utilizate pentru a îmbunătăți calitatea codului nostru.

2. Nevoia de generice

Să ne imaginăm un scenariu în care vrem să creăm o listă în Java pentru a stoca Integer ; putem fi tentați să scriem:

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

În mod surprinzător, compilatorul se va plânge de ultima linie. Nu știe ce tip de date este returnat. Compilatorul va necesita o distribuție explicită:

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

Nu există niciun contract care să poată garanta că tipul de returnare a listei este un număr întreg. Lista definită poate conține orice obiect. Știm doar că recuperăm o listă inspectând contextul. Când privim tipurile, poate garanta doar că este un obiect , necesită astfel o distribuție explicită pentru a se asigura că tipul este sigur.

Această distribuție poate fi enervantă, știm că tipul de date din această listă este un întreg . Distribuția ne încurcă și codul. Poate provoca erori de runtime legate de tip dacă un programator face o greșeală cu castingul explicit.

Ar fi mult mai ușor dacă programatorii ar putea să își exprime intenția de a utiliza tipuri specifice, iar compilatorul poate asigura corectitudinea unui astfel de tip. Aceasta este ideea de bază din spatele genericelor.

Să modificăm prima linie a fragmentului de cod anterior pentru:

List list = new LinkedList();

Prin adăugarea operatorului diamant care conține tipul, restrângem specializarea acestei liste doar la tipul întreg, adică specificăm tipul care va fi păstrat în listă. Compilatorul poate aplica tipul în momentul compilării.

În programele mici, acest lucru ar putea părea o adăugare banală, cu toate acestea, în programele mai mari, acest lucru poate adăuga o rezistență semnificativă și face programul mai ușor de citit.

3. Metode generice

Metodele generice sunt acele metode care sunt scrise cu o singură declarație de metodă și pot fi apelate cu argumente de diferite tipuri. Compilatorul va asigura corectitudinea oricărui tip utilizat. Acestea sunt câteva proprietăți ale metodelor generice:

  • Metodele generice au un parametru de tip (operatorul de diamant care cuprinde tipul) înainte de tipul de returnare a declarației metodei
  • Parametrii de tip pot fi delimitați (limitele sunt explicate mai târziu în articol)
  • Metodele generice pot avea parametri de tip diferiți separați prin virgule în semnătura metodei
  • Corpul metodei pentru o metodă generică este la fel ca o metodă normală

Un exemplu de definire a unei metode generice pentru a converti o matrice într-o listă:

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

În exemplul anterior, în semnătura metodei presupune că metoda va fi de-a face cu tipul generic T . Acest lucru este necesar chiar dacă metoda revine nulă.

Așa cum s-a menționat mai sus, metoda poate trata mai multe tipuri generice, acolo unde este cazul, toate tipurile generice trebuie adăugate la semnătura metodei, de exemplu, dacă dorim să modificăm metoda de mai sus pentru a trata tipul T și tipul G , ar trebui scris astfel:

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

Trecem o funcție care convertește o matrice cu elementele de tip T în listă cu elemente de tip G. Un exemplu ar fi să convertim întregul la reprezentarea sa 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")); }

Este demn de remarcat faptul că recomandarea Oracle este de a utiliza o literă majusculă pentru a reprezenta un tip generic și de a alege o literă mai descriptivă pentru a reprezenta tipurile formale, de exemplu în Java Collections se folosește T pentru tip, K pentru cheie, V pentru valoare.

3.1. Bounded Generics

După cum sa menționat anterior, parametrii de tip pot fi delimitați. Delimitat înseamnă „ restricționat ”, putem restricționa tipurile care pot fi acceptate printr-o metodă.

De exemplu, putem specifica că o metodă acceptă un tip și toate subclasele sale (limita superioară) sau un tip toate superclasele sale (limita inferioară).

Pentru a declara un tip cu margine superioară, utilizăm cuvântul cheie extinde după tipul urmat de marginea superioară pe care dorim să o folosim. De exemplu:

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

Cuvântul cheie extends este folosit aici pentru a însemna că tipul T extinde limita superioară în cazul unei clase sau implementează o limită superioară în cazul unei interfețe.

3.2. Legături multiple

Un tip poate avea, de asemenea, mai multe limite superioare, după cum urmează:

Dacă unul dintre tipurile care sunt extinse cu T este o clasă (adică Număr ), trebuie să fie plasat primul în lista de limite. În caz contrar, va provoca o eroare în timpul compilării.

4. Utilizarea comodinelor cu generice

Jokerurile sunt reprezentate de semnul întrebării în Java „ ? ”Și sunt folosite pentru a se referi la un tip necunoscut. Jokerurile sunt deosebit de utile atunci când se utilizează generice și pot fi utilizate ca tip de parametru, dar mai întâi, există o notă importantă de luat în considerare.

Se știe că Object este supertipul tuturor claselor Java, cu toate acestea, o colecție de Object nu este supertipul niciunei colecții.

De exemplu, o Listă nu este supertipul List și atribuirea unei variabile de tip List unei variabile de tip List va provoca o eroare a compilatorului. Aceasta este pentru a preveni posibilele conflicte care se pot întâmpla dacă adăugăm tipuri eterogene la aceeași colecție.

Aceeași regulă se aplică oricărei colecții a unui tip și a subtipurilor acestuia. Luați în considerare acest exemplu:

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 este un supliment puternic la limbajul Java, deoarece face munca programatorului mai ușoară și mai puțin predispusă la erori. Genericele impun corectitudinea tipului în momentul compilării și, cel mai important, permit implementarea algoritmilor generici fără a provoca nicio cheltuială suplimentară aplicațiilor noastre.

Codul sursă care însoțește articolul este disponibil pe GitHub.