Types de référence Nullable en C # 8.0 et analyse statique

Image 9


Ce n'est pas un secret que Microsoft travaille depuis un certain temps sur la 8e version du langage C #. La nouvelle version linguistique (C # 8.0) est déjà disponible dans la dernière version de Visual Studio 2019, mais elle est toujours en version bêta. Cette nouvelle version va avoir quelques fonctionnalités implémentées de manière quelque peu non évidente, ou plutôt inattendue. Les types de référence Nullable en font partie. Cette fonctionnalité est annoncée comme un moyen de lutter contre les exceptions de référence nulles (NRE).

Il est bon de voir le langage évoluer et d'acquérir de nouvelles fonctionnalités pour aider les développeurs. Par coïncidence, il y a quelque temps, nous avons considérablement amélioré la capacité de l'analyseur C # de PVS-Studio à détecter les NRE. Et maintenant, nous nous demandons si les analyseurs statiques en général et PVS-Studio en particulier devraient encore se soucier de diagnostiquer les déréférences nulles potentielles car, au moins dans le nouveau code qui utilisera Nullable Reference, de telles déréférences deviendront "impossibles"? Essayons de clarifier cela.

Avantages et inconvénients de la nouvelle fonctionnalité


Un rappel avant de continuer: la dernière version bêta de C # 8.0, disponible au moment de la rédaction de cet article, a les types de référence Nullable désactivés par défaut, c'est-à-dire que le comportement des types de référence n'a pas changé.

Alors, quels sont exactement les types de référence nullables en C # 8.0 si nous activons cette option? Ce sont fondamentalement les mêmes bons anciens types de référence, sauf que vous devrez maintenant ajouter "?" après le nom du type (par exemple, chaîne? ), de manière similaire à Nullable <T> , c'est-à-dire les types de valeur nullable (par exemple, int? ). Sans le «?», Notre type de chaîne sera désormais interprété comme une référence non nullable, c'est-à-dire un type de référence qui ne peut pas être attribué null .

L'exception de référence nulle est l'une des exceptions les plus dérangeantes à entrer dans votre programme car elle ne dit pas grand-chose sur sa source, surtout si la méthode de lancement contient un certain nombre d'opérations de déréférencement consécutives. La possibilité d'interdire l'affectation null à une variable d'un type de référence semble cool, mais qu'en est-il des cas où le passage d'un null à une méthode a une logique d'exécution en fonction? Au lieu de null , nous pourrions, bien sûr, utiliser un littéral, une constante ou simplement une valeur "impossible" qui ne peut logiquement être affectée à la variable nulle part ailleurs. Mais cela pose le risque de remplacer un plantage du programme par une exécution "silencieuse" mais incorrecte, ce qui est souvent pire que de faire face à l'erreur tout de suite.

Qu'en est-il alors de lever une exception? Une exception significative levée dans un endroit où quelque chose s'est mal passé est toujours meilleure qu'un NRE quelque part en haut ou en bas de la pile. Mais c'est seulement bon dans votre propre projet, où vous pouvez corriger les consommateurs en insérant un bloc try-catch et c'est uniquement votre responsabilité. Lors du développement d'une bibliothèque utilisant une référence (non) nulle, nous devons garantir qu'une certaine méthode retourne toujours une valeur. Après tout, il n'est pas toujours possible (ou du moins facile) même dans votre propre code de remplacer le retour de null par le lancement d'exceptions (car cela peut affecter trop de code).

La référence Nullable peut être activée soit au niveau du projet global en ajoutant la propriété NullableContextOptions avec la valeur enable, soit au niveau du fichier au moyen de la directive du préprocesseur:

#nullable enable string cantBeNull = string.Empty; string? canBeNull = null; cantBeNull = canBeNull!; 

La fonction de référence nullable rendra les types plus informatifs. La signature de la méthode vous donne un indice sur son comportement: si elle a une vérification nulle ou non, si elle peut retourner null ou non. Maintenant, lorsque vous essayez d'utiliser une variable de référence nullable sans la vérifier, le compilateur émet un avertissement.

C'est assez pratique lorsque vous utilisez des bibliothèques tierces, mais cela ajoute également un risque d'induire en erreur l'utilisateur de la bibliothèque, car il est toujours possible de passer null en utilisant le nouvel opérateur de tolérance de null (!). C'est-à-dire, l'ajout d'un seul point d'exclamation peut briser toutes les autres hypothèses sur l'interface à l'aide de ces variables:

 #nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; 

