fbpx

Sziasztok!

Ebben a cikkben a Java generikus nyelvi konstrukcióiról lesz szó. Célom, hogy részletesen megismertessem és megszerettessem ezeket veletek. Valószínűleg még kell egy kis gyakorlás, mire magabiztosan fogjátok használni, de az alapvető fogalmakkal tisztában lesztek, és a Java lelki világa is közelebb fog állni hozzátok.

Mi a generikusok célja?

Egy kulcsszó: általánosítás. Az általánosítás segítségével elkerülhető, hogy majdnem ugyanazt a kódot írjuk kétszer csak azért, mert más típuson szeretnénk dolgozni.

Logikusnak tűnik, hogy ilyenkor egy ősosztályon dolgozzunk (pl. Object), de a generikusok segítségével ennél sokkal erősebb fordításidejű típusellenőrzést kapunk. Ez a típusellenőrzés olyan sok hibát képes már fordítási időben felderíteni, és olyan sokat segít a kód fejlesztése, karbantartása közben, hogy bőven megtérül a megértésére fordított idő :)

Generikus osztályok

Ez a fejezet a generikus osztályok legalapvetőbb használatáról szól. Ha már használtál generikusokat, valószínűleg ez a fejezet nem sok újat fog jelenteni számodra, nyugodtan ugorj a következőre :)

A legtipikusabb generikus példa valamilyen kollekció (pl. lista) használata. Ha nem használnánk generikusokat, tetszőleges Object példányokat helyezhetnénk bele, és a kivett elemről sem tudnánk többet, mint hogy Object. Ha bevezetnénk olyan fogalmat, mint Stringek listája (a lista generikus paramétere String), akkor fordítási időben garantálhatnánk, hogy a listába kerülő és az abból kivett elem is String lesz. Ezzel nagyobb biztonságot és sok castolás elkerülését nyerjük.

Egy saját lista oszály generics nélkül Lista generikusok használatával
public class List {
    public void add(Object object) { ... }
    public Object get(int index) { ... }
}
public class List<T> {
    public void add(T object) {...}
    public T get(int index) {...}
}

A kódokból látszik, hogy az osztály kapott egy T generikus paramétert, és az Object nevet lecseréltük T-re minden olyan helyen, ahol ezt a paramétert használhatjuk.

Az így létrehozott generikus osztályt a következőképp használjatjuk:

List<String> list = new List<String>();
list.add("Hello");
String s = list.get(0);

Mint látjuk, a List osztály használatakor meg kell adnunk annak típusparaméterét hasonlóan, mint a tömbök esetén. Egy fontos különbség a tömbökhöz képest, hogy generikus paraméter nem lehet primitív típus. Primitív típusok helyett a megfelelő csomagoló osztályt célszerű használni (pl. int helyett Integer).

Amennyiben egy generikus osztályt öröklünk, ezt a következőképpen tudjuk megtenni:

A típusparaméter delegálásával Fix típusparaméterrel
public class MyList<T> extends List<T>{
    ...
}
public class StringList extends List<String>{
    ...
}

Ilyenkor a MyList a List altípusa lesz, de az altípusosságról még lesz szó részletesebben.

Gyakran több típusparaméterre van szükségünk, ilyenkor vesszővel elválasztva soroljuk fel a formális és az aktuális típusparamétereket egyaránt:

public class Pair<F, S> {
    private F first;
    private S second;

    public Pair(F first, S second) {
        this.first = first;
        this.second = second;
    }

    public F getFirst() {
        return first;
    }

    public S getSecond() {
        return second;
    }
}
Pair<String, Integer> pair = new Pair<String, Integer>("test", 123);
String first = pair.getFirst();
Integer second = pair.getSecond();

Gyakran a típusparaméterek teljesen redundánsak, hiszen változó deklarálásakor a változó és a konstruktor típusparaméterei általában azonosak. Java 7-ben megjelent a diamond operátor (<>), amellyel elkerülhető a típusrendszer által kikövetkeztethető típusparaméterek kiírása. Fontos, hogy ez nem azonos a nyers típus használatával, amelyről a következő fejezetben lesz szó. Példa a diamond operátor használatára:

Pair<String, Integer> pair = new Pair<>("test", 123);

Nyers típusok

A nyers típusok a generikus osztályok típusparaméterek nélküli változatai. A generikusok megjelenésekor kompatibilitási okok miatt maradt a nyelvben, hogy a Java library változatlanul használható legyen.

Egy nyers típussal rendelkező változónak értékül adható tetszőleges típusparaméterrel rendelkező – az alaptípusnak megfelelő – objektum:

ArrayList list = new ArrayList<String>();

Fordítva azonban “unchecked” figyelmeztetést kapunk:

ArrayList<String> list = new ArrayList(); //"unchecked" warning

A nyers típusú objektum generikus metódusait használva ugyanilyen figyelmeztetést kapunk:

List list = new ArrayList();
list.add("test"); //warning

Zárt típusparaméterek

Amikor generikus osztályt készítünk, lehetőségünk van a típusparaméter korlátozására, például szeretnénk egy olyan listát készíteni, amely képes összeadni a benne található számokat, ezért csak a Number osztály leszármazottait (Number, Integer, Double, BigDecimal…) fogadja el típusparaméterként. A továbbiakban egy típus leszármazottai és ősei alatt az aktuális típust is beleértjük.

 public class NumberList<T extends Number> extends ArrayList<T> {
    public double sum() {
        double sum = 0.0;
        for (Number n : this) {
            sum += n.doubleValue();
        }
        return sum;
    }
}

Amint a kódból is látszik, a korlátozott típusparaméter garantálja, hogy a listában levő elemek a Number leszármazottai, ezért rendelkeznek doubleValue() metódussal. Megadhatunk több felső korlátot is, a következő módon:

public interface B {
}
public interface C {
}
public interface D {
}

public class A<T extends B & C & D> {
}

Altípusosság

A leggyakoribb hiba a generikusok használata során, hogy ha két generikus típus nyers típusa megegyezik, és típusparamétereik leszármazási viszonyban vannak (pl. List<String> és List<Object>), akkor ezeket a generikus típusokat egymás leszármazottainak gondoljuk, de ez nincs így. Lássuk, mi ennek az oka!

A Java követi a Liskov féle helyettesítési elvet: ha egy típus altípusa egy másiknak, akkor bárhol, ahol a típus egy példányát használjuk, a leszármazott példányát is használhatjuk. Igaz ez a List<Object> és a List<String> esetében? Elsőre úgy tűnik, hogy igen. Ha valahol a List<Object> egy elemével kell dolgoznunk, használhatjuk a List<String> egy elemét, hiszen az elemek típusa leszármazotti viszonyban áll egymással. Mi történik, ha a listába egy elemet szeretnénk helyezni? A List<Object> lehetővé teszi, hogy tetszőleges típusú elemet helyezzünk bele (pl. számokat), ez azonban nem igaz a List<String> típusra, hiszen ez már csak Stringeket fogad el, vagyis szigorítása, nem pedig bővítése a List<Object>-nek!

Tömböknél ugyanez a probléma merül fel, ott azonban a az Object[] őse a String[]-nek, viszont a tömbbe hibás elem helyezésekor futásidőben kapunk hibát:

Object[] objects = new String[1];
objects[0] = new Object(); //runtime error: ArrayStoreException

Mivel futásidőben már nem áll rendelkezésre generikus típus információ, az ilyenfajta hibák felfedezetlenül maradnának (lásd: type erasure), ezért a fordításidejű ellenőrzésnek kell szigorúbbnak lennie. Amint majd látjuk, a generikus típusok használata egy meglehetősen kifejező, erős fordításidejű típusellenőrzést ad a kezünkbe.

Wildcardok

Hogyan implementálnánk egy olyan metódust, ami egy tetszőleges objektumokat tartalmazó lista elemeit kiírja? Az első próbálkozásunk valahogy így nézne ki:

private void printObjects(List<Object> list) {

    for (Object o : list) {
        System.out.println(o);
    }
}

