Oskar Dudycz

Pragmatycznie o programowaniu

Kilka spostrzeżeń o rekordach i nullowalnych typach referencyjnych w C#

2021-05-10 oskar dudycz.NET

cover

Cześć!

Pracuję aktualnie nad prezentacją na 4Developers o CQRS i nowościach w .NET 5 i C# 9. Męczę przy tym na różne sposoby rekordy (records) oraz nullowalne typy referencyjne (NRT - Nullable Reference Types). Przerobiłem już pod tym kątem wstępnie moje repozytorium z Event Sourcing: https://github.com/oskardudycz/EventSourcing.NetCore/pull/40.

Od razu powiem, że mam mieszane uczucia. Liczyłem, że dzięki nim będę mógł lepiej zaufać swoim typom. Czyli, że jeśli typ mi mówi, że nie jest nullem, to faktycznie nim nie jest.Jeśli utworzyłem obiekt, to że jest on niezmienialny i że spełnia zadane reguły. Jak to wyszło?

Zacznijmy od prostego rekordu reprezentującego transfer pieniężny:

public record MoneyTransfer(
    decimal Amount,
    Guid FromAccountId,
    Guid ToAccountId,
    // zawsze powinien być podany i nigdy nie być nullem
    string Title,
    // opcjonalny, dzięki nullowalnym typom referencyjnym
    // można to teraz zdefiniować
    string? Comment = null
)

Przykład użycia wygląda następująco:

var anna = Guid.NewGuid();
var john = Guid.NewGuid();
var amount = 100;

var moneyTransfer = new MoneyTransfer(
    amount,
    anna,
    john,
    "Money laundry",
    "Do not tell anyone!"
);

Póki co wszystko super. Mój transfer jest niezmienialny, jeśli spróbuję przypisać jako tytuł nulla, to kompilator mi na to nie pozwoli. Ekstra!

OK, tylko jak wymusić w rekordzie wymagania, żeby np. kwota była powyżej zera,a identyfikatory konta i tytuł nie były pustymi wartościami?

Tak jak pisałem kiedyś w mailu. Lubię stosować wzorzec [“Smart Constructor”](https://oskar-dudycz.netlify.app/pl/smartconstructor/)_. W skrócie mam faktorkę, która tworzy obiekt i waliduje wartości. Konstruktor nie ma w sobie walidacji, bo używam go do deserializacji. To samo mógłbym zrobić dla rekordów.

public record MoneyTransfer(
    decimal Amount,
    Guid FromAccountId,
    Guid ToAccountId,
    // zawsze powinien być podany i nigdy nie być nullem
    string Title,
    // opcjonalny, dzięki nullowalnym typom referencyjnym
    // można to teraz zdefiniować
    string? Comment = null
)
{
    public static MoneyTransfer Create(
        decimal Amount,
        Guid FromAccountId,
        Guid ToAccountId,
        string Title,
        string? Comment = null
    )
    {
        if (Amount <= 0) throw new ArgumentOutOfRangeException();

        if (FromAccountId == default) throw new ArgumentOutOfRangeException();
        if (ToAccountId == default) throw new ArgumentOutOfRangeException();

        if(Title.Trim().Length == 0) throw new ArgumentOutOfRangeException();

        return new(Amount, FromAccountId, ToAccountId, Title, Comment);
    }
};

Dzięki czemu mogę zrobić tak:

var moneyTransfer = MoneyTransfer.Create(
    amount,
    anna,
    john,
    "Money laundry",
    "Do not tell anyone!"
);

No i fajnie, tylko, że mogę potem zrobić tak z nową składnią dla rekordów:

var wrongMoneyTransfer = moneyTransfer with {Amount = -100};

Oczywiście mogę sobie zdefiniować prywatny konstruktor jak się uprę, albo dodać regułę walidacyjną dla właściwości. Tylko niestety coraz bardziej oddalam się od zalet, jakie wprowadzają rekordy.

Wróćmy jeszcze do nulli. Kompilator faktycznie po użyciu NRT nie pozwoli mi przypisać nulla do pola nie oznaczonego znakiem zapytania. No prawie, bo mogę zrobić tak:

var evenWorseMoneyTransfer = new MoneyTransfer(
    amount,
    anna,
    john,
    // i tak, mogę wymusić nulla, a co!
    null!,
    "Do not tell anyone!"
);

I przypiszę nulla, mimo, że nie powinienem móc tak zrobić. Może powiesz, że “OK, przecież nikt nie będzie na siłę tak robił”. I prawdą to może być co do ludzi. Dla serializerów już niekoniecznie. Jeśli ktoś nam wyśle request z nullem, to nasz kod się wywali przy deserializacji.

Niestety okazuje się, że nullowalne typy referencyjne to tylko synctactic sugar nałożony na język. Nie są to pełnoprawne byty w kompilatorze/runtime. Ciągle jeśli się chce można nulla przypisać.

Podsumowując, ani rekordy ani NRT nie są w pełni tym czym się wydawały na wstępnych prezentacjach. Używanie ich przypomina mi pisanie w TypeScript. Niby mamy typy zdefiniowane, ale jak odpalimy kod to pod spodem jest ciągle JavaScript. Tak samo tutaj, deserializacja lub wymuszenie przez programistę pozwoli nam przypisać nulle. Jestem w stanie sobie wyobrazić upierdliwy błąd z produkcji zrobiony przez ich błędne użycie.

Moja rekomendacja to:

  1. Używanie rekordów jako proste Data Transfer Objects - np. żądania z API. Do tego nadają się świetnie.
  2. Dla typów reprezentujących żądania z API zawsze oznaczać wszystkie pola jako nullowalne. Takie mogą być, możemy się spodziewać wszystkiego. Potem mapować je do już poprawnie otypowanych klas.
  3. Value Objecty raczej robić jako klasy tak aby mieć pewność, że typy są tym za jakie je uważamy.
  4. Pobawić się samemy i wyrobić swoją opinię.

Obydwie funkcjonalności są dobrym krokiem, ale niestety wyglądają na dodane pospiesznie i nie do końca przemyślane. Fajnie, że są, ale używając ich musimy dobrze wiedzieć co i jak robimy. Nie możemy zamknąć oczu i w 100% im zaufać.

Co o tym sądzisz?

Pozdrawiam!

Oskar

P.S. Zachęcam do lektury mojego ostatniego artykułu na blogu “Memoization, a useful pattern for quick optimization”. Jest to rozbudowana wersja starego newslettera. Dodałem tam implementacje “Thread-safe”, z czasowym cache oraz rekordencją. Zerknij też do nowego Architecture Weekly: https://github.com/oskardudycz/ArchitectureWeekly#10th-may-2021.

  • © Oskar Dudycz 2019-2020