87 votes

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

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

Lorsque je construis ceci, la ligne marquée avec !!! donne un avertissement au compilateur : warning CS8603: Possible null reference return. .

Je trouve cela un peu déroutant, étant donné que _test est en lecture seule et initialisée à une valeur non nulle.

Si je modifie 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 votes

Debug.Assert n'est pas pertinent car il s'agit d'un contrôle au moment de l'exécution, alors que l'avertissement du compilateur est un contrôle au moment de la compilation. Le compilateur n'a pas accès au comportement du temps d'exécution.

0 votes

Je suppose que c'est la même chose que si vous faites if(thisBool == true) { var x = 1; } else { var x = 0; } return x; Ceci ne peut pas non plus être compilé, pour la raison que x n'est pas déclaré, même s'il existe à 100% à ce stade. Pour votre code, le compilateur ne peut tout simplement pas réaliser, que _test ne peut pas être null. Vous pourriez le mettre dans un if(_test != null)

6 votes

The Debug.Assert is irrelevant because that is a runtime check - Il est pertinent car si vous commentez cette ligne, l'avertissement disparaît.

66voto

Jon Skeet Points 692016

Je peux faire un raisonnable devinez de ce qui se passe ici, mais c'est un peu compliqué :) Il s'agit de la l'état nul et le suivi nul décrits dans le projet de spécification . Fondamentalement, au moment où nous voulons retourner, le compilateur nous avertira si l'état de l'expression est "peut-être nul" au lieu de "non nul".

Cette réponse se présente sous une forme plus narrative que le simple "voici les conclusions"... J'espère qu'elle sera plus utile de cette façon.

Je vais simplifier légèrement l'exemple en me débarrassant des champs, et considérer 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 de pouvoir faire référence à des exemples spécifiques sans ambiguïté. Cela 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 retourner text - donc c'est l'état nul de text c'est important.

Retour inconditionnel

Tout d'abord, essayons de le renvoyer directement :

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

Jusqu'ici, c'est simple. L'état nullable du paramètre au début de la méthode est "peut-être null" s'il est de type string? et "not null" s'il est de type string .

Retour conditionnel simple

Maintenant, vérifions la présence de null dans le if la condition de l'énoncé lui-même. (J'utiliserais l'opérateur conditionnel, qui, je pense, 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
    }
}

Super, donc on dirait que dans un if où la condition elle-même vérifie la nullité, l'état de la variable dans chaque branche de l'instruction if peut être différente : dans le else l'état est "non nul" dans les deux morceaux de code. Donc, en particulier, dans M3, l'état passe de "peut-être nul" à "non nul".

Retour conditionnel avec une variable locale

Essayons maintenant de faire passer cette condition dans 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 sites M5 et M6 émettent des avertissements. Ainsi, non seulement nous n'obtenons pas l'effet positif du changement d'état de "peut-être nul" à "non nul" dans M5 (comme c'était le cas dans M3)... mais nous obtenons l'effet positif de la modification de l'état. en face de dans M6, où l'état passe de "non nul" à "peut-être nul". Cela m'a vraiment surpris.

On dirait qu'on l'a appris :

  • La logique autour de "comment une variable locale a été calculée" n'est pas utilisée pour propager des informations d'état. Nous y reviendrons plus tard.
  • L'introduction d'une comparaison de nullité peut avertir le compilateur que quelque chose qu'il pensait ne pas être null pourrait l'être 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 semble être équivalent à M2 - les deux ont un paramètre not-null qu'ils retournent inconditionnellement - mais l'introduction d'une comparaison avec null change l'état de "not null" à "maybe null". Nous pouvons obtenir une preuve supplémentaire de cela en essayant de déréférencer text avant 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 comment le return La déclaration n'a pas d'avertissement maintenant : l'État après exécution de text.Length est "non nulle" (car si nous exécutons cette expression avec succès, elle ne peut pas être nulle). Ainsi, le text commence par être "non nul" en raison de son type, devient "peut-être nul" en raison de la comparaison avec le paramètre nul, puis redevient "non nul" après l'application de la méthode de comparaison. text2.Length .

Quelles comparaisons affectent l'état ?

Donc c'est une comparaison de text is null ... quel est l'effet de comparaisons similaires ? Voici quatre autres méthodes, qui commencent toutes par un paramètre de type chaîne de caractères non nul :

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
}

Donc, même si x is object est désormais une alternative recommandée à x != null ils n'ont pas le même effet : seule une comparaison est possible. avec null (avec l'un des is , == ou != ) fait passer l'état de "non nul" à "peut-être nul".

Pourquoi l'élévation de la condition a-t-elle un effet ?

Pour en revenir à notre premier point, 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. L'intégration de ce type de logique dans le compilateur et les spécifications représente beaucoup de travail, pour un bénéfice relativement faible. Voici un autre exemple qui n'a rien à voir avec la nullité et où l'inlining 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 sachez que alwaysTrue sera toujours vrai, il ne satisfait pas aux exigences de la spécification qui font que le code après la balise if inaccessible, ce qui est ce dont nous avons besoin.

Voici un autre exemple, autour de 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 savoir que le code entrera exactement une de ces if Les corps de la déclaration, il n'y a rien dans la spécification pour travailler cela. Les outils d'analyse statique peuvent très bien être capables de le faire, mais essayer d'intégrer cela dans la spécification du langage serait une mauvaise idée, IMO - 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 langage.

8 votes

Excellente analyse, Jon. La chose essentielle que j'ai apprise en étudiant le vérificateur de Coverity est que le code est la preuve des convictions de ses auteurs . Lorsque nous voyons une vérification de nullité, cela devrait nous informer que les auteurs du code pensaient que cette vérification était nécessaire. Le vérificateur recherche en fait la preuve que les convictions des auteurs étaient incohérentes parce que c'est là où nous voyons des croyances incohérentes sur, par exemple, la nullité, que les bugs se produisent.

6 votes

Lorsque nous voyons par exemple if (x != null) x.foo(); x.bar(); nous disposons de deux éléments de preuve ; le if est la preuve de la proposition "l'auteur croit que x pourrait être nul avant l'appel à foo" et la déclaration suivante est la preuve de "l'auteur croit que x n'est pas nul avant l'appel à bar", et cette contradiction mène à la conclusion qu'il y a un bogue. Ce bogue est soit le bogue relativement bénin d'une vérification inutile de la nullité, soit le bogue qui risque de faire planter le système. Il n'est pas évident de savoir quel est le véritable bogue, mais il est clair qu'il y en a un.

1 votes

Le problème est que les vérificateurs relativement peu sophistiqués qui ne suivent pas la signification des localisations et qui n'éliminent pas les "faux chemins" - les chemins de flux de contrôle que les humains peuvent vous dire être impossibles - ont tendance à produire des faux positifs précisément parce qu'ils n'ont pas modélisé avec précision les croyances des auteurs. C'est là que le bât blesse !

53voto

Mads Torgersen - MSFT Points 1209

L'analyse du débit nul permet de suivre le état nul de variables, mais il ne suit pas d'autres états, tels que la valeur d'une bool variable (comme isNull ci-dessus), et il ne permet pas de suivre la relation entre l'état de variables distinctes (par ex. isNull et _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 suit, et ces règles pourraient même changer au fil du temps.

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

Au fur et à mesure que nous déployons la fonctionnalité, nous avons l'impression d'avoir trouvé le bon équilibre, mais il y a quelques endroits qui semblent gênants, et nous allons les revoir pour C# 9.0.

5 votes

Vous savez que vous voulez inclure la théorie du treillis dans la spécification ; la théorie du treillis est génial et pas du tout déroutant ! Faites-le ! :)

18 votes

Vous savez que votre question est légitime lorsque le responsable du programme C# vous répond !

0 votes

@EricLippert Puis-je noter votre commentaire plusieurs fois ? Après avoir découvert ce qu'est la théorie des treillis.

31voto

Eric Lippert Points 300275

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

Je n'ai aucune connaissance spécifique de l'implémentation du contrôleur de flux, mais ayant travaillé sur des implémentations de code similaire dans le passé, je peux faire quelques suppositions éclairées. Le contrôleur de flux est probablement déduire deux choses dans le cas des faux positifs : (1) _test pourrait être nulle, car si ce n'était pas le cas, vous n'auriez pas la comparaison en premier lieu, et (2) isNull pourrait être vrai ou faux -- parce que si ce n'était pas le cas, vous ne l'auriez pas dans une if . Mais la connexion que le return _test; ne fonctionne que si _test n'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 qu'il faille un certain temps pour que le compilateur atteigne la sophistication d'outils ayant bénéficié de plusieurs années de travail par des experts. Le contrôleur de flux Coverity, par exemple, n'aurait aucun problème à déduire qu'aucune de vos deux variations n'a un retour nul, mais le contrôleur de flux Coverity coûte très cher aux entreprises.

De plus, les vérificateurs de Coverity sont conçus pour fonctionner sur des bases de code importantes. La nuit, ; l'analyse du compilateur C# doit s'exécuter entre les frappes dans l'éditeur ce qui modifie considérablement le type d'analyses approfondies que vous pouvez raisonnablement effectuer.

0 votes

"Je considère qu'il est pardonnable s'il bute sur des choses comme les conditionnels, car nous savons tous que le problème de la halte est un peu difficile à résoudre dans ce domaine. bool b = x != null vs bool b = x is { } (sans qu'aucune des deux affectations ne soit réellement utilisée !) montre que même les modèles reconnus pour les vérifications nulles sont discutables. Il ne s'agit pas de dénigrer le travail indubitablement difficile de l'équipe pour faire en sorte que cela fonctionne en grande partie comme il se doit pour des bases de code réelles et utilisées - il semble que l'analyse soit d'un pragmatisme majuscule.

0 votes

@JeroenMostert : Jared Par mentionne dans un commentaire sur Réponse de Jon Skeet que Microsoft discute de cette question en interne.

11voto

Andy Gocke Points 111

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

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

L'élément qui n'est pas mentionné est la façon dont nous décidons si une vérification de null doit être considérée comme "pure", dans le sens où si vous la faites, nous devons sérieusement considérer si null est une possibilité. Il y a beaucoup de vérifications de null "accidentelles" en C#, où l'on teste null dans le cadre d'une autre action, et nous avons donc décidé de réduire 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 nous avons décidé d'utiliser le terme "null". x != null et x is object produisent des résultats différents.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X