Fogalmak, szabványok

Fő kategória: Java.

Ebben a részben a Java nyelvi elemeit fogjuk áttekinteni. Elkerülhetetlen ugyanakkor az, hogy az alapkönyvtárak bizonyos elemeit már most használjuk, melyről részletesebben a Java Standard Könyvtárak oldalon fogunk megismerkedni.

Alap szintaxis

A bevezetőben láthattunk egyszerű példaprogramokat, melyből a szintaxisra is következtethetünk. A legfontosabbak:

  • A Java-ban mindent osztályba kell tenni, tehát (pl. a C++-szal ellentétben) nincsenek globális változók, függvények.
  • Minden forrásfájl kötelezően egy publikus osztályt tartalmaz (public class MyClass {…}). A fájl neve kötelezően az osztálynév, .java kiterjesztéssel (a fenti példában tehát MyClass.java).
  • A források csomagokba szervezhetőek. A csomagnévnek kötelezően követnie kell a fájlszerkezetet.
  • A belépési pont a következő függvény: public static void main(String[] args) {…}. Arról, hogy ebből mi micsoda, később lesz szó.
  • Többsoros megjegyzés: /* … */. Egysoros megjegyzés: // …
  • Kulcsszók: abstract, assert, boolean, break, byte, case, catch, char, class, const, continue, default, do, double, else, enum, extends, final, finally, float, for, goto, if, implements, import, instanceof, int, interface, long, native, new, package, private, protected, public, return, short, static, strictfp, super, switch, synchronized, this, throw, throws, transient, try, void, volatile, while (megjegyzés: noha a goto kulcsszó, nem létezik a Java-ban feltétel nélküli ugró utasítás).
  • A Java 5-ben megjelentek az annotációk: ezek @-cal kezdődnek, és az utánuk álló nyelvi elemre vonatkoznak. Ezek nem a fordítónak, hanem a futtatónak szólnak. Pl. a bevezető példában a @Test a unit teszt futtatónak információ, hogy azt a függvényt kell unit tesztként kezelnie. (Az annotációs bevezetésének az elsődleges célja az Enterprise Java egyszerűsítése volt.)
  • A Java 8-ban megjelentek a lambda függvények, pl. (int a, int b) -> return a+b;

Változók

A változók a programozási nyelvek alap építőkövei: ezekben tudjuk eltárolni a később használatos adatokat. Számos helyen hozhatunk létre változót: pl. osztályokban (ezeket attribútumoknak nevezzük), függvényekben (ezeket lokális változóknak), a függvények fejléceiben (ezek a paraméterek). A változó szintaxisa: típus változónév;

Alaptípusok

A Java az ún. erősen típusos nyelvek közé tartozik: minden változónak meg kell adni a pontos típusát. Nincsenek előjel nélküli (unsigned) típusok, csak előjelesek. A Java alaptípusai az alábbiak:

  • byte: 1 bájtos előjeles szám.
  • short: 2 bájtos előjeles szám.
  • int: 4 bájtos előjeles szám (megjegyzés: az int minden esetben 4 bájt hosszú, ellentétben a C++-szal, melyben a processzor bitszáma dönti el).
  • long: 8 bájtos előjeles szám. A L posztfixet kell megadnunk ahhoz, hogy biztosan long típust kapjunk: long l = 1234567890L;.
  • float: 4 bájtos előjeles lebegőpontos szám (a legkevésbé ismert Java kulcsszó, a strictfp erre vonatkozik: platformfüggetlen lebegőpontos kalkulációt lehet vele kikényszeríteni). Az f posztfix jelenti ezt a típust: float f = 12.34f;
  • double: 8 bájtos előjeles lebegőpontos szám. A d posztfix jelenti ezt a típust: double f = 12.34d;
  • boolean: 1 bájtos logikai értéket vehet fel: true vagy false (a C++-szal ellentétben nem lehet számot logikai értékként felhasználni úgy, hogy a 0 jelenti a hamis, minden más az igaz értéket; a Java-ban kötelező boolean típust használni, vagy olyan kifejezést, ami boolean-ra értékelődik ki, pl. i != 0)
  • char: egy karakter ASCII kódja, ami 2 bájton tárolódik (tehát mivel nem 1 bájton, belefér az összes ékezetes karakter).

Számok megadása

A számokat a következőképpen adhatjuk meg:

  • Tízes számrendszerben, mint a fenti példákban.
  • Binárisan, 0b prefix-szel pl. 0b01010101.
  • Hexadecimálisan, 0x prefix-szel, pl. 0x1E3F.
  • Oktálisan, prefix-szel, pl. 0123.
  • Exponenciális alakban, az e segítségével pl. 3e5 (melynek értéke 30000).

A szokásos műveleteket lehet végrehajtani a változókon, a szokásos precedenciával:

  • Műveletek számokkal:
    • Alapműveletek: +, -, *, /, % (maradékos osztás)
    • Növelés és csökkentés eggyel: ++, —
    • Bitenkénti műveletek: & (bitenkénti és), | (vagy), ^ (kizáró vagy), ~ (komplementer)
    • Bit tologató műveletek: « (pl. a « 2), », »> (ez utóbbi kettő között az a különbség, hogy az az első figyelembe veszi az előjelt, a második viszont mindig nullával tölti fel)
    • Értékadó utasítások: = (ez minden típusra működik), += (pl. a += b az a = a + b rövidítése), -=, *=, …
  • Logikai típust eredményező műveletek: == (egyenlő), != (nem egyenlő), <, <=, >, >=
  • Műveletek logikai típusokkal: && (és), || (vagy), ! (tagadás)

Lássunk egy példát!

// Main.java
public class Main {
    public static void main(String[] args) {
        int a = 123;
        int b = 0XAB;
        int c = a + b;
        System.out.println(a + " + " + b + " = " + c);
    }
}

A fent bemutatott módon futtassuk le! A a + " + " + b + " = " + c rész értelmezése: az a, b és c változók értékeit automatikusan szöveggé konvertálja, majd összefűzi a + hatására szövegtöredékekkel, így az eredmény 123 + 171 = 294 lesz.

Szöveg

A szövegek tárolását kétféleképpen tudjuk megoldani: vagy karaktertömböket használunk, vagy a String nevű belső osztályt alkalmazzuk. A gyakorlatban szinte kizárólag ez utóbbit használjuk, mivel tartalmaz számos plusz funkciót: hibakezelést, optimalizálást, valamint különböző műveleteket. A String kezelésről részletesebben lesz szó a későbbiekben, most csak magával a típussal ismerkedünk meg. A String típust ugyanúgy használjuk mint bármely más primitív típust. Az értékadásnál a szöveget idézőjelek közé kell tennünk. A Java-ban a String-eket összevonhatjuk a + művelettel (ami egyébként számos programozási nyelvben nem működik, és jelentős könnyebbséget jelent). Lássunk egy példát!

// Main.java
public class Main {
    public static void main(String[] args) {
        String s1 = "abc";
        String s2 = "123";
        String s3 = s1 + s2;
        System.out.println(s3);
    }
}

Típuskonverzió

A különböző típusok egymásba konvertálása nem nyilvánvaló. Általában a kisebb méretről a nagyobb felé a konverzió nyilvánvaló, pl. short-ból int-be, és többnyire ezt nem is kell külön jelezni a fordítónak. A másik irányban már adatvesztés léphet fel. A típuskonverzió explicit megadásakor a konvertálandó érték elé zárójelbe írva az új típust.

Egy példán illusztrálom a típuskonvertálás problémáját: (5/2)*2. Azt várnánk, hogy az eredmény 5 lesz, a valóságban viszont 4, mert az osztás egész osztás, maradékkal, az eredmény 2, és 2*2=4, ráadásul a fordító még csak nem is figyelmeztet erre. Megoldás: először az 5-öt kell lebegőpontosra konvertálni, így az osztás a lebegőpontos aritmetikával történik, majd a végeredményt vissza egésszé: (int)(((float)5/2)*2). Ebből a példából láthatóak az erős típusosság hátulütői: a nehezen felderíthető hibák, valamint a helyenként nehezen olvasható kód.

Memóriaterületek

