NullReferenceException: nie ta linia, która jest zepsuta
FormatReceiptLine użyła pustego klienta. FindCustomer, metoda, która go stworzyła, już wróciła i nie ma jej w stosie wywołań.
Ten program do paragonów wygląda na gotowy. Przechodzi po trzech zamówieniach i dla każdego wypisuje linię paragonu z danymi klienta, a na trzecim zaraz rzuci NullReferenceException:
foreach (var order in orders)
{
PrintReceipt(order);
}
void PrintReceipt(Order order)
{
var customer = FindCustomer(order.CustomerId);
var line = FormatReceiptLine(order, customer);
Console.WriteLine(line);
}
Customer? FindCustomer(int customerId) =>
customers.FirstOrDefault(c => c.Id == customerId);
string FormatReceiptLine(Order order, Customer customer) =>
$"#{order.Id} {customer.Name}: {order.Total:F2} PLN";
dotnet build już to zgłosił, jedną linią, łatwą do przewinięcia bez
czytania:
Program.cs(22,41): warning CS8604: Możliwy argument odwołania o wartości null dla parametru „customer” w „string FormatReceiptLine(Order order, Customer customer)”.
Nikt nie czyta każdego ostrzeżenia, więc build i tak przechodzi. Dwa paragony wypisują się poprawnie. Trzecie zamówienie wskazuje klienta, którego usunięto tydzień wcześniej, i program się zatrzymuje:
#100 Kowalska: 129,99 PLN
#101 Nowak: 89,50 PLN
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<<Main>$>g__FormatReceiptLine|0_2(Order order, Customer customer) in Program.cs:line 33
at Program.<<Main>$>g__PrintReceipt|0_0(Order order, <>c__DisplayClass0_0&) in Program.cs:line 22
at Program.<Main>$(String[] args) in Program.cs:line 16
Komunikat zostaje po angielsku, mimo że kompilator chwilę wcześniej tłumaczył ostrzeżenie na polski. To nie kwestia ustawień Windowsa. Współdzielone środowisko uruchomieniowe w wersji 10.0.12 w ogóle nie ma katalogu z polskimi tłumaczeniami dla tej biblioteki, więc wyjątek nie ma skąd wziąć polskiego tekstu.
Nazwy ramek wyglądają obco, bo PrintReceipt i FormatReceiptLine to
funkcje lokalne zadeklarowane wewnątrz instrukcji najwyższego poziomu.
Kompilator zamienia każdą z nich na ukrytą metodę klasy Program, nazwaną
g__<metoda>|<numer_generacji>_<indeks>, stąd w stosie g__FormatReceiptLine|0_2
zamiast nazwy, którą faktycznie wpisałeś. Numery linii pod spodem wskazują
mimo to prosto na twój własny kod. Metoda w zwykłej klasie zachowuje w
stosie własną nazwę, bez żadnego przedrostka: statyczna Helper.Crash()
rzucająca ten sam wyjątek pojawi się po prostu jako
at Helper.Crash() in Program.cs:line 8. Dopisek g__ dostają tylko
funkcje lokalne zadeklarowane wewnątrz instrukcji najwyższego poziomu, bo
same te instrukcje kompilują się do ukrytej metody Main, a wszystko
zadeklarowane w jej wnętrzu dziedziczy tę anonimowość.
Linia 33 to customer.Name, wewnątrz FormatReceiptLine. To ostatnia
ramka przed wyjątkiem i można ją odczytać jako miejsce błędu. Nie jest
nim. Ta metoda dostała customer jako parametr i mu zaufała. Pustej
wartości nie stworzyła, tylko pierwsza jej dotknęła.
Ramka, która się nie pojawia
Poszukaj FindCustomer w tym stosie. Nie ma go tam. Zanim
FormatReceiptLine rzuci wyjątek, FindCustomer już się wykonał i zszedł
ze stosu, trzy linie wcześniej w PrintReceipt. Stos wywołań pokazuje
tylko ramki, które wciąż tam są w momencie wyjątku, a ramka, która
stworzyła pustą wartość, dawno zniknęła.
Stos nie wskaże więc przyczyny wprost. Może za to nazwać funkcję, w której
zaufana wartość powstała, pod warunkiem że czytasz kod wokół rzucającej
linii, a nie samą tę linię. FormatReceiptLine(order, customer) stoi
zaraz pod FindCustomer(order.CustomerId), a FirstOrDefault to metoda z
biblioteki standardowej, której cały kontrakt brzmi „zwróć null, gdy nic
nie pasuje”. To jedno wywołanie jest właściwą usterką. Wszystko poniżej
niego tylko przeniosło problem o jeden krok dalej, zanim wypłynął na
powierzchnię.
FirstOrDefault istnieje właśnie po to, żeby dać wywołującemu wybór, co
zrobić z brakiem dopasowania. First zakłada, że dopasowanie musi
istnieć, i to założenie egzekwuje przez wyjątek już w miejscu wywołania,
zamiast oddawać decyzję dalej. Wybór między nimi jest tym, co faktycznie
rozstrzyga, czy pusta wartość zdąży dotrzeć trzy funkcje dalej, zanim
ktokolwiek ją zauważy.
Dlatego ostrzeżenie kompilatora znaczy więcej niż sam wyjątek. CS8604
zapaliło się na linii 22, przy wywołaniu FormatReceiptLine, dokładnie na
styku wartości, która może być pusta, i kodu, który zakłada, że nie jest.
CS8604 jest ostrzeżeniem, a nie błędem builda, bo referencje dopuszczające
null są włączane umownie, nie wymuszane od razu. Domyślny szablon
dotnet new console już ustawia <Nullable>enable</Nullable>, ale
projekt napisany przed pojawieniem się tej funkcji przestałby się w ogóle
kompilować, gdyby te same adnotacje były błędami od pierwszego dnia. Nic
nie stoi na przeszkodzie, żeby ten próg podnieść samodzielnie: dopisanie
<WarningsAsErrors>Nullable</WarningsAsErrors> do tego samego pliku
projektu zamienia to konkretne ostrzeżenie w error CS8604, z identycznym
komunikatem, i build odmawia dokończenia.
Dwa kolejne miejsca, w których null powstaje
FirstOrDefault zwracające null przy braku wiersza to jedno źródło. Dwa
kolejne trafiają się równie często w kodzie, który na pierwszy rzut oka
wygląda czysto.
Właściwość, której nikt nigdy nie przypisał wartości:
var cart = new ShoppingCart();
Console.WriteLine(cart.Items.Count);
public class ShoppingCart
{
public List<string> Items { get; set; }
public decimal Total { get; set; }
}
Items nie ma inicjalizatora, a konstruktor nigdy jej nie ustawia, więc
zaczyna jako null i taka zostaje, dopóki coś jej nie przypisze listy.
Kompilator już to wie:
Program.cs(6,25): warning CS8618: Niedopuszczający wartości null element właściwość „Items” musi zawierać wartość inną niż null podczas kończenia działania konstruktora. Rozważ dodanie modyfikatora „required” lub zadeklarowanie właściwość jako dopuszczającego wartość null.
Uruchomienie rzuca wyjątek na linii 2, przy cart.Items.Count. Inny numer
ostrzeżenia, ten sam kształt pomyłki.
Wpis konfiguracji, którego nie ma:
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Smtp:Host"] = "smtp.example.com",
})
.Build();
string host = configuration["Smtp:Port"].ToUpper();
Indekser IConfiguration zwraca null dla klucza, którego nikt nie ustawił.
Słownik w tym miejscu rzuciłby KeyNotFoundException, ale konfiguracja nie.
Build już ostrzega (CS8602: Wyłuskanie odwołania, które może mieć wartość null), a uruchomienie kończy się tym samym nieobsłużonym wyjątkiem co
poprzednio, tym razem przy .ToUpper().
Trzy różne źródła i trzy różne numery ostrzeżeń kompilatora prowadzą do jednego, identycznego komunikatu NullReferenceException, co do słowa. Tego akurat wyszukiwarka po frazie „odwołanie do obiektu nie zostało ustawione” nie powie: tekst jest ten sam bez względu na to, które z tych trzech go wywołało. Poprawka musi więc wyjść z przeczytania kodu wokół miejsca, w którym pusta wartość weszła w kod, który zakładał, że nią nie będzie.
Dlaczego sprawdzenie w miejscu wyjątku nie jest poprawką
Oczywista łatka to sprawdzenie null w miejscu, gdzie padł wyjątek:
void PrintReceipt(Order order)
{
var customer = FindCustomer(order.CustomerId);
if (customer is null)
{
return;
}
var line = FormatReceiptLine(order, customer);
Console.WriteLine(line);
}
Kompiluje się, a program już się nie wywraca:
#100 Kowalska: 129,99 PLN
#101 Nowak: 89,50 PLN
Zamówienie 102 zniknęło z wyjścia, bez błędu i bez linii w logu. Osoba z
obsługi klienta patrząca na ten terminal nie ma jak się dowiedzieć, że
jeden paragon przepadł, a ten, kto tydzień wcześniej usunął klienta o
identyfikatorze 3, nie ma powodu, żeby połączyć swoją zmianę z drukarką,
która teraz po cichu gubi co trzecie zamówienie. Wyjątek przynajmniej jest
głośny. Wczesny return przy pustej wartości zamienia błąd znaleziony w
kilka minut na taki, który klient zgłosi za kilka tygodni.
To jest właśnie powód, dla którego NullReferenceException bywa
pierwszym poważnym wyjątkiem w czasie działania, na jaki trafia osoba ucząca
się C#. Błędy kompilacji zatrzymują cię, zanim program w ogóle ruszy, więc
uczą czytania komunikatu od razu. Ten wyjątek przechodzi przez build bez
przeszkód i czeka na konkretne dane wejściowe, więc pierwszy odruch to
najczęściej właśnie sprawdzenie null w miejscu, w którym program akurat
padł, zamiast cofnięcie się do miejsca, w którym pusta wartość powstała.
Właściwa poprawka należy tam, gdzie null powstał, a nie tam, gdzie go
użyto. Decyzję trzeba przenieść z powrotem do FindCustomer:
Customer FindCustomer(int customerId) =>
customers.FirstOrDefault(c => c.Id == customerId)
?? throw new InvalidOperationException($"No customer with id {customerId}");
FindCustomer zwraca teraz zwykły Customer, nie Customer?, więc
FormatReceiptLine nigdy nie dostaje wartości, co do której musiałaby
zgadywać. Te same trzy zamówienia znowu rzucają wyjątek, tylko że tym
razem komunikat nazywa prawdziwy problem, zamiast być ogólnikiem:
#100 Kowalska: 129,99 PLN
#101 Nowak: 89,50 PLN
Unhandled exception. System.InvalidOperationException: No customer with id 3
at Program.<<Main>$>g__FindCustomer|0_1(Int32 customerId, <>c__DisplayClass0_0&) in Program.cs:line 27
at Program.<<Main>$>g__PrintReceipt|0_0(Order order, <>c__DisplayClass0_0&) in Program.cs:line 21
at Program.<Main>$(String[] args) in Program.cs:line 16
Górna ramka to tym razem FindCustomer, w miejscu, gdzie brakujące dane
faktycznie były, a komunikat podaje identyfikator klienta zamiast zmuszać
cię do odtworzenia go z trzech ramek. Taki jest ogólny kształt czytania
stosu wywołań przy tego rodzaju błędzie: górna ramka mówi, gdzie program
się zatrzymał, a wszystko poniżej pokazuje drogę, którą do tego doszedł.
Dobra poprawka przesuwa górną ramkę bliżej funkcji, która faktycznie
odpowiada za brakującą wartość, a nie dalej od niej.
Krótsza wersja tej samej poprawki zamienia FirstOrDefault na First,
która sama rzuca wyjątek, gdy nic nie pasuje:
Customer FindCustomer(int customerId) =>
customers.First(c => c.Id == customerId);
To też rzuca wyjątek we właściwej ramce, tylko że z komunikatem samego LINQ, a nie napisanym pod ten program:
Unhandled exception. System.InvalidOperationException: Sequence contains no matching element
at System.Linq.Enumerable.First[TSource](IEnumerable`1 source, Func`2 predicate)
at Program.<<Main>$>g__FindCustomer|0_1(Int32 customerId, <>c__DisplayClass0_0&) in Program.cs:line 27
Właściwa ramka, uboga treść: nic w tym tekście nie mówi, którego klienta
zabrakło. Jawny throw kosztuje jedną linię więcej i odkupuje dokładnie
ten szczegół, którego First nie podaje.
Pozostałe dwa źródła przechodzą ten sam ruch co FindCustomer: decyzję
przenosi się tam, gdzie null faktycznie powstaje. required przy Items
zamienia cichy null w błąd builda w momencie, gdy ktoś tworzy
ShoppingCart bez listy:
Program.cs(1,16): error CS9035: Wymagana składowa „ShoppingCart.Items” musi być ustawiona w konstruktorze inicjatora obiektów lub atrybutów.
A configuration["Smtp:Port"] ?? throw new InvalidOperationException("Missing configuration key: Smtp:Port")
zamienia brakujące ustawienie w komunikat, który wprost podaje, którego
klucza brakuje, i to przy starcie programu, a nie przy dowolnej innej
linii, która akurat próbuje go odczytać jako pierwsza.
Nie każde brakujące ustawienie zasługuje na throw. Numer portu często ma
sensowną wartość domyślną, a configuration.GetValue("Smtp:Port", 587)
zwraca ją bez żadnego błędu:
int port = configuration.GetValue("Smtp:Port", 587);
Console.WriteLine(port);
To wypisuje 587 i kończy działanie normalnie. Wybór między rzuceniem
wyjątku a wartością domyślną nie polega na tym, który wariant jest
bezpieczniejszy w teorii. Chodzi o to, czy brak wartości to pomyłka w
konfiguracji, dla której warto się zatrzymać, czy przypadek, który program
powinien już umieć obsłużyć. Obie odpowiedzi biją znalezienie tego trzy
funkcje później, że coś nigdy nie zostało ustawione.
Roslyn Language Server, którego używa DevJourney, podkreśla
CS8604, CS8618 i CS8602, zanim jeszcze skończysz pisać linię, więc łapie je,
zanim cokolwiek trafi do terminala. Jeden
breakpoint na FindCustomer zamiast Console.WriteLine
pokazałby customer jako null, zanim wyjątek w ogóle padł, a
CS0029 to sytuacja, w której kompilator
odmawia wprost, zamiast tylko ostrzegać.