NullWzór.5 W pewnym wpisie omówiłem, że niektórzy architekci nie lubią wartość NULL, która występuję we wszystkich typach referencyjnych. Co, jeśli chciałbyś się tego pozbyć i zapomnieć o tym, jak C# 8 próbuje rozwiązać ten problem

No cóż, wtedy korzystasz ze wzoru projektowego Null Object

Powiedzmy, że masz następującą bibliotekę, która korzysta z następującego interfejsu

public interface ILog
{
    void Info(string msg);
    void Error(string msg);
}

Interfejs jest następnie używany w takie klasie statusu gry. Gdy gracz dodaje monety do automatu gry, to zwiększamy mu liczbę kontynuacji. 

public class GameStats
{
    private ILog log;
    private int continues;

    public GameStats(ILog log)
    {
        this.log = log;
    }

    public void AddCoins(int amount)
    {
        continues += amount;
        log.Info
            ($"You add another continue to the game");
    }
}

Gdy dodajemy monety, to chcemy tę informację zalogować i wyświetlić w konsoli.

Tworzy więc do tego odpowiednią implementację ILog.

class ConsoleLog : ILog
{
    public void Info(string msg)
    {
        Console.WriteLine(msg);
    }
    public void Error(string msg)
    {
        Console.WriteLine("ERROR:" + msg);
    }
}

Wydaje się to proste, a co jeśli chciałbyś wyłączyć logowanie na raz we wszystkich klasach, które chcą by, ILog był do nich wstrzykiwany.

Najgłupsze podejście

Zawsze możesz zmienić interfejs ILog na klasę . Łamie to zasadę otwarte-zamknięte (Open–closed principle)

...ale możesz to walić.

To jednak twój kod i możesz robić w końcu wszystko w nim

public class ILog
{
  public virtual void Info(string msg) {}
  public virtual void Error(string msg) {}
}

Oto Twój null object. Jednak zróbmy to dobrze, bo jeśli twoje klasy chcą by ILog, był interfejsem, to masz jeszcze większy problem i bałagan.

NullObject

Spójrzmy jeszcze raz na konstruktor klasy GameStats

public GameStats(ILog log)
{
    this.log = log;
}

Skoro klasa potrzebuje czegoś, co implementuje ILog, to lepiej bezpiecznie jest uznać, że wysłanie tam wartości Null wywali aplikacje

Tutaj przychodzi rozwiązanie wzorca Null Object. Czyli tworzysz klasę implementującą ILog, ale nie tworzysz żadnej funkcjonalności do jej metod.

Klasa ta będzie robić nic

public sealed class NullLog : ILog
{
    public void Info(string msg) { }
    public void Error(string msg) { }
}

Zwróć uwagę na słowo kluczowe sealed. No cóż, skoro NullLog ma być obiektem bezcelowym, to nie ma sensu po nim dziedziczyć.

Inne rozwiązania

Używając składni najnowszego C# i operatora "?" który robi za sprawdzenie, czy obiekt nie ma wartości null, możesz w ogóle zapomnieć o tym wzorcu projektowy,

Chociaż będziesz musiał w kodzie postawiać wszędzie ten operator, aby nie było wpadki.

public void AddCoins(int amount)
{
    continues += amount;
    log?.Info
        ($"You add another continue to the game");
}

Jest jednak pewien problem, Ten kod nie komunikuje, że możesz przesłać wartość null do konstruktora. 

Null Object Virtual Proxy

Istnieje kolejny pomysł i rozwiązanie tego problemu. Zawsze możesz przecież utworzyć opcjonalny logger, który domyślnie nie będzie robił nic. Takie proksy 

public class OptionalLog : ILog
{
    private ILog impl;
    private static ILog NoLogging = null;

    public OptionalLog(ILog impl)
    {
        this.impl = impl;
    }
    public void Info(string msg)
    {
        impl?.Info(msg);
    }

    public void Error(string msg)
    {
        impl?.Error(msg);
    }
}

Umieszczają taką klasę Proksy do każdego konstruktora, zabezpieczasz się całkiem dobrze przed wartościami null. 

Gdy wartość null zostanie przekazana, to będziemy robić nic.

public GameStats(ILog log)
{
    this.log = new OptionalLog(log);
}

Czy jednak rzeczywiście tak jest?

var stats= new GameStats(null);
stats.AddCoins(5);

Jak widać dodanie monety, nie wywala aplikacji.

Dynamic Null Object

Mamy jednak kolejny problem z tym wzorcem projektowym

Aby stworzyć poprawnie Null Object dla wszystkich interfejsów, to niestety musisz utworzyć tyle samo klas.

Pytanie, jak możemy napisać jakiś automat, który by takie klasy tworzył jakoś generycznie

Dzięki Dynamiczność języka C# jest to możliwe.

Oto klasa generyczna Null<T>, która dziedziczy po dynamicznym obiekcie

public class Null<T> : DynamicObject where T:class
{
    public override bool TryInvokeMember(InvokeMemberBinder
    binder, object[] args, out object result)
    {
        var name = binder.Name;
        result = Activator.CreateInstance(binder.ReturnType);
        return true;
    }
}

Da ona brak funkcjonalności dla każdej metody klasy, która w tym typie generycznym się znajdzie

Czy jest jednak T w tym klasie?

T będzie interfejsem, do którego potrzebujemy obiektu, który nic nie robi. 

W tym kodzie brakuje nam jeszcze metody, która mogłaby zwrócić instancje tego obiektu. Niestety nie da się tego zrobić bez dodatkowej biblioteki o nazwie ImpromptuInterface

public class Null<T> : DynamicObject where T : class
{
    public override bool TryInvokeMember(InvokeMemberBinder
    binder, object[] args, out object result)
    {
        var name = binder.Name;
        result = Activator.CreateInstance(binder.ReturnType);
        return true;
    }

    public static T Instance
    {
        get
        {
            if (!typeof(T).IsInterface)
                throw new ArgumentException("I must be an interface type");
            return new Null<T>().ActLike<T>();
        }
    }
}

Będzie nam potrzebna metoda ActLike z tej biblioteki. Metoda ta bierze dynamiczny obiekt i tworzy jego ciało w trakcie działania programu.

Taki kod z buta sam się nie napiszę.

actlike.png

NuGet idzie z pomocą

actlike.png

Ostatnie nasze rozwiązanie wygląda tak.

var log = Null<ILog>.Instance;
var stats = new GameStats(log);
stats.Add(5);

Podsumowanie:

Jakie założenia mamy, gdy przesyłam typ referencyjny do konstruktora lub do metody

Czy może on być null, czy nigdy nie powinien być null? Czy musisz zawsze sprawdzać, czy to co zostało nam przesłane, jest null czy nie?

C# 8 rozwiązuje co prawda ten problem, ale nie każdy kod działa na najnowszej platformie .NET

Wzorzec Null Object jest próbą podejścia do tego problemu. Jednak nie jest to idealne rozwiązanie. W końcu Null Object może być wykorzystany tylko dla interfejsów, które ma mają metody, które niczego nie zwracają ("void").

Pamiętaj też, że jeśli wspierasz filozofię tego wzorca, to musisz powiedzieć o tym jawnie powiedzieć w swoim kodzie. Na przykład w konstruktorze ustawić wartość NullObject jako domyślną dla danego interfejsu

Dużo roboty z tym wzorcem więc może poczekać, aż każdy kod będzie pisany w C# 8