Fontos kérdés, hogy hol helyezkednek el a változók a memóriában. Alapvetően két memóriaterület létezik:

  • Stack (verem): ide kerülnek a lokális változók, melynek legfontosabb oka az, hogy a függvény hívási láncban minden függvény a saját változóit lássa. A memóriaterület felszabadítása akkor következik be, ha a függvény futása befejeződött. A verembe csak primitív változók kerülhetnek, valamint referenciák az objektumokra.
  • Heap (kupac): itt az objektumok helyezkednek el. Felfoghatjuk úgy, mint egyfajta lokális felhő, a pontos felépítése (ellentétben a veremmel) megvalósítás függő. Ha tehát létrejön egy objektum, akkor a hivatkozás rá a verembe kerül, míg maga az objektum a kupacba. Egy objektumra több hivatkozás is lehet (pl. egy függvény hívási láncban, ha paraméterül kerül átadásra, ill. az objektumok is hivatkozhatnak egymásra attribútumaik által). Az objektum által elfoglalt memóriaterületet "kézzel" felszabadítani nem lehetséges (ellentétben a C++-szal, ahol kötelező kézzel, azaz függvényhívással felszabadítani, különben előbb-utóbb elfogy a memória), ezt az ún. garbage collector (szemétgyűjtő) végzi, automatikusan. A Java virtuális gép dönti el, hogy mikor fusson le, de programból lehet javaslatot tenni a lefutásra (amit a JVM nem feltétlenül fogad el). Működése nem nyilvánvaló, pl. az elérhetetlen körkörös hivatkozásokat is fel kell tudnia oldani.

volatile

Létezik egy nehezen érthető kulcsszó: a volatile, amit tetszőleges változó elé írhatunk. Ez nem Java specifikus dolog, de itt is megtalálható. A lényege a következő. A fordítók nagyon sok belső optimalizációs végeznek. Vegyük pl. a következő programrészletet:

int a;
int b;
...
a = 2;
b = a + 3;

Elképzelhető, hogy a fordító "rájön" arra, hogy az utolsó utasításban a b változó értéke csakis 5 lehet, és a gyorsítás érdekében eleve azt az értéket írja be, ahelyett, hogy kiolvasná az a értékét, hozzáadna 3-at, és beleírná a b-be. Viszont tegyük fel, hogy az a változó értéke bármikor megváltozhat (pl. egy mikrokontroller esetén egy megszakítás megváltoztatja, vagy Java-ban egy másik szálon, mégpedig oly módon, hogy arra nem jön rá a fordító); ez esetben hibás eredményre vezet. A volatile kulcsszó azt jelenti, hogy bármikor megváltozhat a változó értéke (pl. akár a fenti két utolsó utasítás között is megváltozhat az a változó), tehát nem számíthat a fordító arra, hogy a pillanatnyi érték lesz benne, így ne hajtsa végre az adott változóra vonatkozó optimalizálásokat, hanem mindenképpen hajtsa végre a fent felsorolt műveleteket. Ezt a következőképpen tudjuk Java-ban megadni: a fenti példa első sorának a következőképpen kell kinéznie: volatile int a;.

Tömb

Ahogy szinte minden programozási nyelvben, a Java-ban is hozhatunk létre tömböket. A tömb sorszámozása a 0-ról indul. A tömbök elemeit kapcsos zárójelekkel adhatjuk meg, míg egy-egy elemre szögletes zárójellel hivatkozhatunk:

// Main.java
public class Main {
    public static void main(String[] args) {
        int[] fibonacci = {0, 1, 1, 2, 3, 5, 8, 13};
        System.out.println(fibonacci[3]);
    }
}

Ez a kódrészlet 2-t ír ki.

Egy tömb elemei lehetnek tömbök:

int[][] matrix = {{1,2,3}, {4,5}, {6,7,8,9}};

Az is lehetséges, hogy az elemek felsorolása helyett lefoglaljuk a memóriaterületet, és a szögletes zárójeles módszerrel adunk kértéket egyesével (vagy ciklusban) az elemeknek:

int[] array = new int[5];
array[2] = 4;

Ez utóbbinak az a nagy előnye, hogy a tömb mérete nem feltétlenül csak konstans, lehet változó is, pl. new int[n];

var

A 10-es Java verzióban megjelent a típus nélküliséget jelölő var kulcsszó. Ennek lényege a következő: a típusokat az esetek döntő többségében ki lehet következtetni, miért kelljen tehát azt kiírni? Vegyünk egy példát: String fruit = "apple";. Valójában abból, hogy "apple", tudjuk, hogy egy String, a fordító is tudja, így használhatjuk anélkül is: var fruit1 = "apple";.

Ha a típus kiírása segíti az olvashatóságot, akkor érdemes továbbra is kiírni, de ha gátolja, akkor használhatjuk a var-t. Ugyanakkor azt is érdemes tudnunk, hogy nem minden esetben működik a var.

Vezérlés

A vezérlő utasítások a számítógép két alapvető lényegének programozási megvalósulásai.

Feltételkezelés

A Java nyelvben az alap feltételkezelés a következőképpen néz ki:

if ([feltétel]) [utasítás];

A gyakorlatban az utasítás nem egy utasítás, hanem több, és ezeket kapcsos zárójelek közé tesszük. Jó programozási gyakorlat akkor is új kapcsos zárójelek közé tenni és új sorba írni az utasítást, ha csak egy van belőle:

if ([feltétel]) {
    [utasítás];
}

A feltételkezelésnek a továbbgondolása az, hogy akkor is történjen valami, ha a feltétel nem teljesül, és ne kelljen megismételni a feltételt, immáron tagadva, és ez az else ág:

if ([feltétel]) {
    [utasítások 1];
} else {
    [utasítások 2];
}

Lássunk erre egy példát!

// Main.java
public class Main {
    public static void main(String[] args) {
        int x = 5;
        if (x % 2 == 1) {
            System.out.println("páratlan");
        } else {
            System.out.println("páros");
        }
    }
}

Persze az else is folytatódhat rögtön egy újabb if utasítással, majd a következő is stb.:

if ([feltétel 1]) {
    [utasítások 1];
} else if ([feltétel 2]) {
    [utasítások 2];
} else if ([feltétel 3]) {
    [utasítások 3];
} else {
    [utasítások 4];
}

Ez a struktúra leginkább akkor fordul elő, ha van egy változó, ami jól felsorolható értékeket vehet fel (pl. a hét napjai; a felsorolás típusról később még lesz szó), és mindegyik érték esetén mást és mást kell végrehajtani. Ebben az esetben a fenti struktúra ugyan működhet, de kissé áttekinthetetlen; jobb megoldás a switch … case … default struktúra:

switch (változó) {
case [érték 1]:
    [utasítások 1];
    break;
 
case [érték 2]:
    [utasítások 2];
    break;
 
case [érték 3]:
    [utasítások 3];
    break;
 
default:
    [utasítások 4];
}

A struktúra értelmezése: a változó értékétől függően fut le az első, a második, a harmadik vagy a negyedik utasításblokk; ez utóbbi akkor, ha a változó pillanatnyi értéke egyik felsorolt értéket sem veszi fel. Az egyes esetek végén álló break utasítás azt jelenti, hogy a lefutás ne folytatódjon ott, hanem éjen véget. Ha nem tennénk oda, akkor a következő értékre vonatkozó utasítások is lefutnának, és jó eséllyel ezt nem szeretnénk. (Valójában előfordulhatnak olyan esetek, hogy szeretnénk végrehajtani, de ez olyan nehezen észrevehető problémákhoz vezethet, hogy még a kódismétlés is jobb, mint a break szándékos lehagyása.)

Erre is lássunk egy példát!

// Main.java
public class Main {
    public static void main(String[] args) {
        int x = 2;
        switch (x) {
        case 1:
            System.out.println("egy");
            break;
        case 2:
            System.out.println("kettő");
            break;
        default:
            System.out.println("se nem egy, se nem kettő");
        }
    }
}

Ciklus

A számláló ciklus szintaxisa a Java-van az alábbi:

for ([inicializálás]; [feltétel]; [változás]) {
    [utasítások];
}

Először a rendszer végrehajtja az inicializálást (egyszer), majd megvizsgálja a feltételt, ha teljesül, végrehajtja az utasításokat, a végén végrehajtja a változást, ismét megvizsgálja a feltételt, és így tovább. Ha a feltétel nem teljesül, akkor véget ér a ciklus.

Ezt a módszert leggyakrabban arra használjuk, hogy a ciklusmagot (a cikluson belüli utasításokat) adott számszor hajtsuk végre, az alábbi példában 10-szer:

// Main.java
public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            System.out.println(i);
        }
    }
}

A ciklus változót (a példában i) használhatjuk a ciklusmagon belül, viszont az értékét ne változtassuk meg, mert az nehezen felderíthető nem várt problémákhoz vezethet. A gyakorlatban a ciklusváltozók elnevezései i, j, k (egymásba ágyazott ciklusok esetén). Az is gyakran előfordul, hogy a szám, ahányszor a ciklusmagot végre kell hajtani, egy változó aktuális értéke; ez esetben a fenti példában a feltétel nem az lesz, hogy i < 10, hanem i < n.

A for ciklusnak van egy olyan változata is, amely kezdetben nem létezett: adott tömb elemein való végigiterálás:

// Main.java
public class Main {
    public static void main(String[] args) {
        int[] array = {3, 5, 7, 4};
        for (int element : array) {
            System.out.println(element);
        }
    }
}

A példában a ciklusmagon belül az element változó tartalmazza az adott lefutáskor a tömb aktuális elemét.

A for ciklusok közös jellemzője az, hogy előre tudjuk, hányszor fut le a ciklusmag. Ellenben a while ciklus esetén ezt nem tudjuk, ez esetben addig fut a ciklusmag, amíg a feltétel igaz:

while ([feltétel]) {
    [utasítások];
}

A fenti példa ezzel a módszerrel, ami kiírja a számokat 0-tól 9-ig az alábbi:

// Main.java
public class Main {
    public static void main(String[] args) {
        int i = 0;
        while (i < 10) {
            System.out.println(i);
            i++;
        }
    }
}

Ennek egy speciális esete a végtelen ciklus (amit egyébként a for (;;) szintaxissal is el tudunk érni):

while (true) {
    [utasítások];
}

Ez utóbbinál felvetődhet, hogy egy végtelen ciklus hogyan érhet véget. Van két utasítás, ami a ciklus utasításokat vezérli:

break: erről már volt szó; ennek hatására a program kilép a legbelső ciklusból, és az azt követő első utasításra ugrik. Ezt tipikusan valamilyen feltétel hatására hajtja végre, tehát többnyire egy if utasításon belül található.

Lássuk a fenti példát végtelen ciklussal és break; utasítással!

// Main.java
public class Main {
    public static void main(String[] args) {
        int i = 0;
        while (true) {
            System.out.println(i);
            i++;
            if (i == 10) {
                break;
            }
        }
    }
}

Megjegyzés: feltétel nélküli ugró utasítás, azaz goto nincs a Java-ban, ez viszont egy olyan pont, ahol valóban hasznát lehetne venni: teljesen kiugrani egy egybe ágyazott ciklusból. Noha megvalósítható lenne változókkal és feltételkezelésekkel, a nyelv megalkotói mégis beletettek egy lehetőséget: tetszőleges blokk elé helyezhetünk címkét, kettősponttal elválasztva a blokktól, és a break utasításnak megadhatjuk, hogy melyik ciklusra vonatkozik, pl.:

outer : for (...) {
    inner : while (...) {
        ...
        if (...) {
            break outer;
        }
        ...
    }
}

Egy példa:

// Main.java
public class Main {
    public static void main(String[] args) {
        int x = 0;
        outer: for (int i = 0; i < 5; i++) {
            for (int j = 0; j < 5; j++) {
                x++;
                if (x == 15) {
                    break outer;
                }
                System.out.println("(" + i + "," + j + ")");
            }
        }
    }
}

continue: a ciklusmag következő iterációját hajtja végre. Azaz végrehajtja a növekményt (ha van), a feltételvizsgálatot (ha van), és ha a feltétel teljesül, akkor lefuttatja a ciklusmagot.

Egy példa:

// Main.java
public class Main {
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            if (i % 2 == 1) {
                continue;
            }
            System.out.println(i);
        }
    }
}

(Megjegyzés: más programozási nyelvekben létezik még a do … while struktúra is, ami annyiban tér el a while-tól, hogy a ciklusmag legalább egyszer mindig lefut; ilyen a Java-ban nincs.)

Függvények

Az alábbi példa illusztrálja a függvények szintaxisát Java-ban:

public class Fuggveny {
    static int hatvany(int alap, int kitevo) {
        int eredmeny = 1;
        for (int i = 1; i <= kitevo; i++) {
            eredmeny = eredmeny * alap;
        }
        return eredmeny;
    }
 
    public static void main(String[] args) {
        System.out.println(hatvany(2, 3));
    }
}

A static kulcsszó magyarázatát később látni fogjuk, most az azt követő részekre koncentráljunk! A példafüggvény hatványozást valósít meg, melynek két paramétere van: az alap és a kitevő. A már ismert megoldásokkal számolja ki az eredményt, amit a return kulcsszóval adunk vissza a hívó félnek. A hívó oldalon kiírjuk az eredményt.

Egy függvénynek nem feltétlenül van értelmezhető visszatérési értéke, bár visszatérési típust mindenképpen meg kell adni. A Java-ban a void típus jelzi azt hogy az adott függvény nem tér vissza semmivel (ez esetben is használhatjuk a return utasítást, érték nélkül). Az ilyen függvényeket tipikusan azok melléghatásáért szoktuk használni, és helyesebb volna eljárásnak nevezni őket, mivel lényegében semmi közük sincs a matematikai értelemben vett függvényekhez.

Megengedett, hogy ugyanolyan nevű függvény különböző paraméter listákkal szerepeljen, pl. int add(int a, int b), double add(double a, double b), int add(int a, int b, int c). Ezt túlterhelésnek (angolul overloading) nevezzük.

Objektumorientáltság a Java-ban

Arról már volt szó, hogy a Java-ban mindent osztályba kell tenni, nincsenek globális függvények, változók. A programozás oldalon, az objektumorientált programozás alfejezetben leírtak itt is érvényesek; az alapelvek Java-specifikus pontosításai találhatóak itt. A Java-ban igen sok finomhangolásra van lehetőség.

Osztályok

Egy Java forrásfájl egy publikus osztályt tartalmaz, aminek a neve meg kell, hogy egyezzen a fájl nevével. Az osztály deklarációja a következő: public class [Osztálynév], majd kapcsos zárójelben jönnek az attribútumok (azaz osztály szintű változók) és metódusok (osztály szintű függvények). Ahhoz, hogy egy osztályt használni tudjunk, példányosítanunk kell a new kulcsszóval. Az osztály egy példányát objektumnak hívjuk. Az osztály egy elemére ponttal . hivatkozunk.

A következő példához hozzuk létre a MyClass.java és a Main.java forrásokat az alábbi tartalommal:

// MyClass.java
public class MyClass {
    private int param1;
    private int param2;
 
    public MyClass() {
        param1 = 0;
        param2 = 0;
    }
 
    public MyClass(int param1, int param2) {
        this.param1 = param1;
        this.param2 = param2;
    }
 
    public int getSum() {
        return param1 + param2;
    }
 
    public int getProduct() {
        return param1 * param2;
    }
}
 
// Main.java
public class Main {
    public static void main(String[] args) {
        MyClass myClass = new MyClass(2, 3);
        System.out.println(myClass.getSum());
        System.out.println(myClass.getProduct());
    }
}

A visszatérési érték nélküli eljárás, aminek a neve megegyezik az osztály nevével, a konstruktor, melyről lesz még szó később. A this kulcsszó az adott példányra hivatkozik (a konstruktorban ugyanis már meg van a példány).

Tetszőleges osztály tetszőleges példánya felveheti a null értéket, ami az adat hiányára utal.

Öröklődés

Az öröklődést Java-ban az extends kulcsszóval tudjuk megvalósítani. Tekintsük az aábbi példát!

// BaseClass.java
public class BaseClass {
    int baseAttribute;
    void baseFunction() {}
}
 
// SubClass.java
public class SubClass extends BaseClass {
    int subAttribute;
    void subFunction() {}
}

A SubClass nevű osztály a BaseClass leszármazottja, így örökli azok attribútumait és metódusait. A SubClass tehát valójában 2 attribútumot és 2 metódust tartalmaz: egyrészt megörökli a BaseClass-ból a baseAttribute nevű attribútumot és baseFunction nevű metódust, másrészt definiál egy saját attribútumot (subAttribute) és saját metódust (subFunction). Így aki a SubClass-t példányosítja, az mind a 4 elemet eléri. (Ill. hogy pontosan mit ér el és mit nem, azt lejjebb találjuk, a láthatóságról szóló szakaszban.)

A Java-ban öröklődés mindig van. Ha nem írjuk ki, akkor az az osztály a java.lang.Object osztályból öröklődik.

A Java-ban nem lehetséges a többszörös öröklődés. Azokban az esetekben, amelyekben erre lenne szükség, az alábbi lehetőségek közül választhatunk:

  • Leszármazás helyett hivatkozzunk azokra az osztályokra, amelyeket használni szeretnénk. Ebben az esetben lehetséges, hogy a hivatkozott osztályt is módosítanunk kell, pl. ha egy védett (protected) eljárást szeretnénk meghívni.
  • Ha mindenképpen örökölni érdemes akkor kihasználhatjuk azt, hogy egy osztály akármennyi interfészt megvalósíthat (az interfészeket ld. lejjebb). Ehhez elképzelhető, hogy át kell szervezni az osztályhierarchiát.

Felüldefiniálás

A Java programozási nyelvben alapból mindegyik metódus felüldefiniálható. Lássuk az alábbi példát!

// BaseClass.java
public class BaseClass {
    int myFunction(int a, int b) {
        return a + b;
    }
}
 
// SubClass.java
public class SubClass extends BaseClass {
    int myFunction(int a, int b) {
        return a * b;
    }
}
 
// Main.java
public class Main {
    public static void main(String[] args) {
        BaseClass baseClass = new BaseClass();
        SubClass subClass = new SubClass();
        BaseClass baseSubClass = new SubClass();
 
        System.out.println(baseClass.myFunction(2, 3));
        System.out.println(subClass.myFunction(2, 3));
        System.out.println(baseSubClass.myFunction(2, 3));
    }
}

Az alaposztályban definiálunk egy két paraméterű függvényt, ami a paraméterek összegével tér vissza. A leszármazott ezt felüldefiniálja (figyeljük meg, hogy a függvény szignatúrája ugyanaz), és a paraméterek szorzatával tér vissza. A főprogramban háromféleképpen használjuk a fenti függvényt:

  • Az őszosztályt példányosítjuk: ez esetben az ősosztály függvénye fut le, és az összeg lesz az eredmény.
  • A leszármazott osztályt példányosítjuk: ez esetben a leszármazott osztály függvénye fut le, és a szorzat lesz az eredmény.
  • A leszármazott osztályt példányosítjuk, de egy ősosztály típusú változónak adjuk át az értéket. (Ez megengedett az objektumorientált programozásban, sőt, ez adja az objektumorientált programozás egyik lényegét.) Az ősosztályon hívjuk meg a függvényt, az eredmény mégis a szorzat lesz, mert a példányosított objektum tényleges (dinamikus) típusa a leszármazott, és a felüldefiniált metódus fut le.

Megjegyzés: egyes "szigorú" Java fordítók figyelmeztetést adnak akkor, ha felüldefiniálunk egy metódust, mert nem biztos abban, hogy véletlenül tettük-e vagy szándékosan. (Gondoljunk egy összetett osztályhierarchiára, melynek ősosztályaiban rengeteg függvény van; az egyik leszármazottban véletlenül is választhatunk pont ugyanolyan szignatúrát). Az annotációkról még lesz szó részletesebben az Enterprise Java részben; ezek @ karakterrel kezdődő jelölők, ami többnyire a futtató rendszer számára hordoz információt. Annak jelölésére, hogy mi valóban felül szerettük volna definiálni a metódust, a @Override annotációval tudjuk jelezni, a következőképpen:

public class SubClass extends BaseClass {
    @Override
    int myFunction(int a, int b) {
        return a * b;
    }
}

Konstruktor

Java-ban a konstruktor neve megegyezik az osztály nevével, visszatérési értéke nincs. Paramétereket kaphat, és egy osztálynak akárhány konstruktora lehet. Ha egy osztálynak nincs konstruktora, akkor a fordító generál egyet, ami publikus és paraméter nélküli; ezt alapértelmezett konstruktornak (default constructor) nevezzük. Egy konstruktor első utasítása mindig a this(…) vagy a super(…); az előbbi az adott osztály egy másik konstruktorát hívja, az utóbbi pedig az ősosztály konstruktorát. Ezek csak első utasításként szerepelhetnek, egyszerre pontosan egy. Ha nem írunk oda semmit, akkor automatikusan generálódik egy super() (paraméter nélkül). Ez utóbbi egyúttal azt is jelenti, hogy ha nincs az ősosztálynak egy paraméter nélküli konstruktora, akkor nem tudunk konstruktor nélküli leszármazottat létrehozni.

Az alábbi példa két konstruktort tartalmaz: egy alapértelmezettet, ami meghívja a két paraméterrel rendelkezőt:

public class ConstructorExample {
    private int a;
    private int b;
 
    public ConstructorExample(int a, int b) {
        this.a = a;
        this.b = b;
    }
 
    public ConstructorExample() {
        this(0, 0);
    }
}

Igen gyakori egyébként, hogy a paramétereket tartalmazó konstruktor az osztály attribútumainak ad értéket.

A Java-ban hagyományos értelembe vett destruktor nincs. Van egy void finalize() szignatúrájú függvény a java.lang.Object osztályban, ami így mindegyik osztályban szerepel, ezt felül tudjuk definiálni, és a Java szemétgyűjtő (garbage collector) meghívja ezt a függvényt, mielőtt megszüntetné az osztályt, ezt viszont ritkán használjuk.

Interfészek

Interfészt a Java-ban az interface kulcsszóval hozhatunk létre, az osztályhoz hasonlóan. A fájl nevének meg kell egyeznie az interfész nevével. Interfészen belül lehetnek függvény fejlécek (ami lehet statikus vagy nem statikus), valamint konstansok, azaz static és final kulcsszóval ellátott változók, melynek azonnal értéket kell adni. A metódusoknak nincs megvalósítása, azokat pontosvesszővel le kell zárni. Mindegyik elem automatikusan publikus, így a public kulcsszót nem kell kiírni (bár ki szabad), ill. a változó automatikusan public static final lesz. Egy interfész akármennyi más interfészt kiterjeszthet az extends kulcsszóval; a kiterjesztendő interfészek listáját vesszővel kell elválasztani. Az interfészeket nem lehet példányosítani.

Egy osztály akárhány interfészt megvalósíthat, amit az implements kulcsszóval adhatunk meg az osztály deklarációjában, az esetleges osztály kiterjesztést (extends) követően. Lássunk egy példát!

// InterfaceExample.java
public interface InterfaceExample {
    int add(int a, int b);
    int multiply(int a, int b);
}
 
// ImplementationExample.java
public class ImplementationExample implements InterfaceExample {
    public int add(int a, int b) {
        return a + b;
    }
 
    public int multiply(int a, int b) {
        return a * b;
    }
}
 
// Main.java
public class Main {
    public static void main(String[] args) {
        InterfaceExample interfaceExample = new ImplementationExample();
        System.out.println(interfaceExample.add(2, 3));
        System.out.println(interfaceExample.multiply(2, 3));
    }
}

A példában a változó statikus típusa az interfész, és a megvalósítást példányosítottuk. Igen gyakori, hogy pl. egy függvény paramétere interfész típusú, és megvalósítást adunk át neki.

Felsorolás

A Java-ban létezik felsorolás típus, amit az enum kulcsszóval lehet létrehozni. Hasonlóan működik, min a class vagy az interfész: a forrásfájl nevének meg kell egyeznie az enum nevével, a kiterjesztés itt is .java, de máshol, pl. osztályon belül is létre tudjuk hozni. Az enum konstansok vesszővel elválasztott felsorolása, amelyek implicit módon statikusak, publikusak és programból megváltoztathatatlanok. Szokás szerint a lista elemeit csupa nagybetűvel írjuk. A felsorolás egy konkrét elemére ponttal (.) hivatkozunk. Gyakran a már bemutatott switch utasítással együtt szoktuk használni.

Az alábbi példa osztályon belül hoz léte egy felsorolást, ami a hét napjait tartalmazza, majd egy függvény a paraméterül kapott nap függvényében kiír valamit. A főprogram egy konkrét nappal meghívja.

public class Main {
    public enum Day {
        MONDAY,
        TUESDAY,
        WEDNESDAY,
        THURSDAY,
        FRIDAY,
        SATURDAY,
        SUNDAY
    }
 
    static void printDay(Day day) {
        System.out.println(day);
        switch (day) {
        case MONDAY:
            System.out.println("The week has just started. Bad luck...");
            break;
        case TUESDAY:
            System.out.println("Onde day already passed this week!");
            break;
        case WEDNESDAY:
            System.out.println("Middle of the working week.");
            break;
        case THURSDAY:
            System.out.println("The weekend is slowly coming...");
            break;
        case FRIDAY:
            System.out.println("This is the last working day this week!");
            break;
        case SATURDAY:
            System.out.println("The weekend has started! Enjoy!");
            break;
        case SUNDAY:
            System.out.println("Enjoy the weekend, but prepare for the next week.");
            break;
        default:
            System.out.println("Definitely there must be an error!");
        }
    }
 
    public static void main(String[] args) {
        printDay(Day.FRIDAY);
    }
}

A fenti példa a switch … case … default struktúrát is jól illusztrálja: mindegyik case ágat egy break utasítás zár le, és van default is.

Csomagok

A Java az osztályok nagyobb logikai egységbe szervezését a csomagok (package) létrehozásával oldotta meg. A csomagokat fastruktúrába szervezhetjük, pontosan úgy, ahogy a fájlok szerepelnek a fájlrendszerben. Az egyes elemeket ponttal (.) választjuk el. A Java szigorú rendet vár el: a csomagstruktúrának pontosan meg kell felelnie a forrásoknak a fájlrendszerben elfoglalt helyével. Tehát pl. a package1.package2.package3.MyClass osztályt megvalósító forrásfájl (melynek neve MyClass.java) a package3 nevű könyvtárban kell, hogy legyen, ami a package2 könyvtárban ami végül a package1 könyvtárban.

A forrásfájl első kötelező utasítása a package, amely megmondja, hogy az adott osztály mely csomagban van. (Idáig amiatt nem szerepelt, mert ha a gyökérbe tesszük a forrást, akkor nem kell megadni - ez az egyetlen kivétel. Az egy-két osztályból álló példaprogramokat lehet "ömleszteni", de nagy általánosságban elmondható, hogy célszerű létrehozni csomaghierarchiát.)

Csomagon belül minden nehézség nélkül tudunk más elemre (pl. osztályra, interfészre) hivatkozni. Csomagon kívül viszont meg kell adnunk, hogy hol található az adott elem definíciója. Ezt az import kulcsszóval tudjuk megadni, amely tipikusan a package után következik, és a gyakorlatban gyakran több tucat elemet tartalmaz (mindegyik import új sorba kerül.) Erre amiatt van szükség, mert ugyanolyan nevű osztályok lehetnek az elérhető osztályok között (az ún. classpath-on; pl. csak abból, hogy Process, van nálam 3), és meg kell tudnunk mondani, hogy melyiket szeretnénk használni. Elvben az is előfordulhat, hogy a különböző helyen levű, ugyanolyan nevű osztályokat szeretnénk használni; ez esetben egyiket sem importjuk, hanem a használat helyén megadjuk a teljes elérési útvonalat.

Általában egyesével adjuk meg az osztályok importját, de a nyelv megengedi azt is, hogy adott csomagon belül az összes osztályt importáljuk; ez esetben csillagot (*) kell tenni az import utasítás végére. A Java nyelv alaposztályai a java.lang csomagon belül vannak, azokat nem kell importálni; ezt úgy képzeljük el, mintha mindegyik forrásban lenne egy implicit import java.lang.*; utasítás. Ilyen osztály pl. az Object, a String és az Exception.

Az alábbi példa a csomagok használatát illusztrálja:

// com/mycompany/mypackage/MyClassOne.java
package com.mycompany.mypackage;
 
import com.mycompany.myanotherpackage.MyClassTwo;
 
public class MyClassOne {
    MyClassTwo myclassTwo;
}
 
// com/mycompany/myanotherpackage/MyClassTwo.java
package com.mycompany.myanotherpackage;
 
public class MyClassTwo {}

Az import azt sugallja, mintha betöltene valamit; valójában azon kívül nem csinál mást, mint jelzi a fordítónak, hogy az adott osztály hol található. Tehát nem ekvivalens a C+ #include utasításával, ami valóban betölti a fejlécet és a függőségeit, kifejti a makrókat stb. A puszta jelölésnél annyival több haszna van ennek, hogy már a fejlécben kiderül az esetleges fordítási hiba, ha nem elérhető a betöltendő osztály.

A példában látható még az a gyakorlat, hogy milyen konvenciót érdemes használni a csomag elnevezése során: az első 2 szint a szoftverfejlesztő cégre vonatkozik (a honlapja URL-ének a fordítottja), utána általában a fő projekt neve következik, és csak ezt követően jönnek az adott feladatot megvalósító komponensekhez tartozó csomagok.

Ezzel máris elérkeztünk a csomagokhoz képest is nagyobb logikai egységekhez: a könyvtárakhoz, melyek kiterjesztése .jar, és a megfelelő csomagnév adással azok is hierarchiába szerveződnek.

Láthatóság

A fenti példákban többször találkoztunk (idáig magyarázat nélkül) a public és private kulcsszót. Egy osztályon belül attribútumok, metódusok és egyéb egyéb elemek esetén az alábbiakat használhatjuk:

Mindegyik attribútumnak és metódusnak van láthatósága, ami Java-ban az alábbi 4 lehetőség egyike lehet:

  • public: publikus, bármely másik osztály elérheti.
  • protected: a leszármazott osztályok, valamint az adott csomagban szereplő többi osztály elérheti, a többi nem.
  • alapértelmezett: csak az adott csomagban szereplő osztályok érhetik el.
  • private: csak az adott osztály érheti el.
public class AccessModifiers {
    private int myPrivateAttribute;
    int myPackagePrivateAttribute;
    protected void myProtectedFunction() {}
    public void myPublicMethod() {}
}

Speciális esetek

A leggyakoribb objektumorientált témákon túl vannak még ritkábban használt részek is. Első körben ez akár ki is hagyható, esetleg érdemes csak egyszer átfutni rajta, és később visszatérni rájuk.

Több osztályt tartalmazó forrásfájlok

Egy forrásfájl több osztályt is tartalmazhat, de ezek közül csak egy lehet publikus: melynek neve megegyezik a fájl nevével.

public class PublicClass {}
class PrivateClass1 {}
class PrivateClass2 {}

Noha a nyelv megengedi ezt célszerű elkerülni, és ragaszkodni az egy forrás - egy osztály felálláshoz.

Belső osztályok

A Java-ban osztályon belül is deklarálhatunk osztályt, ezt belső osztálynak (angolul inner class) nevezzük. Ez nem magához az osztályhoz, hanem annak egy példányához tartozik, így a belső osztály példányosításához először a befoglaló osztályt kell példányosítani. A belső osztály eléri a külső osztály attribútumait és metódusait.

// MyClass.java
public class MyClass {
    class MyInnerClass {
        ...
    }
    ...
}
 
// Main.java
public class Main {
    public static void main(String[] args) {
        MyClass myClass = new MyClass();
        MyClass.MyInnerClass myInnerClass = myClass.new MyInnerClass();
        ...
    }
}

Az osztályon belüli osztály lehet statikus (static class MyInnerClass); ez esetben a példányosításhoz nem kell osztálypéldány, hanem csak maga az osztály (MyClass.MyInnerClass myInnerClass = new MyClass.MyInnerClass();).

Absztrakt osztályok és függvények

Az eddigi osztályok mind példányosíthatóak voltak. Előfordulhat azonban az, hogy a fejlesztő nem szeretné, ha az osztályát bárki is közvetlenül példányosítaná, leginkább amiatt, mert egy-egy függvény nincs megvalósítva, anélkül pedig hibás működést eredményezne. Ez esetben kötelező leszármaztatni az adott osztályból, megvalósítani a hiányzó metódusokat, és a leszármazottakat tudjuk példányosítani. Hivatkozni persze továbbra is tudunk az absztrakt ősosztályra. Egy absztrakt osztály leszármazottja is maradhat absztrakt; ez esetben abból kell leszármaztatni.

Az abstract kulcs szóval tudjuk jelezni azt, hogy az osztály absztrakt. Az absztrakt osztályokban szereplő absztrakt függvényeket szintén az abstract kulcsszóval jelezzük; ennek nincs megvalósítása (még üres sem), azt pontosvesszővel (;) kell lezárni.

Az alábbi példa az absztrakt osztály mechanizmust illusztrálja.

// AbstractClass.java
public abstract class AbstractClass {
    public int add(int a, int b) {
        return a + b;
    }
 
    public abstract int multiply(int a, int b);
}
 
// ConcreteClass.java
    public class ConcreteClass extends AbstractClass {
    @Override
    public int multiply(int a, int b) {
        return a * b;
    }
}
 
// Main.java
public class Main {
    public static void main(String[] args) {
        AbstractClass abstractExample = new ConcreteClass();
        System.out.println(abstractExample.multiply(2, 3));
    }
}

Lényeges különbség az absztrakt osztályok és az interfészek között, hogy az absztrakt osztályokban van üzleti logika, azok "majdnem" példányosíthatóak, általában csak egy-két függvényt kell megvalósítani, míg az interfészek esetén tényleg csak interfészről beszélünk, és mindent meg kell valósítani.

Név nélküli osztályok

Az osztályok létrehozása Java-ban nehézkes: létre kell hozni külön forrásfájlt, gyakran az se nyilvánvaló, hogy melyik csomagba kerüljön (esetleg csomagot és létre kell hozni számára), és előfordulhat, hogy csak egyszer szeretnénk használni, még nevet sem szeretnénk neki adni. Az ilyen célra hozták létre a Java nyelv megalkotói a név nélküli osztályokat (anonymous class): segítségével pl. egy absztrakt osztályt nem kell példányosítani, hanem elég "röptében" (on the fly) megvalósítani az absztrakt függvényeket.

Vegyük a fenti példát! Az AbstractClass-t hagyjuk meg, a ConcreteClass-t töröljük ki, és írjuk át a példányosítást úgy, hogy ne kelljen megvalósítani nevesítve az AbstractClass-t. A megoldás az alábbi:

public class Main {
    public static void main(String[] args) {
        AbstractClass abstractExample = new AbstractClass() {
            @Override
            public int multiply(int a, int b) {
                return a * b;
            }
        };
        System.out.println(abstractExample.multiply(2, 3));
    }
}

A final kulcsszó

Az objektumorientált programozás egyik lényege az, hogy módosítani, kiterjeszteni tudjuk a meglevő kódot anélkül, hogy ahhoz hozzányúlnánk. Vannak viszont esetek, amikor ezt kifejezetten meg szeretnénk tiltani, ilyen-olyan oknál fogva. A final kulcsszót tudjuk használni erre a célra, a következő esetekben:

  • Osztályok: ezeket az osztályokat nem lehet kiterjeszteni. Szintaxis: public final class MyFinalClass. Az abstract és a final természetesen kizárják egymást.
  • Függvények: a final kulcsszóval ellátott függvényt nem lehet felüldefiniálni a leszármazott osztályban.
  • Változók: a final kulacsszóval ellátott változóknak nem tudunk új értéket adni, így gyakorlatilag azok konstansok. Mivel nem lehet neki utólag más értéket adni, így azonnal értéket kell neki adni. Ez alól egyetlen kivétel van: a final attribútumok kaphatnak értéket a konstruktorban.

Statikus import

A fenti importálás szakaszban nem fejtettem ki a lehetőségek minden szegletét: a Java 1.5-ös verziójától kezdve bevezetésre került a statikus import. Enélkül a statikus függvényeket csak osztálynévvel tudtuk meghívni; a statikus importtal osztályban levő függvényre hivatkozhatunk. Leggyakrabban az egységtesztelésnél használjuk, ahol a különböző assert függvények statikusok, és egyszerűbb és átláthatóbb lesz a kód, ha a fejlécben van egy statikus import; nagy általánosságban viszont inkább célszerű ezt a gyakorlatot elkerülni kerülni.

Az alábbi példa a szintaxist mutatja meg. A bonyolultabb matematikai műveletek a java.lang.Math osztályban szerepelnek statikus függvényekként; a példa azt mutatja be, hogy a hatványozást hogyan tudjuk végrehajtani anélkül, hogy ki kellene írnunk a Math. előtagot.

import static java.lang.Math.pow;
 
public class Main {
    public static void main(String[] args) {
        System.out.println(Math.pow(2, 3));
        System.out.println(pow(2, 3));
    }
}

Interfész alapértelmezett megvalósítással

A Java 8-ban megjelent az a lehetőség, hogy egy interfészen belül egy metódusnak alapértelmezett megvalósítást adhatunk. Ezt a default kulcsszóval tudjuk megtenni (ez a metódus visszatérési típusa elé kerül), és ez esetben adhatunk megvalósítást.

Nem jó gyakorlat interfész metódusokat megvalósítani interfész szinten; az oka annak, hogy bevezették az az, hogy ha van egy létező interfész, amit számos osztály megvalósít, és az interfészbe bele szeretnénk tenni egy új metódust, amire esetleg a legtöbb megvalósító osztálynak nincs is szüksége, akkor ne kényszerítsük az összes osztályt arra, hogy számára felesleges kóddal szemetelje össze magát. Ehelyett adjunk meg egy triviális megvalósítást, ami pl. kiírja, hogy ez még nincs megvalósítva, vagy ad egy triviális megvalósítást.

Felmerül a kérdés, hogy ha osztályokban lehet megvalósítás nélküli (absztrakt) metódusokat létrehozni, interfészekben pedig tudunk alapértelmezett megvalósítást adni, akkor mi a különbség az osztály és interfész között, és melyiket használjuk. Az osztály és interfész között elég sok különbség van, de ami a legfontosabb: amíg az osztályon belül absztrakt függvények létezése az objektumorientált programozás lényegét képezik, addig az alapértelmezett megvalósítással rendelkező interfészek kerülendők.

public static void main(String[] args)

Ezen a potot elvileg már minden rendelkezésünkre áll ahhoz, hogy megnézzük, mit is jelent pontosan a Java programok public static void main(String[] args) belépési pontja! Haladjunk sorban!

  • public: ez a függvény publikus, kívülről is látható. Ha nem lenne publikus, akkor csak belülről lehetne hívni, így nem tudnánk elindítani a programot.
  • static: a függvény statikus, azaz a tartalmazó osztályt nem kell példányosítani ahhoz, hogy meg tudjuk hívni.
  • void: ez a függvény visszatérési típusa, tehát nincs visszatérési értéke.
  • main: ez a függvény neve. Ha nem így nevezzük el, akkor az nem okoz fordítási hibát, csak épp nem indul el; a program belépési pontjának a neve kötelezően main.
  • String: a függvény paramétere szöveges. A String nem alap típus, hanem ez is egy osztály, melyről később lesz szó részletesebben.
  • []: a függvény paramétere valójában nem is egy egyszerű szöveg, hanem szövegek (szavak) tömbje.
  • args: a programnak átadott paramétereket ebből a változóból tudjuk kiolvasni. Valahogy el kell nevezni, a név itt mindegy, az args egy olyan konvenció, amit célszerű követni.

Kivételkezelés

Emlékeztetőül: a kivételkezelés célja az, hogy ha egy metódus valamilyen hiba miatt nem tudja folytatni a futását, akkor ezt jelezni tudja a hívó metódusnak anélkül, hogy a visszatérési értéket fel kellene készíteni erre.

Áttekintés

A Java nyelv tartalmaz egy igen jól felépített kivételkezelés rendszert. A metódusok fejlécébe, a név és a paraméterlista után throws kulcsszóval tudjuk felsorolni a vesszővel elválasztott kivételeket, amit az adott metódus dobni tud. A kivétel dobása a következőképpen történik: példányosítani kell a kivétel osztályt (ami normál esetben a beépített Exception vagy annak leszármazottja; erről még lesz szó bővebben), és a throw kulcsszóval tudjuk eldobni, pl. így: throw new Exception("Error");

Azok a metódusok, amelyek azt hívják, amely kivételt dobhat, dönthetnek: vagy tovább engedik, vagy elkapják. Az előző esetben a hívó függvénynek is fel kell sorolnia a fejlécben a lehetséges kivételeket a már bemutatott throws kulcsszó használatával. Az utóbbi estben a függvényhívást egy try … catch struktúrába kell helyezni, ahol a hívás a try utáni blokkba kerül, ami után közvetlenül következnek a lehetséges kivétel elkapások (catch).

Az egész struktúrát egy opcionális finally zárhatja le, ami mindenképpen lefut, akár történt kivétel, akár nem. A finally akkor is lefut, ha a try vagy valamelyik catch blokkon belül volt egy return utasítás, vagy egy újabb throw volt benne. A finally egyetlen esetben nem fut le: ha van egy System.exit(); hívás, ami a program fizikai végét jelenti. A finally blokk általában olyan utasításokat tartalmaz, amelyeket a sikerességtől függetlenül végre kell hajtani, pl. egy adatbázis olvasást követően a kapcsolat lezárása (amit akár sikerült az olvasás, akár nem, végre kell hajtani).

Ha kivétel történik a try blokkon belül, akkor az azt követő utasítások nem hajtódnak végre. Emiatt van egyébként értelme annak is, hogy egy olyan struktúrát hozzunk létre, hogy a try blokk után nincs catch, hanem egyből finally.

Lássunk egy példát!

class MyExceptionA extends Exception {}
class MyExceptionB extends Exception {}
 
public class ExceptionExample {
    public static double myFunction(int i) throws MyExceptionA, MyExceptionB {
        if (i < 0) {throw new MyExceptionA();}
        if (i == 0) {throw new MyExceptionB();}
        return i * i;
    }
 
    public static void main(String[] args) {
        for (int i = -1; i <= 1; i++) {
            try {
                System.out.println("Calling myFunction(" + i + ")");
                System.out.println(ExceptionExample.myFunction(i));
                System.out.println("Call myFunction(" + i + ") successfully finished");
            } catch (MyExceptionA e) {
                System.out.println("Call myFunction(" + i + ") finished with exception A");
                e.printStackTrace();
            } catch (MyExceptionB e) {
                System.out.println("Call myFunction(" + i + ") finished with exception B");
                e.printStackTrace();
            } finally {
                System.out.println("Call finished with parameter " + i);
            }
        }
    }
}

A példában létrehoztunk két kivételt: MyExceptionA és MyExceptionB. Figyeljük meg a két konstruktort. Az ExceptionExample osztályban található myFunctionB() dobhatja ezeket a kivételeket, ami a fejlécében található felsorolásból is látható, valamint a kódban a kivétel kiváltódása is. A myFunctionA() hívja a fenti függvényt. Abban nincs külön kivételkezelés, tovább dobja a kivételt. Így annak a fejlécében is fel kell sorolni a lehetséges kivételeket. A kivételkezelés a hívó oldalon, a main függvényben található. Ha kivétel váltódik ki a myFunctionA() függvény hívásakor, akkor a harmadik println() nem fut le, hanem a megfelelő catch ág, valamint a finally. A a.printStackTrace() hívás igen gyakori; ez kiírja a kivétel nevét, a paraméterül átadott hibaüzenetet, valamint a hívási láncot. Ez utóbbi megmutatja, hogy pontosan hol történt a kivétel, ami két szempontból is megkönnyíti a hibakeresést: egyrészt pontosan megmutatja, hogy ki hívott kit, másrészt ez általában igen hosszú, és látványosan "virít" a naplófájlban.

A fenti programnak a kimenete az alábbi:

Calling myFunction(-1)
Call myFunction(-1) finished with exception A
basics.MyExceptionA
Call finished with parameter -1
Calling myFunction(0)
Call myFunction(0) finished with exception B
    at basics.ExceptionExample.myFunction(ExceptionExample.java:8)
    at basics.ExceptionExample.main(ExceptionExample.java:17)
basics.MyExceptionB
    at basics.ExceptionExample.myFunction(ExceptionExample.java:9)
    at basics.ExceptionExample.main(ExceptionExample.java:17)
Call finished with parameter 0
Calling myFunction(1)
1.0
Call myFunction(1) successfully finished
Call finished with parameter 1

Részletek

A kivételkezelés idáig is elég bonyolultnak tűnik, de a Java nyelv alkotói még inkább tökélyre fejlesztették. A fenti példában a kivételek az Exception (beépített) osztályból származnak. Valójában a catch nemcsak az Exception osztály leszármazottjait kaphatja el, hanem a Throwable osztályét. A kivételkezelés osztálystruktúrája az alábbi:

exceptionhierarchy.png

Ha a kivételkezelést a fenti módon alkalmaznánk, akkor hamar rájönnénk arra, hogy túl sok lenne a kivétel, amit egész egyszerűen nem tudnánk kezelni, a teljes kód tele lenne lehetséges kivételekkel. Így megszületett az ellenőrzött (checked) és nem ellenőrzött (unchecked) kivétel fogalma. Ez utóbbi kategóriába tartozó kivételeket nem kell külön kezelni: az ha kiváltódik, végig fut a hívási láncon és hibát ír ki. Ezek tipikusan olyan hibák, amit kezelni nem lehet, hanem javítani kell. Ilyen pl. a nullával való osztás, amikor egy ArithmeticException váltódik ki. Ha nem lenne nem ellenőrzött kivétel, akkor pl. minden olyan eljárás fejlécébe, amelyben van osztás, ki kellene tenni a nullával való osztás kivételét, még akkor is, ha biztosak vagyunk abban, hogy ezt megfelelően kezeltük. Vagy pl. ha lenne benne tömb, akkor minden esetben foglalkoznunk kellene a tömb túlcsordulással. És mivel az eljárások öröklik a meghívott eljárások kivételeit, a legtöbb eljárás fejlécében tucatnyi kivétel lehetőséget kellene felsorolni. A nem ellenőrzött kivételeket nem kell felsorolni, csak az ellenőrzötteket.

A fenti ábrán látható komponensek a következők:

  • Throwable: minden "dobható" komponens ősosztálya.
  • Exception: az alapértelmezett kivételosztály; ha ellenőrzött kivételeket szeretnénk létrehozni, akkor ezt kell használni.
  • RuntimeException: a nem ellenőrzött kivételek ősosztálya. Akkor kell alkalmaznunk, ha biztosan vagyunk abban, hogy nem lehet a problémát futásidőben lekezelni. A gyakorlatban ilyet ritkán hozunk létre.
  • Error: ez sem ellenőrzött, viszont a catch Exception nem kapja el. Ezek általában Java virtuális gép szintű hibák, amelyeket döntően nem tudunk futási időben kezelni, pl. elfogyott a memória. A gyakorlatban ilyet szinte sohasem hozunk létre, és sohasem próbáljuk elkapni.

Néhány gyakori, a rendszer által definiált kivétel:

  • Ellenőrzött:
    • IOException: írás-olvasási hiba, pl. nem létező fájlból szeretnénk olvasni, vagy úgy szeretnénk elérni egy távoli számítógépet, hogy lejárt az internet előfizetés.
    • ClassNotFoundException: ez egy futásidejű hiba, és a virtuális gép nem talál egy osztályt. A leggyakrabban induláskor lép fel, amikor rosszul adtuk meg a főosztályt, pl. elfelejtettük megadni a csomagot.
  • Nem ellenőrzött:
    • NullPointerException: ha egy objektum értéke null és megpróbálunk hivatkozni valamelyik attribútumára vagy metódusára.
    • ArrayIndexOutOfBoundsException: ha egy tömb nem létező elemét címezzük meg (pl. a tömb 5 elemű, és mi a nyolcadiknak szeretnénk értéket adni).
    • NumberFormatException: ha egy String-et számmá szeretnénk alakítani, de nem megfelelő a formátum, pl. "alma".
    • ArithmeticException: nem értelmezett matematika művelet, pl. 0-val való osztás.
    • ClassCastException: ha át szeretnénk alakítani egy osztályt egy másikká, de nem sikerül. Pl. vagy egy Állat ősosztályunk, abból származik a Kutya és a Macska, van egy Kutya objektumunk, amit Macskává szeretnénk konvertálni.

A kivételkezelés továbbfejlesztése

A Java haladó témák oldalon olvashatunk a kivételkezelés következő továbbfejlesztéseiről:

  • try with resources: a Java 7-ben megjelent lehetőségként a try-nak átadhatunk egy Closable interfész megvalósítást, és az lekezeli a lezárást, nem kell a finally-ban nekünk megtennünk. Valójában enélkül igencsak elbonyolódik a kód.
  • Több kivétel elkapása egyszerre: a catch (MyExceptionA | MyExceptionB e) szintaxis.

Csomagoló típusok

A Java 5-ben megjelentek a Java-ban az ún. csomagoló (wrapper) típusok. Ezek olyan osztályok, amelyek egy primitív típust tartalmaznak, valamint hozzá tartozó műveleteket. Pl. az Integer osztály int-et tartalmaz, a Double egy double-t stb. Ez a következő előnyökkel jár:

  • Segítségével tudjuk jelezni azt, hogy egy adat hiányzik. Primitív típusok esetén ez gyakran problémához vezetett. Ott gyakori megoldás volt pl. az, hogy a 0 jelezte az adat hiányát, de ha az egy elképzelhető érték volt (pl. emberek száma), akkor egy olyan értéket kellett használni, ami egyértelműen nem lehet valódi (pl. -1). Viszont ez csökkentette a kód olvashatóságát, valamint hibákhoz is vezethetett (gondoljunk pl. arra, hogy összeadjuk az emberek számát, de elfelejtjük kezelni a -1-et). A csomagoló osztályok esetén a null érték jelzi azt, hogy az adat hiányzik.
  • Számos műveletet tudunk segítségével végrehajtani, amit esetleg felül is tudunk definiálni.

A Java-ban létezik az ún. autoboxing, ami azt jelenti, hogy szükség esetén a rendszer automatikusan átkonvertálja egyik típusból a másikba, pl. int-ből Integer-be (pl. egy paraméter átadásakor, ha a meghívandó függvény Integer-t vár), vagy Integer-ből int-be (pl. egy művelet végrehajtásakor).

Lássunk egy példát!

// Main.java
public class Main {
    public static void main(String[] args) {
        int a = 5;
        Integer b = new Integer(a);
        Integer c = 3;
        System.out.println(b + c + 2);
    }
}

A b változó létrehozása a "gyalogos" módszer: átadjuk az Integer osztály konstruktorának a paramétert. A c megadásánál kihasználtuk az autoboxingot: ott a fordító automatikusan becsomagolja nekünk a 3-at. A kiírásnál ismét kihasználtuk az autoboxingot, de most a másik irányba: ott automatikusan meghívja a fordító az intValue() függvényt, ami visszaadja a becsomagolt értéket, így az összeadás eredménye 10 lesz.
A példából is látszik, hogy végső soron ugyanúgy használhatjuk a becsomagolt típusokat mint a primitíveket, viszont azzal az előnnyel jár, hogy hiányzó adat esetén megadhatunk neki null értéket.

Generikus típusok

A Java-ban a generikus típusok az 5-ös verzióban jelentek meg. Ez lehetővé teszi azt, hogy olyan osztályokat és függvényeket készítsünk, amelyeknél nem adjuk meg a típust, így többféle típussal létrehozhatjuk ill. lefuttathatjuk. A generikus típust csúcsos zárójelek (< és >) közé kell tennünk, pl. így: <T>. Lássunk egy példát!

// GenericsExample.java
public class GenericsExample<T> {
    T object;
 
    public GenericsExample(T object) {
        this.object = object;
    }
 
    public void fancyPrint() {
        System.out.println("> " + object + " <");
    }
}
 
// Main.java
public class Main {
    public static <E extends Comparable<E>> E getMaxElement(E[] inputArray) {
        if (inputArray.length == 0) {
            return null;
        }
        E max = inputArray[0];
        for (E element : inputArray) {
            if (element.compareTo(max) > 0) {
                max = element;
            }
        }
        return max;
    }
 
    public static void main(String[] args) {
        GenericsExample<Integer> geInt = new GenericsExample<Integer>(15);
        geInt.fancyPrint();
 
        GenericsExample<String> geString = new GenericsExample<String>("apple");
        geString.fancyPrint();
 
        Integer[] intArray = {4, 8, 2};
        Double[] doubleArray = {4.89, -5.2, 1.0};
        Character[] charArray = {'a', 'p', 'p', 'l', 'e'};
        System.out.println(getMaxElement(intArray));
        System.out.println(getMaxElement(doubleArray));
        System.out.println(getMaxElement(charArray));
    }
}

A példában a Comparable egy olyan interfész, amely maga is generikus, és egy metódust definiál: int compareTo(o); ha az adott objektum a nagyobb, akkor a visszatérési érték pozitív, ha a másik (azaz a paraméterül átadott o), akkor negatív, és ha egyenlőek, akkor 0.

Az egész értelme leginkább a Collections esetén van, amit lejjebb látunk majd részletesebben.

A GenericsExample osztály egy osztály szintű generikus típust tartalmaz, amit T-vel jelölünk. Az osztályban létrehozott fancyPrint() metódus csúcsos zárójelek közé helyezve kiírja a generikus típusú attribútumot. Ezt a főprogramból kétféleképpen hívjuk meg: először egész számmal (Integer), másodszor szöveges típussal (String).

A második példa a főosztályba került. Ez egy generikus függvény, a generikus típus tehát itt függvény szinten van megadva, nem osztály szinten. A típusnév (E) után találunk egy extends kulcsszót; ezzel azt jelöljük, hogy a konkrét típus nem lehet akármilyen, hanem csak olyan, amely megvalósítja a Comparable interfészt. A függvény maga, kihasználva azt, hogy meghívhatja a compareTo() függvényt, meghatározza a maximális elemet.

Összefoglalva és kiegészítve:

  • A generikus típust csúcsos zárójelbe tesszük, így: <T>
  • A típust általában egy nagybetűs karakterrel adjuk meg. Néhány konvenció:
    • T: általános típus. Ha szükség van továbbiakra: S, U, V stb.
    • K és V: kulcs (key) és érték (value).
    • E: elem (element) különböző adatszerkezetek esetén.
    • N: szám (number).
  • Léteznek generikus osztályok és generikus függvények; a generikus típus jelölést mindkét esetben a név elé kell tenni.
  • Az extends kulcsszóval adhatjuk meg az, hogy a generikus típus csak egy adott interfész megvalósítása vagy adott osztály leszármazottja lehet, pl. <N extends Number>.
  • A generikus típusra az öröklődés nem vonatkozik, pl. az Integer a Number osztályból öröklődik, de a GenericsExample<Number> nem őse a GenericsExample<Integer>-nek, azaz az előbbinek nem adhatjuk utóbbit értékül. (Emlékeztetőül: egy Number típusnak értékül adhatunk Integer-t).
  • Egyszerre megadhatunk több megszorítást is & jellel elválasztva (melyek közül értelemszerűen legfeljebb egy lehet osztály), pl. <T extends Comparable & Serializable>.
  • Paraméterek, mezők, lokális változók és visszatérési értékek esetén használhatjuk a dzsóker paramétert, ami a kérdőjel (<?>). Ennek adhatunk alsó korlátot (pl. ? extends Number) vagy felső korlátot (pl. ? super Integer).
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License