Prednáška č. 2
- Základné koncepty objektovo orientovaného programovania
- Zapuzdrenie
- Preťažovanie metód
- Statické a nestatické metódy a premenné
- Odkazy
Základné koncepty objektovo orientovaného programovania
Objekty a triedy
Dvoma najzákladnejšími konceptmi objektovo orientovaného programovania (OOP) sú triedy a objekty.
- Trieda (angl. class) je typ, ktorý podobne ako
structv C/C++ môže združovať údaje rôznych typov. Okrem toho ale obvykle obsahuje aj definície metód na manipuláciu s týmito údajmi. - Objekt (angl. object) je inštancia triedy – obsahuje teda už nejakú konkrétnu sadu údajov vyhovujúcu definícii triedy, na ktorú možno aplikovať metódy definované v triede.
- Triedu teda možno chápať ako „vzor”, podľa ktorého sa vytvárajú objekty.
Príklad: nasledujúca trieda Fraction reprezentuje zlomky. Obsahuje
dve premenné numerator a denominator zodpovedajúce čitateľu a
menovateľu zlomku a metódu na vyhodnotenie zlomku.
public class Fraction {
int numerator;
int denominator;
double evaluate() {
return (double) numerator / denominator;
}
}
Inštanciami tejto triedy, t. j. objektmi typu Fraction, sú konkrétne
realizácie triedy Fraction (napr. zlomok s čitateľom 2 a menovateľom
3). O spôsobe ich vytvorenia si povieme o chvíľu. Avšak v prípade, že už
máme nejakú inštanciu fraction triedy Fraction vytvorenú, môžeme
hodnotu zlomku vypísať napríklad nasledovne:
Fraction fraction;
// Sem pride vytvorenie instancie triedy Fraction a jej priradenie do premennej fraction.
System.out.println(fraction.evaluate());
Príklad: časť triedy reprezentujúcej zásobník implementovaný pomocou poľa (čo je v Jave značne suboptimálne riešenie) by mohla vyzerať napríklad nasledovne.
public class MyStack {
int data[];
int count;
int pop() {
count--;
return data[count];
}
// Dalsie metody (napr. push) ...
}
Ak si opäť odmyslíme vytvorenie samotného zásobníka, môžeme so
zásobníkom typu MyStack pracovať napríklad takto:
MyStack stack;
// Sem pride vytvorenie zasobnika a napriklad niekolko prikazov push.
int x = stack.pop();
Neskôr uvidíme, že medzi štandardnými triedami jazyka Java možno nájsť
aj množstvo dátových štruktúr a medzi nimi aj triedy, ktoré možno priamo
použiť ako zásobník (napr.
LinkedList
alebo
ArrayDeque).
Príklad vyššie je teda iba ilustračný a tvorbe tried podobného druhu je
vo všeobecnosti lepšie sa vyvarovať.
Príklad: v Jave sú inštancie všetkých typov okrem primitívnych objektmi. S výnimkou veľmi špecifického prípadu polí, o ktorom si viac povieme neskôr, viac-menej pôjde o objekty v podobe, v akej si ich predstavíme na tejto prednáške.
Referencie na objekty
Premenná, ktorej typom je trieda, obsahuje referenciu na objekt, ktorý je inštanciou tejto triedy.
- Podľa toho sa teda správajú operátory
=a==. - K premenným a metódam objektu, na ktorý príslušná referencia
ukazuje, pristupujeme pomocou operátora
.a píšeme napríkladfraction.numeratoralebofraction.evaluate().
Fraction fraction1, fraction2;
// ...
fraction1 = fraction2; // Obidve premenne ukazuju na to iste miesto v pamati.
fraction1.numerator = 3; // Zmeni sa aj hodnota fraction2.numerator.
- Do premennej, ktorej typom je trieda, možno priradiť hodnotu
null– v takom prípade ide o referenciu, ktorá neukazuje na žiaden objekt.
Konštruktory a inicializácia objektov
Často je potrebné súčasne s vytvorením objektu vykonať rôzne
inicializačné úkony – napríklad pri zásobníku typu MyStack alokovať
pole, pri zlomkoch typu Fraction inicializovať premenné na vhodné
hodnoty, a pod. Na takúto inicializáciu objektov v Jave slúžia špeciálne
kusy kódu podobné metódam – takzvané konštruktory. Volanie
konštruktorov je neodmysliteľne späté s vytváraním objektov.
- Názov konštruktora je vždy rovnaký ako názov triedy, ku ktorej patrí (na rozdiel od bežných metód teda ich názov podľa konvencie začína veľkým písmenom).
- Do hlavičky konštruktora sa nepíše návratový typ (konštruktor
žiaden nemá; nepíše sa ale ani
void). V opačnom prípade pôjde o bežnú metódu (nie o konštruktor), čo môže viesť k pomerne nepríjemným chybám. - Prípadné argumenty sa zapisujú rovnako ako pri bežných metódach.
- Pre jednu triedu možno definovať aj viacero konštruktorov, ktoré sa však musia líšiť postupnosťou typov argumentov (aby bolo pri volaní jasné, o ktorý z konštruktorov ide).
Príklad: pre triedu Fraction môžeme napísať napríklad nasledujúce
dva konštruktory.
public class Fraction {
// ...
public Fraction() {
numerator = 0;
denominator = 1;
}
public Fraction(int num, int denom) { // Neskor uvidime, ze nie je nutne pre argumenty konstruktora a premenne instancie volit ine nazvy
numerator = num;
denominator = denom;
}
// ...
}
- Ak pre triedu nedefinujeme žiaden konštruktor, automaticky sa vytvorí konštruktor bez parametrov, ktorý v princípe sám o sebe neurobí nič, ale je možné ho zavolať (bez čoho objekt nevytvoríme).
Samotné vytvorenie inštancie triedy sa realizuje pomocou operátora
new, za ktorým nasleduje volanie niektorého konštruktora.
Fraction f1 = new Fraction();
Fraction f2;
f2 = new Fraction(2, 3);
System.out.println(f1.evaluate());
System.out.println(f2.evaluate());
- Operátor
newdynamicky alokuje pamäť pre objekt, zavolá príslušný konštruktor a vráti referenciu na vytvorený objekt. - Nie je potrebné starať sa o neskoršie odalokovanie pamäte – túto úlohu v JVM vykonáva tzv. garbage collector.
Na rozdiel od lokálnych premenných sú premenné inštancií inicializované
automaticky, a to na hodnoty 0, false, alebo null v závislosti od
typu premennej. Prípadne je možné niektoré premenné inštancií
inicializovať aj explicitne na odlišné hodnoty:
public class Fraction {
int numerator; // Inicializuje sa na nulu.
int denominator = 1;
// ...
}
Alternatívne možno premenné inicializovať v rámci konštruktora, rovnako
ako v jednom z vyššie uvedených príkladov. Pri vytváraní inštancie
triedy pomocou operátora new sa jednotlivé procesy vykonajú v
nasledujúcom poradí:
- Najprv sa vykoná automatická alebo explicitná inicializácia premenných (a to aj v prípade, že nebol definovaný žiaden konštruktor triedy a je tak volaný jej východzí konštruktor bez parametrov).
- Až následne sa spustí volaný konštruktor.
Kľúčové slovo this
V rámci (nestatických) metód a konštruktorov tried možno používať
kľúčové slovo this, ktoré sa pre každú inštanciu tejto triedy
interpretuje ako referencia na seba, t. j. na objekt, na ktorom bola
metóda obsahujúca toto kľúčové slovo volaná. Kľúčové slovo this sa
používa predovšetkým nasledujúcimi troma spôsobmi:
- Pre ľubovoľnú premennú
premennaalebo metódumetodainštancie triedy možno na prístup k tejto premennej resp. metóde použiť zápisthis.premennaresp.this.metoda. Často sú teda tieto zápisy ekvivalentné kratším zápisompremennaresp.metoda. Niekedy sa však môže stať, že sa niektorá premenná inštancie prekryje napríklad argumentom alebo lokálnou premennou s rovnakým názvom. V takom prípade možno k premennej inštancie pristúpiť iba prostredníctvomthis.premenna. Naša triedaFractionby teda napríklad mohla vyzerať aj takto:
public class Fraction {
int numerator;
int denominator = 1;
public Fraction() {
}
public Fraction(int numerator, int denominator) {
this.numerator = numerator;
this.denominator = denominator;
}
public double evaluate() {
return (double) numerator / denominator;
}
}
- Kľúčové slovo
thismožno využiť aj na predanie danej inštancie ako argument nejakej metódy (metódaprintnižšie slúži len na ilustráciu tejto funkcionality; omnoho krajšie by bolo implementovať ibatoStringa samotný výpis ponechať na iné triedy).
public class Fraction {
// ...
@Override // Tuto znacku si zatial nevsimajme, kod by fungoval aj bez nej.
public String toString() {
return numerator + " / " + denominator;
}
public void print() {
System.out.println(this); // Rovnake spravanie ako s argumentom toString() resp. this.toString().
}
}
- V rámci prvého príkazu konštruktora možno pomocou
this( ... )zavolať iný konštruktor tej istej triedy. Často napríklad potrebujeme spraviť niektoré úkony pri inicializácii objektu vždy, t. j. v ľubovoľnom konštruktore, zatiaľ čo iné sú žiadúce iba v niektorých konštruktoroch. V takom prípade by z návrhového hľadiska nebolo rozumné opakovať spoločné časti kódu v každom z konštruktorov. (Čo sa stane v prípade, keď v tomto kóde bude potrebné niečo zmeniť?) Namiesto toho je vhodnejšie v „pokročilejších” konštruktoroch zavolať nejaký „menej pokročilý” konštruktor (dá sa to ale iba v rámci prvého príkazu).
public class Circle {
double centerX = 0; // Netreba explicitnu inicializaciu, ale moze to byt prehladnejsie
double centerY = 0; // Netreba explicitnu inicializaciu, ale moze to byt prehladnejsie
double radius = 1;
public Circle() {
System.out.println("Vytvaram kruh.");
}
public Circle(double centerX, double centerY) {
this();
this.centerX = centerX;
this.centerY = centerY;
}
public Circle(double centerX, double centerY, double radius) {
this(centerX, centerY);
this.radius = radius;
}
}
Občas môže byť užitočné použiť kľúčové slovo this aj iným spôsobom,
napríklad vrátiť this na výstupe.
Modifikátory prístupu
Premenným, metódam, konštruktorom, ako aj triedam samotným možno v Jave nastavovať tzv. modifikátory prístupu určujúce viditeľnosť týchto súčastí z iných tried. Modfikátory prístupu sú v Jave štyri:
private: k premennej, metóde, alebo konštruktoru možno pristúpiť iba v rámci danej triedy; na triedy sa tento modifikátor použiť nedá.- (žiadny modifikátor): premenná, metóda, konštruktor, alebo trieda je viditeľná len v rámci jej balíka.
protected: len pre premenné, metódy a konštruktory a podobné ako v predchádzajúcom prípade; rozdiel uvidíme na budúcej prednáške.public: premennú, metódu, konštruktor, alebo triedu možno použiť z ľubovoľnej triedy.
Každá trieda Trieda s modifikátorom public musí byť uložená v
zdrojovom súbore, ktorého názov musí byť Trieda.java. Názvy tried bez
modifikátora prístupu sa nemusia zhodovať s názvom súboru a jeden
zdrojový súbor môže obsahovať aj viacero tried (najviac jedna z nich
však môže byť public, pričom v takom prípade sa názov tejto triedy
musí zhodovať s názvom súboru). Za dobrú prax sa ale považuje pre
každú triedu vytvoriť samostatný súbor, ktorého názov sa zhoduje s
názvom triedy.
Ako sme už spomenuli na minulej prednáške, modifikátor prístupu
statickej metódy main musí byť vždy public.
Zapuzdrenie
Jedným z hlavných metodických princípov objektovo orientovaného programovania je zapuzdrenie (angl. encapsulation). Ide o „zabalenie” dát a metód na manipuláciu s nimi do spoločného „puzdra” – inštancie nejakej triedy.
- Kód z iných tried by mal s dátami „zabalenými” v objekte manipulovať iba pomocou jeho metód na to určených.
- To sa obvykle zabezpečí tak, že sa modifikátor
publicpriradí iba tým metódam, ktoré sú určené na použitie „zvonka”. Premenným a pomocným metódam sa priradí iný modifikátor, najčastejšieprivate. - Verejné metódy tak tvoria akúsi „sadu nástrojov”, ktorú trieda
poskytuje iným triedam na prácu s jej inštanciami. Napríklad trieda
pre zásobník by mohla mať (okrem konštruktora) verejné metódy
push,pop,isEmptyapeek, pričom jej premenné a prípadné pomocné metódy by boli súkromné. - Výhodou tohto prístupu je, že možno zmeniť vnútornú implementáciu triedy bez toho, aby to nejako ovplyvnilo ostatné triedy. Jediné, čo musí zostať zachované, je správanie verejných metód (čo zvyčajne ide zariadiť aj pri zmenenej implementácii zvyšku triedy). Napríklad v triede pre zásobník by sme mohli namiesto poľa použiť spájaný zoznam a zvyšné triedy by to nijak neovplyvnilo.
- Zapuzdrenie tak umožňuje rozdeliť projekt na relatívne nezávislé logické celky s dobre definovaným rozhraním.
Metódy get a set
Premenné inštancií tried sú teda zvyčajne súkromné. Existujú však
prípady, keď je opodstatnené k niektorým z nich umožniť prístup aj iným
triedam. Napríklad v našom príklade so zlomkami by sa v prípade
nemožnosti pristúpiť k čitateľu alebo k menovateľu zlomku podstatne
obmedzila funkcionalita triedy Fraction. Obvyklé riešenie takýchto
situácií je ponechať samotnú premennú súkromnú, ale poskytnúť verejné
metódy na čítanie a zmenu hodnoty tejto premennej. Takéto metódy pre
premennú hodnota sa zvyknú konvenčne pomenúvať ako getHodnota a
setHodnota. Podstatná časť našej triedy Fraction by tak mohla
vyzerať napríklad nasledovne:
public class Fraction {
private int numerator;
private int denominator = 1;
public Fraction() {
}
public Fraction(int numerator, int denominator) {
this.numerator = numerator;
this.denominator = denominator;
}
public int getNumerator() {
return numerator;
}
public void setNumerator(int numerator) {
this.numerator = numerator;
}
public int getDenominator() {
return denominator;
}
public void setDenominator(int denominator) {
this.denominator = denominator;
}
public double evaluate() {
return (double) numerator / denominator;
}
}
- Určite nie je vhodné bezmyšlienkovite vytvárať metódy
getasetpre všetky premenné. Opodstatnené to je iba vtedy, keď je daná premenná podstatnou charakteristikou triedy navonok, t. j. pokiaľ prístup k nej môže byť zaujímavý aj v prípade, že sa na samotnú triedu pozeráme ako na „čiernu skrinku”.
Výhody použitia metód get a set oproti použitiu verejných premenných
sú napríklad nasledujúce:
- Môžeme poskytnúť iba metódu
get. Tým sa premenná stane „určenou iba na čítanie”. Ak by sme napríklad v našej triedeFractionzmazali metódysetNumeratorasetDenominator, dostali by sme triedu reprezentujúcu nemodifikovateľné zlomky (podobne ako napríkladStringreprezentuje nemodifikovateľné reťazce). - V rámci metódy
setmožno kontrolovať, či sa do premennej ukladá rozumná hodnota. Napríklad metódasetDenominatorvyššie by mohla vyhodiť výnimku (ešte nevieme ako) v prípade, že by sme sa pokúsili nastaviť menovateľ na nulu. - Metódy
getasetnemusia presne korešpondovať s premennými, a teda môžu ostať zachované aj po zmene vnútornej reprezentácie triedy. Uvažujme napríklad podstatnú časť našej triedyCirclez príkladu vyššie, v ktorej premenné nastavíme na súkromné a pridáme metódygetaset.
public class Circle {
private double centerX = 0;
private double centerY = 0;
private double radius = 1;
public double getCenterX() {
return centerX;
}
public void setCenterX(double centerX) {
this.centerX = centerX;
}
public double getCenterY() {
return centerY;
}
public void setCenterY(double centerY) {
this.centerY = centerY;
}
public double getRadius() {
return radius;
}
public void setRadius(double radius) {
this.radius = radius;
}
// ...
}
- Predpokladajme, že sa rozhodneme kruh reprezentovať namiesto jeho
stredom a polomerom napríklad jeho stredom a ľubovoľným bodom na
jeho hranici (čo je tiež jednoznačná reprezentácia kruhu). V takom
prípade určite nie je dobré ponechať aj premennú
radius, pretože by sme v celej triede museli zabezpečiť jej konzistenciu s premennými pre bod na hranici kruhu, a to by mohlo byť potenciálnym zdrojom chýb. Keby sme teda polomer ostatným triedam zverejňovali priamo ako premennú, spôsobila by naša malá zmena v implementácii triedyCirclenutnosť zmeny aj vo všetkých triedach s triedouCirclepracujúcich, čo je známkou zlého návrhu. MetódygetRadiusasetRadiusvšak ľahko prerobíme tak, aby pracovali zmysluplným spôsobom aj pri novom spôsobe reprezentácie kruhu.
public class Circle {
private double centerX = 0;
private double centerY = 0;
private double boundaryPointX = 1;
private double boundaryPointY = 0;
private double distance(double aX, double aY, double bX, double bY) {
return Math.sqrt((aX - bX) * (aX - bX) + (aY - bY) * (aY - bY));
}
public double getRadius() {
return distance(centerX, centerY, boundaryPointX, boundaryPointY);
}
public void setRadius(double radius) {
double currentRadius = getRadius();
// Nasledujuce pracuje spravne len pre nedegenerovane kruhy, t. j. za predpokladu currentRadius > 0:
boundaryPointX = centerX + (boundaryPointX - centerX) * radius / currentRadius;
boundaryPointY = centerY + (boundaryPointY - centerY) * radius / currentRadius;
}
// ...
}
- Možnosť takéhoto zmysluplného prerobenia metódy
getalebosetje ale do veľkej miery daná tým, že polomer je prirodzeným parametrom kruhu; stále teda platí, že metódygetasettreba implementovať s mierou. Pokiaľ ide o zvyšok triedy, zmena reprezentácie kruhu by si pravdepodobne vyžadovala pridanie nových konštruktorov (hoci možno polomer vypočítať z bodu na hranici kruhu, opačne by to bolo minimálne nejednoznačné; po úprave teda trieda reprezentuje viac informácií a bolo by preto vhodné pridať konštruktor umožňujúci bod na hranici kruhu pri jeho vytvorení zadať). Konštruktor na báze polomeru, opísaný v týchto poznámkach vyššie, však nie je potrebné mazať – jeho nová implementácia by napríklad mohla využívať metódusetRadius.
Preťažovanie metód
Podobne ako môže mať trieda viacero konštruktorov, môže obsahovať aj viacero metód s rovnakým názvom. Podmienkou aj tu je, aby mali metódy s rovnakými názvami rôzne postupnosti typov argumentov (t. j. rôzne signatúry). Takéto vytváranie viacerých metód s rovnakým názvom sa nazýva ich preťažovaním (angl. overloading).
Príklad:
public class Circle {
public void draw() {
// ...
}
public void draw(String color) {
// ...
}
public void draw(int r, int g, int b) {
// ...
}
public void draw(String color, int penWidth) {
// ...
}
public void draw(int r, int g, int b, int penWidth) {
// ...
}
// ...
}
(Veľmi ľahké) cvičenie: nájdite príklady preťažovania metód v štandardných triedach jazyka Java.
Statické a nestatické metódy a premenné
Doposiaľ sme sa na tejto prednáške zaoberali výhradne metódami a
premennými inštancií, t. j. nestatickými metódami a premennými. Na
minulej prednáške sme naopak tvorili statické metódy a podobne možno
definovať aj statické premenné – statickosť pritom znamená, že nepôjde
o metódy resp. premenné inštancií, ale o metódy resp. premenné
samotných tried, ktoré sa v mnohom správajú ako bežné funkcie a
globálne premenné, ako ich poznáme z minulého semestra. Statickú metódu
alebo premennú definujeme pomocou modifikátora static; bez jeho
použitia sa metóda alebo premenná považuje za nestatickú.
V jednej triede možno kombinovať statické metódy a premenné s nestatickými.
- Nestatické prvky príslušia inštanciám tejto triedy a statické samotnej triede.
- Uvažujme teda napríklad triedu
Trieda, jej inštanciuinstancia, statickú metódupublic static void statickaMetoda()a nestatickú metódupublic void nestatickaMetoda(). Potom možno písaťinstancia.nestatickaMetoda()aTrieda.statickaMetoda(), zápisTrieda.nestatickaMetoda()ale nedáva zmysel a nemal by sa používať ani zápisinstancia.statickaMetoda()(hoci technicky je ekvivalentný zápisuTrieda.statickaMetoda()). - Zo statických metód (v statickom kontexte) nemôžeme pristupovať k nestatickým premenným a volať nestatické metódy.
- Z nestatických metód môžeme pristupovať ako k nestatickým prvkom (ktoré sa týkajú príslušnej inštancie), tak aj k prvkom statickým (ktoré sa týkajú triedy samotnej).
Príklad 1:
public class Trieda {
static int a = 1;
int b = 2;
static void f() {
System.out.println("Som staticka metoda f.");
}
void g() {
System.out.println("Som nestaticka metoda g.");
}
public static void main(String[] args) {
f();
Trieda.f(); // To iste ako na predchadzajucom riadku, ale pouzitelne aj z inych tried.
Trieda instancia = new Trieda();
instancia.g();
instancia.f(); // To iste ako iba f(), ale IDE O VELMI SKAREDY ZAPIS, pretoze f je staticka a "patri" triede
// g(); // Chyba: non-static method g() cannot be referenced from a static context
System.out.println(a);
System.out.println(instancia.b);
}
}
Príklad 2: v Jave neskôr narazíme na situácie, keď je potrebné za
každých okolností pracovať s objektmi. Aj za týmto účelom Java
definuje špeciálne „baliace” triedy (angl. wrapper classes) pre
hodnoty všetkých primitívnych typov, na ktoré sa možno pozerať ako na
„primitívne typy zabalené do objektov”. „Baliaca” trieda pre typ int
má názov Integer, pre typ char má názov Character a pre ostatné
primitívne typy ide o názov daného typu, avšak s veľkým začiatočným
písmenom.
V Jave funguje automatická konverzia medzi primitívnymi typmi a príslušnými „baliacimi” triedami, tzv. boxing a unboxing. Možno teda písať napríklad
Integer i1 = 1;
int i2 = 2;
i1 = i2; // boxing
i2 = i1; // unboxing
Avšak pozor: operátory == a != síce možno použiť na porovnanie
„zabaleného” celého čísla typu Integer s „nezabaleným” celým číslom
typu int, avšak pri aplikácii na dva objekty i,j typu Integer sa,
rovnako ako pri ľubovoľnej inej dvojici objektov, porovnávajú
referencie. Správne sa porovnanie týchto hodnôt realizuje napríklad
prostredníctvom i.equals(j).
Pohľad do dokumentácie triedy
Integer
ukazuje, že táto trieda obsahuje tri metódy toString konvertujúce celé
čísla na reťazce:
public String toString();
public static String toString(int i);
public static String toString(int i, int radix);
Prvá z týchto metód je nestatická a možno ju teda aplikovať na inštancie
triedy Integer:
Integer n = 42;
String s = n.toString(); // s teraz obsahuje textovu reprezentaciu cisla n
Druhá z nich je naopak statická a ako parameter berie celé číslo, ktoré prevedie na reťazec:
int n = 42;
String s = Integer.toString(n); // s teraz obsahuje textovu reprezentaciu cisla n
Posledná je tiež statická; ako parameter berie okrem čísla aj základ pozičnej číselnej sústavy a výsledný reťazec bude reprezentáciou daného čísla v sústave o danom základe:
int n = 42;
String s = Integer.toString(n, 12); // s teraz obsahuje textovu reprezentaciu cisla n v dvanastkovej sustave