Megoldás – Minifeladatok II.

Ez a cikk a Minifeladatok II megoldását tárgyalja.

A példában DoThatCalculation() és a DoFurtherCalculation() viselkedésében furcsa kettősség figyelhető meg. Bár mind a két metódus egy IIncrementable típust vár, a program kimenete alapján azt látjuk, hogy a DoFurtherCalculation() visszahat a hívásnál átadott értékre, míg a DoThatCalculation() nem:

Result after step 1 is 1
Result after step 2 is 2
Result is 0

A jelenség oka

A .NET interfészen keresztül csak referencia típusokat képes elérni. Emiatt a DoThatCalculation() hívás előtt egy boxing-ot hajt végre, hogy az érték típusú változó által reprezentált értékből egy referencia típusú példányt hozzon létre.

A Minifeladatok I megoldásánál láttuk a koncepcionális különbséget az objektum jellegű típusok (referencia típusok) és az érték típusok között. Az érték típus egy halmazelemet ábrázol, amely megfoghatatlan, amíg az objektum jellegű (vagy referencia) típus példánya egy megfogható valami.

Korábban a változókat papírfecniknek képzeltük el, amire érték típus esetén az érték reprezentációját (pl. 2006.08.12 14:42:12) míg referencia típus esetén a konkrét objektum példány hivatkozását írjuk fel. Ebbe a képbe a boxingot úgy helyezhetjük el, hogy fogunk egy érték típust reprezentáló papírfecnit, és a továbbiakban ezt egy objektumként kezeljük. Hogy biztosan érthető legyen, nem a papírfecni által reprezentált adatot tekintjük objektumnak, hanem magát a papírfecnit (ami ábrázolja az érték típusú elemet). Erre a papírfecnire, mint objektumra ezután már a szokásos módon lehet egy másik papírfecnivel hivatkozni, ugyanúgy, mint bármely más objektumra.

A fentiek alapján, a DoThatCalculation() hívása előtt létrejött a SuperValue reprezentációját hordozó objektum. A DoThatCalculation() ezután az így létrejött objektumra kapott egy referenciát. Az Increment() művelet az ezen példány által hordozott reprezentáció értékét írja át.

A DoFurtherCalculation() ugyanazt a referenciát kapja meg, mint a DoThatCalculation(), így az Increment ugyanazt az érték reprezentációt találja meg, ezért lesz az értéke kettő.

Miután a két metódus visszatér, a Main() az eredeti – érintetlen érték típusú változót írja ki, ezért lesz az utólsó kiírt érték nulla. (Az éles szeműek észreveszik, hogy a Consol.WriteLine() csinál egy másik boxingot)

Ha a DoThatCalculation() hívása után el szeretnénk érni a műveletek eredményét, akkor magunknak kell kikényszeríteni a boxingot, és a hívás után az objektumosított érték típusú változót kell kiíratni.

static void Main(string[] args)
{
    var value = new SuperValue() as IIncrementable;

    AlgorithmSuite.DoThatCalculation(value);

    Console.WriteLine("Result is {0}", value);
} // Main()

Ha mindez nem világos, remélhetőleg kiderül a következőkből, miről is van szó.

Miért csak referencián működik az interfész?

Jogosan merül fel a kérdés, hogy miért nem működik trükkök nélkül érték típusokon az interfészen keresztüli metódushívás? Magyarázhatnánk úgy, hogy az interfész a polimorfizmus egyik megvalósítási eszköze, és mint ilyen, az objektum orientált programozás világába tartozik. Láttuk a minifeladatok I magyarázatánál, hogy az érték típusok az objektum orientált világon kívüli dolgok, így a polimorfizmus létjogosultsága megkérdőjelezhető. Azonban nagyon sántítana, ha ilyet mondanánk. Egyrészt, akkor miért engedi meg a C#, hogy érték típusok interfészeket valósítsanak meg? Másrészt, miért valósítanak meg az alaptípusok, mint az Int32 kapásból jó négy féle interfészt?