Az előzőekben láttuk, hogy egy ilyen metódusnak nem adhatunk át egy List<String> típusú objektumot, mivel fordítási hibát kapunk (ha átadhatnánk, a metódus képes lenne egy new Object()-et a listába helyezni), ezért más megoldáshoz kell folyamodnunk. A paraméterként kapott objektum nem kell, hogy egy tetszőleges objektumokat tartalmazó lista(List<Object> legyen, elég ha egy olyan T típusú listát kapunk, ahol a T ismeretlen (fontos különbség!).

Javaban az ismeretlen típusparamétert ?-el jelöljük, ezt hívjuk wildcardnak. Ez nem használható generikus osztály példányosításához, örökléséhez, vagy generikus metódus meghívásához, de bárhol máshol igen. A megoldás tehát a következő:

private void printObjects(List<?> list) {

    for (Object o : list) {
        System.out.println(o);
    }
}

Ugyan nem ismerjük a lista típusát, de az biztos, hogy az Object leszármazottja, ezért Objectként használhatjuk.

Mi lenne, ha list.add()-ot akarnánk meghívni? Fordítási hiba: nem tudunk olyan objektumot átadni, amely biztos, hogy megfelel annak az ismeretlen T-nek. Egyetlen kivétel létezik: a null érték, amely bármilyen típusnak megfelel.

Wildcard upper bound

Amennyiben nem elég egy ismeretlen típusú lista, hanem további megkötéseket szeretnénk rá adni (pl. valamilyen szám típusú legyen, mert össze szeretnénk adni az elemeit), akkor az extends szócskával adhatunk felső korlátot (wildcardnál maximum egyet) a generikus osztály típusparaméterének. Az alábbi kódban a metódus egy olyan listát vár paraméterül, amelynek ugyan nem tudjuk a típusparaméterét, de az biztos, hogy a Number osztály leszármazottja.

public static double sum(List<? extends Number> list) {

    double sum = 0.0;
    for (Number n : list)
        sum += n.doubleValue();
    return sum;
}

Érdemes megjegyeznünk, hogy a <? extends Object> egyenértékű a <?>-el.

Wildcard lower bound

Tegyük fel, hogy a feladatunk egy listába egész számok pakolása. Első megoldásként paraméterül egy List<Integer> típusú objektumot várnánk, azonban miért ne lehetne egy List<Number> vagy egy List<Object> típusú objektumba egész számokat pakolni? Jó lenne, ha ki lehetne fejezni, hogy egy olyan listát várunk, amely ugyan tetszőleges típusparaméterrel rendelkezik, de az az Integer, vagy annak valamely őse.

Pontosan erre szolgál a wildcardoknál a lower bound (alsó korlát), amely az upper boundhoz hasonlóan néz ki, csak a super kulcsszóval adhatjuk meg.

public static void addNumbers(List<? super Integer> list) {

    for (int i = 1; i <= 10; i++) {
        list.add(i);
    }
}

Érdemes megjegyeznünk hogy egy wildcard nem rendelkezhet egyszerre upper és lower bounddal.

Wildcard altípusosság

Eddig ugyan nem mondtuk ki, de a wildcardok abban segítenek, hogy a különböző típusparaméterű típusok között altípusosságot fejezhessünk ki (pl. List<? extends Number>-nek értékül adható List<Integer>). Tekintsük át, hogy mely generikus típusok között áll fenn altípusosság!

Wildcard altípusosság (forrás: docs.oracle.com)

Mint az ábrán is látszik, a List<?> azaz az ismeretlen típusparaméterű lista áll a hierarchia csúcsán, hiszen bármilyen típusparaméterű lista lehet ismeretlen típusparaméterű.A bal oldalon láthatjuk, hogy a List<? extends Number> (olyan lista, amelynek nem ismerjük a típusparaméterét, de az a Number osztály leszármazottja) őse a List<? extends Integer>-nek, azaz az olyan listának, amelynek a típusparamétere az Integer leszármazottja. Ez a kapcsolat nyilvánvaló, hiszen ha tudjuk, hogy a típusparaméter őse az Integer, akkor biztosak lehetünk benne, hogy a Number is őse. Az is egyértelmű, hogy a List<? extends Integer> őse a List-nek, hiszen ha tudjuk, hogy a típusparaméter Integer, akkor a típusparaméter biztosan az Integer leszármazottja. A jobb oldalon a List<? super Integer> (olyan lista, amelynek a típusparamétere az Integer őse) őse a List<? super Number>-nek (olyan lista, amelynek típusparamétere a Number őse). Ha tudjuk, hogy a típusparaméter a Number őse, akkor az nyilvánvalóan az Integer őse is lesz. Jobb oldalt, legalul láthatjuk, hogy a List<? super Number> őse a List-nek, hiszen a Number őse a Number-nek. Az ábrán két keresztirányú nyíl is látszik: ezek magyarázata szintén az, egy típus mindig önmaga leszármazottja, illetve őse.

Type erasure

A Java Generikusainak egyik legfontosabb, és kezdők számára legfurcsább jellemzője a type erasure. A generikus paraméterek ellenőrzése fordításidőben zajlik, futásidőben a generikusok nem okoznak többlet számításigényt, és a különböző generikus paraméterű objektumok ugyanazon osztály leszármazottjai lesznek (nem jön létre több osztály, mint más nyelvekben).

A type erasure során a generikus paraméterek eltűnnek, és azok az elemek, amelyek típusa ilyen paraméter volt, a generikus paraméter boundjára változnak. A generikusok tehát csupán egy fordításidejű hókusz-pókusz, a lefordított osztály pontosan olyan lesz, mintha nem is használtunk volna generikusokat! Pl:

Generikusan megírt osztály Lefordított osztály
public class Holder<T extends Number> {
    private T number;

    public Holder(T number) {
        this.number = number;
    }

    public T get() {
        return number;
    }
}
public class Holder {
    private Number number;

    public Holder(Number number) {
        this.number = number;
    }

    public Number get() {
        return number;
    }
}

Ha a Holder típusparamétere nem lenne zárt (extends Number), a lefordított osztályban mindenhol Object típusok szerepelnének.

A type erasure miatt sem az instanceof operátor, sem pedig a castolás nem használható generikus típusokra, pl obj instanceof List<String>, vagy list = (List<String>) obj.

Az sem megengedett, hogy egy osztályban két olyan metódus legyen, amelynek szignatúrája csak a generikus paraméterben különbözik, pl. add (List<String> list) és add (List<Integer> list).

A generikus típus nem példányosítható, és tömb sem hozható létre vele, hiszen futásidőben nem ismert ez a típus (new T() vagy new T[0])

A type erasure az oka annak is, hogy tömb nem lehet generikus típusú, pl. List<String>[]. Ha megengednénk, a következő történhetne:

Hibás osztály!
Object[] lists = new List<String>[1]; //Ha ez nem lenne fordítási hiba...
lists[1] = new ArrayList<Integer>(); //...Akkor ez sem fordítási, sem futásidőben nem szállna el!

Generikus metódusok

A generikus metódusok olyan metódusok, amelyek saját típusparaméterrel rendelkeznek, hasonlóan a generikus osztályokhoz. Képzeljünk el egy függvényt, amely a paraméterül kapott listába egy paraméterül kapott elemet helyez. Amennyiben nem használunk generikus paramétert, nem tudnánk ezt a függvényt megvalósítani, hiszen nem tudnánk a két paraméter típusa közti összefüggést jelezni. Hasonló a helyzet akkor is, ha egy olyan metódust készítenénk, amely a paraméterül kapott objektumot becsomagolja egy Holder objektumba, és ezzel tér vissza. Itt is ugyanaz a probléma áll fenn: nem tudjuk a visszatérési érték és a paraméter típusa közötti összefüggést jelezni. Lássuk tehát, hogyan lehet ezeket a feladatokat generikus metódusokkal megvalósítani!

public static <T> void addToList(List<T> list, T element) {
    list.add(element);
}

public static <T extends Number> Holder<T> wrap(T t) {
    return new Holder<>(t);
}

Amint látható, a metódus generikus paraméterét (paramétereit) a visszatérési érték típusa elé kell írnunk, és lehetőségünk van upper boundot megadni.

Összefoglalás

A bejegyzésből kiderült, hogy a Java generikusaival milyen sok feladat oldható meg típusbiztosan, és ehhez a nyelv milyen lehetőségeket biztosít. A generikusoknak van néhány buktatója (altípusosság, type erasure), de ha megértjük ezeket, egy remek eszközzé válik.


A szerző

Mihályi Zoltán

Mihályi Zoltán

Sziasztok! Mihályi Zoltán vagyok, négy éve dolgozok Java és JavaScript programozóként.

Elsősorban a programozási nyelvek érdekelnek részletesen, mert ezek sokkal ritkábban változnak, mint a rájuk épülő technológiák, keretrendszerek. Ezen kívül érdekelnek a tervezési minták, a clean code, a játékprogramozás, és a Node.js platform.