Warum gibt dieser Code eine Compiler-Warnung "Mögliche Nullreferenzrückgabe" aus?


70

Betrachten Sie den folgenden Code:

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 = "";
    }
}

Wenn ich dies erstelle, gibt die mit gekennzeichnete Zeile !!!eine Compiler-Warnung aus : warning CS8603: Possible null reference return..

Ich finde das etwas verwirrend, da _testes schreibgeschützt und auf ungleich Null initialisiert ist.

Wenn ich den Code wie folgt ändere, verschwindet die Warnung:

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

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

Kann jemand dieses Verhalten erklären?


1
Der Debug.Assert ist irrelevant, da dies eine Laufzeitprüfung ist, während die Compilerwarnung eine Prüfung der Kompilierungszeit ist. Der Compiler hat keinen Zugriff auf das Laufzeitverhalten.
Polyfun

5
The Debug.Assert is irrelevant because that is a runtime check- Dies ist relevant, da die Warnung verschwindet, wenn Sie diese Zeile kommentieren.
Matthew Watson

1
@Polyfun: Der Compiler kann möglicherweise (über Attribute) wissen, dass Debug.Asserteine Ausnahme ausgelöst wird, wenn der Test fehlschlägt.
Jon Skeet

2
Ich habe hier viele verschiedene Fälle hinzugefügt, und es gibt einige wirklich interessante Ergebnisse. Werde später eine Antwort schreiben - arbeite erstmal.
Jon Skeet

2
@EricLippert: hat Debug.Assertjetzt eine Anmerkung ( src ) DoesNotReturnIf(false)für den Bedingungsparameter.
Jon Skeet

Antworten:


38

Die Analyse des nullbaren Flusses verfolgt den Nullzustand von Variablen, verfolgt jedoch keinen anderen Zustand, wie z. B. den Wert einer boolVariablen (wie isNulloben), und verfolgt nicht die Beziehung zwischen dem Zustand separater Variablen (z . B. isNullund _test).

Eine tatsächliche statische Analyse-Engine würde diese Dinge wahrscheinlich tun, wäre aber bis zu einem gewissen Grad auch "heuristisch" oder "willkürlich": Sie konnten nicht unbedingt die Regeln angeben, denen sie folgte, und diese Regeln könnten sich sogar im Laufe der Zeit ändern.

Das können wir nicht direkt im C # -Compiler tun. Die Regeln für nullbare Warnungen sind ziemlich ausgefeilt (wie Jons Analyse zeigt!), Aber sie sind Regeln und können begründet werden.

Bei der Einführung der Funktion scheint es, als hätten wir größtenteils die richtige Balance gefunden, aber es gibt einige Stellen, die sich als unangenehm herausstellen, und wir werden diese für C # 9.0 erneut prüfen.


3
Sie wissen, dass Sie die Gittertheorie in die Spezifikation aufnehmen möchten. Gittertheorie ist fantastisch und überhaupt nicht verwirrend! Tu es! :)
Eric Lippert

7
Sie wissen, dass Ihre Frage berechtigt ist, wenn der Programmmanager für C # antwortet!
Sam Rueby

1
@ TanveerBadar: In der Gittertheorie geht es um die Analyse von Wertesätzen, die eine Teilordnung haben. Typen sind ein gutes Beispiel; Wenn ein Wert vom Typ X einer Variablen vom Typ Y zugewiesen werden kann, bedeutet dies, dass Y "groß genug" ist, um X zu halten, und dass dies ausreicht, um ein Gitter zu bilden, das uns dann sagt, dass die Überprüfung der Zuweisbarkeit im Compiler formuliert werden könnte in der Spezifikation in Bezug auf die Gittertheorie. Dies ist für die statische Analyse relevant, da viele andere Themen, die für einen Analysator von Interesse sind, als die Typzuweisbarkeit, auch in Form von Gittern ausgedrückt werden können.
Eric Lippert

1
@ TanveerBadar: lara.epfl.ch/w/_media/sav08:schwartzbach.pdf enthält einige gute einführende Beispiele dafür, wie statische Analyse-Engines die Gittertheorie verwenden.
Eric Lippert

1
@EricLippert Awesome beginnt nicht, dich zu beschreiben. Dieser Link wird sofort in meine Must-Read-Liste aufgenommen.
Tanveer Badar

