Dlaczego ten kod wyświetla ostrzeżenie kompilatora „Możliwy powrót do wartości zerowej”?

70

Rozważ następujący kod:

using System;

#nullable enable

namespace Demo
{
    public sealed class TestClass
    {
        public string Test()
        {
            bool isNull = _test == null;

            if (isNull)
                return "";
            else
                return _test; // !!!
        }

        readonly string _test = "";
    }
}

Kiedy budować to, linia oznaczona !!!daje ostrzeżenie kompilatora: warning CS8603: Possible null reference return..

Uważam to za nieco mylące, biorąc pod uwagę, że _testjest to tylko do odczytu i jest inicjowane na wartość inną niż null.

Jeśli zmienię kod na następujący, ostrzeżenie zniknie:

        public string Test()
        {
            // bool isNull = _test == null;

            if (_test == null)
                return "";
            else
                return _test;
        }

Czy ktoś może wyjaśnić to zachowanie?

Matthew Watson
źródło
1
Debug.Assert nie ma znaczenia, ponieważ jest to kontrola czasu wykonywania, podczas gdy ostrzeżenie kompilatora to kontrola czasu kompilacji. Kompilator nie ma dostępu do zachowania środowiska wykonawczego.
Polyfun,
5
The Debug.Assert is irrelevant because that is a runtime check- Jest to istotne, ponieważ jeśli skomentujesz tę linię, ostrzeżenie zniknie.
Matthew Watson,
1
@Polyfun: Kompilator może potencjalnie wiedzieć (za pomocą atrybutów), że Debug.Assertzgłosi wyjątek, jeśli test się nie powiedzie.
Jon Skeet,
2
Dodałem tutaj wiele różnych przypadków i są naprawdę interesujące wyniki. Napiszę odpowiedź później - praca na razie.
Jon Skeet,
2
@EricLippert: Debug.Assertma teraz adnotację ( src ) DoesNotReturnIf(false)parametru warunku.
Jon Skeet,

Odpowiedzi:

38

Analiza przepływu zerowalnego śledzi stan zerowy zmiennych, ale nie śledzi innego stanu, takiego jak wartość boolzmiennej (jak isNullwyżej), i nie śledzi związku między stanem oddzielnych zmiennych (np. isNullI_test ).

Rzeczywisty silnik analizy statycznej prawdopodobnie zrobiłby te rzeczy, ale byłby również do pewnego stopnia „heurystyczny” lub „arbitralny”: nie można koniecznie powiedzieć, jakie reguły przestrzega, a reguły te mogą nawet ulec zmianie z czasem.

Nie możemy tego zrobić bezpośrednio w kompilatorze C #. Reguły ostrzeżeń null są dość wyrafinowane (jak pokazuje analiza Jona!), Ale są regułami i można je uzasadnić.

Kiedy wprowadzamy tę funkcję, wydaje się, że przeważnie osiągnęliśmy właściwą równowagę, ale jest kilka miejsc, które wydają się niewygodne, i będziemy ponownie odwiedzać te dla C # 9.0.

Mads Torgersen - MSFT
źródło
3
Wiesz, że chcesz umieścić teorię siatki w specyfikacji; teoria sieci jest niesamowita i wcale nie myląca! Zrób to! :)
Eric Lippert,
7
Wiesz, że twoje pytanie jest uzasadnione, gdy menedżer programu dla C # odpowiada!
Sam Rueby,
1
@TanveerBadar: Teoria kratowa dotyczy analizy zbiorów wartości, które mają częściowy porządek; typy są dobrym przykładem; jeśli wartość typu X można przypisać do zmiennej typu Y, oznacza to, że Y jest „wystarczająco duży”, aby pomieścić X, i to wystarcza do utworzenia sieci, co następnie mówi nam, że sprawdzenie możliwości przypisania w kompilatorze może być sformułowane w specyfikacji pod względem teorii sieci. Jest to istotne dla analizy statycznej, ponieważ bardzo wiele tematów będących przedmiotem zainteresowania analizatora innych niż możliwość przypisania typu jest również wyrażonych w postaci sieci.
Eric Lippert,
1
@TanveerBadar: lara.epfl.ch/w/_media/sav08:schwartzbach.pdf ma kilka dobrych wstępnych przykładów wykorzystania statycznych silników analizy do teorii siatki.
Eric Lippert
1
@EricLippert Awesome nie zaczyna Cię opisywać. Ten link trafia do mojej listy do przeczytania od razu.
Tanveer Badar
56

