vid, 2008-02-06 | Revize: 1.0 |
Pre veľa programátorov nižších jazykov je objektovo orientované programovanie (OOP) záhadnou oblasťou plnou zdanlivo nezmyselných abstrakcii. V tomto článku postupne vystaviam a vysvetlím niektoré aspekty OOP z pohľadu programátora v C alebo Assembleri.
Upozorňujeme že jazyková syntax v príkladoch nieje vždy 100% správna - uprednostnil som čitateľnosť.
Na čo je objektové programovanie vlastne dobré? Všeobecne by sa dalo povedať, že umožňuje pomocou jednotného interfacu pristupovať k rôznym typom "objektov" (dátových štruktúr) ktoré majú podobné vlastnosti. Robí to kvázi štandardizovaným, a čistým spôsobom. Najlepšie to ale pochopíte keď uvidíte v praxi o čo sa jedná.
Najprv si musíme vedieť predstaviť čo to taký objekt je. Pekný príklad sú objekty v počítačovej hre. Predstavme si že vyvíjame vlastnú počítačovú hru. V hre budú existovať nejakí nepriatelia, zbrane, letiace náboje, bonusové predmety, atď. Všetko toto budeme reprezentovať ako objekt, ktorý zatiaľ vnímajme ako dátovú štruktúru v pamäti.
Každý objekt je nejakého typu. Typ objektu môže byť napríklad napríklad lekárnička, ktorá doplní energiu. Samozrejme, v hre môže byť naraz viac lekárničiek. Všetky lekárničky sa správajú rovnako, až na niekoľko atribútov ktoré môžu byť u jednotlivých lekárničiek odlišné (koordináty udávajúce kde na mape sa lekárnička nachádza, množstvo energie ktorú lekárnička doplní). Typ objektu sa v OOP terminológii nazýva "trieda" ("class"). Jednotlivé objekty tohoto typu sa nazývajú "inštancie triedy" ("class instance"). Atribúty ktoré sú rôzne pre každú inštanciu sa nazývajú "dátové členy" objektu ("data members")
Dohodnime sa že naše objekty budú začínať číslom (veľkosti dword) ktoré udáva typ objektu. Zaťiaľ budeme mať iba jeden typ, lekárničku (TYPE_MEDIKIT). Ďalej budú nasledovať dátové členy, podľa typu objektu. Pre lekárničku to budú: koordináty X,Y, a množstvo energie ktorú doplní. Deklarácia objektu typu lekárnička na súradniciach [10,15], ktorá doplní 50 bodov energie potom bude vyzerať následne:
dd TYPE_MEDIKIT ;typ objektu = lekárnička dd 10 ;x koordinát = 10 dd 15 ;y koordinát = 15 dd 50 ;energia = 50
struct MEDIKIT { int type; int x, y; int hp; }; MEDIKIT mk = {TYPE_MEDIKIT, 10, 15, 50};
Teraz si zadefinujme ďalší objekt: mínu. Mína bude svojím spôsobom opak lekárničky: keď sa jej hráč dotkne, uberie mu zo života. Takisto bude mať koordináty, a množstvo energie ktoré uberie. Navyše však bude mať ďalší atribút (resp. dátový člen), a to čas ktorý ubehne odkedy sa hráč dotkne míny, dokým mína vybuchne. Náš objekt môže vyzerať takto:
dd TYPE_MINE ;typ objektu = mína dd 10 ;x koordinát = 10 dd 15 ;y koordinát = 15 dd 5 ;čas vybuchnutia = 5 sekúnd dd 50 ;energia = 50
struct MINE { int type; int x, y; int timeout; int hp; };
Pri pristupovaní k objektu, si najprv zistíme jeho typ, ktorý je u všetkých typov objektov v prvom dworde. Potom podľa typu poznáme množstvo a význam nasledujúcich dátových členov.
Zamyslime sa nad tým ako vyzerá program pracujúci s takými objektami, ako sme ich definovali doteraz. Konkrétne pre náš príklad, ako vyzerá časť programu ktorá zabezpečuje ošetrenie a spracovanie kontaktu hráča s objektom. Neuvažujeme časť ktorá zisťuje či nejaký kontakt nastal, iba časť ktorá spracúva udalosť keď už nastal kontakt hráča s niektorým objektom.
Najprv musíme zistiť aký je to typ objektu (akej triedy je objekt inštanciou), a podľa toho sa zariadiť ďalej. Ak je typ objektu "lekárnička", doplníme hráčovi energiu a objekt odstránime z hry (aby sa nedala tá istá lekárnička používať stále dokola). Ak je typ objektu "mína", začneme odpočítavanie do vybuchnutia míny.
;esi = adresa objektu cmp dword [esi], TYPE_MEDIKIT je handle_medikit cmp dword [esi], TYPE_MINE je handle_mine
struct OBJECT { int type; } *obj; ... switch(obj->type) { case TYPE_MEDIKIT: MEDIKIT *mk = (MEDIKIT*)obj; ... case TYPE_MINE: MINE *mn = (MINE*)obj; ...
Tento prístup má však praktický problém. Časti programu pracujúce s konkrétnym typom objektu sú roztrúsené po celom programe, pri udalosti ktorú spracúvajú (napr. dotknutie hráča a objektu). Takto, ak chceme zmeniť nejaký aspekt správania sa objektu, musíme vyhľadať a opraviť všetky miesta v programe. Ľahko sa môže stať že niektoré vynecháme a potom musíme zdĺhavo hľadať kde nastala chyba.
Lepší prístup je mať pokope celý kód pracujúci s určitým typom objektov (tj. s konkrétnou triedou). Vyriešime to tak, že pre každý typ objektu (pre každú triedu) dáme kód čo s ňou pracuje do samostatných funkcii, a tieto zoskupíme podľa triedy ktorú obsluhujú. V našej ukážke budeme mať teda funkciu ktorá bude obsahovať kód na spracovanie kontaktu hráča s lekárničkou, a ďalšiu na spracovanie kontaktu s mínou. Funkcie budú mať ako argument adresu objektu (inštancie) pre ktorú su volané:
cmp dword [esi], TYPE_MEDIKIT jne not_medikit push esi call medikit_touched jmp done not_medikit: cmp dword [esi], TYPE_MINE jne not_mine push esi call mine_touched jmp done not_mine:
switch(obj->type) { case TYPE_MEDIKIT: medikit_touched((MEDIKIT*)obj); break; case TYPE_MINE: mine_touched((MINE*)obj); break;
Takéto funkcie, ktoré pracujú s konkrétnou triedou, nazývame "metódy" triedy.
Ukážka ako takáto metóda môže vyzerať. Každá metóda má štandardne ako prvý argument ukazovateľ na objekt (inštanciu triedu) s ktorým pracuje. Tento pointer sa štandardne nazýva "this" pointer:
MEDIKIT.X = 8 MEDIKIT.Y = 12 MEDIKIT.HP = 16 ;pri volani, ESI=ukazovatel na objekt medikit_touched: mov esi, [esp+4] ;nacitaj argument zo zasobniku mov eax, [esi + MEDIKIT.HP] add [player.hp], eax ;doplnime zivot hracovi mov [esi + MEDIKIT.HP], 0 ;lekarnicka vycerpana mov [esi + MEDIKIT.X], -1 ;odsunieme lekarnicku doprdele mov [esi + MEDIKIT.Y], -1 retn 4
void medikit_touched((MEDIKIT*)this) { player.hp += this->hp; // doplnime zivot hracovi this->hp = 0; // lekarnicka vycerpana this->x = -1; // odsunieme lekarnicku doprdele z mapy this->y = -1; }
Ešte stále to však nieje ono. Stále musíme, pri pridaní novej triedy, pridávať na veľa miest kód, ktorý zisťuje typ objektu a podľa toho volá príslušnú metódu. Je to možné riešiť aj elegantnejšie.
Priamo do objektu vložíme pointer na metódu, a potom pri volaní metódy už netreba zisťovať typ objektu, ale len priamo zavolať metódu na ktorú sa odkazuje:
; objekt typu lekarnicka dd TYPE_MEDIKIT ;typ objektu = lekarnicka dd medikit_touched ;ukazovatel na metodu "touched" dd 10 ;x koordinat = 10 dd 15 ;y koordinat = 15 dd 50 ;energia = 50 ; objekt typu mina dd TYPE_MINE ;typ objektu = mine dd mine_touched ;ukazovatel na metodu "touched" dd 10 ;x koordinat = 10 dd 15 ;y koordinat = 15 dd 5 ;time to explode = 5 seconds dd 50 ;damage = 50 ; volanie metody "touched" objektu, na ktory ukazuje ESI push esi ;adresa objektu call dword [esi+4] ;volanie metody podla pointeru
struct MEDIKIT { int type; void (*touched)(OBJECT* this); // ukazovatel na metodu "touched" int x, y; int hp; }; struct MINE { int type; void (*touched)(OBJECT* this); // ukazovatel na metodu "touched" int x, y; int timeout; int hp; }; struct OBJECT { // spolocna struktura pre vsetky objekty int type; void (*touched)(OBJECT* this); // ukazovatel na metodu "touched" } *obj; // volanie metody "touched" pre objekt "obj" obj->touched(obj);
Zadefinujme si ďalšiu metódu. Môže to byť napriklad metóda "shot", ktoru bude zavolaná keď hráč strelí do objektu. Podľa doterajšieho systému to bude vyzerať asi takto:
; objekt typu lekarnicka dd TYPE_MEDIKIT dd medikit_touched dd medikit_shot ;pridali sme ukazovatel na druhu metodu dd 10 dd 15 dd 50 ; objekt typu mina dd TYPE_MINE dd mine_touched dd medikit_shot ;pridali sme ukazovatel na druhu metodu dd 10 dd 15 dd 5 dd 50 ; volanie metody "touched" objektu, na ktory ukazuje ESI push esi ;adresa objektu call dword [esi+4] ;volanie metody podla pointeru ; volanie metody "shot" objektu, na ktory ukazuje ESI push esi ;adresa objektu call dword [esi+8] ;volanie metody podla pointeru
struct MEDIKIT { int type; void (*touched)(OBJECT* this); void (*shot)(OBJECT* this); // pridany ukazovatel na metodu "shot" int x, y; int hp; }; MEDIKIT mk = {TYPE_MEDIKIT, &medikit_touched, &medikit_shot, 10, 15, 50}; struct MINE { int type; void (*touched)(OBJECT* this); void (*shot)(OBJECT* this); // pridany ukazovatel na metodu "shot" int x, y; int timeout; int hp; }; MINE mn = {TYPE_MINE, &mine_touched, &mine_shot, 10, 15, 50}; struct OBJECT { // spolocna cast int type; void (*touched)(OBJECT* this); void (*shot)(OBJECT* this); // ukazovatel na metodu "shot" } *obj; // volanie metody "touched" pre objekt "obj" obj->touched(obj); // volanie metody "shot" pre objekt "obj" obj->shot(obj);
Pre úplnosť si môžeme ukázať aj príklady týchto metód. Metóda "medikit_shot" spôsobí že energia lekárničky sa zníži na polovicu, a "mine_shot" spôsobí okamžity výbuch míny (nastaví "timeout" na nulu).
medikit_shot: mov esi, [esp+4] mov eax, [esi + MEDIKIT.HP] shr eax, 1 mov [esi + MEDIKIT.HP], eax retn 4 mine_shot: mov esi, [esp+4] mov dword [esi + MINE.TIMEOUT], 0 retn 4
void medikit_shot(MEDIKIT *this) { this->hp = this->hp / 2; } void mine_shot(MINE *this) { this->timeout = 0; }
Tu už začíname vidieť problém s týmto prístupom. Každý objekt má v sebe ukazovatele na metódy triedy. Avšak pre všetky inštancie tej istej triedy sú tieto ukazovatele rovnaké. To znamená že každým objektom zbytočne plytváme pamäť, tým viac čím viac je metód.
Riešením je zadefinovať pevne tabuľku ukazovateľov raz pre každú triedu, a do objektov dať len ukazovateľ na každú triedu. Táto tabuľka sa nazýva "virtuálna tabuľka" (virtual table). Týmto spôsobom je navyše ukazovateľ unikátny pre objekty každej triedy, a teda sa zároveň dá využiť na určenie typu objektu (to je ale málokedy treba, keďže všetku prácu s objektom vykonáme priamo v metódach). To znamená že člen "type" už nieje potrebný.
; virtualna tabulka triedy MEDIKIT medikit_vtab: dd medikit_touched dd medikit_shot ; instancia triedy MEDIKIT mk: dd medikit_vtab ;adresa virtualnej tabulky dd 10 ;x dd 15 ;y dd 50 ;hp ; virtualna tabulka triedy MINE mine_vtab: dd mine_touched dd mine_shot ;instancia triedy MINE mn: dd mine_vtab ;adresa virtualnej tabulky dd 20 ;x dd 20 ;y dd 30 ;timeout dd 50 ;hp ;volanie metody "touched" objektu na ktory ukazuje ESI push esi ;argument pre metodu mov eax, dword [esi] ;EAX = adresa virtualnej tabuly call dword [eax] ;EAX+0 = adresa metody "touched" vo virtualnej tabulke ;volanie metody "shot" objektu na ktory ukazuje ESI push esi ;argument pre metodu mov eax, dword [esi] ;EAX = adresa virtualnej tabuly call dword [eax+4] ;EAX+4 = adresa metody "shot" vo virtualnej tabulke
// deklaracia spolocnej virtualnej tabulky struct VTAB { void (*touched)(OBJECT* this); void (*shot)(OBJECT* this); }; // lekarnicka struct MEDIKIT { VTAB *vtab; int x,y; int hp; }; VTAB medikit_vtab = {&medikit_touched, &medikit_shot}; // virtualna tabulka triedy MEDIKIT MEDIKIT mk1 = {&medikit_vtab, 10, 15, 50}; MEDIKIT mk2 = {&medikit_vtab, 20, 20, 10}; // mina struct MINE { VTAB *vtab; int x,y; int timeout; int hp; }; VTAB mine_vtab = {&mine_touched, &mine_shot}; // virtualna tabulka triedy MINE MINE mn1 = {&mine_vtab, 10, 15, 30, 30}; // spolocna cast vsetkych objektov struct OBJECT { VTAB *vtab; // uz iba vtab }; OBJECT *obj; // volanie metody "touched" objektu na ktory ukazuje "obj" obj->vtab->touched(obj); // volanie metody "shot" objektu na ktory ukazuje "obj" obj->vtab->shot(obj);
Poznámka: V praxi väčšina metód triedy nemusí byť virtuálnych, a kompilátor (ak je to možné) dokáže zavolať tu správnu metódu sám. Virtuálne metódy majú skutočný význam pri dedičnosti, ktorá v tejto časti ešte nebola zadefinovaná (aj keď v príkladoch sme ju mali, bez toho aby sme o tom vedeli).
To je zatiaľ všetko. Pokiaľ bude dobrá odozva na článok, možno napíšem aj pokračovanie kde podobným štýlom rozoberiem aj niektoré ďalšie aspekty moderného objektového programovania.
Pokračujte na diskuzním fóru.
Autora lze kontaktovat pomocí e-mailu vid@x86asm.net.
Navštivte domovskou stránku autora.
2008-02-06 | 1.0 | První veřejná verze | vid |
(formáty dat odpovídají ISO 8601)