Archive for the ‘C#’ Category

[Ez a cikk egy egyetemi beadandóm kapcsán készült. Kicsit hosszúra sikeredett (15 oldalra), emiatt akit érdekel az alábbi címről letöltheti pdf formátumban is: innen. Mellesleg az íromány az alábbi két Jeffrey Richter által írt angol nyelvű értekezés magyar változata: 1, 2. Némileg kibővítve, némileg megcsonkítva 😀]

A mai korszerű programozási nyelvekhez tartozó futtatókörnyezetek többsége tartalmaz valamilyen automatikus szemétgyűjtési mechanizmust a memóriakezelés leegyszerűsítése végett. Ebben a cikkben a C# programozási nyelvhez tartozó .NET CLR futtatókörnyezetben található szemétgyűjtőt (angolul: gargabe collector, vagy röviden csak gc) fogom bemutatni a C++-os memóriakezelési megoldáshoz viszonyítva.

A szemétgyűjtés okai

Arra a kérdésre, hogy miért van szükségünk automatikus szemétgyűjtésre, a válasz egyszerű. Mert könnyebbé teszi a programozó életét, hiszen így jobban tud koncentrálni a tényleges feladatára, ha nem kell külön törődnie a memória kezelésével is. Pontosan ez a lényeg, ugyanis a memóriakezelés kapcsán két olyan tipikus hibát követhetünk le, melyek sokkal alattomosabbak, mint bármilyen más alkalmazásbeli bug, ugyanis ezek hatására a program működése nem lesz megjósolható, vagyis nemdeterminisztikussá válik. Mielőtt még továbbmennénk, nézzük is meg, mi ez a két hibalehetőség:

– Az egyik a memóriaszivárgás (memory leak) esete, amikor a programozó elfelejti felszabadítani az általa lefoglalt tárterületet. Ekkor a memóriában az ilyen objektumok csak foglalják a helyet, de senki se használja őket semmire. Ez ahhoz vezethet, hogy elfogy a szabad memória, ami miatt az alkalmazás elszáll OutOfMemomeryException-nel.

– A másik lehetőség, amikor egy már felszabadított tárterületre hivatkozik a program. Vagyis azon a mutatón keresztül, amelyet az allokációkor kapott, olyan memóriaterületet ér el, mely már nem az övé, és itt bármilyen adat lehet, aminek a következtében kiszámíthatatlanná válik az alkalmazás futása.

Ez két olyan hiba, melyet általában rendkívül nehéz fülön csípni, és az esetek nagy részében a javításuk se feltétlenül triviális feladat. Az automatikus szemétgyűjtés eme két hiba bekövetkezésének valószínűségét hivatott nullára lecsökkenteni.

Egy objektum élete, memóriakezelés szempontjából

Egy erőforrást használó objektum életciklusa során az alábbi 5 tevékenység valamelyikét végezheti:

1) Helyet foglal a szükséges erőforrás számára
2) Inicializálja az erőforrást a kezdeti állapotnak megfelelően, és előkészíti a használatra
3) Eléri és használja az erőforrást az osztálypéldány megfelelő tagján keresztül
4) „Feltakarít” maga utána
5) Felszabadítja a lefoglalt tárterületet

Memóriakezelés szempontjából minket most csak az első, a negyedik és az ötödik tevékenységek érdekelnek. Kezdjük először a két szélsővel, ugyanis ezeket a lépéseket képes automatikusan elvégezni a számunkra a GC! (A negyedik lépeshez a GC-nek ismernie kéne az erőforrás típusát és működését, de mivel ez nem várható el, ezért ez a feladat továbbra is a programozó feladata marad, de erről majd picit később).

C++ esetén, ha a memóriában helyet szeretnénk allokálni egy adott objektumnak, akkor azt úgy tesszük meg, hogy végigmegyünk egy láncolt listán, mely a memória szabad területeit reprezentálja, és az első méretben megfelelő szabad helyet lefoglalja. Ez így szép és jó, viszont ennek a műveletigénye a lineáris keresés műveletigényével, O(n)-nel egyenértékű. A GC ehhez képest O(1) műveletigénnyel képes nyújtani ugyanezt a szolgáltatást. Vajon miként lehetséges ez?

A memória allokáció egy olcsó művelet .NET környezetben

A GC esetén a memóriában a helyfoglalás azért lehet ilyen rendkívül olcsó, mert mindösszesen egy mutató értékének növeléséről van szó. Ez a mutató arra a tárterületre hivatkozik, amelyet a következő allokáció során az újonnan létrehozandó objektum fog majd megkapni, így nincs szükség semmilyen keresésre a helyfoglalás során. Hívjuk ezt a pointert NextObjPtr-nek. A legegyszerűbben ezt úgy tudjuk elképzelni, ha a memóriára, mint egy veremre gondolunk, és a verem legfelső elemére van egy referenciánk. (A továbbiakban erre a „veremre”, mint managed heap-re fogunk hivatkozni.)
Nézzünk egy egyszerű példát. Tegyük fel, hogy van A, B és C objektumunk. Először, amikor az A objektum számára akarunk helyet allokálni, akkor a NextObjPtr a verem legaljára mutat. Helyfoglalást követően ez az érték pontosan annyival nő meg, mint amekkora helyre szüksége volt az A objektumnak. A B és a C objektum helyfoglalása után valahogy így nézhet ki a memória.

Menedzselt Heap

Ahhoz, hogy mindez jól és hatékonyan működhessen, két eléggé erős feltételnek kell teljesülnie: A címtér és a tárhely legyen végtelen. Nos, ezeket a feltételek eléggé nehezen lehet biztosítani, emiatt van szükség néha a memória kitakarítására. Ezt a mechanizmust hívjuk szemétgyűjtésnek.

Hogyan történik a szemét összegyűjtése?

A szemétgyűjtés menete röviden az alábbi: Ha a managed heap megtelt, akkor a szemétgyűjtő összegyűjti azokat az objektumokat, melyekre már nincs szüksége az alkalmazásnak és felszabadítja az általuk foglalt helyet, így ismét lesz szabad tárterület. Ez mind szép és jó, de honnan tudja a GC, hogy mire van szüksége egy alkalmazásnak és mire nincs? Onnan, hogy úgynevezett erős referenciákat, vagyis röviden root-okat használ. Ezek azok a pointerek, melyek például globális vagy statikus objektumokra hivatkoznak, vagy egy szál stack-jének lokális változói, vagy akár a CPU regiszterében lévő mutatók, tehát az alkalmazás élő komponensei. A root-ok hivatkozhatnak egy managed heap-beli tárterültre, vagy akár lehet az értékük null is.

Azok az objektumok, melyre van ilyen root hivatkozás, azok „aktív” objektumok, vagyis az alkalmazás számára elérhetőek. Az összes többi nem elérhető, vagyis szemét. Íme, egy ábra, mely erre mutat egy példát.

Allokált objektumok és ROOT-ok

Maga a GC algoritmus ezek alapján a következőképpen működik. Ha megtelt a managed heap, akkor a GC elkezdi összegyűjteni a szemetet. Abból a feltevésből indul ki, hogy minden, ami a memóriában van, az szemét. Viszont, ha talál rá root hivatkozást, akkor azt elérhetőnek minősíti, és nem fogja törölni. A fenti ábrán jól látszik, hogy nem csak az A, a C, a D, és az F objektumok érhetőek el, hanem a H is. (Indirekt módon a  D-n keresztül.) Azért, hogy a GC ne töröljön olyan objektumokat, melyek, ha direktbe nem is, de elérhetőek, ezért rekurzívan végignézi az összes root-ból elérhető objektumot. A rekurzív bejárás során a GC algoritmus felépít egy elérhetőségi gráfot, és azokat az objektumokat fogja majd csak törölni, amelyeket nem lehet elérni, vagyis amelyek nem szerepelnek a gráfban. A vizsgálat során, ha már egy adott objektum szerepel a gráfban, akkor azt már nem fogja továbbvizsgálni, egyrészt teljesítmény szempontból, másrészt a végtelen körhivatkozások elkerülése érdekében.

A fentebbi ábrán látható példa a GC lefutása után az alábbi módon néz ki.

GC lefutása után

Vagyis a szemétgyűjtés olyan módon valósul meg, hogy a GC végigmegy az összes a root-okból elérhető objektumokon rekurzívan, közben felépít egy gráfot, és azokat az objektumokat, melyek nem szerepelnek a gráfban, törli. A törlés után a managed heap-et ismét konzisztens állapotba hozza, vagyis memcpy-val szépen átmásolgatja az elemeket a felszabadult üres helyeknek megfelelően. Természetesen az átrendezést követően a GC feladatai közé tartozik még a pointerek értekeinek fixálása is az új helyezet alapján.

Ami a fentebbi leírásból egyből kitűnik, hogy egy GC lefutás nem egy olcsó művelet, viszont szerencsére csak akkor fut le, ha megtelt a managed heap. Köztes állapotban ellenben sokkal gyorsabb megoldást nyújt, mint a C++-os megvalósítás. Ami még szintén látszik az algoritmusból az az, hogy a fentebb említett két probléma, itt már nem fordulhat elő. Hiszen ha már nincs az adott objektumra referencia, akkor biztosan fel lesz szabadítva. A másik irány is igaz, vagyis ameddig van rá hivatkozás, addig biztosan nem kerül felszámolás alá. A nagy kérdés már csak az, hogy ha a GC ennyire jó, akkor az ANSI C++ miért nem ezt használja? A kérdésre a válasz a kasztolásban rejlik, ugyanis C++-ban a pointer által mutatott objektumok átkasztolhatóak egyik típusról a másikra, viszont így a rendszer nem tudná kideríteni, hogy a pointer mire is mutat.

Ideje feltakarítani magunk után

Most hogy már tudjuk, miként kezeli a GC az egyes (allokáció) és az ötös (felszabadítás) tevékenységét egy adott objektumnak, nézzük meg a négyes (feltakarítás) lépést is. Ebben az állapotában az objektumnak az általa használt erőforrást fel kell szabadítania, oly módon, hogy visszaállítja eredeti állapotába (pl.: fájl esetén bezárja a kommunikációs csatornát és leveszi róla az írási zárolást). Ezt a GC nem tudja automatikusan megcsinálni, ezért a fejlesztő kap egy olyan nyelvi lehetőséget, amelyen keresztül ezt a lépést megvalósíthatja. Ezt hívják Finalization-nek. Ez egy olyan függvény, melyet akkor hív meg a GC az adott objektumon, miután már szemétnek minősítette, de még mielőtt törölné. C++-os fejlesztők egyből analógiába hoznák ezt a destruktorral, de a valóságban eléggé nagy az eltérés a kettő között (részletesebben kicsit később).

Tehát kapunk a rendszertől egy Finalize elnevezésű metódust, mely segítségével feltakaríthatunk magunk után. Viszont ügyelnünk kell arra, hogy mikor használjuk ezt. Ökölszabályként elmondható, hogy szinte soha, próbáljuk meg kerülni, mert eléggé sok hátrányunk származik belőle. Íme, a teljesség igénye nélkül néhány példa:

– A Finalize metódussal rendelkező objektumok két körben kerülnek felszabadításra. (lásd később)

– Már maga az objektum allokálása is tovább tart, ugyanis speciálisan kell kezelni az objektumot, már az elejétől kezdve. (szintén lásd később)

– Hivatkozhat olyan objektumokra, melyek nem tartalmaznak Finalize-t, de emiatt mégis sokáig élnek.

– Több száz példány esetén már jelentős teljesítménycsökkentést okoz, ha egy osztály példányain egyszerre meg kell hívni a Finalize-t.

– Nincs kontrollunk afölött, hogy pontosan mikor is hívódik meg.

– Ha a program valami miatt terminál, akkor nem fut le a Finalize metódus, kivéve, ha ki nem kényszerítjük belőle (RequestFinalizeOnShutdown).

– Nem garantált a meghívásuk sorrendje, vagyis elképzelhető, hogy előbb a gyerek objektum(ok) Finalize-ja hívódik meg és csak utána a szülőé, ami probléma lehet, ha a szülőben hivatkozunk a gyerek objektum(ok)ra.

Ha ezek ellenére is szükségesnek tartjuk a Finalize metódus megírását, akkor törekedjünk arra, hogy minél gyorsabb és rövidebb legyen. Illetve ügyeljünk arra is, hogy ne tartalmazzon szál szinkronizációt és ne dobjon kivételt!

Visszatérve a Finalize != destruktor problémakörhöz, vizsgáljuk meg kicsit közelebbről a problémát. C++ és C# esetén is a fordító képes arra, hogy a származtatott osztály konstruktorába beleszerkessze az ősosztály konstruktorának hívását. C++ esetén ez a destruktorra is igaz, hiszen ott a sorrendiség garantált.  Ellenben C# esetén, ahol nincs destruktor, csak Finalize, a fordító számára nem egyértelmű, hogy van-e az adott osztálynak, meg kell-e hívnia, stb. Emiatt az ősosztály Finalize metódusának a meghívása a programozó felelőssége. Ilyenkor ügyeljünk arra, hogy az ősosztály Finalize-ának hívása kerüljön a metódus legvégére.

Érdekesség: C# esetén van arra lehetőség, hogy szintaktikailag „destruktort” írjunk (vagyis lehet írni ~Object() metódust), de ez a fordítás során az ősosztály Finalize-nak felüldefiniálására fordul le (protected override void Finalize()). Tehát Finalize != destruktor.

Feltakarítás két lépésben

Azon objektumokat, melyek rendelkeznek Finalize metódussal, már akkor meg kell különböztetni a többitől, amikor lefut a new operátoruk. Ezért a rendszer két segédtáblát használ arra, hogy ezt az információt nyilvántartsa. Az egyik a finalization queue névre hallgat. Ebbe a sorba bekerülnek azokra az objektumokra mutató referenciák, melyek rendelkeznek Finalize metódussal, mint ahogyan azt az alábbi ábra is mutatja.

Menedzselt Heap felépítése a két sorral kiegészítve

Amikor lefut a szemétgyűjtő, akkor megnézni, hogy a szemétnek ítélt objektumok közül, melyekhez tartozik finalization queue-beli bejegyzés. Amelyikhez van, azt innen törli és átteszi a másik segédtáblába, az freachable queue-ba. Tehát itt azok az objektumok vannak, amelyeknek van Finalize metódusa és szemétnek lettek minősítve. A fenti ábrán ezek az E, I és a J. (A B, G és a H objektumok ilyenkor már nincsenek benne a managed heap-ben). Ezt az állapotot szemlélteti az alábbi ábra.

GC lefutása után

Az freachable sor nevének az elején az f a finalizable-re utal, vagyis, hogy van Finalize metódusa az adott objektumnak, a reachable pedig arra, hogy újra van az adott elemre referencia, vagyis újból elérhető (van rá root), emiatt a GC nem törölheti. Azért kell újból elérhetővé tenni, hogy le lehessen futtatni a feltakarítást. Ezt egy külön szál végzi, ami mindaddig alszik, amíg üres a sor. (Emiatt a Finalize-ban kerüljük a szálkezelést!) Amint került a sorba új elem, végigmegy az összes bejegyzésen és szépen meghívogatja mindegyiken a Finalize-t, majd ezután törli a sorból a bejegyzést. Vagyis az objektum ténylegesen majd csak a következő GC lefutásakor fog felszámolódni, addig továbbra is ott lesz a managed heap-en.

Tehát a GC első lefutásakor az adott objektumra kapunk egy root-ot azáltal, hogy átpakoljuk a freachable sorba, így emiatt már nem lesz szemét, majd végrehajtatjuk egy külön szálon a Finalze metódust, töröljük a sorból, így elveszik az elérhetősége, emiatt a következő körben már az objektum szemétnek minősül. Az előző ábra az első GC lefutása utáni állapotot mutatja, az alábbi a második utánit.

GC 2. lefutása után

Amikor a holtak életre kelnek

Ha jobban belegondolunk, hogy mi is történik valójában a Finalize metódus kezeléskor, akkor azt láthatjuk, hogy van egy objektum, ami meghal, majd a Finalize miatt újra él, majd megint (de most már végleg) meghal. Ezt a folyamatot hívjuk feltámadásnak (resurrection-nek).

A második meghalás, azért következik be, mivel a Finalize lefutása után már nincs erős hivatkozásunk az objektumra. De mi van akkor, ha a metóduson belül értékül adjuk az adott objektumot egy globális vagy statikus változónak? Nos, újra élni fog. 🙂 Ami több szempontból sem szerencsés. Hiszen egyrészt már feltakarított maga után, másrészt az általa hivatkozott más objektumok is már halottak lehetnek. Jön a zseniális ötlet: élesszük őket is újra. És mindjárt ott is tartunk, hogy van egy teljes zombi hadseregünk. A zombi itt nagyon találó név, hiszen él, mivel van rá root, de halott, mivel már lefutott a finalize. Így, egy olyan rendszert kapunk, amelynek viselkedése teljesen megjósolhatatlan.

Ha valamilyen okból kifolyólag mégis szükségünk lenne a holtak feltámasztására, akkor azt csináljuk okosan. Egy flag segítségével tároljuk el, hogy egyszer már halálra lett ítélve szerencsétlen, és ez alapján a flag alapján cselekedjünk az objektum metódusain belül.

Sőt még tovább megyek, ha újból feleslegessé válik az objektum, jó lenne, ha ismét lefutna a Finalize metódus. Viszont az objektum nem lett újra allokálva, így nem került bele a finalization queue-ba, vagyis nem fog meghívódni alapból. Ezen úgy tudunk segíteni, hogy meghívjuk a GC ReRegisterForFinalize() metódusát. Ilyenkor egy új bejegyzés kerül be a sorba. Ennek köszönhetően pedig sikerült eljutni a hallhatatlansághoz, hiszen ahányszor meghalunk, annyiszor újra is tudjuk élesztetni magunkat, így megtaláltuk az örök élet elixírét. 🙂 Viccet félretéve, van lehetőség újraélesztésre, de csak óvatosan használjuk! (A ReRegisterForFinalize-t csak egyszer hívjuk meg, ha kell, mert különben annyiszor kerül be egy új bejegyzés a sorba, ahányszor meghívjuk a függvényt, vagyis annyiszor fog lefutni a Finalize metódus.)

Még élőként rendezzük a végrendeletünket

Az előző pár bekezdésben láthattuk a Finalize előnyeit és hátrányait, most nézzünk meg egy segédtechnikát, mely segítségével ki lehet küszöbölni a Finalize hibáinak egy részét. A technika lényege, hogy a feltakarítást ne bízzuk a GC-re, hanem mi magunk végezzük el explicite a programból. Ehhez arra van szükségünk, hogy létrehozzunk egy Close, vagy egy Dispose metódust. Előbbre akkor van szükségünk, ha feltakarítást követően még használható az objektum >> tartozik hozzá Open is. Utóbbit pedig akkor, ha már nincs tovább szükségünk az adott objektumra.

Nézzünk egy egyszerű példát ennek a használatára. Tegyük fel, hogy van egy FileStream objektumunk, amellyel egy fájlba akarunk írni. A jobb teljesítmény érdekében az implementáció soron puffereket használ az objektum, viszont ezek tartalma csak akkor kerül ki ténylegesen az adott fájlba, ha a pufferek megteltek. Ha ezek flush műveletét a Finalize-ban hívnánk meg, akkor mindaddig zárolva lenne a fájl, ameddig fel nem szabadítja a GC a tárhelyet, amire várhatnánk egy ideig. Ezért a FileStream objektumnak van Close metódusa is. Ilyenkor felmerülhet bennünk a következő kérdés: ha meg lett hívva explicite a Close, akkor is meg fog-e hívódni a Finalize, és ha igen, akkor mit fog csinálni?

A válasz az, hogy nem, nem fog meghívódni. Egy flag a háttérben beállítódik a Close meghíváskor, így a Finalize-ban lévő kódot ki is lehet hagyni. Ettől még a Finalize továbbra is meghívódna, hiszen benne van a finalization queue-ban, teljesen feleslegesen. Ennek elkerülése érdekében a GC-nek van egy olyan metódusa, mellyel ki tudjuk innen venni (pontosabban kihagyathatjuk azt a lépést, hogy áttegye az freachable sorba). Ez pedig nem más, mint a SuppressFinalize() metódus.

Természetesen az előző példát még tovább lehet egy kicsit bonyolítani azzal, hogy egy olyan StreamWriter-t használunk, mely egy FileStream-et alkalmaz. Mindkét objektum puffert használ, így a StreamWriter-nél is szükséges a flush meghívása. Ha tehát a StreamWriter Close metódusát meghívjuk explicite, akkor az meghívja a FileStream-ét is, így azt már nem kell külön. Ez így jól is működik, de mi van, ha lemaradt a Close meghívása? Nos, ez esetben jön a GC és a Finalize metódusok. DE, van egy kis bibi. A GC nem garantálja a sorrendet, vagyis előfordulhat, hogy előbb hajtja végre a FileStream Finalize-n belül a puffer flush metódusát és majd csak aztán a StreamWriter Finalize-jának flush metódusát, ami nem túl jó nekünk, hiszen így akár fontos adatok is elveszhetnek.

Erre a Microsoft megoldása a következő: A StreamWriter-nek nincs Finalize metódusa, mivel nem garantálható a sorrend. Viszont van Close metódusa, mely explicit meghívása esetén a rendszer jól működik, meg nem hívása esetén pedig adatvesztés esélye állhat fenn, amelyet a programozó észrevesz, így a probléma feloldható.

Ez még hátha jó lesz valamire

A feltakarítás hátulütőinek tisztázása után most nézzük meg azt, hogy miként lehet egy objektumot „kómába helyezni”. A dolog lényege az, hogy van egy objektumunk, mely jelen pillanatban bevégezte a dolgát, de lehet, hogy a későbbiekben még szükségünk lenne rá, csak nem tudjuk, hogy pontosan mikor, vagy, hogy egyáltalán kell-e még. Tegyük fel továbbá azt is, hogy ezen objektum létrehozása drága memória szempontjából. Emiatt nem szeretnénk még egyszer újrainicializálni, de ha sokáig nem kell, akkor akár törlődhet is. Ehhez az úgynevezett gyenge referenciákra van szükségünk (WeakReference, vagy röviden wr).

Vagyis, ha másfelől közelítjük meg a problémát, azt mondhatjuk, hogy a wr-k segítségével olyan objektumokra adhatunk referenciát, melyek el is érhetőek, és törölhetőek is. Ez mégis hogyan lehetséges? A válasz az időzítésben rejlik. Ha még a GC lefutása előtt a wr-en keresztül létre tudunk hozni az objektumra egy erős referenciát (Strong Reference, vagy röviden sr = root), akkor az objektum elérhető. Ha a GC lefutása után szeretnénk elérni az objektumot, akkor nem fog sikerülni, ugyanis a GC már felszabadította az adott tárterületet.

Nézzük ezt meg egy egyszerű példán keresztül. Tegyük fel, hogy van egy alkalmazásunk, mely két nagy egységből áll, melyek között a felhasználó bármikor átválthat. Az alkalmazás egyik felének el kell érnie a fájlrendszerbeli mappákat. Tegyük tovább fel azt, hogy a mappákról készített gráfot eltároljuk a memóriában a jobb teljesítmény érdekében. Ilyenkor van egy root-unk erre az objektumra. Ha a felhasználó átvált az alkalmazás másik felére, ahol nincs szükségünk a mappaszerkezetre, akkor készíthetünk erről az objektumról egy wr-t. Így, ha a felhasználó nem tér vissza egy újabb GC lefutása előtt, akkor az erőforrás felszabadítható, hiszen csak wr-ünk van az objektumra, sr-ünk nincs. Ebben az esetben sajnos újra fel kell építeni a teljes gráfot, ha a felhasználó visszatér az alkalmazás egyik feléhez. Ellenben, ha a felhasználó még a GC lefutása előtt tér vissza, akkor a wr-n keresztül el tudja úgy ismét érni az objektumot, hogy létrehoz rá egy erős referenciát.

Íme, a gyenge referenciák használatának a pszeudó-kódja:

LargeObject lo = new LargeObject(); //van egy sr-ünk az objektumra
//dolgozunk vele, majd átváltunk az alkalmazás másik felére
WeakReference wr = new WeakReference(lo); //van egy sr-ünk és egy wr-ünk is
lo = null; //már csak egy wr-ünk van az objektumra
// dolgozunk az alkalmazás másik felében, majd visszatérünk
lo = wr.Target;
if(lo == null)
lo = new LargeObject(); //későn értünk vissza, lefutott a GC

A fenti példakód két érdekes részt tartalmaz: az egyik a wr létrehozása, a másik pedig az sr létrehozása wr-ből. Nézzük először az elsőt. Amikor egy gyenge referenciát szeretnénk létrehozni egy adott objektumra, akkor azt úgy tudjuk megtenni, hogy a beépített WeakReference típusból létrehozunk egy új példányt és átadjuk a konstruktornak paraméterként az objektumra mutató referenciát. Ezek után pedig töröljük az erős referenciát, hiszen ha ez továbbra is megmaradna, akkor az egésznek nem lenne semmi értelme.

A gyenge referenciáknak két fajtája van, a rövid és a hosszú életű. A rövid gyenge referenciák (short weak reference) olyanok, hogy nem figyelik, hogy újra lettek-e élesztve. Míg a hosszú életű gyenge referenciákat (long weak reference) igen. Ez utóbbit úgy tudjuk létrehozni, hogy a konstruktor egy másik túlterhelt változatát használjuk, amelyik vár egy bool paramétert is. Ha long wr-t akarunk, akkor ezt állítsuk be true-ra. Mellesleg megjegyezném, hogy kerüljük a long wr-eket, mert az általuk mutatott objektumok feltámadása után az objektumok kiszámíthatatlan viselkedést produkálnak!

A gyenge referenciából erős referencia készítés oly módon valósul meg, hogy a wr Target tulajdonságán keresztül elérhető objektumot értékül adjuk egy root-nak, vagy egy abból elérhető referenciának. Ha ez az érték null, akkor elkéstünk, lefutott a GC, nincs mit tenni, újra létre kell hozni az objektumot. Viszont, ha az értéke nem null, akkor minden további nélkül tovább tudjuk használni.

A gyenge referenciákkal kapcsolatban felmerülhet bennünk egy érdekes kérdés. Hogyan tudunk úgy létrehozni gyenge referenciát, hogy közben nem hozunk létre egyben erős referenciát is? Másképp megfogalmazva a kérdést, ha az objektum csak a WeakReference-n keresztül elérhető, akkor tulajdonképpen elérhető, vagy mégsem?

A gyenge referenciák misztériuma

Hogy tudunk úgy létrehozni egy objektumra referenciát, hogy az valójában ne legyen referencia? A válasz nem is annyira egyszerű. Amikor a WeakReference beépített típus konstruktorának átpasszoljuk az objektumra mutató pointert, akkor valójában nem történik a managed heap-en allokáció. De ha nincs allokáció, nem jön létre egy új objektum, mely hivatkozna rá, akkor mégis, hogy tudjuk őt elérni a későbbiekben? Nos, egy segédtábla segítségével. Pontosabban kettővel, ugyanis, amikor létrehozunk egy WeakReference-t, akkor egy bejegyzés bekerül a short weak reference táblába vagy a long weak reference táblába a referencia típusától függően.

Ezekben a táblákban a managed heap-en lévő memóriacímre vannak hivatkozások, de ezek nem tekintendőek root-oknak! Ezek alapján tekintsük meg a GC algoritmus gyenge referenciák kezelésével kibővített változatát:

1) A GC felépíti az elérhetőségi gráfot a managed heap alapján

2) Megnézi a short weak reference táblában, van-e olyan hivatkozás, mely szemétre mutat. Ha van, akkor ennek a bejegyzésnek az értékét null-ra állítja.

3) Megnézi a finalization sort, hogy van-e olyan hivatkozás benne, mely szemétre mutat. Ha igen, akkor áthelyezi a mutatót a freachable sorba, és hozzáadja az objektumot a gráfhoz, így újból elérhetővé válik az.

4) Megnézi a long weak reference táblát van-e benne olyan hivatkozás, mely olyan objektumra mutat, mely nincs a gráfban. (Ilyenkor már a gráf részét képezik az freachable objektumai is!). Ha talál ilyet, akkor ennek az értékét null-ra állítja.

5) Törli a szemetet és eltünteti a lyukakat, illetve frissíti a referenciákat.

A short és a long wr-k közötti igazi különbség a következő. Short wr esetén, ha az objektum szemétnek lett minősítve, akkor egyből ezután törlődik is a short weak reference táblából róla a bejegyzés. Viszont, ha ennek az objektumnak van Finalize-ja, ami még nem futott le, és el szeretnénk érni az objektumot, akkor azt már nem tudja, hiszen már nincsen rá mutató, pedig még mindig ott van a managed heap-en. Ezzel szemben a long wr-nál csak akkor törlődik a bejegyzés a long weak reference táblából, ha az objektum tárhelye fel lett szabadítva, vagyis, ha újra is élesztik, akkor is megmarad rá a hivatkozás.

Különböző generációk, különböző szokások

A cikk hátralévő részében néhány GC optimalizálási technikát fogok bemutatni. Ezek közül az egyik legfontosabb a generációk kezelése. Amikor több különböző generációval dolgozunk egyszerre, akkor az alábbi négy feltevés mindegyike igaz kell, hogy legyen:

1) Az új objektumok, rövid ideig fognak élni

2) A régi objektumok, sokáig fognak élni

3) Az új objektumok között erős kapocs van, és elérésük gyakran azonos időben történik

4) A heap egy adott részének a kezelése gyorsabb, mint a teljes heap menedzselése

Tanulmányok állítják, hogy ezek az állítások megállják a helyüket a manapság használt szoftverek nagy részénél. Nézzük, ezek a feltevések hogyan segítik a GC-t a teljesítményének a javításában.
Kezdetben egy üres managed heap-pel indulunk. Létrehozunk objektumot, majd megtelik a memóriánk. Az ilyenkor a memóriában lévő objektumok a 0. generáció részét képezik. Ezek azok az új objektumok, melyeket a GC még nem vizsgált meg, mint ahogyan az az alábbi ábrán is látható.

0. Generáció

A rózsaszínnel jelölt objektumokat a GC szemétnek minősítette, ezért felszabadítja az általuk lefoglalt helyet. A megmaradt objektumokat ezután 1. generációsaknak nevezzük, és ezek feltehetően nem is nagyon fognak változni. A GC lefutása után érkező újonnan allokált objektumok fogják képezni az új 0. generációt, mint ahogyan az alábbi ábrán is látszik. A GC az esetek többségében csak a 0. generációs objektumokat vizsgálja.

0. + 1. Generáció

Az M törlése után a 1. generációból 2. generáció lesz, a 0. generációból pedig 1. (A legmagasabb generációs szint a 2.). Az újonnan érkező objektumok lesznek az új 0. generáció, melyet a heap betelésekor a GC-nek meg kell vizsgálnia. Ezt szemlélteti az alábbi ábra.

0. + 1. + 2. Generáció

Tehát összefoglalva a generációk kezelését, a rendszer úgy viselkedik, hogy mindig csak a legutóbbi GC óta létrejött objektumokat vizsgálja meg, a régieket békén hagyja. Ilyenkor két kérdés merülhet fel bennünk:

– Mi van akkor, ha a 0. generációs takarítás után sincsen elég szabad hely?

– Mi van akkor, ha nem egy 0. generációs objektum valamely hivatkozása frissül egy újonnan létrehozott objektumra?

Az első kérdésre a válasz, hogy ebben az esetben a GC a 0. és az 1. generációt kezdi el vizsgálni. Ha ez se hozná meg a kellő sikert, akkor a 0., 1. és a 2. generáció elemeit fogja megvizsgálni.

A második kérdésre a válasz kicsit összetettebb. Alapból a régebbi generációk olyan elemeinek belső hivatkozásait, melyre van root, a GC nem vizsgálja a fa építésekor. Viszont ezek idővel változhatnak is, emiatt a GC igénybe veszi a rendszer write-watch támogatását, ami a kernel32.dll-beli GetWriteWatch metódushívást jelenti. Ez által megtudhatja, hogy a legutóbbi GC lefutás óta történt-e referenciafrissítés.

A kezdeti 4 felvetésünk közül eddig még csak hármat használtunk fel. Most nézzük meg a 3-as sorszámú állításból milyen előnyünk származik. Ha C++-ban új elemeket folyamatosan allokálgatunk, akkor azok nagy valószínűséggel nem fognak egymás mellé kerülni, hiszen az első méretben is megfelelő szabad helyet fogják megkapni. DotNET esetén a veremszerű szerkezet itt előnyt jelent, hiszen így biztosan közel lesznek egymáshoz az újonnan allokált elemek. Mivel ezek sűrűn lépnek kapcsolatba egymással, emiatt a folytonos elhelyezés előnyös, hiszen nagyon valószínű, hogy sok ilyen objektum befér a processzor cache-ébe, ami gyorsabb elérést biztosít, mint a Ram-ból történő adatkinyerés.

Többszálúság kezelése

Utolsó blokként még néhány szót ejtenék a többszálú programoknál lévő optimalizációs lehetőségekről. De mielőtt még belemennénk a mechanizmusokba, nézzük azt, milyen kockázatot is hordoz magában a többszálúság a GC szempontjából.

Ha van egy többszálú alkalmazásunk, akkor azt meg kell gátolnunk, hogy ezek a szálak hozzáférjenek a managed heap-hez mindaddig, amíg a GC tevékenykedik. Hiszen az átrendezéskor az egyes mutatók értekei is megváltoznak. Tehát amíg a GC egy külön szálon fut, addig a több szál működését fel kell függeszteni, és az overhead-et minimálisra kell csökkenteni. Erre sokféle mechanizmus létezik, a teljesség igénye nélkül, íme, néhány:

–  Teljesen megszakítható kód: Amikor a GC elkezd dolgozni, akkor az összes többi szál felfüggesztődik. Viszont ahhoz, hogy utána folytatni lehessen őket a JIT (Just-In-Time) fordító által kezelt táblákban el kell tárolnia azt, hogy az adott szál éppen hol tartott egy adott metódusban, mely objektumokat használta és azok hogyan voltak elérhetőek (változó, regiszter, stb.). Ha ezek az infók megvannak, akkor a GC lefutása után adatvesztés nélkül folytatható tovább az alkalmazás.

Szál-eltérítés (Hijacking): Mivel a GC eléri és módosítani is tudja a szál stack-jét, ezért az éppen aktuálisan végrehajtott függvény visszatérési pontját átirányíthatja egy speciális függvényre. Ha az éppen futó metódus végére ér a szál, meghívódik a speciális függvény, mely felfüggeszti a szál tevékenységét a GC idejére. A takarítás után pedig visszaadja a vezérlést a szálnak.

Érdekesség: Hijacking esetén unmanaged code futtatása lehetséges párhuzamosan a GC futtatásával mindaddig, míg nem akar managed objektumhoz hozzáférni.

Mentési-pontok: A JIT fordító képes elhelyezni a metódus belsején belül olyan speciális függvényhívásokat, melyek azt ellenőrzik, hogy a GC nem várakozik-e. Ha igen, akkor altatja a szálat, majd a lefutása után felébreszti. Azokat a helyeket, ahova beszúrja ezeket a speciális függvényhívásokat a JIT, mentési pontnak nevezzük.

További lehetőségek többprocesszoros rendszerek esetén:

Szinkronizáció-mentes allokálás: Többprocesszoros rendszerek esetén a managed heap 0. generációja felosztható annyi részre, ahány processzor található az adott rendszerben. Ebben az esetben ezek szabadon, párhuzamosan végezhetnek allokációt, mindenféle szinkronizáció és zárolás nélkül.

Skálázható szemétgyűjtés: Többprocesszoros szerverek esetén nem csak a managed heap-et lehet megosztani, hanem magát a GC-t is. Ez azt jelenti, hogy mindegyik CPU a saját kis memória részét maga kezeli, és azon futtatgatja időről időre a nagytakarítást. Ebben az esetben a GC egy speciális változatáról beszélünk, mely a SrvGC névre hallgat és az MSCorSrv.dll-ben található. A „normál” változat mellesleg a WksGC nevet kapta és az MSCorWks.dll-ben lakik.

Összefoglalás

A cikk elején megismerkedhettünk a memóriakezelés szemétgyűjtést nem használó módszereinél leggyakrabban előforduló hibákkal, majd megnéztük, hogy a GC ezeket hogyan képes orvosolni. Ezt követően utánajártunk annak, hogy milyen feltakarítási mechanizmusok léteznek és azoknak mik az előnyei, illetve a hátrányai. A cikk második felében pedig jó néhány trükköt megnéztünk arra, hogy miként lehet kicselezni a GC-t, például újraélesztés, gyenge referenciák, stb. segítségével. Végezetül pedig betekintést nyerhettünk abba is, hogy a GC-t miként lehet optimalizálni a jobb teljesítmény elérése érdekében.

C# dokumentációs kommentek

Posted: 2011. február 11. in C#

/Ez a cikk egy idei egyetemi beadandóm kapcsán készült! /

A programozók alapjában véve lusták, és nem az önkifejezés nagy mesterei. Emiatt nem is igazán rajonganak a dokumentációkészítésért. Mindemellett még az is igaz, hogy a dokumentáció írásakor nem a jól bevált fejlesztő környezetükben kell dolgozniuk, hanem egy olyan helyen, ahol például nincsen az eszköztáron Build gomb (szövegszerkesztő). Emiatt mindig az utolsó utáni pillanatra halasztják a szoftver dokumentációjának elkészítését.

Nos, a dokumentációs kommentek ezen a helyzeten próbálnak meg javítani. Minden programozó használ többé-kevésbé kommenteket, hogy az általa előállított kódrészlet a későbbiekben is dekódolható legyen, vagy mások számára (itt fejlesztőre kell gondolni, nem átlag halandóra) is olvasható, érhető legyen. Miért ne lehetne ezeket felhasználni a dokumentációkészítéshez? A válasz nagyon egyszerű, mivel mindenki másképp csinálna. Ezért a Microsoft ezen úgy próbál meg segíteni, hogy ad egy ajánlást arra, hogy szerinte minkét kéne kinéznie a kommenteknek ahhoz, hogy utána lehessen belőlük dokumentációt generáltatni. Ehhez
egy speciális formátumra van szükség, amelyet a szakirodalomban dokumentációs kommenteknek neveznek.

A kommentektől a kész dokumentációig

A dokumentációs komment feladata egyfelől az egységes kinézet/felület biztosítása a kommentekhez, másfelől pedig a hierarchiába szervezhetőség. Utóbbit nézzük meg részletesebben.

Általában amikor egy osztályt felkommentezzünk, akkor megadunk az osztályról egy általános leírást, amely röviden összefoglalja az általa megvalósított funkcionalitást. Másrészről az egyes függvényekhez, illetve metódusokhoz is meg szokás adni egy-egy részletesebb meghatározást. Vagyis az objektumok és a tagjaik közötti kapcsolatot célszerű a kommentek szintjén is eltárolni.

Egy másik fontos kapcsolat, amely szintén megjelenik a kommentek szintjén is, az az objektumok közötti hierarchia. Például hivatkozunk egy másik osztályra, amely használja ezt (akár példakód megadásával is), vagy fordítva, egy olyanra hivatkozunk, amelyet ebben szeretnénk felhasználni. Tehát ezeket a kapcsolatokat is célszerű eltárolni, az egymásra történő hivatkozhatóság miatt.

A Microsoft ahhoz, hogy a fentebb említett két fontos tulajdonságot (vagyis az egységes felületet, és a hierarchiába szerverhetőséget) be tudja vezetni a kommentek szintjére, úgy gondolta, hogy az XML nyelv használatára van itt is szükség. Ugyanis az XML-nél az egységes felületet könnyen lehet garantálni séma vagy definíciós fájlok segítségével, illetve az XML fájlok alapból hierarchikus felépítésűek.

Az XML nem csak a kommentek tárolásánál jelenik meg, hanem már forráskódban is (vagyis magunkban a kommentekben is). Ugyanis így könnyen lehet garantálni az egységes kinézetet már kód szinten is, másrészt pedig a forráskód elemező is így egyszerűbben elő tudja állítani a kommentekből a megfelelő XML fájlt. A Microsoft részéről itt véget is ér a sztori. Vagyis a kezünk be ad egy reprezentációs modellt (hogyan kommentáljunk), illetve egy a modellnek megfelelő leírást (a generált XML fájlt). Az, hogy ezzel mi ezek után mit kezdünk, az már a mi dolgunk – állítja az MS.

Formálisabban megfogalmazva az előző bekezdés végét: a rendszer biztosítja számunkra a dokumentációs kommenteket, illetve a dokumentáció generátort, de a dokumentáció megjelentő már a fejlesztő feladata. Erre az álláspontra (érhetetlen okból) a .NET 2.0 és a Visual Studio 2005 megjelenésekor tértek át. Ugyanis azelőtt az 1.X-es verzióknál az IDE-ben még volt támogatás dokumentáció
megjelenítésre, úgynevezett web report formátumban. Sajnos ezt a 2005-ös változatból már kihagyták.

De hogy még se maradjunk megjelenítő eszköz nélkül a Microsoft nem sokkal a 2005-ös fejlesztő környezet megjelenés után nyilvánossá tette az általuk belsőleg is használt dokumentáció megjelenítőt, amely a SandCastle névre hallgat. Ezzel az eszközzel részletesebben is fogunk még foglalkozni a későbbiekben, ezért előjáróban csak annyit, hogy nem egyszerű használni. Ezért megjelentek a piacon olyan eszközök,
amelyek teljes körű dokumentációkezelési támogatást nyújtottak, ilyen volt például a DoxyGen. (Ez így nem teljesen igaz, ugyanis a DoxyGen már előbb jelen volt, csak azelőtt a C# nyelvet nem támogatta).

Alap dokumentációs kommentek

Ennyi bevezető után, lássuk hogyan is néznek ki ezek a dokumentációs kommentek. Az egysoros dokumentációs komment három darab jobbra dőlő perjellel kezdődik, míg a többsoros változata ennek egy jobbra dőlő perjellel és két csillaggal kezdődik és egy csillag + egy perjel párossal záródik. Nézzünk ezekre egy egyszerű példát:

/// Egysoros dokumentációs komment
/**
* Többsoros dokumentációs
* komment
*/

Utóbbit ritkán szokás használni, inkább az egysoros változat kezdő szimbólumait szokták ismételgetni. Amit ezekről még fontos megjegyezni itt az elején az az, hogy úgy kell őket használni, mint az attribútumokat, vagyis mindig az adott osztály, metódus, stb. fölé kell elhelyezni. Íme, egy konkrét példa:

/// <summary>
/// Ezt az osztályt 2D-s pontok leírására használjuk
/// </summary>
public class Point
{

Mint, ahogyan azt már fentebb is említettem, illetve, ahogyan azt a kódrészlet is mutatja, XML elemek segítségével történik a dokumentációs kommentek megadása. A summary az egyik legegyszerűbb, amely egy általános szöveges összefoglalás megfogalmazását teszi lehetővé.

Két dolgot jegyeznék még meg, mielőtt belekezdenék az ajánlott dokumentációs kommentek felsorolásába. Az egyik, hogy mivel XML alapú a rendszer, ezért szabadon bővíthető. A másik dolog szintén az XML alapúságból fakad: a generikus típusoknál különös elővigyázatossággal kell eljárni, ugyanis az xml elemek leírásához használt forma megegyezik a generikus paraméterek megadásának módjával. Ennek kivédésével a megfelelő dokumentációs kommentek résznél fogunk foglalkozni.

A dokumentációs kommenteket általában két nagy csoportba szokás osztani. Az egyiket magyar fordításban elsődleges, a másikat pedig másodlagos csoportnak lehetne nevezni. Az elsődleges csoportba olyan XML tag-ek tartoznak bele, amelyek a komment szintjén használhatóak. A másodlagos csoportba pedig azok kaptak helyet, akiket nem lehet önmagunkban használni, hanem be kell őket csomagolni, ágyazni egy elődleges kommentbe. Eme csoportosítás alapján a 22 alap dokumentációs komment:

– Elsődleges csoport: <example>, <exception>, <include>, <param>, <permission>, <remarks>, <returns>, <seealso>, <summary>, <typeparam>

– Másodlagos csoport: <c>, <code>, <description>, <item>, <list>, <listheader>, <term>, <typeparamref>, <para>, <paramref>, <see>, <value>

Egy másfajta szemléletmód szerint történő kategorizálása a kommenteknek a funkció szerinti csoportosítás (természetesen egy komment akár több funkcionális csoportba is beletartozhat). Ezek alapján az alábbi kategóriákat lehet megkülönböztetni: szövegformázó tag-ek, lista/táblázat készítő tag-ek, hivatkozás leíró tag-ek, általános leíró tag-ek, paraméter leíró tag-ek, és függvény leíró tag-ek. Íme, ezek alapján a 22 alap dokumentációs komment csoportosítása:

– Szövegformázó tag-ek: <c>, <code>, <param>

– Lista/Táblázat készítő tag-ek: <list>, <listheader>, <item>, <term>, <description>

– Hivatkozás leíró tag-ek: <see>, <seealso>, (<include>)

– Általános leíró tag-ek: <summary>, <remarks>, <value>, <example>

– Paraméter leíró tag-ek: <param>, <paramref>, <typeparam>, <typeparamref>

– Függvény leíró tag-ek: <returns>, <exception>, <permission>

A dokumentációs kommenteket a funkcionális csoportosítás szerint haladva fogom bemutatni.

<c>

Ez egy olyan formázó tag, amely segítségével inline vagy egysoros (tehát viszonylag rövid) kódrészlet stílusú szöveget lehet elhelyezni a kommentben. Vagyis a nyitó és záró tag-ek közé elhelyezett szöveg olyan betűtípust (Courier New-t) fog használni, mint amilyen a forráskód betűtípusa. Ezt általában osztály vagy metódus nevek formázására, illetve egysoros utasítások beszúrására szokták használni a kommenteken belül. Íme,egy egyszerű példa:

/// <remarks>
/// Ez az osztály a <c>Singleton</c> osztályból van származtatva
/// </remarks>
public class DataAccessLayer: Singleton

(A remarks tag osztályok általános leírására szolgáló tag, lásd lentebb bővebben.)

<code>

Ez a formázó tag nagyon hasonlít a c tag-re, a kettő között az a különbség, hogy a code-t többsoros forráskódok idézésére is használhatjuk. Általában az example (példaadásra használt leíró) tag-gel együtt szokott előfordulni a kommentekben. Íme, egy egyszerű példa a használatára:

/// <example> Mivel ez egy egyke osztály, ezért az elérése az alábbi módon történik:
/// <code>
/// Product p = DataAccessLayer.GetInstance().GetProductById(1);
/// DataAccessLayer.GetInstance().UpdateProduct(originalproduct, modifiedproduct);
/// </code>
/// Tehát nincs szükség külön példányosításra!
/// </example>

(Természetesen az example tag-ek köré is kell tenni egy-egy remark tag-et.)

<para>

Ez egy olyan formázó tag, amely hosszú leírások esetén használatos, ugyanis ennek segítségével lehet paragrafusokat létrehozni. A html-es p tag-re hasonlít leginkább. Általában a remark és a summary tag-eken belül szokás használni. Íme, egy egyszerű példa:

/// <remarks>
/// Ez az osztály az alábbi funkcionalitásért felelős:
/// <para> Termékek elérése. Ide az alábbi fgv-ek tartoznak: …</para>
/// <para> Termékek módosítása. Ide az alábbi fgv-ek tartoznak: …</para>
/// <para> Új termék felvitele, melyet a <c>CreateProduct</c> meghívásával lehet végrehajtatni.</para>
/// …
/// </remarks>

<list>

Ezen lista/táblázat készítő tag segítségével számozást (számozott listát) vagy felsorolást (pontozott listát) lehet létrehozni, illetve akár táblázatot is. Azt, hogy éppen melyikre van szükségünk azt a type attribútumon keresztül tudjuk beállítani, amely az alábbi értékek valamelyikét veheti fel: bullet, number, table. Az egyes elemeket az item tag-ek segítségével lehet leírni, amelyek általában egy term és egy description tag-ből állnak. Van lehetőség címke (listákhoz) vagy fejlécsor (táblázathoz) megadására is a listheader tag segítségével, melynek felépítése megegyezik az item-éval. A list tag segítségével tehát úgy nevezett definíciós listát/táblázatot lehet létrehozni, vagyis megmondhatjuk, hogy mi (term), mit jelent (description). Íme, egy egyszerű példa (az előző példa táblázatos változata):

/// <list type=”table”>
/// <listheader>
/// <term>Művelet típusa (CRUD)</term>
/// <description>A konkrét műveletek nevei</description>
/// </listheader>
/// <item>
/// <term>Create</term>
/// <description>CreateProduct, CreateProducts</description>
/// </item>
/// <item>
/// <term>Retrieve</term>
/// …
/// </list>

<listheader>

Ezen tag segítségével a táblázatokhoz fejlécsort, vagy listákhoz címkét/fejlécet lehet megadni. A html table objektumának th tag-jével hozható analógiába. Fontos: csak a list-tel együtt használható. Példa: lásd list
példa.

<item>

Ez a tag arra szolgál, hogy egy lista egy elemét vagy egy táblázat egy sorát leírjuk vele. Alapból két alkotó elemből tevődik össze egy term-ből (mi) és egy description-ből (mit jelent). De van arra is lehetőség, hogy csak a description használjuk! A html table objektumának tr tag-jével hozható párhuzamba. Példa: lásd list példa.

<term>

Ezen tag megadása nem kötelező, de ha megadjuk, akkor azt mondja meg, hogy mihez találunk leírást a description tag-ben. Példa: lásd list példa.

<description>

Ezen tag egy táblázat- vagy egy listabeli elem leírását tartalmazza. Használható a term-mel együtt, illetve nélküle is. Mindig egy item tag-en belül kell lennie. Példa: lásd list példa.

<see>

Ez a tag a hivatkozások leírására szolgál. Egy másik osztályra, vagy egy metódusra történő hivatkozáskor, a cref attribútumon keresztül kell megadnunk annak a nevét. A tag manifesztációja a dokumentációban egy hiperlink lesz. Íme, egy egyszerű példa:

/// <summary>
/// A fgv. ellentett párja: <see cref=”ConvertBack” />
/// </summary>
public object ConvertTo(object value, Type …

/// <summary>
/// A fgv. ellentett párja: <see cref=”ConvertTo” />
/// </summary>
public object ConvertBack(object value, Type …

<seealso>

Ezzel a tag-gel a dokumentáció „See also” (~„Lásd bővebben”) részébe tudunk elhelyezni linkeket. A felépítése teljesen megegyezik a see tag-ével.

/// <seealso cref=”UpdateProduct” />
/// <seealso cref=”CreateProduct” />
public void CreateOrUpdateProduct …

<include>

Ez a tag is a hivatkozás leíró tag-ek csoportjába sorolandó, de itt a hivatkozás alatt kicsit mást értünk, mint a see, vagy a seealso esetében. Ezzel egy XML dokumentumra (vagy annak részlétére) tudunk hivatkozni, amelyet a dokumentáció generálásakor a fordító behelyettesít a megfelelő xml tartalommal. A hivatkozás szintaktikája a következő: a file attribútum szolgál az xml dokumentum relatív elérési útjának megadására, a path opcionális attribútum pedig egy XPATH lekérdezést tartalmazhat. Íme, egy egyszerű példa:

/// <summary> Az alábbi adatbázisok valamelyikéhez kapcsolódhat (teljes connstring-ek!)
/// <code>
/// <include file=”web.config” path=’configuration/connectionstrings/*’ />
/// </code>
/// </summary>
public void Connect2ADataBase()

<summary>

Ez az egyik legáltalánosabb célú dokumentációs tag. Használható önmagában is, illetve konténerként is. A Microsoft ajánlása szerint a függvényeket és a metódusokat ezzel a tag-gel célszerű ellátni, és itt röviden összefoglalni az általuk megvalósított funkcionalitást. Példa: korábbi példák közül jó néhány.

<remarks>

Ez a tag nagyon hasonlít a summary-ra, a különbség a kettő között az, hogy míg a summary függvényekhez és metódusokhoz lett kitalálva, addig a remarks osztályoknál és interfészeknél használandó. A Visual Studio mindenhol alapból a summary-t ajánlja fel (a /// beírása után automatikusan beszúrja a forráskódba), ezért ezt kézzel át kell írni típusok kommentezése esetén. Példa: korábbi példák közül jó néhány.

<value>

Ez a tag az osztály tulajdonságainak felkommentezésére használható. Itt célszerű röviden összefoglalni, hogy mit és miért tárolunk el ezekben a tulajdonságokban, illetve adott esetben a módosíthatósági megkötésekre is fontos felhívni a figyelmet (pl.: csak olvasható). Íme, egy egyszerű példa:

/// <value>A csatlakozott kliensek számát adja meg. Csak lekérdezhető.
/// Új kliens felvételekor ez a szám automatikusan megnő eggyel. (<c>AddClient</c>)</value>
public int Clients { get; private set;}
public void AddClient (Client c)

<example>

Ez a tag általában a code-dal együtt használatos. Eme dokumentációs kommenttel példakódot adhatunk az adott osztály vagy függvény használatára, elérésére. (Az example tag alapból a tartalmát nem forráskód stílusban jeleníti meg, ezért van szükség a code tag-re). Példa: lásd code példa.

<param>

Ez a tag a függvény paramétereinek kommentezésére szolgál. Itt azt szokás megadni, hogy mit reprezentál az adott paraméter, módosítjuk-e a paraméter értéket a metódusban, illetve van-e a bemenő értékre valamilyen megkötés. Íme, egy egyszerű példa

/// <param name=”originalproduct”>Az eredeti termék entitás</param>
/// <param name=”modifiedproduct”>A módosított termék entitás</param>
public void UpdateProduct(Product originalproduct, Product modifiedproduct)

<paramref>

Ezt a tag-et akkor szokás használni, ha egy függvény paraméterére szeretnénk hivatkozni valahol a kommentben (például valahol a summary-n belül). A hivatkozott paraméter nevét a name attribútumon keresztül lehet megadni. Íme, egyszerű példa (az előző példa kibővítése):

/// <summary>
/// Először összehasonlítja az <paramref name=”originalproduct” /> és a <paramref name=”modifiedproduct” /> értékeket, és ezek alapján csak a deltát küldi fel a szervernek.
/// </summary>
/// <param name=”originalproduct”>Az eredeti termék entitás</param>
/// <param name=”modifiedproduct”> …

<typeparam>

Ez a tag a generikus paraméterekhez történő megjegyzésfűzésre használható. Itt célszerű leírni, hogy az adott paraméterrel mit szeretnénk csinálni, illetve van-e rá valamilyen megkötés. A typeparam egyaránt használható generikus függvényeknél és generikus osztályoknál is. Íme, egyszerű példa:

/// <typeparam name=”T”>A pár első elemének típusa</typeparam>
/// <typeparam name=”U”>A pár második elemének típusa</typeparam>
pubic class Pair<T, U>

<typeparamref>

Ezen tag segítségével lehet kommentben generikus paraméterre hivatkozni. A hivatkozást a name attribútumon keresztül kell megadni. Fontos, ha magára a generikus osztályra vagy függvényre szeretnénk a kommentben utalni, akkor nem használhatjuk a <,> jeleket, mivel az összezavarná az XML értelmezőt. Emiatt használjunk inkább a {} zárójeleket, vagy a html-nél is használt &lt; &gt; párost. Íme,egy egyszerű példa:

/// <summary>
/// A <typeparamref name=”T” /> típusparaméter alapján először meghatározza a táblát, majd utána lekéri belőle az összes adatot, végül pedig egy listában adja őket vissza.
/// </summary>
/// <typeparam name=”T”>Az entitás neve</typeparam>
public List<T> RetriveAllData<T>()

<returns>

Ezzel a tag-gel a függvények visszatérési értékéhez lehet kommentet fűzni. Úgy is fogalmazhatunk, hogy a param ellentett párja, csak itt nincs returnsref. Általában itt a visszaadott típusban történő reprezentációról szokás egy-két szót ejteni. Íme, egy egyszerű példa:

///<returns>Egy termék szöveges reprezentációja kulcs-érték párok felsorolásával, pontosvesszőkkel elválasztva.</returns>
public override string ToString()

<exception>

A Java-val ellentétben, ahol kötelező kiírni a függvény által le nem kezelt kivételek teljes listáját a szignatúránál, C#-ban erre nincsen se mód, se lehetőség. Viszont ez egy eléggé hasznos dolog, ezért célszerű mindenesetben (még ha nem is készül dokumentáció a kommentekből, akkor is) az exception tag-et használni. (Illetve a lekezelt kivételekre is érdemes így felhívni a figyelmet). A cref attribútumon keresztül kell megadni a lehetséges Exception osztály nevét. A nyitó és a záró tag-ek közé pedig 2 információt ajánlott leírni: mikor keletkezhet ez a kivétel, és levan-e kezelve. Íme, egy egyszerű példa:

/// <exception cref=”System.DivideByZeroException”>
/// Ha az <paramref name=”y” /> értéke 0, akkor ilyen kivétel dobódik, de le van kezelve.
/// </exception>
/// <param name=”x”>Osztandó</param>
/// <param name=”y”>Osztó</param>
public double Divide (int x, int y)

<permission>

Ezzel a kommenttel azt írhatjuk le, hogy egy adott függvény milyen jogok mellett hívható meg, vagy egy adott típus milyen jogok mellett használható. Ha van rá bármilyen megkötés csak akkor van értelme ennek a kommentnek. Íme, egy értelmetlen példa:

/// <permisson cref=”System.Security.PermissionSet”>
/// Ezt a függvényt bárki meghívhatja</permission>
public void PublicMethod()

XML generálás

A dokumentációs kommentekkel való ismerkedés után folytassuk tovább az utunkat a dokumentációgenerálásával. Ez a név kicsit félrevezető lehet, ugyanis itt nem arról van szó, hogy egy kész chm, vagy html alapú dokumentációt generáltatunk le a rendszerrel, hanem ebben a terminológiában azt értjük ez alatt, hogy a dokumentációs kommentekből egy xml dokumentum generálódik, amely egy helyen és egységes ábrázolja a kommenteket. Vagyis ez a dokumentáció modellje, ha úgy vesszük, ahonnan a dokumentáció megjelenítő majd kinyeri az adatokat.

Tehát ebben a részben az XML dokumentum előállításával és felépítésével fogunk foglalkozni. A dokumentációs kommentekből az XML fájl előállításához mindösszesen a C# fordítóra van szükségünk. Ennek van egy olyan speciális kapcsolója, amellyel az adott cs forrásállományból képes előállítani a megfelelő dokumentációs modellt. Ez a kapcsoló a /doc. Íme, egy egyszerű példa, amelyet a VS Command Promt-ból lehet kiadni:

csc Pair.cs /doc: PairDocumentation.xml

Ennek persze van egy egyszerűbb és kényelmesebb módja is. Visual Studio-ban a projekt-en jobb egér gombbal kell kattintatni és ott a helyi menüből a Properties lehetőséget kell kiválasztani. Az új ablakban a Build fülre kell átváltani, majd ennek az Output szekciójában meg kell keresni az XML documentation file jelölőnégyzetet. Ezt pipálja be! Ilyenkor alapból a lefordított dll mellé helyezi el az xml fájlt, vagyis bin/debug
mappába.

Ezek után nézzük meg, hogy mit is tartalmaz egy ilyen XML fájl. Íme, egy egyszerű példa:

A forrásállomány: Pair.cs

/// <summary>
/// Ezen osztály segítségével egy párt lehet reprezentálni
/// </summary>
/// <typeparam name=”T”>A pár első elemének típusa</typeparam>
/// <typeparam name=”U”>A pár második elemének a típusa</typeparam>
class Pair<T, U>
{
    public Pair(T t, U u)
    {
        First = t;
        Second = u;
    }
    public T First { get; set; }
    public U Second { get; set; }
}

A belőle generált modell: PairDocumentation.xml

<?xml version=”1.0″?>
<doc>
    <assembly>
        <name>DocComDemo</name>
    </assembly>
    <members>
        <member name=”T:DocComDemo.Pair`2″>
            <summary>
            Ezen osztály segítségével egy párt lehet reprezentálni
            </summary>
            <typeparam name=”T”>A pár első elemének típusa</typeparam>
            <typeparam name=”U”>A pár második elemének típusa</typeparam>
        </member>
    </members>
</doc>

A generált XML állományban minden dokumentációs komment kap egy egyedi azonosítót, egyfelől az egymásra történő hivatkozás megkönnyítése végett, másrészt pedig a dokumentáció megjelenítőnek is szüksége van erre. Ennek az egyedi azonosítónak az előállítása egy viszonylag hosszú algoritmus alapján történik. Ezért az algoritmussal nem untatom a nagyérdeműt (akit esetleg érdekel, az megtalálhatja az MSDN-en), viszont a fentebbi kódrészletbeli példán keresztül megmutatom, mennyi információ tárol el a rendszer csak az azonosítókban.

A T:DocComDemo.Pair’2 részei:

T: A dokumentációs komment egy típushoz tartozik. Ha például egy metódushoz tartozna, akkor M lenne, vagy ha egy eseményhez, akkor pedig E.

DocComDemo.Pair: A teljes neve, annak az objektumnak, amelyhez a kommentet fűztük. (Metódusok esetén az esetleges paraméterek típusa is fel lenne itt sorolva.)

’2: 2 generikus paramétere van az osztálynak.

XML értelmezése, avagy a Dokumentáció megjelenítő

Végezetül elérkeztünk a dokumentáció megjelenítőhöz, amely értelmet ad a modellben tárolt adatoknak. Mint, ahogy azt a bevezetőben is említettem, alapból nem tartozik ilyen eszköz a Visual Studio-hoz, ezért ezt külön kell beszerezni. A Microsoft ajánlása erre a saját fejlesztésű SandCastle nevezetű program, amelyet az alábbi címről lehet letölteni: http://sandcastle.codeplex.com/.

A szoftver feltelepítése után úgy tudjuk használni a programot, hogy elindítjuk a hozzá tartozó grafikus felületet. Sajnos ez alapból nem jelenik meg a Start menüben, ezért nekünk kell megkeresünk és futtatnunk adminisztrátori jogokkal. Alapból az alábbi elérési úton keresztül érhetjük el: C:\Program Files\Sandcastle\Examples\Generic\SandcastleGui.exe.

A felületen keresztül meg kell adni az dokumentációs kommentekkel ellátott alkalmazás, vagy osztály könyvtár lefordított exe-jét, vagy dll-jét. Ezenkívül a C# fordító által generált XML fájlt is meg kell adni, illetve az esetleges + osztálykönyvtárakat is, amelyeket külön adtunk a projekthez hozzá. Ezek után be kell még állítanunk a dokumentáció nevét, a megjelenítési stílusát (alapból vs2005, de a prototype is egész jó), illetve a leendő formátumát (chm, sima html, vagy hxs). Végezetül már csak rá kell kattintani a Build gombra, ahol először el kell menteni a projektet és csak utána kezdődhet meg a tényleges munka. A végleges dokumentáció elkészítésének ideje nagyban függ a projekt méretéttől, de az általánosságban elmondható róla, hogy nem sieti el a dolgot.

Fordítás közben töménytelen log információt kapunk, amelyek viszont nem túl sok hasznos információval szolgál. Ha elsőre nem generálja le rendesen a dokumentációt, akkor célszerű letölteni a SandCastle-höz készült kis segédalkalmazást, amely egyrészt a rengeteg log-ot képes érhető, emészthető formába átkonvertálni, illetve nagyobb testreszabhatóságot is biztosít. Az alábbi címről tölthető le a SandCastle Help
File Builder
nevezetű program: http://shfb.codeplex.com/.

Javaslat

A dokumentációs kommentek használata mindenféleképpen javaslott, ugyanis így átláthatóbb lesz tőle a programkód, illetve lehet belőle dokumentációt gyártani és még az IntelliSence is fel tudja használni!