Kihagyás

Objektumok kezelése

Az előadás videója elérhető a itt.

Memória felosztása

Ahhoz, hogy jobban megértsük a dolgok működését, nézzük meg, hogy mi is történik a memóriában egy-egy program futtatása során. Magát a memóriát 5 nagyobb részre oszthatjuk, ezen a területen osztoznak a program különböző elemei:

  1. Regiszterek: ez a leggyorsabb tároló hely, hiszen a processzorban helyezkedik el. Azonban a mérete véges, itt azok az adatok fognak tárolódni, amik az aktuális számításokban résztvesznek.
  2. Stack: a verem a RAM azon része, amely a stack pointer által egy direkt eléréssel rendelkezik a processzor számára. A stack pointer mozgásával tud újabb területeket allokálni, vagy épp felszabadítani. A regiszterek után ez a második leggyorsabban kezelhető memóriaterület. Amikor Java programokkal dolgozunk, akkor azért azt figyelembe kell vennünk, hogy maguk az objektum referenciák a stacken kerülnek tárolásra, a Java programnak pedig folyamatosan figyelni kell, mely referenciára van még hivatkozás, azaz az élettartalma meddig tart, így ez egy kicsit ront a stack flexibilitásán.
  3. Heap: szintén RAM terület, az objektumok tárolási helye. A stackkel ellentétben itt nem kell figyelni, hogy egy terület mikor fog felszabadulni, meddig kell ott az adatot tárolni. Amikor egy objektum számára területet kell allokálnunk, akkor azt egyszerűen megtehetjük a new operátor használatával. Viszont arra felkészülhetünk, hogy ez az allokáció, és utána az esetleges memória felszabadítás jóval lassabban megy, mint a stack esetében.
  4. Statikus/konstans területek: a konstans adatok általában a programmal egy területen kapnak helyet, ami rendben is van, hiszen ezeket az adatokat nem akarjuk módosítani.
  5. Non-RAM tárolók: azon adatok helye, amik a programon kívüli helyekről jönnek, amik akkor is léteznek, amikor a program nem fut.

Objektumok élete

Az eddig tanultakból tudjuk, hogy az objektum orientáltság nem más, mint az egységbezárás, újrafelhasználhatóság és polimorfizmus szenthármasa. Azonban ez nem elég ahhoz, hogy értsük az objektumorientált programunk működését, hiszen nagyon fontos az is, hogy lássuk, mi történik a háttérben a program futása közben.

Általánosan egy objektumorientált nyelv objektumai a stacken, a statikus programterületen és a heapen keletkezhetnek. Javaban viszont csak a heapen jöhetnek létre objektumok, a new operátor által, a többi memóriaterületen a primitív adattípusok elemei tárolódnak, illetve a referencia változók. Láttuk, hogy a stack az egy automatikus, gyorsan elérhető hely, de nem mindig megfelelő egy objektum tárolására (méret!!), a statikus programterület nem flexibilis, viszont gyorsan elérhető, a heap dinamikusan kezelhető, viszont lassabb.

Általános esetben csak a stacken és a statikus tárolón allokált memória szabadul fel automatikusan. A heap felszabadítása csak Javaban lesz automatikus a szemétgyűjtő (garbage collector) által, általános esetben azonban ez nem automatikus. Ennek az automatizmusnak ára van, lassabb lesz miatta a Java programok futása.

Inicializálás - konstruktor

Eddig szó volt arról, hogy a new-val hozzuk létre az objektumokat, de a részletekbe nem mentünk bele, azaz hogy mi is történik pontosan azután, hogy meghívtuk ezt az operátort.

Mivel a régebbi megoldások (nyelvek) hiánya sokszor az volt, hogy elmaradt adatok inicializálása, illetve a memóriaterületek felszabadítása, így ezeket a Javaban megpróbálták orvosolni.

Az inicializálásra megoldás a konstruktor hívása, amely feladata az, hogy megfelelő értékekkel inicializálja egy-egy objektum adattagjait. Ez a konstruktor (aminek neve megegyezik mindig az osztály nevével) mindig meghívódik, amikor a new-val való allokáció megtörténik, így sokkal jobb, mintha csak lenne egy inicializáló metódusunk, amit vagy meghívunk, vagy egyszerűen elfelejtkezünk róla.

Térjünk vissza az alakzatos példához:

class Alakzat {
    /* attribútumok */
    ...
    Alakzat() {
        /* inicializáló kód */
        szin = 0;
        terulet = 0 f;
        xy = new Koordinata(0, 0);
    }
}

Az Alakzat osztály kapott egy olyan konstruktort, aminek üres a paraméter listája. Maga a konstruktor egy speciális metódusnak tekinthető, amelynek nincs visszatérési értéke. Feladata az adattagok inicializálása. A paraméter nélküli (default konstruktor) esetében tetszőleges értékekkel inicializálhatjuk az osztály adattagjait. Ha nem adunk meg konstruktort, akkor a fordító generál egy, a fentihez hasonló konstruktort, amely a primitív adatokat 0-ra, a referenciákat pedig null-ra állítja.

Ha akarjuk, akkor persze a konstruktornak is lehetnek paraméterei, amiket felhasználhatunk az adattagok inicializálására:

class Alakzat {
    /* attribútumok */
    ...
    public Alakzat(int x, int y) {
        /* inicializáló kód */
        szin = 0;
        terulet = 0 f;
        xy = new Koordinata(x, y);
    }
}

Ilyenkor az alakzat létrehozása a következő módon történik:

Alakzat a = new Alakzat(1, 19);

Persze az is elképzelhető, hogy több konstruktora van az osztálynak (overloading), és azt választjuk, amelyik a legmegfelelőbb az adott pillanatban.

A new operátor

Hogyan működik pontosan a new operátor? Szintaktikailag a megadása:
new <OsztályNév>(<argumentumlista>)

Első feladata a new-nak allokálni az objektum számára a megfelelő memóriaterületet, majd meghívja az objektumhoz tartozó konstruktort, illetve visszaadja az objektumra mutató referenciát.

Az objektum osztályát utólag nem lehet módosítani.

Operáció kiterjesztés

Mi van akkor, ha több konstruktora is van az osztálynak? Ez megtehető, feltéve, hogy más a paraméterlistájuk. Azaz ugyanazzal a névvel, de más értelemben használhatjuk őket, ha a paraméterükben különböznek. Ezt nevezzük a programozásban kiterjesztésnek, overloadingnak, amit persze nemcsak a konstruktoroknál, hanem tetszőleges metódusnál alkalmazhatunk.

class Alakzat {
    public Alakzat() {
        /* inicializáló kód */
    }
    public Alakzat(int x, int y) {
        /* inicializáló kód */
    }
}

Ha van több konstruktorunk, akkor az objektum létrehozásakor választhatunk, melyiket fogjuk használni. A paraméterlista egyértelműen meg fogja határozni, hogy melyiket akartuk alkalmazni:

Alakzat a1 = new Alakzat();
Alakzat a2 = new Alakzat(1, 19);

Ha nincs pontos egyezés az aktuális paraméterek és a formális paraméterek között, akkor primitív típusok esetében a paraméter konvertálódik, de csak nagyobb típusra. Ez igaz a sima metódusokra is, azonban fontos, hogy a visszatérési érték sohasem különböztethet meg két metódust, mivel sokszor ezt fel sem használjuk, így viszont a hívási környezetből nem derülne ki, melyik metódus meghívása volt a cél.

A paraméter nélküli konstruktort default konstruktornak is nevezzük. Azért, mert ha más konstruktora nincs az osztálynak, akkor a fordító generál egyet.

Ha van bármilyen konstruktora az osztálynak, a default konstruktor generálása elmarad.

Attribútumok inicializálása

Lokális változókat kötelező expliciten inicializálni, ezt nem teszi meg helyettünk a fordító. Az adattagok azonban inicializálódnak mindig, mégpedig a deklaráció helyén, primitív típusok 0-ra, a nem primitív típusok pedig null-ra.

A deklaráció helyén is megadhatjuk, milyen értékkel iniciálizálódjon egy-egy adattag, de megadhatjuk az inicializálást az úgynevezett instance initialization clause-ban is. Ez egy olyan blokk, ami közvetlen az osztályban van definiálva.

A konstruktor gyakorlatilag csak ez után fog meghívódni.

class A {
    char c; // default
    int i = 1; // definíció helyén
    float f = init(); // függvénnyel
    B a = new B(); // nem primitív
    C c1;
    C c2;
    // instance initialization clause:
    {
        c1 = new C(1);
        c2 = new C(2);
    }
    A() {
        i = 2;
    } // default konstruktorral
    // előbb 1, utána 2
    A(int i) {
        this.i = i;
    }
}

Takarítás

Ha létrehoztunk egy objektumot, akkor annak felszabadításáról is gondoskodni kell. Ezt a Java szerencsére megteszi helyettünk a szemétgyűjtő mechanizmus (garbage collector) segítségével.

Ez persze csak memória felszabadítással foglalkozik, egyéb tevékenységet nem fog elvégezni helyettünk, illetve memória felszabadításnál is csak a new által történt memóriafoglalásokat szabadítja fel (natív hívások által foglalt memóriát pl. nem).

Az, hogy a memória mikor szabadul fel, nincs előre rögzítve, ha elkerülhető, akkor fölöslegesen lassítaná a programot. Mivel kiszámíthatatlan, hogy mikor fog meghívódni, így van lehetőség arra is, hogy kézzel meghívjuk, ha mégis valami miatt ki akarjuk azt kényszeríteni:

System.gc();
System.runFinalization();

Ha van olyan memóriafelszabadítással kapcsolatos dolog, amit a felszabadítás előtt mindenképp meg kell tenni, akkor azt a finalize() metódusba tehetjük meg, de mivel a szemétgyűjtés nem is mindig fut le, így olyan tevékenységet ide semmiképp sem szabad tenni, aminek mindenképpen le kell futnia.

Tömbök

Természetesen előfordul, hogy nem egy, hanem sok azonos típusú objektumot szeretnénk egyszerre létrehozni. Ilyenkor használhatjuk a tömböket. Gyakorlatilag a tömb is, mint egy absztrakt típus jelenik meg a Java-ban, így egy-egy konkrét tömb szintén egy-egy objektum lesz.

Deklarálásakor természetesen meg kell adnunk, hogy milyen típusú elemeket szeretnénk a tömbben tárolni, illetve a deklarációban jeleznünk kell, hogy tömböt deklarálunk, illetve a dimenzió számot is meg kell adjuk. Például egy egy dimenziós egészeket tároló tömböt az alábbi módokon deklarálhatunk:

int[] a1;
int a2[];

Persze ekkor a tömb még csak deklarálva van, memóriát nem foglaltunk számára. Ekkor még csak egy referencia változónk van. Ahhoz, hogy memóriát is foglaljunk, használnunk kell a new operátort:

int[] a1 = new int[i];

ahol a new után megadjuk, mely típus elemeinek szeretnénk a tömböt allokálni, illetve a zárójelek között egy kifejezéssel megadjuk a tömb méretét. Fontos, hogy a tömb ilyenkor "üres", ha primitív típusokat tárolna, akkor az elemei 0-ra, ha referenciákat tárol, akkor null-ra inicializálódik.

Megtehetjük azt is, hogy a tömböt deklarálásakor konkrét tömb elemekkel inicializáljuk:

int[] a1 = {1, 3, 4};

Ilyenkor a méretét az inicializáló lista elemszáma meghatározza.

Az is lehet azonban, hogy a tömb referenciát egy már meglevő tömbre irányítjuk, ilyenkor persze új memórai allokáció nem szükséges:

int[] a2 = a1;

A tömbök mérete futás közben nem módosítható, csak olvasható:

for (int i = 0; i < a1.length; i++)
    System.out.println(a1[i]);

Az, hogy a tömbök, mint objektumok tudják a méretüket, ezt fel is használják, és nem engedik, hogy a tömböt alul, vagy túlindexeljük. Ez bár lassítja a program futását, ugyanakkor biztonságossá teszi.

Ne feledjük, hogyha referenciákat tárolunk a tömbben, akkor a tömb elemeinek a tömbtől független kell memóriát foglalni:

Integer[] a1 = new Integer[3];
a1[0] = new Integer(500);

Integer[] a2 = new Integer[] {
    new Integer(1),
    new Integer(2),
}; // new Integer[] elhagyható

Több dimenziós tömbök kezelése hasonló az egy dimenziós tömbökhöz:

int[][] a2d = { {1,2,3}, {4,5,6} };
int[][][] a3d = new int[2][3][5];

Utolsó frissítés: 2024-04-11 07:54:27