Types de référence nullables dans C # 8.0 et analyse statique

Image 9


Ce n'est un secret pour personne que Microsoft travaille depuis un certain temps à la sortie de la huitième version de C #. Dans la récente version de Visual Studio 2019, une nouvelle version du langage (C # 8.0) est déjà disponible, mais jusqu'à présent uniquement en version bêta. Les plans de cette nouvelle version ont plusieurs fonctionnalités, dont la mise en œuvre peut ne pas sembler assez évidente, ou plutôt, pas tout à fait attendue. L'une de ces innovations est la possibilité d'utiliser des types de référence Nullable. La signification déclarée de cette innovation est la lutte contre les exceptions de référence nulles (NRE).

Nous sommes ravis que le langage se développe et que de nouvelles fonctionnalités devraient aider les développeurs. Par coïncidence, dans notre analyseur PVS-Studio pour C #, les capacités de détecter exactement les mêmes NRE dans le code se sont relativement récemment étendues. Et nous nous sommes demandé - est-ce que les analyseurs statiques en général, et pour PVS-Studio en particulier, ont un sens pour essayer de rechercher un déréférencement potentiel de références nulles, si, au moins dans le nouveau code utilisant la référence Nullable, un tel déréférencement deviendra "impossible" ? Essayons de répondre à cette question.

Avantages et inconvénients de l'innovation


Pour commencer, il convient de rappeler que dans la dernière version bêta de C # 8.0, disponible au moment d'écrire ces lignes, Nullable Reference est désactivée par défaut, c'est-à-dire le comportement des types de référence ne changera pas.

Quels sont les types de référence annulables dans C # 8.0 si vous les incluez? Il s'agit du même bon ancien type de référence, à la différence près que les variables de ce type doivent maintenant être marquées avec '?' (par exemple chaîne? ), similaire à la façon dont cela est déjà fait pour Nullable <T> , c'est-à-dire types significatifs nullables (par exemple int? ). Cependant, maintenant la même chaîne sans '?' commence déjà à être interprété comme une référence non nulle, c'est-à-dire il s'agit d'un type de référence dont la variable ne peut pas contenir de valeurs nulles .

L'exception de référence nulle est l'une des exceptions les plus gênantes car elle en dit peu sur la source du problème, surtout s'il y a plusieurs déréférences consécutives dans la méthode qui lève l'exception. La possibilité d'interdire la transmission de null à une variable de référence de type semble correcte, mais si une valeur null antérieure a été transmise à la méthode et qu'une logique d'exécution supplémentaire y était liée, alors que dois-je faire maintenant? Bien sûr, vous pouvez passer un littéral, une constante ou simplement une valeur «impossible» au lieu de null , qui, selon la logique du programme, ne peut être assigné à cette variable nulle part ailleurs. Cependant, la chute de l'ensemble du programme peut être remplacée par une nouvelle exécution incorrecte «silencieuse». Ce ne sera pas toujours mieux que de voir l'erreur tout de suite.

Et si au lieu de cela, jetez une exception? Une exception significative dans un endroit où quelque chose s'est mal passé est toujours meilleure qu'un NRE quelque part plus haut ou plus bas sur la pile. Mais c'est bien si nous parlons de notre propre projet, où nous pouvons réparer les consommateurs et insérer un bloc try-catch, et lorsque nous développons une bibliothèque en utilisant la référence (non) nulle, nous prenons la responsabilité qu'une méthode retourne toujours une valeur. Et ce n'est pas toujours même dans le code natif qu'il sera (au moins simple) de substituer le retour de null pour lever une exception (trop de code peut être affecté).

Vous pouvez activer Nullable Reference au niveau du projet entier en ajoutant la propriété NullableContextOptions avec la valeur enable , ou au niveau du fichier à l'aide de la directive de préprocesseur:
#nullable enable string cantBeNull = string.Empty; string? canBeNull = null; cantBeNull = canBeNull!; 

Les types seront désormais plus visuels. Par la signature de la méthode, il est possible de déterminer son comportement, qu'il contienne ou non une vérification de null , il peut retourner null ou pas. Maintenant, si vous essayez d'accéder à une variable de référence nullable sans vérification, le compilateur générera un avertissement.

Assez pratique lors de l'utilisation de bibliothèques tierces, mais il y a une situation avec une possible désinformation. Le fait est que le passage de null est toujours possible, par exemple, en utilisant le nouvel opérateur de tolérance de null (!). C'est-à-dire c'est juste qu'à l'aide d'un seul point d'exclamation, vous pouvez casser toutes les autres hypothèses qui seront faites sur une interface en utilisant ces variables:
 #nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; 

Oui, on peut dire qu'il est faux d'écrire de cette façon, et personne ne le fera jamais, mais tant que cette opportunité restera, il ne sera plus possible de s'appuyer entièrement uniquement sur le contrat imposé par l'interface de cette méthode (qu'elle ne peut pas retourner nulle).

Et vous pouvez, en passant, écrire la même chose à l'aide de plusieurs opérateurs !, Parce que C # vous permet maintenant d'écrire comme ça (et ce code est complètement compilé):
 cantBeNull = canBeNull!!!!!!!; 

C'est-à-dire nous tenons à souligner davantage: faites attention - cela peut être nul !!! (nous, dans l'équipe, appelons cela une programmation «émotionnelle»). En fait, le compilateur (de Roslyn), lors de la construction d'un arbre de syntaxe de code, interprète l'opérateur! similaire aux crochets simples, leur nombre, comme c'est le cas avec les crochets, est donc illimité. Bien que, si vous en écrivez beaucoup, le compilateur puisse être "vidé". Peut-être que cela changera dans la version finale de C # 8.0.

De la même manière, vous pouvez contourner l'avertissement du compilateur lors de l'accès à une variable de référence nullable sans vérifier:
 canBeNull!.ToString(); 

Vous pouvez écrire plus émotionnellement:
 canBeNull!!!?.ToString(); 

Cette syntaxe est en fait difficile à imaginer dans un vrai projet, mettant un opérateur pardonnant le zéro que nous disons au compilateur: tout va bien ici, aucune vérification n'est nécessaire. En ajoutant un opérateur elvis on dit: mais en général ce n'est peut-être pas normal, vérifions.

Et maintenant une question légitime se pose - pourquoi, si le concept d'un type de référence non nullable implique qu'une variable de ce type ne peut pas contenir null , peut-on encore l'écrire si facilement là-bas? Le fait est que «sous le capot», au niveau du code IL, notre type de référence non nul peut rester… tout de même le type de référence «ordinaire». Et toute la syntaxe de nullité n'est en fait qu'une annotation pour l'analyseur statique intégré au compilateur (et, à notre avis, pas l'analyseur le plus pratique, mais plus à ce sujet plus tard). À notre avis, inclure la nouvelle syntaxe dans le langage uniquement comme annotation pour un outil tiers (même s'il est intégré au compilateur) n'est pas la plus «belle» solution, car pour un programmeur utilisant ce langage, ce n'est qu'une annotation peut ne pas être évident du tout - après tout, une syntaxe très similaire pour les structures nullables fonctionne d'une manière complètement différente.

Revenons à la façon dont il est toujours possible de "casser" les types de référence Nullable. Au moment de l'écriture, s'il y a plusieurs projets dans la solution, lors du passage d'une méthode déclarée dans un projet une variable de référence, par exemple de type String, à une méthode d'un autre projet où NullableContextOptions est activé , le compilateur décidera qu'il s'agit déjà d'une chaîne non nullable, et ne donnera pas d'avertissement. Et cela malgré le grand nombre d' attributs [Nullable (1)] ajoutés à chaque champ et méthode de classe dans le code IL lorsque les références Nullable sont activées . Soit dit en passant, ces attributs doivent être pris en compte si vous travaillez avec une liste d'attributs par réflexion, en comptant uniquement sur les attributs que vous avez ajoutés vous-même.

Cette situation peut créer des problèmes supplémentaires lors de la conversion d'une grande base de code en une référence nullable. Ce processus sera très probablement progressif, projet par projet. Bien sûr, avec une approche compétente du changement, vous pouvez progressivement passer à une nouvelle fonctionnalité, mais si vous avez déjà un projet de travail, tout changement est dangereux et indésirable (cela fonctionne - ne le touchez pas!). C'est pourquoi lors de l'utilisation de l'analyseur PVS-Studio, il n'est pas nécessaire de modifier le code source ou de le marquer d'une manière ou d'une autre pour détecter les NRE potentiels. Pour vérifier les endroits où une exception NullReferenceException peut se produire , il vous suffit de démarrer l'analyseur et de consulter les avertissements du V3080. Pas besoin de modifier 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 votre code.

Avec la prise en charge des types de référence Nullable dans l'analyseur PVS-Studio, nous avons fait face à un choix - l'analyseur doit-il interpréter les variables de référence non nulles comme des valeurs toujours non nulles? Après avoir étudié la question des possibilités de «briser» cette garantie, nous sommes arrivés à la conclusion qu'il n'y en avait pas - l'analyseur ne devrait pas faire une telle hypothèse. En effet, même si des types de référence non nullables sont utilisés partout dans le projet, l'analyseur peut compléter leur utilisation en découvrant simplement les situations dans lesquelles une valeur nulle peut apparaître dans une telle variable.

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


Les mécanismes de flux de données dans l'analyseur C # PVS-Studio surveillent les valeurs possibles des variables pendant l'analyse. En particulier, PVS-Studio effectue également une analyse interprocédurale, c'est-à-dire Il essaie de déterminer la valeur possible retournée par la méthode, ainsi que les méthodes appelées dans cette méthode, etc. Entre autres choses, l'analyseur se souvient des variables qui peuvent potentiellement être nulles . Si à l'avenir l'analyseur voit un déréférencement sans vérifier une telle variable, encore une fois, soit dans le code actuel en cours de vérification, soit dans la méthode appelée dans ce code, un avertissement V3080 concernant une éventuelle exception de référence nulle sera émis.

Dans le même temps, l'idée principale sous-jacente à ce diagnostic est que l'analyseur ne jure que s'il a vu quelque part l'affectation de null à une variable. Il s'agit de la principale différence entre le comportement de ce diagnostic et l'analyseur intégré au compilateur qui fonctionne avec les types Nullable Reference. L'analyseur intégré au compilateur ne jurera que par toute référence à une variable de référence nullable non vérifiée du type, à moins, bien sûr, que cet analyseur ne soit «trompé» par l'opérateur! de toute autre manière, absolument n'importe quel analyseur peut être utilisé, surtout si vous vous fixez un tel objectif, et PVS-Studio ne fait pas exception).

PVS-Studio ne jure que s'il voit null (dans un contexte local, ou provenant d'une méthode). Dans le même temps, même si la variable est une variable de référence non nulle, le comportement de l'analyseur ne changera pas - il jurera toujours s'il voit que null lui a été écrit. Cette approche nous semble plus correcte (ou, du moins, pratique pour l'utilisateur de l'analyseur), car cela ne nécessite pas de «couvrir» tout le code avec des vérifications nulles pour trouver des déréférences potentielles - cela aurait pu être fait auparavant, sans référence nulle, par exemple, avec les mêmes contrats. De plus, l'analyseur peut désormais être utilisé pour un contrôle supplémentaire sur les mêmes variables de référence non nulles. S'ils sont utilisés "honnêtement", et qu'ils ne sont jamais affectés à zéro - l'analyseur restera silencieux. Si null est attribué et que la variable est déréférencée sans vérification, l'analyseur en avertit avec le message V3080:
 #nullable enable String GetStr() { return _count > 0 ? _str : null!; } String str = GetStr(); var len = str.Length; <== V3080: Possible null dereference. Consider inspecting 'str' 


