Programovanie (2) v Jave
1-INF-166, letný semester 2023/24
Letný semester, prednáška č. 2
Obsah
Oznamy
- Prvú bonusovú úlohu treba odovzdať najneskôr do začiatku stredajších cvičení.
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 struct v 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 triedy.
- 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 = s.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íklad fraction.numerator alebo fraction.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 new dynamicky 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í alokované 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ú premenna alebo metódu metoda inštancie triedy možno na prístup k tejto premennej resp. metóde použiť zápis this.premenna resp. this.metoda. Často sú teda tieto zápisy ekvivalentné kratším zápisom premenna resp. 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íctvom this.premenna. Naša trieda Fraction by 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 this možno využiť aj na predanie danej inštancie ako argument nejakej metódy.
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().
}
}
- Na prvom riadku 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 treba niečo zmeniť?) Namiesto toho je vhodnejšie v „pokročilejších” konštruktoroch zavolať nejaký „menej pokročilý” konštruktor (ale iba na prvom riadku).
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 ale 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 a metódam, 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 alebo metóde možno pristúpiť iba v rámci jej triedy; na triedy sa tento modifikátor použiť nedá.
- (žiadny modifikátor): premenná, metóda, alebo trieda je viditeľná len v rámci jej balíka.
- protected: podobné ako v predchádzajúcom prípade; rozdiel uvidíme na budúcej prednáške.
- public: premennú, metódu, 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 s inými modifikátormi 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 public priradí 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, napríklad private.
- 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, isEmpty a peek, 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 funkcií (č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 get a set pre 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 triede Fraction zmazali metódy setNumerator a setDenominator, dostali by sme triedu reprezentujúcu nemodifikovateľné zlomky (podobne ako napríklad String reprezentuje nemodifikovateľné reťazce).
- V rámci metódy set možno kontrolovať, či sa do premennej ukladá rozumná hodnota. Napríklad metóda setDenominator vyšš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 get a set nemusia 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 triedy Circle z príkladu vyššie, v ktorej premenné nastavíme na súkromné a pridáme metódy get.
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 triedy Circle nutnosť zmeny aj vo všetkých triedach s triedou Circle pracujúcich, čo je známkou zlého návrhu. Metódy getRadius a setRadius vš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();
boundaryPointX = centerX + (boundaryPointX - centerX) * radius / currentRadius;
boundaryPointY = centerY + (boundaryPointY - centerY) * radius / currentRadius;
}
// ...
}
- Možnosť takéhoto zmysluplného prerobenia metódy get alebo set je ale do veľkej miery daná tým, že polomer je prirodzeným parametrom kruhu; stále teda platí, že metódy get a set treba 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ódu setRadius.
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é vs. 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štanciu instancia, statickú metódu public static void statickaMetoda() a nestatickú metódu public void nestatickaMetoda(). Potom možno písať instancia.nestatickaMetoda() a Trieda.statickaMetoda(), zápis Trieda.nestatickaMetoda() ale nedáva zmysel a nemal by sa používať ani zápis instancia.statickaMetoda() (hoci technicky je ekvivalentný zápisu Trieda.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 funkcia f.");
}
void g() {
System.out.println("Som nestaticka funkcia 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 všetky hodnoty 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