Attribútumok vs Marker Interfészek

A .NET-ben attribútumokat lehet használni arra, ha egy assembly-hez, osztályhoz, osztály-member-höz vagy egyéb más dologhoz információkat szeretnénk hozzárendelni. Ezek az információk lehetnek összetettebbek, de lehetnek egészen egyszerűek is, amelyek például csak megjelölnek egy osztályt. Ilyen jelölő vagy marker attribútummal már mindenki találkozott, ha mással nem is, a [Serializable] attributmmal biztosan. A [Serializable] semmit nem csinál az általa megjelölt osztállyal, az egyetlen feladata, hogy jelezze, az osztály készítője átgondolta az osztályt, és készen áll arra, hogy a serializációt végző mechanizmusok dolgozzanak rajta.

Vannak programozási nyelvek, ahol nincsenek a .NET attribútumokhoz hasonló lehetőségek, viszont jelentkezik igény egy osztály megjelölésére. Ekkor, ha a nyelv támogatja például az interfészek használatát, egy áthidaló megoldás lehet, ha készítenek egy üres interfészt, és az adott típusú osztály az üres interfészt “megvalósítja”. Az ilyen célból használt interfészeket marker interfésznek hívják.

Előfordul azonban, hogy .NET esetében is találkozik marker interfészekkel az ember, annak ellenére, hogy attribútumokkal ugyanaz kifejezhető – ráadásul koncepcionálisan az attribútumok használata sokkal szebb. Miért használnak akkor egyes esetekben marker interfészeket .NET-ben?

Az indok, ami ilyenkor elhangzik, az az, hogy sokkal gyorsabb egy osztályról vagy egy osztály példányáról eldönteni azt, hogy egy marker interfészt megvalósít, mint azt, hogy egy attribútum hozzá van-e rendelve. Ennek az indoknak fogunk utánajárni a következőkben, először mérésekkel, majd pedig az interfészek és attribútumok mögötti mechanizmusokat feltárva.

Mit mérünk?

Az marker interfészek lekérdezését két fő esetre oszthatjuk. Az egyik esetben van egy osztálypéldány, a másikban nincs, csak az osztály típusa. Az első eset elvileg visszavezethető a másodikra, ha a példánynak le tudjuk kérdezni a típusát, ami a .NET-ben mindig lehetséges. Ugyanakkor most előre megsejtjük, hogy egy példánnyal a kezünkben előnyt tudunk szerezni, emiatt nem vonjuk össze a két esetet. Attribútumot csak osztály típuson keresztül lehet lekérdezni, így itt nincs értelme a kétféle mérésnek.

A .NET esetében többször láthattuk, hogy csak akkor végez el feladatokat, amikor szükség van rá. Ilyen például a függvények fordítása (jittelés), vagy a statikus osztályok inicializálása. Ezek miatt azt is feltételezzük, hogy egy első és egy második lekérdezés között időkülönbségek lehetnek. A fentiek alapján hat mérésünk lesz, a 2×2 az interfészek esetében, és 2 az attribútumok esetében.

A mérésekben résztvevő osztályok

Szükség lesz egy marker attribútumra és egy marker interfészre. Ezek kódja a következő:

[AttributeUsage (AttributeTargets.Class)]
public class MarkerAttribute : Attribute
{
} // class MarkerAttribute

public interface IMarker
{
} // interface IMarker

A mérést a következő felépítésű osztályokon végezzük:

[Marker]
public class Marked1 : IMarker
{
} // class Marked1

Ebből a típusból 100 variációt fogunk használni, Marked1-Marked100 néven. A mérések nem ciklus köré lesznek szervezve, hogy az amúgy elenyésző költségű ciklus szervezés se vigye az időt. Ettől egyébként nem feltétlenül lesz gyorsabb a kód, mivel a mai processzorok egy kis méretű ciklust miszlikekre szedve és kioptimalizálva esetleg gyorsabban tudnak futtatni, mint egy viszonylag nagy, lineáris lefutású kódot.

Az első méréshez létre kell hozni a 100 objektum példányt:

object o1 = new Marked1();
object o2 = new Marked2();
object o3 = new Marked3();
...

Majd utána elvégezni a marker interfészre vonatkozó mérést:

bool isMarked = true;

isMarked &= o1 is IMarker;
isMarked &= o2 is IMarker;
isMarked &= o3 is IMarker;
...

Az eredmények azért vannak egymásba szőve egy logikai ‘és’ művelettel, mert a jitter hajlamos kihagyni a kódból azokat a műveleteket, amikről látja, hogy az eredménye nincs felhasználva, és meg tudja állapítani, hogy a műveletnek mellékhatása sincs. Az ‘is’ operátor esetében fenn áll a gyanú, hogy ezt megtenné, lehetetlenné téve a mérésünket.

A második típusú mérésnél nem a példányokat fogjuk felhasználni, hanem típusokat. A típusok alapján kell megállapítani, hogy adott típus meg van-e jelölve az IMarker interfésszel. Az ehhez szükséges kód a következő:

bool isMarked = true;

Type IMarkerType = typeof(IMarker);

isMarked &= IMarkerType.IsAssignableFrom(typeof(Marked1));
isMarked &= IMarkerType.IsAssignableFrom(typeof(Marked2));
isMarked &= IMarkerType.IsAssignableFrom(typeof(Marked3));
...

Mérési eredmények

Interfészek esetén a mérési eredmények száz típusra a következőek:

operator 'is' 1. előfordulás: 0.0041 ms
operator 'is' 2. előfordulás: 0.0041 ms
IsAssignableFrom hívás 1. előfordulás: 0.0926 ms
IsAssignableFrom hívás 2. előfordulás: 0.0097 ms

Látható, hogy az időeredmények nagyon kicsik, felmerül a kérdés, hogy ilyen szűk időintervallumot lehet-e egyáltalán jól mérni. A kérdés teljesen jogos, valójában két közvetlen egymás után mért időpillanat eredménye a mérésre használt gépen 0.0020 ms, tehát az ‘is’ operátor mérési eredményének a fele (0.0020 ms) legnagyobb valószínűséggel nem a hasznos műveleteket írja le. Ettől függetlenül a nagyságrendek látszódnak, és később pontosan kibogozzuk, hogy mivel foglalkozik a processzor a műveletek elvégzése közben, így a cikk konklúziójához elegendő ez a pontatlan mérési adat is. Ami látszik, hogy az ‘is’ operátor másodpercenként minimum 25 millió (de inkább 50 millió) példányról el tudja dönteni, hogy megvalósít-e egy interfészt vagy nem. Az is látszik, hogy az ‘is’ operátor számára teljesen mindegy, hogy első ízben vizsgál egy példányt, vagy nem.

A második mért módszer Type objektumokat használ. A Type típusnak van egy IsAssignableFrom függvénye, ami lényegében azt vizsgálja, hogy az egyik típus a másikra cast-olható-e. Ez egy interfész esetében csak akkor igaz, ha a típus megvalósítja az interfészt. Látható, hogy az első előfordulás esetében a vizsgálatok egy nagyságrenddel lassabban futnak le, mint a második fordulóban. A második vizsgálatok viszont már csak 2-3 szor “lassabbak” az ‘is’ operátornál, ha másodpercenként tízmillió műveletet lehet lassúnak nevezni.

Bár harmadik megoldásként találkozhat néha az ember a Type.GetInterface()/GetInterfaces() hívásokkal, ezek annyira pazarlóak, hogy itt nem is foglalkozunk velük.

Attribútumok esetében a Type objektumot felhasználva le lehet kérdezni, hogy egy attribútum adott típuson definiált-e vagy nem:

bool isMarked = true;

Type MarkerAttributeType = typeof(MarkerAttribute);

isMarked &= typeof(Marked1).IsDefined(MarkerAttributeType, false);
isMarked &= typeof(Marked2).IsDefined(MarkerAttributeType, false);
isMarked &= typeof(Marked3).IsDefined(MarkerAttributeType, false);

A mérési eredmények a következők:

IsDefined hívás 1. előfordulás: 0.4794
IsDefined hívás 2. előfordulás: 0.3818

Ezek az eredmények két nagyságrenddel rosszabbak a marker interfésznél. Bár ez így nagyon csúnyának tűnik, ez még mindig 2-3 százezer ellenőrzés/másodpercet jelent, ami azért nem olyan lassú.

Mi okozhatja ezt a nagy különbséget a két módszer között? Egyik fellelhető indok az, hogy az ‘is’ operátornak saját IL utasítása van, ezért olyan gyors. Attribútumok esetében viszont a metaadatok között kell keresgélni, az pedig tudvalévő, hogy lassú. Ezek hirtelen hallásra jó magyarázatoknak tűnnek, azonban nem feltétlen igazak. Az, hogy valaminek külön IL utasítása van, nem jelenti egyben azt, hogy gyors is. Ha valaminek külön IL utasítása van, az csak annyit jelent, hogy vagy a többi utasítás segítségével az adott művelet nem lenne elvégezhető, vagy pedig annyira gyakori a művelet, hogy érdemes volt neki saját utasítást adni. Ez utóbbira példa az unbox/ldobj páros kiváltására szolgáló unbox.any IL utasítás, az előbbire pedig a newobj. A newobj a többi IL utasítással nem váltható ki, ugyanakkor egy lassú művelet, hiszen memóriafoglalással jár, esetleg egy szemétgyűjtést is beindít, utána pedig lefut egy konstruktorhívás. Az ‘is’ operátornak megfelelő IL-es isinst tehát nem garancia a sebességre.

Az sem egyértelmű, hogy a metaadatok közötti válogatás lassú. Néha a metaadatok között string egyeztetések után kell keresni, igen, ez lassú lehet. De önmagában egy táblázatban való keresés nem lassú, és az IsDefined() hívás nem biztos, hogy belső megvalósításában string-eket használ. A metaadatok táblázatai ráadásul rendezettek, így szerencsés esetben bináris keresés valósítható meg rajtuk, ami többezres rekordszámnál is csak 10-es nagyságrendű egyeztetést kíván. Ezeknek a kérdéseknek járunk utána a következőkben.

Mit csinál az ‘is’ operátor?

Tudjuk, hogy az ‘is’ operátornak saját IL kódja van, ugyanakkor ezt az IL kódot is le kell fordítania a Jitternek valamire, hogy a processzor végre tudja hajtani. Mi ez a valami? Nem akarok túl mélyre menni az assembly kódokban, de kiindulópontnak mégis nézzük meg saját szemmel, hogy IL kód ide vagy oda, az ‘is’ operátor nem valami különleges dolog:

;az "o is IMarker" Jittelt kódja:

mov         edx,dword ptr [ebp-0Ch] ; Függvényhívás második paramétere
mov         ecx,243070h             ; Függvényhívás első paramétere
call        6C5E5DC8                ; eax = f( a, b)
test        eax,eax                 ; eax == 0?

Mit csinál a kód? Egy közönséges függvényhívást láthatunk, amely két paramétert vesz át. A Jitter az ecx-ben szokta átadni az első paramétert, edx-ben a másodikat (osztálymember hívásnál így ecx általában a this pointer, de itt nem osztálymember-t hív). A függvény első paramétere a 243070. Mi lehet ez?

A .NET CLR erősen támaszkodik futás közben a típusinformációkra. Ez ma már triviális kijelentésnek tűnhet, de nem az. C++ esetében, amikor a programozó létrehoz egy új objektumpéldányt adott típussal, akkor a C++ program fordítása közben a fordító elrendez mindent, a lefordított program már csak annyi információt hordoz, hogy hozzon létre n-byte-ot a memóriában, és hívja meg az x címen levő függvényt, (mivel a fordító tudta, hogy az a konstruktor kódja). A C++ programok nagyszerűen megvannak futás közbeni típusinformációk nélkül is.

A .NET CLR teljesen más utat követ. Egy új objektum létrehozása például úgy történik, hogy minden típusnak van egy Handle-je (Type Handle), és ha új példányt kell létrehozni, akkor a típus Type Handle-jét kell megadni a CLR belső implementációjában az objektumok létrehozását végző komponensnek (ami egyébként egy C++-ban megírt függvény valahol az mscorlib.dll belsejében). Ez a kód a Type Handle alapján összeszedi a szükséges információkat (például mennyi memória szükséges) és ezután hozza létre a típusnak megfelelő objektumot.

Bár néhány forrás – köztük a nagy kedvencem, a CLR via C# – azt érzékelteti, hogy .NET-ben a Type Object (az objektum, amit a typeof(Alma) hívásával kapunk) jelenti a sarokkövet, a CLR-nek erre nincs szüksége. Annyira nincs szüksége rá, hogy legtöbb típushoz létre sem jön a Type Object. Akkor jön létre, ha mi a programban azt ilyen-olyan módon elkérjük, és ezt a jelenséget később látni is fogjuk. Tehát bár nekünk, .NET programozóknak, ha típusokkal foglalkozunk, akkor a Type Object a megfelelő kellék, a .NET CLR a Type Handle-t használja.

Mi ez a Type Handle? Első ránézésre csak egy szám. Ezt meg is szerezhetjük a következő sorral:

typeof(IMarker).TypeHandle.Value

A fenti sor eredménye egyébként két futás között is változhat, szóval egy dinamikus értékről van szó, egy futáson belül azonban az értéke változatlan. Ha akkor kértük le ezt a számot, amikor a fenti assembly kód készült, akkor 243070 lenne az eredmény. De mégis, hogyan használja a CLR ezt a számot?

A Type Handle-k két csoportra oszlanak. Vannak olyan típusok, amelyek egyébként nekünk, programozóknak a természetes típusoknak tűnnek, mint a Class-ok és Struct-ok. Ezekhez a típusokhoz tartozik egy metódustáblának (MethodTable) nevezett struktúra. Bár a neve szerény, ezek a metódustáblák mindent leírnak egy típusról, amit csak tudni lehet, nem csak a metódusokat. A típus metódustáblája leggyakrabban arra használt a CLR által, hogy egy típus függvényeit (virtuális függvények, interfészen keresztül meghívott függvények) helyét megtalálja a memóriában. De a metódustáblán keresztül érhetőek el egyéb struktúrák is, amelyek a típusra vonatkoznak. Például, ha egy típushoz létrejött már a Type Object (amit a typeof(tipus) tud visszaadni), akkor a metódustábla erre az objektumra is tartalmaz egy hivatkozást, így a Type Object is a metódustáblán keresztül érhető el. Ha ez a hivatkozás üres, akkor tudja a CLR, hogy létre kell hozni egy újat.

A típusok másik csoportjába tartoznak azok, amiket mi nem is nagyon szoktunk típusnak gondolni. Arról van szó például, amit egy “ref Alma” jelent a kódban, mint paraméter. Amikor egy függvény ref Alma típussal vár paramétert, a CLR-nek nyilván kell legyen fogalma arról, hogy a ref Alma az mi. Az, hogy mi, pedig egy Type Descriptor írja le. Type Descriptor írja le ezenkívül a pointereket, generikus típusokat, egyéb dolgokat. Számunkra ezek most nem lényegesek, csak azért lettek megemlítve, hogy lássuk a lényeget, ami a következő:

A Type Handle nem más, mint a típushoz tartozó leíró struktúra címe a memóriában. A tipusok egy részénél a Type Handle egy metódus tábla címét tartalmazza, a másik csoportban pedig egy type descriptor címét.

Az assembly kódban látott 243070 tehát nem más, mint egy metódus tábla címe, és erről meg is bizonyosodhatunk a Visual Studio Immediate ablakában, ha betöltjük az sos kiterjesztést. Az sos egyik parancsával ki lehet íratni adott metódustáblát:

!DumpMT 243070
EEClass: 002413f8
Module: 00242c5c
Name: isdebug.IMarker
mdToken: 02000002  (D:\Test\isdebug\isdebug\bin\Debug\isdebug.exe)
BaseSize: 0x0
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 0

Tudjuk tehát, hogy mi az ‘is’ operátorból a Jitter által fordított függvény első paramétere. A függvény második paramétere, ami az edx-be kerül, az annak az objektumnak a címe, amiről meg akarjuk tudni, hogy cast-olható-e a kérdéses (esetünkben IMarker) típusra. Ebben a pillanatban ott tartunk, hogy az ‘is’ operátor egy függvényre lett fordítva, két paraméterrel, olyanra, amilyeneket mi is írni szoktunk. A hívott függvény az eddigiek alapján következő szignatúrájú:

object f(IntPtr targetTypeHandle, object sourceObject)

Miért object a visszatérési érték, és miért nem bool? Az assembly kódból látható, hogy a program arra ellenőriz, hogy a függvény visszatérési értéke nulla-e vagy nem. Ettől még lehetne bool a visszatérési érték, és egyébkén ‘is’ operátor esetén ez volna logikus. Ezzel szemben ha futás közben végignyomkövetjük a kódot (vagy elolvassuk az isinst IL parancs dokumentációját), akkor látszik, hogy az eax-ben a második paraméter, tehát a sourceObject jön vissza. Abból, hogy az assembly kód nullára ellenőriz, illetve hogy más esetekben maga a sourceObject jön vissza, hozzáadva, hogy most az ‘is’ operátor kódjával állunk szemben, beugorhat egy ötlet: nem pontosan így működik az ‘as’ operátor?

De igen, és ki is próbálhatjuk, hogy tényleg ez a turpisság:

;bool a = o is IMarker

mov         edx,dword ptr [ebp-1Ch] ; o referencia értéke a veremről az edx-be
mov         ecx,243070h             ; IMarker type handle érték az exc-be
call        6C5E5DC8                ; f(IMarker, o)
test        eax,eax                 ; eredmény nulla?
setne       al                      ; al = 1, ha test eax,eax = 0 (azaz ha eax 0)
movzx       eax,al                  ; eax = 000000(al) (konverzio bool-ra)
mov         dword ptr [ebp-8],eax   ; 'a' változó = eax

;IMarker b = o as IMarker

mov         edx,dword ptr [ebp-1Ch] ; o referencia értéke a veremről az edx-be
mov         ecx,243070h             ; IMarker type handle érték az exc-be
call        6C5E5DC8                ; f(IMarker, o)
mov         dword ptr [ebp-20h],eax ; 'a' változó = eax (nincs konverzio bool-ra)

Tehát a meghívott függvény ugyanaz az ‘is’ illetve az ‘as’ operátor esetében is. Ha tudnánk mi ez a függvény, és elérhető lenne .NET alatt, akkor azt kézzel is meg lehetne hívni:

f(typeof(IMarker).TypeHandle.Value, o);

Észre kell azonban venni, hogy a Jitter jobb kódot fordít az ‘is’ operátorral, mint amit tenne a mi kódunkkal, mivel közvetlenül “tudja” az IMarker Type Handle-t, az általunk megírt hívás pedig elkér egy Type Object-et, és abból vadássza elő az értéket. Szó volt róla, hogy a Type Object ekkor esetleg még nem is létezik, de ha igen, akkor is a metódus táblából kell előszedni, így az álatlunk .NET-ben leírt hívás néhány indirekcióval lassabb, mire megszerzi a Type Handle-t. Másik oldalról az igazsághoz hozzátartozik, hogy a Jitter-nek a kód fordításakor ezt az értéket szintén elő kellett keresni az isinst IL utasítás paraméterének megadott metaadat hivatkozások alapján, ami ugyanúgy egyszeri teljesítményveszteséggel járt, mint a Type Object létrehozása – bár utána a lefordított kód már gyorsabb.

Most, hogy egyszerű függvényhívássá degradáltuk az ‘is’ operátort, nyomozzuk ki, hogy milyen implementáció van mögötte. Egyáltalán mik a lehetőségek? A CLR minden objektumpéldányhoz ismeri a típusinformációkat. Onnan ismeri, hogy ha van egy (referencia típusú) példány, akkor a példányt megtestesítő memóriaszelet egyik első eleme a Type Handle. Most már tudjuk, hogy a Type Handle ebben az esetben egy metódus táblára mutat, és megemlítettük, hogy a metódus tábla egy információkban gazdag táblázat a típusról.

A metódus táblákról az interfészek kapcsolatában már volt szó a Mire Optimalizálsz című cikkben. A cikk azt tárgyalta, hogy honnan tudja a CLR egy interfész típusú referencián keresztül, hogy milyen függvény implementációt kell meghívni. Akkor a metódus táblának egy olyan bejegyzését vizsgáltuk, amelyik az Interface Offset Table megfelelő helyére mutat. A mi esetünkben azonban nem kell elmenni az Interface Offset Table-ig. A metódus táblának ugyanis van egy része, amely a megvalósított interfészek Type Handle-jeit sorolja fel. Eddig tehát a következőket tudjuk:

A fenti kép bal felső sarkában van egy referencia. Ez a referencia lehet egy lokális változó vagy egy osztálypéldány egy mezője, ez teljesen mindegy. A referencia egy Marked típusú osztálypéldányra mutat. A (referencia típusú) osztálypéldányok jellemzően a heap (halom) memóriaterületen jönnek létre, ahol a példány mezői számára elegendő hely kerül lefoglalásra. Minden példány tartalmaz ugyanakkor egy plusz (a programozó számára láthatatlan) mezőt. Ez a mező a Type Handle, vagy közönséges osztálypéldányoknál mondhatjuk azt is, hogy metódus tábla pointer. Erről a témáról több olvasható az Érték típus, referencia típus – hogy is van ez című cikkben. A Type Handle (method table pointer) a típushoz tartozó metódustáblára mutat, ebben az esetben a Marked típus metódustáblájára. A metódus tábla egy összetett struktúra (valójában egy C++ osztály példánya, tele okos tagfüggvényekkel). A fenti ábra csak néhány mezőjét tartalmazza. Maga a metódustábla CLR verzióról verzióra változhat, az ábra az általam hozzáférhető SSCLI forráskódja alapján készült (ínyenceknek, sscli20\clr\src\vm\methodtable.h, 2700-ik sortól).

A metódus tábla tartalmazza a megvalósított interfészek egy táblázatát. Az ‘is’ operátor programkódjának tehát annyi a dolga, hogy az f(IntPtr targetTypeHandle, object sourceObject) hívásból veszi o referencián keresztül a metódustáblát, és az interfészeket leíró táblázatban megnézi, hogy szerepel-e benne a targetTypeHandle. Ez egy egyszerű ciklus, maga az egész folyamat feltehetőleg 1-2 száz gépi utasítás. Az ‘is’ operátor sebessége tehát ennek köszönhető.

Most már sejtjük, hogy az ‘is’ operátorhoz generált kód által hívott függvény mit csinál. De honnan kerül elő ez a függvény? A Jitter által generált kód két csoportra osztható. Aritmetikai műveletek, control flow műveletek esetén a Jitter a hagyományos fordítóprogramok logikája alapján generál megfelelő gépikódú programot. Az ‘is’ operátorhoz hasonló esetekben viszont “konzerv kódot” kap elő. Ezek a konzerv kódok a “jit helper” kódok, tulajdonképpen egy előre megírt függvényhalmaz. Cast-olást, objektum példányosítást, tömbkezelést, ilyen jellegű rutinokat kell elképzelni. Amikor a Jitter megtalálja az isinst IL utasítást (érdeklődőknek, SSCLI kód, sscli20\clr\src\fjit\fjit.cpp, FJit::jitCompile() függvény, bazinagy switch CEE_ISINST ága), akkor veszi az isinst utasítás paraméterét. Ez egy metaadat, ez alapján elő kell venni a metaadat által leírt típust. Miután ez megvan, a típus jellege (pl hogy interfész-e) alapján veszi a konzerv függvény címét (a mi esetünkben, sscli20\clr\src\vm\jithelpers.cpp, JIT_IsInstanceOfInterface_Portable() függvény, 2393-ik sor), és kigenerálja ezt a függvényt meghívó gépikódot. A korábban látott gépikódú call tehát ebbe a C++-ban megírt függvénybe hív bele.

Type.IsAssignableFrom()

A mérésekből kiderül, hogy az ‘is’ operátorhoz képest az IsAssignableFrom() hívás elsőként nagyon lassan fut le, második futtatásra pedig körülbelül 2-3-szor lassabb. Két kérdést van tehát. Az egyik, hogy első futtatásra miért olyan lassú egy típuson az IsAssignableFrom() függvény. A második, hogy mi okozza a 2-3-szoros lassulást az ‘is’ operátorhoz képest.

A Type Object ára

Az IsAssignableFrom() függvény a következő módon került meghívásra:

IMarkerType.IsAssignableFrom(typeof(Marked1)); 

A paraméter tehát egy Type Object. Korábban volt róla szó, hogy a Type Object csak szükség esetén jön létre. Ezt könnyen ki is próbálhatjuk az alábbi kis programmal, és az sos debugger extension használatával az Immediate ablakban. Nézzük a következő programot:

class Alma
{
} // class Alma

class Program
{
  static void Main(string[] args)
  {
    Alma a = new Alma();
    Type t = typeof(Alma);
  } // Main()
} // class Program

A programot lépésenként végrehajtva írassuk ki az immediate ablakban a Type Object-eket. A programon végiglépegetve, és minden lépés után kiíratva a Type Object-eket a !DumpRuntimeTypes paranccsal, csak a typeof(Alma) hívás lefutása után kapjuk meg a következőhöz hasonló listát:

!DumpRuntimeTypes
 Address   Domain       MT Type Name              
------------------------------------------------------------------------------
02473478 006634a8 6740f730 System.ValueType
02473490 006634a8 6740f760 System.Enum
024734a8 006634a8 6740f5e8 System.Object
...
0247b69c 006634a8 67415cb0 System.Security.Policy.Zone
0247ba14 006634a8 001f3880 TypeObjectTest.Alma 

A typeof(Alma) hívás előtt a “TypeObjectTest.Alma” típusnevet tartalmazó sor nem jelenik meg. Ebből a tesztből látszik, hogy a Type Object csak szükség esetén jön létre. A használt !DumpRuntimeTypes parancs neve egyébként onnan jön, hogy amit mi Type Object-nek hívunk, az valójában nem Type típusú, hanem annak egy leszármazottja, a RuntimeType egy példánya. Az IsAssignableFrom() hívások adott típusra tehát első alkalommal azért lassúak, mert ekkor jön létre a Type Object. Egy kis programmal ki is lehet mérni az ezzel töltött időt:

Type typeofMarked1 = typeof(Marked1);
Type typeofMarked2 = typeof(Marked2);
Type typeofMarked3 = typeof(Marked3);
...

Ennek a kódnak 100 elemre a lefutása kb 0.084 ms körüli érték, azaz kb annyi, mint amennyi az első és a második lefutás között van az IsAssignableFrom() tesztben. Ha lassúnak tűnik a Type Object létrehozása, akkor érdemes kipróbálni, mennyi idő alatt jön létre 100 db normál objektum:

object o1 = new Marked1();
object o2 = new Marked2();
object o3 = new Marked3();
...

Ebből száz darab lefutása kb 1.5000 ms, tehát a normál objektumok (igazából nem normál, mert a MarkedX-nek nincsenek is mezői, meg csak default konstruktora van, szóval igen könnyűsúlyú) a Type Object-nél 20-szor lassabban jönnek létre. Ez úgy lehetséges, hogy a Type Object létrehozására egy saját specializált függvény van a CLR-ben.

Második futtatásra az eredmények már csak 2-3 szor rosszabbak az IsAssignableFrom() esetében, mint az ‘is’ operátornál. Az IsAssignableFrom() egy közönséges .NET függvény, .NET implementációval, legalábbis a hívási lánc tetején .NET implementáció található. Emiatt bele lehet nézni például Reflector-ral. Ahelyett, hogy most megtennénk, elég rámutatni az alapproblémára: az ‘is’ operátor esetében a Jitter fordítás közben elvégzett néhány ellenőrzést, például látta, hogy a paraméter egy interfész, ennek megfelelően olyan kódot fordított, ami azonnal interfésztáblába keres. Az IsAssignableFrom() implementációja viszont minden típusra fel van készülve, és futás közben minden hívás esetén kell ellenőrizni azokat a dolgokat, amelyeket a Jitter csak egyszer, a fordítás pillanatában végzett el. Egyik jó példa, hogy az IsAssignableFrom() implementációjában, mivel az fogadhat enumokat is, hív a RuntimeType-ra egy UnderlyingSystemType property-t, ami azonban Type típust ad vissza, emiatt az IsAssignableFrom() implementációja egy ‘as’ operátorral ezt visszaalakítja RuntimeType típusra. Mégegyszer, most az IsAssignableFrom() sebességét hasonlítjuk az ‘is’ operátorhoz, de részműveletként belül az IsAssignableFrom() használja az ‘as’ operátort, amiről láttuk, hogy ugyanazt csinálja, mint az ‘is’. Ennek eredményeképpen az IsAssignableFrom() függvény minimum kétszer annyi ideig fut.Ezek után a függvény áthív natív oldalra, ahol pár ellenőrzés után rátér arra a végrehajtási vonalra, mint amit az ‘is’ operátor is csinált.

Ennek ellenére lehet azt állítani, hogy az IsAssignableFrom() nagyon gyors. Egyedül azért tudja sokkal lekörözni az ‘is’ operátor, mert itt néhány százas nagyságrendű gépi utasításokról van szó, emiatt minden apró plusz munka nagyon feltűnik arányaiban.

Attribútumok használata

Legtöbben ismerik, hogy az attribútumok a metaadatokon keresztül vannak hozzárendelve az osztályokhoz (illetve minden egyéb máshoz, amihez attribútumot lehet rendelni). De mik ezek a metaadatok?

A .NET-ben minden deklarált vagy referált “dolgot” metaadatok írnak le. Metaadatok írnak le egy osztályt, az osztály member-jeit, a member-ek, például metódusok paramétereit, lokális változóit. Ez alapján a metaadatokat akár hierarchikus szervezésűnek is elképzelhetnénk, de valójában a metaadatok a relációs adatbázisokhoz hasonlóan referálnak egymásra, és normalizált táblázatokból állnak. Nézzünk egy egyszerű példát, a következő kis program segítségével:

using System;

namespace MetaTest
{
  [AttributeUsage(AttributeTargets.Class)]
  class MarkerAttribute : Attribute
  {
  } // class MarkerAttribute

  [Marker]
  class Small
  {
    private int member = 0;

    public int Operation(int parameter)
    {
      return this.member * parameter;
    } // int Operation
  } // class Small

  class Program
  {
    static void Main(string[] args)
    {
    } // Main()
  } // Program
} // MetaTest

Nem érdekes, hogy mit csinál a program (amúgy semmit, az üres Main() miatt), inkább az, hogy milyen metaadatok születtek. Ismét, a metaadatok normalizált táblázatokból állnak, ezáltal az adatok miszlikekre esnek szét, de legalább a tárolásnál nincs redundancia. Az alábbi táblázatok az IL Disassembler nevű programmal készültek, amely minden .NET fejlesztő gépén megtalálható. A programot a következő beállításokkal kell használni, különben az IL Disassembler kényelmi okok miatt hierarchikus formába rendezi az adatokat:

A sokféle táblázatból az egyik például azt írja le, hogy milyen típusokat használ fel a programunk más assembly-kből.

 1(0x1): TypeRef              cRecs:   21(0x15), cbRec:  6(0x6), cbTable:   126(0x7e)
  col  0:  ResolutionScope oCol: 0, cbCol:2, ResolutionScope
  col  1:  Name         oCol: 2, cbCol:2, string 
  col  2:  Namespace    oCol: 4, cbCol:2, string 
-------------------------------------------------
   1 == 0:ResolutionScope[23000001], 1:string#4e, 2:string#47
   2 == 0:ResolutionScope[23000001], 1:string#58, 2:string#47
   3 == 0:ResolutionScope[23000001], 1:string#9c, 2:string#8a
   ... 

A szaggatott vonal feletti rész a tábla sémája. Ennek nagy része a CLR logikájába van beégetve. Az értékek a következőt jelentik:

1(0x1): TypeRef

Minden metaadat táblázatnak van egy kódja. Az 1-es táblázat például a “Class reference descriptors” táblázat, vagy rövidített nevén TypeRef. Ez a táblázat a nevének megfelelően azt írja le, milyen típusokat referál a program (pontosabban, a vizsgált assembly)

cRecs: 21(0x15), cbRec: 6(0x6), cbTable: 126(0x7e)

A táblázatban 21 rekord található, egy rekord 6 byte-ot foglal, az egész táblázat pedig 126 byte-ot. Érdemes felfigyelni arra, hogy a rekord csak hat byte-ot tartalmaz, közben a fenti táblázatban az első oszlop láthatólag 4 byte-os értékekből áll, míg a második és harmadik oszlopban vannak két byte-on tárolandó értékeket, összesen tehát 8 byte. Hogy lehet ez? Úgy, hogy a metaadatok különböző trükkökkel tömörítve vannak, például az első oszlop, ha lehet, két byte-on tárolt. Az IL Disassembler azonban számunkra a visszaalakított értékeket mutatja meg. Így egy rekord valóban elfér 6 byte-on annak ellenére, hogy mi 8-at látunk.

col  0:  ResolutionScope oCol: 0, cbCol:2, ResolutionScope
col  1:  Name            oCol: 2, cbCol:2, string 
col  2:  Namespace       oCol: 4, cbCol:2, string 

Ez a táblázatot alkotó oszlopok leírása. A táblázat három oszlopból áll. A 0. oszlop jelenését tekintve egy ResolutionScope. Azt adja meg, hogy adott típust (amelyet az 1. és 2. oszlop együtt nevez meg) hol lehet megtalálni. Az érték együtt nevez meg egy táblát és egy rekordszámot. Az első rekord első oszlopának 23000001 értéke például azt jelenti, hogy a referált típust a 0x23-as tábla első rekordja alapján lehet megtalálni. A 0x23-as metadata tábla az Assembly Reference (AssemblyRef) tábla, a rekordjai a hívatkozott assembly-ket írják le. A 23-as tábla 1-es rekordja a következőképpen néz ki:

1 == 0:0004, 1:0000, 2:0000, 3:0000, 4:00000000, 5:blob#1, 6:string#3e, 7:string#0, 8:blob#0

Az érdekes oszlop itt a 6-os, amely az assembly nevét adja meg. Ez a String Heap 0x3e-ik offsetjétől található meg:

String Heap:  727(0x2d7) bytes
00000000: 00                                               >                <
00000001: 3c 4d 6f 64 75 6c 65 3e  00                      ><Module>        <
0000000a: 4d 65 74 61 54 65 73 74  2e 65 78 65 00          >MetaTest.exe    <
...
0000003e: 6d 73 63 6f 72 6c 69 62  00                      >mscorlib        <
00000047: 53 79 73 74 65 6d 00                             >System          <
0000004e: 41 74 74 72 69 62 75 74  65 00                   >Attribute       <
...
0000005f: 2e 63 74 6f 72 00                                >.ctor           <
...

A hívatkozott assembly tehát a mscorlib. Ha a TypeRef tábla első rekordját tovább nézzük, akkor az 1-es és a 2-es oszlop megadja a Name/Namespace-t, hogy melyik típus is hívatkozik az mscorlib-re. Ezek a 0x4e/0x47-es offseten levő stringek. A fenti String Heap bejegyzésből látszik, hogy itt a System.Attribute-ről van szó. Amikor a programban tehát a System.Attribute típust használtuk a MarkedAttribute osztály örököltetéséhez, akkor a CLR a fenti metaadatok alapján tudja, hogy az mscorlib-ben megtalálja a típus leírását.

Az előzőek talán könnyebben áttekinthetőek az alábbi ábrán:

Tehát, valahol valami olyan típust használ, ami más assembly-ben definiált. Az összes ilyen típus fel van sorolva a TypeRef táblázatban, és a típus használója pontosan meg is mondja, hogy melyik rekordról van szó, mondjuk, hogy az 1-es rekordról. Az 1-es rekord 0. oszlopa megmondja, hogy a típus hol találjuk meg (1). Ekkor eljutunk a 23-as tábla 1-es rekordjához. Ennek a rekordnak a 6. oszlopa megmondja, hogy melyik assembly-re hívatkozunk (2). Ez az mscorlib. A TypeRef tábla 1-es rekordja azt is megmondja, hogy hogyan hívják a referált típust (3). Ez egyrész azért fontos, mert az mscorlib metaadataiban ezen a néven kell keresni, másrészt pedig ha az aktuális assembly-ben név alapján hívatkozunk erre a típusra, akkor a CLR is ez alapján a két mező alapján találja meg.

Ezek alapján van egy kis elképzelésünk a metaadatok felépítéséről. Látható, hogy tényleg nagyon hasonlít egy relációs adatbázishoz.

Most arra vagyunk kíváncsiak, hogy honnan lehet megtudni, hogy a Small osztály típuson definiálva van-e a Marker attribútum. Hogyan van ez ábrázolva?

A metaadat táblák között van egy “Custom Attribute Descriptors” (CustomAttribute) nevű tábla. Ebben van felsorolva az összes attribútum, amit valamire – osztályra, osztályfüggvényre, függvényparaméterre, assemblyre vagy egyébre – ráaggattak. A tábla a következő módon néz ki:

12(0xc): CustomAttribute      cRecs:   16(0x10), cbRec:  6(0x6), cbTable:    96(0x60)
  col  0:* Parent       oCol: 0, cbCol:2, HasCustomAttribute
  col  1:  Type         oCol: 2, cbCol:2, CustomAttributeType
  col  2:  Value        oCol: 4, cbCol:2, blob   
-------------------------------------------------
   1 == 0:HasCustomAttribute[20000001], 1:CustomAttributeType[0a000006], 2:blob#6c
   2 == 0:HasCustomAttribute[20000001], 1:CustomAttributeType[0a000007], 2:blob#57
   ...
   f == 0:HasCustomAttribute[02000002], 1:CustomAttributeType[0a000011], 2:blob#37
  10 == 0:HasCustomAttribute[02000003], 1:CustomAttributeType[06000001], 2:blob#40

A táblázat három oszlopból áll. Az első oszlop azt adja meg, hogy az attribútumot mire alkalmazták (melyik típusra, melyik assembly-re, melyik függvényre). Az információ két részből áll, az egyik egy tábla azonosító, a másik a rekordszám a táblán belül. Az első sor például a 0x20-as tábla első rekordját címezi meg. A 20-as tábla az kurrens assembly leírását tartalmazza, és maximum egy sora lehet, ami leírja az assembly verziószámát, nevét, publikus kulcsát, stb. A CustomAttribute tábla első néhány sora tehát olyan attribútumot ír le, amit a kurrens assembly-hez rendeltek.

A második oszlop, bár az IL Dissasembler leírása szerint ez a Type, azaz az attribútum típusa, valójában ez az oszlop az attribútumot megvalósító osztály konstruktorára hívatkozik. Azok az értékek, amelyek 0x0a-val kezdődnek, azok a MemberRef táblázatra hívatkoznak, amely jellemzően azokat a függvényeket (vagy mezőket) tartalmazzák, amelyek nem ebben az assembly-ben kerültek definiálásra. A 0x06-os táblára hívatkozó értékek azok ebben az assembly-ben definiált konstruktorok. Látszik, hogy a 0x10-es rekord ilyen, nyilván ez a rekord tartalmazza a MarkerAttribútumot, amelyet a Small típusra alkalmaztunk.

Most ha meg akarjuk tudni, hogy a CustomAttribute tábla első rekordja milyen típusú attribútumot tesz az aktuális assembly-re, a következőt kell csinálnunk. Első lépésben a 0x0a kódú táblából elővesszük a 6-os rekordot. Ez egy konstruktor függvényt ír le, de a leírásban benne lesz, hogy melyik típus konstruktorát, és nekünk pont erre az információra van szükségünk:

10(0xa): MemberRef            cRecs:   19(0x13), cbRec:  6(0x6), cbTable:   114(0x72)
  col  0:  Class        oCol: 0, cbCol:2, MemberRefParent
  col  1:  Name         oCol: 2, cbCol:2, string 
  col  2:  Signature    oCol: 4, cbCol:2, blob   
-------------------------------------------------
   1 == 0:MemberRefParent[01000003], 1:string#5f, 2:blob#1c
   ...
   6 == 0:MemberRefParent[01000008], 1:string#5f, 2:blob#1c
   7 == 0:MemberRefParent[01000009], 1:string#5f, 2:blob#1c
   ...

Az egyes oszlop adja meg a függvény nevét. A String Heap-en láthatjuk, hogy a 0x5f string az a “.ctor”, azaz konstruktor. De minket most a 0. oszlop érdekel, ami hívatkozza a típust, amihez a konstruktor tartozik. Ehhez a 0x01-es tábla 8-as rekordját kell elővenni:

 1(0x1): TypeRef              cRecs:   21(0x15), cbRec:  6(0x6), cbTable:   126(0x7e)
  col  0:  ResolutionScope oCol: 0, cbCol:2, ResolutionScope
  col  1:  Name         oCol: 2, cbCol:2, string 
  col  2:  Namespace    oCol: 4, cbCol:2, string 
-------------------------------------------------
   1 == 0:ResolutionScope[23000001], 1:string#4e, 2:string#47
   2 == 0:ResolutionScope[23000001], 1:string#58, 2:string#47
   ...
   8 == 0:ResolutionScope[23000001], 1:string#121, 2:string#8a

Korábban már találkoztunk a 0x01-es táblával, a 0. oszlop leírja, hogy a típust hol találjuk, a 23000001 volt az mscrolib. A típus nevét pedig megtudjuk, ha előkeressük a string-ek között a 0x121 és 0x8a offsettől kezdődőt:

String Heap:  727(0x2d7) bytes
...
0000008a: 53 79 73 74 65 6d 2e 52  65 66 6c 65 63 74 69 6f >System.Reflectio<
        : 6e 00                                            >n               <
...
00000121: 41 73 73 65 6d 62 6c 79  43 6f 70 79 72 69 67 68 >AssemblyCopyrigh<
        : 74 41 74 74 72 69 62 75  74 65 00                >tAttribute      <
...

Az alkalmazott attribútum tehát a System.Reflection.AssemblyCopyrightAttribute. Ami még hátravan a CustomAttribute táblából, az a 2. indexű oszlop (azaz a harmadik oszlop). Ez egy “blob”, és ami bele van kódolva az az, hogy milyen paraméterrel kell meghívni az attribútum konstruktorát, illetve milyen értékekkel kell inicializálni a property-ket. A kódolás nem túl bonyolult, de azért nem pár soros leírás. Az érdeklődők utána járhatnak például az Expert .NET 2.0 IL Assembler című könyvben (329. oldal, Custom Attribute Value Encoding). Magába a blob-ba beletekinthetünk, a lényeg látszik:

Blob Heap:  348(0x15c) bytes
    0,0 :                                                  >                <
    1,8 : b7 7a 5c 56 19 34 e0 89                          > z\V 4          <
...
   6c,20: 01 00 1b 43 6f 70 79 72  69 67 68 74 20 c2 a9 20 >   Copyright    <
        : 4d 69 63 72 6f 73 6f 66  74 20 32 30 31 31 00 00 >Microsoft 2011  <
...

Az AssemblyCopyrightAttribute konstruktora tehát a fenti copyright szöveggel lesz meghívva, ha az attribútum példányát elkéri a program.

Ha a fentiek bonyolultnak tűnnek, álljon itt egy összefoglaló tábla:

Onnan indultunk, hogy meg akartuk tudni, az 1. rekord milyen attrib]tumot ad a kurrens assembly-hez. Ehhez követtük a CustomAttribute tábla 1. mezőjét (1). Ez elvezet annak az attribútumnak a konstruktorához, amelyre kíváncsiak vagyunk. A MemberRef tábla, mivel nem a vizsgált assembly-ben van a típus definiálva, a TypeRef táblához visz, ami megadja, hogy a referált típust hol kell keresni (2). A TypeRef tábla rekordjából viszont megtudjuk a típus nevét (3). Amennyiben kíváncsiak vagyunk, hogy az attribútumot a CLR-től elkérve az milyen adatokkal inicializálná a létrejöző attribútum példányt, a 4-es nyilat követve eljutunk az inicializáló adatokig.

Gyors metaadat keresés

A mi esetünkben csak azt kell megtudni, hogy az attribútum definiált-e egy adott típuson. Az eddigi ismereteink alapján már össze tudjuk rakni, hogy milyen lépések szükségesek ehhez. Illetve egy észrevételt kell még tenni, méghozzá azt, hogy a CustomAttribute tábla rekordjai a HasCustomAttribute oszlop alapján rendezve van. Mi, hogy nincs is rendezve?

Ha az IL Disassembler által adott adatokat nézzük, akkor tényleg nincs. Az IL Disassembler a hivatkozásokat tartalmazó oszlopok esetén metadata token értékeket mutat. Általában a token értékre van szükség, azonban egy token 4 byte-ot foglal, és a metaadatokban rengeteg van belőlük. A helytakarékosság miatt a metaadat táblázatai egy kódolt formát tartalmaznak. A kódolás alapötlete az, hogy nincs értelme minden táblázat minden oszlopából minden másik táblázatba hivatkozni, emiatt a token azon részét, amelyik a táblahivatkozást adja, jóval tömörebben is le lehet írni. Ha például egy tábla oszlopa csak két táblára hivatkozhat (mert másnak nincs értelme), akkor ennek a tárolására elég egyetlen egy bit. Ha mondjuk öt lehetséges táblázat van, akkor három bit szükséges. Mivel a különböző értelmű oszlopok esetében különböző kettő-három táblázatra kell hivatkozi, ezért a CLR tervezői csináltak néhány táblacsoportot, amit lehet használni. Ha ez eddig nem világos, nézzük meg a CustomAttribute táblát. Ennek az 1. indexű oszlopa CustomAttributeType típusú. Ez azt jelenti, hogy a CustomAttributeType csoportba tartozó táblázatok közül címzi meg az egyik rekordot. A CustomAttributeType csoportba a következő táblázatok tartozhatnak:

CustomAttributeType (74): 5 referenced tables, tag size 3

Table Name Table Code
TypeRef 0
TypeDef 1
Method 2
MemberRef 3
String 4

Öt lehetséges tábla van, tehát 3 bit szükséges annak ábrázolására, hogy melyik tábláról van szó. Ha a Method táblába kell hivatkozni a CustomAttribute tábla 0. oszlopából, akkor a három bit állása 010, mivel a csoporton belül a Method tábla száma a 2-es. Ezek a bitek a token-nel ellentétben a legalacsonyabb helyértéken vannak, a rekord index pedig felette. Az, hogy a végső érték hány byte-on tárolt, attól függ, hogy mi a táblázatban a maximális index érték, amit még le kell írni. Ha a legnagyobb index ábrázolása elfér 16-3 biten, azaz 13 biten, akkor két byte lesz a CustomAttribute tábla CustomAttributeType oszlopának tárolására foglalt terület, egyébként pedig négy. Ezek alapján mondjuk a 255-ik rekordja a Method táblának a következő módon ábrázolt: 0000011111111010 = 0x07fa. Ezt persze az IL Disassembler visszaalakítaná, tudván, hogy a Method tábla kódja 0x06, a 0x060000ff értéket látnánk.

Maga a CustomAttribute táblázat a Parent, azaz 0. indexű oszlop alapján van rendezve, de a rendezés a kódolt tokenek alapján történik. A rendezésnek van egy nagyon nagy előnye, a táblázatban így bináris kereséssel lehet megtalálni egy rekordot egy típus, például a Small metaadat tokenje alapján.

Tegyük fel, hogy azt szeretnénk megtudni, hogy a Small osztályon van-e MarkerAttribute attribútum. Mit kell tennünk? Ha semmi nincs a kezünkben, csak az típus névtere és neve, akkor név alapján el kell kezdenünk keresni a TypeDef és TypeRef táblázatokban. A TypeDef például így néz ki:

2(0x2): TypeDef              cRecs:    4(0x4), cbRec: 14(0xe), cbTable:    56(0x38)
  col  0:  Flags        oCol: 0, cbCol:4, ULONG  
  col  1:  Name         oCol: 4, cbCol:2, string 
  col  2:  Namespace    oCol: 6, cbCol:2, string 
  col  3:  Extends      oCol: 8, cbCol:2, TypeDefOrRef
  col  4:  FieldList    oCol: a, cbCol:2, Field  
  col  5:  MethodList   oCol: c, cbCol:2, Method 
-------------------------------------------------
 1 == 0:00000000, 1:string#1, 2:string#0, 3:TypeDefOrRef[02000000], 4:Field[4000001], 5:Method[6000001]
 2 == 0:00100000, 1:string#17, 2:string#27, 3:TypeDefOrRef[01000001], 4:Field[4000001], 5:Method[6000001]
 3 == 0:00100000, 1:string#30, 2:string#27, 3:TypeDefOrRef[01000002], 4:Field[4000001], 5:Method[6000002]
 4 == 0:00100000, 1:string#36, 2:string#27, 3:TypeDefOrRef[01000002], 4:Field[4000002], 5:Method[6000004]

Látszik, hogy ami most minket érdekel, az az 1. és 2. indexű oszlopok tartalma. Amelyik sor nekünk kell, az az, amelyiknek a 1 oszlopa a string heap-en egy “Small” stringre, a 2 oszlopa pedig “MetaTest”-re mutat. Kihasználva, hogy a string heap nem tartalmaz duplikált stringeket, előkereshető, hogy a “Small” a 0x30-as, a “MetaTest” pedig a 0x27-es offseten van. Ekkor a TypeDef táblázatban keresve a 3-ik rekord az, ami nekünk kell. A Small osztály metaadat token-je így 0x02000003.

Ez a keresés a szekvenciális jellege miatt elég lassú, de szerencsére sok esetben ezt nem kell végigcsinálni a program futása közben. Láttuk például az isinst IL utasításnak már eleve a metaadat volt a paramétere, a C# fordító ezt fordítási időben kikereste. Ha van egy Type Handle a kezünkben akkor a metódustáblában megtaláljuk a típushoz tartozó metaadat tokent. Egy typeof(Small), vagy egy Small objektumpéldánnyal a kezünkben tehát pár gépi utasítással a metaadat token megszerezhető. Ezt az értéket egy típusra mi magunk is megszerezhetjük a Type.MetadataToken property segítségével.

Ha megszereztük a Small osztály típus metaadat token-jét, akkor a CustomAttribute táblában meg kell keresni az összes olyan rekordot, ami a Small osztály típus tokenjét tartalmazza a Parent (azaz 0. indexű) oszlopban. Mivel a CustomAttribute a Parent oszlop alapján rendezve van, bináris kereséssel még nagy táblázat esetén is pár lépésben található egy olyan rekord, amelyik megfelelő. A rendezés miatt a bináris kereséssel talált elem körül helyezkedik el az összes többi Small-hoz tartozó rekord (néhány magasabb, néhány pedig alacsonyabb indexen), hiszen a bináris keresés algoritmusa, ha a keresett értékből több van, akkor véletlenszerűen áll meg valamelyik értéken. A példában csak egy attribútum van az osztályhoz rendelve, ezért a fenti alá/fölé ellenőrzésre nincs szükség.

Bár már közel vagyunk annak a megválaszolásával, hogy adott típuson adott típusú attribútum definiálva van-e, még dolgozni kell egy kicsit. A most megtalált rekordok ugyanis nem mondják meg közvetlenül, hogy melyik típusú attribútumról van szó. Már említettük, hogy egy konstruktor függvény metaadat tokenjét találjuk a CustomAttribute tábla Type oszlopában. Bár a konstruktor alapján vissza tudnánk keresni a típust, sokkal egyszerűbb, ha a típus alapján keressük meg az összes lehetséges konstruktort. Így tudni fogjuk, hogy mely rekordok tartalmaznak megfelelő értéket a Type oszlopban. Ugyanúgy, ahogy a Small típus metaadat tokenjét megtaláltuk, megkeressük a MarkerAttribute típusét is. Ennek értéke 02000002. Most a TypeDef táblából meg kell találni, hogy a 02000002 típushoz hol találhatóak a függvények. Feljebb már látható a 2-es tábla, nézzünk rá ismét.

A 2-es tábla 2-es számú rekordja érdekes, ami az utolsó oszlopban megmondja, hogy a 6-os tábla 1-es rekordjától kezdődnek a MarkerAttribute típus metódusai (és a következő rekordból megtudjuk, hogy a 6-os tábla 2-es rekordja már más típushoz tartozik). Mivel csak egy metódus van, ami tuti a konstruktor, ebben a speciális esetben nem is kell tovább keresgélni, mostantól nyilvánvaló, hogy a minket érdeklő Type mező értéke 06000001 lesz. Általános esetben viszont össze kellene gyűjteni az összes konstruktor metaadat tokent. Ha ezzel végeztünk, akkor egyrészt van a CustomAttribute táblában egy indexünk, ami körül a minket érdeklő (Small) osztály típushoz tartozó bejegyzések vannak. Másrészt, van egy listánk (most egy elemű) ami az érdekes konstruktor tokeneket tartalmazza. Innen már csak végig kell nézni azt a pár bejegyzést, hogy stimmelnek-e a token értékek. A mi esetünkben a CustomAttribute tábla 0x10-es rekordja a megfelelő 06000001 értéket tartalmazza Type-ként, így az eredeti kérdésre, hogy a Small típushoz van-e MarkerAttribute attribútum típus rendelve, a válasz az, hogy igen.

Bár elmondva a folyamatot, az hosszúnak tűnik, az alábbi összefoglaló ábrát áttekintve látszik, hogy nincsenek igazán drága műveletek.

Ha a kezünkben van a Small típus metaadat tokenje, akkor bináris kereséssel ezt a CustomAttribute táblában pár lépéssel megtaláljuk (1). Ekkor már képesek vagyunk megvizsgálni, hogy a Small típusra milyen attribútumokat raktak. Mi a MarkerAttribute attribútumra vagyunk kíváncsiak, de ennek a konstruktorához tartozó metaadat van a CustomAttribute táblában. Emiatt a MarkerAttribute típus tokenje alapján megkeressük a típus leírását a TypeDef táblázatban (2). Ebből megtudjuk, hogy a Method táblázatban hol találhatóak a MarkerAttribute metódusai (3). Minket a konstruktorok érdekelnek, ebből összeállítunk egy listát (most csak egy elemű) (4). Ekkor tudjuk, hogy a CustomAttribute táblában milyen értékeket keressünk az 1. oszlopban (5)

A .NET Framework módszere

Az interfészekhez és az ‘is’ operátorhoz képest több lépés szükséges, de nem nagyságrendileg. Elméletileg metaadatokat használva csak néhányszor lassabb módszerrel kellene szembesülnünk, de mi mégis két nagyságrenddel roszabb, azaz kb század olyan gyors eredményeket mértünk. Mi lehet a baj?

Hogy megtudjuk a választ, utána kell mennünk, hogy a Type.IsDefined() függvény hogyan van implementálva. A Reflector segítségével megtudhatjuk, hogy a megvalósítás pont a nehezebb utat választja. Első lépésben összeszedi a CustomAttribute táblából az összes bejegyzést, ami a vizsgálni kívánt osztályra vonatkozik. Már ezt is pazarlóan tudja csak megcsinálni, elöször lekérdezi az elemek számát (ez egy táblakeresés) ezután lefoglalja a szükséges helyet, és abba beleteteti a rekordok indexét (második táblakeresés), ezután az indexek alapján lekérdezgeti a rekordok adatait (harmadik táblakeresés). Az igazán erőforrás műveletek azonban most jönnek, ugyanis a CustomAttribute tábla Type mezőjének metaadata alapján előkeresi azt a típust, amihez a konstruktor tartozik. Ezt csak úgy tudja megcsinálni, hogy végignézi a TypeDef táblázatot (illetve más assembly-kből használt típus esetén szükség lesz a MethodRef és TypeRef táblákra), hogy melyik típus rekordja címez a Method tábla azon régiójára, ahol a konstruktor rekorja is van. Annyi könnyebbség van, hogy a TypeDef tábla Method oszlopának értékei növekvő sorrendben vannak, ezért bináris kereséssel keresheztő.

Ezek miatt a pazarló táblaellenőrzések miatt, illetve hogy az ‘is’ operátor implementációja csak nagyon kevés gépi utasításból áll, összességében kijöhet a százszoros sebesség szorzó. Az ‘is’ operátor kicsi és gyors kódja miatt azonban valószínűleg optimális megoldással sem lehetne megközelíteni azt a sebességet.

Konklúzió

Most már pontosan értjük az attribútumok és a marker interfészek mögötti mechanizmusokat, és van elképzelésünk a sebességekről. Látszik, hogy a marker interfészek sokkal gyorsabbak az attribútumoknál. Ezt a gyorsaságot annak köszönhetik, hogy néhány gépi utasítás elég az ellenőrzésre. Az attribútumok ennél jóval nehezebb kóddal ellenőrizhetőek csak, ha az ‘is’ operátor néhány száz, az attribútumok valószínű néhány tízezer gépikódú utasításba kerülnek. Számításba véve, hogy a modern processzorok milliárdos nagyságrendű utasítást hajtanak végre másodpercenként, még ez is nagyon gyors működést eredményez, százezred másodperces válaszidőkkel.

Nehéz elképezlni olyan feladatot, ahol folyamatosan sokezer típusról kell eldönteni, hogy meg van-e jelölve, vagy nincs. Ha mégis ilyen helyzet adódik, érdemes lehet a marker interfészeket használni. Legtöbb esetben azonban sebesség szempontjából valószínűleg teljesen kielégítő az attribútumok használata. Mivel ez utóbbi egyébként koncepcionálisan szebb, én magam ezt javasolnám használatra.

  1. #1 by LacKoder on 2011. February 10. - 22:36

    Úgy kb mennyi ideig írsz meg egy ilyen cikket?

  2. #2 by Tóth Viktor on 2011. February 11. - 07:57

    Mivel egy csomó dolognak nekem is utána kell járnom, mindennel együtt nagyságrendileg 40 óra. Maga a szöveg írása meg a rajzolgatás az pár óra.

  3. #3 by botond.kopacz on 2011. February 11. - 09:22

    Grat az ismételten igencsak jól sikerült és jól érthető cikkhez. Igazán remek dolgokat tudtam meg belőle ismét.🙂

  4. #4 by Tóth Viktor on 2011. February 11. - 14:22

    Köszi!

  5. #5 by eMeL on 2011. May 21. - 14:22

    Köszi a befektetett energiát és időt.

    Azért azon a 40 órán túl van még néhány évnyi tudásfelhalmozás emögött.
    Kiérződik a profizmus.😉

  1. String műveletek belülről - 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: