4. diel - Referenčné a primitívne dátové typy
V predchádzajúcom cvičení, Riešené úlohy k 3. lekcii OOP v Jave, sme si precvičili získané skúsenosti z predchádzajúcich lekcií.
Začíname pracovať s objektmi a objekty sú referenčnými dátovými
typmi, ktoré sa v niektorých ohľadoch správajú inak než typy primitívne
(napr. int
). Je dôležité, aby sme presne vedeli, čo sa vo
vnútri programu deje, inak by nás v budúcnosti mohlo všeličo
prekvapiť.
Pre istotu si ešte raz zopakujme, čo sú to primitívne typy. Všeobecne
sú to jednoduché štruktúry, napr. jedno číslo, jeden
znak. Väčšinou sa chce, aby sme s nimi pracovali čo
najrýchlejšie, v programe sa ich vyskytuje veľmi
veľa a zaberajú málo miesta. V anglickej
literatúre sú často popisované slovami light-weight. Majú
pevnú veľkosť. Príkladom sú napr. int
,
float
, double
, char
, boolean
a ďalšie.
Aplikácia (resp. jej vlákno) má operačným systémom pridelenú pamäť v podobe tzv. zásobníka (stack). Ide o veľmi rýchlu pamäť s priamym prístupom, jej veľkosť aplikácia nemôže ovplyvniť, prostriedky sú prideľované operačným systémom. Táto malá a rýchla pamäť je využívaná na ukladanie lokálnych premenných primitívneho typu (až na výnimky pri iteráciách, ktorými sa nebudeme zaoberať). Premennú si v nej môžeme predstaviť asi takto:
Na obrázku je znázornená pamäť, ktorú môže naša aplikácia
využívať. V aplikácii sme si vytvorili premennú a
typu
int
. Jej hodnota je 56
a uložila sa nám priamo do
zásobníka. Kód by mohol vyzerať takto:
int a = 56;
Môžeme to chápať tak, že premenná a
má pridelenú časť
pamäte v zásobníku (veľkosti dátového typu int
, teda 32
bitov), v ktorej je uložená hodnota 56
.
Vytvorme si novú konzolovú aplikáciu s názvom napríklad
ReferenceTypes
a pridajme si k nej jednoduchú triedu, ktorá bude
reprezentovať používateľov nejakého systému. Pre názornosť vypustím
komentáre a nebudem riešiť viditeľnosti:
public class User { public int age; public String name; public User(String name, int age) { this.name = name; this.age = age; } @Override public String toString() { return name; } }
Trieda má dva jednoduché verejné atribúty, konštruktor a prepísanú
metódu toString()
, aby sme používateľov mohli jednoducho
vypisovať. Do nášho pôvodného programu (metóda main()
)
pridajme vytvorenie inštancie tejto triedy:
int a = 56; User james = new User("James Brown", 28);
Premenná james
je teraz referenčného typu. Pozrime sa na
novú situáciu v pamäti:
Vidíme, že objekt (premenná referenčného dátového typu) sa už neukladá do zásobníka, ale do pamäte zvanej halda. Je to z toho dôvodu, že objekt je spravidla zložitejší ako primitívny dátový typ (väčšinou obsahuje hneď niekoľko ďalších atribútov) a taktiež zaberá viac miesta v pamäti.
Zásobník aj halda sa nachádzajú v pamäti RAM. Rozdiel je v prístupe a veľkosti. Halda je prakticky neobmedzená pamäť, ku ktorej je však prístup zložitejší a tým pádom pomalší. Naopak zásobník je pamäť rýchla, ale veľkostne obmedzená.
Premenné referenčného typu sú v pamäti uložené vlastne dvakrát, raz v zásobníku a raz v halde. V zásobníku je uložená iba tzv. referencia, teda odkaz do haldy, kde sa potom nachádza skutočný objekt.
Napr. v C++ je veľký rozdiel medzi pojmom ukazovateľ a referencia. Java žiadne ukazovatele našťastie nemá a používa termín referencie, tie sa paradoxne princípom podobajú skôr ukazovateľom v C++. Pojmy ukazovateľ a referencia tu spomínané teda znamenajú referenciu v zmysle Javy a nemajú s C++ nič spoločné.
Môžete sa pýtať, prečo je to takto urobené. Dôvodov je hneď niekoľko, poďme si niektoré vymenovať:
- Miesto v stacku je obmedzené.
- Keď budeme chcieť použiť objekt viackrát (napr. ho odovzdať ako parameter do niekoľkých metód), nemusíme ho v programe odovzdávať ako kópiu. Odovzdáme iba malý primitívny typ s referenciou na objekt namiesto toho, aby sme všeobecne pamäťovo náročný objekt kopírovali. Toto si o chvíľu ukážeme.
- Pomocou referencií môžeme jednoducho vytvárať štruktúry s dynamickou veľkosťou, napr. štruktúry podobné poľu, do ktorých môžeme za behu vkladať nové prvky. Tie sú na seba navzájom odkazované referenciami ako reťaz objektov.
Založme si dve premenné typu int
a dve premenné typu
User
:
int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32);
Situácia v pamäti bude nasledovná:
Teraz skúsme do premennej a
priradiť premennú b
.
Rovnako tak priradíme aj premennú jack
do premennej
james
. Primitívny typ sa v zásobníku len skopíruje, pri objekte
sa skopíruje iba referencia (čo je vlastne aj primitívny typ), ale objekt
máme stále len jeden. V kóde vykonáme teda toto:
int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32); a = b; james = jack;
V pamäti bude celá situácia vyzerať nasledovne:
Presvedčme sa o tom, aby ste videli, že to naozaj tak je Najprv si necháme všetky štyri
premenné vypísať pred a po zmene. Pretože budeme výpis volať viackrát,
napíšem ho trochu úspornejšie. Mohli by sme dať výpis do metódy, ale
ešte nevieme, ako deklarovať metódy priamo v súbore s metódou
main()
(v tomto prípade súbor ReferenceTypes.java
) a
spravidla sa to ani veľmi nerobí, pre vážnejšiu prácu by sme si mali
urobiť triedu. Upravme teda kód nasledovne:
{JAVA_OOP} {JAVA_MAIN_BLOCK} // variable declaration int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32); System.out.printf("a: %s%nb: %s%njames: %s%njack: %s%n%n", a, b, james, jack); // assignment a = b; james = jack; System.out.printf("a: %s%nb: %s%njames: %s%njack: %s%n%n", a, b, james, jack); {/JAVA_MAIN_BLOCK} {/JAVA_OOP}
{JAVA_OOP} public class User { public int age; public String name; public User(String name, int age) { this.name = name; this.age = age; } @Override public String toString() { return name; } } {/JAVA_OOP}
Na výstupe programu zatiaľ rozdiel medzi primitívnym a referenčným typom nespoznáme:
Konzolová aplikácia
a: 56
b: 28
james: James Brown
jack: Jack White
a: 28
b: 28
james: Jack White
jack: Jack White
Avšak vieme, že zatiaľ čo v a
a b
sú naozaj
dve rôzne čísla s rovnakou hodnotou, v james
a jack
je ten istý objekt. Poďme zmeniť meno používateľa jack
a
podľa našich predpokladov by sa mala zmena prejaviť aj v premennej
james
. K programu pripíšeme:
{JAVA_OOP} {JAVA_MAIN_BLOCK} // variable declaration int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32); System.out.printf("a: %s%nb: %s%njames: %s%njack: %s%n%n", a, b, james, jack); // assignment a = b; james = jack; System.out.printf("a: %s%nb: %s%njames: %s%njack: %s%n%n", a, b, james, jack); // change jack.name = "John Doe"; System.out.printf("james: %s%njack: %s%n", james, jack); {/JAVA_MAIN_BLOCK} {/JAVA_OOP}
{JAVA_OOP} public class User { public int age; public String name; public User(String name, int age) { this.name = name; this.age = age; } @Override public String toString() { return name; } } {/JAVA_OOP}
Zmenili sme objekt v premennej jack
a znova vypíšeme
james
a jack
:
Konzolová aplikácia
a: 56
b: 28
james: James Brown
jack: Jack White
a: 28
b: 28
james: Jack White
jack: Jack White
james: John Doe
jack: John Doe
Spolu so zmenou premennej jack
sa zmení aj premenná
james
, pretože premenné ukazujú na ten istý objekt. Ak sa
pýtate, ako vytvoriť skutočnú kópiu objektu, tak najjednoduchšie je objekt
znovu vytvoriť pomocou konštruktora a dať do neho rovnaké dáta. Ďalej
môžeme použiť klonovanie, ale o tom zas až niekedy inokedy. Pripomeňme si
situáciu v pamäti ešte raz a zamerajme sa na Jamesa Browna:
Čo sa ním stane? "Zožerie" ho tzv. Garbage collector.
Garbage collector a dynamická správa pamäte
Pamäť môžeme v programoch alokovať staticky, to znamená, že v zdrojovom kóde vopred určíme, koľko jej budeme používať. Doteraz sme to tak vlastne robili a nemali sme s tým problém, pekne sme do zdrojového kódu napísali potrebné premenné. Čoskoro sa ale budeme stretávať s aplikáciami (a už sme sa vlastne aj stretli), kedy nebudeme pred spustením presne vedieť, koľko pamäte budeme potrebovať. Spomeňte si na program, ktorý spriemeroval zadané hodnoty v poli. Na počet hodnôt sme sa používateľa opýtali až za behu programu. JVM teda musel za behu programu pole v pamäti založiť. V tomto prípade hovoríme o dynamickej správe pamäte.
V minulosti, hlavne v dobách jazykov C, Pascal a C++, sa na tento účel používali tzv. pointery, čiže priame ukazovatele do pamäte. Vo všeobecnosti to fungovalo tak, že sme operačný systém požiadali o kus pamäte o určitej veľkosti. On ju pre nás vyhradil a dal nám jej adresu. Na toto miesto v pamäti sme mali pointer, cez ktorý sme s pamäťou pracovali. Problém bol, že nikto nestrážil, čo do pamäte dávame (ukazovateľ smeroval na začiatok vyhradeného priestoru). Keď sme tam dali niečo väčšie, skrátka sa to aj tak uložilo a prepísali sa dáta za naším priestorom, ktoré patrili napríklad inému programu alebo operačnému systému (v tom prípade by našu aplikáciu OS asi zabil – zastavil). Často sme si však my v pamäti prepísali nejaké ďalšie dáta nášho programu a program sa začal správať chaoticky. Predstavte si, že si uložíte používateľa do poľa a v tej chvíli sa vám zrazu zmení farba používateľského prostredia, teda niečo, čo s tým vôbec nesúvisí. Hodiny strávite tým, že kontrolujete kód pre zmenu farby, potom zistíte, že je chyba v založení používateľa, kedy dôjde k pretečeniu pamäte a prepísaniu hodnôt farby.
Keď naopak nejaký objekt prestaneme používať, musíme po ňom miesto sami uvoľniť. Ak to neurobíme, pamäť zostane blokovaná. Pokiaľ toto robíme napr. v nejakej metóde a zabudneme pamäť uvoľňovať, naša aplikácia začne padať, prípadne zasekne celý operačný systém. Takáto chyba sa opäť zle hľadá, prečo program prestane po niekoľkých hodinách fungovať? Kde tú chybu v niekoľkých tisícoch riadkov kódu vôbec hľadať? Nemáme jedinú stopu, nemôžeme sa ničoho chytiť, musíme prejsť celý program riadok po riadku alebo začať preskúmavať pamäť počítača, ktorá je v binárke. Brrr. Podobný problém nastane, keď si niekde pamäť uvoľníme a následne pointer opäť použijeme (zabudneme, že je uvoľnený, to sa môže ľahko stať). Povedie niekam, kde je už uložené niečo iné a tieto dáta budú opäť prepísané. Povedie to k nekontrolovanému správaniu našej aplikácie a môže to dopadnúť aj takto:
Môj kolega raz hovoril: "Ľudský mozog sa nedokáže starať ani o správu vlastnej pamäte, nieto aby riešil memory management programu." Mal samozrejme pravdu, až na malú skupinu géniov ľudí prestalo baviť riešiť neustále a nezmyselné chyby. Za cenu mierneho zníženia výkonu vznikli riadené jazyky (managed) s tzv. garbage collectorom, jedným z nich je aj Java a C#. C++ sa samozrejme naďalej používa, ale iba na špecifické programy, napr. časti operačného systému alebo 3D enginy komerčných hier, kde je potrebné z počítača dostať maximálny výkon. Na 99 % všetkých ostatných aplikácií sa hodí Java, hlavne kvôli automatickej správe pamäte.
Garbage collector (ďalej iba GC) je vlastne program, ktorý beží paralelne s našou aplikáciou v samostatnom vlákne. Občas sa spustí a pozrie sa, na ktoré objekty už v pamäti nevedú žiadne referencie. Tie potom odstráni. Strata výkonu je minimálna a značne to zníži percento samovrážd programátorov ladiacich po večeroch rozbité pointery. Zapnutie GC môžeme dokonca ovplyvniť, aj keď to nie je v 99 % prípadov vôbec potrebné. Pretože je jazyk riadený a nepracujeme s priamymi pointermi, nie je vôbec možné pamäť nejako narušiť, nechať ju pretiecť a podobne, interpreter sa o pamäť automaticky stará.
Hodnota null
Posledná vec, ktorú si spomenieme, je tzv. hodnota null
.
Referenčné typy môžu, na rozdiel od primitívnych, nadobúdať špeciálne
hodnoty a to null
. Hodnota null
je kľúčové slovo a
označuje, že referencia neukazuje na žiadne dáta. Keď nastavíme premennú
jack
na null
, zrušíme iba tú jednu referenciu. Ak
na náš objekt existuje ešte nejaká referencia, bude aj naďalej existovať.
Ak nie, bude uvoľnený GC. Zmeňme ešte posledné riadky nášho programu
na:
{JAVA_OOP} {JAVA_MAIN_BLOCK} // variable declaration int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32); System.out.printf("a: %s%nb: %s%njames: %s%njack: %s%n%n", a, b, james, jack); // assignment a = b; james = jack; System.out.printf("a: %s%nb: %s%njames: %s%njack: %s%n%n", a, b, james, jack); // change jack.name = "John Doe"; jack = null; System.out.printf("james: %s%njack: %s%n", james, jack); {/JAVA_MAIN_BLOCK} {/JAVA_OOP}
{JAVA_OOP} public class User { public int age; public String name; public User(String name, int age) { this.name = name; this.age = age; } @Override public String toString() { return name; } } {/JAVA_OOP}
Výstup:
Konzolová aplikácia
a: 56
b: 28
james: James Brown
jack: Jack White
a: 28
b: 28
james: Jack White
jack: Jack White
james: John Doe
jack: null
Vidíme, že objekt stále existuje a ukazuje naň premenná
james
, v premennej jack
už nie je referencia. Hodnota
null
sa hojne využíva ako vo vnútri Javy, tak v databázach. K
referenčným typom sa ešte raz vrátime.
V nasledujúcom kvíze, Kvíz - Úvod, konštruktory, metódy, dátové typy v Jave OOP, si vyskúšame nadobudnuté skúsenosti z predchádzajúcich lekcií.
Mal si s čímkoľvek problém? Stiahni si vzorovú aplikáciu nižšie a porovnaj ju so svojím projektom, chybu tak ľahko nájdeš.
Stiahnuť
Stiahnutím nasledujúceho súboru súhlasíš s licenčnými podmienkami
Stiahnuté 12x (2.68 kB)
Aplikácia je vrátane zdrojových kódov v jazyku Java