Boxing, Unboxing – hogy is van ez?

Bevezetés

A boxing, unboxing műveletek sok forrásban két rövid bekezdésben kerülnek tárgyalásra. Az első bekezdés elmagyarázza, hogy a vermen létrehozott értéktípus hogyan alakul át a halmon tárolt referenciatípussá. A második bekezdés azt írja le, hogy a referenciatípus hogyan alakul át újra értéktípussá. Ezek a műveletek teljesen automatikusan működnek, és az esetek kilencvenöt százalékában nem is kell többet tudni róla. A maradék öt százalék okozza azokat a hibákat, amelyek felderítése órákig, esetleg napokig tarthat.

Szükséges ismeretek

A továbbiak megértéséhez szükséges az értéktípus és referenciatípus közötti különbségek ismerete, a verem és a halom memória területek ismerete. Ezekre vonatkozó információ megtalálható például a „Értéktípus, referenciatípus – hogy is van ez?” című olvasmányban. Szükséges továbbá az interfészek és a Nullable típus ismerete.

Boxing

A boxing működésének bemutatásában a következő értéktípus nyújt segítséget:

struct Vödör
{
  public double átmérő;
  public double mélység;
}

Tegyük fel, hogy rendelkezésre áll az alábbi függvény:

public static void f( Object o ) { … valami hasznos történik … }

A típust használó program pedig így néz ki:

public static void Boxing()
{
  Vödör v;

  v.átmérő  = 15.0;
  v.mélység = 30.0;

  f( v );
}

A Vödör típus egy értéktípus, így a v változó maga hordozza a Vödör mezőinek értékét, mint az átmérő és a mélység. Ezzel szemben egy referenciatípusú változó egy valahol már létező Vödör típusú példány eléréséhez szolgáló információt tárolná. Jelen esetben az érték tárolása a vermen történik, ahol automatikusan lefoglalásra került elegendő hely az átmérő és a mélység mezők számára. Miután a Boxing() függvény kilép (visszatér a hívóhoz), a v változó, és így az általa tárolt érték törlésre kerül, azaz a v változó által hordozott átmérő és mélység mezők tartalma elvész.

Még mielőtt a v változó elveszik, a program meghívja az f() függvényt. Ez a függvény egy Object típusú paramétert vár, az Object pedig referenciatípus. Szemet hunyva a részletek felett, tegyük fel, hogy az f() függvényen belül az o referenciaváltozó képes a v változó által tárolt értékre referálni. Ez ugyan sok problémát felvet, de egy kiemelkedik közülük: mi van, ha az f() függvény olyan kódot tartalmaz, ami lemásolja az o referenciaváltozót, hogy az általa referált érték később, akár a függvény visszatérése után is használható legyen? Az 1-es ábrán nyomon követhető a folyamat.

1-es ábra

Az első pontban az az állapot látható, amikor a v változó már létrejött és inicializálva lett. Ekkor a program meghívja az f() függvényt, paraméternek átadva a v változót. Függvényhíváskor a paraméterek a veremre kerülnek, így létrejön az o változó, mely Object típusú lévén egy referenciaváltozó. Ez a referencia a v változóra kell, hogy referáljon, mivel v volt a függvény paramétere. A verem állapotát a kettes pont mutatja. Tegyük fel, hogy az f() függvény olyan műveleteket végez, melynek következtében az o referenciaváltozó értéke átmásolódik a halmon levő egyik adatszerkezet egyik mezőjébe, melynek típusa szintén Object, neve pedig ref. Ez az állapot látható a hármas pont alatt. A művelet után az f() függvény visszatér, így az o referenciaváltozó eltűnik a veremről, mint ahogy a négyes pont alatt látszik. Végül a v értéktípusú változót létrehozó függvény is visszatér. Ez megszünteti a v változót, így az általa hordozott értékek is elvesznek. Ekkor a ref referencia által referált helyen nem tudni mi található, amint azt az ötös pont mutatja.

Sok helyen olvasható az az egyszerűsítés, hogy az értéktípusok példányai a vermen jönnek létre, a referenciatípusok példányai pedig a halmon. Ez nem igaz, az értéktípusok is létrejöhetnek a halmon, egy referenciatípusú objektum mezőjeként beágyazva. Ha egy referenciaváltozó egy beágyazott értéktípusú mezőre referál, szintén okozhat memóriakezelési és egyéb nehézségeket. Ekkor, ha a példány, amelyik a referált mezőt tartalmazza megszűnik, az őt hivatkozó referenciák érvénytelenné válnak. A .NET memóriakezelője figyelhetne erre a helyzetre, ekkor viszont nem csak objektum példányonként kellene felderíteni a függőségeket, hanem minden objektum minden mezőjét figyelembe kellene venni, hatalmas teljesítményveszteséget okozva.

A problémát több módon át lehetne hidalni. A legegyszerűbb, ha nincsenek értéktípusok, minden referenciatípusú. Ez bizonyos helyzetekben komoly teljesítményvesztést okozna, de a rendszer egységesebb lehetne. Másik lehetőség, ha az értéktípusokat nem lehetne referenciatípusokon keresztül használni. Ez beszűkítené az értéktípusok használati lehetőségeit, ami főleg igaz volt a generikus típusok megjelenése előtt, ahol az univerzális megoldások az Object típus használatával születtek. A .NET nem ezek közül az utak közül választ, hanem az úgynevezett Boxing eljárást valósítja meg.

A Boxing eljárás lényege a következő: ha a fő gondot az okozza, hogy az értéktípus példányai nem rendelkeznek saját életciklussal (az élettartamuk a létrehozó függvény kilépéséig, vagy a befoglaló referencia típus élettartamáig tart), akkor az értéket önálló életre kell kellteni. Ehhez egy önálló terület foglalása történik a halmon, ahova az eredeti példány értéke átmásolásra kerül.

A másoláson kívül az újonnan létrehozott memóriaterület kap a halmon tárolt összes objektumra jellemző keretet. Ez a keret szükséges a futtatórendszer számára például az objektumok típusának meghatározásához, illetve a virtuális függvények hívásához. Az értéktípusú változóknál a típus mindig egyértelmű. Egy referenciatípusú változó viszont mutathat különböző típusú objektumra. Egy Object típusú referenciaváltozó konkrétan minden más típusú objektumra mutathat, a típus azonban mindig meghatározható az objektumot befoglaló keretből. Ez a keret hiányzik az értéktípusú változóknál, ezért kellett szemet hunyni az 1-es ábránál abban a kérdésben, hogy hogyan tud egy referencia a vermen lévő értéktípusra mutatni.

2-es ábra

A 2-es ábrán látható, hogyan működik a Boxing. Az első pontban a vermen már létezik a v változó. Az f() függvény paraméterének típusa Object referencia, ez kellene, hogy a paraméternek átadott Vödör értéktípusú változóra mutasson. Mivel ez közvetlenül nem lehetséges, létrejön a v változó tartalmának egy másolata a halmon. A másolat kiegészül egy kerettel, ami egyéb információk mellett tartalmazza a típust is. (Valójában közvetlenül nem tartalmazza a típust, hanem a típushoz tartozó úgynevezett metódus táblára címét tartalmazza. A metódus táblán keresztül viszont el lehet érni a típusinformációkat hordózó objektumot) Ezzel a kiegészítéssel „referencia kompatibilissé” válik a másolat. Az o változó ezután a másolatra mutat, ahogy a kettes pontban látszik. A folyamat talán hackelésnek tűnhet, ezért meg kell jegyezni, hogy a v változót akkor is másolni kellene, ha az f() függvény egy Vödör típusú paramétert várna érték szerinti átadással, és ez történik az olyan egyszerű típusokkal is, mint az int. A különbség annyi, hogy a felsorolt esetekben a másolat a vermen jönne létre a kiegészítő keret nélkül. A 2-es ábrát tovább tekintve, a harmadik pont alatt az o változó értéke átmásolásra kerül egy valahol létező ref nevű referenciaváltozóba. Az o változó a halmon létrehozott, másolt objektumra mutat, tehát a ref változó is a másolatra fog mutatni. A negyedik ábra az f() függvény visszatérését szemlélteti. A másolat Vödör típusú objektum a halmon jött létre, ami a szemétgyűjtő mechanizmus hatókörében van. A függvényvisszatérés ezért nem takarítja el a másolatot, mert az csak a veremre hat. Emiatt a másolt Vödör típusú objektum megmarad a Boxing() függvény visszatérése után is, ahogy az ötödik pontban ábrázolva van.

