Lekce 4 - Referenční a primitivní datové typy
V předešlém cvičení, Řešené úlohy k 3. lekci OOP v Javě, jsme si procvičili nabyté zkušenosti z předchozích lekcí.
Začínáme pracovat s objekty a objekty jsou referenčními datovými typy,
které se v některých ohledech chovají jinak, než typy primitivní (např.
int
). Je důležité, abychom přesně věděli, co se uvnitř
programu děje, jinak by nás v budoucnu mohlo leccos překvapit.
Zopakujme si pro jistotu ještě jednou, co jsou to primitivní typy. Obecně
jsou to jednoduché struktury, např. jedno číslo, jeden
znak. Většinou se chce, abychom s nimi pracovali co
nejrychleji, v programu se jich vyskytuje velmi
mnoho a zabírají málo místa. V anglické
literatuře jsou často popisovány slovy light-weight. Mají
pevnou velikost. Příkladem jsou např. int
,
float
, double
, char
, boolean
a další.
Aplikace (resp. její vlákno) má operačním systémem přidělenou paměť v podobě tzv. zásobníku (stack). Jedná se o velmi rychlou paměť s přímým přístupem, její velikost aplikace nemůže ovlivnit, prostředky jsou přidělovány operačním systémem. Tato malá a rychlá paměť je využívána k ukládání lokálních proměnných primitivního typu (až na výjimky při iteracích, kterými se nebudeme zabývat). Proměnnou si v ní můžeme představit asi takto:
Na obrázku je znázorněna paměť, kterou může naše aplikace využívat.
V aplikaci jsme si vytvořili proměnnou a
typu int
.
Její hodnota je 56
a uložila se nám přímo do zásobníku. Kód
by mohl vypadat takto:
int a = 56;
Můžeme to chápat tak, že proměnná a
má přidělenu část
paměti v zásobníku (velikosti datového typu int
, tedy 32
bitů), ve které je uložena hodnota 56
.
Vytvořme si novou konzolovou aplikaci s názvem například
ReferencniTypy
a přidejme si k ní jednoduchou třídu, která
bude reprezentovat uživatele nějakého systému. Pro názornost vypustím
komentáře a nebudu řešit viditelnosti:
public class Uzivatel { public int vek; public String jmeno; public Uzivatel(String jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } @Override public String toString() { return jmeno; } }
Třída má dva jednoduché veřejné atributy, konstruktor a přepsanou
metodu toString()
, abychom uživatele mohli jednoduše vypisovat.
Do našeho původního programu (metoda main()
) přidejme
vytvoření instance této třídy:
int a = 56; Uzivatel jan = new Uzivatel("Jan Novák", 28);
Proměnná jan
je nyní referenčního typu. Podívejme se na
novou situaci v paměti:
Vidíme, že objekt (proměnná referenčního datového typu) se již neukládá do zásobníku, ale do paměti zvané halda. Je to z toho důvodu, že objekt je zpravidla složitější než primitivní datový typ (většinou obsahuje hned několik dalších atributů) a také zabírá více místa v paměti.
Zásobník i halda se nacházejí v paměti RAM. Rozdíl je v přístupu a velikosti. Halda je prakticky neomezená paměť, ke které je však přístup složitější a tím pádem pomalejší. Naopak zásobník je paměť rychlá, ale velikostně omezená.
Proměnné referenčního typu jsou v paměti uloženy vlastně nadvakrát, jednou v zásobníku a jednou v haldě. V zásobníku je uložena pouze tzv. reference, tedy odkaz do haldy, kde se poté nalézá opravdový objekt.
Např. v C++ je velký rozdíl mezi pojmem ukazatel a reference. Java žádné ukazatele naštěstí nemá a používá termín reference, ty se paradoxně principem podobají spíše ukazatelům v C++. Pojmy ukazatel a reference zde zmíněné tedy znamenají referenci ve smyslu Javy a nemají s C++ nic společného.
Můžete se ptát, proč je to takto udělané. Důvodů je hned několik, pojďme si některé vyjmenovat:
- Místo ve stacku je omezené.
- Když budeme chtít použít objekt vícekrát (např. ho předat jako parametr do několika metod), nemusíme ho v programu předávat jako kopii. Předáme pouze malý primitivní typ s referencí na objekt místo toho, abychom obecně paměťově náročný objekt kopírovali. Toto si vzápětí ukážeme.
- Pomocí referencí můžeme jednoduše vytvářet struktury s dynamickou velikostí, např. struktury podobné poli, do kterých můžeme za běhu vkládat nové prvky. Ty jsou na sebe navzájem odkazovány referencemi, jako řetěz objektů.
Založme si dvě proměnné typu int
a dvě proměnné typu
Uzivatel
:
int a = 56; int b = 28; Uzivatel jan = new Uzivatel("Jan Novák", 28); Uzivatel josef = new Uzivatel("Josef Nový", 32);
Situace v paměti bude následující:
Nyní zkusme přiřadit do proměnné a
proměnnou
b
. Stejně tak přiřadíme i proměnnou josef
do
proměnné jan
. Primitivní typ se v zásobníku jen zkopíruje, u
objektu se zkopíruje pouze reference (což je vlastně také primitivní typ),
ale objekt máme stále jen jeden. V kódu vykonáme tedy toto:
int a = 56; int b = 28; Uzivatel jan = new Uzivatel("Jan Novák", 28); Uzivatel josef = new Uzivatel("Josef Nový", 32); a = b; jan = josef;
V paměti bude celá situace vypadat následovně:
Přesvědčme se o tom, abyste viděli, že to opravdu tak je Nejprve si necháme všechny
čtyři proměnné vypsat před a po změně. Protože budeme výpis volat
vícekrát, napíši ho poněkud úsporněji. Mohli bychom dát výpis do
metody, ale ještě nevíme, jak deklarovat metody přímo v souboru s metodou
main()
(v tomto případě soubor ReferencniTypy.java
)
a zpravidla se to ani moc nedělá, pro vážnější práci bychom si měli
udělat třídu. Upravme tedy kód na následující:
{JAVA_OOP} {JAVA_MAIN_BLOCK} // založení proměnných int a = 56; int b = 28; Uzivatel jan = new Uzivatel("Jan Novák", 28); Uzivatel josef = new Uzivatel("Josef Nový", 32); System.out.printf("a: %s%nb: %s%njan: %s%njosef: %s%n%n", a, b, jan, josef); // přiřazování a = b; jan = josef; System.out.printf("a: %s%nb: %s%njan: %s%njosef: %s%n%n", a, b, jan, josef); {/JAVA_MAIN_BLOCK} {/JAVA_OOP}
{JAVA_OOP} public class Uzivatel { public int vek; public String jmeno; public Uzivatel(String jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } @Override public String toString() { return jmeno; } } {/JAVA_OOP}
Na výstupu programu zatím rozdíl mezi primitivním a referenčním typem nepoznáme:
Konzolová aplikace
a: 56
b: 28
jan: Jan Novák
josef: Josef Nový
a: 28
b: 28
jan: Josef Nový
josef: Josef Nový
Nicméně víme, že zatímco v a
a b
jsou opravdu
dvě různá čísla se stejnou hodnotou, v jan
a
josef
je ten samý objekt. Pojďme změnit jméno uživatele
josef
a dle našich předpokladů by se měla změna projevit i v
proměnné jan
. K programu připíšeme:
{JAVA_OOP} {JAVA_MAIN_BLOCK} // založení proměnných int a = 56; int b = 28; Uzivatel jan = new Uzivatel("Jan Novák", 28); Uzivatel josef = new Uzivatel("Josef Nový", 32); System.out.printf("a: %s%nb: %s%njan: %s%njosef: %s%n%n", a, b, jan, josef); // přiřazování a = b; jan = josef; System.out.printf("a: %s%nb: %s%njan: %s%njosef: %s%n%n", a, b, jan, josef); // změna josef.jmeno = "John Doe"; System.out.printf("jan: %s%njosef: %s%n", jan, josef); {/JAVA_MAIN_BLOCK} {/JAVA_OOP}
{JAVA_OOP} public class Uzivatel { public int vek; public String jmeno; public Uzivatel(String jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } @Override public String toString() { return jmeno; } } {/JAVA_OOP}
Změnili jsme objekt v proměnné josef
a znovu vypíšeme
jan
a josef
:
Konzolová aplikace
a: 56
b: 28
jan: Jan Novák
josef: Josef Nový
a: 28
b: 28
jan: Josef Nový
josef: Josef Nový
jan: John Doe
josef: John Doe
Spolu se změnou proměnné josef
se změní i proměnná
jan
, protože proměnné ukazují na ten samý objekt. Jestli se
ptáte, jak vytvořit opravdovou kopii objektu, tak nejjednodušší je objekt
znovu vytvořit pomocí konstruktoru a dát do něj stejná data. Dále můžeme
použít klonování, ale o tom zas až někdy jindy. Připomeňme si situaci v
paměti ještě jednou a zaměřme se na Jana Nováka:
Co se sním stane? "Sežere" ho tzv. Garbage collector.
Garbage collector a dynamická správa paměti
Paměť můžeme v programech alokovat staticky, to znamená, že ve zdrojovém kódu předem určíme, kolik jí budeme používat. Doposud jsme to tak vlastně dělali a neměli jsme s tím problém, hezky jsme do zdrojového kódu napsali potřebné proměnné. Brzy se ale budeme setkávat s aplikacemi (a už jsme se vlastně i setkali), kdy nebudeme před spuštěním přesně vědět, kolik paměti budeme potřebovat. Vzpomeňte si na program, který zprůměroval zadané hodnoty v poli. Na počet hodnot jsme se uživatele zeptali až za běhu programu. JVM tedy musel za běhu programu pole v paměti založit. V tomto případě hovoříme o dynamické správě paměti.
V minulosti, hlavně v dobách jazyků C, Pascal a C++, se k tomuto účelu používaly tzv. pointery, neboli přímé ukazatele do paměti. Vesměs to fungovalo tak, že jsme si řekli operačnímu systému o kus paměti o určité velikosti. On ji pro nás vyhradil a dal nám její adresu. Na toto místo v paměti jsme měli pointer, přes který jsme s pamětí pracovali. Problém byl, že nikdo nehlídal, co do paměti dáváme (ukazatel směřoval na začátek vyhrazeného prostoru). Když jsme tam dali něco většího, zkrátka se to stejně uložilo a přepsala se data za naším prostorem, která patřila třeba jinému programu nebo operačnímu systému (v tom případě by naši aplikaci OS asi zabil - zastavil). Často jsme si však my v paměti přepsali nějaká další data našeho programu a program se začal chovat chaoticky. Představte si, že si uložíte uživatele do pole a v tu chvíli se vám najednou změní barva uživatelského prostředí, tedy něco, co s tím vůbec nesouvisí. Hodiny strávíte tím, že kontrolujete kód pro změnu barvy, poté zjistíte, že je chyba v založení uživatele, kdy dojde k přetečení paměti a přepsání hodnot barvy.
Když naopak nějaký objekt přestaneme používat, musíme po něm místo sami uvolnit, pokud to neuděláme, paměť zůstane blokovaná. Pokud toto děláme např. v nějaké metodě a zapomeneme paměť uvolňovat, naše aplikace začne padat, případně zasekne celý operační systém. Taková chyba se opět špatně hledá, proč program přestane po několika hodinách fungovat? Kde tu chybu v několika tisících řádků kódu vůbec hledat? Nemáme jedinou stopu, nemůžeme se ničeho chytit, musíme projet celý program řádek po řádku nebo začít prozkoumávat paměť počítače, která je v binárce. Brrr. Podobný problém nastane, když si někde paměť uvolníme a následně pointer opět použijeme (zapomeneme, že je uvolněný, to se může lehce stát), povede někam, kde je již uloženého něco jiného a tato data budou opět přepsána. Povede to k nekontorolovanému chování naší aplikace a může to dopadnout i takto:
Můj kolega jednou pravil: "Lidský mozek se nedokáže starat ani o správu paměti vlastní, natož aby řešil memory management programu." Měl samozřejmě pravdu, až na malou skupinu géniů lidi přestalo bavit řešit neustálé a nesmyslné chyby. Za cenu mírného snížení výkonu vznikly řízené jazyky (managed) s tzv. garbage collectorem, jedním z nich je i Java a C#. C++ se samozřejmě nadále používá, ale pouze na specifické programy, např. části operačního systému nebo 3D enginy komerčních her, kde je potřeba z počítače dostat maximální výkon. Na 99 % všech ostatních aplikací se hodí Java, hlavně kvůli automatické správě paměti.
Garbage collector (dále pouze GC) je vlastně program, který běží paralelně s naší aplikací, v samostatném vlákně. Občas se spustí a podívá se, na které objekty již v paměti nevedou žádné reference. Ty potom odstraní. Ztráta výkonu je minimální a značně to sníží procento sebevražd programátorů, ladících po večerech rozbité pointery. Zapnutí GC můžeme dokonce z kódu ovlivnit, i když to není v 99 % případů vůbec potřeba. Protože je jazyk řízený a nepracujeme s přímými pointery, není vůbec možné paměť nějak narušit, nechat ji přetéct a podobně, interpret se o paměť automaticky stará.
Hodnota null
Poslední věc, o které se zmíníme, je tzv. hodnota null
.
Referenční typy mohou, na rozdíl od primitivních, nabývat speciální
hodnoty a to null
. Hodnota null
je klíčové slovo a
označuje, že reference neukazuje na žádná data. Když nastavíme proměnnou
josef
na null
, zrušíme pouze tu jednu referenci.
Pokud na náš objekt existuje ještě nějaká reference, bude i nadále
existovat. Pokud ne, bude uvolněn GC. Změňme ještě poslední řádky
našeho programu na:
{JAVA_OOP} {JAVA_MAIN_BLOCK} // založení proměnných int a = 56; int b = 28; Uzivatel jan = new Uzivatel("Jan Novák", 28); Uzivatel josef = new Uzivatel("Josef Nový", 32); System.out.printf("a: %s%nb: %s%njan: %s%njosef: %s%n%n", a, b, jan, josef); // přiřazování a = b; jan = josef; System.out.printf("a: %s%nb: %s%njan: %s%njosef: %s%n%n", a, b, jan, josef); // změna josef.jmeno = "John Doe"; josef = null; System.out.printf("jan: %s%njosef: %s%n", jan, josef); {/JAVA_MAIN_BLOCK} {/JAVA_OOP}
{JAVA_OOP} public class Uzivatel { public int vek; public String jmeno; public Uzivatel(String jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } @Override public String toString() { return jmeno; } } {/JAVA_OOP}
Výstup:
Konzolová aplikace
a: 56
b: 28
jan: Jan Novák
josef: Josef Nový
a: 28
b: 28
jan: Josef Nový
josef: Josef Nový
jan: John Doe
josef: null
Vidíme, že objekt stále existuje a ukazuje na něj proměnná
jan
, v proměnné josef
již není reference. Hodnota
null
se bohatě využívá jak uvnitř Javy, tak v databázích. K
referenčním typům se ještě jednou vrátíme.
V následujícím kvízu, Kvíz - Úvod, konstruktory, metody, datové typy v Javě OOP, si vyzkoušíme nabyté zkušenosti z předchozích lekcí.
Měl jsi s čímkoli problém? Stáhni si vzorovou aplikaci níže a porovnej ji se svým projektem, chybu tak snadno najdeš.
Stáhnout
Stažením následujícího souboru souhlasíš s licenčními podmínkami
Staženo 765x (11.88 kB)
Aplikace je včetně zdrojových kódů v jazyce Java