Considérons quelques exemples d'un tel déclenchement de diagnostics V3080 dans le code de Roslyn lui-même. Nous avons vérifié ce projet il n'y a pas si longtemps, mais cette fois-ci, nous ne considérerons que les déclencheurs d'exception de référence Null potentiels qui n'étaient pas dans les articles précédents. Voyons comment l'analyseur PVS-Studio peut trouver un déréférencement potentiel de références nulles et comment ces emplacements peuvent être corrigés à l'aide de la nouvelle syntaxe de référence nullable.

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 être nulle dans l'une des branches d'exécution de code. Ensuite, chainedTupleType est transmis à l'intérieur de la méthode ConstructTupleUnderlyingType et y est utilisé avec vérification via Debug.Assert . Cette situation est très courante à Roslyn, cependant, il convient de se rappeler que Debug.Assert est supprimé dans la version finale de l'assembly. Par conséquent, l'analyseur considère toujours que le déréférencement à l'intérieur de la méthode ConstructTupleUnderlyingType est dangereux. Ensuite, nous donnons le corps de cette méthode, où le déréférencement se produit:
 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; } 

La question de savoir si l'analyseur doit prendre en compte une telle assertion est en réalité un point discutable (certains de nos utilisateurs le souhaitent), car les contrats de System.Diagnostics.Contracts, par exemple, l'analyseur prend désormais en compte. Je vais vous donner seulement un petit exemple de notre utilisation réelle du même Roslyn dans notre analyseur. Récemment, nous avons pris en charge la nouvelle version de Visual Studio et en même temps mis à jour l'analyseur Roslyn vers la version 3. Après cela, l'analyseur a commencé à tomber lors de la vérification d'un certain code sur lequel il ne s'était pas écrasé auparavant. En même temps, l'analyseur a commencé à tomber non pas dans notre code, mais dans le code de Roslyn lui-même - pour tomber avec une exception de référence nulle. Et un débogage supplémentaire a montré qu'à l'endroit où Roslyn tombe maintenant, exactement deux lignes au-dessus, il y a le même contrôle nul via Debug.Assert . Et elle, comme on le voit, n'a pas sauvé.

Ceci est un très bon exemple de problèmes avec la référence Nullable , car le compilateur considère Debug.Assert comme une vérification valide dans n'importe quelle configuration. Autrement dit, si vous activez simplement #nullable enable et marquez l'argument chainedTupleTypeOpt comme référence nullable , il n'y aura aucun avertissement du compilateur à l'emplacement de déréférencement dans la méthode ConstructTupleUnderlyingType .

Prenons l'exemple de déclenchement PVS-Studio suivant.

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 note que l'appel de la méthode WithEffectiveAction peut retourner null , mais le résultat est utilisé sans vérification ( effectiveRuleset.GeneralDiagnosticOption ). Le corps de la méthode WithEffectiveAction , qui peut retourner null, est écrit dans la variable effectiveRuleset :
 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; } } 


Si vous activez le mode Nullable Reference pour la méthode GetEffectiveRuleSet , nous aurons deux emplacements dans lesquels nous devons changer le comportement. Puisqu'il y a une levée d'exception dans la méthode ci-dessus, il est logique de supposer que l'appel de méthode est encapsulé dans un bloc try-catch et il réécrira correctement la méthode, lançant une exception au lieu de retourner null. Mais en montant les défis, on voit que l'interception est élevée et les conséquences peuvent être assez imprévisibles. Regardons la variable consumer effectiveRuleset - 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'un simple commutateur pour deux énumérations avec une valeur d'énumération possible de ReportDiagnostic.Default . Il est donc préférable de réécrire l'appel comme suit:

La signature WithEffectiveAction changera:
 #nullable enable public RuleSet? WithEffectiveAction(ReportDiagnostic action) 

l'appel ressemblera à ceci:
 RuleSet? effectiveRuleset = ruleSet.GetEffectiveRuleSet(includedRulesetPaths); effectiveRuleset = effectiveRuleset?.WithEffectiveAction(ruleSetInclude.Action); if (IsStricterThan(effectiveRuleset?.GeneralDiagnosticOption ?? ReportDiagnostic.Default, effectiveGeneralOption)) effectiveGeneralOption = effectiveRuleset.GeneralDiagnosticOption; 

sachant que IsStricterThan effectue uniquement une comparaison - la condition peut être réécrite, par exemple comme ceci:
 if (effectiveRuleset == null || IsStricterThan(effectiveRuleset.GeneralDiagnosticOption, effectiveGeneralOption)) 

Passons au message suivant de l'analyseur.

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

L'utilisation ultérieure de la variable propertySymbol doit être prise en compte lors de la correction de l'avertissement de l'analyseur.
 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 retourner null dans certains cas.
 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; } 

En utilisant un type de référence nullable, l'appel changera comme ceci:
 #nullable enable SourcePropertySymbol? propertySymbol = GetPropertySymbol(parent, resultBinder); MethodSymbol? accessor = propertySymbol?.GetMethod; if ((object)accessor != null) resultBinder = new InMethodBinder(accessor, resultBinder); 

Assez simple quand vous savez où le réparer. L'analyse statique trouve facilement cette erreur potentielle en obtenant toutes les valeurs de champ possibles sur toutes les chaînes d'appels 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 de simpleName.Length. simpleName est le résultat de toute une chaîne de méthodes et peut être nul . Par ailleurs, par curiosité, vous pouvez regarder la méthode RemoveExtension et trouver des différences avec Path.GetFileNameWithoutExtension. Ici, nous pourrions nous limiter à vérifier simpleName! = Null , mais dans le contexte des liens non nuls, le code ressemblera à ceci:
 #nullable enable public static string? RemoveExtension(string path) { .... } string simpleName; 

L'appel ressemblera à ceci:
 simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? String.Empty; 

Conclusion


Les types de référence Nullable peuvent être d'une grande aide dans la planification d'une architecture construite à partir de zéro, mais retravailler le code existant peut potentiellement nécessiter beaucoup de temps et de soin, car il peut provoquer de nombreuses erreurs subtiles. Dans cet article, nous n'avons pas cherché à décourager quiconque d'utiliser les types de référence Nullable dans nos projets. Nous pensons que cette innovation est généralement utile pour la langue, bien que la façon dont elle a été mise en œuvre puisse soulever des questions.

Vous devez toujours vous rappeler les limitations inhérentes à cette approche, et le fait que le mode Nullable Reference activé ne protège pas contre les erreurs de déréférencement de liens NULL, et s'il est utilisé de manière incorrecte, il peut même y conduire. Il vaut la peine d'envisager l'utilisation d'un analyseur statique moderne, par exemple PVS-Studio, qui prend en charge l'analyse interprocédurale, comme un outil supplémentaire qui, avec la référence nullable, peut vous protéger contre le déréférencement de références nulles. Chacune de ces approches - à la fois l'analyse interprocédurale approfondie et l'annotation des signatures de méthode (qui fait essentiellement la référence nullable), a ses avantages et ses inconvénients. L'analyseur vous permettra d'obtenir une liste des endroits potentiellement dangereux et aussi, lors du changement d'un code existant, de voir toutes les conséquences de tels changements. Si vous attribuez null dans tous les cas, l'analyseur doit immédiatement indiquer à tous les consommateurs la variable, où elle n'est pas vérifiée avant le déréférencement.

Vous pouvez rechercher indépendamment d'autres erreurs à la fois dans le projet considéré et dans le vôtre. Pour ce faire, il vous suffit de télécharger et d'essayer l'analyseur PVS-Studio.



Si vous souhaitez partager cet article avec un public anglophone, veuillez utiliser le lien vers la traduction: Paul Eremeev, Alexander Senichkin. Types de référence Nullable en C # 8.0 et analyse statique

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


All Articles