Typy nullable w C# — wartościowe vs referencyjne

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.ValuerzucaInvalidOperationException, jeśliHasValuejestfalse. To klasyczny błąd Juniora — odczytanie.Valuebez wcześniejszego sprawdzeniaHasValuealbo bez??. Efekt jest podobny doNullReferenceException, ale to inny wyjątek, z inną przyczyną.- Operatory arytmetyczne i porównania są “podniesione” (lifted) do wersji nullable.
a + b, gdziealubbjestnull, zwracanull, a nie rzuca wyjątku. Porównania (>,<,==) znullpo jednej stronie zwracająfalse(poza==i!=, które działają intuicyjnie:null == nulltotrue). 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?) | |
|---|---|---|
| Dotyczy | Typów wartościowych (struct) | Typów referencyjnych (class, interfejsy) |
| Kiedy działa | W runtime — realna struktura z polem HasValue | Wyłącznie w czasie kompilacji — analiza przepływu danych |
| Wpływ na IL | Tak — generowany jest realny typ Nullable<T> | Żaden — string? i string kompilują się do identycznego IL |
Czy runtime wymusza brak null? | Tak, przez samą strukturę typu | Nie — string bez ? nadal może otrzymać null w runtime, np. z zewnętrznej biblioteki lub deserializacji |
| Wymaga włączenia | Nie, dostępne domyślnie od C# 2.0 | Tak, <Nullable>enable</Nullable> w .csproj (domyślnie wyłączone w starszych projektach) |
| Co się dzieje przy naruszeniu | InvalidOperationException przy próbie odczytu .Value | Ostrzeż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 Object — Nullable<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 polamiHasValueiValue—int?to cukier składniowy naNullable<int>.- Operatory arytmetyczne i porównania są automatycznie „podnoszone” (lifted) do obsługi
nullbez rzucania wyjątku. - Boxing
Nullable<T>to specjalny przypadek w CLR:nullboxuje się donull, a wartość boxuje się do zwykłego boxedT, nie do boxedNullable<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 przeciwNullReferenceException. .Valuebez sprawdzeniaHasValuerzucaInvalidOperationException— to odpowiednikNullReferenceExceptiondla typów wartościowych.
Powiązane: nie myl tego z Nullable Reference Types dla typów referencyjnych.
🚀 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.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
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ę →