6. diel - Referenčné a hodnotové dátové typy
V predchádzajúcom cvičení, Riešené úlohy k 4.-5. 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, ako hodnotové typy
(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. Vo všeobecnosti
sú to jednoduché štruktúry, napr. jedno číslo, jeden
znak. Väčšinou je potrebné, 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 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 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. referencia, 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++. Tu spomínané pojmy ukazovateľ a referencie teda znamenajú referenciu v zmysle C# a nemajú s C++ nič spoločné.
Možno sa pýtate, prečo je to takto. 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é poliam, do ktorých môžeme za behu vkladať nové prvky. Tie sú na seba navzájom odkazované referenciami, ako reťaz objektov.
Vytvorme si 2 premenné typu int a 2 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 takáto:

Teraz skúsme priradiť do premennej a premennú b.
Rovnako tak priradíme aj premennú jack do premennej
james. 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 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ť takto:

Presvedčme sa o tom, aby ste videli, že je to naozaj tak
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úci:
{CSHARP_CONSOLE} // variable declaration int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32); Console.WriteLine("a: {0}\nb: {1}\njames: {2}\njack: {3}\n", a, b, james, jack); // assignment a = b; james = jack; Console.WriteLine("a: {0}\nb: {1}\njames: {2}\njack: {3}\n", a, b, james, jack); 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
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ú skutočne
2 rôzne čísla s rovnakou hodnotou, v james a jack
je ten istý objekt. Poďme zmeniť meno užívateľa jack a podľa
našich predpokladov by sa mala zmena prejaviť aj v premennej
james. K programu pripíšeme:
{CSHARP_CONSOLE} // variable declaration int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32); Console.WriteLine("a: {0}\nb: {1}\njames: {2}\njack: {3}\n", a, b, james, jack); // assignment a = b; james = jack; Console.WriteLine("a: {0}\nb: {1}\njames: {2}\njack: {3}\n", a, b, james, jack); // change jack.name = "John Doe"; Console.WriteLine("james: {0}\njack: {1}\n", james, jack); 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 jack a znova vypíšeme
james a v 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 jack sa zmení aj 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 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 polia v pamäti vytvoriť. 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 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í 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 uvoľniť sami, pokiaľ to neurobíme, pamäť zostane blokovaná. Ak to 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 riadkoch 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, ale následne opätovne použijeme pointer (zabudneme, že je uvoľnený, to sa môže ľahko stať). Pointer povedie niekam, kde je už uložené niečo iné, a tieto dáta budú opäť prepísané. To zapríčiní nekontrolovateľné správanie našej aplikácie a môže to dopadnúť aj takto:

Môj kolega raz povedal: "Ľudský mozog sa nedokáže starať ani o správu vlastnej pamäte, nieto aby riešil memory management programu." Mal samozrejme pravdu, až kým 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 používa naďalej, 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 automatickú správu 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ší.

Hodnota null
Posledná vec, ktorú spomenieme, 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ú
jack na null, zrušíme iba 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:
{CSHARP_CONSOLE} // variable declaration int a = 56; int b = 28; User james = new User("James Brown", 28); User jack = new User("Jack White", 32); Console.WriteLine("a: {0}\nb: {1}\njames: {2}\njack: {3}\n", a, b, james, jack); // assignment a = b; james = jack; Console.WriteLine("a: {0}\nb: {1}\njames: {2}\njack: {3}\n", a, b, james, jack); // change jack.name = "John Doe"; jack = null; Console.WriteLine("james: {0}\njack: {1}\n", james, jack); 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
james: James Brown
jack: Jack White
a: 28
b: 28
james: Jack White
jack: Jack White
james: John Doe
jack:
Vidíme, že objekt stále existuje a ukazuje na neho premenná
james. V premennej jack už referencia nie je. 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é 8x (56.4 kB)
Aplikácia je vrátane zdrojových kódov v jazyku C#

David sa informačné technológie naučil na