Mogę rozsądnie zgadnąć, co się tutaj dzieje, ale to wszystko jest trochę skomplikowane :) Obejmuje stan zerowy i śledzenie zerowe opisane w specyfikacji roboczej . Zasadniczo w miejscu, w którym chcemy powrócić, kompilator ostrzeże, jeśli stan wyrażenia to „może null” zamiast „not null”.

Ta odpowiedź ma nieco narracyjną formę, a nie tylko „oto wnioski” ... Mam nadzieję, że w ten sposób będzie bardziej przydatna.

Uproszczę nieco ten przykład, pozbywając się pól i rozważę metodę z jednym z tych dwóch podpisów:

public static string M(string? text)
public static string M(string text)

W poniższych implementacjach podałem każdej metodzie inny numer, aby móc jednoznacznie odwoływać się do konkretnych przykładów. Pozwala także na obecność wszystkich implementacji w tym samym programie.

W każdym z opisanych poniżej przypadków zrobimy różne rzeczy, ale w końcu spróbujemy wrócić text- więc textważny jest stan zerowy .

Bezwarunkowy zwrot

Najpierw spróbujmy zwrócić go bezpośrednio:

public static string M1(string? text) => text; // Warning
public static string M2(string text) => text;  // No warning

Do tej pory takie proste. Stan zerowania parametru na początku metody to „może null”, jeśli jest typu, string?i „nie jest null”, jeśli jest typu string.

Prosty warunkowy zwrot

Teraz sprawdźmy, czy w samym ifwarunku instrukcji nie ma wartości null . (Chciałbym użyć operatora warunkowego, który, jak sądzę, będzie miał taki sam efekt, ale chciałem pozostać bardziej wierny temu pytaniu.)

public static string M3(string? text)
{
    if (text is null)
    {
        return "";
    }
    else
    {
        return text; // No warning
    }
}

public static string M4(string text)
{
    if (text is null)
    {
        return "";
    }
    else
    {
        return text; // No warning
    }
}

Świetnie, więc wygląda na to, że w ifinstrukcji, w której sam warunek sprawdza zerowość, stan zmiennej w każdej gałęzi ifinstrukcji może być inny: w elsebloku stan nie jest „null” w obu częściach kodu. W szczególności w M3 stan zmienia się z „może zerowy” na „nie zerowy”.

Warunkowy powrót z lokalną zmienną

Teraz spróbujmy podnieść ten warunek do zmiennej lokalnej:

public static string M5(string? text)
{
    bool isNull = text is null;
    if (isNull)
    {
        return "";
    }
    else
    {
        return text; // Warning
    }
}

public static string M6(string text)
{
    bool isNull = text is null;
    if (isNull)
    {
        return "";
    }
    else
    {
        return text; // Warning
    }
}

Zarówno M5, jak i M6 wydają ostrzeżenia. Więc nie tylko nie uzyskujemy pozytywnego efektu zmiany stanu z „może zerowy” na „nie zerowy” w M5 (tak jak w M3) ... otrzymujemy odwrotnie efekt w M6, skąd stan przechodzi z „ not null ”na„ może null ”. To mnie naprawdę zaskoczyło.

Wygląda na to, że dowiedzieliśmy się, że:

  • Logika wokół „jak została obliczona zmienna lokalna” nie jest używana do propagowania informacji o stanie. Więcej o tym później.
  • Wprowadzenie porównania zerowego może ostrzec kompilator, że coś, co wcześniej uważało, że nie jest zerowe, może w końcu być zerowe.

Bezwarunkowy zwrot po zignorowanym porównaniu

Spójrzmy na drugi z tych punktów, wprowadzając porównanie przed bezwarunkowym powrotem. (Więc całkowicie ignorujemy wynik porównania.):

public static string M7(string? text)
{
    bool ignored = text is null;
    return text; // Warning
}

public static string M8(string text)
{
    bool ignored = text is null;
    return text; // Warning
}

Zauważ, że M8 wydaje się, że powinien być równoważny M2 - oba mają parametr inny niż null, który bezwarunkowo zwracają - ale wprowadzenie porównania z null zmienia stan z „not null” na „może null”. Możemy uzyskać dalsze dowody na to, próbując wywnioskować, textzanim warunek:

public static string M9(string text)
{
    int length1 = text.Length;   // No warning
    bool ignored = text is null;
    int length2 = text.Length;   // Warning
    return text;                 // No warning
}

