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

Szembenézni az ürességgel – 1. rész: A nullobjektum

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. Az első rész a nullobjektum (Null Object) tervezési mintát mutatja be.

photo-1473777785975-efb668f9ea99

A null referenciát olyan jól ismerjük, programozói életünk mindennapjait annyira áthatja, hogy nehéz lenne elképzelnünk nélküle a világot. Viszont rengeteg frusztrációt és konkrét anyagi veszteséget okozhat, ezért érdemes megvizsgálnunk, bizonyos esetekben hogyan kerülhetjük el a használatát.

De mi is pontosan a probléma?

Képzeljünk el egy olyan rendszert, amelyben az ügyfelek megrendeléseket (Order) adhatnak le. A rendelések sorokból (LineItem) állnak, melyek a mennyiséget és egy, a megrendelt termékre mutató referenciát tárolnak. Ezen kívül az árat is ki tudják számolni az egységár és a megadott mennyiség alapján.

public class LineItem
{
    public Product Product { get; }
    public decimal Quantity { get; }
    public decimal Price => Product.UnitPrice * Quantity;

    public LineItem(Product product, decimal quantity)
    {
        if (quantity <= 0)
             throw new ArgumentOutOfRangeException(nameof(quantity));
        if (product == null)
             throw new ArgumentNullException(nameof(product));

        Product = product;
        Quantity = quantity;
    }
}

Rendelésenként egy, de többféle típusú kedvezményt alkalmazhatunk. A kedvezmények kalkulációs logikáját a stratégia (Strategy) mintával kapcsoltuk le az Order osztályról.

public interface IDiscount
{
    decimal Calculate(decimal orderTotal);
}

public class CouponDiscount : IDiscount
{
    public CouponDiscount(string couponCode, decimal rate) { /* ... */ }
    public decimal Calculate(decimal orderTotal) => orderTotal * rate;
}

public class FrequentBuyerDiscount : IDiscount {/* ... */}

A rendelés végösszegéből a Discount objektum a csökkentett árat a saját szabályai szerint számítja ki.

Az Order osztály metódusokat biztosít sorok és egy kedvezmény felvételére, és egy tulajdonság segítségével lekérdezhető a kedvezmény alkalmazása utáni végösszeg.

public class Order
{
    public IEnumerable<LineItem> LineItems => lineItems.AsReadOnly();
    public decimal Total => discount.Calculate(totalBeforeDiscount);

    private decimal totalBeforeDiscount => lineItems.Sum(i => i.Price);
    private IDiscount discount;
    private List lineItems;

    public Order()
    {
        lineItems = new List<LineItem>();
    }

    public void AddLineItem(LineItem lineItem)
    {
        if (lineItem == null)
            throw new ArgumentNullException(nameof(lineItem));

        lineItems.Add(lineItem);
    }

    public void AddDiscount(IDiscount discount)
    {
        if (discount == null)
            throw new ArgumentNullException(nameof(discount));

        this.discount = discount;
    }
}

A fenti osztály teljesen ártalmatlannak tűnhet. Nincs itt hiba, hiszen mindent jól csináltunk, még arra is gondoltunk, hogy az argumentumok nem lehetnek null értékűek. Mégis nagyon valószínű, hogy a kód NullReferenceExceptiont fog dobni.

var order = new Order();
order.AddLineItem(new LineItem(product1, quantity: 2));
order.AddLineItem(new LineItem(product2, quantity: 1));
Console.WriteLine($"Total: {order.Total}"); // BUMM!!!

Hát persze! Elfelejtettünk kedvezményt rögzíteni.

De várjunk csak. Az teljesen normális, ha egy rendeléshez egyáltalán nem tartozik kedvezmény. Nem gond, erre is gyorsan felkészülhetünk.

public decimal Total =>
    discount == null
        ? totalBeforeDiscount
        : discount.Calculate(totalBeforeDiscount);

Ez a kód jól működik, bár szépnek semmiképp nem mondhatjuk. De az, hogy minden alkalommal, amikor a discount mezőhöz nyúlunk, fel kell készülnünk a null értékre, akkor is problémás, ha az esztétikai megfontolásokat félretesszük. Ha csak egyszer is elfelejtjük, egy NullReferenceException a jutalmunk. Ráadásul az alapértelmezett logikát, vagyis a totalBeforeDiscount értékének visszaadását minden alkalommal le kell másolnunk. Ez a logika nincs egységbe zárva.

Szerencsére van jobb megoldás. Nézzük meg, hogyan kezeltük pontosan ugyanezt a helyzetet a sorok felvételekor. A lineItems mezőt egy biztonságos alapértékre, az üres listára inicializáltuk a konstruktorban.

public Order()
{
    lineItems = new List<LineItem>();
}

Így már nem kellett felkészülnünk a null értékre az AddLineItem metódusban.

lineItems.Add(lineItem);

El tudunk képzelni hasonló alapértéket a kedvezménynek is? Természetesen. Ez egy olyan kedvezménytípus lesz, amely nem módosítja az átadott végösszeget.

public class NoDiscount : IDiscount
{
    public decimal Calculate(decimal orderTotal) => orderTotal;
}

Az ilyen alapérték-objektumokat nevezzük nullobjektumnak (Null Object). Ezt már használhatjuk kezdőértéknek a konstruktorban,

public Order()
{
    lineItems = new List<LineItem>();
    discount = new NoDiscount();
}

és megszabadulhatunk a problémás nullvizsgálattól a Total tulajdonságban.

public decimal Total => discount.Calculate(totalBeforeDiscount);

Búcsúzóul azért szükséges leszögeznünk, hogy a nullobjektumok alkalmazásakor érdemes önmérsékletet gyakorolni. Amikor az alapértelmezett viselkedés ártalmatlan és kiszámítható, a minta igen elegáns megoldásokhoz vezet. Ha azonban a nullobjektum viselkedése nem egyértelmű a kliens számára, akkor bizony egy NullReferenceException sokkal hasznosabb, mint a rosszul megtervezett nullobjektum alattomos működése.

Vélemények a cikkről a prog.hu-n

Szembenézni az ürességgel – 1. rész: A nullobjektum