Értéktípus konstruktorok és a szecskáztatás

Nemrégiben egy ifjú ám tehetséges kollegám mesélt a próbatételeiről, amelyekkel szembesül, amint ki akar törni junior státuszából. Ennek során különböző feladatokat old meg, illetve kérdésekre válaszol. Az egyik ilyen kérdés, hogy “felül lehet-e írni az értéktípusok default konstruktorát?”.

A kérdés jó interjú illetve szecskáztatós kérdés, de az egyszerű válasz helyett sok furcsaságot is lehet mesélni a témáról. Ezeknek járunk utána a következőkben.

A tények

A C# specifikáció szerint minden értéktípus implicit deklarál egy paraméter nélküli konstruktort, amit default konstruktornak hívnak. A név félrevezető lehet a C++ háttérrel rendelkezőknek, vagy a CLI felöl közeledőknek, ahol minden paraméter nélküli konstruktort default konstruktornak hívnak, nem csak azokat, amelyeket a fordító program generál. Az implicit default konstruktornak két következménye van.

Az egyik, hogy működik a következő:

struct SimpleValType
{
  public int Tag { get; set; }
} // struct SimpleValType
...
var t = new SimpleValType(); // implicit deklarált konstruktor hívása

A másik, hogy nem működik a következő:

struct SimpleValType
{
  public int Tag { get; set; }
  public SimpleValType() { this.Tag = 1; } // Error
} // struct SimpleValType

A probléma az, hogy mivel a SimpleValType már implicit rendelkezik egy paraméter nélküli konstruktorral, nem írhatok neki egy másik paraméter nélküli konstruktort, hiszen egy típus nem rendelkezhet két egyforma szignatúrájú konstruktorral.

A másik tény a referencia típusok konstruktorával kapcsolatos. A C# specifikáció szerint, ha egy referencia típus deklarációja nem tartalmaz konstruktor deklarációt, akkor az osztály kap egy paraméter nélküli default konstruktort, ami csak szimplán meghívja az ősosztály konstruktorát (miután végrehajtotta az változó inicializereket, ha vannak). Ennek értelmében működik a következő:

class SimpleRefType
{
  public int Tag { get; set; }
} // class SimpleRefType
...
var t = new SimpleRefType(); // Mi nem adtunk konstruktort, de kapunk egy default implementációt

Illetve az értéktípusokkal ellentétben megtehetjük ezt:

class SimpleRefType
{
  public int Tag { get; set; }
  public SimpleRefType() { this.Tag = 1; }
} // class SimpleRefType

Mivel a default konstruktor referencia típus esetén nem implicit deklarált, hanem csak abban az esetben jön létre, ha a programozó nem készített konstruktort, nincs akadályoztatva a saját paraméter nélküli konstruktor deklarálása. Látszik, hogy a lehetőségek aszimmetrikusak. Miért? Hamarosan kiderítjük.

Mi a konstruktor?

Valószínűleg mindenki rendelkezik egy elég alapos képpel arról, hogy az objektum orientált nyelvekben mi is az a konstruktor. Van azonban néhány technikai különbség abban, hogy mit nevezünk C#-ban konstruktornak, és mi a CLI (Common Language Infrastructure) szerint a konstruktor. A különbségek két dologból adódnak.

Az egyik, hogy a C# tervezői pár dolgot átemeltek a C++-ból, a CLI-t viszont nem kötik a C++-hoz rokoni szálak. Emiatt C#-ban a konstruktor neve megegyezik az őt magábafoglaló osztály nevével, a CLI szerint a konstruktor neve mindig “.ctor”. A C# szerint a konstruktornál nem szabad definiálni a visszatérési érték típusát, a CLI szerint viszont ez void. Ezek apró dolgok, és a C# compiler úgy fordítja a kódot, hogy az a CLI-nek megfeleljen, azaz átnevezi és void visszatérési értéket rendel hozzá.

A különbségek másik csoportja abból adódik, hogy a C#-nak vannak olyan szolgáltatásai, amelyet közvetlenül a CLI nem támogat. Legtöbben tudják, hogy a C# konstruktorába generálódik az a kód, amelyet az osztály deklarációjakor változó inicializátorként megadunk. Ez azért van így, mert a CLI-ben nincsenek változó inicializátorok. Azt is szinte mindenki tudja, hogy a C#-ban a konstruktor egyik első feladataként meghívja az ősosztály konstruktorát. Ami érdekes viszont, hogy a CLI ezt nem követeli meg – bár egy ilyen kódot a CLR csak bizonyos feltételek teljesülése esetén enged futni. További különbség, hogy a CLI szerint a konstruktor a speciális nevén kívül még egy flag-gel is rendelkezik a metódusokat leíró metaadat táblában – a C# szintjén nem kell a metaadat táblázatokkal foglalkozni.

Szükség van a konstruktorra?

Megtudtuk, hogy a CLI/CLR nem követeli meg, hogy egy konstruktor hívja a típus ősének a konstruktorát. Ekkor azonban előfordulhat, hogy maga a konstruktor nem is csinál semmit. Ilyen konstruktort látszólag C#-ban is tudunk készíteni, de a C# fordító mindig a generált kód elejére helyez egy kis kódot, ami az ősosztály konstruktorát meghívja. Emiatt C#-ban nem tudunk ténylegesen üres konstruktort készíteni. Más nyelvekben, például közvetlenül IL-t használva ez megtehető, azaz készíthető olyan konstruktor, ami tényleg nem csinál semmit. Felmerül a kérdés, hogy akkor egyáltalán szükség van-e rá?

IL-t használva készíthető olyan osztály, aminek egyáltalán nincs konstruktora. A baj akkor kezdődik, ha az osztálynak egy példányát létre akarjuk hozni. IL-ben osztályok példányosítására a newobj IL művelet használható. Ennek a műveletnek viszont van egy paramétere, ami egy metaadat token, méghozzá a metódus definíciós táblára hivatkozó token.

Hogy mi az a metaadat token? A .NET-ben a megírt program rengeteg adata, mint például milyen típusok milyen metódusokat definiálnak, melyik metódus milyen lokális változókat használ, a program milyen külső modul milyen típusait használja és még sok más hasonló jellegű adat táblázatokba van gyűjtve. Az egyik ilyen táblázat például az összes osztály összes metódusának adatait felsorolja, minden sora egy metódust. A tábláknak van egy száma, a metódus definíciós tábláé például 6. A táblák sorainak szintén van egy sorszáma. A kettő kombinációjából össze lehet gyúrni egy metaadat tokent, például a 0x06000002 a metódus tábla második bejegyzését jelenti. A newobj tehát egy metaadat tokent vár, olyat, ami a metódustábla egy sorát jelöli.

Nem mindegy azonban, hogy milyen metódust jelöl ez a paraméter. Nem túl nehéz kitalálni, hogy egy konstruktor kell, hogy legyen, megfelelő névvel, megfelelően beállított flag-gel. Ezeket a feltételeket a jitter ellenőrzi, miközben generálja a newobj-hoz tartozó kódot. (érdeklődőknek, sscli20\clr\src\fjit\fjit.cpp, compileCEE_NEWOBJ() függvény)

Ha tehát példányosítani szeretnénk egy osztályt, mindenképpen kell, hogy legyen neki konstruktora, különben nincs mivel meghívni a newobj IL utasítást. Ezek alapján érthető, miért kénytelen a C# fordító konstruktort generálni, ha mi magunk nem készítünk egy osztálynak. Ha egy osztályt nem akarunk példányosítani, akkor persze nincs szükség default konstruktorra sem. Ezek miatt egy statikus osztálynak a C# fordító sem generál ilyet.

Mi a minimális konstruktor funkcionalitás?

Említésre került, hogy a CLI/CLR nem követeli meg, hogy egy konstruktor az ősosztály konstruktorát meghívja. Ezt ki is próbálhatjuk a következő egyszerű kis programmal. Az IL kód ne rettentsen el senkit, kommentben a lényeges sorok felett ott van, hogy hogyan kell ugyanazt leírni C#-ban:

.assembly extern mscorlib { auto }  
.assembly SimpleCtor {}          
.module SimpleCtor.exe


// public class SimpleRefType
.class public SimpleRefType                               
{                              
    // public SimpleRefType()
    .method public void .ctor()                           
    {    
        ret           // üres konstruktor implementáció
    } // .ctor()                                          
} // .class SimpleRefType     

// A program belépési pontja. C#-pal ellentétben nem kell, hogy egy
// osztály metódusa legyen.
.method public static void Run()
{
    .entrypoint

    // SimpleRefType refType; 
    .locals (class SimpleRefType refType)

    // refType = new SimpleRefType();
    newobj instance void SimpleRefType::.ctor()
    stloc refType

    ret
} // Run()

A SimpleRefType osztály konstruktora nem csinál semmit, de attól még szerepel hozzá egy megfelelő bejegyzés a metódus definícós metaadat táblában. Nekünk ennyi elég is, Ezt át lehet adni a newobj IL utasításnak, mint paraméter, és egy példányt létre tudunk hozni.

Katt a képre a nagyobb változatért

A fenti ábrán látszik az IL-ben írt program hexa reprezentációja. Az 1) pont mellett kiderül, hogy a newobj utasítás (0x73) paramétere egy metaadat token, aminek az értéke 0x06000002, azaz a 6-os tábla második bejegyzéséről van szó. A hatos tábla a metódus definíciós tábla, és ennek a második sora egy konstruktor metódust ír le. Hogy konstruktorról van szó, azt a 2) pontnál láthatjuk, mégpedig onnan, hogy a metódus neve a string tábla egy .ctor bejegyzésére hivatkozik. Ha kibogoznánk a 0x1806 flag bitjeit, azon szintén látszódna, hogy speciális függvényről van szó. Közvetlenül a metódustáblából nem látszik, hogy melyik osztály metódusáról van szó, ezt a 2-es tábla, a típusdefiníciós tábla adja meg. A legalsó táblázatban a 3) pont mellett látható, hogy melyik típus metódusai kezdődnek a 6-os tábla második sorától, és ez a típus a SimpleRefType (4 pont).

