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

Szembenézni az ürességgel – 1. rész: A nullobjektum” bejegyzéshez ozzászólás

  1. Peti szerint:

    A NullRef exception velemenyem szerint sosem hasznos, az mindig azt jelzi ha a programozo hibazott; es veszelyes is mert nem tudod eppen milyen state-ig jutott el a program kod, nem kerulunk-e invalid allapotba.

    Kedvelés

    1. Alapvetően egyetértünk, két kiegészítéssel.

      Egyrészt maga a kivétel nagyon hasznos, mert azonnal jelzi az említett programozói hibát. C++-ban például előfordulhat, hogy null mutatón sikeresen hív valaki metódust, ha a metódus nem használja az osztály tagváltozóit.

      Másrészt én nem terhelném a teljes felelősséget szegény programozóra, ennek jó része ugyanis a ma divatos nyelvek tervezőinek vállán nyugszik. Vannak nyelvek, ahol egyáltalán nincs null, pl. a Haskell. Ott az érték hiányát a Maybe típus jelöli, a típusrendszer explicit módon fejezi ki, ha egy érték opcionális. Az elterjedt nyelvek referenciatípusaival az egyik fő baj pont az, hogy legtöbbször nem akarjuk, hogy null is lehessen a változók/mezők értéke, de ezt nem tudjuk sehogy megtiltani.

      A témában egyébként a legjobb cikk ez:
      https://www.lucidchart.com/techblog/2015/08/31/the-worst-mistake-of-computer-science/

      Az egész cikksorozatom célja pont az lesz, hogy megmutassam, a nullra nem kell adottságként tekinteni, C#-ban is vannak eszközök a megszelídítésére, és a jövő útja mindenképp a null kiiktatásához vezet.

      Remélhetőleg már a C# 8-ban lesznek olyan referenciatípusok, melyek értéke soha nem lehet null:

      https://github.com/dotnet/roslyn/issues/5032
      https://github.com/dotnet/roslyn/issues/227

      A Roslyn csapat legalábbis láthatóan rajta van az ügyön.

      Kedvelés

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