Oui, vous pouvez affirmer qu'il s'agit d'une mauvaise programmation et que personne n'écrirait du code comme ça pour de vrai, mais tant que cela peut potentiellement être fait, vous ne pouvez pas vous sentir en sécurité en vous basant uniquement sur le contrat imposé par l'interface d'une méthode donnée ( en disant qu'il ne peut pas retourner null ).

Au fait, vous pourriez écrire le même code en utilisant plusieurs ! , comme C # vous permet désormais de le faire (et ce code est parfaitement compilable):

 cantBeNull = canBeNull!!!!!!!; 

En écrivant de cette façon, nous insistons pour ainsi dire sur l'idée «regardez, cela peut être nul !!!» (nous dans notre équipe, nous appelons cela une programmation "émotionnelle"). En fait, lors de la construction de l'arbre de syntaxe, le compilateur (de Roslyn) interprète le ! de la même manière qu'il interprète les parenthèses régulières, ce qui signifie que vous pouvez en écrire autant ! comme vous le souhaitez - comme avec les parenthèses. Mais si vous en écrivez suffisamment, vous pouvez "renverser" le compilateur. Peut-être que cela sera corrigé dans la version finale de C # 8.0.

De même, vous pouvez contourner l'avertissement du compilateur lors de l'accès à une variable de référence nullable sans vérification:

 canBeNull!.ToString(); 

Ajoutons plus d'émotions:

 canBeNull!!!?.ToString(); 

Cependant, vous ne verrez presque jamais de syntaxe comme celle-ci dans du vrai code. En écrivant l'opérateur pardonnant les valeurs nulles , nous disons au compilateur: "Ce code est correct, vérification non nécessaire." En ajoutant l'opérateur Elvis, nous lui disons: «Ou peut-être pas; vérifions-le juste au cas où. "

Maintenant, vous pouvez raisonnablement vous demander pourquoi vous pouvez toujours affecter null à des variables de types de référence non nullables si le concept même de ces types implique que ces variables ne peuvent pas avoir la valeur null ? La réponse est que "sous le capot", au niveau du code IL, notre type de référence non nullable est toujours ... le bon vieux type de référence "régulier", et la syntaxe de nullabilité entière n'est en fait qu'une annotation pour le compilateur intégré analyseur (qui, selon nous, n'est pas très pratique à utiliser, mais je développerai cela plus tard). Personnellement, nous ne trouvons pas que ce soit une solution "soignée" d'inclure la nouvelle syntaxe simplement comme une annotation pour un outil tiers (même intégré au compilateur) car le fait qu'il ne s'agisse que d'une annotation ne soit pas du tout évident. au programmeur, car cette syntaxe est très similaire à la syntaxe des structures nullables mais fonctionne d'une manière totalement différente.

Revenir à d'autres façons de casser les types de référence Nullable. Au moment de la rédaction de cet article, lorsque vous avez une solution composée de plusieurs projets, en passant une variable d'un type de référence, par exemple, String d'une méthode déclarée dans un projet à une méthode dans un autre projet qui a le NullableContext le compilateur suppose qu'il s'agit d'une chaîne non nullable et le compilateur restera silencieux. Et cela malgré les tonnes d' attributs [Nullable (1)] ajoutés à chaque champ et méthode dans le code IL lors de l'activation des références Nullable . Soit dit en passant, ces attributs doivent être pris en compte si vous utilisez la réflexion pour gérer les attributs et supposez que le code contient uniquement vos attributs personnalisés.

Une telle situation peut entraîner des problèmes supplémentaires lors de l'adaptation d'une grande base de code au style de référence nullable. Ce processus durera probablement un certain temps, projet par projet. Si vous faites attention, bien sûr, vous pouvez progressivement intégrer la nouvelle fonctionnalité, mais si vous avez déjà un projet fonctionnel, toute modification est dangereuse et indésirable (si cela fonctionne, ne le touchez pas!). C'est pourquoi nous nous sommes assurés que vous n'avez pas à modifier votre code source ou à le marquer pour détecter les NRE potentiels lors de l'utilisation de l'analyseur PVS-Studio. Pour vérifier les emplacements susceptibles de déclencher une exception NullReferenceException, exécutez simplement l'analyseur et recherchez les avertissements V3080. Pas besoin de changer les propriétés du projet ou le code source. Pas besoin d'ajouter des directives, des attributs ou des opérateurs. Pas besoin de changer le code hérité.

Lors de l'ajout de la prise en charge des références nulles à PVS-Studio, nous avons dû décider si l'analyseur devait supposer que les variables des types de référence non nullables ont toujours des valeurs non nulles. Après avoir étudié les moyens de rompre cette garantie, nous avons décidé que PVS-Studio ne devrait pas faire une telle hypothèse. Après tout, même si un projet utilise des types de référence non nullables tout au long, l'analyseur pourrait ajouter à cette fonctionnalité en détectant les situations spécifiques où de telles variables pourraient avoir la valeur null .

Comment PVS-Studio recherche les exceptions de référence nulles


Les mécanismes de flux de données dans l'analyseur C # de PVS-Studio suivent les valeurs possibles des variables pendant le processus d'analyse. Cela inclut également l'analyse interprocédurale, c'est-à-dire le suivi des valeurs possibles renvoyées par une méthode et ses méthodes imbriquées, etc. En plus de cela, PVS-Studio se souvient des variables auxquelles une valeur nulle pourrait être affectée. Chaque fois qu'il voit une telle variable être déréférencée sans vérification, que ce soit dans le code en cours d'analyse ou à l'intérieur d'une méthode invoquée dans ce code, il émettra un avertissement V3080 concernant une éventuelle exception de référence nulle.

L'idée derrière ce diagnostic est que l'analyseur ne se fâche que lorsqu'il voit une affectation nulle . C'est la principale différence entre le comportement de notre diagnostic et celui de l'analyseur intégré du compilateur gérant les types de référence Nullable. L'analyseur intégré pointera vers chaque déréférence d'une variable de référence nullable non vérifiée - étant donné qu'elle n'a pas été induite en erreur par l'utilisation du ! ou même simplement une vérification compliquée (il convient de noter, cependant, que tout analyseur statique, PVS-Studio ne faisant pas exception ici, peut être "induit en erreur" d'une manière ou d'une autre, surtout si vous avez l'intention de le faire).

PVS-Studio, en revanche, ne vous avertit que s'il voit une valeur nulle (que ce soit dans le contexte local ou dans le contexte d'une méthode externe). Même si la variable est d'un type de référence non nul , l'analyseur continuera de pointer dessus s'il voit une affectation nulle à cette variable. Cette approche, nous pensons, est plus appropriée (ou du moins plus pratique pour l'utilisateur) car elle ne nécessite pas de "barbouiller" le code entier avec des vérifications nulles pour suivre les déréférences potentielles - après tout, cette option était disponible avant même la référence Nullable ont été introduites, par exemple, par le biais de contrats. De plus, l'analyseur peut désormais fournir un meilleur contrôle sur les variables de référence non nulles elles-mêmes. Si une telle variable est utilisée "équitablement" et ne reçoit jamais de valeur nulle , PVS-Studio ne dira pas un mot. Si la variable est affectée de null puis annulée sans vérification préalable, PVS-Studio émet un avertissement V3080:

 #nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; <== V3080: Possible null dereference. Consider inspecting 'str' 

Voyons maintenant quelques exemples montrant comment ce diagnostic est déclenché par le code de Roslyn lui-même. Nous avons déjà vérifié ce projet récemment, mais cette fois, nous ne nous pencherons que sur les exceptions de référence Null potentielles non mentionnées dans les articles précédents. Nous verrons comment PVS-Studio détecte les NRE potentiels et comment ils peuvent être corrigés à l'aide de la nouvelle syntaxe Nullable Reference.

V3080 [CWE-476] Déréférence nulle possible à l'intérieur de la méthode. Pensez à inspecter le deuxième argument: chainedTupleType. Microsoft.CodeAnalysis.CSharp TupleTypeSymbol.cs 244

 NamedTypeSymbol chainedTupleType; if (_underlyingType.Arity < TupleTypeSymbol.RestPosition) { .... chainedTupleType = null; } else { .... } return Create(ConstructTupleUnderlyingType(firstTupleType, chainedTupleType, newElementTypes), elementNames: _elementNames); 

Comme vous pouvez le voir, la variable chainedTupleType peut se voir attribuer la valeur nulle dans l'une des branches d'exécution. Il est ensuite transmis à la méthode ConstructTupleUnderlyingType et y est utilisé après une vérification Debug.Assert . C'est un modèle très courant dans Roslyn, mais gardez à l'esprit que Debug.Assert est supprimé dans la version finale. C'est pourquoi l'analyseur considère toujours la déréférence à l'intérieur de la méthode ConstructTupleUnderlyingType comme dangereuse. Voici le corps de cette méthode, où la déréférence a lieu:

 internal static NamedTypeSymbol ConstructTupleUnderlyingType( NamedTypeSymbol firstTupleType, NamedTypeSymbol chainedTupleTypeOpt, ImmutableArray<TypeWithAnnotations> elementTypes) { Debug.Assert (chainedTupleTypeOpt is null == elementTypes.Length < RestPosition); .... while (loop > 0) { .... currentSymbol = chainedTupleTypeOpt.Construct(chainedTypes); loop--; } return currentSymbol; } 

Il est en fait controversé de savoir si l'analyseur doit prendre en compte les assertions de ce type (certains de nos utilisateurs le souhaitent) - après tout, l'analyseur prend en compte les contrats de System.Diagnostics.Contracts. Voici un petit exemple concret de notre expérience d'utilisation de Roslyn dans notre propre analyseur. Tout en ajoutant récemment la prise en charge de la dernière version de Visual Studio , nous avons également mis à jour Roslyn vers sa 3e version. Après cela, PVS-Studio a commencé à planter sur un certain code sur lequel il ne s'était jamais crashé auparavant. Le plantage, accompagné d'une exception de référence nulle, ne se produirait pas dans notre code mais dans le code de Roslyn. Le débogage a révélé que le fragment de code où Roslyn plantait maintenant avait ce même type de vérification nulle basée sur Debug.Assert plusieurs lignes plus haut - et cette vérification n'a évidemment pas aidé.

C'est un exemple graphique de la façon dont vous pouvez rencontrer des problèmes avec Nullable Reference car le compilateur traite Debug.Assert comme une vérification fiable dans n'importe quelle configuration. Autrement dit, si vous ajoutez #nullable enable et marquez l'argument chainedTupleTypeOpt comme référence nullable , le compilateur n'émettra aucun avertissement sur la déréférence à l'intérieur de la méthode ConstructTupleUnderlyingType .

Passons à d'autres exemples d'avertissements de PVS-Studio.

V3080 Déréférence nulle possible. Pensez à inspecter «effectiveRuleset». RuleSet.cs 146

 var effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, ....)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption; 

