Szembenézni az ürességgel – 2. rész: Nullable

Korábban láttuk, miért lehet a null érték idegesítő problémaforrás. Bizonyos esetekben viszont kifejezetten szeretnénk, hogy egy változó, amely különben nem lehetne null, mégis null értéket vehessen fel.

Sorozatunkban az üresség különböző megjelenési formáit vizsgáljuk a C# nyelvben: a null referenciát, a Nullable és a void típusokat, illetve módszereket a nullal kapcsolatos problémák kivédésére.

zpp-zp8hyg0-nadine-shaabana

Képzeljünk el például egy weboldalt, amely minden felhasználóhoz tárolja az utolsó belépés idejét.

public class User
{
    public string FullName { get; set; }
    public string Email { get; set; }
    public string PasswordHash { get; set; }
    public DateTime LastLoginTime { get; set; }
}

Ha egy felhasználó épp most regisztrált, de még nem lépett be, azt szeretnénk, hogy a LastLoginTime tulajdonság valahogyan üres legyen.

var user = new User();
user.LastLoginTime = null; // fordítási idejű hiba

A tulajdonságnak hiába is próbálnánk null értéket adni. A DateTime értéktípus, ezért a fenti kód le sem fordul.

Kitüntetett értékek

Első ötletünk a probléma megodására, hogy az üresség kifejezésére a DateTime típus valamely, egyébként érvényes értékét jelöljük ki. Használhatjuk például a DateTime.MinValue értéket.

var user = new User();
user.LastLoginTime = DateTime.MinValue;

Ez így működik, de magából a kódból egyáltalán nem látszik, mit jelent ez az érték. Ilyenkor mindig jó ötlet egy konstanst bevezetni, amely megmagyarázza a literális érték jelentését, ezért mi is így fogunk tenni.

private readonly DateTime Never = DateTime.MinValue;

// később, egy metódusban
var user = new User();
user.LastLoginTime = Never;

Ez már egész jól érthető. De csupán a User osztályt megnézve fogalmunk sincs, hogy a LastLoginTime tulajdonság esetén a DateTime.MinValue érték különleges jelentéssel bír. Ezt persze dokumentálhatjuk egy XML-kommentben, de ha igazán őszinték vagyunk magunkhoz, beláthatjuk, hogy ezt senki sem fogja elolvasni. Ezen kívül, bár olyan biztosan nem fog előfordulni, hogy valaki Kr. u. 1-ben belépett a rendszerünkbe, és azóta soha nem tért vissza, ez csak egy szerencsés véletlen. A megoldásunk távolról sem általános. Sok olyan eset lehetséges, amikor a kérdéses értéktípus teljes értékkészletére szükségünk van, és az üresség kifejezésére egy érték sem marad.

Logikai mezők

Ez elvezet a második ötletünkhöz. Kitüntetett érték használata helyett az “üres” állapotot egy különálló bool tulajdonságba helyezzük ki.

public DateTime LastLoginTime { get; set; }
public bool HasLastLoginTime { get; set; }

Ezután már explicit módon jelezhetjük, ha a felhasználónak nincs utolsó belépési ideje; ez a lehetőség a User osztály nyilvános felületén is látszik.

var user = new User();
user.HasLastLoginTime = false;

Ez a közvetlen problémát megoldja, de egy másik továbbra is jelen van. A User osztály kliensének emlékeznie kell, hogy ne nyúljon a LastLoginTime-hoz, amíg a HasLastLoginTime értékét meg nem vizsgálta. Ezt az ellenőrzést valahogy ki kell kényszerítenünk.

Burkoló referenciatípus

Harmadik ötletünk pontosan ezt oldja meg. Ha tényleg azt szeretnénk, hogy a tulajdonság referenciatípusként viselkedjen, miért nem csinálunk belőle referenciatípust? Így ha a kliens egy null érték használatát kísérelné meg, azonnal NullReferenceExceptiont kapna, amely bár kellemetlen, pontosan kijelöli a probléma helyét, és nem hagy lehetőséget hibás működésre.

public object LastLoginTime { get; set; }

Használatjuk akár az object típust is, de így a típusbiztosságról teljesen lemondunk. Ha már statikusan típusos nyelvet használunk, ne adjuk fel a típusrendszer használatát az első kis nehézség láttán.

Sokkal szebb megoldás, ha egy burkolóosztályt – egy referenciatípust – definiálunk, mely egy DateTime értéket tárol.

public class NullableDateTime
{
    public DateTime Value { get; }

    public NullableDateTime(DateTime value)
    {
        Value = value;
    }

    public static explicit operator DateTime(NullableDateTime ndt)
    {
        if (ndt == null)
            throw new NullReferenceException(
                "Cannot convert null to DateTime");

        return ndt.Value;
    }

    public static implicit operator NullableDateTime(DateTime dt) =>
        new NullableDateTime(dt);
}

A konverziós operátorok miatt nagyon kellemes lesz a típus használata. Ha módosítjuk a User osztályt, hogy az új NullableDateTime osztályt használja a LastLoginTime tulajdonság típusaként,

public NullableDateTime LastLoginTime { get; set; }

akkor DateTime típusú értékre minden további nélkül beállíthatjuk, mert ebben az irányban létezik implicit konverzió. Ha a tulajdonság értékét adnánk egy DateTime típusú változónak, ezt csak explicit átalakítás (cast) segítségével tehetjük meg. Így figyelmeztetjük a hívót arra, hogy ez nem egy egyszerű DateTime példány, és így sugalljuk, hogy érdemes előbb a null értéket ellenőriznie.

var user = new User();
user.LastLoginTime = DateTime.Now;

DateTime lastLoginTime;
if (user.LastLoginTime != null)
{
    lastLoginTime = (DateTime)user.LastLoginTime;
}

Ez már egészen jó elgondolás, az eddigi ötleteinknél mindenképp jobb. Mégis van egy kellemetlen hiányossága. Ha nullértékű egészekre (int), lebegőpontos számokra (float, double) és más hasonló értéktípusokra van szükségünk a kódban, mindegyikükhöz definiálnunk kell ugyanezt a burkoló osztályt.

Generikus burkoló osztály

Erre egyszerű megoldást biztosít a generikus típusosság.

public class Nullable<T> where T : struct
{
    public T Value { get; }

    public Nullable(T value)
    {
        Value = value;
    }

    public static explicit operator T(Nullable<T> nullable)
    {
        if (nullable == null)
            throw new NullReferenceException(
                "Cannot convert null to " + typeof(T).Name);

        return nullable.Value;
    }

    public static implicit operator Nullable<T>(T value) =>
        new Nullable<T>(value);
}

Ha mindenhova, ahol korábban a DateTime-ot használtuk, a T generikus típusparamétert helyettesítjük, a fenti, meglepően jól működő megoldást kapjuk. Vegyük észre, hogy T-re a struct, vagyis értéktípus megszorítást alkalmaztuk. Ez azt is megelőzi, hogy a Nullable<Nullable<int>>-hez hasonló értelmetlen típusdeklarációkat írjunk, mert a Nullable referenciatípus.

Ha a LastLoginTime tulajdonság típusát a generikus típusra cseréljük,

public Nullable<DateTime> LastLoginTime { get; set; }

változatlanul használhatjuk tovább, viszont végtelen számú további nullértékű típust is deklarálhatunk. Az Equals és a GetHashCode metódusok megvalósításával igazán tökéletesre csiszolhatjuk az osztályt, és valóban, ez a megoldás már éles feladatokhoz is sikeresen felhasználható lenne.

Generikus burkoló struktúra

Ha sok helyen használjuk a Nullable osztályt, ahol korábban értéktípusokkal dolgoztunk, kiderülhet, hogy az új objektumpéldányok okozta többletterhelés teljesítményproblémákat okoz programunkban. Ezt valahogy meg kell előznünk. Ha kombináljuk korábbi kudarcos próbálkozásunkat a bool típusú tulajdonságok bevezetésére a burkoló osztály ötletével, a Nullable típust értéktípussá alakíthatjuk, melynek sokkal kedvezőbb a teljesítménye.

public struct Nullable<T> where T : struct
{
    private T value;

    public Nullable(T value)
    {
        this.value = value;
        HasValue = true;
    }       

    public bool HasValue { get; private set; }

    public T Value
    {
        get
        {
            if (!HasValue)
                throw new NullReferenceException();
            return value;
        }
    }

    public override bool Equals(object other)
    {
        if (!HasValue)
            return other == null;
        if (other == null)
            return false;

        return value.Equals(other);
    }

    public override int GetHashCode() =>
        HasValue ? value.GetHashCode() : 0;

    public override string ToString() =>
        HasValue ? value.ToString() : string.Empty;

    public static implicit operator Nullable<T>(T value) =>
        new Nullable<T>(value);

    public static explicit operator T(Nullable<T> value) =>
        value.Value;
}

Beláthatjuk, hogy e struktúra alapértékében a hasValue mező értéke false, épp ahogy szeretnénk. Ez majdnem tökéletes, egyetlen korábban említett esetben van még probléma. Ha ez egy értéktípus, a struct megszorítás többé nem véd meg minket az egymásba ágyazott nullértékű deklarációktól (pl. Nullable<Nullable<int>>).

Támogatás a C# nyelvben

Eljött az ideje, hogy beismerjem, egészen idáig megtévesztettem az Olvasót. A .NET keretrendszerben már létezik egy, a fentihez kísértetiesen hasonlóan megvalósított Nullable<T> típus, amelyből nem lehet egymásba ágyazott példányokat létrehozni. Érdemes megnézni a forráskódját!

Ezt a C# nyelv tervezői némi csalással érték el: a where T: struct megszorítást az alábbi módon definiálták:

A típusargumentum értéktípus kell, hogy legyen. Bármely nem nullértékű típus megadható. (The type argument must be a value type. Any value except Nullable can be specified.)

A nyelv egy kényelmes rövidítést is biztosít, ha nullértékű típusokkal szeretnénk dolgozni. Nullable<T> helyett T?-et írhatunk.

public DateTime? LastLoginTime { get; set; }

A C# nyelv a Nullable típust további képességekkel is felruházza, amely így inkább egy nullobjektumhoz hasonlít, mint a null referenciához. Ezt az úgynevezett operátor-beemelés (operator lifting) segítségével éri el. Bármely, a mögöttes típusra definiált operátor a nullértékű típuson is használható, azzal a megkötéssel, hogy ha bármely operandus értéke null, az eredmény is null lesz.

int? a = 2;
int? b = 3;
int? c = null;

int? d = a + b; // d == 5
int? e = b * c; // e == null

További hasznos eszköz a ?? operátor, mely az első operandusát adja vissza, ha az nem null, egyébként pedig a másodikat.

int? a = null;
int? b = 5;

int c = a ?? 10; // c == 10
int d = b ?? 10; // d == 5

Az operátor érdekes tulajdonsága, hogy láncolható.

int? x = null;
int? y = null;

int z = x ?? y ?? 10; // z == 10

Fontos kiegészítés, hogy a ?? operátor referenciatípusokkal is használható.

Az utolsó nyelvi szintű funkció a bool? típus kivételes viselkedése. Íme a logikai és és vagy műveletek igazságtáblája, ha azokat nullértékű típusokra alkalmazzuk:

x y x && y x || y
true true true true
true false false true
true null null true
false true false true
false false false false
false null false null
null true null true
null false false null
null null null null

Támogatás a futtatókörnyezetben

A Nullable<T> típus összes eddig bemutatott funkcióját vagy a keretrendszer, vagy a fordító biztosította. Ezek a futtatókörnyezettől teljesen függetlenek. Van azonban egy sajátos működés, nevezetesen a nullértékű típusok be- és kidobozolása (boxing, unboxing), amelyhez már a CLR támogatása is szükséges. Ennek bemutatása túlmutat a cikk keretein, de akit érdekel, ebben a Stack Overflow válaszban egy kiváló magyarázatot olvashat.

Befejezés

Eredeti problémánkat tehát annyival elintézhetjük, hogy egy kérdőjelet biggyesztünk a DateTime típus után az alábbi sorban:

public DateTime? LastLoginTime { get; set; }

Ám közben tettünk egy hatalmas utazást, és láttuk, mennyi ötlet és alkotó mérnöki munka áll egyetlen karakter mögött.

Források

A cikk elkészítése során rendkívül sokat segítettek az alábbi könyvek. Mindenkinek szívből ajánlom őket.

Szembenézni az ürességgel – 2. rész: Nullable

Vélemény, hozzászólás?

Adatok megadása vagy bejelentkezés valamelyik ikonnal:

WordPress.com Logo

Hozzászólhat a WordPress.com felhasználói fiók használatával. Kilépés / Módosítás )

Twitter kép

Hozzászólhat a Twitter felhasználói fiók használatával. Kilépés / Módosítás )

Facebook kép

Hozzászólhat a Facebook felhasználói fiók használatával. Kilépés / Módosítás )

Google+ kép

Hozzászólhat a Google+ felhasználói fiók használatával. Kilépés / Módosítás )

Kapcsolódás: %s