Avez-vous déjà voulu vous débarrasser du problème de déréférencement de référence nulle? Si tel est le cas, l'utilisation des types Nullable Reference n'est pas votre choix. Je me demande pourquoi? C'est ce qui sera discuté aujourd'hui.
Nous avons prévenu et c'est arrivé. Il y a environ un an, mes collègues ont écrit un article dans lequel ils avertissaient que l'introduction de types Nullable Reference ne protégerait pas contre le déréférencement de références nulles. Nous avons maintenant une vraie confirmation de nos paroles, qui a été trouvée dans les profondeurs de Roslyn.
Types de référence nulles
L'idée même d'ajouter des types Nullable Reference (ci-après - NR) me semble intéressante, car le problème associé au déréférencement des références nulles est pertinent à ce jour. La mise en œuvre de la protection contre le déréférencement est extrêmement peu fiable. Comme prévu par les créateurs, supposer que la valeur null ne peut que les variables dont le type est marqué d'un "?". Par exemple, une variable de type string? dit qu'il peut contenir null , de type chaîne - au contraire.
Cependant, personne ne nous interdit de passer null à référence non nullables des variables de toute façon.(ci-après - NNR), car ils ne sont pas implémentés au niveau du code IL. L'analyseur statique intégré au compilateur est responsable de cette limitation. Par conséquent, cette innovation est plutôt de nature consultative. Voici un exemple simple pour montrer comment cela fonctionne:
#nullable enable
object? nullable = null;
object nonNullable = nullable;
var deref = nonNullable.ToString();
Comme nous pouvons le voir, le type de nonNullable est spécifié comme NNR, mais nous pouvons y passer null en toute sécurité . Bien sûr, nous recevrons un avertissement concernant la conversion de "Conversion de littéral nul ou d'une valeur nulle possible en type non Nullable." Cependant, cela peut être contourné en ajoutant un peu d'agression:
#nullable enable
object? nullable = null;
object nonNullable = nullable!; // <=
var deref = nonNullable.ToString();
Un point d'exclamation et il n'y a aucun avertissement. Si l'un de vous est un gourmet, alors une autre option est disponible:
#nullable enable
object nonNullable = null!;
var deref = nonNullable.ToString();
Eh bien, un autre exemple. Créons deux projets de console simples. Dans le premier, on écrit:
namespace NullableTests
{
public static class Tester
{
public static string RetNull() => null;
}
}
Dans le second, on écrit:
#nullable enable
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
string? nullOrNotNull = NullableTests.Tester.RetNull();
System.Console.WriteLine(nullOrNotNull.Length);
}
}
}
Vol stationnaire au- dessus de nullOrNotNull et voir le message suivant:
On nous dit que la chaîne ne peut pas être nulle ici . Cependant, nous comprenons qu'il sera nul ici . Nous démarrons le projet et obtenons une exception:
Bien entendu, ce ne sont que des exemples synthétiques, dont le but est de montrer que cette introduction ne vous garantit pas une protection contre le déréférencement de références nulles. Si vous pensiez que les synthétiques sont ennuyeux et qu'il y a de vrais exemples, alors je vous demande de ne pas vous inquiéter, alors tout cela le sera.
Les types NR ont un autre problème - il n'est pas clair s'ils sont inclus ou non. Par exemple, la solution comporte deux projets. L'un est balisé avec cette syntaxe, et l'autre ne l'est pas. Après avoir entré un projet avec des types NR, vous pouvez décider qu'une fois qu'un seul est marqué, tous sont marqués. Cependant, ce ne sera pas le cas. Il s'avère que vous devez vérifier à chaque fois si le contexte Nullable est inclus dans le projet ou le fichier. Sinon, vous pourriez penser à tort que le type de référence normal est NNR.
Comment la preuve a été trouvée
Lors du développement de nouveaux diagnostics dans l'analyseur PVS-Studio, nous les testons toujours sur notre base de projets réels. Cela aide dans divers aspects. Par exemple:
- voir "en direct" à la qualité des avertissements reçus;
- se débarrasser de certains des faux positifs;
- trouvez des points intéressants dans le code, dont vous pourrez ensuite parler;
- etc.
L'un des nouveaux diagnostics V3156 a trouvé des endroits où des exceptions peuvent être levées en raison d'un potentiel nul . Le libellé de la règle de diagnostic est: "L'argument de la méthode ne devrait pas être nul". Son essence est que la méthode n'attend pas null , en valeur peut être passé comme argument à null . Cela peut conduire, par exemple, à une exception ou à une exécution incorrecte de la méthode appelée. Vous pouvez en savoir plus sur cette règle de diagnostic ici .
Preuves ici
Nous sommes donc arrivés à la partie principale de cet article. Ici, vous verrez de vrais fragments de code du projet Roslyn, pour lesquels les diagnostics ont émis des avertissements. Leur signification principale est que soit le type NNR est passé nul , soit il n'y a pas de vérification de la valeur du type NR. Tout cela peut conduire à la levée d'une exception.
Exemple 1
private static Dictionary<object, SourceLabelSymbol>
BuildLabelsByValue(ImmutableArray<LabelSymbol> labels)
{
....
object key;
var constantValue = label.SwitchCaseLabelConstant;
if ((object)constantValue != null && !constantValue.IsBad)
{
key = KeyForConstant(constantValue);
}
else if (labelKind == SyntaxKind.DefaultSwitchLabel)
{
key = s_defaultKey;
}
else
{
key = label.IdentifierNodeOrToken.AsNode();
}
if (!map.ContainsKey(key)) // <=
{
map.Add(key, label);
}
....
}
V3156 Le premier argument de la méthode 'ContainsKey' ne devrait pas être nul. Valeur nulle potentielle: clé. SwitchBinder.cs 121 Le
message indique que la clé est potentiellement nulle . Voyons où cette variable peut obtenir une telle valeur. Vérifions d' abord la méthode KeyForConstant :
protected static object KeyForConstant(ConstantValue constantValue)
{
Debug.Assert((object)constantValue != null);
return constantValue.IsNull ? s_nullKey : constantValue.Value;
}
private static readonly object s_nullKey = new object();
Puisque s_nullKey n'est pas null , voyons ce que constantValue.Value renvoie :
public object? Value
{
get
{
switch (this.Discriminator)
{
case ConstantValueTypeDiscriminator.Bad: return null; // <=
case ConstantValueTypeDiscriminator.Null: return null; // <=
case ConstantValueTypeDiscriminator.SByte: return Boxes.Box(SByteValue);
case ConstantValueTypeDiscriminator.Byte: return Boxes.Box(ByteValue);
case ConstantValueTypeDiscriminator.Int16: return Boxes.Box(Int16Value);
....
default: throw ExceptionUtilities.UnexpectedValue(this.Discriminator);
}
}
}
Il y a deux littéraux nuls ici, mais dans ce cas, nous n'entrerons dans aucun cas avec eux. Cela est dû aux vérifications IsBad et IsNull . Cependant, je voudrais attirer votre attention sur le type de retour de cette propriété. Il s'agit d'un type NR, mais la méthode KeyForConstant renvoie déjà un type NNR. Il s'avère que, en général, la méthode KeyForConstant peut retourner null . Une autre source qui peut renvoyer null est la méthode AsNode :
public SyntaxNode? AsNode()
{
if (_token != null)
{
return null;
}
return _nodeOrParent;
}
Encore une fois, veuillez faire attention au type de retour de la méthode - c'est un type NR. Il s'avère que quand on dit que null peut être retourné par la méthode , cela n'affecte rien. La chose intéressante est que le compilateur ne jure pas de convertir de NR en NNR ici:
Exemple 2
private SyntaxNode CopyAnnotationsTo(SyntaxNode sourceTreeRoot,
SyntaxNode destTreeRoot)
{
var nodeOrTokenMap = new Dictionary<SyntaxNodeOrToken,
SyntaxNodeOrToken>();
....
if (sourceTreeNodeOrTokenEnumerator.Current.IsNode)
{
var oldNode = destTreeNodeOrTokenEnumerator.Current.AsNode();
var newNode = sourceTreeNodeOrTokenEnumerator.Current.AsNode()
.CopyAnnotationsTo(oldNode);
nodeOrTokenMap.Add(oldNode, newNode); // <=
}
....
}
V3156 Le premier argument de la méthode 'Add' ne devrait pas être nul. Valeur nulle potentielle: oldNode. SyntaxAnnotationTests.cs 439
Un autre exemple avec la fonction AsNode décrite ci-dessus. Seulement cette fois, oldNode sera de type NR. Alors que la clé ci-dessus était de type NNR.
Soit dit en passant, je ne peux que partager avec vous une observation intéressante. Comme je l'ai décrit ci-dessus, lors du développement de diagnostics, nous les testons sur différents projets. Lors de la vérification des points positifs de cette règle, un moment curieux a été remarqué. Environ 70% de tous les avertissements concernaient des méthodes de la classe Dictionary . De plus, la plupart d'entre eux sont tombés sur la méthode TryGetValue... Cela est peut-être dû au fait qu'inconsciemment, nous n'attendons pas d'exceptions d'une méthode contenant le mot try . Vérifiez donc votre code pour ce modèle pour voir si vous trouvez quelque chose de similaire.
Exemple 3
private static SymbolTreeInfo TryReadSymbolTreeInfo(
ObjectReader reader,
Checksum checksum,
Func<string, ImmutableArray<Node>,
Task<SpellChecker>> createSpellCheckerTask)
{
....
var typeName = reader.ReadString();
var valueCount = reader.ReadInt32();
for (var j = 0; j < valueCount; j++)
{
var containerName = reader.ReadString();
var name = reader.ReadString();
simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
new ExtensionMethodInfo(containerName, name));
}
....
}
V3156 Le premier argument de la méthode 'Add' est passé comme argument à la méthode 'TryGetValue' et ne devrait pas être nul. Valeur nulle potentielle: typeName. SymbolTreeInfo_Serialization.cs 255
L'analyseur dit que le problème réside dans le typeName . Assurons-nous d'abord que cet argument est effectivement nul potentiel . Nous regardons ReadString :
public string ReadString() => ReadStringValue();
Alors, regardez ReadStringValue :
private string ReadStringValue()
{
var kind = (EncodingKind)_reader.ReadByte();
return kind == EncodingKind.Null ? null : ReadStringValue(kind);
}
Super, maintenant rafraîchissons notre mémoire en regardant où notre variable a été passée:
simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
new ExtensionMethodInfo(containerName,
name));
Je pense qu'il est temps d'entrer dans la méthode Add :
public bool Add(K k, V v)
{
ValueSet updated;
if (_dictionary.TryGetValue(k, out ValueSet set)) // <=
{
....
}
....
}
En effet, si null est passé à la méthode Add comme premier argument , alors nous obtiendrons une ArgumentNullException . En passant, il est intéressant de noter que si nous passons le curseur sur typeName dans Visual Studio , nous verrons que son type est string? :
Dans ce cas, le type de retour de la méthode est simplement une chaîne :
Dans ce cas, si vous créez une variable de type NNR et que vous lui attribuez typeName , aucune erreur ne sera affichée.
Essayons de laisser tomber Roslyn
Pas par malice, mais pour le plaisir, je suggère d'essayer de reproduire l'un des exemples présentés.
Test 1
Prenons l'exemple décrit sous le numéro 3:
private static SymbolTreeInfo TryReadSymbolTreeInfo(
ObjectReader reader,
Checksum checksum,
Func<string, ImmutableArray<Node>,
Task<SpellChecker>> createSpellCheckerTask)
{
....
var typeName = reader.ReadString();
var valueCount = reader.ReadInt32();
for (var j = 0; j < valueCount; j++)
{
var containerName = reader.ReadString();
var name = reader.ReadString();
simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
new ExtensionMethodInfo(containerName, name));
}
....
}
Pour le reproduire, vous devez appeler la méthode TryReadSymbolTreeInfo , mais elle est privée . Il est bon que la classe avec elle ait une méthode ReadSymbolTreeInfo_ForTestingPurposesOnly , qui est déjà interne :
internal static SymbolTreeInfo ReadSymbolTreeInfo_ForTestingPurposesOnly(
ObjectReader reader,
Checksum checksum)
{
return TryReadSymbolTreeInfo(reader, checksum,
(names, nodes) => Task.FromResult(
new SpellChecker(checksum,
nodes.Select(n => new StringSlice(names,
n.NameSpan)))));
}
Il est très agréable que l'on nous propose directement de tester la méthode TryReadSymbolTreeInfo . Par conséquent, créons notre classe côte à côte et écrivons le code suivant:
public class CheckNNR
{
public static void Start()
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write((byte)170);
writer.Write((byte)9);
writer.Write((byte)0);
writer.Write(0);
writer.Write(0);
writer.Write(1);
writer.Write((byte)0);
writer.Write(1);
writer.Write((byte)0);
writer.Write((byte)0);
stream.Position = 0;
using var reader = ObjectReader.TryGetReader(stream);
var checksum = Checksum.Create("val");
SymbolTreeInfo.ReadSymbolTreeInfo_ForTestingPurposesOnly(reader, checksum);
}
}
Maintenant, nous collectons Roslyn , créons une application console simple, connectons tous les fichiers dll nécessaires et écrivons le code suivant:
static void Main(string[] args)
{
CheckNNR.Start();
}
Nous lançons, nous atteignons l'endroit souhaité et voyons:
Ensuite, accédez à la méthode Add et obtenez l'exception attendue:
Permettez-moi de vous rappeler que la méthode ReadString renvoie un type NNR qui, de par sa conception, ne peut pas contenir null . Cet exemple confirme une fois de plus la pertinence des règles de diagnostic de PVS-Studio pour la recherche de déréférencement de références nulles.
Test 2
Eh bien, puisque nous avons déjà commencé à reproduire des exemples, pourquoi ne pas en reproduire un de plus. Cet exemple ne sera pas lié aux types NR. Cependant, les mêmes diagnostics V3156 l'ont trouvé, et je voulais vous en parler. Voici le code:
public SyntaxToken GenerateUniqueName(SemanticModel semanticModel,
SyntaxNode location,
SyntaxNode containerOpt,
string baseName,
CancellationToken cancellationToken)
{
return GenerateUniqueName(semanticModel,
location,
containerOpt,
baseName,
filter: null,
usedNames: null, // <=
cancellationToken);
}
V3156 Le sixième argument de la méthode 'GenerateUniqueName' est passé en tant qu'argument à la méthode 'Concat' et ne devrait pas être nul. Valeur nulle potentielle: null. AbstractSemanticFactsService.cs 24
Je vais être honnête: en faisant ce diagnostic, je ne m'attendais pas vraiment à des points positifs sur la ligne droite null . Après tout, il est plutôt étrange d'envoyer null à une méthode qui lèvera une exception à cause de cela. Bien que j'ai vu des endroits où cela était justifié (par exemple, avec la classe Expression ), mais maintenant il ne s'agit pas de cela.
Par conséquent, j'ai été très intrigué lorsque j'ai vu cet avertissement. Voyons ce qui se passe dans la méthode GenerateUniqueName .
public SyntaxToken GenerateUniqueName(SemanticModel semanticModel,
SyntaxNode location,
SyntaxNode containerOpt,
string baseName,
Func<ISymbol, bool> filter,
IEnumerable<string> usedNames,
CancellationToken cancellationToken)
{
var container = containerOpt ?? location
.AncestorsAndSelf()
.FirstOrDefault(a => SyntaxFacts.IsExecutableBlock(a)
|| SyntaxFacts.IsMethodBody(a));
var candidates = GetCollidableSymbols(semanticModel,
location,
container,
cancellationToken);
var filteredCandidates = filter != null ? candidates.Where(filter)
: candidates;
return GenerateUniqueName(baseName,
filteredCandidates.Select(s => s.Name)
.Concat(usedNames)); // <=
}
Nous voyons qu'il n'y a qu'un seul moyen de sortir de la méthode, aucune exception n'est levée et aucun goto . En d'autres termes, rien ne vous empêche de transmettre usedNames à la méthode Concat et d'obtenir une ArgumentNullException .
Mais ce sont tous des mots, faisons-le. Pour ce faire, regardez où vous pouvez appeler cette méthode. La méthode elle-même est dans la classe AbstractSemanticFactsService . La classe est abstraite, donc pour plus de commodité, prenons la classe CSharpSemanticFactsService , qui en hérite. Dans le fichier de cette classe, créez le nôtre, qui appellera la méthode GenerateUniqueName . Cela ressemble à ceci:
public class DropRoslyn
{
private const string ProgramText =
@"using System;
using System.Collections.Generic;
using System.Text
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine(""Hello, World!"");
}
}
}";
public void Drop()
{
var tree = CSharpSyntaxTree.ParseText(ProgramText);
var instance = CSharpSemanticFactsService.Instance;
var compilation = CSharpCompilation
.Create("Hello World")
.AddReferences(MetadataReference
.CreateFromFile(typeof(string)
.Assembly
.Location))
.AddSyntaxTrees(tree);
var semanticModel = compilation.GetSemanticModel(tree);
var syntaxNode1 = tree.GetRoot();
var syntaxNode2 = tree.GetRoot();
var baseName = "baseName";
var cancellationToken = new CancellationToken();
instance.GenerateUniqueName(semanticModel,
syntaxNode1,
syntaxNode2,
baseName,
cancellationToken);
}
}
Maintenant, nous collectons Roslyn, créons une application console simple, connectons tous les fichiers dll nécessaires et écrivons le code suivant:
class Program
{
static void Main(string[] args)
{
DropRoslyn dropRoslyn = new DropRoslyn();
dropRoslyn.Drop();
}
}
Nous lançons l'application et obtenons ce qui suit:
C'est trompeur
Disons que nous sommes d'accord avec le concept nullable. Il s'avère que si nous voyons un type NR, alors nous pensons qu'il peut contenir un potentiel nul . Cependant, vous pouvez parfois voir des situations où le compilateur nous dit le contraire. Par conséquent, nous examinerons ici quelques cas où l'utilisation de ce concept n'est pas intuitive.
Cas 1
internal override IEnumerable<SyntaxToken>? TryGetActiveTokens(SyntaxNode node)
{
....
var bodyTokens = SyntaxUtilities
.TryGetMethodDeclarationBody(node)
?.DescendantTokens();
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
{
if (ctor.Initializer != null)
{
bodyTokens = ctor.Initializer
.DescendantTokens()
.Concat(bodyTokens); // <=
}
}
return bodyTokens;
}
V3156 Le premier argument de la méthode 'Concat' ne devrait pas être nul. Valeur nulle potentielle: bodyTokens. CSharpEditAndContinueAnalyzer.cs 219 Voyons
pourquoi bodyTokens est potentiellement nul et voyons l' opérateur conditionnel nul :
var bodyTokens = SyntaxUtilities
.TryGetMethodDeclarationBody(node)
?.DescendantTokens(); // <=
Si nous allons dans la méthode TryGetMethodDeclarationBody , nous verrons qu'elle peut retourner null . Cependant, il est relativement grand, donc je laisse un lien vers celui-ci si vous voulez voir par vous-même. Avec bodyTokens, tout est clair, mais je veux attirer l'attention sur l'argument ctor :
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
Comme nous pouvons le voir, son type est défini sur NR. Dans ce cas, le déréférencement se produit avec la ligne ci-dessous:
if (ctor.Initializer != null)
Cette combinaison est un peu alarmante. Cependant, vous pourriez dire que, très probablement, si IsKind renvoie true , alors ctor n'est certainement pas nul . C'est comme ça:
public static bool IsKind<TNode>(
[NotNullWhen(returnValue: true)] this SyntaxNode? node, // <=
SyntaxKind kind,
[NotNullWhen(returnValue: true)] out TNode? result) // <=
where TNode : SyntaxNode
{
if (node.IsKind(kind))
{
result = (TNode)node;
return true;
}
result = null;
return false;
}
Ici, des attributs spéciaux sont utilisés pour indiquer à quelle valeur de sortie les paramètres ne seront pas nuls . Nous pouvons le vérifier en examinant la logique de la méthode IsKind . Il s'avère qu'à l'intérieur de la condition, le type de ctor doit être NNR. Le compilateur comprend cela et dit que le ctor à l'intérieur de la condition ne sera pas nul . Cependant, afin de comprendre cela pour nous, nous devons entrer dans la méthode IsKind et y remarquer l'attribut. Sinon, cela ressemble à déréférencer une variable NR sans vérifier null . Vous pouvez essayer d'ajouter un peu de clarté comme ceci:
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
{
if (ctor!.Initializer != null) // <=
{
....
}
}
Cas 2
public TextSpan GetReferenceEditSpan(InlineRenameLocation location,
string triggerText,
CancellationToken cancellationToken)
{
var searchName = this.RenameSymbol.Name;
if (_isRenamingAttributePrefix)
{
searchName = GetWithoutAttributeSuffix(this.RenameSymbol.Name);
}
var index = triggerText.LastIndexOf(searchName, // <=
StringComparison.Ordinal);
....
}
V3156 Le premier argument de la méthode 'LastIndexOf' ne devrait pas être nul. Valeur nulle potentielle: searchName. AbstractEditorInlineRenameService.SymbolRenameInfo.cs 126
Nous sommes intéressés par la variable searchName . null peut y être écrit après avoir appelé la méthode GetWithoutAttributeSuffix , mais ce n'est pas si simple. Voyons ce qui s'y passe:
private string GetWithoutAttributeSuffix(string value)
=> value.GetWithoutAttributeSuffix(isCaseSensitive:
_document.GetRequiredLanguageService<ISyntaxFactsService>()
.IsCaseSensitive)!;
Allons plus loin:
internal static string? GetWithoutAttributeSuffix(
this string name,
bool isCaseSensitive)
{
return TryGetWithoutAttributeSuffix(name, isCaseSensitive, out var result)
? result : null;
}
Il s'avère que la méthode TryGetWithoutAttributeSuffix renverra soit result, soit null . Et la méthode renvoie un type NR. Cependant, en revenant d'un pas en arrière, nous remarquons que le type de méthode a soudainement changé en NNR. Cela se produit à cause du signe caché "!":
_document.GetRequiredLanguageService<ISyntaxFactsService>()
.IsCaseSensitive)!; // <=
À propos, il est assez difficile de le remarquer dans Visual Studio:
En le fournissant, le développeur nous dit que la méthode ne retournera jamais null . Bien que, en regardant les exemples précédents et en entrant dans la méthode TryGetWithoutAttributeSuffix , personnellement, je ne peux pas en être sûr:
internal static bool TryGetWithoutAttributeSuffix(
this string name,
bool isCaseSensitive,
[NotNullWhen(returnValue: true)] out string? result)
{
if (name.HasAttributeSuffix(isCaseSensitive))
{
result = name.Substring(0, name.Length - AttributeSuffix.Length);
return true;
}
result = null;
return false;
}
Production
Enfin, je tiens à dire qu'essayer de nous épargner les vérifications nulles inutiles est une excellente idée. Cependant, les types NR sont plutôt de nature consultative, car personne ne nous interdit strictement de passer null à un type NNR. C'est pourquoi les règles PVS-Studio correspondantes restent pertinentes. Par exemple, comme V3080 ou V3156 .
Bonne chance et merci pour votre attention.
Si vous souhaitez partager cet article avec un public anglophone, veuillez utiliser le lien de traduction: Nikolay Mironov. Nullable Reference ne vous protégera pas, et en voici la preuve .