Az ok, amiért az interfészek esetlenül működnek .NET/C# alatt, sokkal prózaiabb: így volt praktikus a .NET-et implementálni.

Mi működteti a polimorfizmust?

Először is, mi a poliformizmus gyakorlati szempontból? Az, hogy van egy “A” típusú változóm, amin keresztül egy nem feltétlenül “A” típusú objektummal végeztethetek el műveleteket. Az egyetlen, ami biztos, hogy az “A” által definiált összes művelet végrehajtható az “A” mögött levő valódi objektumon. C# esetében a polimorfizmusnak két fajtája van. Egyrészt megjelenik az öröklődésen keresztül, másrészt interfészeken keresztül. A poliformizmust próbálja érzékeltetni a következő ábra:

Ahhoz, hogy megértsük, miért nem triviális dolog kiszolgálni a polimorfizmust, ismerni kell, hogy milyen kódot generálnak a fordító programok. Ezt a témát már érintettem az érték típusokról szóló cikkben, így most csak gyorstalpaló módjára nézzük meg, miről van szó. Alacsony szinten (a gépikódú generált kódon) az objektum orientált működés eltűnik. Itt nem egy objektum példány metódusaival találkozunk, hanem objektumoktól független metódusokkal, amely paraméterként megkapja egy példány állapotát tároló memóriaterület címét, amin dolgoznia kell. Ez a paraméter a “this” pointer.

Tegyük fel, hogy van egy “A” típus, ennek van egy “Alma” művelete. Ebből a fordító generál egy programkódot, ami a program futása közben mondjuk, hogy az 0x1000-es memóriacímen helyezkedik el. Tegyük fel továbbá, hogy van egy “A” típusú “a” változónk, amelyre a program az “a.Alma()” metódushívást tartalmazza. Ekkor a fordító olyan kódot generál, ami a vezérlést az 0x1000-es címen levő metódusnak adja át, továbbá paraméterként átadja az “a” változó által referált memóriaterületet. (a .NET esetében a fenti fordítási folyamat a jitter miatt jóval bonyolultabb, de a lényeg akkor is ez)

A fent leírt folyamatnak az a problémája, hogy nem támogatja a polimorfizmust. Valójában csak azok a metódusokhívások generálódnak így, amelyek nem virtuálisak (és nem interfész metódust valósítanak meg). Miért nem jó ez virtuális metódusok esetében?

Tegyük fel, hogy van egy “A” típus egy “Alma” virtuális metódussal. Az a típusból származik “B” típus, ami felülírja az “Alma” metódust. A fordító az A.Alma() kódját a 0x1000-es, a B.Alma() kódját a 0x2000-es címre generálja. Amikor a fordító egy “A” típusú “a” változóval találkozik, akkor fordítási időben nem tudja, hogy ez az “a” változó majd futás közben “A” típusú vagy “B” típusú példányra fog referálni, hisz a gyakorlatban mindkét eset előfordulhat. Emiatt nem tud szimplán olyan kódot generálni, ami az 0x1000-es vagy 0x2000-es címen levő kódnak adja át a vezérlés. Olyan kódot kell generálni, ami futás közben megvizsgálja az “a” változó által mutatott példány típusát, és aszerint adja át a vezérlést az 0x1000-es vagy a 0x2000-es címre. Hogy ez pontosabban hogy működik, megtalálható az érték típusokról szóló cikkben, illetve az interfész metódusok működése a Mire Optimalizálsz című cikkben. Ami most számunkra fontos, hogy egy objektum példányhoz el kell tárolni az objektum típusát is, különben nem működik a polimorfizmus. Ezt a típusinformációt használja a fordító által generált program, hogy eldöntse, melyik metódust kell meghívnia.

