OOP z pohľadu C/Asm programátora

vid, 2008-02-06Revize: 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á.

Prvý objekt

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:

Asm:
dd TYPE_MEDIKIT		;typ objektu = lekárnička
dd 10			;x koordinát = 10
dd 15			;y koordinát = 15
dd 50			;energia = 50
C:
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:

Asm:
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
C:
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.

Metódy

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.

Asm:
;esi = adresa objektu
cmp	dword [esi], TYPE_MEDIKIT
je	handle_medikit
cmp	dword [esi], TYPE_MINE
je	handle_mine
C:
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é:

Asm:
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:
C:
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:

Asm:
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
C:
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;
}

Virtualne metódy

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:

Asm:
; 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
C:
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);

Virtuálna tabuľka

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:

Asm:
; 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
C:
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).

Asm:
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
C:
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ý.

Asm:
; 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
C:
// 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).

Pokračovanie nabudúce?

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.


Komentáře

Pokračujte na diskuzním fóru.

Autora lze kontaktovat pomocí e-mailu vid@x86asm.net.

Navštivte domovskou stránku autora.


Revize

2008-02-061.0První veřejná verzevid

(formáty dat odpovídají ISO 8601)