56

Ich kann eine vernünftige Vermutung anstellen , was hier vor sich geht, aber es ist alles etwas kompliziert :) Es handelt sich um den im Entwurfsspezifikation beschriebenen Nullzustand und die Nullverfolgung . Grundsätzlich warnt der Compiler an dem Punkt, an dem wir zurückkehren möchten, wenn der Status des Ausdrucks "vielleicht null" anstelle von "nicht null" ist.

Diese Antwort ist eher narrativ als nur "hier sind die Schlussfolgerungen" ... Ich hoffe, es ist auf diese Weise nützlicher.

Ich werde das Beispiel etwas vereinfachen, indem ich die Felder entferne, und eine Methode mit einer dieser beiden Signaturen in Betracht ziehen:

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

In den folgenden Implementierungen habe ich jeder Methode eine andere Nummer gegeben, damit ich eindeutig auf bestimmte Beispiele verweisen kann. Außerdem können alle Implementierungen im selben Programm vorhanden sein.

In jedem der unten beschriebenen Fälle werden wir verschiedene Dinge tun, aber am Ende versuchen, zurückzukehren text- es ist also der Nullzustand text, der wichtig ist.

Bedingungslose Rückgabe

Versuchen wir zunächst, es direkt zurückzugeben:

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

So weit, so einfach. Der nullfähige Status des Parameters zu Beginn der Methode ist "möglicherweise null", wenn er vom Typ iststring? und "nicht null", wenn er vom Typ ist string.

Einfache bedingte Rückgabe

Lassen Sie uns nun in der ifAnweisungsbedingung selbst nach Null suchen. (Ich würde den bedingten Operator verwenden, von dem ich glaube, dass er den gleichen Effekt hat, aber ich wollte der Frage treu bleiben.)

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
    }
}

Großartig, also sieht es so aus, als ob innerhalb einer ifAnweisung, in der die Bedingung selbst auf Nichtigkeit prüft, der Status der Variablen in jedem Zweig der ifAnweisung unterschiedlich sein kann: innerhalb derelse Blocks ist der Status in beiden Codeteilen "nicht null". Insbesondere in M3 ändert sich der Zustand von "vielleicht null" zu "nicht null".

Bedingte Rückgabe mit einer lokalen Variablen

Versuchen wir nun, diese Bedingung auf eine lokale Variable zu heben:

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
    }
}

Sowohl M5 als auch M6 geben Warnungen aus. Wir erhalten also nicht nur nicht den positiven Effekt der Zustandsänderung von "vielleicht null" zu "nicht null" in M5 (wie wir es in M3 getan haben) ... wir bekommen die gegenteiligen Effekt in M6, wo der Zustand von " nicht null "bis" vielleicht null ". Das hat mich wirklich überrascht.

Es sieht also so aus, als hätten wir Folgendes gelernt:

  • Die Logik "wie eine lokale Variable berechnet wurde" wird nicht zur Weitergabe von Statusinformationen verwendet. Dazu später mehr.
  • Das Einführen eines Nullvergleichs kann den Compiler warnen, dass etwas, von dem er zuvor dachte, dass es nicht null ist, doch null sein könnte.

Bedingungslose Rückgabe nach einem ignorierten Vergleich

Schauen wir uns den zweiten dieser Punkte an, indem wir einen Vergleich vor einer bedingungslosen Rückkehr einführen. (Wir ignorieren also das Ergebnis des Vergleichs vollständig.):

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
}

Beachten Sie, wie sich M8 anfühlt, als sollte es M2 entsprechen - beide haben einen Nicht-Null-Parameter, den sie bedingungslos zurückgeben -, aber die Einführung eines Vergleichs mit Null ändert den Status von "nicht null" in "vielleicht null". Wir können weitere Beweise dafür erhalten, indem wir versuchen, textvor der Bedingung zu dereferenzieren :

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
}

Beachten Sie, dass die returnAnweisung jetzt keine Warnung enthält: Der Status nach der Ausführung text.Lengthist "nicht null" (denn wenn wir diesen Ausdruck erfolgreich ausführen, kann er nicht null sein). Der textParameter beginnt also aufgrund seines Typs als "nicht null", wird aufgrund des Nullvergleichs zu "vielleicht null" und wird danach wieder zu "nicht null" text2.Length.

Welche Vergleiche wirken sich auf den Zustand aus?

Das ist also ein Vergleich von text is null... welchen Effekt haben ähnliche Vergleiche? Hier sind vier weitere Methoden, die alle mit einem nicht nullbaren String-Parameter beginnen:

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
}

Obwohl dies x is objectjetzt eine empfohlene Alternative zu ist x != null, haben sie nicht den gleichen Effekt: nur einen Vergleich mit null (mit einem von is, ==oder!= ) ändert den Zustand von „nicht null“ bis „vielleicht null“.

Warum wirkt sich das Heben des Zustands aus?

Wenn wir zu unserem ersten Aufzählungspunkt zurückkehren, warum berücksichtigen M5 und M6 nicht die Bedingung, die zur lokalen Variablen geführt hat? Das überrascht mich nicht so sehr, wie es andere zu überraschen scheint. Das Einbauen dieser Art von Logik in den Compiler und die Spezifikation ist viel Arbeit und für relativ wenig Nutzen. Hier ist ein weiteres Beispiel, das nichts mit Nullfähigkeit zu tun hat, bei der das Inlining etwas bewirkt:

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
}

Obwohl wir wissen, dass dies alwaysTrueimmer der Fall sein wird, erfüllt es nicht die Anforderungen in der Spezifikation, die den Code nach der ifAnweisung unerreichbar machen, was wir brauchen.

Hier ist ein weiteres Beispiel für eine bestimmte Zuordnung:

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);
}

Obwohl wir wissen, dass der Code genau einen dieser ifAnweisungskörper eingibt, gibt es in der Spezifikation nichts, was das klären könnte. Statische Analysewerkzeuge sind möglicherweise in der Lage, dies zu tun, aber es wäre eine schlechte Idee, dies in die Sprachspezifikation aufzunehmen, IMO - es ist in Ordnung, wenn statische Analysewerkzeuge alle Arten von Heuristiken haben, die sich im Laufe der Zeit entwickeln können, aber nicht so sehr für eine Sprachspezifikation.


7
Großartige Analyse Jon. Das Wichtigste, was ich beim Studium des Coverity Checkers gelernt habe, ist, dass Code ein Beweis für die Überzeugungen seiner Autoren ist . Wenn wir eine Nullprüfung sehen, die uns darüber informieren sollte, dass die Autoren des Codes die Prüfung für notwendig hielten. Der Prüfer sucht tatsächlich nach Beweisen dafür, dass die Überzeugungen der Autoren inkonsistent waren, da wir an den Stellen inkonsistente Überzeugungen über beispielsweise die Nichtigkeit sehen, dass Fehler auftreten.
Eric Lippert

6
Wenn wir zum Beispiel sehen if (x != null) x.foo(); x.bar();, haben wir zwei Beweisstücke; Die ifAussage ist ein Beweis für den Satz "Der Autor glaubt, dass x vor dem Aufruf von foo null sein könnte" und die folgende Aussage ist ein Beweis für "Der Autor glaubt, dass x vor dem Aufruf von bar nicht null ist", und dieser Widerspruch führt zu dem Schlussfolgerung, dass es einen Fehler gibt. Der Fehler ist entweder der relativ harmlose Fehler einer unnötigen Nullprüfung oder der möglicherweise abstürzende Fehler. Welcher Fehler der wahre Fehler ist, ist nicht klar, aber es ist klar, dass es einen gibt.
Eric Lippert

1
Das Problem, dass relativ unkomplizierte Prüfer, die die Bedeutung von Einheimischen nicht verfolgen und "falsche Pfade" nicht beschneiden - Kontrollflusspfade, von denen Menschen sagen können, dass sie unmöglich sind -, dazu neigen, falsch positive Ergebnisse zu erzeugen, gerade weil sie die nicht genau modelliert haben Überzeugungen der Autoren. Das ist das Knifflige!
Eric Lippert

3
Die Inkonsistenz zwischen "is object", "is {}" und "! = Null" ist ein Punkt, den wir in den letzten Wochen intern besprochen haben. Wir werden es in naher Zukunft bei LDM ansprechen, um zu entscheiden, ob wir diese als reine Nullprüfungen betrachten müssen oder nicht (was das Verhalten konsistent macht).
JaredPar

1
@ArnonAxelrod Das sagt , es ist nicht gemeint null sein. Es könnte immer noch null sein, da nullfähige Referenztypen nur ein Compiler-Hinweis sind. (Beispiele: M8 (null!); Oder Aufrufen aus C # 7-Code oder Ignorieren von Warnungen.) Es ist nicht wie die Typensicherheit des Restes der Plattform.
Jon Skeet

29

Sie haben Beweise dafür gefunden, dass der Programmablaufalgorithmus, der diese Warnung erzeugt, relativ unkompliziert ist, wenn es darum geht, die in lokalen Variablen codierten Bedeutungen zu verfolgen.

Ich habe keine spezifischen Kenntnisse über die Implementierung des Flow Checkers, aber nachdem ich in der Vergangenheit an Implementierungen ähnlichen Codes gearbeitet habe, kann ich einige fundierte Vermutungen anstellen. Der Flussprüfer leitet im falsch positiven Fall wahrscheinlich zwei Dinge ab: (1) _testkönnte null sein, denn wenn dies nicht möglich wäre, hätten Sie den Vergleich überhaupt nicht, und (2) isNullkönnte wahr oder falsch sein - weil Wenn es nicht könnte, würden Sie es nicht in einem haben if. Aber die Verbindung, die return _test;nur ausgeführt wird, wenn _testnicht null ist, wird diese Verbindung nicht hergestellt.

Dies ist ein überraschend kniffliges Problem, und Sie sollten damit rechnen, dass es eine Weile dauern wird, bis der Compiler die Raffinesse von Tools erreicht hat, an denen Experten seit mehreren Jahren arbeiten. Der Coverity Flow Checker zum Beispiel hätte überhaupt kein Problem damit, zu folgern, dass keine Ihrer beiden Varianten eine Nullrendite hatte, aber der Coverity Flow Checker kostet für Firmenkunden viel Geld.

Außerdem sind die Coverity-Prüfer so konzipiert, dass sie über Nacht auf großen Codebasen ausgeführt werden können . Die Analyse des C # -Compilers muss zwischen den Tastenanschlägen im Editor ausgeführt werden , wodurch sich die Art der eingehenden Analysen, die Sie vernünftigerweise durchführen können, erheblich ändert.


"Unsophisticated" ist richtig - ich halte es für verzeihlich, wenn es über Dinge wie Bedingungen stolpert, da wir alle wissen, dass das Problem des Anhaltens in solchen Angelegenheiten ein bisschen schwierig ist, aber die Tatsache, dass es überhaupt einen Unterschied zwischen bool b = x != nullvs bool b = x is { }(mit gibt Keine der tatsächlich verwendeten Zuordnungen!) zeigt, dass selbst die erkannten Muster für Nullprüfungen fraglich sind. Um die zweifellos harte Arbeit des Teams nicht herabzusetzen, damit diese Arbeit größtenteils so funktioniert, wie es für echte, in Gebrauch befindliche Codebasen sein sollte - es sieht so aus, als wäre die Analyse kapitalpragmatisch.
Jeroen Mostert

@JeroenMostert: Jared Par erwähnt in einem Kommentar zu Jon Skeets Antwort, dass Microsoft dieses Problem intern diskutiert.
Brian

8

Alle anderen Antworten sind ziemlich genau richtig.

Falls jemand neugierig ist, habe ich versucht, die Logik des Compilers unter https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947 so explizit wie möglich zu formulieren

Das eine Stück, das nicht erwähnt wird, ist, wie wir entscheiden, ob eine Nullprüfung als "rein" betrachtet werden soll, in dem Sinne, dass wir ernsthaft überlegen sollten, ob Null eine Möglichkeit ist, wenn Sie dies tun. Es gibt viele "zufällige" Nullprüfungen in C #, bei denen Sie als Teil einer anderen Aktion auf Null testen. Daher haben wir beschlossen, die Anzahl der Überprüfungen auf diejenigen zu beschränken, von denen wir sicher waren, dass sie absichtlich durchgeführt wurden. Die Heuristik, die wir uns ausgedacht haben, war "enthält das Wort null", deshalb x != nullund x is objectführen Sie zu unterschiedlichen Ergebnissen.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.