Cet avertissement indique que l'appel de la méthode WithEffectiveAction peut retourner null , tandis que la valeur de retour affectée à la variable effectiveRuleset n'est pas vérifiée avant utilisation ( effectiveRuleset.GeneralDiagnosticOption ). Voici le corps de la méthode WithEffectiveAction :

 public RuleSet WithEffectiveAction(ReportDiagnostic action) { if (!_includes.IsEmpty) throw new ArgumentException(....); switch (action) { case ReportDiagnostic.Default: return this; case ReportDiagnostic.Suppress: return null; .... return new RuleSet(....); default: return null; } } 

Avec Nullable Reference activé pour la méthode GetEffectiveRuleSet , nous aurons deux emplacements où le comportement du code doit être modifié. Étant donné que la méthode ci-dessus peut lever une exception, il est logique de supposer que l'appel à elle est encapsulé dans un bloc try-catch et il serait correct de réécrire la méthode pour lever une exception plutôt que de retourner null . Cependant, si vous retracez quelques rappels, vous verrez que le code de capture est trop loin pour prévoir de manière fiable les conséquences. Jetons un œil au consommateur de la variable effectiveRuleset , la méthode IsStricterThan :

 private static bool IsStricterThan(ReportDiagnostic action1, ReportDiagnostic action2) { switch (action2) { case ReportDiagnostic.Suppress: ....; case ReportDiagnostic.Warn: return action1 == ReportDiagnostic.Error; case ReportDiagnostic.Error: return false; default: return false; } } 

Comme vous pouvez le voir, il s'agit d'une simple instruction de commutateur choisissant entre deux énumérations, avec ReportDiagnostic.Default comme valeur par défaut. Il serait donc préférable de réécrire l'appel comme suit:

La signature de WithEffectiveAction changera:

 #nullable enable public RuleSet? WithEffectiveAction(ReportDiagnostic action) 

Voici à quoi ressemblera l'appel:

 RuleSet? effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset?.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset?.GeneralDiagnosticOption ?? ReportDiagnostic.Default, effectiveGeneralOption)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption; 

Comme IsStricterThan effectue uniquement la comparaison, la condition peut être réécrite - par exemple, comme ceci:

 if (effectiveRuleset == null || IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, effectiveGeneralOption)) 

Exemple suivant.

V3080 Déréférence nulle possible. Pensez à inspecter «propertySymbol». BinderFactory.BinderFactoryVisitor.cs 372

 var propertySymbol = GetPropertySymbol(parent, resultBinder); var accessor = propertySymbol.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder); 

Pour corriger cet avertissement, nous devons voir ce qui arrive à la variable propertySymbol ensuite.

 private SourcePropertySymbol GetPropertySymbol( BasePropertyDeclarationSyntax basePropertyDeclarationSyntax, Binder outerBinder) { .... NamedTypeSymbol container = GetContainerType(outerBinder, basePropertyDeclarationSyntax); if ((object)container == null) return null; .... return (SourcePropertySymbol)GetMemberSymbol(propertyName, basePropertyDeclarationSyntax.Span, container, SymbolKind.Property); } 

La méthode GetMemberSymbol peut également renvoyer null dans certaines conditions.

 private Symbol GetMemberSymbol( string memberName, TextSpan memberSpan, NamedTypeSymbol container, SymbolKind kind) { foreach (Symbol sym in container.GetMembers(memberName)) { if (sym.Kind != kind) continue; if (sym.Kind == SymbolKind.Method) { .... var implementation = ((MethodSymbol)sym).PartialImplementationPart; if ((object)implementation != null) if (InSpan(implementation.Locations[0], this.syntaxTree, memberSpan)) return implementation; } else if (InSpan(sym.Locations, this.syntaxTree, memberSpan)) return sym; } return null; } 

Lorsque les types de référence nullables sont activés, l'appel change comme suit:

 #nullable enable SourcePropertySymbol? propertySymbol = GetPropertySymbol(parent, resultBinder); MethodSymbol? accessor = propertySymbol?.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder); 

C'est assez facile à réparer quand vous savez où chercher. L'analyse statique peut détecter cette erreur potentielle sans effort en collectant toutes les valeurs possibles du champ à partir de toutes les chaînes d'appel de procédure.

V3080 Déréférence nulle possible. Pensez à inspecter «simpleName». CSharpCommandLineParser.cs 1556

 string simpleName; simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)); outputFileName = simpleName + outputKind.GetDefaultExtension(); if (simpleName.Length == 0 && !outputKind.IsNetModule()) .... 

Le problème vient de la vérification simpleName.Length . La variable simpleName résulte de l'exécution d'une longue série de méthodes et peut être affectée null . Par ailleurs, si vous êtes curieux, vous pouvez regarder la méthode RemoveExtension pour voir en quoi elle est différente de Path.GetFileNameWithoutExtension. Un simpleName! = Une vérification nulle serait suffisant, mais avec des types de référence non nullables, le code changera en quelque chose comme ceci:

 #nullable enable public static string? RemoveExtension(string path) { .... } string simpleName; 

Voici à quoi pourrait ressembler l'appel:

 simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? String.Empty; 

Conclusion


Les types de référence Nullable peuvent être d'une grande aide lors de la conception d'une architecture à partir de zéro, mais retravailler le code existant peut nécessiter beaucoup de temps et d'attention, car cela peut conduire à un certain nombre de bugs insaisissables. Cet article n'a pas pour but de vous décourager d'utiliser des types de référence Nullable. Nous trouvons cette nouvelle fonctionnalité généralement utile même si la manière exacte dont elle est mise en œuvre peut être controversée.

Cependant, souvenez-vous toujours des limites de cette approche et gardez à l'esprit que l'activation du mode Nullable Reference ne vous protège pas contre les NRE et que, lorsqu'il est mal utilisé, il peut lui-même devenir la source de ces erreurs. Nous vous recommandons de compléter la fonction Nullable Reference avec un outil d'analyse statique moderne, tel que PVS-Studio, qui prend en charge l'analyse interprocédurale pour protéger votre programme contre les NRE. Chacune de ces approches - analyse interprocédurale approfondie et signature de méthodes d'annotation (ce qui est en fait ce que fait le mode Nullable Reference) - a ses avantages et ses inconvénients. L'analyseur vous fournira une liste des emplacements potentiellement dangereux et vous permettra de voir les conséquences de la modification du code existant. S'il y a une affectation nulle quelque part, l'analyseur pointe vers chaque consommateur de la variable où elle est déréférencée sans vérification.

Vous pouvez vérifier ce projet ou vos propres projets pour d'autres défauts - téléchargez simplement PVS-Studio et essayez-le.

Source: https://habr.com/ru/post/fr455234/


All Articles