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
Czym jest jednak T w tej 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ę.
NuGet idzie z pomocą
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 on 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 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