
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