A típusinformáció tárolása nem tűnik olyan nagy dolognak, ezért nézzük meg közelebbről, mit is jelent ez. A .NET ben a típusinformációt egy Type Handle nevű érték hordozza (gyakran hívják még Method Table Pointernek, mert ez a handle egy táblázat címét adja meg a memóriában), ami 32 bites rendszernél 4 byte, 64 bites rendszernél 8 byte területet foglal. Ha adva van a következő típus:

class Alma
{
    private double atmero;
    private double tomeg;

    virtual public void ErjelMeg() {}
} // class Alma

class FinomAlma : Alma
{
    override public void ErjelMeg() { }
} // class FinomAlma

...

Alma a = new FinomAlma();
a.ErjelMeg();

Akkor a példányok memórialenyomatát így kell elképzelni:

A memóriában tehát az Alma (és FinomAlma) állapotát leíró 2*8 byte helyett 2*8+4 byte-ot kell foglalni, hogy a Type Handle segítségével meg lehessen találni a megfelelő virtuális metódust. (bár az ábrán a nyíl közvetlenül mutat a megfelelő metódusra, valójában egy nagyobb táblázaton keresztül lehet elérni a metódusokat). A példakódban tehát hiába Alma típusú az “a” változó, egy a.ErjelMeg() hívás esetén a Type Handle-n keresztül meg lehet találni a megfelelő metódust – aminek az ára minden példány esetében a plusz 4 (64 bittes rendszernél 8) byte.

A helyzet pontosan ugyanez, ha interfész metódusokról van szó:

interface IEresreKepesValami
{
    void ErjelMeg();
} // EresreKepesValami

class Alma : IEresreKepesValami
{
    private double atmero;
    private double tomeg;

    public void ErjelMeg() {}
} // class Alma

class Sajt : IEresreKepesValami
{
    private double atlagosLyukmeret;

    public void ErjelMeg() { }
} // class Alma

IEresreKepesValami a = new FinomAlma();
a.ErjelMeg();

A generált kód ebben az esetben szintén az IEresreKepesValami típusú referencia által mutatott objektum type handle-jén keresztül találja meg, hogy végül is hol található a memóriában az a metódus, amit futtatni kell.

Úgy tűnik viszonylag olcsón megúsztuk a polimorfizmust kiszolgáló mechanizmust, példányonként 4 (8) plusz byte ráfordítással. Azonban nem vettünk figyelembe valamit. Az Alma típusnak van egy atmerő mezője, double típussal, ami megvalósítja a például az IFormattable interfészt. Ennél fogva, ha van egy ilyen metódusom:

void FancyDump(IFormattable toBeDumped) {...}

Akkor leírhatok az Alma egyik metódusába egy ilyet:

FancyDump(this.atmero);

A FancyDump() megvalósítását nem zavarja, hogy double vagy bármi mással van-e meghívva, neki a lényeg, hogy elérje a paraméter típusinformációját, hogy azon megkeresse, a konkrét paraméter esetében melyik ToString() implementációt kell hívnia (az IFormattable egyetlen metódusa a ToString()).

A fentiek alapján tehát az Alma példány memórialenyomata így kell, hogy kinézzen:

Itt már kezd drágának tűnni a polimorfizmus, és ráadásul a példában 8 byte-os double-k vannak, pedig gyakran használt primitív típus az int is a maga kis 4 byte-os memóriaigényével, amire a típusinformáció rádupláz. Mivel minden összetett típus primitív típusokból áll, amelyekhez a fentiek alapján oda kell csapni a típusinformációt, a poliformizmus kiszolgálása igen jelentősen növeli a memóriaigényt, akár duplájára is.

Lehet olcsóbban?

Valójában a .NET nem eszi olyan mértékben a memóriát, mint ahogyan a fentiekből következne. Miért kellett a típusinformáció? Azért, hogy az öröklődés illetve az interfészekkel szembeni programozás esetében futásidőben lehessen eldönteni, hogy milyen konkrét típushoz tartozó metódust kell meghívni.

Az öröklődés kérdésében a .NET nagyon egyszerű döntést hozott: érték típusoknál egyszerűen nincs öröklődés. Ennek az a következménye, hogy ha egy változó int típusú, vagy DateTime típusú, akkor az egészen biztos, hogy int vagy DateTime-ot hordoz. Ennél fogva nem lehet olyan metódust gyártani, amely DateTime vagy leszármazottjain működik, és emiatt nincs szükség a típusinformáció segítségével a konkrét metódusok feloldására sem. Emiatt el lehetne hagyni a típusinformációt – ha nem lennének a képben az interfészek.

Az interfészeket nem hagyta el a .NET az érték típusok esetében. Ennek oka egyszerű, interfészekkel szemben hasznos, általános célú algoritmusokat lehet készíteni. Legegyszerűbb példa erre a rendezés, amely az IComparable metódusát használja. Az interfészek miatt azonban szükség van a típusinformációra.

Másik oldalról, legtöbb esetben nem használjuk az érték típusok interfészeit, vagy ha igen, akkor is sok esetben template osztályok segítségével, ahol a futásidejű típusinformációra megint csak nincs szükség – az adott típusra testreszabott kód fog generálódni a template alapján. Marad tehát egy kicsi felhasználási kör, ami miatt nem biztos, hogy megéri minden program memóriaigényét 70%-80%-kal növelni. A .NET esetében egy kompromisszumos megoldás született. Alapesetben az érték típusok nem hordoznak típusinformációt. Abban az esetben viszont, amikor az érték típust interfészen keresztül akarjuk elérni, akkor abból egy speciális változat jön létre (a memória egy másik helyén). Ez a speciális változat ugyanúgy tartalmazza a Type Handle-t, mint a referencia típusok példányai. Az interfész típusú referencia utána ezt a speciális példányt kapja meg. Ez a folyamat a boxing. Ha az Alma típus “atmero” mezőjével meghívjuk a FancyDump(IFormattable toBeDumped) metódust, akkor tehát az alábbi folyamat megy végbe:

Mivel az Alma.atmero nem tartalmaz típusinformációt, a FancyDump ezen nem tud dolgozni. Emiatt egy boxingolt példány jön létre típusinformációval, és egy erre mutató referencia kerül átadásra a FancyDump()-nak.

A .NET tehát racionális okokból működik ilyen esetlenül, ha értéktípusokat rejtünk el interfészek mögé.

Konklúzió

Az objektum orientált / nem objektum orientált világok keveredésének újabb furcsaságával találkozhattunk. A két világ áthidalásának eszköze a Boxing. A gondot itt nem a Boxing okozta, tehát nem a Boxing mechanizmusa a rossz. Ami rossz, az az, hogy egyrészt a használt érték típus ebben a példában sem volt Immutable. Egy jól tervezett típussal a fenti jelenség nem fordult volna elő. Másrészt, ha már mindenképpen Mutable érték típust kell gyártani, és azt mindenképpen interfészen keresztül kell használni, akkor érdemest törekedni az implicit boxingok elkerülésére, hogy a mellékhatások ne lepjenek meg. Egyik módszer erre a template-ek használata, vagy ha ez nem lehetséges, akkor explicit módon a boxing kikényszerítése.

  1. #1 by szelpe on 2012. March 15. - 22:16

    Szia!

    Én mondjuk e helyett:

    var value = new SuperValue() as IIncrementable;

    Ezt írnám:

    IIncrementable value = new SuperValue();

    Ugyan az történik, de utóbbi esetben sokkal világosabb, hogy milyen típusú változó jön létre.

  2. #2 by Johnd177 on 2014. September 10. - 03:42

    Merely a smiling visitor here to share the adore , btw outstanding style. Audacity, more audacity and always audacity. by Georges Jacques Danton. cadddcfecedd

  1. Minifeladatok III. - 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: