4. diel - Referenčné a hodnotové dátové typy
V predchádzajúcom cvičení, Riešené úlohy k 3. lekcii OOP v C# .NET, 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 hodnotové
(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ť.
Zopakujme si pre istotu ešte raz, čo sú hodnotové 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
, bool
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ácie 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 hodnotového 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 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:
class User { public int age; public string name; public User(string name, int age) { this.name = name; this.vek = age; } public override string ToString() { return name; } }
Trieda má 2 jednoduché verejné atribúty, konštruktor a prepísanú
metódu ToString()
, aby sme užívateľov mohli jednoducho
vypisovať. Do nášho pôvodného programu pridajme vytvorenie inštancie tejto
triedy:
int a = 56; User u = new User("James Brown", 28);
Premenná u
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 hodnotový 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 na dvakrát, raz v zásobníku a raz v halde. V zásobníku je uložená iba tzv. referencie, teda odkaz do haldy, kde sa potom nachádza skutočný objekt.
Napr. v C++ je veľký rozdiel medzi pojmom ukazovateľ a referencie. C# ž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 referencie tu spomínané teda znamenajú referenciu v zmysle C# 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ý hodnotový typ s referenciou na objekt namiesto toho, aby sme všeobecne pamäťovo náročný objekt kopírovali. Toto si vzápätí ukážeme.
- Pomocou referencií môžeme jednoducho vytvárať štruktúry s dynamickou veľkosťou, napr. štruktúry podobné poli, 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 2 premenné typu int
a 2 premenné typu
User
:
int a = 56; int b = 28; User u = new User("James Brown", 28); User v = new User("Jack White", 32);
Situácia v pamäti bude nasledovná:
Teraz skúsme priradiť do premennej a
premennú b
.
Rovnako tak priradíme aj premennú v
do premennej u
.
Hodnotový typ sa v zásobníku len skopíruje, pri objekte sa skopíruje iba
referencie (čo je vlastne aj hodnotový typ), ale objekt máme stále len
jeden. V kóde vykonáme teda toto:
int a = 56; int b = 28; User u = new User("James Brown", 28); User v = new User("Jack White", 32); a = b; u = v;
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 Program.cs
a
spravidla sa to ani veľmi nerobí, pre vážnejšiu prácu by sme si mali
urobiť triedu. Upravme teda kód na nasledujúce:
{CSHARP_CONSOLE} // variable declaration int a = 56; int b = 28; User u = new User("James Brown", 28); User v = new User("Jack White", 32); Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // assignment 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 User { public int age; public string name; public User(string name, int age) { this.name = name; this.age = age; } public override string ToString() { return name; } } {/CSHARP_OOP}
Na výstupe programu zatiaľ rozdiel medzi hodnotovým a referenčným typom nepoznáme:
Konzolová aplikácia
a: 56
b: 28
u: James Brown
v: Jack White
a: 28
b: 28
u: Jack White
v: Jack White
Avšak vieme, že zatiaľ čo v a
a b
sú skutočne
2 rôzne čísla s rovnakou hodnotou, v u
a v
je ten
istý objekt. Poďme zmeniť meno užívateľa v
a podľa našich
predpokladov by sa mala zmena prejaviť aj v premennej u
. K
programu pripíšeme:
{CSHARP_CONSOLE} // variable declaration int a = 56; int b = 28; User u = new User("James Brown", 28); User v = new User("Jack White", 32); Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // assignment a = b; u = v; Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // change v.name = "John Doe"; Console.WriteLine("u: {0}\nv: {1}\n", u, v); Console.ReadKey(); {/CSHARP_CONSOLE}
{CSHARP_OOP} class User { public int age; public string name; public User(string name, int age) { this.name = name; this.age = age; } public override string ToString() { return name; } } {/CSHARP_OOP}
Zmenili sme objekt v premennej v
a znova vypíšeme
u
av v
:
Konzolová aplikácia
a: 56
b: 28
u: James Brown
v: Jack White
a: 28
b: 28
u: Jack White
v: Jack White
u: John Doe
v: John Doe
Spolu so zmenou v
sa zmení aj u
, 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 Jamese 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 ju 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. CLR teda musel za behu programu poľa 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. Napospol to fungovalo tak, že sme si povedali operačnému systému 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 av 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í uží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ť, pokiaľ 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ého 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 C# a Java. 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í C#, kvôli možnosti používať .NET a hlavne automatickej správe pamäte. Používať .NET bolo umožnené aj v C++, hovoríme o tzv. managed C++, kde výsledná aplikácia používala garbage collector. Projekt sa však neuchytil, pretože C++ tak už nemalo žiadne výhody oproti C#, ktorý je modernejší.
Garbage collector 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ánia. Strata výkonu je minimálna a značne to zníži percento samovrážd programátorov, ladiacich po večeroch rozbitých pointerov. Zapnutie GC môžeme dokonca z kódu 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, interpret sa o pamäť automaticky stará.
Hodnota null
Posledná vec, o ktorej sa zmienime, je tzv. hodnota
null
. Referenčné typy môžu, na rozdiel od
hodnotových, nadobúdať špeciálne hodnoty a to null
.
Kľúčové slovo null
označuje, že referencie neukazuje na
žiadne dáta. Keď nastavíme premennú v
na null
,
zrušíme iba jednu referenciu. Ak na náš objekt existuje ešte nejaká
referencie, bude aj naďalej existovať. Pokiaľ nie, bude uvoľnený GC.
Zmeňme ešte posledné riadky nášho programu na:
{CSHARP_CONSOLE} // variable declaration int a = 56; int b = 28; User u = new User("James Brown", 28); User v = new User("Jack White", 32); Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // assignment a = b; u = v; Console.WriteLine("a: {0}\nb: {1}\nu: {2}\nv: {3}\n", a, b, u, v); // change v.name = "John Doe"; v = null; Console.WriteLine("u: {0}\nv: {1}\n", u, v); Console.ReadKey(); {/CSHARP_CONSOLE}
{CSHARP_OOP} class User { public int age; public string name; public User(string name, int age) { this.name = name; this.age = age; } public override string ToString() { return name; } } {/CSHARP_OOP}
Výstup:
Konzolová aplikácia
a: 56
b: 28
u: James Brown
v: Jack White
a: 28
b: 28
u: Jack White
v: Jack White
u: John Doe
v:
Vidíme, že objekt stále existuje a ukazuje na neho premenná
u
, v premennej v
už nie je referencie. Hodnota
null
sa bohato využíva ako vo vnútri .NET, 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 C# .NET 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é 2x (51.25 kB)
Aplikácia je vrátane zdrojových kódov v jazyku C#