Zwróć uwagę, że returninstrukcja nie ma teraz ostrzeżenia: stan po wykonaniu text.Lengthma wartość „nie jest zerowa” (ponieważ jeśli wykonamy to wyrażenie pomyślnie, nie może być zerowy). Tak więc textparametr zaczyna się od „not null” ze względu na jego typ, staje się „może null” ze względu na porównanie wartości null, a następnie ponownie staje się „not null” text2.Length.

Jakie porównania wpływają na stan?

To porównanie text is null... jaki wpływ mają podobne porównania? Oto cztery kolejne metody, wszystkie zaczynające się od niepozwalającego na podanie parametru ciągu:

public static string M10(string text)
{
    bool ignored = text == null;
    return text; // Warning
}

public static string M11(string text)
{
    bool ignored = text is object;
    return text; // No warning
}

public static string M12(string text)
{
    bool ignored = text is { };
    return text; // No warning
}

public static string M13(string text)
{
    bool ignored = text != null;
    return text; // Warning
}

Dlatego, mimo że x is objectjest obecnie zalecana alternatywa x != null, nie mają one ten sam efekt: tylko porównanie z wartością null (z dowolnego is, ==lub !=) zmienia stan z „NOT NULL” do „Może null”.

Dlaczego podniesienie warunku ma wpływ?

Wracając do naszego pierwszego punktu wcześniejszego, dlaczego M5 i M6 nie biorą pod uwagę stanu, który doprowadził do zmiennej lokalnej? Nie zaskakuje mnie to tak bardzo, jak wydaje się zaskakiwać innych. Wbudowanie tego rodzaju logiki w kompilator i specyfikację jest bardzo pracochłonne i przynosi stosunkowo niewielkie korzyści. Oto kolejny przykład, który nie ma nic wspólnego z zerowalnością, w której wprowadzenie czegoś ma wpływ:

public static int X1()
{
    if (true)
    {
        return 1;
    }
}

public static int X2()
{
    bool alwaysTrue = true;
    if (alwaysTrue)
    {
        return 1;
    }
    // Error: not all code paths return a value
}

Choć my wiemy, że alwaysTruezawsze będzie prawdziwe, to nie spełnia wymogów określonych w specyfikacji, które sprawiają, że kod po ifoświadczeniu nieosiągalnym, czyli to, czego potrzebujemy.

Oto kolejny przykład dotyczący określonego przypisania:

public static void X3()
{
    string x;
    bool condition = DateTime.UtcNow.Year == 2020;
    if (condition)
    {
        x = "It's 2020.";
    }
    if (!condition)
    {
        x = "It's not 2020.";
    }
    // Error: x is not definitely assigned
    Console.WriteLine(x);
}

Choć my wiemy, że kod wejdzie dokładnie jeden z tych iforganów rachunku, nie ma nic w specyfikacji do pracy, która na zewnątrz. Narzędzia do analizy statycznej mogą być w stanie to zrobić, ale próba wprowadzenia tego do specyfikacji języka byłaby złym pomysłem, IMO - dobrze, że narzędzia do analizy statycznej mają wszystkie rodzaje heurystyki, które mogą ewoluować w czasie, ale nie tak bardzo dla specyfikacji języka.

