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 _test
jest 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?
c#
nullable-reference-types
Matthew Watson
źródło
źródło
The Debug.Assert is irrelevant because that is a runtime check
- Jest to istotne, ponieważ jeśli skomentujesz tę linię, ostrzeżenie zniknie.Debug.Assert
zgłosi wyjątek, jeśli test się nie powiedzie.Debug.Assert
ma teraz adnotację ( src )DoesNotReturnIf(false)
parametru warunku.Odpowiedzi:
Analiza przepływu zerowalnego śledzi stan zerowy zmiennych, ale nie śledzi innego stanu, takiego jak wartość
bool
zmiennej (jakisNull
wyżej), i nie śledzi związku między stanem oddzielnych zmiennych (np.isNull
I_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.
źródło
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:
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ęctext
ważny jest stan zerowy .Bezwarunkowy zwrot
Najpierw spróbujmy zwrócić go bezpośrednio:
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 typustring
.Prosty warunkowy zwrot
Teraz sprawdźmy, czy w samym
if
warunku 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.)Świetnie, więc wygląda na to, że w
if
instrukcji, w której sam warunek sprawdza zerowość, stan zmiennej w każdej gałęziif
instrukcji może być inny: welse
bloku 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:
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:
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.):
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ć,
text
zanim warunek:Zwróć uwagę, że
return
instrukcja nie ma teraz ostrzeżenia: stan po wykonaniutext.Length
ma wartość „nie jest zerowa” (ponieważ jeśli wykonamy to wyrażenie pomyślnie, nie może być zerowy). Tak więctext
parametr 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:Dlatego, mimo że
x is object
jest obecnie zalecana alternatywax != null
, nie mają one ten sam efekt: tylko porównanie z wartością null (z dowolnegois
,==
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:
Choć my wiemy, że
alwaysTrue
zawsze będzie prawdziwe, to nie spełnia wymogów określonych w specyfikacji, które sprawiają, że kod poif
oświadczeniu nieosiągalnym, czyli to, czego potrzebujemy.Oto kolejny przykład dotyczący określonego przypisania:
Choć my wiemy, że kod wejdzie dokładnie jeden z tych
if
organó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.źródło
if (x != null) x.foo(); x.bar();
, mamy dwa dowody;if
oś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.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)
_test
może mieć wartość NULL, ponieważ gdyby nie mógł, nie byłoby porównania w pierwszej kolejności, a (2)isNull
może być prawdziwe lub fałszywe - ponieważ gdyby nie mógł, nie miałbyś tego wif
. Ale połączenie, którereturn _test;
działa tylko wtedy, gdy_test
nie 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ć.
źródło
bool b = x != null
vsbool 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.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 != null
ix is object
dają różne wyniki.źródło