A fenti kis program lefordítható az

ilasm SimpleCtor.il

paranccsal, és a létrehozott exe állomány hiba nélkül lefut – legalábbis, amíg megfelelő jogokkal futhat. A gond akkor kezdődik, ha az assembly-t nem teljes jogokkal futtatjuk, ami könnyen előfordul internetről, vagy akár intranetes meghajtóról indított alkalmazások esetén. Próbáljuk meg lefuttatni az alkalmazást egy olyan application domain-en, aminek nincsenek teljes jogai. Ezt .NET 4.0 előtt egy sorral megtehettük volna, 4.0-nál már kicsit macerásabb az új sandbox model miatt:

using System;
using System.IO;
using System.Security;
using System.Security.Permissions;

namespace LimitedDomain
{
    class Program
    {
        static void Main(string[] args)
        {
            string basePath = @"c:\temp"; 

            // Egy minimális jogosultsághalmazt kell adni, különben a legegyszerűbb
            // program sem fog elindulni: 
            var permissions = new PermissionSet(PermissionState.None);            

            // Execution jog az abszolut minimum
            permissions.AddPermission(
                          new SecurityPermission(
                                 SecurityPermissionFlag.Execution));

            // File jogok kellenek, különben nem tudjuk beolvasni a programunkat
            permissions.AddPermission(
                          new FileIOPermission(
                                FileIOPermissionAccess.AllAccess, basePath));

            // Az alkalmazásunk egy konzol alkalmazás, kell neki az UI
            permissions.AddPermission(
                          new UIPermission(
                                PermissionState.Unrestricted));

            // Ezt minimum be kell állítani az appdomainen, bár mi nem használjuk
            var domainSetup = new AppDomainSetup();
            domainSetup.ApplicationBase = basePath;

            // Új appdomain csökkentett jogokkal
            var sandbox = 
                AppDomain.CreateDomain(
                    "Sandbox", null, domainSetup, permissions);

            // A furcsa konstruktorunk használata limitált jogokkal.
            sandbox.ExecuteAssembly(
                Path.Combine(basePath, "SimpleCtor.exe"));           
        } // Main()
    } // class Program
} // namespace LimitedDomain

A fenti program úgy indítja el az üres konstruktort használó alkalmazást, hogy nem teljes jogokkal fut. A program három jogot ad, ami minimálisan kell ahhoz, hogy az appdomain be tudja tölteni az alkalmazásunkat, és el tudja indítani. A futtatás eredménye a következő lesz:

A probléma az, hogy a jitter fordítás közben egy ellenőrzést végez, hogy a fordítandó kód biztonságosan futtatható-e. Ehhez egy “Code Verification” eljárást használ, sok szabállyal, amelyek közül az egyik, hogy egy referencia típus konstruktorának hívnia kell az ősosztály konstruktorát. A mi kódunk esetében az object konstruktorát kellene hívni. Mivel ez nem történik meg, a kód nem “verifiable”, emiatt jön az exception.

Teljes jogokkal futó kód esetében a program rendelkezik egy “Skip Verification” permission-nel, ami miatt a jitter nem foglalkozik azzal, hogy a kód verifiable vagy nem. Ezzel időt takarít meg, illetve erősen optimalizált kódok áthághatnak biztonsági szabályokat, például virtuózkodhatnak pointerekkel. Emiatt fut le az üres konstruktor parancssorból és lokális meghajtóról indítva. Egy kódról a PEVerify.exe tool segítségével is meg lehet mondani, hogy verifiable vagy nem:

Ahhoz tehát, hogy egy program általánosan használható legyen, a konstruktorból hívni kell az ősosztály konstruktorát. Ráadásul azelőtt kell hívni, mielőtt bármelyik másik tagfüggvényt meghívná a kód. Ezért nem engedi a C# fordító a this referenciát használni változó inicializátorokban, mivel az inicializátorok az ősosztály konstruktorának hívása előtt fognak lefutni. Ilyen esetekben nem tudná a fordító (illetve a CLR) garantálni, hogy a this referencián keresztül az inicializátor kód nem hív tagfüggvény még azelőtt, mielőtt a konstruktorlánc lefutna:

class SimpleValType
{
    // Error, nem hívható tagfüggvény az ősosztály konstruktora előtt. Az alábbi kód
    // pedig hamarabb fog lefutni.
    public int tag1 = GetThatValue();                 

