← Wszystkie wpisy

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ń.

Artur Kot 10 min czytania

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ć.

Tematy: C#Błędy i komunikatyDebugowaniePodstawy

Pobieranie ruszyło

DevJourney_2.1.1_x64-setup.exe · 2.1.1

Jeśli system ostrzeże i jak sprawdzić plik

Jeśli Windows ostrzeże

Komunikat „System Windows ochronił Twój komputer” nie oznacza wykrycia wirusa. SmartScreen ufa certyfikatowi, którym podpisany jest instalator, a ten jeszcze zbiera reputację poprzez licznik pobrań.

W oknie ostrzeżenia: Więcej informacji → Uruchom mimo to.

Sprawdź to sam

Zobacz wynik skanowania w VirusTotal ↗

Albo w PowerShellu, w katalogu z pobranym plikiem:

Get-FileHash .\DevJourney_2.1.1_x64-setup.exe

Powinno wypisać:

2e441de05e02ae32caad96bdca86f9ca0ff59e01cbc8b289a1f6f783e4f7bc46