Kihagyás

Objektumok és osztályok

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

Absztrakció

Minden programozási nyelv egyfajta absztrakción alapszik. Minél bonyolultabb a programozás problémája, annál absztraktabb megoldások kellenek. Az assembly programok egy kisebb absztrakciót jelentenek egy adott gép felett. A legtöbb (úgynevezett imperatív nyelv, mint a Fortran, Basic, vagy C) pedig absztrakciónak tekinthető az assembly nyelvek felett, de ezeknél az elsődleges absztrakció még sokkal inkább épül a gép szerkezetére és lehetőségeire, mint magára a megoldandó problémára. A LISP, APL jellegű nyelvek már egy-egy konkrét szempontból vizsgálják a problémát, és magára a problémára fókuszálnak, nem pedig annak kivitelezésére, de ezekkel még csak nagyon korlátozott dolgokat tudunk leírni. Az objektum orientált megközelítések egy lépéssel még közelebb kerülnek a problémához. Az általuk nyújtott alkalmazásfüggetlen reprezentáció elég általános ahhoz, hogy a programozó keze ne legyen megkötve, és ne csak egy bizonyos típusú probléma megoldására alkalmazhassa.

Objektum orientált programozás

Az objektum orientált programozásban az objektumok képezik a probléma elemeinek egy-egy alkalmazhatóság független reprezentációját. A program pedig nem más, mint ezeknek az egymással kommunikáló objektumoknak az összessége. Egy-egy objektumot számos kisebb objektumból állíthatunk össze.

Az egymáshoz igen hasonló objektumok szerkezetét, viselkedését azok absztrakt típusa fogja leírni, amit osztálynak nevezünk, és a class kulcsszóval definiálunk. Az a közös az ugyanolyan típusú objektumokban, hogy azok ugyanolyan üzeneteket fogadhatnak. Ha van egy "Kör" objektumom, ami egyben "Alakzat" is, akkor az fogadhatja azokat az üzeneteket, ami egy "Alakzatnak" érkezhet, de reagálhat rá, mint "Kör" is. A gyakorlatban ez lesz az objektum orientált programok egyik legfontosabb tulajdonsága, amit polimorfizmusnak nevezünk.

Objektum interfésze

Habár minden objektum egyedi, mindegyik objektum része objektumok egy olyan osztályának, amelyek tulajdonságai és viselkedése hasonló. Már Arisztotelész is használta a típus megadására az osztály fogalmat (halak osztálya, madarak osztálya...), de a programozásba a Simula-67 nyelv vezette be a class kulcsszót, amivel egy úgy típust lehet definiálni a programban.

Az osztályok, mint absztrakt adattípusok bevezetésével el is jutottunk az objektum orientált programok alapvető fogalmáig. Ezek az az absztrakt adattípusok ugyanolyan típusai a programnak, mint a beépített típusok, azaz lehet belőlük változókat létrehozni, amelyek állapotát akár befolyásolhatjuk, módosíthatjuk is. Az osztály, mint adattípus leírja, hogy az elemei milyen tulajdonsággal és viselkedéssel rendelkezhetnek, míg egy-egy konkrét objektum, ami az osztály egy adott példánya lesz, egy konkrét állapottal fog rendelkezni. A tulajdonságokat az attribútumok fogják meghatározni, míg a viselkedést azok az operációk, metódusok, amelyeket az adott osztály definiál. Minden egyes viselkedés egy-egy üzenethíváson (metódus híváson) keresztül aktiválható.

Új típusok létrehozása

Technikailag ha a Java programunkban szeretnénk egy új osztályt létrehozni, akkor azt a class kulcsszó után kell definiálnunk:

1
class Alakzat { /* az osztály törzse*/ }

Amelyből osztályból egy objektumot a new kulcsszó segítségével hozhatunk létre:

1
Alakzat a = new Alakzat();

Persze ahhoz, hogy egy osztálynak értelme is legyen, testre kell szabni és el kell látni őt a megfelelő adattagokkal és operációkkal.

Attribútumok hozzáadása

Minden attribútum lehet valamilyen primitív típusú elem, amely egy értéket tárol, de lehet egy osztály típusú referencia is (amit persze létre kell hozni a new-val.)

Bővítsük ki az Alakzat osztályunkat úgy, hogy legyen mindkét fajta attribútuma. Azaz legyen olyan, ami primitív típusú értékeket tárol (szín és terület), illetve legyen olyan, ami egy másik osztály típusú referenciát (Koordináta):

1
2
3
4
5
class Alakzat {
    int szin;
    float terulet;
    Koordinata xy;
}

Ha most példányosítjuk ezt az Alakzatot, ezek az adattagok még "értelmes" értékkel nem rendelkeznek, ezeket be kell állítsuk:

1
2
3
4
5
Alakzat a = new Alakzat();
// ...
a.szin = 1;
a.terulet = 23.99 f;
a.xy = new Koordinata(1, 19);

Látható, hogyha van egy "a" nevű objektumunk, akkor azon keresztül a "." operátor segítségével tudjuk elérni az adattagokat (ld. C struktúrák).

Önmagában persze még ez az osztály mindig csak adattárolására szolgál, ezt még ki tudjuk egészíteni azzal, hogy operációkat is adunk hozzá.

Operációk hozzáadása

Operációk hozzáadásával funkcionalitást is tudunk adni az osztálynak. A metódusok megadása hasonlóan működik, mint ahogy azt láttuk C-ben, hisz meg kell adjuk annak nevét, paramétereit, a paraméterek típusát, és a visszatérési érték típusát is.

1
2
3
visszateresiTipus operacioNev( /* paraméter lista */ ) {
    /* metódus törzs */
}

FONTOS! Csak osztályokon belül lehet metódusokat definiálni, olyan nincs, hogy egy metódus nem része valamely osztálynak (vagy interfésznek, enumnak).

A metódus hívás nem más, mint egy üzenet küldés az adott objektumnak, vagy osztálynak. Legyen a továbbiakban egy szine nevű metódusa is az Alakzat osztálynak, amely képes visszaadni a szin attribútum értékét egy adott objektum esetén:

1
2
3
4
5
6
7
8
class Alakzat {
    int szin;
    float terulet;
    Koordinata xy;
    int szine() {
        return szin;
    }
}

Ekkor a létrehozott "Alakzat" objektumunknak küldhető egy üzenet, amely által az visszaadja a szin adattag értékét:

1
2
3
Alakzat a = new Alakzat();
// ...
int sz = a.szine();

Osztályok használata

Melyik osztályokat használhatjuk adott környezetben? Azokat mindenképp, amik abban a fordítási egységben vannak definiálva megfelelő láthatósággal, amelyben használni szeretnénk. Még akkor is igaz ez, ha azok csak a használat után lesznek definiálva.

Az olyan osztályokat, amik viszont a saját osztályunktól távol vannak definiálva (könyvtári függvények, vagy csak szimplán más könyvtárban (más csomagban) vannak definiálva), azokat az osztályokat vagy importálni kell, vagy a teljes elérhetőségükkel meg kell nevezni.

Például ha a láncolt listákat akarjuk használni, akkor importálni konkrétan azt az osztályt. Ezt így tehetjük meg:

1
import java.util.LinkedList;

Vagy legalábbis a csomagját:

1
import java.util.*;

Ekkor persze nemcsak a láncolt listát, de minden a java.util csomagban definiált adatszerkezetet elérünk az adott fordítási egységben.

A java.lang és a default package tartalma (vagyis a forrás főkönyvtárában definiált adatszerkezetek) automatikusan elérhetőek mindenhonnan, ezeket nem kell importálni.

Interfész és implementáció

Az osztály, mint absztrakt adattípus meghatározza, vagy deklarálja azt, hogy az adott típussal rendelkező objektumoknak milyen üzeneteket küldhetünk. Ez meghatározza az osztály interfészét. Hogy konkrétan az adott osztályra hogyan kell működnie az adott operációnak, azt a metódus törzse implementálja, definiálja.

Példaként figyeljük meg, hogy a Lampa osztály, mint absztrakt adattípus definiálja egy lámpa objektum lehetséges adattagjait, műveleteit, amit az osztálydiagramon is látunk:

cd

Egy konkrét objektum állapotát leírja az objektum diagram:

cd

Azt azonban, hogy ezt konkrétan hogyan fogja az adott osztály megvalósítani, az implementáció határozza meg:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class Lampa {
    private int fenyero = 100;
    public void be() {
        fenyero = 100;
    }
    public void ki() {
        fenyero = 0;
    }
    public void fenyesit() {
        if (fenyero < 100) fenyero++;
    }
    public void tompit() {
        if (fenyero > 0) fenyero--;
    }
}
...
Lampa lampa1 = new Lampa();
lampa1.be();

Implementáció elrejtése

Az objektum orientált programozás közben egyik legnagyobb feladatunk az osztályok gyártása, a másik az osztályok felhasználása. Helyesen akkor programozunk, ha a felhasználónak nem kell látnia azt, hogy az adott osztály hogyan működik, ha elegendő csak azt tudnia, hogyan tudja azt megszólítani a kért funkcionalitás eléréséhez. Lehetőség szerint vigyázni kell arra, hogy külső felhasználó az osztályunkat, illetve objektumaink állapotát "ne tudja" elrontani. Ahhoz, hogy ezt biztosítani tudjuk, úgynevezett elérési módosítókat fogunk használni, amikről később lesz szó részletesebben, most elég annyit tudni, hogy 4 féle láthatóság van, amiből 3-hoz kapcsolódik kulcsszó (private, protected, public), az utolsó pedig az alapértelmezett eset, amire szokás packege privete-ként hivatkozni.

this

Amikor létrehozunk egy objektumot, akkor nyilvánvaló, hogy az objektumhoz tartozó adattagoknak memóriát kell valahol allokálni, hogy azokon keresztül egy-egy objektum állapota rögzíthető legyen. De mi a helyzet a metódusokkal?

Kicsit pazarlónak tűnik az a megoldás (legalábbis a memória igényeket tekintve), hogy mindannyiszor, amikor létrehozunk egy objektumot, annak minden metódusát másoljuk le. Viszont ha ezt nem tesszük, vajon honnan tudja a rendszer, hogy adott metódus meghívásakor mely objektum adatait kell elérni, módosítani?

A válasz nagyon egyszerű. Minden objektumnak van egy úgynevezett this mutatója, vagy ha jobb tetszik referenciája, amivel az adott objektumra hivatkozhatunk. Ez a referencia egy titkos paraméterként átadódik valamennyi esetben, amikor az objektumon keresztül annak egy metódusát meghívjuk.

Térjünk vissza az Alakzatos példára! Tegyük fel, hogy az Alakzat objektumoknak van egy rajzolj nevű metódusa:

1
2
3
class Alakzat {
    public void rajzolj() { /* ... */ }
}

Legyen két alakzat objektumunk, a1 és a2, és mind a kettőnek hívjuk meg a rajzolj metódusát:

1
2
3
4
Alakzat a1 = new Alakzat();
Alakzat a2 = new Alakzat();
a1.rajzolj();
a2.rajzolj();

A valóságban olyan, mintha a this referencia mindkét híváskor átadódna a rajzolj metódusnak, aminek így egyértelmű, hogy az a1, vagy az a2 metóduson keresztül lett meghívva:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// a valóságban ez történik
// a fordító belsejében:

class Alakzat { /*...*/ }
void rajzolj(Alakzat this) {
    /*osztályon kívül van!*/
}

rajzolj(a1);
rajzolj(a2);

A this refenerciát azonban akkor is tudjuk használni, ha valamire konkrétan fel akarjuk használni.

Például elképzelhető, hogy egy metódus paramétere elfedi az objektum egy adattagjának láthatóságát, így az adattagra hivatkozhatunk a this-en keresztül, és akár értékül adhatjuk neki a paraméterben kapott értéket:

1
this.x = x; // az első az attribútum

Az is elképzelhető, hogy visszaadjuk a this-t, mint például ott a kicsinyit metódusban:

1
2
3
4
5
6
7
8
9
class Alakzat {
    public void rajzolj() {
        System.out.println("Alakzat");
    }
    public Alakzat kicsinyit() {
        /*...*/
        return this;
    }
}

Aminek előnye az lesz, hogy a kicsinyítést újra és újra megismételhetjük, mindaddig, amíg az objektum a kívánt méretet el nem éri (feltételezzük, hogy a kicsinyit metódus megvalósítja mindazt a dolgot, amin mi azt értjük, hogy az alakzatot lekicsinyítjük):

1
2
Alakzat a1 = new Alakzat();
a1.kicsinyit().kicsinyit();

Osztálytag

Eddigi példáinkban olyan attribútumok, metódusok szerepeltek, amik meghatározták egy-egy objektum állapotát, viselkedését. Ezek mellett azonban létezhetnek olyan adattagok, metódusok is, amik nem az objektumokhoz, hanem magához az osztályhoz tartoznak. Ezeket független az osztály objektumaitól tudjuk használni, akár az osztály példányosítása nélkül is.

Fizikailag ezek az úgynevezett osztálytagok úgy ismerhetőek fel, hogy szerepel előttük egy static módosító szó. (Pont ilyen a main metódus is, nem is véletlen, hiszen azt is meg tudjuk úgy hívni, hogy az osztályt nem példányosítjuk előtte.)

Osztálytag értelemszerűen lehet egy osztályváltozó, ilyenkor az egyetlen egy példányban lesz jelen a memóriában, fizikailag a statikus memóriaterületen fog tárolódni. Az egyes objektumok osztoznak rajta.

Illetve lehetnek osztálymetódusaink, amelyeknél annyi megkötés van, hogy csak a többi osztálytagot látja (hiszen nem kötődik egyetlen egy konkrét objektumhoz sem), és ebből adódóan a this-re sem tud hivatkozni.

Példánytag

Minden olyan eleme az osztálynak, ami előtt NEM szerepel a static módosító, lesz az osztály példánytagja, amely így egy-egy objektumhoz köthető.

Értelemszerűen lehetnek példányváltozóink, ezek meghatározzák az objektumunk állapotát, és minden objektumra ezek külön definiáltak. Illetve vannak a példánymetódusok, amelyek egyaránt látják az osztály- és példánytagokat, és amelyek a this paraméter által egyértelműen hivatkozni tudják az objektumot, amihez adott meghívásuk kapcsolódik.

Osztálytag és példánytag

Kicsit foglaljuk össze az eddigieket! A statikus metódus meghívható anélkül, hogy az osztályából objektumot hoznánk létre. Viszont fontos, hogy statikus metódusból csak statikus metódus hívható közvetlen (mert persze ha példányosítjuk benne az osztályt, akkor adott objektumon keresztül bármi hívható). És persze statikus metódusból közvetlen csak a statikus adattagok érhetőek el.

Kezdetben még nincsenek objektumok, így csak statikus metódusokat hívhatunk, ezért statikus a main is:

1
public static void main(String[] args)

Statikus metódust nem lehet felüldefiniálni. Ezt egyelőre csak jegyezzük meg, később látni fogjuk, hogy ez mit is jelent valójában. Gyakorlatilag fordítási időben meghatározható, hogy a statikus hívások esetén pontosan melyik metódus fog meghívódni. Vagyis a fordító korai kötést alkalmaz ezen hívások célpontjának meghatározására.

Hivatkozási kör

Nézzük, hogy a program adott pontján milyen elemekre tudunk hivatkozni! Egy adott metódus blokkjában lehet hivatkozni az osztály bármely tagjára, persze azzal a megkötéssel, hogy osztálymetódusokból csak osztálytagokra.

Adattagnál csak olyan adattagra lehet hivatkozni, ami előbb lett definiálva az adott osztályban. Itt is fontos, hogy osztály adattag hivatkozásánál csak másik osztály adattagra hivatkozhatunk.

Adott metódus lokális változóira csak az adott lokális deklarálása után hivatkozhatunk, és ráadásul csak abban a blokkban, amelyben deklarálva lett. A lokális változókat inicializálni is kell felhasználásuk előtt.

Metódusnak és attribútumnak lehet ugyanaz a neve, hiszen a környezetből, ahonnan használni szeretnénk, egyértelműen kiderül, hogy mikor hivatkozunk a változóra, mikor a metódusra. Ugyanakkor ez nem ajánlott programozási technika.

Elképzelhető az is, hogy egy metódus formális paramétere, vagy lokális változója eltakarja az osztály valamely adattagjának láthatóságát. Ilyen esetben az osztályváltozót az osztály nevén keresztül, a példányváltozót pedig a this referencián keresztül érhetjük el.

Implementáció újrafelhasználása

Az objektumorientáltság egyik legfontosabb tulajdonsága az implementáció újrafelhasználásának lehetősége. Ennek egyik legegyszerűbb eszköze egy objektum direkt felhasználása, de azt is csinálhatjuk, hogy egy objektumot egyszerűen beleteszünk egy újonnan létrehozandó osztály adattagjai közé. Ez utóbbi tulajdonképpen a kompozíció, vagy aggregáció.

A kompozíciók általában nagyon rugalmasak. Az objektum adattagjai egy osztálynak általában private láthatóságúak, ami annyit tesz, hogy ezeket osztályon kívülről nem lehet elérni (esetlegesen módosítani), így a kliens számára, aki használja az adott osztályunkat, nem lesznek ezek elérhetőek. Ezek az adattagok a program futása közben dinamikusan lecserélhetőek, így megváltoztatható a program viselkedése dinamikusan.

Például ha adott egy tetszőleges járművünk, annak fontos eleme lehet az, hogy milyen motort teszünk bele. Ilyenkor a motor objektumokat újrafelhasználhatjuk a járművön belül. Jó esetben ez a Motor osztály már jól letesztelt. Így beillesztése egy járműbe csomó többletmunkát spórol meg.

A kompozíciónak köszönhetően pedig az is elképzelhető, hogy adott esetben egy konkrét jármű objektum "fejlesztését" úgy valósítjuk meg, hogy a benne levő motor objektumot lecseréljük egy erősebb példányra.

composition

Interfész újrafelhasználása

Amikor nagyon hasonló osztályokat szeretnénk létrehozni, de azért kicsi módosítások kellenek, akkor fordulhatunk az öröklődés alkalmazásához. Ilyenkor az adott osztály interfészét használjuk fel. Lényeg, hogy "hasonló" osztályokat ne kelljen mindig újra és újra létrehozni, inkább csak a megfelelő részeket "lemásoljuk", esetleg kibővítjük, vagy módosítjuk.

Öröklődéskor az az osztály, amelyből származtatunk egy másikat, lesz az ős, vagy base, vagy super osztály. A származtatott pedig a gyerek.

Ha egy ős változik, akkor változik azzal a gyerek is.

Ha egy ős osztály egy adattagja private láthatóságú, akkor ugyan a származtatott osztály azt direkt módon nem éri el, viszont ugyanúgy a része lesz a származtatott osztálynak is. Az egyéb láthatósággal ellátott elemek viszont elérhetőek a gyermek osztályokban is minden további nélkül.

Öröklődéssel könnyű gyorsan egész nagy osztályhierarchiákat létrehozni. Amire nagyon kell figyelnünk, hogy ezen hierarchiák ne haladják meg a 3-5 szintmagasságot, különben a kódunk nehezen karbantarthatóvá válik.

Öröklődés jelentése

Öröklődéskor ha a hasonlóságot az ős felé szeretnénk kifejezni, akkor azt mondjuk, hogy az adott osztályt általánosítjuk. A gyerek irányában kifejezve a hasonlóságot pedig azt mondjuk, hogy specializáljuk az ős viselkedését.

A származtatott osztály ugyan új típus, ami duplikálja az ős interfészét, a gyermek osztályból származtatott objektum azonban mind az ős, mind a gyerek típusjegyeit magán hordozza.

inheritance

Azaz a fentiek alapján az Alakzat a Negyzet (vagy Kor, vagy Haromszog) általánosítása, míg a Negyzet (Haromszog, Kor) speciális Alakzatok.

A származtatott osztályok duplikálják az ős interfészét, azaz mind a Negyzet, mind a Kor, mind a Haromszog tartalmaz egy szinLekeres nevű metódust, azonban valamennyi módosítja az Alakzat rajzolj metódusát.

Osztályok bővítései

Nemcsak a megörökölt metódusokat tudjuk módosítani, de bővíteni is lehet egy-egy osztály interfészét. Ezt megtehetjük úgy, hogy új adattagokat adunk az osztályhoz, vagy pedig új metódusokat.

Amikor az ős meglévő metódusát módosítjuk, akkor az interfész megmarad, a viselkedést azonban felüldefiniáljuk (overriding).

Referenciák típuskonverziói

Objektumokat konvertálni egyikből a másik típusba csak egy öröklődési hierarchián belül lehet. Általánosságban azonban elmondható, hogy ősosztály típusú referencia mindig értékül kaphat leszármazott osztály típusú referenciát. Leszármazottról az ősre a konverzió implicit, annyi megkötés van, hogy ős típusú objektumként az objektumnak csak az a része érhető el, ami az ős részeként definiált, abból kihivatkozni nem lehet, ezáltal használata biztonságos.

Leszármazott osztály típusú referencia típuskényszerítéssel értékül kaphat ősosztály típusú referenciát, ekkor expliciten meg kell mondani, mely gyermek típusra akarunk konvertálni: (LeszarmazottTipus) osReferencia

Amikor a gyermek típusra konvertálunk egy referenciát, akkor az eredeti statikus típusához (amivel deklarálva lett) képest egy nagyobb memóriaterületre tudunk hivatkozni. Ez veszélyes lehet, ha rossz típusra konvertálunk, és nem arra a dinamikus típusra, amivel az adott objektum definiálva lett.

Egy gyökerű öröklődési hierarchia

A legtöbb objektumorientált nyelvben van egy beépített ős. Ez Java-ban az Object osztály, ami minden osztály őse, így az osztály deklarálásakor nem kell külön jelezni. Az Object osztályban definiált alapfunkcionalitások hasznosak, mert segítik tetszőleges objektumok összehasonlítását, sztringesítését, vagy adott esetben a referencia számolást, ami alapja lesz a szemétgyűjtő mechanizmusnak.


Utolsó frissítés: 2021-05-04 07:53:32