15. Általános programozás

Az általános (generikus) programozás típusparaméterek segítségével teszi lehetővé, hogy osztályokat, interfészeket hozhassunk létre úgy, hogy bizonyos paraméterek típusait a példányosításkor dönthessük el.

Általános típus definiálása és használata

Ahhoz, hogy általános típust definiáljunk, a típusdefiníciónak tartalmaznia kell egy vagy több típus paramétert a típus neve után. A típus paraméterek vesszővel elválasztott listái ’<’ és ’>’ között szerepelnek. Konvencionálisan a típus paraméterek nagybetűsek. A típus paraméterek aztán megjelennek a típus metódusaiban, vagy a metódus paraméterlistájában, vagy visszatérési érték típusként. A gyakorlatban a legtöbb fejlesztőnek nincs szüksége új általános típusok definiálására, de szükséges megjegyezni a szintaxisát és a használatát az általános típusnak.

Nézzük újra át a Stack osztályt, melyet az osztályok létrehozásánál mutattunk be. A generikus verzió (Stack2<T>), egy gyűjteményt használ (ArrayList<T>), hogy a veremben tárolja az érékeket.

public class Stack2<T> {
    private ArrayList<T> items;
    ...
    public void push(T item) {...}
    public T pop() {...}
    public boolean isEmpty() {...}
}

Vegyük észre, hogy a T típus paraméter bevezetésre került az osztálynév után, és ezek után feltűnik, mint a push metódus paraméter típusa, és a pop metódus viszszatérési típusa.

A gyűjtemények gyakran használatosak arra, hogy bemutassuk az általános típus használatát, mivel nagyon jellemzőek az interfészekben és osztályokban. A valóságban a gyűjtemények voltak a fő motivációs erő az általános típusok bevezetésénél a Java nyelvben, mivel elérhetővé teszik a fordítás idejű ellenőrzését a gyűjteményeken végzett műveletek típusbiztonságának. Amikor specifikáljuk egy gyűjteményben tárolt objektum típusát:

  • a fordító tud ellenőrizni bármilyen műveletet, ami egy objektumot ad a gyűjteményhez
  • ismert az objektum típusa, mely a gyűjteményből lett kinyerve, ezért nincs szükség arra, hogy cast-oljuk (átalakítsuk) típussá. Ugyanakkor nincs lehetőség arra sem, hogy átalakítsunk egy rossz típussá, és ekkor megtapasztaljunk egy futás idejű ClassCastException kivételt.

Ahogy az előbbiekben írtuk, ha egy általános típust használunk, helyettesítjük a paraméter egy aktuális típus paraméterét, nagyjából ugyanezen módszerrel helyettesítünk egy metódushoz a paramétereihez tartozó aktuális értékeket. Egy aktuális típus paraméternek referencia típusnak kell lenni, nem lehet primitív. Például, itt látható az, hogy hogyan lehet létrehozni Stack2-t String típus paraméterrel; és ezek után push-olni és pop-olni a „hi” String-et.

Stack2<String> s = new Stack2<String>();
s.push("hi");
String greeting = s.pop(); //no cast required here

Egy nem generikus verem kód így nézne ki:

Stack s = new Stack();
s.push("hi");
String greeting = (String)s.pop(); //cast required here

Vegyük észre, hogy amikor az általános típust használjuk, akkor a fordító egy olyan technikával fordítja le az általános típust, amit típus törlésnek hívnak.

Gyakorlatilag a fordító törli az összes olyan információt, mely a típus paraméterrel, vagy a típus paraméterekkel kapcsolatos. Például a Stack2<string> típust lefordítja Stack2-re, melyet nyers típusnak neveznek. Abból következtethetünk a típus törlésre, hogy a típus paraméter nem érhető el futásidőben ahhoz, hogy használjuk típuskényszerítésben, vagy mint az instanceof eljárás paramétere.

15.1. Kapcsolatok az általános típusok között

Valószínűleg azt várjuk, hogy a Stack2<Object> a szülő típusa a Stack2<String>-nek, mert az Object szülője a String-nek. A valóságban nem létezik ilyen kapcsolat az általános típusok példányai között. A szülő-gyermek kapcsolat hiánya az általános típus példányai között nehézkessé teheti a többalakú (polimorf) eljárások megírását.

Tegyük fel, hogy szeretnénk megírni egy eljárást, ami kiírja egy gyűjteményben tárolt objektumokat az objektumok típusától függetlenül a konzolra.

public void printAll(Collection<Object> c) {
    for (Object o : c) {
        System.out.println(o);
    }
}

Választhatnánk, hogy létrehozunk egy String listát és ezt a metódust használjuk az összes String kiíratására:

List<String> list = new ArrayList<String>();
...
printall(list);   //error

Ha ezt használjuk, akkor észre fogjuk venni, hogy az utolsó sor fordítási hibát ad. Mivel az ArrayList<String> nem leszármazott típusa a Collection<Object>-nek, ezért nem adható át, mint paraméter a kiíratási eljárásnak annak ellenére, hogy a két típus ugyanannak az általános típusnak a leszármazottai, rokon öröklött típus paraméterekkel. Más részről az öröklés miatt kapcsolatban álló általános típusok ugyanazzal a típus paraméterrel kompatibilisek:

public void printAll(Collection<Object> c) {
    for (Object o : c) {
        System.out.println(o);
    }
}
List<Object> list = new ArrayList<Object>();
...
printall(list);   //this works

A List<Object> kompatibilis a Collection<Object>-el, mert a két típus példánya egy általános szülőtípusnak és annak leszármazott típusának, és a példányok ugyanahhoz a típus paraméterhez tartoznak, konkrétan az Object-hez.

15.2. Helyettesítő típus

Ahogy körbejárjuk a printAll metódus első változata által felvetett kérdést, leszögezhetjük, hogy a printAll paramétere egy gyűjtemény, melynek elemi típusa lehet bármi, amit Collection<?> formában leírhatunk:

public void printAll(Collection<?> c) {
    for (Object o : c) {
        System.out.println(o);
    }
}

A ?-es típus határozatlan típusként ismert. A gyűjteményből bármikor kiolvasható az objektum, mert a visszatérési érték mindig garantáltan Object. Azonban nem adható objektum a gyűjteményhez, mert a ? ismeretlen típust jelöl, és nem lehet egyértelműen tudni, hogy a hozzá adni kívánt objektum leszármazott típusa-e az ismeretlen típusnak. Az egyetlen kivétel a null, amely eleme minden típusnak.

Korlátozhatjuk (vagy kényszeríthetjük) is a helyettesítő típust valamely típus segítségével. A korlátozott helyettesítő típus akkor hasznos, mikor csak részben ismerjük a paramétereket. Például tegyük fel, hogy van egy osztály hierarchiánk geometriai alakzatokból (Shape), és ennek leszármazott típusaiból (Circle, Rectangle, és így tovább). A rajzoló program, ami ezekre az objektumokra hivatkozik, meghív egy drawAll nevű metódust, hogy egy gyűjteményt rajzoljon ezekből az alakzatokból:

public void drawAll(Collection<Shapes> shapes) {
    for (Shape s: shapes) {
        s.draw();
    }
}

Mivel láthattuk, hogy nem megengedett a Shape típus valamely leszármazott típusának (például a Circle) alkalmazása, ezért ez a metódus csak korlátozottan használható: például nem hívható meg a Collection<Circle> esetén. Hogy lehetővé tegyük a Shape típus valamely leszármazott típusának típus paraméterként való alkalmazását, kiterjeszthetjük az alakzatok gyűjtemény típus paraméterét helyettesítő típusként. De mivel tudjuk, hogy a típus paraméter valamilyen alakzat lesz, a helyettesítést korlátozhatjuk a Shape típusra a következőképpen:

void drawAll(Collection<? extends Shapes> shapes) { ... }

Ez lehetővé teszi a drawAll számára, hogy elfogadjon bármilyen, a Shape típus leszármazott típusából álló gyűjteményt.

Összefoglalva, a helyettesítő típus felső korlátozással specializálható a <? extends Type> módon, ezáltal megfelelővé téve az adott Type minden leszármazott típusához. A helyettesítő típust alulról is korlátozhatjuk. Egy alulról korlátozott helyettesítő a <? super Type> módon írható le, és a Type minden ősosztályára használható. Megfigyelhetjük, hogy továbbra sem lehetséges ismeretlen típusú gyűjteményhez objektumot hozzáadni, és ez nem lehetséges korlátozott, ismeretlen típusú gyűjtemény esetén sem.

15.3. Általános metódusok definiálása és használata

Nem csak a típusokat, a metódusokat is paraméterezhetjük. A statikus és nem statikus metódusoknak épp úgy, ahogy a konstruktoroknak, lehet típus paraméterük.

A metódusok típus paraméter deklarálásának szintaxisa megegyezik az osztályoknál használt szintaxissal. A típusparaméter listát a kerek zárójelek közé kell helyezni, még a metódus visszatérési értékének típusa elé. Például a következő gyűjtemény osztály metódus feltölt egy <? super T> típusú listát T típusú objektumokkal:

static <T> void fill(List<? super T> list, T obj)

Az általános metódusok lehetővé teszik a típus paraméterek használatát, hogy egyértelművé tehesse a típusok kapcsolatát egy vagy több paraméter esetén, egy metódus, vagy annak visszatérési értéke számára (vagy mindkettőnek). Az általános metódusok típus paraméterei általában osztálytól függetlenek vagy interfész-szintű típus paraméterek. A Collections osztály algoritmusainak definíciója sok lehetőséget kínál az általános metódusok használatára.

Az egyetlen különbség az általános típusok és általános metódusok között az, hogy az általános metódusokat hagyományos metódusként hívjuk meg. A típus paramétereket a hívás függvényében állapítjuk meg, ahogy a fill metódus alábbi hívásánál is:

public static void main(String[] args) {
    List<String> list = new ArrayList<String>(10);
    for (int i = 0; i < 10; i++) {
        list.add("");
    }
    String filler = args[0];
    Collections.fill(list, filler);
    ...
}

15.4. Általános típusok használata az öröklésben

A fejezetben már korábban tárgyalt Cat osztálynak tekintsük a getLitter nevű metódusát, melynek visszatérési értéke egy Cat objektumokból álló gyűjtemény:

public Collection getLitter(int size) {
    ArrayList litter = new ArrayList(size);
    for (int i = 0; i < size; i++) {
        litter.add(i, new Cat());
        return litter;
    }
}

Megfigyelhető, hogy a Collection objektum határozatlan típusú: a gyűjteményben lévő objektumok típusa nem meghatározott. Azonos helyzet állt fenn minden további metódusnál is, melyek objektumot adnak vissza, még mielőtt az általános típusok elérhetővé váltak volna a Java 5.0-ás verzióban.

Tegyük fel, hogy írunk egy programot Cats néven, mely ezt a visszatérési értéket adja át egy gyűjteménynek, melynek típusa kifejezetten Cat kell, hogy legyen:

public static void main(String[] args) {
Collection<Cat> litter = myCat.getLitter(3);
for (Cat c : litter) {
    System.out.println(c.getColor());
}

Mikor lefordítja a Cats.java állományt, a következő figyelmeztetést kapja:

A Cats.java ellenőrizetlen vagy nem biztonságos műveleteket használ. Fordítsa le újra a -Xlint:unchecked
kapcsolóval a részletek megtekintéséhez.

Az Xlint:checked kapcsoló használata információgyűjtéshez:

% javac -Xlint:unchecked Cats.java
Cats.java:5: warning: [unchecked] unchecked conversion
found : java.util.Collection
required: java.util.Collection
Collection litter = myCat.getLitter(3);
^
Összegezve, ha a Cat-et olyan fordítóval fordítjuk újra, mely támogatja a típus paramétereket, a következő figyelmeztetést kapjuk:

% javac -Xlint:unchecked Cat.java
Cat.java:19: warning: [unchecked] unchecked call to
add(int,E) as a member of the raw type java.util.ArrayList
                                 litter.add(i, new Cat());
                                           ^

Habár a kód hibátlan, ez a figyelmeztetés mutatja, hogy a fordító nem tudja biztosítani a művelet pontosságát, mikor speciális típusú gyűjteményeket használunk. Amikor „ellenőrizetlen” figyelmeztetést kap, ellenőriznie kell, hogy a művelet, mely a figyelmeztetést generálta, valóban megfelelő-e.

Végül tekintsünk át egy listát a teljes Stack2 osztályról:

public class Stack2<T> implements Cloneable {
    private ArrayList<T> items;
    private int top=0;
    public Stack2() {
        items = new ArrayList<T>();
    }
    public void push(T item) {
        items.add(item);
        top++;
    }
    public T pop() {
        if (items.size() == 0)
        throw new EmptyStackException();
        T obj = items.get(--top);
        return obj;
    }
    public boolean isEmpty() {
        if (items.size() == 0)
            return true;
        else
            return false;
    }
    protected Stack2<T> clone() {
        try {
            Stack2<T> s = (Stack2<T>)super.clone();
            s.items =  (ArrayList<T>)items.clone();
            return s; // Return the clone
        } catch (CloneNotSupportedException e) {
            throw new InternalError();
        }
    }
}

Megfigyelhetjük, hogy a clone metódus a clone metódusokat az ősosztályából és a tartalmazott listájából hívja meg. A clone metódusok öröklődő metódusok, mert még az általános típusok elérhetősége előtt definiálva lettek.

Mikor lefordítja a Stack2.java állományt, a következő figyelmeztetést kapja:

% javac -Xlint:unchecked Stack2.java
Stack2.java:32: warning: [unchecked] unchecked cast
found   : java.lang.Object
required: Stack2<T>
         Stack2<T> s = (Stack2<T>)super.clone();
                                             ^
Stack2.java:33: warning: [unchecked] unchecked cast
found   : java.lang.Object
required: java.util.ArrayList<T>
         s.items =  (ArrayList<T>)items.clone();
                                             ^
2 warnings

Ez a figyelmeztetés mutatja, hogy a fordító nem képes biztosítani az eljárás kifogástalan működését. Más szóval, mivel a clone metódust úgy definiálták, hogy egy Object osztálybeli objektumot adjon visszatérési értéknek, ezért a fordító nem tudja biztosítani, hogy a gyűjtemény visszatérési értéke Stack2<T> legyen. Azonban a clone metódus feletti megállapodás szerint a művelet engedélyezett, habár „ellenőrizetlen” figyelmeztetést okoz.

Rengeteg apró dologra kell figyelni az általános típusok használatánál az öröklésben.

Ellenőrző kérdések

  • Mikor érdemes típus paramétert alkalmazni?
  • Mi az előnye a típus paraméter alkalmazásának?