🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Typy nullable w C# — wartościowe vs referencyjne

Nullable T vs Nullable Reference Types w C# — grafika

W poprzednim artykule “Class, struct czy record w C#?” ustaliliśmy, że struct to typ wartościowy — żyje na stosie (albo inline wewnątrz obiektu), nie może być null, zawsze ma jakąś wartość domyślną. int x; to zawsze 0, nigdy null. A jednak w kodzie produkcyjnym codziennie widzisz int? wiek, DateTime? dataUsuniecia czy kolumnę w EF Core, która dopuszcza NULL dla typu liczbowego. Jak to możliwe, skoro typ wartościowy z definicji nie może być pusty?

Odpowiedzią jest Nullable<T> — i to jest zupełnie inny mechanizm niż Nullable Reference Types (string?), które prawdopodobnie kojarzysz z ostrzeżeniami kompilatora w Visual Studio. Te dwie rzeczy używają tego samego znaku zapytania i tej samej nazwy „nullable”, ale działają na zupełnie różnych poziomach — jedna jest realną strukturą w runtime, druga jest wyłącznie analizą statyczną kompilatora. Mylenie ich to jeden z najczęstszych nieporozumień na pograniczu Junior/Mid.

Nullable<T> — struktura, nie magia języka

Nullable<T> to generyczna struktura zdefiniowana w BCL (System.Nullable<T>), która „opakowuje” typ wartościowy dodatkowym bitem informacji: czy wartość jest obecna. W uproszczeniu wygląda to tak:

public struct Nullable<T> where T : struct
{
    private readonly bool hasValue;
    private readonly T value;

    public bool HasValue => hasValue;
    public T Value => hasValue
        ? value
        : throw new InvalidOperationException("Nullable object must have a value.");

    public T GetValueOrDefault() => value;
    public T GetValueOrDefault(T defaultValue) => hasValue ? value : defaultValue;
}

int? to tylko skrót składniowy (syntactic sugar) na Nullable<int>. Kompilator traktuje ? po typie wartościowym jako cukier składniowy dokładnie w tym miejscu:

int? wiek = null;          // to samo co: Nullable<int> wiek = null;
wiek = 25;

if (wiek.HasValue)
{
    Console.WriteLine(wiek.Value); // 25
}

Kluczowa różnica względem zwykłego int: int? naprawdę może przechowywać stan „brak wartości” — nie przez konwencję (jak -1 czy int.MinValue jako umowny „pusty” wiek), tylko przez dedykowane pole hasValue. To eliminuje całą kategorię błędów, w których magic number udaje null.

Praca z Nullable<T> na co dzień

int? a = null;
int? b = 10;

int wynikA = a ?? 0;              // null-coalescing — 0
int wynikB = a.GetValueOrDefault(); // 0, bezpieczne, nie rzuca wyjątku

int? suma = a + b;                // null, bo a jest null (lifted operator)
bool porownanie = a > b;          // false, nie wyjątek

DateTime? dataZakonczenia = null;
DateTime dzisiaj = dataZakonczenia ?? DateTime.Today;

Trzy rzeczy, które warto zapamiętać z tego przykładu:

  • ?? (null-coalescing) to najbezpieczniejszy sposób odczytu wartości domyślnej. Czytelniejszy i krótszy niż HasValue + Value.
  • Value rzuca InvalidOperationException, jeśli HasValue jest false. To klasyczny błąd Juniora — odczytanie .Value bez wcześniejszego sprawdzenia HasValue albo bez ??. Efekt jest podobny do NullReferenceException, ale to inny wyjątek, z inną przyczyną.
  • Operatory arytmetyczne i porównania są “podniesione” (lifted) do wersji nullable. a + b, gdzie a lub b jest null, zwraca null, a nie rzuca wyjątku. Porównania (>, <, ==) z null po jednej stronie zwracają false (poza == i !=, które działają intuicyjnie: null == null to true). Kompilator generuje ten kod za Ciebie — to się nazywa lifted operators i warto znać tę nazwę, bo bywa pytana na rozmowach.

Pułapka: boxing Nullable<T> zachowuje się inaczej, niż się spodziewasz

To jest dokładnie ten rodzaj szczegółu, którego nie znajdziesz w większości tutoriali, a który potrafi zaskoczyć nawet doświadczonych developerów. Gdy boxujesz zwykły struct, na stercie powstaje obiekt reprezentujący tę strukturę. Ale gdy boxujesz Nullable<T> z HasValue == false, CLR nie tworzy boxed instancji Nullable<T> — zamiast tego wynikiem jest zwykłe null:

int? pusty = null;
object boxed = pusty;

Console.WriteLine(boxed == null); // true — nie "boxed Nullable ze HasValue=false"
Console.WriteLine(boxed is int?); // false, bo boxed to null

A gdy HasValue == true, boxing odpakowuje Nullable<T> do zwykłego boxed T — nie do boxed Nullable<T>:

int? maWartosc = 5;
object boxed = maWartosc;

Console.WriteLine(boxed.GetType()); // System.Int32, NIE System.Nullable`1[System.Int32]

To specjalny przypadek zaszyty bezpośrednio w CLR (nie w C#), specjalnie po to, by Nullable<T> zachowywał się przewidywalnie przy castowaniu do object — bez tego sprawdzanie is null na zboxowanym Nullable<T> byłoby źródłem bardzo trudnych do znalezienia bugów.

Nullable<T> vs Nullable Reference Types — to nie jest ten sam mechanizm

Tu leży sedno tego artykułu. string? wygląda składniowo identycznie jak int?, ale to, co się za tym kryje, jest fundamentalnie inne.

Nullable<T> (int?)Nullable Reference Types (string?)
DotyczyTypów wartościowych (struct)Typów referencyjnych (class, interfejsy)
Kiedy działaW runtime — realna struktura z polem HasValueWyłącznie w czasie kompilacji — analiza przepływu danych
Wpływ na ILTak — generowany jest realny typ Nullable<T>Żaden — string? i string kompilują się do identycznego IL
Czy runtime wymusza brak null?Tak, przez samą strukturę typuNie — string bez ? nadal może otrzymać null w runtime, np. z zewnętrznej biblioteki lub deserializacji
Wymaga włączeniaNie, dostępne domyślnie od C# 2.0Tak, <Nullable>enable</Nullable> w .csproj (domyślnie wyłączone w starszych projektach)
Co się dzieje przy naruszeniuInvalidOperationException przy próbie odczytu .ValueOstrzeżenie kompilatora (CS8602 itp.), które można zignorować i skompilować mimo to

Innymi słowy: int? to gwarancja na poziomie typu, egzekwowana przez CLR niezależnie od tego, kto i jak wywołuje Twój kod. string? to umowa między programistami, egzekwowana wyłącznie przez kompilator na etapie analizy statycznej — i tylko wtedy, gdy projekt ma włączony nullable context. Możesz dostać NullReferenceException na zmiennej typu string (bez ?) mimo włączonych Nullable Reference Types, jeśli źródłem null jest kod spoza analizy kompilatora (np. deserializacja JSON, refleksja, stary kod bez adnotacji) — kompilator w takich miejscach po prostu Ci zaufał albo nie miał wystarczających informacji.

Dlaczego to ma znaczenie w praktyce (EF Core, API, DDD)

W Entity Framework Core kolumna bazodanowa dopuszczająca NULL mapuje się naturalnie na Nullable<T> po stronie encji:

public class Zamowienie
{
    public int Id { get; set; }
    public DateTime DataZlozenia { get; set; }
    public DateTime? DataAnulowania { get; set; } // kolumna NULL-able w bazie
}

Tu DataAnulowania typu DateTime? niesie realną informację domenową: „zamówienie może, ale nie musi być anulowane” — to lepszy model niż umowna wartość DateTime.MinValue oznaczająca „brak anulowania”. To samo rozumowanie stoi za Value Objectami w DDD, o których pisałem w artykule o Value ObjectNullable<T> pozwala jawnie wyrazić w typie, że brak wartości jest legalnym stanem biznesowym, zamiast ukrywać to w konwencji nazewniczej czy komentarzu.

Pułapki produkcyjne

1. .Value bez sprawdzenia to bomba zegarowa. Dokładny odpowiednik NullReferenceException dla typów wartościowych. Jeśli piszesz wiek.Value bez HasValue albo ??, prędzej czy później dostaniesz InvalidOperationException na produkcji.

2. Domyślna wartość Nullable<T> to null, nie default(T). int? x; (pole niezainicjalizowane) ma HasValue == false — inaczej niż zwykły int x;, który domyślnie ma wartość 0. Ma to znaczenie przy deserializacji i mapowaniu ORM, gdzie brakujące pole JSON dla int? zostanie poprawnie zmapowane na null, a dla int — na 0, co może fałszywie wyglądać jak poprawna wartość biznesowa.

3. Nullable Reference Types nie chronią Cię przed NullReferenceException w 100%. To najczęstsze błędne przekonanie po włączeniu <Nullable>enable</Nullable> w projekcie. NRT redukują liczbę przypadków, ale nie eliminują ich całkowicie — zwłaszcza na granicach z kodem, który nie ma adnotacji nullability (starsze biblioteki NuGet, refleksja, deserializacja). Operator wybaczający null (!) dodatkowo pozwala programiście świadomie „uciszyć” ostrzeżenie kompilatora — co bywa nadużywane, gdy ktoś chce po prostu skompilować kod bez faktycznego rozwiązania problemu.

4. Rzutowanie (int)nullable bez sprawdzenia to ten sam problem co .Value. Jawne rzutowanie z int? na int wewnętrznie wywołuje Value — więc rzuci ten sam wyjątek dla null.

Dlaczego to pytanie rekrutacyjne

“Czym różni się int? od string?” to pytanie, które od razu pokazuje, czy kandydat rozumie różnicę między mechanizmem runtime a analizą statyczną kompilatora — a nie tylko kojarzy oba ze słowem „nullable”. Dobra odpowiedź: Nullable<T> to prawdziwy typ generyczny egzekwowany przez CLR, NRT to adnotacja kompilatora bez wpływu na wygenerowany IL. Warto też umieć wytłumaczyć dlaczego w ogóle Nullable<T> istnieje — bo typy wartościowe domyślnie nie mogą reprezentować „braku wartości”, a biznesowo taka potrzeba pojawia się bardzo często (data anulowania, opcjonalny rabat, nieukończone zadanie). Więcej pytań tego typu, z gotowymi odpowiedziami, znajdziesz w 50 pytań rekrutacyjnych Junior .NET Developer.

FAQ

Czy mogę zrobić Nullable<string>? Nie. Nullable<T> ma ograniczenie where T : struct — działa tylko dla typów wartościowych. Typy referencyjne, jak string, są nullable z definicji (mogą przechowywać null bez żadnego opakowania) — dla nich odpowiedzialny jest osobny mechanizm, czyli Nullable Reference Types.

Czy int? ma taki sam rozmiar w pamięci jak int? Nie, jest większy — poza wartością T przechowuje dodatkowo bool hasValue, więc Nullable<int> zajmuje więcej niż 4 bajty (z uwzględnieniem wyrównania pamięci, zwykle 8 bajtów zamiast 4). To drobna, ale realna różnica przy strukturach danych z milionami elementów.

Czy ?? i ?. działają tak samo dla Nullable<T> i dla typów referencyjnych? ?? działa identycznie dla obu. ?. (null-conditional) ma sens głównie dla typów referencyjnych i dla wywoływania członków Nullable<T> (np. wiek?.ToString()), ale nie zastąpi sprawdzenia HasValue przy bezpośrednim dostępie do .Value.

Podsumowanie

  • Nullable<T> to generyczna struktura (where T : struct) z polami HasValue i Valueint? to cukier składniowy na Nullable<int>.
  • Operatory arytmetyczne i porównania są automatycznie „podnoszone” (lifted) do obsługi null bez rzucania wyjątku.
  • Boxing Nullable<T> to specjalny przypadek w CLR: null boxuje się do null, a wartość boxuje się do zwykłego boxed T, nie do boxed Nullable<T>.
  • Nullable Reference Types (string?) to zupełnie inny mechanizm — czysto kompilacyjna analiza przepływu, bez żadnego wpływu na runtime i bez twardej gwarancji przeciw NullReferenceException.
  • .Value bez sprawdzenia HasValue rzuca InvalidOperationException — to odpowiednik NullReferenceException dla typów wartościowych.

Powiązane: nie myl tego z Nullable Reference Types dla typów referencyjnych.

👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

Dodaj komentarz

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →