Objektovo orientované programovanie

15.1.2012

Pri tvorbe počítačového programu treba myslieť na veľa rôznych vecí. Predstavme si jednoduchú počítačovú hru, v ktorej panáčik preskakuje z plošiny na plošinu, vyhýba sa nepriateľským príšerám, zbiera kľúče a keď má všetky, môže vojsť do cieľa a ocitne sa na ďalšej mape. Čo všetko treba v takejto hre vyriešiť? Poloha panáčika, príšer, kľúčov, cieľa, plošín; grafické súbory, v ktorých sú jednotlivé obrázky uložené; ovládanie panáčika pomocou klávesnice; algoritmy na ovládanie príšer (niekoľko rôznych druhov); pohyb panáčika počas skoku, narazenie do steny, zrážka s príšerou, zobratie kľúča, vojdenie do cieľa; koľko kľúčov ešte treba zobrať, aké má panáčik skóre, na ktorej mape sa nachádza; ako vlastne vyzerajú jednotlivé mapy; plus nejaké úvodné menu a záverečné obrazovky, keď panáčik úspešne prejde celú hru, alebo keď ho zjedia príšery.

Aj pri pomerne jednoduchej hre je toho dosť. Aby sme zvládli toľko rôznych detailov a nič pritom nepoplietli, treba si program rozdeliť na menšie časti; pri programovaní každej časti sa sústredíme na veci, ktoré s ňou súvisia a nesúvisiace veci dočasne pustíme z hlavy. Ak si program dobre rozdelíme, ukáže sa, že jednotlivé časti sú vlastne pomerne jednoduché. Vedieť program dobre rozdeliť je dôležitá časť programátorského remesla.

Objektovo orientované programovanie je spôsob programovania, ktorý nás núti deliť programy na malé časti nazývané objekty. Nedá sa to celé vysvetliť v jednom článku, ale pokúsim sa na niekoľkých príkladoch ukázať pointu. (Zvyšok nájdete v učebniciach a na internete.) Aby boli príklady čo najjednoduchšie, predstavme si program, ktorý pracuje s jednoduchými geometrickými útvarmi, napríklad štvorcami. Každý štvorec má nejakú veľkosť, podľa ktorej môžeme vypočítať jeho obvod a plochu. Program si vytvorí nejaké štvorce a potom spočíta ich celkovú plochu.

Trieda

Jednotlivé štvorce sa od seba odlišujú veľkosťou strany (rôzne hodnoty), ale majú rovnaké vzorce na výpočet obvodu a plochy (rovnaký program). Z hľadiska programu to budú rôzne objekty jednej triedy; triedy „Stvorec“. Tým sme náš program rozdelili na dve menšie časti. Hlavný program vie, aké štvorce chce vytvoriť a že chce nakoniec sčítať ich plochy. Trieda „Stvorec“ zase pozná veci týkajúce sa štvorcov, čiže vzorec na výpočet plochy. Trieda „Stvorec“ sa nestará o to, koľko štvorcov bude existovať a na čo budú použité. Hlavný program sa zase nestará o to, ako sa počíta plocha štvorca; keď bude chcieť vedieť, akú má nejaký štvorec plochu, jednoducho sa opýta: „štvorec, aká je tvoja plocha?“ a dostane odpoveď. Všeobecne povedané, hlavný program vie, čo treba urobiť s objektmi a objekt vie, ako to treba urobiť. Tu je ukážkový program:

class Stvorec {

	int strana;

	int plocha() {
		return strana * strana;
	}

}

public class Pokus {

	public static void main(String[] args) {
		Stvorec maly = new Stvorec();
		maly.strana = 5;
		Stvorec velky = new Stvorec();
		velky.strana = 10;
		System.out.println(maly.plocha() + velky.plocha());
	}

}

Príkazom „new“ vytvoríme nový objekt danej triedy. Aby sme s ním mohli neskôr pracovať, uložíme si ho do premennej. Ak za názov premennej napíšeme bodku, komunikujeme tak s objektom, ktorý je momentálne uložený v tejto premennej. Príkazom „maly.strana“ nastavíme premennú „strana“ štvorcu, ktorý je práve uložený v premennej „maly“. Príkazom „maly.plocha()“ zavoláme funkciu „plocha()“ štvorca, ktorý je práve uložený v premennej „maly“.

Keď máme program takto rozdelený, všetky veci, ktoré sa týkajú štvorca, budeme písať do triedy „Stvorec“. Keby sme jedného dňa náhodou zistili, že sa plocha štvorca počíta nesprávne, budeme chybu hľadať v triede „Stvorec“. Pre väčšiu prehľadnosť môžeme triedu „Stvorec“ dať do samostatného súboru. Ak bude týchto súborov veľa, môžeme ich zoskupiť do rôznych adresárov (v Jave tieto adresáre nazývame „balíky“).

Aby sme si ukázali, načo je dobré oddeliť výpočty štvorca od zvyšku programu, skúsme si predstaviť, že násobenie je veľmi zložitý výpočet, ktorý zaberá veľa času. (V skutočnosti to nie je pravda, ale potrebujem nejakú zámienku na nasledujúce úpravy programu.) Aby sme ušetrili čas, môže si štvorec vypočítanú plochu zapamätať do pomocnej premennej a ak sa ho program opýta na veľkosť po druhýkrát, štvorec nebude počítať odznova, ale vráti zapamätaný výsledok. Takýto vylepšený štvorec nazveme „TurboStvorec“.

class TurboStvorec {

	int strana;
	int vypocitanaPlocha = 0;

	int plocha() {
		if (0 == vypocitanaPlocha) {
			vypocitanaPlocha = strana * strana; 
		}
		return vypocitanaPlocha;
	}

}

Rozšírenie triedy

Ak chceme v programe používať aj pôvodné štvorce aj turboštvorce, máme tu dve podobné triedy. Z hľadiska údajov obsahuje trieda „TurboStvorec“ to isté ako „Stvorec“ (premennú „strana“), plus niečo navyše (premennú „vypocitanaPlocha“). Trieda „TurboStvorec“ má aj rovnaké funkcie ako trieda „Stvorec“ (funkciu „plocha()“), aj keď je program tejto funkcie iný. Ak je medzi dvoma triedami takýto vzťah, môžeme napísať, že trieda „TurboStvorec“ je rozšírením triedy „Stvorec“. Potom nemusíme písať, čo majú tieto triedy spoločné, iba napíšeme, v čom sa odlišujú. V našom prípade je spoločným obsahom iba premenná „strana“, ale pri zložitejších triedach by toho spoločného obsahu mohlo byť viac, čím by sme si ušetrili veľa zbytočného opakovania.

class TurboStvorec extends Stvorec {

	int vypocitanaPlocha = 0;

	@Override
	int plocha() {
		if (0 == vypocitanaPlocha) {
			vypocitanaPlocha = strana * strana; 
		}
		return vypocitanaPlocha;
	}

}

Slovo „extends“ označuje rozšírenie triedy. Značka „@Override“ pred funkciou označuje, že táto funkcia existovala aj v základnej triede, ale v rozšírenej triede je zmenená. (Značka „@Override“ je síce nepovinná, ale oplatí sa ju písať, aby sme na prvý pohľad videli rozdiel medzi pridanouzmenenou funkciou.)

Aká je výhoda rozdelenia programu na objekty? Ak sa teraz vrátime k hlavnému programu, stačí zmeniť riadok „Stvorec maly = new Stvorec();“ na „Stvorec maly = new TurboStvorec();“. Program začne využívať novú funkcionalitu a pritom nevyzerá o nič zložitejšie. Všetka zložitosť spojená s výpočtom plochy štvorca sa nachádza v príslušnej triede; nekomplikuje zvyšok programu. Všimnite si, že objekt z rozšírenej triedy možno uložiť do premennej základnej triedy, takže zvyšok programu ani nemusí vedieť, že pracuje s novým typom objektu. V premennej typu „Stvorec“ sa môže nachádzať objekt typu „Stvorec“ alebo typu „TurboStvorec“; program môže ľubovoľnému z nich nastaviť veľkosť strany a opýtať sa na jeho plochu.

Prístupové metódy

V našom príklade môže vzniknúť problém, ak stranu turboštvorca uprostred výpočtov zmeníme. Pozrite si nasledujúci program: turboštvorec si prvýkrát vypočíta veľkosť plochy a druhýkrát vráti pôvodnú vypočítanú hodnotu, aj keď sa veľkosť strany medzičasom zmenila.

	public static void main(String[] args) {
		Stvorec s = new TurboStvorec();
		s.strana = 5;
		System.out.println(s.plocha());
		s.strana = 10;
		System.out.println(s.plocha()); // zlý výsledok :-(
	}

Aby sme sa vyhli takýmto chybám, nesmieme zvyšku programu dovoliť priamo zasahovať do premenných objektu. Zvyšok programu nevie (a ani by nemal vedieť), aké sú medzi týmito premennými vzťahy. Ak napriek tomu potrebuje nastavovať a zisťovať hodnoty premenných (načo by nám bol štvorec, ktorému nemožno nastaviť veľkosť?), vytvoríme mu na to špeciálne funkcie, nazývané prístupové metódy; tradične ich pomenujeme „set...“ a „get...“ s názvom príslušnej premennej. Bezpečne naprogramovaný štvorec by teda vyzeral takto:

class Stvorec {

	protected int strana;

	public int getStrana() {
		return strana;
	}

	public void setStrana(int strana) {
		this.strana = strana;
	}

	public int plocha() {
		return strana * strana;
	}

}

Označenie „protected“ znamená, že premennú „strana“ môže priamo používať iba trieda „Stvorec“ a jej rozšírenia. Označenie „public“ znamená, že tieto funkcie môže používať hocikto. V našom prípade môže hocikto nastavovať a meniť veľkosť strany, ale iba pomocou funkcií, nie priamo. Hlavný program bude potom vyzerať takto:

	public static void main(String[] args) {
		Stvorec s = new TurboStvorec();
		s.setStrana(5);
		System.out.println(s.plocha());
		s.setStrana(10);
		System.out.println(s.plocha());
	}

Ak objektu „TurboStvorec“ neprospieva, ak mu niekto nečakane zmení veľkosť strany, stačí, aby zmenil aj funkciu „setStrana“ (jedinú funkciu, pomocou ktorej možno zmeniť veľkosť strany) takto. Slovo „super“ označuje triedu, z ktorej je táto trieda odvodená, čiže v tomto prípade „super.setStrana“ označuje funkciu „setStrana“ triedy „Stvorec“.

	@Override
	public void setStrana(int strana) {
		super.setStrana(strana);
		vypocitanaPlocha = 0;
	}

Pridajme si ďalší geometrický útvar, obdĺžnik, reprezentovaný triedou „Obdlznik“. Vďaka nedávno získaným skúsenostiam ho hneď napíšeme správne, s chránenými premennými a prístupovými metódami.

class Obdlznik {

	private int vyska;
	private int sirka;

	public void setVyska(int vyska) {
		this.vyska = vyska;
	}

	public void setSirka(int sirka) {
		this.sirka = sirka;
	}

	public int getVyska() {
		return vyska;
	}

	public int getSirka() {
		return sirka;
	}

	public int plocha() {
		return vyska * sirka;
	}

}

Začiatočník, ktorý sa práve dozvedel o rozširovaní tried, môže byť v pokušení hľadať špeciálny vzťah medzi štvorcom a obdĺžnikom. Nie je azda štvorec špeciálnym prípadom obdĺžnika, ktorý má obe strany rovnaké? Alebo naopak, nie je snáď obdĺžnik rozšírením štvorca o ďalšiu číselnú hodnotu?

Pri programovaní by sme mali jeden typ považovať za podtyp druhého iba vtedy, keď možno prvý typ bezpečne použiť všade, kde sa používa druhý. Napríklad typ „TurboStvorec“ možno bez problémov použiť všade, kde sa predtým používal typ „Stvorec“. Ako je to však so štvorcom a obdĺžnikom?

Ak by jeden útvar mal byť podtypom druhého, musel by mať príslušné funkcie. To by sa dalo dosiahnuť tým, že by napríklad pri štvorci metódy „setVyska“ aj „setSirka“ nastavili veľkosť strany, alebo naopak pri obdĺžniku by metóda „setStrana“ nastavila na danú hodnotu výšku aj šírku. Ale fungovalo by to správne?

Od obdĺžnika očakávame, že ak jeho strany postupne nastavíme na 3 a 5 a potom sa opýtame na plochu, dostaneme výsledok 15. Štvorec toto očakávanie nedokáže splniť, preto štvorec nemôže byť podtypom obdĺžnika. Od štvorca naopak očakávame, že nech je nastavený akokoľvek, jeho plocha sa vždy rovná druhej mocnine jeho výšky; obdĺžnik však toto očakávanie nespĺňa, preto obdĺžnik nemôže byť podtypom štvorca. Ani jeden z týchto typov teda nie je podtypom druhého, preto by sme žiaden z nich nemali programovať ako rozšírenú triedu.

Rozhranie

Z hľadiska hlavného programu však štvorce a obdĺžniky majú čosi spoločné: sú to geometrické útvary, ktoré vedia vypočítať svoju plochu. Tento typ vzťahu označujeme ako rozhranie; objekty nemajú spoločné premenné ani program, ale poskytujú rovnaké funkcie. Rozhranie „Utvar“ zapíšeme takto:

interface Utvar {
	public int plocha();
} 

Aby sme označili, že štvorce a obdĺžniky patria medzi útvary, zmeníme úvodné slová „class Stvorec“ na „class Stvorec implements Utvar“; podobne aj „class Obdlznik implements Utvar“. K ich funkciám „plocha()“ môžeme potom doplniť značku „@Override“. Mimochodom, funkcie používané v rozhraní musia mať viditeľnosť „public“. K triede „TurboStvorec“ nemusíme dopisovať, že spĺňa rozhranie „Utvar“; ako rozšírenie triedy „Stvorec“ dedí túto vlastnosť automaticky.

Odteraz môžeme objekty typu „Stvorec“, „TurboStvorec“ a „Obdlznik“ ukladať do premenných typu „Utvar“.

Konštruktor

Keď v našom programe vytvoríme nový útvar, nastavíme mu vlastnosti (štvorcu stranu, obdĺžniku výšku a šírku). To nie je ideálne riešenie; po prvé je to zbytočne veľa riadkov, po druhé by sa mohlo stať, že niektorú vlastnosť zabudneme nastaviť. Pomôže nám špeciálny druh funkcie, nazývaný konštruktor, ktorý priamo pri vytvorení objektu nastaví jeho hodnoty. Konštruktor sa píše ako funkcia, len namiesto návratového typu a názvu funkcie napíšeme názov danej triedy. Konštruktor objektu „Obdlznik“ môže vyzerať takto:

	public Obdlznik(int vyska, int sirka) {
		this.vyska = vyska;
		this.sirka = sirka;
	}

Nový obdĺžnik potom vytvoríme príkazom:

Obdlznik o = new Obdlznik(3, 5);

V skutočnosti má každý typ nejaký konštruktor. Ak v programe nenapíšeme žiaden konštruktor, jazyk Java automaticky vytvorí konštruktor bez parametrov, ktorý by zapísaný vyzeral takto:

public Typ() {
	super();
}

Ak triede „Stvorec“ vytvoríme nový konštruktor...

	public Stvorec(int strana) {
		this.strana = strana;
	}

...musíme upraviť aj konštruktor triedy „TurboStvorec“, aby využíval tento konštruktor:

	public TurboStvorec(int strana) {
		super(strana);
	}

Nové objekty potom jednoducho vytvoríme takto:

		Stvorec s1 = new Stvorec(2);
		Stvorec s2 = new TurboStvorec(4);
		Obdlznik o = new Obdlznik(3, 5);

Mimochodom, ak teraz vymažeme metódy „set...“, dostaneme objekty, ktorým možno pri vytvorení nastaviť hodnoty, ale neskôr ich nemožno zmeniť. Také objekty sú niekedy užitočné. A inokedy sú zase užitočné objekty, ktorých hodnoty možno priebežne meniť.

Parametrický typ

Toto je už učivo pre pokročilých; nebudeme sa teraz učiť takéto typy vytvárať, iba používať. Často používaným dátovým typom je zoznam. Ale zoznam čoho? Niekedy chceme zoznam čísel, inokedy zoznam textov, a dnes budeme chcieť zoznam geometrických útvarov. Máme teda typ zoznam, ku ktorému treba uviesť parameter, akého typu je to zoznam.

Zoznam v jazyku Java vytvoríme pomocou rozhrania „List“. Parameter uvádzame v zobáčikoch za názvom typu; zoznam geometrických útvarov bude teda „List<Utvar>“. Typ „List“ však nie je trieda, ale rozhranie, pretože zoznamy možno po technickej stránke riešiť niekoľkými spôsobmi. Najčastejšie sa používa trieda „ArrayList“. Nový zoznam teda vytvoríme nasledujúcim príkazom:

List<Utvar> zoznam = new ArrayList<>();

Do zoznamu pridávame prvky pomocou funkcie „add“. Ak chceme postupne použiť jednotlivé prvky zoznamu, použijeme príkaz „for“. V prípade zoznamu môžeme využiť zjednodušený zápis s dvojbodkou „for (Utvar utvar : zoznam)“. Mimochodom, typy „List“ aj „ArrayList“ sú z adresára „java.util“, preto na úvod progamu musíme napísať „import java.util.*;“.

Tu je teda celý program (bez triedy „TurboStvorec“). V hlavnom programe si vytvoríme zoznam geometrických útvarov, do ktorého vložíme niekoľko štvorcov a obdĺžnikov. Potom postupne prechádzame cez tieto útvary (všimnite si, že v danej časti programu nerozlišujeme medzi štvorcami a obdĺžnikmi; sú to jednoducho všetko útvary) a sčítame ich plochy. Celkový súčet na záver programu vypíšeme.

// subor Utvar.java
interface Utvar {
	public int plocha();
}
// subor Stvorec.java
class Stvorec implements Utvar {

	protected int strana;

	public Stvorec(int strana) {
		this.strana = strana;
	}

	public int getStrana() {
		return strana;
	}

	public void setStrana(int strana) {
		this.strana = strana;
	}

	@Override
	public int plocha() {
		return strana * strana;
	}

} 
// subor Obdlznik.java
class Obdlznik implements Utvar {

	private int vyska;
	private int sirka;

	public Obdlznik(int vyska, int sirka) {
		this.vyska = vyska;
		this.sirka = sirka;
	}

	public void setVyska(int vyska) {
		this.vyska = vyska;
	}

	public void setSirka(int sirka) {
		this.sirka = sirka;
	}

	public int getVyska() {
		return vyska;
	}

	public int getSirka() {
		return sirka;
	}

	@Override
	public int plocha() {
		return vyska * sirka;
	}

}
// subor Pokus.java
import java.util.*;

public class Pokus {

	public static void main(String[] args) {
		List<Utvar> zoznam = new ArrayList<>();
		zoznam.add(new Stvorec(10));
		zoznam.add(new Stvorec(20));
		zoznam.add(new Stvorec(30));
		zoznam.add(new Obdlznik(1, 4));
		int suma = 0;
		for (Utvar utvar : zoznam) {
			suma += utvar.plocha();
		}
		System.out.println("Sucet ploch = " + suma);
	}

}

Tento článok bol trochu zložitý, najmä ku koncu, ale prešli sme v ňom základné finty objektovo orientovaného programovania v Jave, takže v ďalšom článku môžeme začať naozaj programovať.

Ak vám niečo nebolo jasné, skúste si to pozrieť v učebnici alebo na internete, a keby nič z toho nepomohlo, pošlite mi e-mail.

Na záver pár poznámok k objektovej terminológii: Slovom „typ“ označujeme množinu údajov, ktoré fungujú rovnakým spôsobom. V jazyku Java máme tri druhy typov. Po prvé, takzvané primitívne typy, napríklad „int“. Píšu sa s malým začiatočným písmenom a je ich v celom jazyku iba deväť (void, boolean, char, byte, short, int, long, float, double). Po druhé, triedy. Po tretie, rozhrania. Triedy aj rozhrania píšeme s veľkým začiatočným písmenom. Funkciu objektu zvykneme nazývať „metóda“. Premennú objektu nazývame „členská premenná“ alebo „premenná inštancie“. Ak majú nejakú premennú spoločnú všetky objekty danej triedy, nazývame ju „statická premenná“ alebo „premenná triedy“.

viliam@bur.sk