Perché questo codice fornisce un avviso del compilatore "Possibile ritorno di riferimento null"?


71

Considera il seguente codice:

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

Quando costruisco questo, la linea tracciata con !!!fornisce un avviso del compilatore: warning CS8603: Possible null reference return..

Lo trovo un po 'confuso, dato che _testè di sola lettura e inizializzato a non nullo.

Se cambio il codice nel modo seguente, l'avviso scompare:

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

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

Qualcuno può spiegare questo comportamento?


1
Debug.Assert è irrilevante perché si tratta di un controllo di runtime, mentre l'avviso del compilatore è un controllo del tempo di compilazione. Il compilatore non ha accesso al comportamento di runtime.
Polyfun,

5
The Debug.Assert is irrelevant because that is a runtime check- Si è rilevante, perché se si commento che linea fuori, l'avviso va via.
Matthew Watson,

1
@Polyfun: il compilatore può potenzialmente sapere (tramite attributi) che Debug.Assertgenererà un'eccezione se il test fallisce.
Jon Skeet,

2
Ho aggiunto molti casi diversi qui e ci sono alcuni risultati davvero interessanti. Scriverà una risposta in seguito - lavoro da fare per ora.
Jon Skeet,

2
@EricLippert: Debug.Assertora ha un'annotazione ( src ) di DoesNotReturnIf(false)per il parametro condition.
Jon Skeet,

Risposte:


39

L'analisi del flusso nullable tiene traccia dello stato null delle variabili, ma non tiene traccia di altri stati, come il valore di una boolvariabile (come isNullsopra), e non tiene traccia della relazione tra lo stato di variabili separate (ad es. isNullE _test).

Un vero motore di analisi statica farebbe probabilmente queste cose, ma sarebbe anche "euristico" o "arbitrario" in una certa misura: non si potrebbe necessariamente dire le regole che stava seguendo e quelle regole potrebbero anche cambiare nel tempo.

Non è qualcosa che possiamo fare direttamente nel compilatore C #. Le regole per gli avvisi nullable sono piuttosto sofisticate (come mostra l'analisi di Jon!), Ma sono regole e possono essere ragionate.

Man mano che implementiamo la funzionalità, sembra che per lo più abbiamo raggiunto il giusto equilibrio, ma ci sono alcuni posti che risultano imbarazzanti e rivisiteremo quelli per C # 9.0.


3
Sai che vuoi mettere la teoria reticolare nelle specifiche; la teoria della grata è fantastica e per nulla confusa! Fallo! :)
Eric Lippert il

7
Sai che la tua domanda è legittima quando il gestore del programma per C # risponde!
Sam Rueby,

1
@TanveerBadar: la teoria della grata riguarda l'analisi di insiemi di valori che hanno un ordine parziale; i tipi sono un buon esempio; se un valore di tipo X è assegnabile a una variabile di tipo Y, ciò implica che Y è "abbastanza grande" per contenere X, e che è sufficiente per formare un reticolo, che quindi ci dice che il controllo dell'assegnabilità nel compilatore potrebbe essere formulato nelle specifiche in termini di teoria reticolare. Ciò è rilevante per l'analisi statica perché molti argomenti di interesse per un analizzatore diversi dall'assegnabilità dei tipi sono anche espressibili in termini di reticoli.
Eric Lippert,

1
@TanveerBadar: lara.epfl.ch/w/_media/sav08:schwartzbach.pdf ha alcuni buoni esempi introduttivi su come i motori di analisi statica usano la teoria dei reticoli.
Eric Lippert,

1
@EricLippert Awesome non inizia a descriverti. Quel link sta entrando nella mia lista dei must subito.
Tanveer Badar,

56

Posso fare un'ipotesi ragionevole su cosa sta succedendo qui, ma è tutto un po 'complicato :) Comprende lo stato e il tracciamento null descritti nella bozza delle specifiche . Fondamentalmente, nel punto in cui vogliamo tornare, il compilatore avviserà se lo stato dell'espressione è "forse null" anziché "non null".

Questa risposta è in qualche modo narrativa piuttosto che solo "ecco le conclusioni" ... Spero sia più utile in questo modo.

Semplificherò leggermente l'esempio eliminando i campi e considererò un metodo con una di queste due firme:

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

Nelle implementazioni di seguito ho assegnato a ciascun metodo un numero diverso in modo da poter fare riferimento a esempi specifici senza ambiguità. Inoltre, consente a tutte le implementazioni di essere presenti nello stesso programma.

In ciascuno dei casi descritti di seguito, faremo varie cose, ma alla fine cercheremo di tornare text, quindi è lo stato nullo di textciò che è importante.

Ritorno incondizionato

Innanzitutto, proviamo a restituirlo direttamente:

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

Finora così semplice. Lo stato nullable del parametro all'inizio del metodo è "forse null" se è di tipo string?e "non null" se è di tipo string.

Ritorno condizionale semplice

Ora controlliamo per null all'interno della ifcondizione dell'istruzione stessa. (Vorrei usare l'operatore condizionale, che credo abbia lo stesso effetto, ma volevo rimanere più fedele alla domanda.)

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

Ottimo, quindi sembra all'interno di ifun'istruzione in cui la condizione stessa verifica la nullità, lo stato della variabile all'interno di ciascun ramo ifdell'istruzione può essere diverso: all'interno del elseblocco, lo stato è "non nullo" in entrambi i pezzi di codice. Quindi, in particolare, in M3 lo stato cambia da "forse null" a "non null".

Ritorno condizionale con una variabile locale

Ora proviamo a sollevare quella condizione su una variabile locale:

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

Sia M5 che M6 ​​emettono avvisi. Quindi non solo non otteniamo l'effetto positivo del cambio di stato da "forse null" a "non null" in M5 (come abbiamo fatto in M3) ... otteniamo l' effetto opposto in M6, da dove lo stato passa " non null "a" forse null ". Mi ha davvero sorpreso.

Quindi sembra che abbiamo imparato che:

  • La logica "come è stata calcolata una variabile locale" non viene utilizzata per propagare le informazioni sullo stato. Ne parleremo più avanti.
  • L'introduzione di un confronto null può avvisare il compilatore che qualcosa che in precedenza pensava non fosse null potrebbe essere nullo dopo tutto.

Ritorno incondizionato dopo un confronto ignorato

Diamo un'occhiata al secondo di quei punti elenco, introducendo un confronto prima di un ritorno incondizionato. (Quindi ignoriamo completamente il risultato del confronto.):

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
}

Nota come M8 dovrebbe essere equivalente a M2 - entrambi hanno un parametro non nullo che restituiscono incondizionatamente - ma l'introduzione di un confronto con null cambia lo stato da "non null" a "forse null". Possiamo ottenere ulteriori prove di ciò provando a dereference textprima della condizione:

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
}

Nota come l' returnistruzione non abbia ora un avviso: lo stato dopo l' esecuzione text.Lengthè "non nullo" (perché se eseguiamo quell'espressione con successo, non potrebbe essere nulla). Quindi il textparametro inizia come "non null" a causa del suo tipo, diventa "forse null" a causa del confronto null, quindi diventa di nuovo "non null" dopo text2.Length.

Quali confronti influenzano lo stato?

Quindi questo è un confronto di text is null... quali effetti hanno comparazioni simili? Ecco altri quattro metodi, tutti che iniziano con un parametro stringa non nullable:

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
}

Quindi, anche se x is objectora è un'alternativa consigliata a x != null, non hanno lo stesso effetto: solo un confronto con null (con uno qualsiasi di is, ==o !=) cambia lo stato da "non null" a "forse null".

Perché il sollevamento della condizione ha un effetto?

Tornando al nostro primo punto elenco in precedenza, perché M5 e M6 non tengono conto della condizione che ha portato alla variabile locale? Questo non mi sorprende tanto quanto sembra sorprendere gli altri. Costruire quel tipo di logica nel compilatore e nelle specifiche richiede molto lavoro e per un beneficio relativamente scarso. Ecco un altro esempio che non ha nulla a che fare con l'annullamento in cui l'inserimento di qualcosa ha un effetto:

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
}

Anche se noi sappiamo che alwaysTruesarà sempre vero, non soddisfa i requisiti del disciplinare che rendono il codice dopo la ifdichiarazione irraggiungibile, che è quello che ci serve.

Ecco un altro esempio di incarico definito:

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

Anche se noi sappiamo che il codice entrerà esattamente uno di quei ifcorpi dichiarazione, non c'è nulla nelle specifiche di lavoro che fuori. Gli strumenti di analisi statica potrebbero essere in grado di farlo, ma cercare di inserirlo nella specifica del linguaggio sarebbe una cattiva idea, IMO: va bene che gli strumenti di analisi statica abbiano tutti i tipi di euristica che possono evolversi nel tempo, ma non così tanto per una specifica della lingua.


7
Grande analisi Jon. La cosa chiave che ho imparato studiando il controllo di Coverity è che il codice è la prova delle credenze dei suoi autori . Quando vediamo un controllo nullo che dovrebbe informarci che gli autori del codice hanno ritenuto che il controllo fosse necessario. Il controllore è in realtà alla ricerca di prove che le credenze degli autori fossero incoerenti perché sono i luoghi in cui vediamo credenze incoerenti, diciamo, sulla nullità, che si verificano i bug.
Eric Lippert,

6
Quando vediamo ad esempio if (x != null) x.foo(); x.bar();abbiamo due prove; l' ifaffermazione è la prova della proposizione "l'autore ritiene che x potrebbe essere nullo prima della chiamata a pippo" e la seguente affermazione è la prova di "l'autore crede che x non sia nullo prima della chiamata a barra", e questa contraddizione porta alla conclusione che c'è un bug. Il bug è il bug relativamente benigno di un controllo null non necessario o il bug potenzialmente in crash. Quale bug è il vero bug non è chiaro, ma è chiaro che ce n'è uno.
Eric Lippert,

1
Il problema che controllori relativamente poco sofisticati che non tengono traccia dei significati dei locali e non potano i "falsi percorsi" - controllano i percorsi di flusso che gli umani possono dirti sono impossibili - tendono a produrre falsi positivi proprio perché non hanno modellato accuratamente il credenze degli autori. Questa è la parte difficile!
Eric Lippert,

3
L'incoerenza tra "è oggetto", "è {}" e "! = Null" è un argomento di cui abbiamo discusso internamente nelle ultime settimane. Andiamo a presentarlo a LDM nel prossimo futuro per decidere se dobbiamo considerare questi come veri e propri controlli nulli (il che rende il comportamento coerente).
JaredPar,

1
@ArnonAxelrod Dice che non è pensato per essere nullo. Potrebbe essere ancora nullo, poiché i tipi di riferimento nullable sono solo un suggerimento del compilatore. (Esempi: M8 (null!); O chiamandolo dal codice C # 7 o ignorando gli avvisi.) Non è come la sicurezza del tipo del resto della piattaforma.
Jon Skeet,

29

Hai scoperto prove che l'algoritmo del flusso di programma che produce questo avviso è relativamente poco sofisticato quando si tratta di tenere traccia dei significati codificati nelle variabili locali.

Non ho una conoscenza specifica dell'implementazione del controllore di flusso, ma avendo lavorato su implementazioni di codice simile in passato, posso fare alcune ipotesi istruite. Il controllo di flusso sta probabilmente deducendo due cose nel caso falso positivo: (1) _testpotrebbe essere nullo, perché in caso contrario, non si avrebbe il confronto in primo luogo e (2) isNullpotrebbe essere vero o falso - perché se non potesse, non lo avresti in un if. Ma la connessione che return _test;esegue solo se _testnon è nulla, quella connessione non viene stabilita.

Questo è un problema sorprendentemente complicato e dovresti aspettarti che il compilatore impiegherà un po 'di tempo a raggiungere la raffinatezza degli strumenti che hanno avuto diversi anni di lavoro da parte di esperti. Il correttore di flussi di Coverity, ad esempio, non avrebbe alcun problema nel dedurre che nessuna delle due varianti avesse un ritorno nullo, ma il controllore di flusso di Coverity ha un costo considerevole per i clienti aziendali.

Inoltre, i controllori Coverity sono progettati per funzionare su basi di codice di grandi dimensioni durante la notte ; l'analisi del compilatore C # deve essere eseguita tra sequenze di tasti nell'editor , il che modifica in modo significativo il tipo di analisi approfondite che è possibile eseguire ragionevolmente.


"Non sofisticato" è giusto - lo considero perdonabile se inciampa su cose come i condizionali, poiché tutti sappiamo che il problema di fermarsi è un po 'duro in tali questioni, ma il fatto che ci sia una differenza tra bool b = x != nullvs bool b = x is { }(con nessuno dei compiti effettivamente utilizzati!) mostra che anche i modelli riconosciuti per i controlli nulli sono discutibili. Non denigrare il lavoro indubbiamente duro del team per farlo funzionare principalmente come dovrebbe per basi di codice reali e in uso - sembra che l'analisi sia pragmatica in termini di capitale.
Jeroen Mostert,

@JeroenMostert: Jared Par menziona in un commento sulla risposta di Jon Skeet che Microsoft sta discutendo la questione internamente.
Brian,

8

Tutte le altre risposte sono praticamente esattamente corrette.

Nel caso qualcuno fosse curioso, ho provato a precisare la logica del compilatore nel modo più esplicito possibile in https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947

L'unico pezzo che non è menzionato è il modo in cui decidiamo se un controllo null debba essere considerato "puro", nel senso che se lo fai, dovremmo seriamente considerare se il null è una possibilità. Ci sono molti controlli null "casuali" in C #, dove si verifica il null come parte del fare qualcos'altro, quindi abbiamo deciso che volevamo restringere il set di controlli a quelli che eravamo sicuri che la gente stesse facendo deliberatamente. L'euristica che ci è venuta in mente era "contiene la parola null", quindi è per questo x != nullche x is objectproduce risultati diversi.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.