    // Error, nem tudhatjuk, hogy a string.Format() vagy bármely más metódus vissza fog
    // hívni a this-en keresztül, például hívhatja a GetThatValue()-t vagy a ToString()-et.
    public string tag2 = string.Format("{0}", this); 

    public int GetThatValue()
    {
        return 4;
    }
} // struct SimpleValType

A C# fordító, egyéb objektum orientációval kapcsolatos megfontolások mellett tehát a fentiek miatt is kénytelen meghívni az ősosztály konstruktorát, és ezért ajánlatos nekünk is:

.assembly extern mscorlib { auto }  
.assembly StructDefCtor {}          
.module StructDefCtor.exe


// public class SimpleRefType
.class public SimpleRefType                               
{                              
    // public SimpleRefType()
    .method public void .ctor()                           
    {       
        // : base(). 
        // Figyeljük meg, hogy az IL szintjén kommersz függvényhívás történik.
        // A .ctor függvény 0-ik paraméterként megkapta a this-t, ezt tesszük a veremre,
        // hiszen az ősosztály paraméter nélküli konstruktora is várja ezt az 
        // értéket "paraméterként"
        ldarg.0
        call instance void [mscorlib]System.Object::.ctor()
        ret     
    } // .ctor()                                          
} // .class SimpleRefType     

.method public static void Run()
{
    .entrypoint

    // SimpleRefType refType; 
    .locals (class SimpleRefType refType)

    // refType = new SimpleRefType();
    newobj instance void SimpleRefType::.ctor()
    stloc refType

    ret
} // Run()

A programot lefordítva a kód mostmár átmegy a teszten:

Ezzel végeztünk is?

Találtunk indokot arra, hogy a C# fordító miért készít default konstruktort, és miért implementálja ezt olyan módon, ahogy. Talán páraknak feltünt, hogy a példákban folyamatosan referenciatípusok szerepeltek. Ez nem véletlen, ugyanis az értéktípusok teljesen másképpen működnek ezen a szinten. Hogy miért? A referencia és értéktípusok közötti különbség leírásra került az értéktípusokról szóló cikkben. A számunkra fontos különbség most az, hogy a referenciatípusokkal ellentétben az értéktípusok “példányai” implicit módon létre tudnak jönni. Létrehozunk egy értéktípus alapú tömböt, akkor ezzel létre jönnek maguk az értéktípusok példányai is, míg referencia típusok esetén csak null referenciák. Belépünk egy metódusba, amely értéktípusú lokális változókat használ, akkor a belépés pillanatában létrejönnek ezek a változók – még ha ez a C# kódból, illetve C++-os háttérrel gondolkodva nem is tűnik úgy. Amikor létrehozunk egy referencia típusú példányt, aminek értéktípusú mezői vannak, akkor a referencia típusnak foglalt terület már magában is foglalja az értéktípusú példányokat.

Látjuk tehát, hogy értéktípusú példányok más módon jönnek létre, mint a referenciatípusok. Ez nem feltétlenül gond, mindössze annyi kell, hogy az értéktípusoknak kötelezően legyen egy paraméter nélküli konstruktora, ami inicializálja az értéktípus területét. Azért kell a paraméter nélküli konstruktor, mert a fent említett helyeken nincs módunk paramétereket átadni a konstruktoroknak. Ezzel a gondolattal ráadásul közeledünk ahhoz, hogy miért mondja a C# specifikáció azt, hogy az értéktípusok rendelkeznek implicit módon egy paraméter nélküli default konstruktorral. Nem világos azonban, hogy miért nem adhat maga a programozó paraméter nélküli konstruktort értéktípusoknak – amikor ez referencia típusok esetén megtehető.

A helyzet az, hogy bár a paraméter nélküli konstruktor megoldana bizonyos problémákat, brutális teljesítményvesztést okozna. Gondoljunk bele: hívunk egy darab függvényt, ami használ mondjuk 3 értéktípusú lokális változót. Ehhez viszont lefutna három konstruktorhívás, azaz az egy függvényhívásból azonnal lett összesen négy. Ha így működne a CLR, senki nem használná a ,NET alapú nyelveket matematikai számításokra, egy 1000×1000 mátrix létrehozása kapásból egymillió függvényhívással indulna az értéktípus konstruktorok lefutása miatt.

Persze, legyen okos a jitter, és optimalizáljon. Azonban van egyszerűbb megoldás is: a CLR nem hív konstruktort az értéktípusok létrehozásánál, és ezt tudomásul kell venni. Értéktípusok néhány speciális helyen jönnek létre, mint például referencia típusok példányainak mezőiként, függvényparaméterként/visszatérési értékként, lokális változóként vagy ideiglenes változóként kifejezések kiértékelése közben.

Referenciatípusok mezőjeként szerencsés a helyzet, ugyanis a CLR garantálja, hogy a referencia típus példányainak lefoglalt terület kinullázásra kerül, így hiába nincs konstruktor hívás a befoglalt értéktípusokon, az értéktípus mezői legalább mindig ugyanarra az értékre (0/null) inicializálva lesznek. Paraméterként/visszatérési értékként vagy ideiglenes változóként kifejezés kiértékelése közben a típus egy másolt vagy számított értéket kap, emiatt mindegy, hogy előtte inicializálva volt vagy nem.

Az egyetlen izgalmas hely a lokális változók. Sokan azt hiszik, hogy a CLR ezekre is garantálja a nulla értéket, Ez azonban nem igaz, és IL-ben tudunk írni kódot, hogy lássuk, a CLR nem nulláz feltétlenül.

.assembly extern mscorlib { auto }  
.assembly StructDefCtor {}          
.module StructDefCtor.exe

// public struct SimpleValueType
.class public value SimpleValueType                       
{                                                   
    // public int tag;
    .field public int32 tag                               
	                                                      
    // public SimpleValueType()
    .method public void .ctor()                           
    {   
        // Consol.WriteLine(...)                                                 
        ldstr "SimpleValueType konstruktor"       
        call void [mscorlib]System.Console::WriteLine(string)  
        ret
    } // .ctor()
} // .class SimpleValueType                             

.method public static void Run()
{
    .entrypoint

    // SimpleValueType valueType;
    .locals (valuetype SimpleValueType valueType)

    // Az értéktípusokat nem kell külön létrehozni. A fenti .locals segítségével megmondtuk
    // hogy ebben a metódusban szükség van egy SimpleValueType-ra. Ennek megfelelően létrejött
    // egy "példány", és már használhatjuk is:

    // Consol.WriteLine(valueType.tag);
    ldloca.s valueType
    ldfld int32 SimpleValueType::tag
    call void [mscorlib]System.Console::WriteLine(int32)

    ret
} // Run()

A kimenetet elnézve látszik, hogy az értéktípus memóriaterületén szemét van, illetve az is látszik, hogy a paraméter nélküli konstruktor tényleg nem hívódott meg, hiszen nem írta ki a várt üzenetet.

Hol a biztonság?

A .NET biztonságos környezetnek van kikiáltva, amitől pedig elvárunk dolgokat. A változók nullára inicializálása egy ilyen elvárás lehet. Ez nem azért lenne jó, mert a programokban a nulla az tipikusan jó kezdőérték. Ez azért jó, mert adott változó minden futás esetén ugyanarról az értékről indul. Ha a nulla nem jó kezdőérték, és a programozó elfelejtette ezt beállítani, akkor az hamar kiderül, a program rosszul működik elejétől fogva. Ha a programozó elfelejt inicializálni, de a nulla az pont jó (vagy legalább nem okoz hibát), akkor százmillió lefutásból is mindig pont jó lesz – a program viselkedése következetes. Ha egy változó véletlenszerű értékkel indul, mert a CLR nem inicializál, akkor a program kiszámíthatatlanul kezd működni. A fenti példaprogram pedig azt mutatja, hogy a .NET-ben ilyen programozói hibát el lehet követni.

Sokan tudják, hogy a fenti jelenség minek köszönhető. A lokális változók a vermen jönnek létre, és a verem egy újrafelhasználható memóriaterület, és mint ilyen kiszámíthatatlan tartalommal bír. Valójában kicsit kiszámítható értékek kerülnek a veremre, de pont emiatt különlegesen sunyik az inicializálatlan változók. A programok nagyon hasonló sorrendben hívják a metódusokat, és sokszor hasonló műveleteket végeznek. Emiatt a vermen hasonló szemét marad, és emiatt az inicializálatlan érték nagy eséllyel vagy az elejétől gondot okoz, vagy nagy eséllyel nem okoz gondot – sokáig. Aztán egyszer pont akkor jön egy megszakítás az operációs rendszertől, amikor a vezérlés a bug-os metódus előtt van. Egy eszközkezelő kölcsönveszi a szálunkat, használgatja a vermet, és most más szemetet hagy rajta. Ezen az új szemét értéken bug már gondot fog okozni, de szándékosan ezt soha nem fogjuk tudni újra előidézni, és jön a szokásos “de hát működik” válasz.

Bár tényleg azt szoktuk mondani, hogy a lokális változók a vermen jönnek létre, ez azonban egy implementációs részlet, Ma még igaz, és a jitter tényleg olyan kódot generál, ami a vermet használja, holnap ki tudja. A CLI/CLR szemszögéből viszont nem verem van, hanem minden metódushoz tartozik egy “Method State”, aminek az egyik eleme a “Local Variable Array”. Amíg egy metódus futása tart, az állapota, mint az utasítás számláló, bejövő paraméterek és egyebek mellett a lokális változók a “Method State”-ben tárolódnak.

Azt, hogy ez a Method State-et hogyan kell konfigurálni (pl mekkora Local Variable Array-ra van szükség), az a metódus metaadataiból tudja a CLR. Ezek között a metaadatok között van egy érdekes flag, ami pedig azt mondja meg, hogy a Local Variable Array-t kell-e nullázni a metódus futtatása előtt, vagy nem, Ha kell nullázni, akkor a jitter a mai állapot szerint kinullázza a verem megfelelő részét, mivel a Local Variable Array ma a vermen jön létre. Miért nincs ez mindig bekapcsolva? Ennek megint csak a teljesítményveszteség az oka, egy kiélezett algoritmus esetén nem biztos, hogy szerencsés minden függvényhívásnál nullázgatni, Ekkor a programozó elképzelhető, hogy bevállalja a “kockázatot”, hogy majd odafigyel, és hátha mindent maga jól elrendez a változói inicializálásánál. Ugyanakkor az ilyen kódok megint csak megfelelő jogokkal képesek lefutni, hiszen nem verifiable-ök. A fenti StructDefCtor alkalmazás nem kapcsolja be a flag-et, ami a Local Variable Array-t kinulláztatja, emiatt nem verifiable:

Amennyiben bekapcsoljuk az említett flag-et, a lokális változók nullázódnak, és a kódunk használható lesz interneten keresztül is:

// SimpleValueType valueType;
// a sor kapott egy "init"-et
.locals init (valuetype SimpleValueType valueType)

Ki lehet találni, hogy a C# fordító szorgalmasan bekapcsolgatja ezt a flag-et az összes általa generált metódusra, emiatt az összes lokális változó nullázva lesz. Ez részben érthető, másrészt viszont elgondolkodtató, hogy akkor miért ragaszkodik olyan görcsösen a C# fordító ahhoz, hogy használat előtt a metódusokban minden lokális értéktípú változót explicit inicializáljunk? Ráadásul egyre gyanusabb kezd lenni az értéktípusok explicit default konstruktora. Minek ilyen, ha a CLR nem hívja meg?

Ha megpróbáljuk megkeresni a C# által generált kódban az implicit default konstruktort, kiderül, hogy ilyen nincs is. Az alábbi képernyő akármelyik értéktípust mutathatná, ezért az eredeti kódot be sem másolom. A lényeg, hogy a SimpleValType-nak nincs paraméter nélküli konstruktora:

A C# nem generált semmit. Nincs is értelme, ezt már tudjuk, hiszen meg sem hívódik. Felülírni meg pletykák szerint azért nem engedi a C#, mert azt sem hívná meg a CLR, és szegény C# programozó ezen esetleg csodálkozna. Ez utóbbi döntést kicsit furcsának, és valahol sértésnek érzem. Egy olyan fejlesztői környezetben, ahol egy közepes programhoz is könyvtári osztályok tucatjait, ha nem százait, LINQ-t, dependency property-t, lambdakifejezéseket closure-okkal és egyéb kevéssé triviális dolgokat kell használjon az ember, nem nézték ki a fejlesztőkből, hogy meg bír jegyezni egy mondatot: “értéktípusok esetén a paraméter nélküli konstruktorod NEM fog automatikusan meghívódni”. Akárhogy is, ez a C# sajátossága, és amint láttuk, IL-ben elkészíthetünk paraméter nélküli konstruktort.

var s = new SimpleValType();

Mivel a C# megígérte az implicit default konstruktort, kénytelen megengedni azt meghívni, különben hamar lebukna, hogy valójában nincs is ilyen. De mi történik egy paraméter nélküli “konstruktorhíváskor”? Azt már láttuk, hogy nincs ilyen konstruktor a C# fordító által összerakott értéktípusban. Az világos, hogy a CLR amúgy sem hívná meg, de ha nem létezik, akkor mi sem tudjuk. Pedig a C# szintjén mégis.

Ezt az ellentmondást feloldhatjuk, ha megvizsgáljuk, milyen kódot generált a C# fordító a konstruktorhívás helyére:

struct SimpleValType
{
    public int tag;
} // struct SimpleValType

class Program
{
    static void Main(string[] args)
    {
        var s = new SimpleValType();

        System.Console.WriteLine(s.tag);
    } // Main()
} // class Program

A fordított kód pedig:

A használt IL kód itt egy initobj. Ezt kifejezetten értéktípusok inicializálására vezették be, és amit csinál, az az, hogy kinullázza a példány összes mezőjét. Ezek alapján úgy tűnik, hogy olyan nagyot mégsem vetít a C# specifikáció, és tekinthetjük úgy, hogy az implicit default konstruktor egy szélsőségesen kioptimalizált és inline-olt konstruktor implementáció. Ez az elmélet akkor dől meg, ha másképpen inicializálunk egy értéktípust:

SimpleValType s;
s.tag = 1;

System.Console.WriteLine(s.tag);

A C# filozófia szerint létrejön egy példány, annak pedig lefut a konstruktora. Jó szándékúan azt mondtuk, hogy biztos az initobj az értéktípusok default konstruktora. Itt viszont már nincs “meghívva” a szuperoptimalizált implicit konstruktor sem, tehát az értéktípus példány konstruktorhívás nélkül jött létre. Olyan kódot generált a C# fordító, ami az értéktípus példányának egy mezőjébe közvetlenül beírja az 1-es értéket. (az ldc a veremre rak egy 32-bites 1-et, az stfld pedig eltárolja a korábban a veremre tett értéktípus címének egy mezőjébe) Ez persze a C# szintjén nem látszik, lehetne azt mondani, hogy deee, meghívódott a default konstruktor, csak hát felül lettek írva a nulla értékek a program által. A C# ilyen esetekben úgyis kényesen ügyel arra, hogy minden mezőnek értéket adjunk, különben nem engedi használni a példányt, tehát esélyünk sincs lebuktatni, hogy nem nullázott ki a meg nem valósított konstruktora semmit. A Local Variable Array pedig az init flag miatt egyébként is nulla.

De mi van akkor, ha létrehozáskor konstruktort használunk, de nem a paraméter nélkülit:

struct SimpleValType
{
    public int tag;

    public SimpleValType(int tag)
    {
        this.tag = tag;
    } // SimpleValType()
} // struct SimpleValType

class Program
{
    static void Main(string[] args)
    {
        var s = new SimpleValType(1);
        System.Console.WriteLine(s.tag);
    } // Main()
} // class Program

Látható, hogy a konstruktor itt közönséges függvényként van meghívva. Ez elsőre furcsa, de hát a konstruktor végső soron nem tud kevesebbet, mint egy közönséges függvény, miért ne lehetne úgy használni? Ezt kipróbálhatjuk referenciatípusok esetében is:

.assembly extern mscorlib { auto }  
.assembly CtorAsFunc {}          
.module CtorAsFunc.exe

// public class SimpleRefType
.class public SimpleRefType                               
{                              
    // public int tag;                           
	.field public int32 tag                               
	                      
    // public SimpleRefType()
	.method public void .ctor()                           
	{         
	    // Consol.WriteLine(...)
            ldstr "SimpleRefType konstruktor"         
            call void [mscorlib]System.Console::WriteLine(string)
            ret
	} // .ctor()                                          
} // .class SimpleRefType     

.method public static void Run()
{
	.entrypoint

	// SimpleRefType refType;
	.locals (class SimpleRefType refType)

	// refType = new SimpleRefType();
	newobj instance void SimpleRefType::.ctor()
	stloc refType

	// Consol.WriteLine(refType.tag);
	ldloc refType
	ldfld int32 SimpleRefType::tag
	call void [mscorlib]System.Console::WriteLine(int32)

	// refType.ctor()
	ldloc refType
	call instance void SimpleRefType::.ctor()

	ret
} // Run()

Némileg érdekes, hogy ez a kód verifiable, pedig a CLI specifikáció kimondja, hogy konstruktort csak az objektum létrehozásával kapcsolatban szabad meghívni (CLI Rule 22)

Azt tapasztaltuk tehát, hogy bár C# szinten akár egységesnek tűnhetnek a dolgok, a háttérben igencsak különböző mechanizmusok működnek. Ráadásul van egy furcsaság is.

Mitől fél a C# fordító?

Értéktípusok esetén tehát a C# tervezői próbálták kerekre csiszolni a konstruktorok koncepcióját, és valóban lehet úgy tekinteni, hogy vagy a paraméter nélküli konstruktor, vagy az általunk megadott paraméteres konstruktor meghívódik “példányosításkor”. Tudjuk már, hogy nem is, de most maradjunk C# szinten. Az implicit default konstruktort pedig azért nem lehet átírni, mert nem tudná biztosítani a C# fordító (vagy csak körülményesen, illetve nagy teljesítményveszteséggel), hogy az a kód lefusson.

Egyetlen kérdés maradt, mégpedig a következő: Referencia típusok esetén tudjuk, hogy nem kell külön nullára inicializálni az értéktípusú mezőket, mivel azok egyébként is ezt az értéket veszik fel. Tekinthetjük úgy, hogy ezeken lefut az implicit default konstruktor. A ReSharper ezért szorgalmasan szól is, bár arról megoszlanak a vélemények, hogy helyes-e mégis kiírni a nullára inicializálást, vagy nem.

Másik oldalról a C# fordító nem engedi használni az amúgy hasonlóan nullára inicializált lokális változókat, amíg az összes mezőjének értéket nem adunk, vagy használjuk direktben kiírva az implicit default konstruktort – vagy a saját paraméteres konstruktorunkat, aminek persze minden mezőt be kell állítania, ellentétben a referencia típusok konstruktorával.

A helyzet a referencia típusba foglalt értéktípusú mezők illetve az értéktípusú lokális változók esetében ugyanannak tűnik, azzal az apró különbséggel, hogy a lokális változók nullázása kikapcsolható. Igaz, maga a C# fordító generál olyan kódot, hogy ne legyen kikapcsolva, így elvileg adott metóduson belül a fordító kezében van a kontrol, hogy biztosítsa a nulla alapértéket. Másik oldalról a C# specifikáció azt állítja (vagy inkább érzékelteti), az implicit default konstruktor nullázza a mezőket, illetve C# oldalról nézve azt kellene hinnünk, hogy a konstruktorok meghívódnak példányosításkor, tehát elviekben inicializálva vannak, ha másra nem nullára. A C# mégsem engedi használni őket, amíg nem inicializáljuk a lokális változókat explicit módon is. Miért van ez így? Nekem nem sikerült rájönnöm. Ha valakinek van ötlete, szívesen várom.

Konklúzió

A default konstruktorok kezelése aszimmetrikusra sikerült értéktípusok és referenciatípusok esetén. Amint láttuk, ez pont annak a következménye, hogy a C# próbál kerek koncepciót adni a konstruktorok használatához, illetve próbálja óvni a tévedésektől a fejlesztőt. Hogy ezt jól csinálja-e, illetve megérte-e elvenni a paraméter nélküli konstruktor készítésének lehetőségét a fejlesztőktől, azt mindenki döntse el maga.

A bónusz

Mivel elég csapongó úton haladtunk végig a konstruktorok témakörén, nem akartam az egyébként odaillő helyre rakni még a következő kerülőt is: arról volt szó, hogy egy osztálypéldányt a newobj IL művelettel lehet létrehozni. Ez igaz, ugyanakkor a runtime megnyit különböző kiskapukat azért, hogy olyan dolgokat is meg lehessen valósítani, amelyek hasznosak, ugyanakkor nem férnek bele a kerek elméletbe. Van emiatt jó néhány C++-os függvénye a runtime-nak, amit elvileg lehet használni, és a .NET-es library-k használják is.

Egy ilyen gyöngyszem az a függvény, ami létrehoz nekünk a heapen egy osztálypéldányt, de úgy, hogy nem hív rá konstruktort. Ehhez nem is kell nekünk közvetlenül a runtime-ba hívogatni, hiszen a FormatterServices.GetUninitializedObject() metódus ezt megteszi helyettünk. Kezünkben van tehát egy eszköz, amivel létre lehet hozni objektumokat konstruktorhívás nélkül. Természetesen adódik a kérdés, hogy ekkor mégis lehet olyan osztály példányosítani, aminek nincsen konstruktora? Próbáljuk hát ki. Az alábbi kód elég csúnya, de csak pár soros:

.assembly extern mscorlib { auto }  
.assembly CtorCheat {}          
.module CtorCheat.exe

// public class SimpleRefType
// NINCS .ctor !!!
.class public SimpleRefType                               
{                              
    // public int tag;                           
	.field public int32 Tag                               
	                      
	// public void Kakukk()
	.method public void Kakukk()
	{
	    // A this argumentumban jön, és ennek akarjuk a Tag
	    // mezőjét:
	    // Consol.WriteLine(this.Tag)
        ldarg.0       
        ldfld      int32 SimpleRefType::Tag
        call       void [mscorlib]System.Console::WriteLine(int32)
	
	    // Consol.WriteLine("Kakukk")                                                 
		ldstr "Kakukk"       
		call void [mscorlib]System.Console::WriteLine(string)  
		ret
	} // Kakukk()
} // .class SimpleRefType     

.method public static void Run()
{
	.entrypoint

    // A FormatterServices.GetUninitializedObject() használjuk, ennek
    // egy Type példány kell:    
    // typeof(SimpleRefType)
    ldtoken SimpleRefType
    call    class [mscorlib]System.Type [mscorlib]System.Type::GetTypeFromHandle(valuetype [mscorlib]System.RuntimeTypeHandle)

    // A vermen van a Type példány, hívhatunk is tovább, mivel
    // statikus függvény esetén nem kell this a veremre.
    call    object [mscorlib]System.Runtime.Serialization.FormatterServices::GetUninitializedObject(class [mscorlib]System.Type)
    
    // Most a vermen van a létrehozott példány referenciája, ez jó is
    // lesz this-nek a következő híváshoz
    callvirt   instance void SimpleRefType::Kakukk()
    
	ret
} // Run()

A dolog tehát lehetséges, létrehozhatunk egy példányt akkor is, ha nincs konstruktora. A fenti kód ráadásul majdnem verfiable, és csak azért nem, mert a GetUninitializedObject() egy object-et tesz a veremre, és én arra hívom a Kakukk() függvényt. Egy cast-olással a kód verifiable lesz, tehát a trükk használható minimális jogok mellett is.

Hát, ennyit tudtam elmondani a konstruktorokról. Szóval hajrá szecskák, hajrá szecskáztatók!

  1. Leave a comment

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: