Lekce 4 - Referenční a hodnotové datové typy
V předešlém cvičení, Řešené úlohy k 3. lekci OOP v C# .NET, jsme si procvičili nabyté zkušenosti z předchozích lekcí.
Jelikož začínáme pracovat s objekty, je nutné si uvědomit, že objekty
jsou referenčními datovými typy. Ty se v některých ohledech chovají jinak
než typy hodnotové (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 hodnotové typy. Obecně
jsou to jednoduché struktury, např. jedno číslo, jeden
znak. Většinou je potřeba, 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 mohou být např. int
,
float
, double
, char
, bool
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 hodnotového typu (až na výjimky při iteracích, kterými se nebudeme zabývat). Proměnnou si v paměti 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ělenou
čá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 a přidejme si k ní jednoduchou třídu, která bude reprezentovat uživatele nějakého systému. Pro názornost vypustíme komentáře a nebudeme řešit viditelnosti:
class Uzivatel { public int vek; public string jmeno; public Uzivatel(string jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } public override 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 přidejme vytvoření instance této
třídy:
int a = 56; Uzivatel u = new Uzivatel("Jan Novák", 28);
Proměnná u
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ž hodnotový 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. C# žádné ukazatele naštěstí nemá a používá termín reference, které se paradoxně principem podobají spíše ukazatelům v C++. Pojmy ukazatel a reference zde zmíněné tedy znamenají referenci ve smyslu C# a nemají s C++ nic společného.
Důvodů, proč je to takto udělané, je hned několik. Pojďme si některé vyjmenovat:
- Místo v zásobníku 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ý hodnotový 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 nich 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 u = new Uzivatel("Jan Novák", 28); Uzivatel v = 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 v
do
proměnné u
. Hodnotový typ se v zásobníku pouze zkopíruje, u
objektu se zkopíruje pouze reference (což je vlastně také hodnotový typ),
ale objekt máme stále jen jeden. V kódu tedy vykonáme toto:
int a = 56; int b = 28; Uzivatel u = new Uzivatel("Jan Novák", 28); Uzivatel v = new Uzivatel("Josef Nový", 32); a = b; u = v;
V paměti bude celá situace vypadat následovně:
Přesvědčme se o tom, abychom viděli, že tomu tak opravdu 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íšeme ho poněkud úsporněji. Mohli bychom dát výpis do
metody. Ještě však nevíme, jak metody deklarovat přímo v
Program.cs
, a zpravidla se to ani nedělá. Pro vážnější
práci bychom si měli vytvořit třídu. Upravme tedy kód na
následující:
{CSHARP_CONSOLE} // založení proměnných int a = 56; int b = 28; Uzivatel u = new Uzivatel("Jan Novák", 28); Uzivatel v = new Uzivatel("Josef Nový", 32); Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // přiřazování a = b; u = v; Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); Console.ReadKey(); {/CSHARP_CONSOLE}
{CSHARP_OOP} class Uzivatel { public int vek; public string jmeno; public Uzivatel(string jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } public override string ToString() { return jmeno; } } {/CSHARP_OOP}
Na výstupu programu zatím rozdíl mezi hodnotovým a referenčním typem nepoznáme:
Konzolová aplikace
a: 56
b: 28
u: Jan Novák
v: Josef Nový
a: 28
b: 28
u: Josef Nový
v: Josef Nový
Nicméně víme, že zatímco v a
a b
jsou opravdu
dvě různá čísla se stejnou hodnotou, v u
a v
je
tentýž objekt. Pojďme změnit jméno uživatele v
. Dle našich
předpokladů by se měla změna projevit i v proměnné u
. K
programu připíšeme:
{CSHARP_CONSOLE} // založení proměnných int a = 56; int b = 28; Uzivatel u = new Uzivatel("Jan Novák", 28); Uzivatel v = new Uzivatel("Josef Nový", 32); Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // přiřazování a = b; u = v; Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // změna v.jmeno = "John Doe"; Console.WriteLine("u: {0}\nv: {1}\n", u, v); Console.ReadKey(); {/CSHARP_CONSOLE}
{CSHARP_OOP} class Uzivatel { public int vek; public string jmeno; public Uzivatel(string jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } public override string ToString() { return jmeno; } } {/CSHARP_OOP}
Změnili jsme objekt v proměnné v
a znovu vypíšeme
u
a v
:
Konzolová aplikace
a: 56
b: 28
u: Jan Novák
v: Josef Nový
a: 28
b: 28
u: Josef Nový
v: Josef Nový
u: John Doe
v: John Doe
Spolu se změnou v
se změní i u
, protože
proměnné ukazují na tentýž objekt. Pokud bychom chtěli vytvořit
opravdovou kopii objektu, tak nejjednodušší postup 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 si povíme později. Připomeňme si situaci v paměti
ještě jednou a zaměřme se na Jana Nováka.
Co se s ním stane? „Sežere“ ho tzv. garbage collector.
Garbage collector a dynamická správa paměti
Paměť můžeme v programech alokovat staticky, což znamená, že ve zdrojovém kódu předem určíme, kolik paměti 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), u nichž nebudeme před spuštěním přesně vědět, kolik paměti budeme potřebovat. Vzpomeňme 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. CLR 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, prostřednictvím něhož jsme s pamětí pracovali. Problém byl, že nic nehlídalo, co do paměti dáváme (ukazatel směroval na začátek vyhrazeného prostoru). Když jsme do paměti vložili 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 dokonce operačnímu systému (v tom případě by naši aplikaci OS asi „zabil“ – zastavil). Často jsme si však v paměti přepsali nějaká další data našeho programu a program se kvůli tomu začal chovat chaoticky. Představme si, že si uložíme uživatele do pole a v tu chvíli se vám najednou změní barva uživatelského prostředí. Stane se tedy něco, co s uložením zdánlivě vůbec nesouvisí. Hodiny strávíme tím, že kontrolujeme kód pro změnu barvy, avšak poté zjistíme, že chyba je 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á. Děláme-li toto např. v nějaké metodě a zapomeneme-li 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 máme 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 projít celý program řádek po řádku nebo začít prozkoumávat paměť počítače, která je v binární soustavě. Brrr. Podobný problém nastane, když si někde paměť uvolníme, ale následně opětovně použijeme pointer (zapomeneme, že je uvolněný, to se může lehce stát). Pointer povede někam, kde je již uloženého něco jiného, a tato data budou opět přepsána. To zapříčiní nekontrolovatelné 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, mezi něž patří i C# a Java. 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 díky možnosti používat .NET a hlavně automatickou správu paměti hodí právě C#. Používat .NET bylo umožněno i v C++, kdy hovoříme o tzv. managed C++. Výsledná aplikace také používala garbage collector. Projekt se však neuchytil, protože C++ pak již nemělo žádné výhody oproti jazyku C#, který je modernější.
Garbage collector 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 ovlivnit z kódu, 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ěť stará automaticky.
Hodnota null
Poslední věcí, o které se v této lekci zmíníme, je tzv. hodnota
null
. Referenční typy mohou na rozdíl od
hodnotových nabývat speciální hodnoty, a to null
. Klíčové
slovo null
označuje, že reference neukazuje na žádná data.
Když nastavíme proměnnou v
na null
, zrušíme pouze
jednu konkrétní referenci. Pokud na náš objekt existuje ještě další
reference, bude objekt i nadále existovat. Pokud ne, bude uvolněn GC. Změňme
ještě poslední řádky našeho programu na:
{CSHARP_CONSOLE} // založení proměnných int a = 56; int b = 28; Uzivatel u = new Uzivatel("Jan Novák", 28); Uzivatel v = new Uzivatel("Josef Nový", 32); Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // přiřazování a = b; u = v; Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // změna v.jmeno = "John Doe"; v = null; Console.WriteLine("u: {0}\nv: {1}\n", u, v); Console.ReadKey(); {/CSHARP_CONSOLE}
{CSHARP_OOP} class Uzivatel { public int vek; public string jmeno; public Uzivatel(string jmeno, int vek) { this.jmeno = jmeno; this.vek = vek; } public override string ToString() { return jmeno; } } {/CSHARP_OOP}
Výstup:
Konzolová aplikace
a: 56
b: 28
u: Jan Novák
v: Josef Nový
a: 28
b: 28
u: Josef Nový
v: Josef Nový
u: John Doe
v:
Vidíme, že objekt stále existuje a ukazuje na něj proměnná
u
. V proměnné v
již reference není. Hodnota
null
se bohatě využívá jak uvnitř .NET, 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 C# .NET 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 871x (25.3 kB)
Aplikace je včetně zdrojových kódů v jazyce C#