Összefoglalva a Boxing eljárás két lényeges lépését:

  • Lemásolja a kérdéses objektumot a veremről vagy a halmon lévő objektum egyik mezőjéből a halmon létrehozott új területre. Ez a terület már önálló életciklussal rendelkezik.
  • Kiegészíti egy kerettel, amely ahhoz szükséges, hogy a referenciaváltozók és az azon keresztül végrehajtott műveletek (pl.: függvényhívás) működhessenek.

Unboxing

A Boxing eljárás azért szükséges, hogy az értéktípusú objektum „referenciakompatibilissé” válhasson. Az Unboxing eljárás ezzel szemben azért kell, hogy a referenciakompatibilissé tett objektum újra használható legyen értéktípusúként. Ez a művelet sokkal egyszerűbb, mint a Boxing, hiszen a memóriában a bekeretezett objektum területén minden rendelkezésre áll, ami egy értéktípusú objektumnak a sajátja. Az egyetlen szükséges lépés a hozzáadott keret eltávolítása. A forrásokkal ellentétben az Unboxing memóriamásolás nélkül történik. Az Unboxing egyszerűen egy memóriacím meghatározását jelenti a keret átugrásával. Érdekes, hogy néhány helyen még az MSDN is másolást említ az Unboxing kapcsán, a korrekt leírás azonban megtalálható az unbox IL assembly utasítás magyarázatánál. A félreértéseket az okozza, hogy az Unboxing művelet után általában valóban megtörténik az érték másolása, ez azonban már nem az Unboxing része.

A C# fordító mindig olyan IL kódot generál, amely az Unboxing művelet által meghatározott memória területről átmásolja az értéktípusú objektumot egy vermen létrehozott területre. Ez a terület lehet egy programkód által deklarált változó, a fordító által generált ideiglenes változó vagy a futtató rendszer által létrehozott ideiglenes terület. Az utóbbi kettő változat meglepő hibák forrása lehet, ahogy azt egy későbbi fejezet tárgyalja.

Vödör v;

v.átmérő = 15;
v.mélység = 30;

Object o = v;                      // Boxing

Vödör v2 = (Vödör)o;               // Unboxing #1
Double r = ((Vödör)o).átmérő;      // Unboxing #2

A fenti programrészlet két példát mutat az Unboxing műveletre. A programban alkalmazott direkt konverziókra a generikus típusok bevezetése óta szerencsére nemigen van szükség, de ezeken a sorokon a legegyszerűbb szemléltetni a folyamatokat. A 3-as ábrán követhető, mi történik a memóriában a program futása közben.

3-es ábra

Az egyes pont azt az állapotot mutatja, amikor a Boxing már megtörtént, az utolsó végrehajtott programsor a „Boxing” kommentárral ellátott volt. A vermen ábrázolva van az összes lokális változó, azok is, amelyekhez a programvezérlés még el sem ért. Ez megfelel a valóságnak, egy függvény belépésekor az összes lokális változó számára szükséges hely lefoglalásra kerül a vermen. A korábbi ábrákon az egyszerűbb áttekinthetőség miatt csak az éppen használt változók voltak feltüntetve. A második pontban történik meg a Vödör v2 = (Vödör)o értékadás miatti Unboxing. Az Unboxing csak az értéktípusú objektum memóriacímének meghatározását jelenti, így létrejön egy értéktípus mutató (value type pointer). A hármas pontban az éppen meghatározott értéktípus mutató által mutatott területről a vermen létrehozott ideiglenes, t-vel jelzett területre másolódik a Vödör típusnak megfelelő adat. Fontos látni, hogy ez nem az értékadás miatti másolás. Ez a másolás C# -ból generált kód esetén minden Unboxing művelet után megtörténik, mivel a C# soha nem használja közvetlenül az Unboxing által megadott területet. Az értékadás miatti másolás a négyes pontban történik. A dupla másolás pazarlásnak tűnik, és az is. Az IL (a .NET „gépikódja”) a többi gépikódhoz hasonlóan azonban erős megkötéseket ad azzal kapcsolatban, hogy mit hova lehet másolni a memóriában, és a halomról nem lehet a CLR verem közbenső területére. Az ötös pont az egyes ponthoz nagyon hasonló veremállapotot mutat. Az egyedüli különbség, hogy a v2 változó az éppen elvégzett értékadásnak köszönhetően feltöltésre került. A soron következő művelet az r = ((Vödör)o).átmérő értékadás. A hatodik és hetedik pont megegyezik az előző értékadásnál látottakkal. A halmon levő, kerettel ellátott értéktípusú objektum keretének „átlépése” (hatos pont), és az így kapott terület veremre másolása (hetes pont). Ez a műveletpáros annyira tipikus, hogy a 2.0-ás .NET-ben saját elemi IL utasítást kapott. A .NET számára tehát a két művelet egyetlen lépéssel megoldható. Ennek ellenére az Unboxing művelet még mindig csak a bekeretezett értéktípusú objektum kereten belüli címének meghatározását jelenti. Miután létrejött a halmon lévő Vödör objektum másolata a vermen, elvégezhető az értékadás művelet az átmérő mező és az r változó között. Ezt mutatja a nyolcadik pont. A meg nem rajzolt kilencedik pontban az t-vel jelzett ideiglenes Vödör objektum eltakarítása történne.

Ha az Unboxing művelet erőforrás igényesnek tűnik a fentiek alapján, végig kell gondolni, hogy az r = ((Vödör)o).átmérő értékadás mennyiben lenne más egy referenciatípusú objektum esetén. Az egyik megspórolható lépés az Unboxing művelet címszámítása a keret átugrásához. De ez a művelet egyáltalán nem kerül sokba. A keret fix méretű, tehát az adott referenciához, amely a kerettel ellátott értéktípusú objektumra mutat, mindig ugyanazt az értéket kell hozzáadni. A processzorok ráadásul gyakran rendelkeznek olyan címzési móddal, ami lehetővé teszi plusz offszet megadását egy mutatóhoz képest, és ezt a JIT fordító ki is használja. Az Unboxing művelethez általában tartozik egy másolás is. Az r = ((Vödör)o).átmérő értékadásnál az átmérő mező másolása előtt létrejön az o referenciaváltozó által hivatkozott objektumnak egy teljes másolata a vermen. Ez a Vödör értéktípus esetén 16 byte másolását jelenti, mivel a két double típusú mező kétszer nyolc byte-ot igényel. Ha a Vödör referenciatípusú lenne, akkor a .NET csak az átmérő mezőből készítene ideiglenes másolatot a vermen. Az ideiglenes másolat ebben az esetben sem kerülhető el, tehát a nyereség a 8 byte-os második mező másolásának az elkerülése. Több mezőből álló adatszerkezetek esetében a nyereség ennél nagyobb, azonban a .NET dokumentációja nem javasol túl nagyméretű értéktípusokat, pont a másolgatás miatt.

Talán érezhető, hogy 8 byte másolásának a megtakarítása nem lehet olyan számottevő nyereség. Hogy ez számokban konkrétan mit jelent, ellenőrizhető a következő kis programkóddal:

Object[] t = new Object[500000];

for( Int64 i = 0; i < 500000; ++i )
  t[i] = new Vödör();

Stopwatch sw = Stopwatch.StartNew();

Double átmérő = 0;
for( Int64 n = 0; n < 10000; ++n )
  for( Int64 i = 0; i < 500000; ++i )
    átmérő += ((Vödör)t[i]).átmérő;

sw.Stop();
Console.WriteLine("Futásidő : {0} {1}", sw.Elapsed, átmérő );

A program egy tömböt tölt fel Vödör típusú objektumokkal. Mivel a tömb Object típusú referenciák tömbje, a benne tárolni kívánt Vödör értéktípusú objektumokat a Boxing művelettel megfelelő formára kell konvertálni, és a tömb csak az átalakított objektumra mutató referenciákat tárolja. Később a program a tömbben hivatkozott objektumok egyik mezőjét éri el. Ehhez végre kell hajtani az Unboxing műveletet és az ideiglenes területre másolást, ahogy az a korábbiakban látható volt. A program a mezőhivatkozáson kívül csak minimális egyéb műveletet tartalmaz, hogy a végrehajtási időből az Unboxing művelet és a hozzá kapcsolódó másolás minél nagyobb szeletet kapjon. A program futása egy átlagos teljesítményű számítógépen 48 másodperc volt.

A teszt elvégezhető a következő Vödör típus definícióval is:

class Vödör
{
  public double átmérő;
  public double mélység;
}

Ez annyiban különbözik az eredeti definíciótól, hogy a struct kulcsszó ki lett cseréve class-ra. Ekkor a program futása közben csak az átmérő mezőről készül ideiglenes másolat, tehát minden egyes ciklusban 8 byte-tal kevesebb adatot kell mozgatni. A ciklus ötmilliárdszor fut le, ami majdnem 40 gigabyte adat mozgatásának a megspórolását jelenti. Soknak tűnik, a program futásának az ideje mégis 45 másodperc. A nyereség tehát körülbelül 6%. Annak alátámasztására, hogy az értéktípusok Unboxing után pazarlóan másolnak, érdemes próbaképpen módosítani a Vödör definícióját:

struct/class Vödör
{
  public double átmérő;
  public double mélység;
  public double v1, v2, v3, v4, v5 ;
}

Ebben az esetben a futásidő közötti különbség további növekedésére lehet számítani, azonban az előzőekben használt számítógépen az eredmény értéktípusnál 97 és referenciatípus esetében 88 másodperc. A különbség tényleg nőtt (majdnem 10%) de az kevéssé érthető, miért nőtt meg a futásidő a referenciatípus esetében.

Az adatok elhelyezkedése a memóriában nagyon sokat számít futásidő szempontjából. Számít például, hogy hányszor elegendő a gyorsító tárba nyúlni az új adatért és hányszor kell frissíteni azt. A második tesztalkalmazás által bevezetett öt mező sorozatban feleslegesen kerül be a gyorsítótárba, ezáltal jelentősen megnöveli a gyorsító tár frissítésének igényét. Ez okozhatja a futásidő jelentős növekedését. Ha a tesztprogram tömböt feltöltő ciklusa nem sorban hozza létre az objektumokat, hanem szétszórva, akkor a tesztprogram futásideje referenciatípus esetén 108 másodperc, értéktípus esetén elképesztően sok, közel 500 másodperc. Mivel a mezők memóriaigénye 56 byte, és ezt az értéktípus esetén mind be kell olvasni, sokkal nagyobb eséllyel fordul elő cache miss, mint a referencia típus 8 byte-os olvasása miatt. Ez egyben lecke arra vonatkozólag is, mennyire kell komolyan venni a pár soros tesztprogramok időeredményeit.

for( Int64 i = 0; i < 500000; i++ )
  t[10001*i%500000] = new Vödör();

A ciklusnak köszönhetően egy t[n] és t[n+1] által mutatott memória közötti távolság várhatóan százhatvan kbyte, megnehezítve a gyorsítótár dolgát.

Az értéktípusok és referenciatípusok használata közötti különbség egyébként a JIT-telt kódban is megtalálható (a kód az eredeti, két mezőt tartalmaző Vödör típussal készült):

(kattintson a képre a nagyobb változatért)

Az első oszlop a referenciatípusú Vödör kódja, a második az értéktípusé. Az első három sor a tömb i-ik elemének helyét számolja ki. A harmadik sorban a 64-bites rendszer miatt van 8-cal való szorzás a címzésben. A negyedik és ötödik sor az átmérő változó értékét másolja be egy ideiglenes váltzozóba, amelyet a compiler generált. A két oszlop kódja eddig megeggyezik (bár a verem kicsit más felépítése miatt a verem címzések más számokkal történnek). A hatodik és hetedik sor viszont különböző. A referenciatípus esetében itt egy cast művelet történik, ami lényegében egy típus és null ellenőrzés. A cast a rax regiszterben adja vissza a cast-olt referenciát. Az értéktípus esetén egy unbox művelet történik ugyanitt, és az rax-ben egy értéktípus pointer jön vissza. (a két hívásra egyébként az IL kódból lehet következtetni, a JIT-telt kódból közvetlen nem látszik, hogy mi hívódik). A két kód ezek után már nagyon különbözik. A referenciatípus kódja a cast után az xmm0-ba olvassa a compiler által generált ideiglenes változó értékét (amibe a kód elején az átmérő változó lett bemásolva). Ezután ehhez hozzáadja azt az értéket, amit a referencia által mutatott terület 8-ik byte-jától kezdve talál. A 8 byte ugrás a metódustábla miatt kell, mindegyik referencia típusú objektum ezzel kezdődik, és ez 64 bites rendszer esetén 8 byte-ot foglal, azután található az átmérő mező, a 8-ik offseten. Végül az eredményt visszamásolja az átmérő nevű változatba. Az értéktípus esetében hosszabb a kód. Az unbox után, ami visszaadott egy értéktípus pointert, az unbox-olt értéket egy ideiglenes Vödör típusú változóba másolja a program. Az értéktípus pointer esetén nem kell átugorni semmit, mivel az értéktípusok memórialenyomata nem tartalmazza a metódustáblát. Pont ez volt a boxing feladata, hogy átugorja a keretet, és közvetlen az értéktípusra mutató pointert adjon vissza. Emiatt csak a két mező (átmérő, mélység) másolása történik a nulladik és nyolcadik offsettől a vermen létrejött ideiglenes változóba. A másolás után a kód nagyon hasonló a referenciatípus kódjához. Beolvasásra kerül az átmérő mező az ideiglenes példányból, majd ehhez hozzáadásra kerül az ideiglenes változó, amely korábban az átmérő változó értékét kapta. A JIT-er tehát itt kihasználta az összeadás kommutativitását. Ezután az eredmény visszamásolódik az átmérő változóba.

Nullable<> típusok

A Nullable<> sablon néhány dologban különbözik a többi sablontól, hogy használhatóbb típusokat lehessen létrehozni belőle. Ez a sajátos viselkedés a Boxing/Unboxing műveleteket is érinti.

Egy Nullable értéktípus logikai szempontból lehet null, fizikai szempontból viszont soha, mivel egy értéktípusú objektum mindig maga hordozza az értékét. Ha egy Object típusú referenciaváltozó értékül kap egy null logikai értékkel rendelkező Nullable objektumot, akkor mi az értelemszerű megoldás? Az eddigiek alapján az Object típusú referencia a bekeretezett Nullable típusú objektumra fog mutatni. A Nullable típus logikája szerint viszont az Object referenciának null értéket kell kapnia. A .NET ez utóbbit valósítja meg. Bármely Nullable típusú objektum, ha logikailag null, akkor a Boxing művelet nem hoz létre a memóriában egy új bekeretezett objektumot, hanem null referenciát ad vissza eredményül.

Ha a Nullable típusú változó logikai értéke nem null, akkor a típus pontosan úgy működik, mint egy T. Ez még a Boxing műveletre is igaz, így ha egy Nullable típusú változó logikai értéke nem null, akkor egy rajta elvégzett Boxing művelet nem egy Nullable típusú változót fog bekeretezni, hanem egy T típusút. Nem is lenne értelme a teljes Nullable adatszerkezetét bekeretezni, mivel a referenciákkal ábrázolható a null „érték” a kibővített adatszerkezet nélkül is, két lehetőség a null ábrázolására pedig csak zavart okozhatna.

Ez azonban felvet két problémát. Az első, hogy az Unboxing műveletet T-re vagy Nullable-re lehet elvégezni? A Nullable<> típusok előtt egyértelmű volt, hogy az Unboxing olyan típusú objektumot vesz ki a keretből, amely abba belekerült. A második probléma, hogy a Nullable egy nagyobb adatszerkezet, mint a T, mert magán a T adatain kívül tartalmaz egy logikai értéket tároló mezőt is (null vagy nem null). Ha csak egy T típusú objektum van a keretben, az Unboxing művelet hogyan ad vissza egy olyan értéktípus pointert, amely a kereten belül egy Nullable típusú objektumra mutat?

Az első problémára az a megoldás, hogy a .NET minden T típus esetén megengedi az Unboxing műveletet magára a T típusra, vagy a Nullable típusra. Ez attól függetlenül igaz, hogy a keretben lévő objektum melyikből lett létrehozva. Ezen felül a null referencián megengedi az „Unboxing” műveletet bármely Nullable típusra.

A következő példák világosabbá teszik a lehetőségeket. A program a jobb áttekinthetőség és a tömörség miatt a Nullable helyett a Double? jelölést használja:

Object o1 = 10.0;                    //  1. Boxing
Object o2 = (Double?)10.0;           //  2. Boxing
Object o3 = (Double?)null;           //  3. referencia nullázás

Double  d1 = (Double)o1;             //  4. Unboxing Double típusra
Double? d2 = (Double)o1;             //  5. Unboxing Double típusra
Double? d3 = (Double?)o1;            //  6. Unboxing Double? típusra
Double? d4 = (Double?)null;          //  7. „Unboxing” Double? típusra

d1 = (Double)o2;                     //  8. Unboxing Double típusra
d2 = (Double)o2;                     //  9. Unboxing Double típusra
d3 = (Double?)o2;                    // 10. Unboxing Double? típusra

d4 = (Double?)o3;                    // 11. Unboxing Double? típusra

Az első sor semmi szokatlant nem tartalmaz, egy Object típusú referencia kell, hogy egy Double értéktípusú objektumra mutasson. Ez közvetlenül nem lehetséges, ezért a .NET egy új területet foglal a halmon, elkészíti hozzá az összes referenciatípusú objektumra jellemző keretet, és átmásolja a Double értéktípusú objektum tartalmát. Végül o1 változó megkapja az újonnan létrehozott objektumra mutató referencia értékét. Ez egy szokásos Boxing művelet, ahogy a negyedik ábra mutatja.

4-es ábra

A második sorban az Object referenciatípusú o2 kell, hogy egy Nullable értéktípusú objektumra mutasson. A Boxing művelet itt nem a Nullable típusú értéket fogja bekeretezni, hanem kiemeli az objektumból a Double típusú mezőjét. Végsősoron o2 pontosan olyan konstrukcióra fog mutatni, mint o1, persze más memória címen. Ez a szokásostól eltérő Boxing, hiszen a bekeretezett objektum nem ugyanolyan típusú, mint az eredetileg hivatkozott. A memória állapota az 5-ös ábrán látható.

5-ös ábra

A harmadik sorban az Object referenciatípusú o3 egy null logikai értékkel rendelkező Nullable értéktípusú objektumra kellene, hogy mutasson. A referenciaváltozók azonban a Nullable típusok szolgáltatása nélkül is kit tudják fejezni a null értéket. Ezért ebben a sorban nem történik Boxing, az o3 egyszerűen null értéket kap, ahogy a hatos ábrán látható.

6-os ábra

A negyedik sor egy szokásos Unboxing művelet a halmon bekeretezett Double típusú objektumra. A folyamat a 7-es ábrán követhető. Az Unboxing művelet megkeresi a kereten belül a Double típushoz tartozó adat címét. Az adat az értékadás miatt – a vermen létrehozott ideiglenes t változón keresztül – a d1 változóba másolódik.

7-es ábra

Az ötödik sor szintén egy szokásos Unboxing, hiszen egy bekeretezett Double típusú objektumban kell megkeresni a Double adat címét. Ezután ezt az értéket kell egy Nullable típusú objektumnak értékül adni. Ez hagyományos típusoknál a konverziós operátorok használatával történik, ami viszont új objektumok létrehozásával (és értéktípusok esetén másolgatásával) jár. A C# fordító a Nullable típusoknál egy optimalizált megoldást használ, ami abból áll, hogy szimplán meghívja a már létező objektum azon konstruktorát, amely T típust, ebben az esetben egy Double típust vár. Így elkerülhető az új objektum létrehozása és annak másolása. Az ötödik sor műveletei a 8-as ábrán láthatóak.

8-as ábra

A hatodik sor a legfurcsább Unboxing műveletet végzi el, ami a .NET-ben létezik. Egy Unboxing a bekeretezett objektum címét keresi meg, és adja vissza. A hatodik sorban lévő Unboxing egy Nullable típusú objektum címét kell, hogy visszaadja. De hogyan, amikor a keretben csak egy Double típusú van? Ez nyilván lehetetlen. Azért, hogy az Unboxing működjön, egy trükkre – elég csúnya trükkre – van szükség. Amikor az Unboxing műveletnek egy Nullable típus címét kell visszaadni, létrehoz egy új, bekeretezett Nullable típusú objektumot a bekeretezett T típusú objektum alapján, így vissza tud adni egy Nullable értéktípusú mutatót. Az újonnan létrehozott Nullable típus nem fér el a T típus helyén, ezért annak új területet kell foglalni a halmon. Ez egy olyan Unboxing művelet, amely fogyasztja a memóriát. A hatodik sor műveletei a 9-es ábrán láthatóak.

9-es ábra

Az első pontban létrejön a halmon az új, Nullable típust bekeretező objektum az o1 referenciaváltozó által mutatott bekeretezett Double érték alapján. Az Unboxing művelet ezen belül adja meg az értéktípus mutatót. A második lépésben az imént létrehozott objektum által bekeretezett értéktípusú objektumról készül egy másolat a veremre tetejére. A harmadik pont a verem tetejéről lemásolja az objektumot a d3 változóba. Ezzel az Unboxing és az azt követő értékadás befejeződött. A negyedik pontban látható, hogy a művelet befejezése után a halmon létrehozott ideiglenes objektum megmaradt, azt majd később, a szemétgyűjtő mechanizmus takarítja el. A memóriafogyasztás a következő egyszerű kóddal ellenőrizhető:

Object o = 10.0;
Double? d = 0.0;

Console.WriteLine( GC.CollectionCount(0).ToString() );

for( Int32 i = 0; i < 10000000; ++i )
  d += (Double?)o;

Console.WriteLine( GC.CollectionCount(0).ToString() );

Az eredmény 228 szemétgyűjtés mindenféle direkt memóriafoglalás nélkül. Bár a programkód kétség kívül jól olvasható, és az Unboxing elve is kerek ezzel a megoldással, a gyakorlatban sokkal hatékonyabb a következő „ronda” ciklus, ha a teljesítmény fontos:

for( Int32 i = 0; i < 10000000; ++i )
  if( o != null ) d += (Double)o;

Meg kell jegyezni, hogy ez a ciklus nem pontosan ugyanazt csinálja, mint az előző. Ha o változó értéke null, akkor az eredeti ciklus d változó értékét null-ra állítja a Nullable típuson értelmezett aritmetika szabályai szerint. Ezzel szemben a második ciklus d változó értékét 0 értéken hagyja, ha o változó null. A konkrét feladat esetében mindig figyelni kell rá, hogy a null referenciák megfelelően legyenek lekezelve. Ebben a példában nincs értelme ezzel foglalkozni, mint ahogy tízmilliószor összeadni ugyanazt a számot szintén nem értelmes.

A hetedik sor nem igazi Unboxing, de ez a művelet teszi teljessé a Nullable típusokhoz kapcsolódó Boxing/Unboxing műveleteket. A művelet eredményeként d4 változó értékét alkotó adat byte-ok közvetlenül kinullázásra kerülnek. A néhány byte kinullázása elég gyors, így ez a fajta „Unboxing” nem pazarló. Az Unboxing „álca” el is hagyható, az alábbi sor ugyanazt az IL kódot generálja:

Double? d4 = null;

A második sorban az o2 változót a programkód egy Nullable típusra állította rá, a Boxing eljárás azonban nem a Nullable típusú objektumot keretezte be, hanem az általa tárolt Double értéket. Emiatt o1 és o2 teljesen egyforma adatszerkezetre mutat, a 8. 9. és 10. sor ugyanazokat a műveleteket végzi el, mint a 4. 5. és 6. sorok.

A tizenegyedik sor a hatodik sorhoz hasonló furcsa Unboxing műveletet végzi el. A folyamat követhető a tizedik ábrán. Az első pontban, mivel o3 referenciaváltozó értéke null, az általa „mutatott” objektumon nem lehet végrehajtani az Unboxing műveletet. Egyéb esetben ez NullReferenceException-t eredményezne, de a Nullable típusok speciális támogatása miatt itt más történik. A halmon létrejön egy új, bekeretezett Nullable típusú objektum. Az új objektum logikai értéke null. Az Unboxing művelet ennek az objektumnak adja vissza a címét. A többi esemény ugyanaz, mint a hatodik programsor és a hozzá tartozó kilencedik ábra esetében. A második pontban az Unboxing által visszaadott memóriacímen található objektumról készül egy másolat a vermen. A harmadik pont ezt bemásolja a d4 változó területére. A negyedik pontnál látható, hogy a halmon ideiglenesen létrehozott objektum megmarad, ezt a szemétgyűjtő mechanizmus szabadítja fel később. A hatodik programsorhoz hasonlóan ez a folyamat nem túl hatékony. Ha a teljesítmény kritikus, az ilyen típusú értékadásokat célszerű a következő módon leírni:

if( o3 == null )
  d4 = null;
else
  d4 = (Double)o3;

10-es ábra

A Boxing/Unboxing veszélyei

A két művelet arra hivatott, hogy láthatatlanul hidat képezzen az értéktípusú példányok és a referenciatípusú példányok között. Ezt a funkcióját be is tölti, azonban van néhány eset, aminek ismerni kell a következményeit. A generikus típusok bevezetése óta szerencsére a Boxing/Unboxing műveletek többnyire könnyen elkerülhetőek.

Teljesítményromlás

A témában fellelhető cikkek jelentős része említi a Boxing/Unboxing által okozott teljesítményromlást. A tesztprogramok egy csoportjának a felépítése a következő:

struct Vödör
{
  public double átmérő;
  public double mélység;
}
...
Object [] ObjectArray = new Object[5000000];
for( Int32 i = 0; i < 5000000; ++i )
  ObjectArray[i] = new Vödör();                  // Boxing
...
Vödör [] VödörArray = new Vödör[5000000];
for( Int32 i = 0; i < 5000000; ++i )
  VödörArray[i] = new Vödör();                   // Nincs Boxing
...

Az első, Object tömböt használó ciklus minden egyes lépésében egy Boxing műveletet kell végrehajtani. A ciklus futásideje 2.4 másodperc. A második ciklus nem használ Boxing műveletet, a futásidő 0.2 másodperc. Mi okozza a több, mint tízszeres sebességnövekedést a második ciklusban? Ilyen lassú lenne a Boxing?

Az igazság az, hogy a fenti tesztprogram félrevezető. Nem a Boxing művelet hátrányát mutatja be, hanem az értéktípusok előnyét. Az első ciklus előtti memóriafoglalás ötmillió referenciának készíti elő a helyet. Bármelyik referenciatípus esetén ugyanez történne. A ciklusmag minden egyes végrehajtásában egy új objektum jön létre a halmon. Bármelyik referenciatípus esetén ugyanez történne. A különbség egy referenciatípus és a Vödör értéktípus típus között, hogy egy referenciatípus konstruktora a halmon létrehozott területen futna le. A Vödör értéktípus esetében a helyzet kicsit más. Egy vermen létrehozott ideiglenes változón futna le a konstruktor, ha lenne .NET-ben default konstruktora az értéktípusoknak. Ehelyett egy mezőnullázás történik, majd az így létrehozott Vödör típusú objektumot a rendszer átmásolja a halmon létrehozott keretbe. Az első ciklus tehát lényegében egy másolással végez többet ahhoz képest, mint ha a Vödör egy referenciatípus lenne.

Vajon ez a másolás okozza a tízszeres szorzót? Természetesen nem. A második ciklus előtti memóriafoglalás elegendő területet készít elő egy lépésben mind az ötmillió Vödör objektumnak. A ciklusmag így nem tesz már mást, csak mezőnullázással inicializálja az objektumokat. A fő különbség a két ciklus között tehát az, hogy az első ciklus ötmilliószor fordul a memóriakezelőhöz, a második pedig csak egyszer. Ez nem a Boxing művelet hibája, hanem a referenciatípusok sajátja. Az „igazságos” összevetés a Boxing és a Boxingot nélkülöző esetek között a következő tesztprogrammal történhet:

class Vödör                                     // A Vödör egy referenciatípus
{
  public double átmérő;
  public double mélység;
}
...
Vödör [] VödörArray = new Vödör[5000000];
for( Int32 i = 0; i < 5000000; ++i )
  VödörArray[i] = new Vödör();                  // Nincs Boxing
...

A futásidő ennél a változatnál 2.4 másodperc, tehát a ciklus nem gyorsabb, mint amikor a Boxing műveletet el kellett végezni.

Természetesen, az probléma, hogy két majdnem teljesen megegyező programkód futásideje között tízszeres különbség van. Az előző programkód rámutat arra, hogy érdemes alaposan megfontolni, hogy egy típust értéktípusként vagy referenciatípusként előnyős definiálni.

A fenti példában a rosszul használt értéktípus „csak” lerontotta a futásteljesítményt arra a szintre, mintha a típus referenciatípusként lett volna definiálva. Egy rossz választás vagy figyelmetlen használat azonban ennél sokkal nagyobb szintű teljesítménycsökkenéshez is vezethet – és itt már tényleg a Boxing miatt.

A következő példában a Vödör típus megvalósít egy interfészt:

interface MerítőEszköz
{
  double Térfogat { get; }
}

struct Vödör : MerítőEszköz
{
  public double átmérő;
  public double mélység;
  public double Térfogat
  {
    get { return (átmérő/2) * (átmérő/2) * System.Math.PI * mélység; }
  }
}

Ezt az interfészt használja a következő függvény:

double Merít( int MerítésSzám, MerítőEszköz m )
{
  return MerítésSzám * m.Térfogat;
}

A példakód egy olyan munkafolyamatot végez, amelyben rengetegszer kell meríteni ugyanazzal a merítőeszközzel. Ilyenkor a merítőeszközt elég egy példányban létrehozni, majd mindig újrafelhasználni. A való életben talán a leggyakoribb ehhez hasonló eszközhasználat a kontrolok újrarazolását végző OnPaint() függvény, ahol nem hatékony minden újrarajzoláskor létrehozni a font és egyéb rajzoláshoz szükséges objektumot, mert az csak feleslegesen teleszórná a memóriát.

Vödör v = new Vödör();
v.átmérő = 10.0;
v.mélység = 15.0;

double t = 0.0;
for( Int32 i = 0; i < 50000000; ++i )
  t += Merít( 10, v );

A programrészlet futásideje 8.7 másodperc. Ha a Vödör típus egy referenciatípus, akkor a futásidő lecsökken 5.7 másodpercre. A probléma az, hogy az interfész, mint típus, egy referenciatípus. A Merít() függvény tehát referenciatípusú paramétert vár, ezért minden hívásnál a v változóra lefut a Boxing művelet. Futás közben az idő egyik része memóriafoglalásra, másik része a v változó értékének keretbe másolására, harmadik része szemétgyűjtésre megy el. Egy valós alkalmazásnál az időveszteség még ennél is nagyobb lehet, mivel a szemétgyűjtőnek a példaprogramban ideális terepen kell dolgoznia: a felszabadítandó területek sorfolytonosak, nem kell lyukakat betömni és lényegében nincsenek változók, láncolt hivatkozások, amelyeket ki kell bogozni annak megállapítására, hogy egy objektumra mutat e referencia. Másik oldalról rá kell viszont mutatni, hogy egy ötvenmilliós iteráció kellett a három másodperces időveszteséghez.

Amennyiben biztos, hogy a készítendő programban az az előnyös megvalósítás, hogy a Vödör típus egy értéktípus, a Merít() függvény pedig MerítőEszköz referenciatípusú paramétert vár, akkor a számtalan Boxing művelet megelőzhető a következő módon:

MerítőEszköz m = v;
double t = 0.0;
for( Int32 i = 0; i < 50000000; ++i )
  t += Merít( 10, m  );

Ennek a kódnak a futásideje lényegében ugyanannyi, mint ha a Vödör típus referenciatípus lenne. A v változót m változónak értékül adva létrejön v változó értékének egy bekeretezett másolata a halmon. A ciklus magja később mindig ezt az m referenciát adja át paraméternek, így nincs szükség újabb Boxing műveletre.

A generikus típusok bevezetésének egyik nagy haszna a Boxing/Unboxing műveletek számának jelentős csökkenthetősége, ezáltal a futásidő javítása. Érdekes, hogy van olyan forrás, ami nem osztja ezt a véleményt. Nem lenne érdemes megjegyzésre sem, de pont a Microsoft Press 70-536 vizsgafelkészítő könyvéről van szó, ahol a Tony Northrup ellenvéleményének ad hangot.* Nurthrup tesztjei szerint a cast művelet gyorsabb, mint a generikus megoldások. Sajnos nem tudni, milyen programkóddal történt a teszt.

A következő példa a generikus típusok előnyét bizonyítja. Az első Vödör definíció megvalósít egy nem generikus interfészt. Az IComparable interfész akkor szükséges például, ha egy tömbben rendezni vagy keresni kell elemeket. A nem generikus interfészfüggvény Object típusú paramétert vár, ami szükségessé teszi a Boxing műveletet. A példaprogram implementációja nem korrekt, a CompareTo() függvénynek el kellene végeznie a megfelelő típusellenőrzéseket:

struct Vödör : IComparable
{
  public double átmérő;
  public double mélység;

  public double Térfogat
  {
    get { return (átmérő/2) * (átmérő/2) * System.Math.PI * mélység; }
  }

  public int CompareTo( Object o )
  {
    return this.Térfogat.CompareTo( ((Vödör)o).Térfogat );
  }
}

A fenti Vödör típusdefiníció az IComparable interfész segítségével használható az alábbi programkódban:

Random rnd = new Random( 0 );

Vödör[] VödörArray = new Vödör[5000000];
for( Int32 i = 0; i < 5000000; ++i )
{
  VödörArray[i] = new Vödör();
  VödörArray[i].átmérő = rnd.NextDouble();
  VödörArray[i].mélység = rnd.NextDouble();
}

Array.Sort( VödörArray );

Az Array.Sort() függvény futásideje 67 másodperc. Maga a függvényhívás egyébként egy Array<Vödör>.Sort(), azaz generikus. A C# fordító az átadott paraméter alapján ki tudja következtetni, hogy melyik generikus függvényt kell meghívni, akkor is, ha a függvény nevéből a rész hiányzik. A teszt végeredményén ez nem változtat, mert a rendezés intenzíven hívja a nem generikus CompareTo() függvényt, ami a teljesítményromlásért felelős. Az elemrendezés alatt több, mint negyvenötezer szemétgyűjtés futott le, ami nem meglepő. Egy QuickSort algoritmus ötmillió elemnél nagyságrendileg minimum százmillió összehasonlítást végez. A példában ez ugyanennyi Boxingot jelent. Mint a korábbi példákban, a szemétgyűjtőnek a kicsi példaprogram esetén könnyebb feladata van, mint egy éles programnál, így a futásidő éles környezetben ennél több is lehet.

Apró módosítással a Vödör típus a generikus IComparable interfészt valósítja meg:

struct Vödör : IComparable<Vödör>
{
…
  public int CompareTo( Vödör v )
  {
    return this.Térfogat.CompareTo( v.Térfogat );
  }
}

Ekkor a futásidő 28 másodpercre csökken. Csak az érdekesség kedvéért, ki lehet próbálni, hogy mennyi idő alatt fut le a rendezés, ha a Vödör egy referenciatípus. Ebben az esetben a rendező eljárás csak a referenciákat mozgatja a tömbben, ami gyorsabb lehet, mint az értéktípus esetén, ahol a két Double típusú mezőt kell mozgatni 16 byte terjedelemben. Éppen ezért nagyon meglepő, hogy a futásidő 55 másodperc, majdnem olyan rossz, mint Boxing-nál. Miért lehet ez? A véletlenszám generátor minden futtatásnál nulla seed értéket kapott, hogy a különbözö szerencsés/szerencsétlen eloszlások ne befolyásolhassák a futásidőt.

Egy korábbi “futásidő anomáliához” hasonlóan itt is a processzor gyorsítómemóriája a megoldás kulcsa. A folyamat megértéséhez figyelembe kell venni, hogy az Array.Sort() függvény a QuickSort algoritmust használja a rendezéshez. A QuitckSort algoritmus működése során a rendezni kívánt értékeket két kisebb csoportra osztja, majd ugyanezt az elvet folytatja a kisebb csoportokon újra és újra – a csoportok folyamatosan egyre kisebbek lesznek. A tesztprogram ötmillió értéket rendez, egy Vödör értéktípusú objektum 16 byte memóriát foglal. Az első lépésben a program tehát egy nyolcvan megabyte méretű területen dolgozik. Durva általánosítással a csoportok mérete minden lépésben feleződik. Ekkor nyolc lépésen belül kb 300 kilóbyte-ra csökken egy csoport mérete, ami már sok processzor gyorsítótárjában egy az egyben elfér. Mivel az algoritmus a nyolcadik lépésben éri el ezt a csoportméretet, várhatólag 256 olyan csoporttal kell dolgoznia, ami nem fér el teljes mértékben a gyorsítótárban. Ez nagyon kevés ahhoz képest, hogy az algoritmus az ötmillió elemű tömb rendezéséhez várhatólag ötmillió csoportot hoz létre a teljes rendezettség eléréséig. Ezek alapján kijelenthető, hogy értéktípusok esetében a processzor szinte csak a gyorsítómemóriát használja az algoritmus futtatásához.

Referenciatípusok esetében az algoritmus csak a referenciákat csoportosítja, a hivatkozott objektum a memóriában a helyén marad. Hiába hoz létre az algoritmus kis méretű csoportokat, az értéktípusú objektumokkal ellentétben a hivatkozott memória a nyolcvan megabyte-on belül bárhol lehet, a gyorsítótár ezért jóval kevésbé hatékonyan tud működni. Ezért van az, hogy a referenciatípusok esetében a futásidő jelentősen rosszabb.

Egy értéktípusnál nem jellemző, hogy szükséges a használata, de a témához tartozik, hogy értéktípusra a GetType() függvény alkalmazása szintén Boxing műveletet igényel. Ez azért van így, mert a GetType() függvény az Object típustól örökölt. Az Object referenciatípus, így a GetType() függvény implementációja rejtett this paraméterként egy referenciatípus mutatót vár, és a program olyan utasításokat tartalmaz, amely feltételezi, hogy a mutató valóban referenciatípusú objektumra, azaz megfelelő kerettel ellátott objektumra mutat. A többi Object típustól örökölt függvényt – a MemberwiseClone() kivételével – felülírja a ValueType típus, így azok használata nem okoz Boxing műveletet. A MemberwiseClone() protected függvény, és értéktípusból nem lehet új típust származtatni. Ezért a MemberwiseClone() hívásának nincs túl nagy esélye. Saját típuson belül ugyan hívható, de aki ilyen elvetemült módon akarja lemásolni a saját típusát, annak a kódjában nem a felesleges Boxing lesz a legnagyobb probléma.

Nagyon sok forrás mutatja be a Boxing által okozott teljesítményromlást a Consol.WriteLine() függvényen keresztül. Ez azért nem szerencsés példa, mert a következő sorok írására ösztönözhet:

Console.WriteLine(
  "Ezek az eredmények: {0}, {1}, {2}, {3}",
  i.ToString(), (2*i).ToString(), (3*i).ToString(), (4*i).ToString()
);

Igen, a teljesítmény fontos szempont egy szoftver megírásánál. Legtöbb esetben fontosabb azonban a kód olvashatósága. A WriteLine(), akár konzolra ír, akár valamilyen stream-re, jellemzően nem az időkritikus feladatok közepén helyezkednek el. Hogy mégis mennyi idő spórolható meg a Boxing elkerülésén, kipróbálható a következő programkódokkal:

for( Int32 i = 0; i < 30000; i++ )
{
  Console.WriteLine(
    "Ezek az eredmények: {0}, {1}, {2}, {3}",
    i.ToString(), (2*i).ToString(), (3*i).ToString(), (4*i).ToString()
  );
}

A fenti program futásideje egy 50 soros konzol ablakon 21.1 másodperc, tíz mérés eredményét átlagolva. A szemétgyűjtések száma a tíz mérés alatt összesen 154. A következő programsorok egy fokkal jobban olvashatóak:

for( Int32 i = 0; i < 30000; i++ )
{
  Console.WriteLine(
    "Ezek az eredmények: {0}, {1}, {2}, {3}", i, (2 * i), (3 * i), (4 * i)
  );
}

A futásidő ebben az esetben 50 soros konzol ablakon 20.5 másodperc, tíz mérés eredményét átlagolva. A szemétgyűjtések száma összesen a tíz mérés alatt 168. Ez nem jelenti azt, hogy a Boxing elkerülését alkalmazó programkód még lassabb is. A különbség olyan kicsi, hogy valószínűleg mérési hiba (bár az ellenőrző futtatás 21.3 átlagidőt adott 21.1 helyett). Egy dolog világosan látszik, elméletben ugyan a plussz Boxing művelet lassítja a program futását, a gyakorlatban ez sorok tízezreinek-százezreinek a kiírása árán sem tűnik fel. Ez nem is csoda, egy korábbi példában ötvenmilliós iteráció kellett ahhoz, hogy a Boxing műveletek három másodperccel növeljék a futásidőt a Boxingot elkerülő megoldással szemben. (lásd.: függvény hívása interfész típusú paraméterrel). Mivel a kódolvashatóság a szoftver minőségét jelentősen befolyásolja, kár lenne feláldozni százezred másodpercekért.

A mellékhatások veszélyei

Egy program futásidejének csökkenése felhasználói szemmel zavaró lehet, legtöbb esetben viszont nem jelent ennél többet. A Boxing műveletek másik veszélye, a mellékhatások által okozott esetleges programhibák azonban a program helytelen működését okozhatják.

Az értéktípusú objektumok maguk hordozzák az értéküket. Ha egy értéktípusú változó értéke megváltozik, akkor biztos, hogy a többi változó értéke változatlan marad. Ezzel szemben a referenciatípusú változó csak hivatkozik egy objektumra. Ha egy referenciatípusú objektum állapota megváltozik, akkor az több referenciaváltozón keresztül látható. A különbség nem okoz problémát, mert a fejlesztő a különbséget még a környezet tanulása közben megszokja, és a gyakorlatban automatikusan a megfelelő módon használja őket. A Boxing viszont az értéktípusú változóból működését tekintve referenciatípusú változót állít elő. Már önmagában ez is zavart kelthet, de a bekeretezett, referenciatípusként működő értéktípusú objektumok használat közben ideiglenesen visszaváltozhatnak (Unboxing). A bekeretezett értéktípus ezért néha referenciatípusként, néha értéktípusként viselkedik. Ha a fejlesztő nem tudja ezt pontosan követni, súlyos programhibákat okozhat.

Egy valóban értéktípusnak való és jól tervezett típusnál kicsi a veszélye, hogy a fent említet zavaros helyzetekben kelljen azokat használni. Emiatt az alábbi példákban definiált értéktípusok tervezési szempontból „rondák” lesznek. A generikus típusok használatával még tovább csökkenthető a Boxing műveletek szükségessége.

A következő példa nem jól tervezett:

struct Vödör
{
  public Double átmérő;
  public Double mélység;

  public void Normalizál()
  {
    átmérő = Math.Pow( átmérő * átmérő * mélység, 1.0/3.0 );
    mélység = átmérő;
  }
}

A típus tesztelhető a következő programmal:

Random rnd = new Random();
ArrayList ar = new ArrayList();         // Object refereciákat tároló tömb
Vödör v;

for( Int32 i = 0; i < 10; ++i )         // Tömb feltöltése
{                                       // véletlen paraméterekkel
  v.átmérő = rnd.NextDouble();          // rendelkező vödrökkel
  v.mélység = 2 * v.átmérő;
  ar.Add( v );
}
                                        // Minden vödör normalizálása a tömbben,
for( Int32 i = 0; i < ar.Count; ++i )   // ekkor a mélység és átmérő mezők
  ((Vödör)ar[i]).Normalizál();          // azonos értéket vesznek fel

v = (Vödör)ar[0];
if( v.átmérő != v.mélység )             // Visszaellenőrzés
  Console.WriteLine("A vödör NEM normalizált!");
else
  Console.WriteLine("A vödör normalizált!");

A kódot lefuttatva megjelenik az „A vödör NEM normalizált” szöveg. Mi a jelenség oka? A memória állapota a program futása közben a 11-es ábrán látható. Az egyes pontban a v változó már inicializálva van. Az ar tömbnek még nincs egyetlen eleme sem. A kettes pontban meghívásra kerül az ar változó Add( Object ) függvénye. Ez egy Object, azaz referenciatípusú paramétert vár. Az átadott v paraméter értéktípusú, tehát szükséges egy bekeretezet másolatot készíteni a halmon. A harmadik pontban az Add() függvény beállítja az ar tömb belső szerkezetét. A tömb elemszáma most 1, az egyetlen eleme a kettes pontban a halmon létrehozott és paraméternek átadott, bekeretezett Vödör típusú objektum. A kettes és a hármas pont a feltöltést végző ciklus magjában ismétlődik. A negyedik pont már azt az állapotot mutatja, amikor a normalizálást végző for ciklus az ar tömb első elemén dolgozik. Az ar tömb Object típusú referenciákat tárol, de a Normalizál() függvény hívásához egy Vödör típusú objektum szükséges. Ezért az ar tömb elemein el kell végezni az Unboxing műveletet.

11-es ábra

A C# fordító által generált kód az Unboxing által visszaadott címről lemásolja a Vödör objektumot egy ideiglenes területre, amelyet az 11-es ábra t névvel jelöl. A Normalizál() függvény hívása a t ideiglenes objektumon történik. A hívás utáni állapot látható az ötödik pontban. A for ciklus összes lépésében az előzőleg leírt folyamat ismétlődik meg. A hatodik pontban a ciklus vége látható. Ekkor már nyoma sincs Normalizál() függvény által módosított értékeknek.

A fenti hiba a Boxing művelet hátulütőjének tűnhet, de az ilyen hibák leginkább a rossz tervezés és implementálás miatt fordulnak elő. A példaprogramban az a hiba, hogy egy értéktípusú objektumnak létrehozása után nem szabadna állapotot váltania. Egy értéktípusnál nincs is igazán értelme állapotról beszélni. Egy értéktípusú objektum egy értékhalmaz egy elemét képviseli. Ilyen értékhalmaz például a valós számok halmaza, a sík pontjainak halmaza vagy akár egy sakktáblán a lehetséges állások halmaza. A műveletek az értékhalmazon vannak értelmezve, nem magukon az értékeken. A halmazon értelmezett műveletek a matematikában függvények formájában jelennek meg, mint például f:R→R, f(x) = 2x. Ezek alapján az lenne logikus, ha az értékhalmazon értelmezett (matematikai) függvények a programban az értéktípus statikus programfüggvényeként jelennének meg, amelyek visszaadják a megvalósított (matematikai) függvény képének megfelelő objektumot. A Vödör típus Normalizál() függvénye esetében így:

public static Vödör Normalizál( Vödör x )
{
  Vödör y;

  y.átmérő = Math.Pow( x.átmérő * x.átmérő * x.mélység, 1.0/3.0 );
  y.mélység = y.átmérő;

  return y;
}

Ennek a megoldásnak van azonban egy problémája. A függvényparamétert a hívó szükségszerűen előállította a memóriában, különben nem tudná átadni. A fenti forma ezt az értéket feleslegesen lemásolja, hogy a függvényen belül, mint lokális x változó létezzen. Referencia szerinti átadással a másolás elkerülhető lenne, de az azt sugallná, hogy a függvény hatással van a paraméternek átadott változó értékére. Ehelyett a Normalizál() függvényt nem statikus függvényként kell definiálni. Ez ugyan azt az érzetet kelti, hogy a Normalizál() függvény az értéken és nem az értékhalmazon értelmezett, de programfejlesztői szemmel nézve ez bizonyos esetekben kisebb hátrány lehet, mint lejjebb adni a teljesítményből:

public Vödör Normalizál()
{
  Vödör y;

  y.átmérő = Math.Pow( this.átmérő * this.átmérő * this.mélység, 1.0/3.0 );
  y.mélység = y.átmérő;

  return y;
}

Ahol a teljesítményveszteség nem számottevő, ott az első forma a célszerűbb, mivel jobb leírása problémának. Azonban mindkét megoldás alkalmazása rákényszeríti a programozót, hogy a példában szereplő ar tömbön a Normalizál() függvényt a következő módon hívja meg:

for( Int32 i = 0; i < ar.Count; ++i )
  ar[i] = Vödör.Normalizál( (Vödör)ar[i] );

vagy ha a Normalizálás() függvény nem statikus:

for( Int32 i = 0; i < ar.Count; ++i )
  ar[i] = ((Vödör)ar[i]).Normalizál();

Az eredeti példaprogramon elvégezve a módosításokat a „Vödör normalizált!” szöveg jelenik meg.

A példakódban a problémát a Boxing művelet okozza, azonban a rosszul tervezett értéktípus miatt egy generikus adatszerkez használata sem biztos, hogy megment a tévedéstől. Ha az ar tömb típusa ArrayList helyett List, akkor a rosszul tervezet Vödör típust használó fejlesztő még mindig hajlamos lehet leírni a következőt:

for( Int32 i = 0; i < ar.Count; ++i )
  ar[i].Normalizál();

A tömb a ciklus lefutása után nem fog normalizált Vödröket tartalmazni. A hiba most az, hogy értéktípusú objektumokról lévén szó, az indexer a tömb adott elemének a másolatát adja vissza, nem a referenciáját. Emiatt a Normalizál() függvény a másolaton hajtódik végre, majd a másolat megszűnik.

A rosszul tervezett és megvalósított interfészek szintén hiba forrásai lehetnek. Kevés a valószínűsége, hogy valaki következő kódhoz hasonlót leírna, de a megoldás nem is működik:

public interface Normalizálható
{
  void Normalizál();
}

struct Vödör : Normalizálható
{
  public double átmérő;
  public double mélység;

  void Normalizálható.Normalizál()
  {
    átmérő = Math.Pow(átmérő * átmérő * mélység, 1.0 / 3.0);
    mélység = átmérő;
  }
}
…
Vödör v;
…
((Normalizálható)v).Normalizál();

A példában a v változó értéke nem lesz normalizált. A nem várt működést az okozza, hogy az interfész referenciatípusú, ezért a (Normalizálható)v cast egy Boxing műveletet okoz. A Normalizál() függvény az ideiglenesen bekeretezett objektumon fut le, v változó értéke érintetlen marad. Az interfészen keresztüli értékmódosítás értéktípusoknál minden esetben gyanakvásra ad okot.

Konklúzió

A Boxing/Unboxing műveletek gyakran ősellenségnek vannak kikiáltva. A megvizsgált esetek megmutatták, hogy a Boxing/Unboxing által okozott teljesítményvesztés gyakran minimális. Az is kiderült, hogy a Boxing által okozott mellékhatások inkább tervezési problémák esetén fordulnak elő, nem közvetlenül a Boxing mechanizmus használatának velejárója.

A Boxing a generikus típusok használatával legtöbb esetben elkerülhető. Ha mégis szükség van rá, elképzelhető, hogy a típus nem megfelelően lett megtervezve, vagy a rá épülő algoritmus, adatszerkezet nem jól megválasztott. Ha mindez rendben, és a Boxing műveletre természetes használat esetén mégis szükség van, nem kell mindent megtenni a Boxing elkerülésére. Abból csak erőltetett megoldások, nehezen olvasható kód születik, amelyek sokkal nagyobb valószínűséggel okoznak programhibákat, mint maga a Boxing.

  1. #1 by flata on 2010. December 17. - 13:13

    Nagyon jó cikk!

  2. #2 by Tóth Viktor on 2010. December 17. - 19:26

    Köszi, nem voltam biztos benne, hogy ekkora terjedelemben egyáltalán lesz, aki végigolvassa🙂

  3. #3 by csakgyakorlok on 2012. January 17. - 17:46

    Hát ez tök jó, nagyon köszi! Az egész blog zseniális.

  1. Megoldás – Minifeladatok II. - 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: