Pourquoi ce code donne-t-il un avertissement du compilateur «Possible null reference return»?


71

Considérez le code suivant:

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

Quand je construis cela, la ligne marquée !!!donne un avertissement du compilateur: warning CS8603: Possible null reference return..

Je trouve cela un peu déroutant, étant donné qu'il _testest en lecture seule et initialisé à non nul.

Si je change le code comme suit, l'avertissement disparaît:

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

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

Quelqu'un peut-il expliquer ce comportement?


1
Debug.Assert n'est pas pertinent car il s'agit d'une vérification de l'exécution, tandis que l'avertissement du compilateur est une vérification de l'heure de compilation. Le compilateur n'a pas accès au comportement d'exécution.
Polyfun

5
The Debug.Assert is irrelevant because that is a runtime check- Il est pertinent , car si vous commentez que sur la ligne, l'avertissement disparaît.
Matthew Watson

1
@Polyfun: Le compilateur peut potentiellement savoir (via des attributs) qui lèvera Debug.Assertune exception si le test échoue.
Jon Skeet

2
J'ai ajouté beaucoup de cas différents ici, et il y a des résultats vraiment intéressants. Je rédigerai une réponse plus tard - travail à faire pour l'instant.
Jon Skeet

2
@EricLippert: a Debug.Assertmaintenant une annotation ( src ) DoesNotReturnIf(false)pour le paramètre de condition.
Jon Skeet

Réponses:


39

L'analyse de flux annulable suit l' état nul des variables, mais elle ne suit pas les autres états, tels que la valeur d'une boolvariable (comme isNullci-dessus), et elle ne suit pas la relation entre l'état des variables distinctes (par exemple isNullet _test).

Un moteur d'analyse statique réel ferait probablement ces choses, mais serait également "heuristique" ou "arbitraire" dans une certaine mesure: vous ne pourriez pas nécessairement dire les règles qu'il suivait, et ces règles pourraient même changer avec le temps.

Ce n'est pas quelque chose que nous pouvons faire directement dans le compilateur C #. Les règles pour les avertissements annulables sont assez sophistiquées (comme le montre l'analyse de Jon!), Mais ce sont des règles et peuvent être motivées.

Au fur et à mesure que nous déployons la fonctionnalité, il semble que nous ayons principalement atteint le bon équilibre, mais il y a quelques endroits qui semblent aussi maladroits, et nous reviendrons sur ceux de C # 9.0.


3
Vous savez que vous voulez mettre la théorie du réseau dans la spécification; la théorie du réseau est impressionnante et pas du tout déroutante! Fais le! :)
Eric Lippert

7
Vous savez que votre question est légitime lorsque le gestionnaire de programme pour C # répond!
Sam Rueby

1
@TanveerBadar: La théorie du réseau concerne l'analyse d'ensembles de valeurs qui ont un ordre partiel; les types sont un bon exemple; si une valeur de type X est attribuable à une variable de type Y, cela implique que Y est "assez grand" pour contenir X, et cela suffit pour former un réseau, ce qui nous dit alors que la vérification de l'assignation dans le compilateur pourrait être formulée dans la spécification en termes de théorie du réseau. Ceci est pertinent pour l'analyse statique car un grand nombre de sujets d'intérêt pour un analyseur autre que l'attribution de type sont également exprimables en termes de réseaux.
Eric Lippert

1
@TanveerBadar: lara.epfl.ch/w/_media/sav08:schwartzbach.pdf contient de bons exemples d'introduction sur la façon dont les moteurs d'analyse statique utilisent la théorie du réseau.
Eric Lippert

1
@EricLippert Awesome ne commence pas à vous décrire. Ce lien va tout de suite dans ma liste de lecture incontournable.
Tanveer Badar

56

Je peux faire une estimation raisonnable de ce qui se passe ici, mais c'est un peu compliqué :) Cela implique l' état nul et le suivi nul décrits dans le projet de spécification . Fondamentalement, au point où nous voulons revenir, le compilateur avertira si l'état de l'expression est "peut-être nul" au lieu de "non nul".

Cette réponse est plus ou moins narrative plutôt que "voici les conclusions" ... J'espère que c'est plus utile de cette façon.

Je vais simplifier légèrement l'exemple en supprimant les champs et envisager une méthode avec l'une de ces deux signatures:

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

Dans les implémentations ci-dessous, j'ai donné à chaque méthode un numéro différent afin que je puisse faire référence à des exemples spécifiques sans ambiguïté. Il permet également à toutes les implémentations d'être présentes dans le même programme.

Dans chacun des cas décrits ci-dessous, nous ferons diverses choses, mais finirons par essayer de revenir text- c'est donc l'état nul textqui est important.

Retour inconditionnel

Tout d'abord, essayons simplement de le retourner directement:

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

Jusqu'à présent, si simple. L'état nullable du paramètre au début de la méthode est "peut-être nul" s'il est de type string?et "non nul" s'il est de type string.

Retour conditionnel simple

Vérifions maintenant la valeur null dans la ifcondition d'instruction elle-même. (J'utiliserais l'opérateur conditionnel, qui, je crois, aura le même effet, mais je voulais rester plus fidèle à la question.)

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

Génial, il ressemble donc à une ifinstruction où la condition elle-même vérifie la nullité, l'état de la variable dans chaque branche de l' ifinstruction peut être différent: dans le elsebloc, l'état n'est "pas nul" dans les deux morceaux de code. Ainsi, en particulier, dans M3, l'état passe de "peut-être nul" à "non nul".

Retour conditionnel avec une variable locale

Essayons maintenant de hisser cette condition à une variable 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
    }
}

Les deux avertissements d'émission M5 et M6. Donc non seulement nous n'obtenons pas l'effet positif du changement d'état de "peut-être nul" à "non nul" dans M5 (comme nous l'avons fait dans M3) ... nous obtenons l' effet inverse dans M6, où l'état va de " pas null "à" peut-être null ". Cela m'a vraiment surpris.

Il semble donc que nous ayons appris que:

  • La logique autour de la façon dont une variable locale a été calculée n'est pas utilisée pour propager les informations d'état. Plus sur cela plus tard.
  • L'introduction d'une comparaison nulle peut avertir le compilateur que quelque chose qu'il pensait précédemment non nul peut être nul après tout.

Retour inconditionnel après une comparaison ignorée

Examinons le deuxième de ces points, en introduisant une comparaison avant un retour inconditionnel. (Nous ignorons donc complètement le résultat de la comparaison.):

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
}

Notez comment M8 a l'impression qu'il devrait être équivalent à M2 - les deux ont un paramètre non nul qu'ils renvoient sans condition - mais l'introduction d'une comparaison avec null change l'état de "non nul" à "peut-être nul". Nous pouvons obtenir des preuves supplémentaires de cela en essayant de déréférencer textavant la condition:

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
}

Notez que l' returninstruction n'a pas d'avertissement maintenant: l'état après l' exécution text.Lengthn'est "pas nul" (car si nous exécutons cette expression avec succès, elle ne peut pas être nulle). Ainsi, le textparamètre commence comme "non nul" en raison de son type, devient "peut-être nul" en raison de la comparaison nulle, puis redevient "non nul" après text2.Length.

Quelles comparaisons affectent l'état?

Voilà donc une comparaison de text is null... quel effet ont des comparaisons similaires? Voici quatre autres méthodes, toutes commençant par un paramètre de chaîne 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
}

Ainsi, même si x is objectc'est maintenant une alternative recommandée à x != null, ils n'ont pas le même effet: seule une comparaison avec null (avec n'importe lequel des is, ==ou !=) change l'état de "non nul" à "peut-être nul".

Pourquoi le levage de la condition a-t-il un effet?

Pour en revenir à notre premier point précédent, pourquoi M5 et M6 ne tiennent-ils pas compte de la condition qui a conduit à la variable locale? Cela ne me surprend pas autant que cela semble surprendre les autres. Construire ce type de logique dans le compilateur et la spécification demande beaucoup de travail et relativement peu d'avantages. Voici un autre exemple qui n'a rien à voir avec la nullité où l'inclusion de quelque chose a un effet:

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
}

Même si nous savons que cela alwaysTruesera toujours vrai, cela ne satisfait pas aux exigences de la spécification qui rendent le code après l' ifinstruction inaccessible, ce dont nous avons besoin.

Voici un autre exemple, concernant l'affectation définitive:

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

Même si nous savons que le code entrera exactement dans l'un de ces ifcorps de déclaration, il n'y a rien dans la spécification pour le résoudre. Les outils d'analyse statique pourraient bien être en mesure de le faire, mais essayer de mettre cela dans la spécification du langage serait une mauvaise idée, OMI - c'est bien pour les outils d'analyse statique d'avoir toutes sortes d'heuristiques qui peuvent évoluer dans le temps, mais pas tellement pour une spécification de langue.


7
Grande analyse Jon. La chose clé que j'ai apprise en étudiant le vérificateur de couverture est que le code témoigne des croyances de ses auteurs . Lorsque nous voyons une vérification nulle qui devrait nous informer que les auteurs du code pensaient que la vérification était nécessaire. Le vérificateur recherche actuellement des preuves que les croyances des auteurs étaient incohérentes, car ce sont les endroits où nous voyons des croyances incohérentes concernant, disons, la nullité, que des bogues se produisent.
Eric Lippert

6
Lorsque nous voyons, par exemple, if (x != null) x.foo(); x.bar();nous avons deux éléments de preuve; la ifdéclaration est la preuve de la proposition "l'auteur pense que x pourrait être nul avant l'appel à foo" et la déclaration suivante est la preuve de "l'auteur pense que x n'est pas nul avant l'appel à la barre", et cette contradiction conduit à la conclusion qu'il y a un bug. Le bogue est soit le bogue relativement bénin d'une vérification nulle inutile, soit le bogue potentiellement en panne. Quel bug est le vrai bug n'est pas clair, mais il est clair qu'il y en a un.
Eric Lippert

1
Le problème que les vérificateurs relativement peu sophistiqués qui ne suivent pas les significations des habitants et n'élaguent pas les «faux chemins» - contrôlent les chemins d'écoulement que les humains peuvent vous dire sont impossibles - ont tendance à produire des faux positifs précisément parce qu'ils n'ont pas modélisé avec précision le croyances des auteurs. C'est la partie délicate!
Eric Lippert

3
L'incohérence entre "is object", "is {}" et "! = Null" est un élément dont nous avons discuté en interne ces dernières semaines. Je vais en parler à LDM dans un très proche avenir pour décider si nous devons les considérer comme des contrôles nuls purs ou non (ce qui rend le comportement cohérent).
JaredPar

1
@ArnonAxelrod Cela dit que ce n'est pas censé être nul. Il peut toujours être nul, car les types de référence nullables ne sont qu'un indice de compilation. (Exemples: M8 (null!); Ou l'appeler à partir du code C # 7, ou ignorer les avertissements.) Ce n'est pas comme la sécurité de type du reste de la plate-forme.
Jon Skeet

29

Vous avez découvert des preuves que l'algorithme de flux de programme qui produit cet avertissement est relativement peu sophistiqué lorsqu'il s'agit de suivre les significations codées dans les variables locales.

Je n'ai aucune connaissance spécifique de l'implémentation du vérificateur de flux, mais ayant travaillé sur des implémentations de code similaire dans le passé, je peux faire des suppositions éclairées. Le vérificateur de flux déduit probablement deux choses dans le cas des faux positifs: (1) _testpourrait être nul, car si ce n'était pas le cas, vous n'auriez pas la comparaison en premier lieu, et (2) isNullpourrait être vrai ou faux - parce que s'il ne le pouvait pas, vous ne l'auriez pas dans un if. Mais la connexion qui return _test;s'exécute uniquement _testn'est pas nulle, cette connexion n'est pas établie.

Il s'agit d'un problème étonnamment délicat, et vous devez vous attendre à ce que le compilateur prenne un certain temps pour atteindre la sophistication d'outils qui ont nécessité plusieurs années de travail par des experts. Le vérificateur de flux Coverity, par exemple, n'aurait aucun problème à déduire qu'aucune de vos deux variantes n'a eu un retour nul, mais le vérificateur de flux Coverity coûte beaucoup d'argent pour les entreprises clientes.

En outre, les vérificateurs de couverture sont conçus pour fonctionner sur de grandes bases de code pendant la nuit ; l'analyse du compilateur C # doit s'exécuter entre les frappes de touches dans l'éditeur , ce qui modifie considérablement les types d'analyses approfondies que vous pouvez raisonnablement effectuer.


"Non sophistiqué" a raison - je considère qu'il est pardonnable s'il tombe sur des choses comme les conditionnelles, car nous savons tous que le problème de l'arrêt est un peu difficile à résoudre dans de telles questions, mais le fait qu'il y ait une différence entre bool b = x != nullVS bool b = x is { }(avec aucune des affectations réellement utilisées!) montre que même les modèles reconnus pour les contrôles nuls sont discutables. Pour ne pas dénigrer le travail sans aucun doute dur de l'équipe pour faire ce travail principalement comme il se doit pour des bases de code réelles et en cours d'utilisation - il semble que l'analyse soit pragmatique en capital-P.
Jeroen Mostert

@JeroenMostert: Jared Par mentionne dans un commentaire sur la réponse de Jon Skeet que Microsoft discute de ce problème en interne.
Brian

8

Toutes les autres réponses sont à peu près exactement correctes.

Au cas où quelqu'un serait curieux, j'ai essayé d'énoncer la logique du compilateur aussi explicitement que possible dans https://github.com/dotnet/roslyn/issues/36927#issuecomment-508595947

Le seul élément qui n'est pas mentionné est la façon dont nous décidons si un chèque nul doit être considéré comme «pur», dans le sens où si vous le faites, nous devrions sérieusement considérer si la nullité est une possibilité. Il y a beaucoup de vérifications nulles "accessoires" en C #, où vous testez la nullité dans le cadre de quelque chose d'autre, nous avons donc décidé de restreindre l'ensemble des vérifications à celles que nous étions sûrs que les gens faisaient délibérément. L'heuristique que nous avons trouvée était "contient le mot null", c'est pourquoi x != nullet x is objectproduire des résultats différents.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.