Értéktípus, referenciatípus – hogy is van ez?

Bevezetés

Mi a különbség a két típus között? Egyik a vermen jön létre, másik a halmon? Egyikből lehet származtatni, másikból nem? Az értéktípus a referenciatípus egy könnyűsúlyú változata? Ezek a leggyakrabban leírt válaszok. Nagyjából meg is állják a helyüket, mint ahogy az a kijelentés is, hogy a versenyautónak kicsi a kereke, a traktornak meg nagy. De tényleg ez a lényeges különbség a versenyautó és a traktor között? Nem. Ez csak egy következménye annak, hogy más célra találták ki őket. És ha egy jó mérnök megismeri ezeket a célokat, anélkül is fel tud sorolni számos látható és nem látható különbséget, hogy életében látott volna közelről versenyautót vagy traktort. Ugyanígy, ha egy jó fejlesztő megismeri az értéktípusok és a referenciatípusok célját, nem kell túl sokat magolnia a különbségek listáját. Egyszerűen látni fogja őket, mint következményeket.

Szükséges ismeretek

A C# szintaktikájának ismerete, objektum orientált programozás alapjainak ismerete szükséges az alábbiak megértéséhez.

A hőskor és maradványai

A számítástechnika modern tudomány, mégis, a mai programozók többsége még nem is élt, amikor az első programozók – akkoriban inkább matematikusok – már programokat írtak. Az akkori programozás teljesen másról szólt. Nem az internetről töltöttek le komponenseket, és nem a fórumokra tették ki a kérdéseiket, ha megakadtak. Kénytelenek voltak mindent maguknak kitalálni, sőt, a szűkös gépi erőforrások miatt nagyon-nagyon jól kitalálni. Ez a világ már a múlté, és mind technológiai mind gazdasági szempontból ez így van jól. Van azonban néhány eset, amikor érdemes visszatekinteni ebbe a korba, megérteni egy-egy módszer lényegét.

Az értéktípusok teljeskörű megértéséhez a procedurális programozás metódusaival, a hívási verem működésével, a dinamikusan foglalható memóriával és az objektum orientált programozás implementációs kérdéseivel érdemes megismerkedni.

A praktikus verem

A verem adatszerkezetet a legtöbb programozó ismeri. Ez egy olyan tároló, amelybe sorban lehet elemeket tenni és elemeket onnan kivenni. A következő kivehető elem mindig a verembe utoljára betett elem lesz. Közbenső elemet kivenni, illetve az verem közepére elemet betenni nem lehet. A verem működése az egyes ábrán látható. Bár sok adatszerkezet létezik az informatikában, a verem adatszerkezetet legtöbb mikroprocesszor hardverszinten támogatja. Miért ez a népszerűség?

verem mukodese

1-es ábra

A processzorok programokat futtatnak, a programok pedig adatokon dolgoznak. Ezek az adatok egy kisebb-nagyobb memóriában helyezkednek el. Hogy hogyan helyezkedjen el az adat, azt a programozónak, a fordítóprogramnak, a futtató környezetnek vagy valamilyen egyéb mechanizmusnak el kell döntenie.

Az egészen kicsi memóriával rendelkező hardvereken, mint amilyenek a régi számítógépek voltak, vagy amilyeneket ma a smartcardok használnak, a programozó határozza meg az adatok pontos helyét a memóriában. Az adatokat több csoportba lehet osztani. Az egyik csoport a program állapotát írja le, mint például az, hogy a PIN-kód ellenőrzés már megtörtént a smartcardon. Egy másik csoport a metódusok által használt munkaváltozókból áll, mint amilyen egy ciklusváltozó. Ha nagyon szűkös a memória, a metódusok munkaváltozói osztoznak a memóriaterületeken. Egy ilyen helyzetet mutat a 2-es ábra. Az A(), B() és C() metódus mindegyike ugyanazt a területet használja munkaváltozói számára.

Ha a metódusok nem hívják egymást, ez nem okoz gondot, és a három metódus memóriaigénye csupán három memóriarekesz. Ez nagy előny olyan hardverkörnyezetben, ahol csupán száz vagy esetleg kétszáz byte RAM áll rendelkezésre. Ha azonban a metódusok egymást hívják, a memóriahasználatot át kell szervezni. Ez hatalmas odafigyelést igényel, mert pontosan követni kell, melyik metódus melyik másik metódust hívja, nehogy felülírják egymás munkaváltozóit. A mikrokontrollerekhez készített mai fejlesztői környezetek és fordítóprogramok képesek kibontani ezeket a függéseket, és olyan programkódot generálni, amelyekben a metódusok mindig a megfelelő memóriarekeszeket használják. Eközben arra is ügyelnek, hogy program memóriaigénye a lehetséges minimumhoz közeli értéken maradjon. Ez megkönnyíti a programozók dolgát, de nem oldanak meg bizonyos problémákat.

2-es ábra

A legkisebb programok is tartalmazhatnak metódusokat (szubrutinokat). A közös funkció metódusokba való kiemelése smartcardok és egyéb mikrokontrollerek esetében elemi kérdés, hiszen minden összevonással értékes byte-ok takaríthatók meg. A régi számítógépeken ez szintén fontos szempont volt, és a mai hatalmas programok sem működnének metódusok nélkül. A metódus hívás lehetővé tétele viszont egy szervezési kérdést vet fel.

Alacsony szinten a processzor a programot az utasítások egy sorának látja, ahol minden utasításnak van egy sorszáma úgy, mint ahogy egy utcában minden háznak házszáma van. A processzor indulásakor mindig egy megadott sorszámú utasítással kezd, például a nulladikkal vagy a kétszázadikkal. Az utasítás végrehajtása után veszi a következő sorszámút, és így tovább. Lehetnek olyan utasítások, amelyek valamilyen feltétel teljesülése esetén vagy akár feltétel nélkül azt az utasítást adják a processzornak, hogy ne a sorban következő, hanem egy adott sorszámmal rendelkező utasítás végrehajtásával folytassák a program futtatását.

Ha a program olyan felépítésű, hogy több helyen ugyanazt a műveletet végzi el, akkor az adott művelet utasításait ki lehet emelni egy programrészbe, például az ezertől induló sorszámtól. Az eredeti helyekre be kell iktatni egy utasítást, ami azt diktálja, hogy a program futása az ezredik programutasítástól folytatódjon. Mit lehet azonban írni a kiemelt programrészlet végére, hogy a vezérlés visszataláljon az eredeti helyre? A processzor több helyről ugorhat az ezredik prog-ramutasításhoz, ezért nem lehet egy „ugorj a nyolcadik programutasításhoz” típusú parancs a kiemelt rész végén, mert egyáltalán nem biztos, hogy a hetedik utasítás ugrott a kiemelt részre.

3-as ábra

A megoldás az, hogy a kiemelt részre ugrás előtt el kell tárolni annak az utasításnak a sorszámát, amely ugrás nélkül a következő utasítás lenne. A kiemelt programrész utolsó utasítása arra az utasításra ugrik vissza, aminek a sorszáma el lett tárolva. Ez a sorszám a visszatérési cím. Legegyszerűbb esetben a visszatérési címet a memória egy fix helyén lehet tárolni a többi adattal együtt. Ez a megoldás nem teszi lehetővé, hogy egy kiemelt programrész – a továbbiakban metódus – további metódusokra ugorjon, hiszen ez az ugrás – a továbbiakban metódushívás – felülírná az előző visszatérési címet. Egy olyan mechanizmus kellene, amelynél sorban lehet visszatérési címeket tárolni minden metódushívás esetén, a metódus végén pedig fordított sorrendben kiolvasni az eltárolt címeket. Pont ezt a feladatot valósítja meg a verem adatszerkezet.

A 3-as ábra a fent említett eseteket mutatja be. Az egyes pontban a programutasítások sorban kerülnek végrehajtásra. A sötétre satírozott részek ugyanazt a funkciót valósítják meg, ezért kiemelhetőek, ahogy a kettes pontban látszik. A kettes pontban már szükség van valamilyen mechanizmusra a visszatérési cím tárolására. A hármas pont egymásba ágyazott metódushívásai miatt a visszatérési címeket egy verem adatszerkezetben célszerű tárolni.

Az első processzorok egy kis kapacitású, beépített vermet tartalmaztak. A kicsiny vermek három vagy más processzoroknál akár nyolc egymásba ágyazott metódushívást tettek lehetővé, mivel ennyi visszatérési címet tudtak eltárolni. Ezek a számok talán elégnek bizonyulhattak, de ahogy a programok bonyolultabbá váltak, egy újabb memóriakezelési probléma várt megoldásra. Abban az időben nem voltak olyan kényelmes fejlesztői eszközök, mint ma. A metódusok munkaváltozóit a programozók szervezték a memóriában, és nekik kellett figyelni arra, hogy a ne ütközzenek, azaz egy hívott metódus ne ugyanazt a területet használja a memóriában a munkaváltozói tárolására, mint a hívó. Nem túl sok metódus kell ahhoz, hogy a feladat egy ember számára átláthatatlan legyen.

Ha van elegendő memória, minden metódushoz tartozhat egy saját fix terület, amely a munkaváltozóit tárolja. Például egy A() metódus mindig az 500-503 sorszámú memóriarekeszeket használná. Ennek a megközelítésnek több hátránya van. Egyrészt nagyon pazarló. Ha a programok így működnének, a mai teljesítményű számítógépeken nem futnának sokkal nagyobb tudású programok, mint harminc évvel ezelőtt voltak. A sok metódus rengeteg memóriát igényelne. Másik hátránya, hogy nem működik akkor, ha egy metódus közvetve vagy közvetlenül saját magát hívja meg. A második körben a metódus felülírná a korábbi hívása munkaváltozóit. Sok algoritmus igényli ezt a fajta körkörös hívást, amit rekurziónak neveznek. Egyszerű esetben a rekurzió kibontható, azaz az algoritmust át lehet fogalmazni, hogy ne igényeljen rekurzív hívást. Az összetettebb, szimultán rekurziót igénylő algoritmusok, amelyek metódusai több lépésben és feltételektől függően hívják egymást, a rekurzió kibontása nagyon nehéz lehet. A több szálon párhuzamosan futó programok szintén nem működnének. Ha az egyik szál pont abban a metódusban fut, amelyikbe egy másik szál belép, akkor tönkreteszik egymás munkaváltozóit. Ha a programot nem egy fejlesztő készíti, gondot okoz annak megszervezése, hogy mindenki más területet használjon a metódus számára.

Ha nem kell byte szinten spórolni a memóriával, minden metódus a hívása kezdetén dinamikusan foglalhat magának ideiglenesen egy elegendő méretű szabad területet a memóriában, amelyen az összes munkaváltozója elfér. A lefoglalt memóriát a metódus visszatérése előtt újra szabaddá tenné. Ez a terület nem keresztezné a hívási láncban korábban szereplő metódusok által foglalt memóriaterületeket, és az adott metódus által esetlegesen meghívott további metódusok sem írnák felül. A módszer akkor is működik, ha egy metódus saját magát hívja meg, a második hívásnál a metódus új területet foglal az aktuális munkaváltozói számára. Memóriapazarlásról nem lehet beszélni, mert memóriaterületet csak azok a munkaváltozók igényelnek, amelyek éppen használatban vannak. Hiába tartalmaz a program akár ezer metódus, csak a hívási láncban szereplő három-négy, bonyolult programnál esetleg tizenöt-húsz metódus munkaváltozói számára szükséges a memória.

Hátránya ennek a megoldásnak is van. Az a fajta dinamikus memóriakezelés, amit az állandó memóriafoglalás és felszabadítás igényel, sok erőforrásba kerülhet. Folyamatosan egy katalógust kell vezetni, hogy a memória melyik része foglalható még le. Memóriafoglaláskor keresni kell egy olyan területet, amely elég nagy a munkaváltozók számára. Felszabadításkor a katalógus szintén karbantartást igényel. Mivel a metódusok hívása nagyon gyakori, olyan memóriakezelési stratégiát kell találni, ami a katalógus vezetését és esetleges egyéb teendőket – mint a szabad memóriablokkok összevonása – kevés erőforrásból megoldja.

4-es ábra

A 4-es ábrán a metódusok dinamikus memóriafoglalással biztosítanak helyet a munkaváltozóik számára. Az ábra a dinamikus memóriakezelés szervezését (azaz, a katalógust, hogy melyik memóriarész szabad, melyik nem) nem mutatja. Az első metódus, amely az 1000-es sorszámú utasítástól kezdődik, kettő memóriarekeszt foglal. Ezek a 0. és 1. rekeszek, amelyek például a magas szintű nyelvben leírt i és j változókat tartalmazzák. Eközben a metódushívás a visszatérési címet eltárolta az erre a célra fenntartott verem adatszerkezetben. A második metódus a működéséhez három rekeszt foglal az adatmemóriából, az ábra hármas pontjában a 2.-4. számú rekeszekkel jelölve. A második metódus a visszatérésekor felszabadítja a 2.-4. rekeszeket, a visszatérési címet tároló veremből pedig kivételre kerül az 1002-es visszatérési cím. A verem és az adatmemória állapota újra a kettes pontban látható állapotot veszi fel. Az első metódus visszatérésekor a verem teljesen üres lesz, az adatmemóriában pedig szabaddá válnak a metódusok által lefoglalt területek.

Könnyű látni, hogy ha az adatmemóriában egyéb célra nem történik foglalás, akkor az adatmemória állapota a visszatérési címeket tároló veremmel szinkronban változik. Felmerül a kérdés, hogy nem e lehet a két mechanizmust egyesíteni. Lehet, csak engedékenyebb verem adatszerkezet kell hozzá. A klasszikus verem két műveletet enged: egy elem veremre helyezése és egy elem levétele a veremről. A metódusok működésük közben folyamatosan írhatják-olvashatják a munkaváltozóikat, ezért olyan verem adatszerkezet kell, amely engedi írni és olvasni a közbenső elemeket. A szükséges terület a munkaváltozók számára viszonylag nagy lehet, ezért a processzorba épített kis kapacitású verem nem jó a célra.

A mai processzorok nagy része a metódushívások működtetéséhez szükséges vermet az egyéb adatok tárolására is szolgáló memóriában, a RAM-ban tárolják. A megvalósítás a processzor részéről egyszerű, csak egy számláló kell, amely a verem tetejét mutatja meg, illetve egy címzési mód, amely lehetővé teszi a veremben tárolt adatok elérését a verem tetejéhez képest. A metódushívások működtetésére használt vermet hívási veremnek vagy call stack-nek nevezik. Az 5-ös ábra a 4-es ábra módosított változata, és hívási vermet használ. Az SP (Stack Pointer) a verem tetejét mutatja.

A megoldásnak sok előnye van. A legnagyobb, hogy a dinamikus memóriafoglalással járó plusz teher csak egy számláló (az 5-ös ábrán az SP) módosítását jelenti, ami nem erőforrás igényes, főleg nem egy általános célú memóriakezelő erőforrásigényével szemben. Másik előny, hogy a verem mérete igény szerint konfigurálható. Ha a futtatott program sok egymásba ágyazott metódushívást, esetleg rekurzív hívásokat tartalmaz, akkor nagyobb verem allokálható a RAM-ból, kisebb megmaradó egyéb célú memóriával. Ha a hívások nem mélyek, és a metódusok nem igényelnek nagy munkaterületet, akkor a verem mérete lehet kicsi, és több hely marad egyéb célokra.

5-es ábra

A hívási verem használható a metódus paraméterek és a metódus eredményének átadására is. Ha a hívó a hívás előtt egy meghatározott protokoll szerinti sorrendben értékeket tesz a veremre, a hívott metódus ezeket az értékeket könnyen meg tudja címezni. Ugyanígy, ha a hívó foglal területet a vermen egy visszatérési érték számára, a hívott metódus arra a területre el tudja tárolni működésének eredményét. A magas szintű programozási nyelvek ezeket a műveleteket szerencsére elrejtik a programozó elől.

A 6-os ábra egy egyszerű metódust és annak hívását mutatja. A metódushívás előtt a hívó a metódus paramétereket elhelyezi a veremre (10 és 20 értékek). A hívó ezután még megnöveli a veremmutatót, hogy ezáltal helyet adjon a visszatérési értéknek (ret). Az A() metódus erre a területre fogja beírni az eredményét. Ezek után megtörténik a metódushívás. A metódushívás eltárolja a vermen a visszatérési címet (1002). Az A() metódus első lépésként lefoglalja a számára szükséges munkaterületet. Az i és a j lokális változók igényelnek egy-egy rekeszt, illetve egyéb munkákra (például a kifejezés kiértékelésére) szükséges még egy rekesz (Temp). A Temp munkaváltozót a magas szintű nyelv fordítóprogramja generálja, és az eredeti forráskódban nincs megnevezve. Ezeket a változókat névtelen változóknak is hívják, a mai programozóknak nem nagyon kell törődnie velük.

6-es ábra

Ha SP[-1] annak a memóriarekesznek a tartalmát jelenti, amelyik rekesz a verem tetejét, azaz az utoljára behelyezett elemet tartalmazza, akkor az A() metódus gépikódú megvalósítása az „i” lokális változó helyett SP[-3]-at, az „a” metódusparaméter helyett SP[-7]-et és „b” helyett SP[-6]-ot használ. A metódus visszatérés előtt a visszatérési értéket az SP[-5] rekeszbe helyezi. Ez a visszatérési érték az A() metódust hívó kód számára az SP[-1] lesz, hiszen addigra a veremről eltűnik az A() metódus minden munkaváltozója és a visszatérési cím is.

A hívási verem nagyszerű találmány, és az informatika fejlődése szempontjából nagyon fontos. Első pillantásra úgy tűnhet, hogy ma már nem kell vele sokat foglalkozni, hiszen automatikusan, láthatatlanul működik. Gyakorló fejlesztők azonban egészen biztos alapbeállításban a képernyőjükön tartják nyomkövetéshez a „call stack” ablakot. A hívási verem megértése tehát a mai programozóknak is hasznos lehet.

A dinamikus memória

A verem nagyon gyors és egyszerű megoldást ad arra, hogy egy metódus számára munkaváltozókat biztosítson. Ezek a munkaváltozók azonban nem élik túl a rajtuk dolgozó metódusokat, ami a verem természetéből adódik. Egy program futása alatt általában szükség van hosszabb életű változókra, amelyeknek az élettartama túlnyúlik a rajta dolgozó metódusokon, esetleg egészen a program futásának a végéig.

A hívási verem ismertetése közben volt róla szó, hogy egy lehetséges stratégia a memória kezelésére az lehet, ha egy programkód dinamikusan, aktuális szükségleteinek megfelelően foglal magának memóriaterületet. Ez a munkaváltozók esetében pazarlónak tűnt, főleg a verem lehetőségei mellé állítva. Egy előnye azonban van: a lefoglalt terület megmaradhat azután is, miután az őt létrehozó metódus futása véget ért.

A dinamikus memória kezelésére azonban nincsen igazán elegáns és egyszerű módszer. A memóriakéréseket össze kell hangolni. Nem megengedhető, hogy egy kód „csak úgy ráterpeszkedjen” a memória egy részére. El kell kerülni, hogy egy másik kód ugyanarra a területre vessen szemet. Emiatt a memóriafoglalásokat jegyzetelni kell. Ha egy memóriaterületre már nincsen szükség, lehetővé kell tenni, hogy a területet más programkód használhassa. Ezeket a feladatokat általában egy memóriakezelő segítségével szokták megoldani, ami valójában egy egyszerűbb-bonyolultabb metódus gyűjtemény.

Ha egy programkódnak memóriaterületre van szüksége, nem önkényesen választ területet a memóriából, hanem a memóriakezelőn keresztül foglaltat magának. A memóriakezelő megvizsgálja a lehetőségeket, és megadja, hogy a memóriát igénylő kód melyik területre dolgozhat, továbbá feljegyzi magának (szintén a memória egy adott helyére), hogy azt a területet többet ne adja ki. Amikor adott memóriaterületre nincsen többet szükség, akkor a területet használó programkód (ami nem is biztos, hogy a terület foglalója volt) meghívja a memóriakezelő megfelelő metódusát. Ez a metódus feljegyzi, hogy a terület újra kiadható. Más memóriakezelők automatikusan derítik fel, hogy melyik memóriaterületekre nincs már szükség, levéve a programozó válláról, hogy a nem használt memóriára figyelnie kelljen.

7-es ábra

A 7-es ábrán a dinamikusan kezelt memória elképzelt állapotváltozásainak sorozata látható. Az ábrán a világos területek jelzik a szabad, a sötét a már lefoglalt területeket. Az első ábrán két szabad „lyuk” van, az egyik 100, a másik 200 egység méretű. Ha egy metódusnak 150 egységnyi memóriára van szüksége, a memóriakezelő megvizsgálja a lehetőségeket, és a 200 egység méretű területből lecsíp 150 egységet. Ekkor keletkezett egy kicsi, 50 egység méretű szabad terület. A harmadik lépésben felszabadításra került egy 200 egység méretű terület. Mivel ez szomszédos egy másik szabad területtel, ezeket össze lehet vonni, ez látható a négyes állapotban. Ezek alapján elképzelhető, hogy mennyi munkája van a memóriakezelőnek. Az is látható, hogy különböző stratégiákat lehet alkalmazni. Ha a negyedik lépés után (vagy akár a második lépés után) egy metódus kér ötven egység területet, akkor a memóriakezelő használhatja az első megtalált blokkot, ami a 7-es ábrán nagyobb, mint 50 egység, tehát fel kell szeletelni. De elképzelhető egy olyan stratégia, hogy a kezelő tovább keresve próbál egy pontosan 50 egység méretű területet találni. Ez ugyan több időbe kerül, de nem szaggatja szét annyira a kezelt területet.

A .NET rendszer memóriakezelője még ennél is továbbmegy, menet közben a memóriablokkok áthelyezésével próbálja kiküszöbölni a képződő lyukakat. Ez az áthelyezgetés több munkát igényel, mint ami elsőre látszódhat: a memóriablokkok másolgatása után a blokkokra hivatkozó referenciákat szintén helyre kell állítani. Valójában a .NET memóriakezelője olyan bonyolult megoldásokat használ a dinamikus memória kezelésére, hogy külön cikksorozatot lehetne indítani róla.

Érezhető tehát, hogy a dinamikus memóriagazdálkodás sokkal több erőforrást igényel, mint a verem használata, ami lényegében csak egyetlen érték növelését csökkentését igényli, amely a verem tetejét mutatja.

Azt a területet, amelyet a dinamikus memóriakezelő használ, általában halomnak (heap-nek) nevezik. Az elnevezés abból ered, hogy a kezelt memória blokkok össze-vissza képződhetnek és törlődhetnek a területen, ellentétben a veremmel, ahol rendezetten, mindig csak a verem tetején képződnek új blokkok, és onnan szabadulnak fel. (Igazából a .NET esetén, mivel az kiküszöböli a képződő lyukakat, a veremhez hasonlóan mindig a memóriablokk végén képződik az új adat, tehát az egész már nem annyira halomszerű, mint a régi memóriakezelők esetében. Az elnevzés ennek ellenére megmaradt) Amikor a program a halomból foglaltat magának egy területet a memóriakezelőn keresztül, a memóriakezelő visszaad „valamit”, amivel a lefoglalt terület hivatkozható. Ez a „valami” egyszerűbb esetekben a lefoglalt memória kezdőcíme. Van olyan memóriakezelő, ami egy azonosító számot ad, és van, ami egy referenciának nevezett dolgot. A memóriát ez utóbbi két esetben nem is lehet közvetlenül elérni, hanem az azonosítón vagy referencián keresztül csak korlátozott műveletek végezhetőek. Akárhogy is, az egymást hívó metódusok egymásnak könnyedén adogathatják a halmon levő memória terület címét vagy referenciáját, és ezen keresztül a metódusok egymás után dolgozhatnak az adott területen.

Bizonyos esetekben ez a verem használatával is megoldható lenne. Más esetekben, például ha csak egy időben később hívott metódus tudja a feladathoz szükséges terület méretét kiszámítani, akkor a dinamikus memóriafoglalást nem lehet megkerülni. Nem kell különleges helyzetekre gondolni, például egy állomány beolvasásánál vagy egy hálózati adat vételénél a hívó nem feltétlenül tudja, hogy mekkora memóriára van szükség, emiatt nem tudja a verem tetején lefoglalni azt a helyet, ahova a hívott metódus dolgozhatna. A hívott metódus a vermen viszont csak a saját területe fölé tudna dolgozni, ami megszűnik abban a pillanatban, amikor a vezérlést visszaadja az őt hívó metódusnak.

8-as ábra

A 8-as ábra a 6-os ábrához nagyon hasonló helyzetet mutat, de ebben az esetben a meghívott A() metódus egy dinamikus területet foglal, 1000 egység méretben. Ha ezt a területet a vermen foglalná le, akkor az a t változó fölött helyezkedne el. Az A metódus ekkor még el tudná végezni a számításait, de amikor visszaadja a vezérlést a hívójának, a vermet le kell takarítani, különben a hívó metódus nem tudna más metódusokat meghívni annak veszélye nélkül, hogy az ezer egységnyi területbe a következő hívás munkaváltozói bele ne lógjanak. Persze, különböző trükközésekkel el lehet kerülni ezeket a helyzeteket például úgy, hogy a vermen a megtartani kívánt területeket a hívó a következő metódus hívásnál „átugorja”, azaz a verem tetejét mutató SP-t annyira megnöveli, hogy a védeni kívánt terültet (itt az ezer egységnyi terület) ne menjen tönkre. Ezekkel a trükkökkel viszont a verem egyszerű kezelhetősége veszne el. Célszerűbb emiatt a metódushívásokon áthidaló területek számára egy a veremtől független mechanizmust használni, és erre jó a halom.

A 8-ik ábra azt mutatja, hogy halmon lefoglalt terület memóriacímét, vagy referenciáját a t változó tárolja el. Miután az A() metódus elvégzi a feladatát, a terület címét vagy referenciáját visszaadja a hívónak. Ehhez a t változó értékét az ábrán a ret nevű rekeszbe másolja, ahova a hívó az A() metódus visszatérési értékét várja. Ekkor az A()-t hívó metódus szintén hozzáfér az ezer egység méretű területhez a halmon. A továbbiakban a memóriacímet vagy referenciát egyéb metódusoknak is át lehet adni. Ezzel kiderül a halom egy másik előnyös tulajdonsága, egy nagyméretű adat esetén is elég egy sokkal kisebb adatot (a címet vagy referenciát) mozgatni. A 6-os ábrán maga a kiszámolt érték „utazott” a vermen. Először a Temp-pel jelzett rekeszekbe kerül, onnan a ret-tel jelzett területre. Ha a számok, amin a metódus dolgozik 4 byteon ábrázoltak, akkor 4 byte másolgatása történik. Ha 8 byteon, akkor 8 byteok másolódnak. Ezek még nem nagy területek, emiatt az értékek adogatása a vermen nem igényel sok erőforrást. Egy húsz-harminc byteos terület másolgatása viszont már észrevehető teljesítményvesztéssel járhat.

Verem vs. Halom, ez lenne a kulcs?

Az eddigiek alapján azt hihetnénk, hogy megvan a fő csoportosítási szempont. Ha egy típust lokális változóként, vagy metódusok közötti paraméterként használunk, akkor annak a vermen a helye. Ha egy típus egy példányának élete több metóduson keresztül ível, vagy túl nagy a vermen való mozgatáshoz, akkor a halmon kell lefoglalni, és csak a címét vagy referenciáját kell a vermen kezelni. De felmerül a kérdés: miért kell azt egy típusról előre eldönteni, a típus definiálásánál, hogy a vermen, vagy a halmon lesz használva?

A .NET esetében előre meg kell mondani egy típusról, hogy érték vagy referencia típus lesz, azzal, hogy a definíciójánál a struct vagy class kulcsszót használjuk. Az eddigiek viszont nem indokolják, hogy miért kellene ezt előre eldönteni. Vannak olyan nyelvek, mint például a C++, ahol nem is kell előre eldönteni, hogy egy típust a vermen, vagy a halom keresztül fogja használni a fejlesztő. A C++ esetén a típus használata közben, megfelelő szintaktikát használva a létrehozás helye befolyásolható. Tovább kell hát keresni, hogy mi van még, ami az érték és referencia típusokat szétválasztja a .NET-ben. Hogy közelebb kerüljünk a válaszhoz, az objektum orientált világ mögött húzódó implementációs kérdésekbe kell belemerülnünk.

Az objektum orientált programozás és kellékei

Ennek az írásnak nem témája az objektum orientált programozás alapelveinek az ismertetése. Rengeteg jó tartalom található a témában. Ami a lényeg, hogy az objektum orientált programozáshoz szükséges mechanizmusokat hogy vették át a programozási nyelvek és futtató környezetek, különös tekintettel a .NET-re.

Mint legtöbb találmány vagy fejlesztés, az objektum orientált programozás elmélete és gyakorlati alkalmazása több műhelyben, egymás mellett fejlődött, és így utólag nehéz megmondani, pontosan mi hogyan és miért történhetett. Ami biztos, hogy a programozók már akkor elkezdtek az összetettebb típusok példányaira objektumként gondolni, amikor az elterjedt nyelvek ezt még nem is tették lehetővé. Ha T egy típus volt, akkor a T-t használó metódusokat, a következőhöz hasonló formában fogalmazták meg: művelet(T, x, y), ahol T mellett az x és y a T-n végrehajtandó művelet egyéb paraméterei. Egy jól strukturált C program például pontosan az előzőhöz hasonló megoldásokból épül fel. Ha egy ilyen C programban összegyűjtjük az összes metódust, ami első paraméternek T típust vár, akkor tekinthetjük T-t egy objektum típusnak, az összegyűjtött metódusokat pedig T objektum műveleteinek.

A C nyelv első lépése az objektum orientált programozás felé nem volt más, mint egy kis segédprogram. Ez a segédprogram nem csinált túl sokat. Lehetővé tette, hogy a típushoz tartozó metódusokat egy speciális szintaktikával deklarálja a programozó, illetve a típushoz tartozó metódusokat speciális szintaktikával hívja meg. A segédprogram a speciális szintaktikát mechanikusan visszaalakította hagyományos, C szintaktikának megfelelő kóddá. A fenti példa alapján, amit a programozó leír, az T.művelet(x,y), ez a segédprogram inputja, amit átírna a már látott művelet(T, x, y) alakba. A speciális szintaktika tehát csak külcsín volt, látványosan összetartozóvá téve azt, ami a fejlesztő fejében egyébként is összetartozott. Némileg vicces, hogy ez a szintaktikai trükk a C#-ban pár évtizeddel később újra felbukkant, extension method néven.

Az objektum orientált programozás azonban nem csak abban áll, hogy egy adatszerkezetet a rajta végrehajtható műveletekkel egy egységnek tekintjük. Fontos kellékek még az öröklődés és a polimorfizmus. Öröklődés esetén arról van szó, hogy ha egy L típus T típus leszármazottja, akkor rendelkezik a T-t felépítő mezőkkel, illetve a T-n végrehajtható műveletekkel is. Ezen kívül L bevezethet új mezőket, és új műveleteket. Arra is lehetőség van, hogy L felülírja a T által már bevezetett műveleteket. A korábban említett, C nyelvhez tartozó segédprogramnak nem volt túl nehéz dolga az öröklődés kezelésében. Mivel több típushoz tartozhat ugyanolyan nevű művelet, a háttérben ezeket átnevezte, például a T típushoz tartozó „művelet” nevű műveletet „T_művelet” névre. Ekkor, ha azt találta leírva, hogy L.művelet(x, y), megnézte, hogy az L-hez készített-e a programozó „művelet” nevű műveletet. Ha igen, átírta a már korábban látott L_művelet(L, x, y) formába. Ha nem, megvizsgálta, hogy T típushoz tartozik-e „művelet” nevű műveletet. Ha igen, akkor az átírás szintén megtörténhet a megszokott T_művelet(L, x, y) formába. Ekkor azonban gyanús, hogy a „T_művelet” az implementációjában T típust vár, nem L-t, hiszen T-hez készült. Ezen segít egy implementációs jellegzetesség.

Az „objektumosított” C nyelv esetében, ha L típus T típusból származik, akkor L típus memóriabéli lenyomatának első része egy az egyben megfelel T típus lenyomatának. Emiatt, ahogy a „T_művelet” implementációja keresgél a memóriában a T-t alkotó mezők után, mindent megtalál az L-en is, pont olyan formában, ahogy várja.

9-es ábra

A 9-es ábra három részből áll. A bal felső részben a programkód azt mutatja, ahogy a programozó definiálhatja és használhatja az objektumait (pszeudo kód). A kódban látható egy T objektum típus leírása, amely tartalmaz két egész típusú mezőt, és egy „művelet” nevű műveletet, amely T mezőit használja. Látható még egy L objektum típus definíció, amely T leszármazottja, és bevezet egy új mezőt. Objektumorientált nyelvekben egyéb dolgok is meghatározásra kerülnek, mint láthatóság, ez itt most nem érdekes. Később a bal felső ábrarész kódja létrehoz egy L típusú objektumot (lényegtelen, hogy a vermen, vagy a halmon) és meghívja rajta a „művelet” nevű műveletet, amit T típustól örökölt.

A 9-es ábra jobb felső része azt mutatja, hogy az objektum orientált program hogyan fordítható át szimpla procedurális programmá. T objektum típus adatszerkezetéből (a mezőiből) egy a procedurális nyelvek által is támogatott összetett típus (vagy struktúra lesz) A „művelet” nevű művelet egy egyszerű átnevezés után (amely a névütközések elkerülése miatt fontos) kiemelésre kerül, és első paraméterként azt a típust várja, amelyik típusnak eredetileg a művelete volt, ez esetben a T típust. A „művelet” kódja is megváltozik, látható, hogy az eredeti mezőhivatkozások átírásra kerülnek, és így már a T paraméter mezőire hivatkoznak. Az L típus, amely az objektum orientált nyelv esetében T-ből származott, most a származtatás, mint lehetőség hiányában megismétli az összes mezőt, amely T típusban is megtalálható volt. L itt nem definiál semmilyen műveletet, emiatt ezzel nem kell foglalkozni. Az L típus példányának létrehozása után a „művelet” végrehajtása szintén átírásra került. A korábban ismertetett módon, a „művelet”, illetve itt már „T_művelet” paraméterben kapja meg az L típusú struktúrát. Ez a lehetőség nem feltétlenül adódik triviálisan, hiszen T_művelet paraméterként egy T típus vár, nem pedig L típust.

A 9-es ábra alsó része próbálja érthetővé tenni, hogy a paraméter átadás miért működik mégis különböző típusok esetében. Az ábra bal alsó részén látható T egy példányának és L egy példányának a memória lenyomata. Az ábra felteszi, hogy a használt „integer” típus egy memóriarekeszt használ, a gyakorlatban ez általában nem igaz, de a lényegen nem változtat. A T és L típusú példányok mellett a számok relatív értékek, és azt mutatják, hogy adott mező a példány kezdő-címéhez képest hol található. Ha például a T példánya az ezredik memóriacímen kapott helyet (például a memóriakezelő oda osztott ki egy szabad területet), akkor az x mező az 1000+0, az y pedig az 1000+1 címen található.

A 9-es ábra alsó részén még egy hívási verem részlet is látható, amely a T_művelet(l) hívás során történteket jeleníti meg. Az SP[-4]-re helyezte a hívó az átadandó paraméter címét vagy referenciáját. Eredetileg ez az objektum, aminek a műveletét meghívjuk. Az SP[-3]-ra várja a hívó a visszatérési értékét a hívott metódusnak. Az SP[-2] az a cím, ahova a metódus majd visszaadja a vezérlést, miután futása véget ért. Az SP[-1] egy ideiglenes változó, a hívott metódusnak szükséges, hogy kiszámolja t.x*t.y értékét. A hívott metódus kódja (return t.x*t.y) arra van felkészítve, hogy az átadott paraméter egy T típusú adat. Amikor a fordító program (mondjuk C fordító) dolgozik a T_művelet(l) metóduson, azzal a feltételezéssel él, hogy az SP[-4] (ami a metódus paramétere, a hívó szerint az l, a hívott szerint t, de alacsony szinten csak SP[-4]) a memóriában olyan helyre mutat ahol egy T típus van. Veszi hát az SP[-4] által mutatott memóriát, és annak 0-ik illetve 1-ik rekeszéből kiolvassa az értékeket, melyek az x és az y-nak felel meg a magas szintű nyelven. Valójában az SP[-4] nem T típusú példányra mutat, hanem L típusúra, de mivel a memória lenyomat megegyezik, pontosan az fog történni, mint amit a programozó vár az objektum orientált programjától. A két kiolvasott érték összeszorzásra kerül, az eredmény az ideiglenes változóba (SP[-1] tárolódik. Ekkor az ideiglenes változó hordozza a visszatérési értéket, emiatt azt az SP[-3]-ba kell másolni, hiszen a metódus hívója onnan fogja kiolvasni.

A 9-es ábra azzal a feltételezéssel él, hogy az L típus példánya a halmon jön létre. Ha L a vermen jönne létre, akkor a T_művelet a vermen várna egy T típusú példányt. Ebben az esetben az történne, hogy az L típusú példánynak csak az a része másolódna tovább a vermen, amely része a T típusnak is megvan. Az L típusú mező T-hez képest új mezői figyelmen kívül maradnának.

Van egy nagyon fontos következménye annak, ahogyan a C nyelv az első lépéseit megtette az objektum orientált programozás felé: az eddig bemutatott mechanizmusok nem jelentenek semmilyen teljesítményromlást a hagyományos, procedurális programozáshoz képest. Ez a kijelentés triviális, hiszen végső soron az objektumorientált jelölésmódot használó program át lett fordítva szimpla procedurális programmá.

A polimorfizmus, az optimalizálás réme

Hátra maradt azonban az objektum orientált programozás egy nagyon fontos jellemzője, a polimorfizmus. A polimorfizmusnak sok típusa van, és nem minden objektumorientált nyelv támogatja az összest. A polimorfizmus egyik típusának szükségessége a következő példa alapján megérthető:

10-es ábra

A tízes ábrán látható kód esetén már nem egyértelmű, hogy az eddig megismert egyszerű konverzió, amely az objektum orientált kódot procedurális kóddá alakítja, jól működik. Az F metódus egy T típusú objektumot vár paraméterként. A 9-es ábrán azt láttuk, hogy abban az esetben is jól működik egy metódus, ha egy T-ből származó típust kap. A 10-es ábrán viszont az F metódus 1-et ad vissza, hiszen az átfordított kódja T_művelet-et hívja. Pedig lehet úgy érvelni, hogy az L_művelet által vissza-adott 2-es eredmény a helyes. A probléma abban gyökerezik, hogy F metódus esetében nem lehet előre tudni, hogy T_művelet vagy L_művelet hívása szükséges.

Amíg ez a probléma nem jött elő, egy metódushívás nem tartozott a drága műveletek közé. A magas szintű nyelv fordítója (vagy az alacsony szintű assembler) a programozó által írt kódot valahova elrendezte a memóriában, mondjuk az 1000-ik memóriarekesztől kezdve, és amikor a magas szintű nyelvben a programozó leírt egy metódushívást, a fordító olyan kódot generált, hogy az vezérlés átadódjon az 1000-ik memória rekesznél található programkódra. A processzor egyszerűen elkezdte feldolgozni a műveleteket az 1000-es címtől. Ha a 10-es ábrán fennálló helyzetet kell kezelni, ahhoz valami bonyolultabb mechanizmus kell, hiszen csak a program futása közben derül ki, hogy a metódushívás pontosan hova irányítja a vezérlést, az T_művelet vagy az L_művelet címére. A konkrét implementációtól függetlenül érezhető, hogy ez az eddigieknél lassabb metódushívást tesz csak lehetővé.

Abban az időben, amikor ezek a problémák előjöttek, a számítógépek mai szemmel mért teljesítménye nagyon kicsi volt. Nem volt könnyű elfogadni egy bonyolultabb metódushívási módszert azért, mert az esetek kisebb százalékában jobban jött volna. Emiatt, például az objektum orientáltság felé tartó C nyelv csak feltételesen alkalmazta, és a típus leírásánál külön jelezni kellett, ha egy metódus olyan környezetben került meghívásra, mint ami a 10-es ábrán látható F() metódus. Ezeket a metódusokat virtuális függvényeknek nevezték el, és a C++ és .NET is átvette őket. A virtuális függvények implementációja C++ esetén könnyen megérthető, és a .NET is nagyon hasonlóan csinálja. A C++ módszerével érdemes előbb megismerkedni, mert az közelebb visz az értéktípusok bevezetésének szükségességéhez a .NET esetében.

A virtuális mechanizmus

C++ esetében, amíg a programozó nem használ virtuális függvényt (metódust), minden ugyanúgy működik, ahogy azt eddig az objektum orientáltról-procedurálissá átalakítás kapcsán megismertük. Ekkor nincs teljesítményveszteség a régi felépítésű programokhoz képest. Amikor a programozó megjelöl egy metódust virtuálisnak, azaz jelzi, hogy a metódust olyan környezetben fogják használni, ahol változhat az aktuális példány típusa, és a konkrét típus az adott környezetben közvetlenül nem látszik, akkor a C++ fordító egy trükkhöz folyamodik. Felvesz az objektum adatait tároló mezők közé egy „titkos” mezőt, egy úgynevezett virtuális tábla mutatót. Ez a virtuális tábla tartalmazza majd a virtuális függvények címeit. Amikor a C++ fordító a 10-es ábra F() metódusát fordítja le gépikódú utasításokká, akkor a „t.művelet” hívást nem úgy fordítja gépikódra, mint korábban, hogy egyszerűen leír egy utasítást, ami továbbítja a vezérlést arra a címre, ahol T_művelet kódját elhelyezte. Ehelyett olyan kódot generál, ami veszi azt a virtuális táblát, amelyre az adott objektumpéldány mutat. Ebben megtalálja annak a metódusnak a címét, ami az adott példányhoz tartozó „művelet” implementáció. Fogja ezt a címet, és átadja oda a vezérlést. Bár leírva hosszúnak tűnik, lényegében csak egy plusz indirekcióról van szó.

11-es ábra

A 11-es ábra azt mutatja, hogy ha az objektumok típusdefiníciójában a fejlesztő megjelölte a metódusokat „virtual” kulcsszóval. Ekkor minden létrejövő T példány, és T-ből származó példány memóriabeli lenyomata kiegészül egy plusz mezővel, a virtuális tábla mutatóval. Ez a mutató annak a táblázatnak a címét tartalmazza a memóriában, amely viszont a típushoz definiált virtuálisnak jelölt metódusok címeit tartalmazza. T típushoz egy „művelet” nevű virtuális metódus tartozik, így a T virtuális táblázata csak egy bejegyzést tartalmaz, 0-s indexszel. Innentől kezdve bármely leszármazott, ha felülírja a „művelet” nevű metódust, akkor a virtuális táblájának nulladik indexű bejegyzése a saját implementációjára mutat, mint ahogy az a 11-es ábrán látszik L típus esetében. Ha nem írja felül, tehát nincs saját implementációja, hanem az ős típus implementációját örökli, akkor a virtuális táblájának a 0-ik eleme az ősosztály implementációjára mutatna. Akárhogy is, ha egy T vagy T-ből származó típus „művelet” metódusát kell meghívni, akkor a virtuális tábla 0-ik indexén levő címen kell futtatni a metódust. Innentől kezdve nem érdekes a konkrét típus, a hívó pontosan megtalálja azt a metódust, amit futtatni kell, még ha kicsit több időráfordítással is, mint a hagyományos metódusokat.

C++ esetén, ha nincsenek egy típusnak virtuális függvényei, akkor minden úgy működik, mint azt korábban láttuk, a polimorfizmus bevezetése előtt. Nincs szükség virtuális táblára sem. .NET esetében azonban van egy kis probléma. Minden típus az Object-ből származik, és az Object-nek eleve vannak virtuális függvényei, emiatt a virtuális táblára mindig szükség van. (A .NET esetében a virtuális táblát a bővebb funkciója miatt metódus táblának hívják, mivel nem csak a virtuális hívásokat szolgálja ki. A továbbiakban azonban a C++ terminológiával élve virtuális táblának nevezzük, mivel ez a funkciója érdekes). Hirtelen nem tűnhet fel, hogy mekkora gond, de nyilvánvalóvá válik, ha külön kiemeljük, hogy pl a Char típus is az Object-ből származik. Csinálni szeretnénk egy furfangos String jellegű osztályt, és minden egyes eltárolt betű magával vonszolna egy virtuális tábla mutatót, aminek több hely kell a memóriában, mint magának a Char típus értékének. Valaki összedob egy 1000×1000-es valós típusú mátrixot a memóriában, akkor készül hozzá egymillió virtuális tábla mutató is. De ha valaki nem matematikai problémákat akar megoldani hatalmas mátrixokkal, akkor is pazarolná a memóriát, sok munkaváltozó valamilyen egész vagy valós típus, az ezeknek a tárolására szánt memóriát majd meg kéne duplázni csak a velük együtt létrejövő virtuális mutatók miatt, azaz jelentősen nagyobb veremre lenne szükség. És ha még ez sem elég elrettentésnek, nézzük meg a 11-es ábra .NET-es változatát, ha feltesszük, hogy a .NET nem ad megoldást arra, hogy az egyszerű típusok is vonszolják magukkal az örökölt virtuális függvények miatt a virtuális tábla mutatóját:

12-es ábra

A 12-es ábrán látható, hogy a T és L típus példányai jóval nagyobb memóriát fogyasztanak, mert az egyébként egyszerű integer (.NET-ben Int32) az Object típustól virtuális függvényeket örököl, és emiatt minden integer példánynak (mint x, y, z) szüksége van egy virtuális táblára. Az előzőekhez hasonlóan a 12-es ábra sem foglalkozik azzal, hogy melyik adat hány rekesz memóriát foglalna valójában, a lényeg akkor is látszik, ha a példában mindennek egy rekesz memória kell. Nyilvánvaló, hogy ekkora memóriapazarlást nem lehet elfogadni.

A megoldáshoz újra végig kell gondolni, hogy miért van szükség a virtuális táblákra. Eredetileg akkor kerültek képbe, amikor egy metódus nem tudta pontosan fordítási időben, hogy milyen típusú objektumon dolgozik, emiatt nem tudta megfelelően meghívni az objektum műveleteit. A virtuális tábla tehát amiatt kellett, mert nem voltak pontosak az objektumról a típusinformációk. Ha egy programrész fordítási időben pontosan tudja, hogy milyen típusú az objektum, amin dolgozik, akkor elvileg virtuális tábla nélkül is meg tud hívni rajta minden műveletet. Ha egy programrész esetében, biztos, hogy L típusú az objektum, akkor úgy lehet generálni a gépikódját, hogy mindig arra a címre ugorjon, ahol L_művelet kódja (vagy ha L nem írta felül, az ősosztály kódja) helyezkedik el a memóriában.

A 12-es ábrán T típus definíciójából egyértelműen kiderül, hogy a T egy példányának memória lenyomatában az „x helyén” csak integer típus lehet. Ekkor, ha a T példány x mezőjét nem lehet T típustól függetlenül elérni, nincs szükség a virtuális táblára. Az, hogy nem lehet x-et T-től függetlenül elérni, az azt jelenti, hogy mindig van egy T (vagy T-ből származó) típusú t változó, és a programkód x-szel kapcsolatban csak t.x hivatkozást ír le. Ekkor a fordítóprogram t típusának leírásából tudja, hogy x pontosan milyen típusú, ezért x minden műveletét már fordítási időben fel tudja oldani. Ez azonban nagyon erős megkötés. Ha van egy F metódus, ami integer típust vár, akkor a program tartalmazhat egy F(t.x) műveletet. Ha egy programozási nyelv olyan, hogy integerből lehet származtatni, és integer tartalmaz virtuális függvényt (ami a .NET esetén az Object miatt azonnal teljesül), akkor az F metóduson belül nem lehet tudni, hogy a bejövő paraméter integer, vagy integer leszármazottja, emiatt nem lehet tudni, hogy pontosan melyik műveletet kell meghívni.

.NET esetében az integernek megfelelő Int32 típusnak az Object miatt van virtuális függvénye, így az F(t.x) jellegű hívás gondot okoz. Akkor nem okoz gondot, ha minden példány a 12-es ábrához hasonlóan hordozza magával a virtuális táblázatát. Ez azonban memóriapazarló. Lehetne egy másik megoldás, ami elsőre furcsán hangzik, de azért mondjuk ki: ha nem lehetne örökölni az integer típusból, akkor nem lenne probléma. Ha F integer paramétert vár, és az integer típusból nem lehet örökölni, akkor F metóduson belül a paraméter típusa nem lehet más, csak integer. Ekkor az integernek hiába van virtuális függvénye, öröklés hiányában azt soha senki nem tudja felüldefiniálni, emiatt nincs is szükség a virtuális táblás mechanizmusra sem, fordítási időben feloldható az összes függvényhívás címe.

Eddig ott tartunk tehát, hogy a 12-ik ábrán látható memóriapazarlást ki lehet kerülni, ha x mezőt csak T típus példányán keresztül lehet elérni, vagy ha x mező típusának nem lehet leszármazottja. Mind a kettő elég nagy megszorítás, gyakorlatban az első lehetőség értelmes keretek között megvalósíthatatlan. Melyik programozó használna egy nyelvet, ha nem lehetne F(t.x) típusú hívásokat leírni?

Ráadásul a fenti gondolatmenetben pár dolog el lett hallgatva. A 12-es ábra úgy ábrázolja a T és L típus memória lenyomatát, hogy azok egy összefüggő blokként magukban foglalják az x, y és z példányok memória lenyomatát is. Igazából egyáltalán nem triviális, hogy ez így legyen. Korábban szó volt róla, hogy a memóriakezelővel lehet foglaltatni területeket, és ezekre a területekre memóriacímmel, referenciával vagy valamilyen mechanizmussal lehet hivatkozni. A T és L típus memória lenyomat felépülhetne úgy is, hogy nem az őt alkotó típusok lenyomatait tartalmazza, hanem csak referenciákat a típusok példányaira. Egy ilyen helyzetet mutat a 13-as ábra.

13-as ábra

Az ábra tovább bonyolódott az előzőhöz képest, de nem a bonyolultság a mondanivaló. A fő probléma az, hogy ha egy nyelv támogatja a polimorfizmust, akkor nem lehet biztosra tudni, hogy a T típusú t objektum x mezője mögött valójában milyen típusú objektum van. Ebben az esetben a programozó a t.x mezőt ráállíthatja egy integerből származó típusra is, és ekkor már mindenképpen szükségesek a virtuális táblák a hívások feloldásához. A 12-es ábra esetében egy integerből származó típus esetleg be sem fér az x mező számára foglalt helyre, mert például plusz mezői vannak. Emiatt nincs értelme arra lehetőséget adni, hogy a 12-es ábra esetében x helyére integeren kívül más kerüljön. Lehetne mondani a 13-as ábra esetére is, hogy x ne referálhasson, csak integer típusú példányra, de ez nagyon nagy megszorítás. Az integer típussal a példában ez nem feltűnő, de az öröklődés bemutatásánál gyakran szereplő „Alakzat”, „Kör”, „Négyzet” (vagy „Állat”, „Disznó”, „Kacsa”) típusokat használva, ha T egyik mezője „Alakzat” típus, lényegében használhatatlanná válik, ha közben nem mutathat „Alakzat” típus leszármazottaira. Pont a polimorfizmus lényegét tenné lehetetlenné.

Ez utóbbi gondolatmenet arra is rávilágít, hogy bizonyos esetekben, amikor a polimorfizmus lehetőségeire van szükség, csak a 13-as ábra szervezése lehet a megoldás a 12-es ábra megoldásával szemben. A 13-as ábra viszont az integer jellegű típusok számára hátrányos. A 12-es ábra esetén legalább felcsillant a remény, hogy meg lehet spórolni az integerek mellől a virtuális táblát – igaz, ehhez meg kellett volna tiltani az integerből való leszármazást.

Láthatunk tehát olyan példát, ahol a polimorfizmus, és az azt támogató virtuális tábla kifejezetten szükséges. Ekkor az az előnyös implementáció, ha a példány adott mezője valahol a memóriában külön helyezkedik el, és valamilyen közvetett hivatkozáson (például referencián) keresztül lehet elérni. Ez a 13-as ábra esete. Más helyzetekben az tűnik jobbnak, ha a példány mezője be van ágyazva a példány memória lenyomatába. Ekkor egyrészt megspórolható egy referenciának (vagy mutatónak) szánt hely, illetve ha meg lehetne tiltani, hogy a mező típusából új típusok származzanak, akkor a virtuális tábla mutatójának a helye is megspórolható lenne. Még egyszer, itt nem kevés memóriáról van szó. Ha egy integer jellegű típus 4 byte-on ábrázolható, és egy virtuális tábla mutató 4 byte-ot, illetve egy referencia is 4 byte-ot foglal el, akkor a 13-as ábra megháromszorozza azt a memóriaigényt, amire optimális esetben szükség lenne. Mivel sok programozási nyelvben minden összetett típus néhány integerhez hasonló alaptípusból épül fel, melyek nagyságrendben az integerhez hasonló méretű területet foglalnak (általában 1-8 byte), itt arról beszélünk, hogy a programok működésük közben n vagy 3*n memóriát fogyasszanak el.

Minden objektum?

Ezek után az ember már hajlik arra, hogy az integer jellegű típusokra elfogadja az öröklődés hiányát. Az öröklődés az objektum orientált világ alapeleme, egy objektum orientált programozási nyelvben ezt támadni, csak komoly indokkal lehet. Mégis, fel kell tenni az eretnek kérdést, hogy például egy integer példány, az egyáltalán objektum-e, az objektum orientált programozás szemszögéből?

Az integerre és a hozzá hasonló típusokra inkább a matematika szemszögéből közelít az ember. Maga az integer típus meghatároz egy értékhalmazt, és a halmazon értelmezett műveleteket. Tehát nem a halmaz egy elemének (például a 12-nek) vannak műveletei, mint egy objektumnak, hanem a halmazon értelmezett műveletek vannak. Úgyanígy gondolhatunk a sík pontjaira. Az „a” pont elforgatása „b” pont körül az se nem az „a” pontnak, se nem a „b” pontnak nem művelete, ez a pontok halmazán értelmezett művelet. Objektum orientált szemléletben az „a” pont mozdulna el. A matematika szempontjából viszont a művelet eredményeként egy új értéket választunk a halmazból. De lehet beszélni időpontok halmazáról, színek halmazáról, magyar keresztnevek halmazáról, mind olyan dolog, ahol nem objektumokban kényelmes gondolkodni, hanem szimpla értékekben. Ebből az irányból tekintve nem tűnik nagy érvágásnak az öröklődés hiánya.

Átesve az érvelés másik oldalára, felmerülhet a kérdés, hogy miért fontos egy integer jellegű típus szempontjából, hogy minden más típussal együtt az Object-ből származzon? Maga az integer típus nem nyer vele sokat. A keretrendszer viszont igen. A legfontosabb, hogy minden típus a .NET-ben az Object-től örökli azt a képességét, hogy vissza tudja adni a saját típusinformációit. Ezen a típusinformáción keresztül azután szinte mindent meg lehet tudni az adott típusról, például a metódusainak nevét a paramétereivel együtt. Nagyon sok mechanizmus használja ki ezt a lehetőséget, például a WPF, WCF, különböző szerializációs osztályok. A közös ősosztály tehát nem csak valami ideológiai rendszer miatt született, nagyon komolyan ki van aknázva a .NET-ben.

A .NET megoldása az eddig felmerült problémákra a fentiek tekintetében nem meglepő: megkülönbözteti azokat a típusokat, amelyeket értékként akar a fejlesztő használni, és azokat, amelyek valóban objektumokként működnek. Az előbbi csoport neve a .NET-ben értéktípusok (value types), az utóbbiaké kicsit szerencsétlenül referenciatípusok (reference types). Az elnevezés azért kevéssé jó, mert egy implementációs jellegzetességet ragad ki. A helyes név az értéktípus analógiájára objektumtípus (object types) kellene, hogy legyen. Hogy miért inkább referencia típus lett a név, csak találgatni lehet, elképzelhető, hogy egy objektum orientált nyelvben, ahol minden típus egy Object típus leszármazottja, tényleg rosszul jött volna ki, egy külön objektumtípusok nevű csoport, tisztán rámutatva, hogy valami sántít.

Akár mi is a név, a két csoport viselkedése a fenti példák és okfejtések után természetesnek tűnik. Az értéktípusoknak nem lehet leszármazottja, illetve a példányai mindig a magába foglaló típus memória lenyomatán belül helyezkednek el. Ennek köszönhetően a példány típusa mindig egyértelműen meghatározható, és így nincs szükség a virtuális táblára (vagy .NET-es terminológiával metódus táblára) sem. Ez jóval takarékosabb memóriahasználatot tesz lehetővé.

Ezzel szemben a referencia típusok példányai mindig a magába foglaló típus memória lenyomatán kívül jönnek létre, a magába foglaló típus memória lenyomata csak egy referencián keresztül hivatkozik a példányra. Ez a szervezés egyébként azt is lehetővé teszi, hogy egy létrejött példányra több helytől hivatkozzon referencia. Egy értéktípusnál ez nem lehetséges, de az „érték” jellegénél fogva ennek nincs is értelme. Az is előfordulhat, hogy a referencia egy leszármazott típusra mutat. A referencia típusok igazi objektumokként működnek, támogatva az objektum orientált programozás lehetőségeit, beleértve az öröklődést és a polimorfizmust.

Egy másik jellemző különbség a két csoport között az élettartamuk. Egy értéktípusú példány élettartama szorosan össze van kötve az őt befoglaló példány élettartamával. Ez a 11-es ábrát elnézve érthető, másképpen nehéz is elképzelni.

A referencia típus esetén, amikor létrejön egy T típusú példány, amely magában foglal egy referencia típusú mezőt, akkor a létrejött T típusú példány csak egy referencia tárolására elegendő helyet foglal magában, mint ahogy a 13-as ábrán látszik. Ez a referencia azonban nem szükségszerűen hivatkozik bármire, sőt a .NET keretrendszer alapból úgy állítja be, hogy nem hivatkozik semmire (értéke null). A referenciát a példány létrejötte után a programozónak külön be kell állítani, és hogy mire (és milyen típusú objektumra), szintén a programozó dolga. Emiatt a referált példány élettartama nincs szorosan összekötve a referáló példányéval.

Típusok példányai, illetve típusok példányaira mutató referenciák azonban nem csak egy befoglaló típus létrejötte miatt keletkezhetnek, mint a típus mezői. Egy metódus munkaváltozókat használ, és az ehhez szükséges területet a veremről foglalja le, ahogyan azt korábban láttuk. Van valami különbség a vermen létrejött példányok (illetve példányra mutató referenciák), és egy típus mezőiként létrejött példányok (referenciák) között? Nem, a vermen létrehozott változók egészen pontosan ugyanúgy működnek, mint ha csak egy objektum mezőiként jöttek volna létre. Az értéktípusok számára ugyanúgy helyben foglalódik akkora terület, hogy az értéktípus teljes egészében ábrázolható legyen a vermen. A referencia típusok számára viszont csak a referencia tárolására szükséges terület jön létre. Ezután a programozó vagy létrehoz külön egy objektum példányt (ami ilyenkor már a halmon keletkezik) és ráállítja a referenciát, vagy egy már régebb óta meglévő példányra állítja rá. Amikor a metódus visszaadja a vezérlést a hívójának, a számára fenntartott verem terület megszűnik, így megszűnnek az értéktípusú példányok, illetve a referenciák is. Azok a példányok viszont nem szűnnek meg, amelyekre a referenciák rá voltak állítva. A verem munkaváltozói és egy objektum példányának mezői tehát hasonlóan működnek. Ez látható a 14-es ábrán.

14-es ábra

A T típus definíciója most pontosan olyan mezőket definiál, mint amit a Method nevű metódus munkaváltozónak használ. Emiatt a T típus egy példányának memória lenyomata a halmon majdnem úgy néz ki, mint a Method munkaváltozóinak lenyomata a vermen. Az x és y váltózok helyben tárolják az értéküket. A z változó viszont referencia típusú, így ő csak a referencia tárolásához szükséges memóriát foglalja el. A referencia hivatkozhat egy reftype típusú példányra a halmon, ha azt a programozó külön beállította (a 14-es ábrán ez az eset látható, ráadásul mind a két z ugyanarra a példányra mutat, ami nem feltétlenül van így, de így is lehet).

Amikor a fordítóprogram egy típusdefiníciót, mint a 14-es ábrán a T feldolgoz, akkor egy táblázatba feljegyzi, hogy az adott típusnak milyen mezői vannak. Innen tudja a futtatókörnyezet, hogy adott típusnak mekkora helyet kell foglalni, illetve innen tudja az egyes mezők típusát, és ha az éppen értéktípusról van szó, akkor a metódus tábla mutatójának a hiányában is innen tudja, hogy melyik metódust kell meghívnia. Ami érdekes, hogy minden egyes metódusra is, mint a 14-es ábra „Method()”-ja, csinál egy táblázatot, hogy adott metódus milyen munkaváltozókat használ. Ebből a táblázatból tudja, hogy egy metódus mekkora helyet igényel a vermen, illetve a vermen lévő értéktípusú példányok típusát is be tudja azonosítani. Emiatt, ha a vermen levő értéktípus egy metódusát meg kell hívni, pontosan tudja a futtató környezet, hogy melyik metódust kell elővennie. Látható, hogy a veremnek és egy típus mezőinek a használata mennyire hasonlít.

Mostanra érezhető, hogy három féle dologról beszélhetünk, ami a memóriában található. Vannak értéktípusok, amelyek egyszerűbb (egész szám) vagy bonyolultabb (idő) értékeket hordoznak. Vannak a referencia típusok, amelyek valahol a memóriában létrejönnek, és tisztességes objektumként tudnak viselkedni. Ezenkívül vannak a referenciák, amelyek egy referencia típusú objektumra hivatkozhatnak.

Ezen a ponton úgy tűnhet, hogy minden problémát sikerült megoldani, jó kompromisszumot találva az objektum orientált programozás és a teljesítmény igények terén. Ideje ezért felidézni, hogy a .NET-ben minden az Object típusból származik, például az Int32. Készíthető emiatt egy Művelet(Object o) jellegű metódus, amit a polimorfizmus miatt meg lehet hívni egy Int32 paraméterrel is. Ekkor több probléma is előkerül. Az egyik, hogy maga az Object egy referencia típus. Ez azt jelenti, hogy az implementációt tekintve, a Művelet kódja arra számít, hogy a vermen egy referenciát fog találni. A várt referencia miatt nem lehet az Int32 típusú változót az értéktípusoknak megfelelően odamásolni. A másik probléma az, hogy az értéktípusok nem hordozzák magukkal a metódustáblájukat. Emiatt a Művelet implementációja nem tudja, hogyan vegye elő az Object által deklarált virtuális metódusokat, például a ToString()-et.

Egyik lehetőség a megoldásra, hogy az értéktípusok nem szerepelhetnek paraméterként, ha egy metódus Object típust vár, vagy ha egy osztály mezője egy Object típusú referencia, akkor annak nem lehet értéktípust, mint Int32 értékül adni. Ez a döntés azonban majdnem azzal lenne egyenértékű, mintha az értéktípusok nem az Object-ből származnának, hiszen lényegében semmit nem lehetne velük tenni, amit egy leszármazott típussal egyébként lehet.

A másik lehetőség, ha szükség esetén a .NET keretrendszer meg tudná jeleníteni az értéktípusokat a referencia típusoknál megismert infrastruktúrával. Ez két dolgot igényel. Egyrészt, az értéket önálló életre kell kelteni, hiszen a vermen vagy egy objektum mezőjeként az érték eltűnhet a metódus végeztével vagy a befoglaló objektum megszűnésével. Másrészt ki kell egészíteni a metódustáblájával, hiszen ezzel értéktípusként nem rendelkezett, mivel nem volt rá szüksége, a típusa mindig ismert volt (vagy a befoglaló típus definíciójából, vagy a metódushoz készített táblázatból, ami leírja, hol milyen típus van a vermen)

A .NET a második megoldást valósítja meg, és a folyamatot boxingnak hívják. A boxing önmagában megér egy témát, ezért itt nem is esik szó róla. A folyamat részletesen leírásra került a „Boxing, Unboxing – hogy is van ez?” című cikkben.
Amiről utoljára szó esik, az az értéktípusokkal kapcsolatos tervezési alapelvek. Az a szerencsétlen helyzet, hogy szinte ugyanazok a nyelvi eszközök és lehetőségek állnak rendelkezésre egy referencia típus és egy értéktípus leírására. Pár dolgot persze nem enged meg a fordító és a keretrendszer, például egy értéktípusnál nem lehet megadni, hogy mi az ősosztály, hiszen az mindig a System.ValueType (vagy felsorolási típusoknál a System.Enum-ból, ami meg a System.ValueType-ból származik). Mivel értéktípusok esetén nincs értelme virtuális metódusoknak definiálni, hiszen a származtatás hiánya miatt úgysem lehet semmi, ami azt felülírná, nem is lehet virtuális függvényt definiálni. Pár egyéb dolgot leszámítva azonban egy értéktípussal ugyanazt meg lehet csinálni, mint egy referencia típussal, márpedig a nyelvi elemek egy objektum szemléletű világhoz lettek kialakítva, így arra vezetheti a programozót, hogy az értéktípusait is objektum jellegű viselkedéssel ruházza fel.

A legalapvetőbb tervezési minta, amit az értéktípusokkal kapcsolatban figyelembe kell venni, az az, hogy az értéktípus egy példánya nem több, mint egy értékhalmaz egy eleme. Egy szimpla érték. Mint ilyennek, nincs állapota, és állapot nélkül nincs rajta mit karbantartani, azaz szükségtelen a mezőit a program futása közben módosítani. Az értéktípusok mezőmódosítása egyébként is okozhat problémákat, erről több olvasható a boxingról szóló cikkben. Az értéktípus „példánya” a létrejötte után tehát nem változik. Idegen szóval élve, „immutable”. Ez persze nem jelenti azt, hogy az értéktípust tároló változó nem vehet fel új értéket. Ez azonban mindig egy új „példány létrejöttén” keresztül történik. Nézzük példaként a .NET DateTime típusát. Ha t egy DateTime típusú változó, akkor annak értéket lehet adni, például a t = DateTime.Now; kifejezéssel. Mivel t egy DateTime, így van neki egy AddDays metódusa. A t.AddDays(3) hívás után vajon változik t értéke? Nem, mivel értékről van szó, és maga az érték sosem változik. Elvileg nincs is értelme egy érték változásáról beszélni. Ha 12-höz hozzáadunk 5-öt, a 12 még 12 marad, ami mellett persze eljutunk egy másik értékhez, ami a 17. A t.AddDays(3) esetében pont erről van szó. A t változó értéke megmarad, a művelet azonban eredményez egy másik értéket. Ha erre az új értékre szükség van, azt értékül lehet adni egy változónak, akár magának a t-nek is: t = t.AddDays(3). Furcsa? Az objektum orientált világ jelőlésrendszere miatt igen, az. Azt kell észbentartani, hogy nem a típusnak vannak vannak műveletei, hanem a típuson értelmezett műveletek vannak. Talán helyesebb lenne az értéktípusoknál csak statikus metódusokat használni, mint DateTime.AddDays(t, 3), ez azonban már inkább ízlés kérdése.

Egy másik zavaró dolog a konstrukció. Az objektumoknak vagy egy konstruktora, aminek a feladata az, hogy a létrejövő új objektum állapotát konzisztens formába hozza. Ha egy típus maga nem definiál konstruktort, akkor a C# fordító készít egy alapértelmezett konstruktort, ami nem csinál semmit. Ezután a C# fordító, amikor azt látja a programkódban, hogy az létrehoz egy új objektumpéldányt, olyan kódot generál, hogy létrehozás után az azonnal meg is hívja az objektum konstruktorát.

Értéktípusoknál más a helyzet. Ha egy értéktípus egy referenciatípus példányának a mezőjeként jön létre, akkor mivel a futtatókörnyezet egy példány memóriafoglalása után kinullázza a területet, az értéktípus akkor is inicializálva lesz nullára, ha a referencia típus konstruktor nem nyúl a mezőhöz. Az értéktípusú mezőn viszont nem feltétlenül lesz hívva konstruktor. Ha a programozó a befoglaló típus kódjában hív konstruktort az értéktípusú mezőre, akkor lesz. Egyébként marad kinullázva.

Ha a az értéktípusú példány a vermen jön létre, akkor azt a területet a futtatókörnyezet nem nullázza ki automatikusan. Erre a helyzetre figyel a C# fordító, emiatt egy értéktípusú lokális változó adott mezőjét nem engedi olvasni egészen addig, amíg annak a programkód előzőleg nem ad értéket. Az értékadásnak több módja lehet, amiből az egyik a referencia típusoknál is megismert forma. Ha a Complex egy értékt típus, aminek a fejlesztő nem készített konstruktort, a referencia típusokhoz hasonlóan akkor is leírható a következő:

Complex c = new Complex();

Azt gondolná az ember, hogy itt a referencia típusoknál megszokott alapértelmezett, fordító által generált konstruktorról, és annak hívásáról van szó, de nem. A C# fordító a referencia típusokkal ellentétben nem készít alapértelmezett konstruktort, és a fenti sorból teljesen más kódot generál, mint ha referencia típus lenne. A futtató környezetnek van egy initobj nevű művelete, ami nem csinál mást, csak kinullázza az adott területet. Ez nagyon gyors egy függvényhíváshoz képest, nyilván az értéktípusok optimalizált használatáról van szó. A C# fordító nem is enged értéktípushoz paraméter nélküli konstruktort definiálni, mert azt úgysem hívná meg, még a fenti formát leírva sem, és ez félreértésekre adna okot. Helyette a fenti sorra az initobj utasítást generálja. Ellenben paraméteres konstruktort már készíthet a programozó C#-ban az értéktípusokhoz is, és használhatja is. Ebben az esetben a fordító nem fogja a területnullázó initobj parancsot generálni, tehát az értéktípus helyén a veremben kiszámíthatatlan érték lenne. A paraméteres konstruktort viszont közönséges függvényként hívja meg, és megköveteli a kódtól, hogy minden mező értékét állítsa be, elkerülve, hogy az egyik mező értéke memóriaszemét legyen. Ellenkező esetben fordításnál hibaüzenetet ad. Itt szintén optimalizációról van szó, minek nullázni az értékeket, ha úgyis kapnak újat a konstruktorban. Referencia típusoknál ez nem számított. Azonos szintaktika, más működés. Arra viszont fel kell készülni, hogy minden értéktípus létrejöhet úgy, hogy a mezői ki vannak nullázva, és nem hívódik meg semmilyen más inicializációs rutin (mint a konstruktor). Emiatt minden értéktípust úgy kell megtervezni, hogy nulla értékű mezőkkel az értékhalmaz egy létező eleme legyen.

Konklúzió

A mai kényelmes fejlesztői eszközök célja az, hogy elrejtse az implementációs részleteket a programozó elől, hogy az magas szinten, a konkrét feladatra tudjon koncentrálni. Azonban csak a magasabb szinten maradva nem mindig lehet érteni, hogy a lehetőségek mire is szolgálnak pontosan. Ettől kényelmetlen érzése támadhat a fejlesztőnek, esetleg azt gondolja, hogy egyszerűen rosszul lett kitalálva a rendszer, amit használ. Az érték és referenciatípusok témaköre esélyes erre a bélyegre. Remélem a cikk elolvasása után viszont nem maradt kétség az olvasóban, hogy értelmes, jól megfontolt kompromisszumok árán került bevezetésre a .NET-be a két fogalom.

  1. #1 by At. on 2010. December 21. - 10:18

    Alapos, érthető és olvasmányos írás.

    Respect!!

  2. #3 by Attila Zoltan Kovacs on 2011. December 31. - 18:18

    Nagyon jó, bár a stack frame-et és ezzel kapcsolatban az EBP regisztert is szerencsés lett volna megemlíteni, ti. nem sokkal növelte volna a terjedelmet, és igazából ez írja le a mai modern programok működését x86-os környezetben.

  3. #4 by surex on 2013. April 5. - 11:22

    Érdemes volt végig olvasni, sok homályos részre rávilágítottál, főleg ami a memóriakezelést illeti! Csinálok is azonnal egy jó kis friss, ropogós értéktípust😉

  1. Értéktípus konstruktorok és a szecskáztatás - pro C# tology - devPortal
  2. A System.Enum egy referencia típus?! - pro C# tology - devPortal
  3. Megoldás – Minifeladatok II. - pro C# tology - devPortal
  4. Megoldás – Minifeladatok I. - pro C# tology - devPortal

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: