Objektum Orientált Programozás v0.7a Hernyák Zoltán E másolat nem használható fel szabadon, a készülő jegyzet egy munkapéldánya. A teljes jegyzetről, vagy annak bármely részéről bármely másolat készítéséhez a szerző előzetes írásbeli hozzájárulására van szükség. A másolatnak tartalmaznia kell a sokszorosításra vonatkozó korlátozó kitételt is. A jegyzet kizárólag főiskolai oktatási vagy tanulmányi célra használható! A szerző hozzájárulását adja ahhoz, hogy az EKF számítástechnika tanári, és programozó matematikus szakján, a 2001/2002-es tanévben a tárgyat az EKF TO által elfogadott módon felvett hallgatók bármelyike, kizárólag saját maga részére, tanulmányaihoz egyetlen egy példány másolatot készítsen a jegyzetből. A jegyzet e változata még tartalmazhat mind gépelési, mind helyességi hibákat. Az állítások nem mindegyike lett tesztelve teljes körűen. Minden észrevételt, amely valamilyen hibára vonatkozik, örömmel fogadok. Eger, 2001. szeptember 1. Hernyák Zoltán 1
Strukturált programozás Program = adat + algoritmus. A megoldandó probléma szerkezetét kell feltárni, és ezt kell leképezni a programra. Hierarchikus programozásnak is nevezték. A 60-as években Böhm és Jacopini kimondja sejtését, miszerint bármely algoritmus leírható az alábbi 3 vezérlési szerkezet véges sokszori alkalmazásával: szekvencia szelekció iteráció Mills bebizonyítja, hogy minden program felépíthető a fenti 3 vezérlési szerkezetek felhasználásával úgy, hogy a program egységek szekvenciája, de egy egységen belül tetszőleges szerkezet lehet. Egy egységnek csak egy bemenete és egy kimenete van. Objektumorientált programozás OOP 1969-ben Alan Kay diplomamunkájában felvázolja, hogy hogyan kellene személyi számítógépeket készíteni, ezeknek a gépeknek mit kellene tudniuk. Az egyetem elvégzése után a Xerox cégnél helyezkedik el, ahol kidolgozza konkrét elképzeléseit a személyi számítógépről, és kitalálja az objektumorientált programozást, mint módszert. Ez 1972-ben történt: akkoriban még a lyukkártyás adatbevitel volt a legelterjedtebb. Kay szerint a személyi számítógépnek grafikus felhasználói felülettel kel rendelkeznie és a fő beviteli egysége az egér lenne. Leírja, hogy a felhasználó ikonokkal, menürendszerrel, ablakokkal dolgozik. Megtervez egy programozási nyelvet, Smalltalk-nak nevezi el. Ez az első és máig is létező objektumorientált programozási nyelv, amelynek napjainkban is készülnek újabb és újabb változatai, de az alapelvek mindvégig ugyanazok maradtak. A Windows 3.1- ben már megtalálhatjuk Alan Kay elképzeléseit. Objektumorientált programozási nyelvek 1980-as évektől az OOP az aktuális programozási filozófia. Több nyelv is megteremtette a lehetőséget az objektumorientált programozásra, azonban el kell különíteni a tisztán OOP nyelveket azoktól, amelyekben a szokásos eljárás-orientált programozás az alapvető, az objektumorientált programozás csak egy új eszköz, egy lehetőség, amit az alapdolgokon felül lehet használni. Ettől ezek a nyelvek még eljárás-orientáltak maradnak. Egy nyelvet OOP nyelvnek tekinthetünk, ha támogatja az objektumok használatát, mint adatabsztrakció (pl. Modula 2., Ada) minden objektum hozzá van rendelve egy objektum típushoz - osztályhoz. az osztályok képesek örökölni az attribútumokat a szülőosztálytól az objektumok üzenetekkel kommunikálnak egymással az osztályokhoz rendelt metódusok az osztályra jellemző módon működik (akkor is, ha az egy örökölt metódus) támogatja a metódusok címének dinamikus (futásidő alatti) meghatározását. Néhány tisztán OOP nyelv: Smalltalk Eiffel Nyelvek OOP eszközökkel kiegészítve: C++ Turbo Pascal Object Pascal OOP Cobol Objective PL/1 Lisp 2
Objektumorientált fejlesztőeszközök Delphi VisualAge Visual C++ CLOS Turbo Pascal és az OOP kapcsolata Turbo Pascal 5.5 1989. május: Anders Heilsberg vezetésével a Turbo Pascal-t négy új alapszóval egészítették ki (object, constructor, destructor, virtual), amelyek segítségével lehetővé vált objektum-orientált programok írása. További érdekesség, hogy a help-ekbe az eljárások használatát bemutató példaprogramok kerültek, amelyek programírás közben könnyedén átmásolhatóak a forrásszövegbe. Turbo Pascal 6.0 A fordító beépített assemblert tartalmaz, a forrásszövegben elhelyezhetünk assemly nyelven megírt betéteket. Megjelent a PRIVATE kulcsszó, amivel megvalósíthetjuk az adatelrejtés elvét az objektumok belsejében. Az "Extended syntax" ({$X+}) bevezetésével feloldották a Pascal nyelvű fordítók hagyományos szigorúságát, használatával függvényeket meghívhatunk eljárásként amennyiben nincs szükségünk a függvény visszatérési értékére. Turbo Pascal 7.0 Megjelent a PUBLIC kulcsszó, melynek segítségével az objektum definiálása során lehetővé vált explicit módon a publikusság meghatározása, és lehetőség nyílt felváltva PRIVATE és PUBLIC részek deklarálása. Újdonság mindhárom IDE-ben a Syntax highlighting, ami a program elemeinek különböző színnel való kiemelésével gyors felismerést tesz. 3
Objektum-orientált programozás alapfogalmai Az objektumorientált programozás a természetes gondolkodást közelítő programozási mód, amelyet a programozás során a valódi világ hatékonyabb leírására használhatunk. Az objektumorientált módon megírt programok sokkal strukturáltabb, modulárisabb és absztraktabb, mint egy hagyományos módon megírt program. Ezek által egy OO program sokkal könnyebben bővíthető, karbantartható. Az OO nyelvet három fontos dolog jellemez: Egységbezárás Öröklés Többrétűség Az objektumorientált programozásban az eljárásokat és a függvényeket közös, összefoglaló szóval metódusoknak hívjuk. Egységbezárás (encapsulation) Az adatokat, és a hozzájuk tartozó metódusokat egy egységként kezeljük, és elrejtjük őket a külvilág elől. Ezek együtt képeznek egy objektumot. A metódusok implementációs része nem látszik kívülről, csak a specifikáció. Sőt, létezhetnek olyan metódusok is, amelyek egyáltalán nem látszódnak kívülről. Tehát egy objektumnak vannak attribútumai: adatelemekből áll, ezek az adatelemek valamilyen szerkezetet alkotnak és meghatározzák az objektum pillanatnyi állapotát és az objektumnak vannak metódusai, amelyek műveleteket tudnak végezni az objektum adatain. Ha kívülről valamilyen hatás éri az objektumot (üzenet érkezik), akkor az objektum metódusai megváltoztatják az objektum adatait, ezek a metódusok mondják meg, hogyan viselkedik az objektum a külső hatásra. Mindez a valós világ szemléletesebb leírását szolgálja. Egy objektum mindig ismeri az adatait, azokat kezelni (lekérdezni, megváltoztatni) csak az objektum saját metódusaival lehet (szabad). Egyes nyelveknél lehetőség van az objektum adatainak kívülről történő közvetlen megváltoztatására is (pl. Pascal), de kerüljük a használatát, mert nem igazodik a OOP elvekhez és később gondot okozhat. Az azonos attribútumokkal és metódusokkal rendelkező objektumok együttese az objektumosztály (vagy osztály). A programozás során nem az objektumokat (objektum példányokat) hozzuk létre először, hanem az osztályokat definiáljuk, és a meglévő osztályokból hozzuk létre az objektumpéldányokat. Tehát az adatok és a metódusok elsődlegesen az osztályhoz kötődnek. Amikor definiálunk egy osztályt, akkor megmondjuk, hogy annak az osztálynak milyen változói (mezői) lesznek, és ezeket a változókat milyen metódusok (eljárások, függvények) kezelik. Ezek után létrehozhatunk ebből az osztályból egy vagy több objektumot (példányt, konkrét változót), ezek mindegyike külön-külön tartalmazni fogja az osztályban deklarált adatszerkezetet és a hozzájuk tartozó metódusokat. Hasonló ehhez a Pascalban a típusdeklaráció és a változódeklaráció. Az osztály feleltethető meg a típusnak, a példány pedig a változónak. Öröklés (inheritance) Egy definiált osztályból származtathatok egy másik osztályt úgy, hogy a leszármazott osztályban (alosztály) ugyanúgy megtalálható az ősosztály (szülőosztály, szuperosztály) összes attribútuma és metódusa. Az így létrehozott alosztály szintén lehet újabb osztálynak az őse. Az alosztályban módosíthatom az örökölt metódusokat, újabbakat tehetek melléjük és az örökölt adatszerkezetet is bővíthetem. Igazi OOP nyelv lehetőséget teremt örökölt metódusok hatásának felfüggesztésére, ki lehet jelölni, mely metódusokat akarom az örökléssel átvenni. Örökléssel kapcsolatot teremtünk két osztály között: az alosztály bővebb, mint a szülőosztály, mert az alosztály tartalmaz mindent, amit a szülőosztály és ezen felül még tartalmazhat mást is, amiket ebben az osztályban definiálunk. Egy ősből több leszármazottat is létrehozhatunk, amelyeket aztán különböző módon bővíthetünk. Ezek a kapcsolatok az osztályok egy hierarchiáját adják, amit egy irányított gráffal tudunk leírni, ahol a kapcsolat iránya mindig az alosztálytól mutat a szuperosztály felé. 4
Ha a nyelv a származtatásnál csak egy őst enged meg, akkor ez a hierarchia egy fastruktúrát alkot. Ezt a struktúrát öröklődési gráfnak ill. öröklődési fának nevezzük. A program tervezésénél kell megszervezni ezt a hierarchiát, figyelembe véve azt, hogy az öröklődési gráfban korábban szereplő lévő osztályok (az ősök), a rákövetkezőknek valamilyen általánosítása, hiszen bármely leszármazottra azt mondhatjuk, hogy ez olyan mint az ős, de egy dologban valami mást, valamivel többet tud. Ha így kezdjük felépíteni a hierarchiát, akkor ez egy felülről lefelé történő programozás: megfogalmazzuk a problémát általánosan, majd egy-egy dolgot konkretizálva egyre jobban eljutunk a speciális problémához; ezek a lépések tükröződnek az öröklődési gráfban. Ha felfelé mozgunk az öröklődési gráfban: általánosítás, ha lefelé: specializáció. Az általános problémaosztályoknak az az előnyük, hogy később, olyan speciális probléma megoldására is felhasználható, amely abból az általános problémából ered csak más specializáció során. Ez nagyon jól működik a OOP során, mert egy igazi OO nyelvben adott egy előre elkészített osztályhierarchia, és a programozó feladata annyi, hogy megkeressük a konkrét probléma megoldásához felhasználható osztályokat, és azokból újakat származtatva kiegészítem a szükséges attribútumokkal és metódusokkal a célnak megfelelően. Ez a kód újrafelhasználhatóságát jelenti, annak egy nagyon hatékony módszere. Többrétűség (polymorphism) Ha származtatunk egy alosztály, akkor ez az alosztály örökli az ős összes metódusát. Ezeket a metódusokat megváltoztathatjuk, de a nevük ugyanaz marad: ugyanannak a metódusnak más-más osztályban más a viselkedése. Ha nem akarunk egy metódust megváltoztatni, akkor nem kell az alosztály deklarációjában felsorolni, ebben az esetben ez a metódus ugyanúgy fog viselkedni ebben az osztályban is, mint a szuperosztályban. Ha az alosztály egy példányánál hivatkozunk egy metódusra akkor két eset lehetséges: ha a metódus szerepel az osztály deklarációjában, akkor egyértelmű, hogy az fog végrehajtódni. ha a metódus nem szerepel az osztály deklarációjában, akkor ez egy örökölt metódus, az öröklődési gráfban kell visszafelé megkeresni, hogy melyik szuperosztálynál történt a deklaráció, és az ott leírt kódot kell végrehajtani. A második esetben felmerül a kérdés, hogy mi történik akkor, ha megtaláljuk az örökölt metódus leírását, és ebben szerepel hivatkozás egy osztálybeli B metódusra. Ugyanis a végrehajtás során ebben a pillanatban az ősosztály metódusában vagyunk, márpedig egy ősosztály nem ismeri a belőle származtatott osztályokat. Melyik B metódust kell végrehajtani? Az ős osztálybeli B metódust vagy a leszármazott osztály B metódusát? Ennek eldöntésére megkülönböztetjük a metódusokat, vannak statikus metódusok, amelyek címe már a fordításkor belekerül a lefordított kódba, ezt korai kötésnek hívjuk (early binding). Ebben az esetben az ősosztálynál leírt metódus az ősosztálybeli metódust hívja. A metódusok másik fajtája a virtuális metódusok. Ha a B metódust virtuálisnak deklaráljuk, akkor a fordító a metódusra való hivatkozáskor nem fordítja a kódba a metódus címét, hanem futásidőben megnézi, hogy melyik osztálytól jutott el az épp aktuális osztály kódjáig, és a hívó (tehát a leszármazott) osztály virtuális B metódusát fogja végrehajtani. Ezt, a futásidőben történő összerendelést késői kötésnek vagy késői összerendelésnek (late binding) nevezzük. Ezzel elérhető, hogy egy korábban definiált metódus olyasvalamit fog csinálni, amit csak egy későbbi osztályban írunk meg. Gondoljunk vissza a kód újrafelhasználhatóságára! Használhatunk mások által megírt kódot, a kód megváltoztatása nélkül, hiszen ha gondoltak arra, hogy később valami mást is kell majd csinálni, akkor meghagyták a lehetőséget úgy, hogy nekünk csak egy származtatott osztályban egy virtuális metódust kell átírni, és az osztálybeli objektum már úgy fog működni, ahogy azt mi szeretnénk. 5
OOP lépésről lépésre Első objektumunk - Verem unit Tegyük fel, hogy egy szoftverfejlesztő cégnél azt a feladatot kapjuk, hogy írjunk olyan programmodult egy nagyobb projekt kapcsán, melynek segítségével veremkezelést valósíthat meg a másik programozó kollégánk. A veremben egész számokat fog tárolni, és tegyük fel, hogy legrosszabb esetben sem többet, mint 100 db. A projekt fejlesztése Turbo Pascal-ban zajlik. Hogyan kezdenénk neki? Mivel modulról van szó, első ötletünk természetesen a unit. unit Verem1; interface procedure VeremInit; procedure Berak(X:integer); function Kivesz:integer; function VeremUres:boolean; function Veremtele:boolean; var vm : integer; T : array [1..100] of integer; implementation procedure VeremInit; vm:=0; procedure Berak(X:integer); if not VeremTeli then inc(vm); T[vm]:=X; function Kivesz; if not VeremUres then Kivesz := T[vm]; dec(vm); end else Kivesz := 0; function VeremTeli; VeremTeli := (vm=100); function VeremUres; VeremUres:= (vm=0); end. 6
A megoldásunk bár nekünk nagyon is tetszhet, nem biztos hogy osztatlan sikert arat a szóban forgó cég vezetősége körében. Nagyon sok baj van a megoldásunkkal, ennek ellenére többször is fogunk rá utalni, így hát nem végeztünk haszontalan munkát. Kezdjük sorban megtárgyalni a megoldásunk hibás pontjait: Első hiba : biztos hogy ez jól működik? Mivel ezen modult mi fejlesztettük, ezért ezen modul működéséért mi vállaljuk a felelősséget. De vajon merjük-e vállalni, hogy a verem valóban jól fog működni. Nagyon sok olyan tényező van, amely rajtunk kívülálló módon okozhat hibát a unit működésében. Az első az, hogy a vm'' és a T'' változók látszanak a uniton kívül is. Ez azért veszélyes, mert mi van akkor, ha programozó kollegánk hozzápiszkál ezen változókhoz? Ha elállítja a vm'' változót, nem tudjuk garantálni, hogy valóban teljesülni fog a FIFO 1 adatkezelési elv. Mellékes, bár fontos megjegyzés, hogy egyébként sem szerencsés unit-on belül olyan változót deklarálni, amely a külső modulok felé is látszik'', mert ott akkor ugyanolyan nevű változók deklarálásával némi zavart idézünk elő. Mit tegyünk? A megoldás kínálja magát, tegyük át ezen két változó deklarációját az implementation szakaszba. Az ott deklarált változók tudottan nem látszanak a unit-on kívül. Második hiba : ki fogja inicializálni a vermet? Ezek után a verem működését már csak egy módon lehet elrontani, ha a kolléga elfelejti'' meghívni a VeremInit eljárásunkat, e módon meggátolva, hogy a veremkezelő eljárások (pl. Berak) működése garantált legyen. Mit tegyünk? A megoldás ismét kínálja magát. A VeremInit eljárást tegyük be a unit inicializációs részébe, a unit végén található end. közé. VeremInit; end. A unit végén lévő inicializációs utasítások a tényleges főprogram végrehajtásának elkezdése előtt hajtódnak végre. Így mire a kolléga kiadhatná az első utasítását, addigra a verem már alaphelyzetbe állt. Harmadik hiba : csak egy verem lehet Amennyiben a programozó kollégánk több vermet is szeretne használni valamely, erre már nem kínálkozik megoldás, hiszen egy unitot csak egyszer lehet felhasználni egy másik modulban. Nem írhatja: uses verem,verem;. Mit kínálunk hát megoldásként? Némi töprengés után jöhet a megoldási javaslat: a verem unitot módosítjuk oly módon, hogy minden, a veremmel kapcsolatos eljárás kap még egy paramétert, egy rekordot. 1 LIFO : Last In, First Out: Ami utoljára be, először ki'', a verem mint összetett adatszerkezet kezelési elve 7
unit Verem2; interface type RVerem = record VM : integer; T : array [1..100] of integer; procedure VeremInit(var V:RVerem); procedure Berak(var V:RVerem,X:integer); function Kivesz(var V:RVerem):integer; function VeremUres(var V:RVerem):boolean; function Veremtele(var V:RVerem):boolean; implementation procedure VeremInit(var V:RVerem); V.vm:=0; procedure Berak(var V:RVerem,X:integer); if not VeremTeli(V) then inc(v.vm); V.T[V.vm]:=X; function Kivesz(var V:RVerem); if not VeremUres(V) then Kivesz := V.T[V.vm]; dec(v.vm); end else Kivesz := 0; function VeremTeli(var V:RVerem); VeremTeli := (V.vm=100); function VeremUres(var V:RVerem); VeremUres:= (V.vm=0); end. 8
Vagy, a with'' utasítás segítségével: unit Verem3; interface type RVerem = record VM : integer; T : array [1..100] of integer; procedure VeremInit(var V:RVerem); procedure Berak(var V:RVerem;X:integer); function Kivesz(var V:RVerem):integer; function VeremUres(var V:RVerem):boolean; function Veremtele(var V:RVerem):boolean; implementation procedure VeremInit(var V:RVerem); with V do vm:=0; procedure Berak(var V:RVerem;X:integer); with V do if not VeremTeli(V) then inc(vm); T[vm]:=X; function Kivesz(var V:RVerem); with V do if not VeremUres(V) then Kivesz := T[vm]; dec(vm); end else Kivesz := 0; function VeremTeli(var V:RVerem); with V do VeremTeli := (vm=100); function VeremUres(var V:RVerem); with V do VeremUres:= (vm=0); end. 9
De mi a helyzet az első és második hibával? El tudja rontani a kolléga a mi veremkezelési elveinket? El tudja rontani azzal az egészet, hogy elfelejti'' inicializálni a vermet? Sajnos mindkettőre szomorú bólogatás a válasz. Mivel a vermet szimbolizáló rekordot e pillanattól kezdve neki kell deklarálnia a saját moduljában, így hozzáférhet azok mezőihez minden további nélkül. A másik hibával sem állunk túl jól. Ugyanis mivel a változó nem a uniton belül van deklarálva, így nem tudjuk a unit inicializálós részén alaphelyzetbe állítani. Program Gonosz_Programozo_Kollega_Foprogramja; uses Verem; var V:RVerem; V.vm := -1000; Berak(V,20); {"Run time error", :-] } end. Hibák még mindig vannak a unit-os megoldásban, de addig ne kezdjük őket sorolni tovább, amíg az eddig megadottakat meg nem oldjuk. A megoldás - az objektum Mivel eluralkodik rajtunk a csak azért is én vagyok a jobb programozó'' érzés, nem hagyjuk magunkat. Kezdjük el tanulmányozni, mit nyújt az OOP ilyen esetekre. Az első, amit megtanulunk, hogy egy objektumot hasonlóan kell deklarálni, mint egy rekordot. Ilyen módon hibátlan az alábbi deklaráció: type TVerem = object VM : integer; T : array [1..100] of integer; Ezzel lényegében már egy objektumot kaptunk (mint a neve is mutatja). Használni is tudjuk, amennyiben szükséges: var V:TVerem; V.vm := 0; V.T[1]:=100; end. Mint látjuk, az objektum ezen formájában semmiben nem más, mint egy egyszerű rekord. Az objektum mezőire is a minősítő operátor (.'') segítségével hivatkozhatunk (v.vm), sőt, a with'' kulcsszót a Pascal kiterjesztette objektumokra is, így a fenti kis (bugyuta) példát az alábbi formában is írhattuk volna: var V:TVerem; with V do vm := 0; T[1]:=100; end. 10
Az objektumtípust (type =object) innentől kezdve objektum-osztálynak, vagy röviden osztálynak nevezzük. Az osztály a típus. A TVerem tehát egy osztály. Ez a fejlettebb OOP támogató nyelvekben már jobban látszik: a legtöbb nyelven az object'' kulcsszó helyett a class'' kulcsszót kell használni a típus definiálásakor. Pl. Delphi 2 -ben a fentieket így kellene írni: type TVerem = class VM : integer; T : array [1..100] of integer; Ettől még nem kerültünk közelebb a problémák megoldásához, de haladjunk tovább. Az object azonban nem egy alternatív szó a record -ra, hanem sokkal több annál. Egy objektum nem csak mezőket tartalmazhat, hanem eljárásokat és függvényeket is. Ezeket közös néven metódusoknak nevezzük. A metódusok fejrészét az osztály deklarálásának helyén kell megadni: type TVerem = class VM : integer; T : array [1..100] of integer; procedure Berak(X:integer); function Kivesz:integer; function Ures:boolean; function Tele:boolean; procedure Init; Ezzel egy kompaktabb deklarálást kapunk. Először is nem csak az látszik, hogy egy veremhez kell két változó (mező), hanem egy verem akkor teljes, ha a fentebb felsorolt 5 metódus is szerepel 3. Az is látszik, hogy a veremmel kapcsolatban más művelet nincs! Hogyan kell ezek alapján a komplett unitot megírni? unit VeremOOP; interface type TVerem = class VM : integer; T : array [1..100] of integer; procedure Berak(X:integer); function Kivesz:integer; function Ures:boolean; function Tele:boolean; procedure Init; implementation procedure TVerem.Init; vm:=0; 2 A Delphi szintaktikája a Pascal-éval nagyon rokon 3 sok helyen a verem-hez még egy műveletet deklarálnak, a function Teteje:integer függvényt is, amely megadja a verem tetején lévő elemet anélkül, hogy azt ki is venné a veremből 11
procedure TVerem.Berak(X:integer); if not Teli then inc(vm); T[vm]:=X; function TVerem.Kivesz; if not Ures then Kivesz := T[vm]; dec(vm); end else Kivesz := 0; function TVerem.Teli; Teli := (vm=100); function TVerem.Ures; Ures:= (vm=0); end. Mint látjuk, a Verem1 unithoz hasonló szerkezet kaptunk. Ami jól látszik, hogy a metódusok törzsének kifejtésekor a metódus azonosításakor az osztály neve, és a metódus neve együtt szerepel (pl. function TVerem.Kivesz). A metódusok belsejében további metódusok hívása szerepelhet: ilyen pl. a Berak-beli Teli hívása. Ez természetes az objektumok esetén, a Pascal tudni fogja, hogy a TVerem.Berak-beli Teli csakis a TVerem.Teli lehet 4. A másik, ami látszik, hogy a metódusok belseje viszont leginkább a Verem1 unit-beli formákhoz hasonlít. Például a TVerem.Kivesz belsejében a not VeremUres'' hívása megegyezik a Verem1- beli móddal (nincs paramétere a VeremUres-nek). Nem esünk esetleg újra ugyanabba a csapdába, hogy nem lehet több vermünk? A választ az objektum használata során kapjuk meg. Először is fontos megértenünk, hogy a fenti deklaráció során még csak típust deklaráltunk. Vermünk még nincs, csak a lehetőség, hogy lehessen. Pont, mint pl. a rekordok esetén. A type RVerem = record. deklarációval még csak egy típust kaptunk. Hogy konkrét rekordunk legyen ahhoz deklarálni kell egy rekord típusú változót. Hogy objektumunk legyen, deklarálni kell egy osztály típusú változót: var OOPVerem : TVerem; Mi a helyzet ezzel a változóval? Mint egy fentebbi példában láthattuk, ezen változó sok szempontból olyan, mint egy rekord, és ugyanúgy lehet használni is - el lehet érni a mezőit (vm.t) egyszerű módon. De mit tegyünk a metódusokkal? A válasz - ugyanazt, mint a mezőkkel. Ha egy ilyen veremmel dolgozni akarunk, akkor az alábbi formát használhatjuk: 4 mielőtt nagyon elbíznánk magunkat :-), ezzel még sok bajunk lesz, lásd öröklődés,virtuális metódusok fejezeteket 12
program OOPVerem_Teszt; uses Verem3; var OOPVerem : TVerem; OOPVerem.Init; OOPVerem.Berak(10); if OOPVerem.vm=1 then writeln('a verem jól működik!'); end. A metódusokra is a.'' minősítő operátorral hivatkozhatunk, ezek segítségével hívhatjuk meg ezen eljárásokat, és függvényeket. Ha a fenti programban véletlenül olyan eljáráshívást írtunk volna, hogy Init;, akkor a Pascal egy egyszerű, hagyományos eljárást indított volna el, amely a procedure Init; formában került volna deklarálásra. A OOPVerem.Init; esetén a Pascal megnézi'', hogy az OOPVerem valójában egy TVerem típusú objektum, így a fenti eljáráshívás a TVerem.Init hívásaként értelmezi. De mi a helyzet a TVerem.Init metódus belsejében lévő vm:=0; utasítással? Mi az a vm változó ilyenkor? A válasz: az OOPVerem objektumbeli vm mező. A módszer olyan, mintha a TVerem.Init megkapná paraméterként az OOPVerem változót cím szerint, és az eljárás belsejében with OOPVerem do sor is lenne 5. Egyelőre fogjuk fel úgy, hogy a metódusok számára a világ az adott objektumra szűkül le, számára nem létezik más, egy metódus belsejében az objektum változói (mezői) olyanok, mintha globális változók lennének. Számára a vm egyértelműen az objektum mezőjét jelenti, és automatikusan tudja, melyik objektumét. Nézzünk egy újabb példát: var OV1,OV2:TVerem; OV1.Init; OV1.Berak(10); OV2.Init; end. Mint láthatjuk, az adott osztályból több objektumot is készíthetünk. A továbbiakban ezt példányosításnak nevezzük, az objektum-osztályból készült változót pedig objektum-példánynak, vagy röviden objektumnak nevezzük. Tehát van két objektumunk: OV1, OV2. Az OV1.Init metódus hívásakor az OV1 objektum vm mezőjét állítja nullára, az OV1.Berak metódus szintén az OV1 objektumon fog dolgozni, és az ő vm és T mezőivel végez munkát. Az OV2.Init viszont már az OV2 vm mezőjét állítja nullára. Ez talán már érhető is eddig (valójában, ha nem gondolkodunk el rajta, a fenti dolgok természetesek, és egyértelműek :-)). Gondoljuk azonban tovább. A Berak'' metódus meghívja a Teli'' metódust, hogy ellenőrizze, hogy a verem nem telt-e meg eddig. A Teli'' metódus is a vm mező alapján dönt. De milyen vm alapján? A válasz természetes, ugyanazon objektum mezője alapján, így a OV1.Berak-beli Teli metódus hívása értelemszerűen megegyezik a OV1.Teli meghívásával, vagyis a Teli-ben a vm szintén az OV1.vm mező lesz 6. Levonhatjuk a következtetést, a fenti mechanizmus automatikus, de mivel teljesen természetes, és logikus, nekünk nem nagyon kell vele foglalkoznunk. 5 valójában pontosan erről van szó, lásd a SELF - láthatatlan paraméter'' című részt 6 itt megint a Self'' a kulcsszó, lásd a megfelelő fejezetet 13
Az objektum finomítása - adatrejtés Ha eddig értjük, térjünk vissza a megoldandó problémáinkra. Az egyik, hogy a gonosz programozó kollégánk ne tudja elrontani a vermünk működését azzal, hogy belepiszkál a mezőkbe. El kellene rejtenünk őket. Itt sajnos nem jöhet szóba az implementation rész, mint az elrejtés tipikus helye. Ugyanis az objektumosztály definiálását kénytelenek vagyunk a interface részben végezni, hogy a külvilág számára a típus elérhető legyen, és tudjon később példányosítani. Sajnos, a típus definiálását nem vághatjuk ketté, nem tehetjük meg, hogy az interface részben elkezdjük'' a definiálást a publikus részekkel, majd az implementation részben folytatjuk a rejtett (privát) részekkel. Ez egyben ellentmondana annak az elvnek, hogy az objektum egyben tartalmazza az objektum adattároló részeinek (mező), és a rajta műveletet végző eljárások és függvények (metódusok) definícióit. A megoldás az, hogy az objektum-osztály definiálásakor közöljük, hogy mi az, amit a külvilág számára szánunk az objektumból, és mi az, amit nem: type TAdvVerem = object private VM : integer; T : array [1..100] of integer; public procedure Berak(X:integer); function Kivesz:integer; function Ures:boolean; function Tele:boolean; procedure Init; 7 Mint látjuk, a private és public kulcsszavakkal jeleztük ezen szándékunkat. A private kulcsszó után következő objektum-részeket a külvilág nem érheti el, számukra az objektum úgy viselkedik, mintha nem is létezne vm és T mezője. A public után következő dolgok (jelen esetben metódusok) viszont hozzáférhetőek lesznek. E szempontból a public rész úgy viselkedik, mintha egy unit interface része lenne, a private-ban definiált dolgok viszont az implementation rész lenne. E miatt szokták a public részben definiált részeket az objektum interfészének is nevezni. Amit most kaptunk, az megfelel az egységbezárás elvének, sőt, annak megspékelt változatával, az adatrejtés elvével. Ezen elv szerint az objektum mezőit a külvilág sohasem érheti el, minden művelet az objektum metódusain keresztül végezhető el 8. Lássuk, hol tartunk! Mit tehet nem túl szimpatikus programozó kollégánk most? Program Gonosz_Programozo_Kollega_Foprogramja; uses Verem; var V:TVerem; V.vm := -1000; { nem megy, szintaktikai hibát kap, a V objektumnak nincs vm mezője, részünkről a mosoly :-) } V.Berak(20); { hibás lehet, részéről a mosoly :-( } end. 7 Ez a forma nem működik a TP v6.0-ban, csak a TP v7.0-ban! 8 ezen elv túlzásba vitelével is sok a gond, a probléma legszebb megoldása a Delphi property''-jei, lásd a megfelelő fejezetet 14
A V.Berak ponton még ő a jobb, sajnos, mivel nem inicializálta az objektumot, ezen a ponton még akár "Run Time Error"-t is kaphat a program. Sajnos egyelőre megteheti, hogy nem inicializálja az objektumot. ( ) Az objektum finomítása - constructor, destructor Hogyan kényszerítsük rá kollégánkat, hogy addig ne használja az objektumot, amíg azt nem inicializálta? Majd minden objektumnál probléma az inicializálás. Korán megtanultuk, hogy ne használjunk inicializálatlan változókat. Mivel majd minden objektumnak vannak mezői, ezért az objektumoknál ez állandó probléma, hogy ki és mikor fogja inicializálni ezen mezőket 9. A fentiek megoldására egy speciális metódus-csoportot hoztak létre az OOP tervezői. Azon metódusok, amelyek elsődleges feladata az objektum-példány alaphelyzetbe hozása (inicializálása), konstruktor nevet kapták. A konstruktorok tehát közönséges eljárások, de speciális feladattal. Honnan ismerjük meg a konstruktorokat? Hogyan tudunk ilyeneket definiálni? type TAdvVerem = object private VM : integer; T : array [1..100] of integer; public procedure Berak(X:integer); function Kivesz:integer; function Ures:boolean; function Tele:boolean; constructor Init; { ez egy konstruktor!!!! } constructor TVerem.Init; vm := 0; Mint láthatjuk, a konstruktorokat könnyű megismerni, hiszen ezen metódus definiálásakor nem a procedure, hanem a constructor szó szerepel. Ennek ellenére a konstruktorok közönséges eljárások, használatukban legalábbis: OV1.Init; end. Hogyan tudjuk kikényszeríteni a konstruktor használatát? Sajnos, rossz hírem van a Pascal-rajongók táborának. Turbo Pascal-ban sehogy. De tisztább OOP-t használó nyelvekben (Java, Delphi, C++) igen! Ugyanakkor elmondhatjuk, hogy ez alapelv az OOP-ben, hogy minden objektumot használatba vétele előtt a konstruktorán keresztül inicializálni kell. Ha ezt nem teszi meg egy programozó, akkor ugyanolyan hibát ejt, mintha egy file-ból olvasna anélkül, hogy azt a file-t megnyitotta volna olvasásra. A virtuális metódusok 10 használata esetén a kényszer erősebb! Ezen problémát ennyivel tudjuk jelenleg elhárítani magunktól! A konstruktorokon kívül a másik, jellemző feladat az objektumok használata befejeztével a tisztogatás 11 '' műveletet elvégezni. Ezen metódusok, amelyek az objektum által lefoglalt erőforrásokat 9 ezzel kapcsolatban lásd a nyelvi különbségek'' c. részt 10 lásd később 11 clean-up 15
(memóriafoglalás, fileok bezárása, hálózati kapcsolatok megszakítása, stb) szabadítják fel, közös néven desktruktoroknak nevezzük. A destruktorokat szintén könnyű felismerni, és készíteni, mivel ezen eljárások neve nem procedure, hanem destructor. A verem objektum esetén ilyen tisztogatásra nincs szükség, ezért a destruktorok bemutatására álljon itt egy másik példa: készítsünk olyan objektumot, amely egy text file kezelését valósítja meg a metódusain keresztül: type TTextFile = object private f : Text; public constructor Letrehoz(DosFileNev:string); procedure Kiir(S:string); destructor Lezar; constructor TTextFile.Letrehoz(DosFileNev:string); Assign(F,DosFileNev); Rewrite(f); procedure TTextFile.Kiir(S:string); Writeln(f,s); destructor TTextFile.Lezar; Close(f); Használata: var T:TTextFile; T.Letrehoz('C:\proba.txt'); T.Kiir('Egy próba kiírás a file-ba'); T.Lezar; A konstruktorok és destruktorok használata, használhatósága az erősebb OOP nyelveken (Java, Delphi) jobban kidomborodik. Később találkozunk még velük. Tegyük most félre az eddigi példáinkat, és kezdjünk el alaposan elmélyedni az OOP azon tulajdonságaiban, amelyek már messzebbre mutatnak náluk. Az OOP nagy tulajdonsága - az öröklődés Az eddigiek is rávilágítottak az OOP néhány előnyére, de mindezen példák erősen építettek arra, hogy a kollégáink igen gonosz emberek, és ezért kell nekünk az OOP. Most lássuk, mit nyújt az OOP akkor nekünk, ha nem gonoszak a kollégák. 16
Az OOP kódtakarékos A cím azt sugallja, hogy az OOP során kevesebb programsort kell leírnunk ugyanazon problémamennyiség megoldásához. Az eddigi ismereteinkből erre semmi sem utal, a metódusokat ez előtt is, eztán is meg kell írnunk. Hogy lesz ebből kódtakarékosság? A válasz a tervezésben, és az öröklődésben rejlik. A tervezéstől minden vérbeli programozó megborzong (:-)). Egy igazi programozó nem tervezi a programját - legalábbis nem látszik. Egy profi úgy tűnik, a feladat megértésének pillanatában billentyűzetet ragad, és elkezdi a sorokat gyártani. Valójában ők is terveznek, a fejükben összeáll az algoritmus, részfeladatokra bomlik - kialakulnak a szükséges eljárások és függvények, átlátják, azoknak milyen paramétereik legyenek, hogy minél kevesebb betűt kelljen leütni ( :-) ). Hogy ők miért ilyen gyorsan látják át ezeket? A legtöbbjük egyébként is átlag feletti zseni, de az ok mégis inkább az, hogy sok hasonló problémával találkoztak már, és az adott feladat rutinból megy nekik. Az OOP tervezés nem csak ennyi! Ezt fontos megérteni! Egy OOP program nem csak annyi, hogy egyszerű eljárások és függvények helyett most metódusokat írunk. Ha valakinek ez csak ennyi, akkor még nem OOP programozó, csupán az eljárások és függvények deklarálására, és aktivizálására kissé már szintaxist használ, mint a többiek. Az OOP programozás 80%-ban arról szól, hogy ne kelljen sokat programoznunk, mégis tökéletesen tesztelt, működőképes, stabil, robosztus programot kapjunk. Egy jól felépített OOP vázon ülő fejlesztő eszközön történő programírás inkább hasonlít a szövegszerkesztésre, a programozó (vagy inkább felhasználó) elmerült arccal kattintgat az egérrel, és aránylag kevés programsort ír le, akkor is leginkább már mások által elkészített, és tesztelt metódusokat hívogat. A fejlesztés ezen típusát, amikor a programozó elkészített objektumokat használ, és azok segítségével old meg feladatokat, időnként drótozásnak'' is nevezik. A programozó feladata a különféle objektumokat (mint chip-eket) különböző metódusaik összekapcsolása révén működésre serkenteni (mint a chip-ek lábainak összekötése révén egy részegységekből összerakni egy komoly elektronikus eszközt). Egy igazi OOP rendszerben csak ritkán kell új objektumokat készíteni, a rendszerrel szállított, és utólag hozzáillesztett objektumok olyan tömeggel vannak, olyan sokoldalúak, olyan általánosak, hogy csak speciális igények kielégítése végett kell tényleges programozói munkát végezni. A Delphi-hez mellékelt objektumosztály-gyűjtemény (library) olyan bőséges, hogy egy nagy méretű (poszter nagyságú) lapon fér csak el a felsorolása - nem túl nagy méretű betűkkel írva. Az ilyen objektumok felhasználásával a fejlesztés és tesztelés ideje lerövidül - de ezért persze valahol meg kell fizetni: az ilyen programok sebessége sokszor nem túl optimális, és a készült program hossza is elég nagy lehet. Arról nem is beszélve, hogy a mellékelt objektum-library alapos ismeretén alapul melyet megszerezni sok időt igényel. Az eddig leírtak szintén nem igényelnek feltétlenül OOP programozási technikát, ez eddig megoldható egy igen nagy méretű unit-gyűjtemény elkészítésével is. Miért jobb mindezt mégis OOP-ben készíteni? Hogyan segít más módon az OOP a programozás felgyorsításában, a kódolási idő lerövidítésében? Vegyünk egy újabb példát: készítsünk el egy objektumot, amely egy grafikus ponttal végez különböző műveletet. Kivételesen nem dolgozunk ki minden metódust, csak a lényegre koncentrálunk. Egy grafikus pont kezeléséhez tárolnunk kell a pont koordinátáit, és a színét. Azért fontos, hogy ezeket az objektum tárolja, hogy a pont emlékezzen'' saját magára, és bármikor képes legyen újrarajzolni saját magát. 17
type TPont = object x : integer; y : integer; szin : byte; constructor Init; procedure Rajzol; procedure Torol; procedure Mozgat(UjX,UjY:integer); constructor TPont.Init; x := 0; y := 0; szin := black; procedure TPont.Rajzol; PutPixel(x,y,szin); procedure TPont.Torol; PutPixel(x,y,black); procedure TPont.Mozgat(UjX,UjY:integer); Torol; {m1} x := UjX; {m2} y := UjY; {m3} Rajzol; {m4} Most pedig örülvén a pont objektumunknak vágjuk újabb nagy fába a fejszét: most egy kört akarunk kezelni. A kör sok szempontból hasonló a ponthoz, a középpont koordinátáján és a színén kívül van sugara is. A középpont koordinátája egy x,y pár. Ezek már adva vannak a pont esetén is. Egy amatőr OOP programozó az alábbi módon kezdene neki: (nulláról indulva) type TKor = object x : integer; y : integer; sugar: integer; szin : byte; constructor Init; procedure Rajzol; procedure Torol; procedure Mozgat(UjX,UjY:integer); Egy profi azonban kihasználná a hasonlóságot a két objektum között: type TKor = object(tpont) sugar : integerr; 18
A fenti esetben az object(tpont) azt jelenti, hogy ezen új objektum írását nem nulláról kezdjük, hanem továbbfejlesztjük a már meglévő pont objektumot. Ez esetben azt mondjuk, hogy a TKor osztály a TPont osztály leszármazottja, míg a TPont az TKor szülője. Ez a leszármazott-szülő kapcsolat az öröklődésre utal, a gyermek mindent örököl a szülő tulajdonságaiból, de azokat továbbfejlesztheti. Lássuk, mi az, amit örököl, és továbbfejleszthet: Egy származtatott objektum minden adatmezőt örököl a szülőjétől. Így a fenti példában a TKor osztály példányainak van x,y,szin mezői, de nekik lesz még sugar mezőjük is, ami a szülő osztályba sorolható objektumoknak nem. Ez az öröklődés egyoldalú, és a gyermek nem válogathat az öröklődés során - egyszerűen mindent örököl. Mi a helyzet az örökölt adatmezők megváltoztatásával? A válasz egyszerű: nincs rá mód! A gyermek objektum az adattagokat ahogy van - úgy van'' 12 elv alapján örökli, itt változtatásnak helye nincs, csak továbbfejlesztésnek. Ennek persze oka van, ezt a kompatibilitással foglalkozó részben fogjuk tárgyalni. Az OOP nagy tulajdonsága - a sokalakúság Mit örököl még? Mi a helyzet az örökölt metódusokkal? Az öröklés során azonban nem csak az adattagok öröklődnek, a metódusok is - beleértve a konstruktorokat, és a destruktorokat is. Az objektum jelen formájában persze a bővített adatmezőket nem kezeli - az örökölt metódusokban nincs hivatkozás a sugar mezőre, az örökölt konstrukor nem inicializálja, stb. Nyilván az adattagok bővítésének akkor lesz meg a haszna, ha a metódusokat is kiegészítjük olyanokkal, amelyek kezelik az új mezőket. Kezdjük a konstruktorral. Készítsünk olyan konstruktort, amely ezen új mezőt is alaphelyzetbe állítja. Mi legyen a neve? Az Init'' konstruktor-nevet már megszoktuk, de vajon használhatjuk-e? Hiszen ilyen nevű konstruktorunk már van, örököltük a Pont -tól. A válasz: használhatjuk nyugodtan. A helyzet hasonló, mintha egy programon belül van valamely nevű globális változónk, és egy eljáráson belül deklarálunk egy ugyanolyan nevű lokális változót! Mi fog ekkor történni? Az eljáráson belül a lokális változó elfedi'' a globális változót, az újabb változat eltakarja'' a régit. Az OOP-ben a helyzet ezzel rokon. Amennyiben egy származtatott objektumban egy ugyanolyan nevű metódust deklarálunk, az egyszerűen elfedi a régit. type TKor = object(tpont) sugar : integer; constructor Init; constructor TKor.Init; x := 0; y := 0; Szin := black; sugar := 0; Megfigyelhetjük, hogy a konstruktor kódjának nagy részét már megírtuk, van olyan metódusunk, amely a négy mezőből hármat inicializál, nekünk csak az új mezővel kellene törődnünk? Hogyan használhatjuk fel az örökölt konstruktor kódját? 12 as-is 19
constructor TKor.Init; Init; { figyelem, ez hibás!! } sugar := 0; Nyílvánvalóan nem így! Az, Init'' eljárás hívása melyik Init lesz? Ez egyértelműen az új Init lesz, a TKor.Init! Ez így rekurzív hívás lenne, méghozzá végtelen mélységben, így ezen konstruktor használata esetén a program elszállna constructor TKor.Init; TPont.Init; sugar := 0; A megoldásban első látásra nincs semmi igazán meglepő - ez megint csak a szintaktikán elgondolkodó programozók számára nyújthat némi érdekességet: a TPont egy objektum-osztály, egy típus neve. Eddig a metódusok hívásánál egy objektum-példány nevét írtuk a metódus neve elé (pl. OV1.Berak). Jelen esetben ez a szintaxis mindösszesen a hívandó metódus pontos beazonosítására szolgál. A származtatott objektum konstruktoraiban gyakran használjuk fel az szülő osztály konstruktorait. De a fenti megoldás - bár működik - valójában elég kényelmetlen, hiszen emlékeznünk'' kell, hogy a Kor őse a Pont (amelyet feltüntetünk a Kor deklarálásakor = object(tpont), és a konstruktor hívásakor is. Ennél kellemesebb megoldást kínál pl. a Delphi ezen problémára 13. Térjünk vissza a Kor fejlesztéséhez. Mit kell még megváltoztatnunk? Természetesen egy kört másképp kell kirajzolni, és eltüntetni, mint egy pontot. type TKor = object(tpont) procedure Rajzol; procedure Torol; procedure TKor.Rajzol; SetColor(szin); Circle(x,y,sugar); procedure TKor.Torol; SetColor(black); Circle(x,y,sugar); Ismételten nem okoz az problémát, hogy ezen metódusok újraírása ugyanazon metódusnévvel történik meg. Nemsokára látunk példát, hogy a használat során egyértelművé válik, melyik metódus hívódik meg. De nézzük a harmadik metódust, a Mozgat-t. Azt is szükséges-e újraírnunk? Elvileg azt mondhatjuk, egy kört is ugyanúgy kell mozgatni a képernyőn, mint egy pontot, meg kell adni az új koordinátákat, a régi helyről le kell törölni, az új helyre ki kell rajzolni. Ezen felbuzdulva tehát beláthatjuk, hogy az OOP kódtakarékos, mert, lám, a körre ezt nem kell megírni, mégis működni fog a mozgatás körre is! 13 Delphi-ben a fentit az inherited Init; módon lehet írni, s az inherited a szülő osztályt jelenti, így mindig a szülő osztálybeli Init hívását jelenti, függetlenül annak nevétől. 20
Próbáljuk ki: var P:TPont; K:TKor; {initgraph, stb.} P.Init; P.Szin := white; P.Rajzol; P.Mozgat(10,10); {p1} {p2} {p3} {p4} K.Init; {k1} K.Sugar := 10; {k2} K.Szin :=yellow; {k3} K.Rajzol; {k4} K.Mozgat(20,20); {k5} { itt figyeljünk } {closegraph, stb.} end. Nézzük, mi történik, ha egy programon belül akarok kört, és pontot is használni. Ha valaki kipróbálja a fenti programot, látni fogja, hogy a pont jól működik. A p1'' hatására elindul a TPont.Init konstruktor, és a P.x, P.y, P.szin megkapja a kezdőértékét. A p2'' hatására a P.szin megváltozik (bár ez nem látszik a képernyőn még). A p3'' hatására elindul a TPont.Rajzol, és a putpixel révén a pont megjelenik a képernyőn. A p4'' hatására a TPont.Mozgat indul el, majd a TPont.Torol hatására a pont eltűnik a képernyőről, a P.x, P.y megkapja új értékét, és a TPont.Rajzol miatt a pont megjelenik az új koordinátán. Honnan tudja a Mozgat, hogy most egy pontot kell rajzolni, és a TPont.Rajzol és TPont.Torol metódusokat kell használni. Két helyről is! Az első, hogy a Mozgat egy P:TPont objektum révén lett aktivizálva (P.Mozgat). A másik okot mindjárt tárgyaljuk. Nézzük meg, mi történik a körrel: a k1'' révén először a TKor.Init indul el, hiszen a konstruktort újradefiniáltuk, és a K.Init természetesen a TKor.Init-et jelenti (mivel a K egy TKor típusú objektum). A TKor.Init-ben először elindul a TPont.Init, és a K.x, K,y, K.Szin megkapja a kezdőértékét, majd visszatérünk a TKor.Init-be, és a K.sugar is megkapja a kezdőértéket. A k2'' révén a K.sugar felveszi az új értékét, a k3'' révén a K.szin is megváltozik. A k4'' miatt elindul a TKor.Rajzol, és megjelenik egy kör a képernyőn. Számunkra a k5'' lesz a legizgalmasabb. Ha valaki tényleg kipróbálta a fenti kis programot, látni fogja, hogy a dolog nem működik! Mi történt? Ha megpróbáljuk nyomon követni a programot, azt látjuk, hogy a k5'' miatt elindul a TPont.Mozgat, de ezen belül nem a TKor.Torol fog aktivizálódni az m1'' ponton (mint várnánk), hanem a TPont.Torol! E miatt nem a kör fog letörlődi a képernyőről, hanem csak egy pont. Bár utána rendben történnek a dolgok tovább, a K.x, K.y megkapja az új értéket ( m2'' és m3'' sor), de az m4''-es soron újra a TPont.Kirajzol fog végrehajtódni! Miért? A válasz valójában elég bonyolult, ugyanakkor nagyon egyszerű. A nagyon egyszerű válasz szerint amikor a TPont-t írtuk, nem figyelmeztettük az objektumot arra, hogy a Rajzol és Torol metódusok később újraírásra kerülhetnek az fejlesztés következő fokozataiban. Így a Pascal az m1'' és m2'' sorokhoz fixen hozzárendelte a TPont.Rajzol és TPont.Torol metódusokat. Ebből levonhatjuk azon következtetéseket, hogy az öröklődés sem feltétlenül fog minden automatikusan működni. De vajon ez már így is marad? Mit tegyünk? Egy kezdő OOP programozó sóhajtana egyet, s azt mondaná: no, akkor írjuk újra az öröklődés ezen szintjén a Mozgat metódust is: 21
type TKor = object(tpont) procedure Mozgat(UjX,UjY:integer); procedure TKor.Mozgat(UjX,UjY:integer); Torol; {x1} x := UjX; {x2} y := UjY; {x3} Rajzol; {x4} Mire végez, észreveszi, hogy az újraírt metódus szóról szóra megegyezik az örökölttel. A dolog kissé gyanúsan néz ki e miatt, de működik. Ha a fenti kis program k5''-s sorát nézzük, ennek hatására mostmár a TKor.Mozgat fog aktivizálódni (hiszen a K objektum egy TKor típusú, és a TKor osztályban létezik Mozgat metódus). Ezen TKor.Mozgat-n belül az x1'' során a TKor.Torol fog meghívódni, mivel a TKor osztály számára ez az aktuális, elfedve az elavult verziót ugyanezen nevű metódusból. Az x4'' során is a TKor.Rajzol hajtódik végre. De hát hol is lesz azon alapelv, hogy az OOP kódtakarékos? A megoldás persze nem is ez, hanem a virtuális metódusokban rejlik! Mit csinált volna az előző esetben egy profi OOP programozó? Mint fent említettem, a TPont írásakor nem figyelmeztettük az objektumot, hogy ezen két metódusból a származtatás során újabb verziók keletkezhetnek! Van erre lehetőség? Igen! type TPont = object x : integer; y : integer; szin : byte; constructor Init; procedure Rajzol; virtual; procedure Torol; virtual; procedure Mozgat(UjX,UjY:integer); A virtual kulcsszó kiírása a metódusok neve mögött, az objektum definiálásakor éppen ezt teszi. A virtuális metódusok segítségével lehet megvalósítani azon vágyunkat, hogy használhassuk az örökölt metódusokat - azok újraírása nélkül - a leszármazott objektumokban is. Amennyiben egy metódusról az öröklődés valamely szintjén egyszer kinyilvánítottuk ezen figyelmeztetést, úgy ezen figyelmeztetést kötelesek vagyunk a származtatott objektumokban is feltüntetni: type TKor = object procedure procedure Rajzol; virtual; Torol; virtual; Vagyis, ha egy metódus egyszer virtuálissá vált, akkor az örökké az is marad! 14 14 Ez azért nem teljesen igaz. Pl. Delphi-ben van rá mód, hogy ezt megszüntessük. De gyakorlatilag erre szinte soha nincs szükség! Ha igen, akkor már a tervezéskor óriási hibát ejtettünk! 22
Nézzük, mi történik ebben az esetben? A helyzet most tehát az, hogy a Mozgat metódushoz nem nyúltunk a TPont objektum készítése során, csupán mindkét objektumban a Rajzol és Torol metódusok deklarálásakor a virtual kulcsszót is kiírtuk? A p1'' p4'' sorok működése nem változott meg! Miért, hiszen van fejlettebb Rajzol és Torol eljárás!? De nem ebben az esetben! Jelen esetben a Mozgat eljárás hívását a P.Mozgat(10,10); programsor váltotta ki, márpedig a P egy TPont. Egy TPont típusú objektum számára nincs ennél fejlettebb változat a fenti két metódusból! A második esetben viszont a p1'' sor végrehajtása a TKor.Torol meghívását fogja jelenteni! Hiszen jelen esetben a K.Mozgat(20,20) váltotta ki a végrehajtást, és a TKor típusú objektumokban van fejlettebb Rajzol (és Torol)! A metódusok ezen tulajdonságát, hogy ugyanazon metódus többféle viselkedést tud produkálni, együtt tud működni a jövőbeni fejlesztésekkel is, az OOP egy másik, nagyon fontos elvének nevezzük, sokalakúságnak (polymorhysm). Ahogy a virtuális metódusok működnek Hogyan lehet a virtuális metódus hívását megvalósítani? Akinek vannak assembly ismeretei, az tudja, hogy az eljárás hívása a call utasítással történik, amely mögé meg kell adni a hívandó eljárás memóriabeli címét! A processzor elugrik'' ezen memóriacímre, és végrehajtja az ott felfedezett programsorokat, majd a ret utasítás hatására visszatért a hívás (call) utáni programsorra, és folytatja a végrehajtást. Ebbe a koncepcióba nem nagyon férnek bele az előző szakaszban megtárgyalt ismeretek! Hiszen pl. a m1'' sorhoz (Torol;) milyen assembly utasítást generál a Pascal fordító? Hiszen ugyanezen assembly utasítás hol a TPont.Torol, hol a TKor.Torol eljárást hívja meg attól függően, hogy egy TPont vagy egy TKor objektumból hívtuk meg? Amennyiben a virtual kulcsszót nem használjuk a metódusok deklarálásakor, úgy a compiler a fordítás során egyetlen, fix helyre mutató call assembly utasítássá alakítja át a metódushívást, mindig a TPont.Torol eljárás hívásaként értelmezve ezen sort. Ezt a technikát korai kötésnek (early binding) nevezik. Ekkor a metódushívás-összerendelés fordítási időben zajlik le. Amennyiben használjuk a virtual kulcsszót - ezzel figyelmeztetve az objektumot (valójában a fordítót), hogy ezen metódusok hívásával vigyáznia kell, mert bár most még csak a TPont-t írjuk, és még nem tudjuk mi lesz a jövőben, milyen leszármazottjai lesznek a TPont-nak, de ezen metódusokból keressen újabb változatot, verziót. Ekkor a fordító nem tudja meghatározni, melyik konkrét metódus hívódik majd meg a program futtatása során erről a pontról, ezért ide egy elég bonyolult mechanizmus fordítódik le egy egyszerű call assembly utasítás helyett - ezen mechanizmus megkeresi a legfejlettebb verziót az aktuális szituációban. Ezt a technikát késői kötésnek (late binding) nevezzük. Hogyan lehet ezt megvalósítani? Amikor egy objektumot definiálunk, és használjuk a virtual kulcsszót valamely metódusra, a fordító felkészül a fenti problémás esetekre a késő kötések feloldásának megkönnyítésére - és készít egy táblázatot. Ezt a táblázatot Virtuális Metódus Táblának nevezik, és a nevének megfelelően a táblázat soraiban az adott objektumosztályban található virtuális metódusok memóriacímeit tartalmazza (egy memóriacím általában 4 byte tárkapacitást igényel). 23
Nézzünk most egy példát: type TElso = object procedure A; procedure B; virtual; procedure C; virtual; procedure D; type TMasodik = object(telso) procedure A; procedure C; virtual; procedure E; virtual; procedure TElso.D; A; {w1} B; {w2} C; {w3} var T1:TElso; T2:TMasodik; T1.D; {s1} T2.D; {s2} end. A TElso osztályhoz tartozó VMT tábla az alábbi bejegyzéseket tartalmazza: TELSO.VMT metódus B = TElso.B metódus C = TElso.C A TMasodik osztályhoz tartozó VMT tábla az alábbi bejegyzéseket tartalmazza: TMASODIK.VMT metódus B = TElso.B metódus C = TMasodik.C metódus E = TMasodik.E Ilyen VMT tábla minden objektumosztályhoz készül (tehát nem minden példányhoz, hanem osztályhoz)! Vagyis ha egy osztályból több példányt is készítek, azok ugyanazon a VMT-n osztoznak. Ez memóriatakarékos megoldás. Mellesleg működik is, hiszen egy osztály objektumainál a VMT tábla egyébként is egyformán néz ki. Ez alapján a fent említett mechanizmus nagyon egyszerűvé vált amikor ilyen típusú eljárás meghívására kerül sor, nem egyszerűen egy call utasítást kell meghívni, hanem a fenti táblázatból ki kell venni a megfelelő sort (memóriacímet), és oda kell elugrani. A második esetet vizsgáljuk alaposabban meg. Először is ebben a VMT-ben szerepel az elj B is, holott azt nem definiáltuk felül a származtatás során. Ugyanakkor erre szükség is van, hiszen a w2'' sorban a fordító szintén késői kötést fog alkalmazni, és ki akarja venni a megfelelő sort a táblázatból - 24