Jon Skeet
źródło
7
Świetna analiza Jon. Kluczową rzeczą, której nauczyłem się podczas badania sprawdzania pokrycia, jest to, że kod jest dowodem przekonań jego autorów . Kiedy widzimy zerowy czek, który powinien nas poinformować, że autorzy kodu uważają, że kontrola jest konieczna. Kontroler faktycznie szuka dowodów na to, że przekonania autorów były niespójne, ponieważ w miejscach, w których widzimy niespójne przekonania o, powiedzmy, nieważności, zdarzają się błędy.
Eric Lippert,
6
Kiedy widzimy na przykład if (x != null) x.foo(); x.bar();, mamy dwa dowody; ifoświadczenie jest dowodem tezy „autor uważa, że X może być zerowy przed wywołaniem foo” i następujące oświadczenie jest dowodem „autor wierzy x nie jest null przed wywołanie Bar”, a sprzeczność ta prowadzi do wniosek, że występuje błąd. Błąd jest albo względnie łagodnym błędem niepotrzebnego sprawdzenia zerowego, albo potencjalnie zawieszającym się błędem. Który błąd jest prawdziwym błędem, nie jest jasne, ale jasne jest, że istnieje.
Eric Lippert,
1
Problem polegający na tym, że stosunkowo niewyszukane mechanizmy kontrolne, które nie śledzą znaczenia miejscowych i nie przycinają „fałszywych ścieżek” - kontrolują ścieżki przepływu, które ludzie mogą powiedzieć, są niemożliwe - mają tendencję do generowania fałszywych trafień właśnie dlatego, że nie dokładnie modelowali przekonania autorów. To trudna sprawa!
Eric Lippert,
3
Niespójność między „is object”, „is {}” a „! = Null” to przedmiot, o którym rozmawialiśmy wewnętrznie w ciągu ostatnich kilku tygodni. Zamierzam przedstawić go w LDM w najbliższej przyszłości, aby zdecydować, czy musimy uznać je za czysto zerowe kontrole, czy nie (co sprawia, że ​​zachowanie jest spójne).
JaredPar
1
@ArnonAxelrod To mówi, że to nie ma być zerowe. Nadal może być zerowy, ponieważ typy odwołania o wartości zerowej są jedynie wskazówką kompilatora. (Przykłady: M8 (null!); Lub wywołanie go z kodu C # 7 lub zignorowanie ostrzeżeń.) To nie jest bezpieczeństwo typowe dla reszty platformy.
Jon Skeet,
29

Odkryłeś dowody na to, że algorytm przepływu programu, który generuje to ostrzeżenie, jest stosunkowo niewyszukany, jeśli chodzi o śledzenie znaczeń zakodowanych w zmiennych lokalnych.

Nie mam konkretnej wiedzy na temat implementacji sprawdzania przepływu, ale pracując nad implementacjami podobnego kodu w przeszłości, mogę zgadywać. Kontroler przepływu prawdopodobnie wydedukuje dwie rzeczy w przypadku fałszywie dodatniego: (1) _testmoże mieć wartość NULL, ponieważ gdyby nie mógł, nie byłoby porównania w pierwszej kolejności, a (2) isNullmoże być prawdziwe lub fałszywe - ponieważ gdyby nie mógł, nie miałbyś tego w if. Ale połączenie, które return _test;działa tylko wtedy, gdy _testnie jest zerowe, to połączenie nie jest nawiązywane.

Jest to zaskakująco podchwytliwy problem i należy oczekiwać, że kompilatorowi zajmie trochę czasu wyrafinowanie narzędzi, na które eksperci pracowali przez wiele lat. Na przykład moduł sprawdzania przepływu Coverity nie miałby żadnego problemu z wnioskiem, że żadna z dwóch odmian nie miała zerowego zwrotu, ale moduł sprawdzania przepływu Coverity kosztuje poważne pieniądze dla klientów korporacyjnych.

Ponadto kontrolery Coverity są zaprojektowane do pracy w dużych bazach kodowych przez noc ; analiza kompilatora C # musi przebiegać między naciśnięciami klawiszy w edytorze , co znacznie zmienia rodzaje szczegółowych analiz, które można rozsądnie wykonać.

Eric Lippert
źródło
„Niewyrafinowany” ma rację - uważam, że można go wybaczyć, jeśli natknie się na takie rzeczy, jak warunkowe, ponieważ wszyscy wiemy, że problem zatrzymania jest trochę trudny w takich sprawach, ale fakt, że w ogóle jest różnica między bool b = x != nullvs bool b = x is { }(z żadne przypisanie faktycznie nie zostało użyte!) pokazuje, że nawet rozpoznane wzorce dla kontroli zerowych są wątpliwe. Aby nie dyskredytować niewątpliwie ciężkiej pracy zespołu, aby działał on tak, jak powinien, w przypadku rzeczywistych, używanych baz kodu - wygląda na to, że analiza jest bardzo pragmatyczna.
Jeroen Mostert,
@JeroenMostert: Jared Par wspomina w komentarzu do odpowiedzi Jona Skeeta, że Microsoft omawia ten problem wewnętrznie.
Brian
8

Wszystkie pozostałe odpowiedzi są prawie całkowicie poprawne.

Jeśli ktoś jest ciekawy, starałem się przeliterować logikę kompilatora tak wyraźnie, jak to możliwe w https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947

Jedynym elementem, o którym nie wspomniano, jest to, w jaki sposób decydujemy, czy czek zerowy powinien być uważany za „czysty”, w tym sensie, że jeśli to zrobisz, powinniśmy poważnie rozważyć, czy zerowanie jest możliwe. Istnieje wiele „przypadkowych” kontroli zerowych w języku C #, w których testujesz na wartość zerową w ramach robienia czegoś innego, więc zdecydowaliśmy, że chcemy zawęzić zestaw kontroli do tych, które, jak byliśmy pewni, ludzie robili celowo. Heurystyka, którą wymyśliliśmy, „zawiera słowo null”, więc właśnie dlatego x != nulli x is objectdają różne wyniki.

Andy Gocke
źródło