您是否曾经想摆脱过空引用解引用问题?如果是这样,则不选择使用Nullable引用类型。我想知道为什么?这就是今天将要讨论的内容。
我们警告过,事情发生了。大约一年前,我的同事写了一篇文章,警告说引入Nullable引用类型将无法避免对null引用的取消引用。现在我们对我们的话有了真正的确认,这是在罗斯林的深处发现的。
可空引用类型
添加可空引用(以下简称NR)类型的想法对我来说似乎很有趣,因为与解引用空引用相关的问题与今天有关。实施反引用保护是极其不可靠的。按照创建者的计划,假定值null只能是类型标记为“?”的那些变量。例如,字符串类型的变量?说它可以包含null,其类型为string-相反。
但是,没有人禁止我们将null传递给不可为空的参考变量。(以下称为NNR)类型,因为它们不是在IL代码级别实现的。内置于编译器中的静态分析器是造成此限制的原因。因此,这种创新本质上是一种建议。这是一个简单的示例,说明其工作方式:
#nullable enable
object? nullable = null;
object nonNullable = nullable;
var deref = nonNullable.ToString();
正如我们所看到的,nonNullable的类型被指定为NNR,但是我们可以安全地在其中传递null。当然,我们将收到有关转换“将空文字或可能的空值转换为非空类型的警告”。但是,可以通过添加一些攻击性来规避此问题:
#nullable enable
object? nullable = null;
object nonNullable = nullable!; // <=
var deref = nonNullable.ToString();
一个感叹号,没有警告。如果您中的一个是美食家,那么可以使用另一种选择:
#nullable enable
object nonNullable = null!;
var deref = nonNullable.ToString();
好,再举一个例子。我们创建两个简单的控制台项目。首先,我们写:
namespace NullableTests
{
public static class Tester
{
public static string RetNull() => null;
}
}
在第二篇中,我们写:
#nullable enable
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
string? nullOrNotNull = NullableTests.Tester.RetNull();
System.Console.WriteLine(nullOrNotNull.Length);
}
}
}
将鼠标悬停 在nullOrNotNull上,然后看到以下消息:
我们被告知这里的字符串不能为null。但是,我们知道在这里它将为null。我们启动项目并得到一个例外:
当然,这些只是综合示例,其目的是表明本介绍不能保证您可以防止空引用取消引用。如果您认为合成材料很无聊,并且根本没有真正的例子,那么我请您不要担心,那么所有这些都将是。
NR类型还有另一个问题-尚不清楚它们是否包含在内。例如,该解决方案有两个项目。一个标记有这种语法,而另一个则没有。输入了NR类型的项目后,您可以决定一旦标记了一个项目,然后标记了所有项目。但是,事实并非如此。事实证明,如果项目或文件中包含可为空的上下文,则需要每次查看。否则,您可能会错误地认为普通引用类型是NNR。
如何找到证据
在PVS-Studio分析仪中开发新的诊断程序时,我们总是在实际项目的基础上对其进行测试。它在各个方面都有帮助。例如:
- 看到“实时”警告的质量;
- 消除一些误报;
- 在代码中找到有趣的要点,然后您可以进行讨论;
- 等等
新的V3156诊断程序之一发现了由于可能为null而可能引发异常的地方。诊断规则的措词为:“该方法的参数不应为空”。其本质是该方法不期望null,可以将value作为null的参数传递。例如,这可能导致被调用方法异常或执行不正确。您可以在此处阅读有关此诊断规则的更多信息。
这里的证明
因此,我们进入了本文的主要部分。在这里,您将看到Roslyn项目中的实际代码片段,诊断程序为此发出了警告。它们的主要含义是将NNR类型传递为null,或者不检查NR类型的值。所有这些都可能导致引发异常。
例子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“ ContainsKey”方法的第一个参数不应为null。可能的空值:键。SwitchBinder.cs 121
消息指出密钥可能为null。让我们看看该变量在哪里可以得到这样的值。让我们首先检查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();
由于s_nullKey不为null,让我们看看constantValue.Value返回的内容:
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);
}
}
}
有两个空文字在这里,但在这种情况下,我们不会去任何情况下他们。这是由于IsBad和IsNull检查。但是,我想提醒您注意此属性的返回类型。它是NR类型,但是KeyForConstant方法已经返回了NNR类型。事实证明,通常,KeyForConstant方法可以返回null。 可以返回null的另一个来源是AsNode方法:
public SyntaxNode? AsNode()
{
if (_token != null)
{
return null;
}
return _nodeOrParent;
}
同样,请注意方法的返回类型-它是NR类型。事实证明,当我们说可以从该方法返回null时,这不会有任何影响。有趣的是,编译器不会在这里发誓要从NR转换为NNR:
例子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“添加”方法的第一个参数不应为null。可能的空值:oldNode。 SyntaxAnnotationTests.cs 439
带有上述AsNode函数的另一个示例。仅这次,oldNode的类型将为NR。而上述密钥是NNR类型。
顺便说一句,我不得不与你分享一个有趣的观察。如上所述,在开发诊断程序时,我们在不同的项目上对其进行测试。在检查该规则的正面结果时,注意到了一个奇怪的时刻。所有警告中约有70%发出给Dictionary类的方法。而且,它们大多数都属于TryGetValue方法... 可能是由于这样的事实:在潜意识里,我们不期望包含单词try的方法产生异常。因此,请检查此模式的代码以查看是否找到类似的内容。
例子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“添加”方法的第一个参数作为参数传递给“ TryGetValue”方法,并且不应为null。可能的空值:typeName。SymbolTreeInfo_Serialization.cs 255
分析器认为问题出在typeName上。首先,请确保此参数确实是潜在的null。我们看一下ReadString:
public string ReadString() => ReadStringValue();
因此,请看ReadStringValue:
private string ReadStringValue()
{
var kind = (EncodingKind)_reader.ReadByte();
return kind == EncodingKind.Null ? null : ReadStringValue(kind);
}
太好了,现在让我们通过将变量传递到的位置来刷新内存:
simpleTypeNameToExtensionMethodMap.Add(typeName, // <=
new ExtensionMethodInfo(containerName,
name));
我认为是时候进入Add方法了:
public bool Add(K k, V v)
{
ValueSet updated;
if (_dictionary.TryGetValue(k, out ValueSet set)) // <=
{
....
}
....
}
实际上,如果将null作为第一个参数传递给Add方法,那么我们将获得ArgumentNullException。 顺便说一句,有趣的是,如果将光标悬停在Visual Studio中的typeName上方,我们将看到其类型为string?:
在这种情况下,方法的返回类型就是string:
在这种情况下,如果您进一步创建NNR类型的变量并为其分配typeName,则不会显示任何错误。
让我们尝试放下罗斯林
我不是为了恶意,而是为了娱乐,我建议尝试重现所示的示例之一。
测试1
让我们以数字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));
}
....
}
要重现它,您需要调用TryReadSymbolTreeInfo方法,但是该方法是private。很好的是,带有它的类具有一个ReadSymbolTreeInfo_ForTestingPurposesOnly方法,该方法已经是内部的:
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)))));
}
很高兴我们直接提供了对TryReadSymbolTreeInfo方法的测试。因此,让我们并排创建我们的类并编写以下代码:
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);
}
}
现在,我们收集Roslyn,创建一个简单的控制台应用程序,连接所有必需的dll文件并编写以下代码:
static void Main(string[] args)
{
CheckNNR.Start();
}
我们启动,到达所需的位置,然后看到:
接下来,转到Add方法并获取预期的异常:
让我提醒您,ReadString方法返回一个NNR类型,根据设计,该类型不能包含null。该示例再次确认了PVS-Studio诊断规则与搜索空引用解除引用的相关性。
测试2
好吧,由于我们已经开始复制示例,为什么不再复制一个。此示例与NR类型无关。但是,它是由相同的V3156诊断程序发现的,我想向您介绍一下。这是代码:
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“ GenerateUniqueName”方法的第六个参数作为参数传递给“ Concat”方法,并且不应为null。可能为null的值:null。 AbstractSemanticFactsService.cs 24
我会说实话:在进行此诊断时,我并不真正期望直线null上有任何正数。毕竟,向此方法发送null会引发异常的方法很奇怪。尽管我已经看到了证明其合理性的地方(例如,使用Expression类),但是现在不相关了。
因此,看到此警告时我很感兴趣。让我们看看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)); // <=
}
我们看到该方法只有一个出口,没有抛出异常,也没有goto。换句话说,没有什么可以阻止您将usedNames传递给Concat方法并获取ArgumentNullException。
但是这些都是言语,让我们去做。为此,请查看可以在何处调用此方法。该方法本身在AbstractSemanticFactsService类中。该类是抽象的,因此为方便起见,让我们采用从其继承的CSharpSemanticFactsService类。在此类的文件中,我们将创建自己的类,该类将调用GenerateUniqueName方法。看起来像这样:
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);
}
}
现在,我们收集Roslyn,创建一个简单的控制台应用程序,连接所有必需的dll文件并编写以下代码:
class Program
{
static void Main(string[] args)
{
DropRoslyn dropRoslyn = new DropRoslyn();
dropRoslyn.Drop();
}
}
我们启动该应用程序并获得以下信息:
这是误导
假设我们同意可为空的概念。事实证明,如果我们看到NR类型,那么我们认为它可以包含潜在的null。但是,有时您会看到编译器另行告诉我们的情况。因此,这里将考虑这种概念的使用不直观的几种情况。
情况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'Concat'方法的第一个参数不应为null。可能的空值:bodyTokens。CSharpEditAndContinueAnalyzer.cs 219让我们
看一下为什么bodyTokens可能为null并查看null条件运算符:
var bodyTokens = SyntaxUtilities
.TryGetMethodDeclarationBody(node)
?.DescendantTokens(); // <=
如果我们进入TryGetMethodDeclarationBody方法,则会看到它可以返回null。但是,它相对较大,因此如果您想自己看看,我会留下一个链接。使用bodyTokens一切都很清楚,但我想提请注意ctor参数:
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
如我们所见,其类型设置为NR。在这种情况下,以下行将取消引用:
if (ctor.Initializer != null)
这种组合有点令人震惊。但是,您可能会说,很可能,如果IsKind返回true,则ctor绝对不是null。事情是这样的:
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;
}
此处,使用特殊属性来指示参数在哪个输出值处不会为null。我们可以通过查看IsKind方法的逻辑来验证这一点。事实证明,在此条件下,ctor的类型必须为NNR。编译器理解这一点,并说条件内的ctor不会为null。但是,为了对我们了解这一点,我们必须转到IsKind方法并注意那里的属性。否则,看起来就像在不检查null的情况下取消引用了NR变量。您可以尝试增加一些清晰度,如下所示:
if (node.IsKind(SyntaxKind.ConstructorDeclaration,
out ConstructorDeclarationSyntax? ctor))
{
if (ctor!.Initializer != null) // <=
{
....
}
}
情况二
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“ LastIndexOf”方法的第一个参数不应为null。可能的空值:searchName。AbstractEditorInlineRenameService.SymbolRenameInfo.cs 126
我们对searchName变量感兴趣。可以在调用GetWithoutAttributeSuffix方法之后将null写入其中,但这并不那么简单。让我们看看其中发生了什么:
private string GetWithoutAttributeSuffix(string value)
=> value.GetWithoutAttributeSuffix(isCaseSensitive:
_document.GetRequiredLanguageService<ISyntaxFactsService>()
.IsCaseSensitive)!;
让我们更深入:
internal static string? GetWithoutAttributeSuffix(
this string name,
bool isCaseSensitive)
{
return TryGetWithoutAttributeSuffix(name, isCaseSensitive, out var result)
? result : null;
}
事实证明,TryGetWithoutAttributeSuffix方法将返回result或null。并且该方法返回NR类型。但是,退后一步,我们注意到该方法的类型突然变为NNR。发生这种情况是由于隐藏符号“!”:
_document.GetRequiredLanguageService<ISyntaxFactsService>()
.IsCaseSensitive)!; // <=
顺便说一句,在Visual Studio中很难注意到它:
通过提供它,开发人员告诉我们该方法将永远不会返回null。虽然,通过查看前面的示例并进入TryGetWithoutAttributeSuffix方法,我个人不能确定这一点:
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;
}
输出量
最后,我想说,试图为我们节省不必要的null检查是一个好主意。但是,NR类型本质上是一种建议,因为没有人严格禁止我们将null传递给NNR类型。这就是为什么相应的PVS-Studio规则保持相关性的原因。例如,例如V3080或V3156。
一切顺利,谢谢您的关注。
如果您想与讲英语的读者分享这篇文章,请使用翻译链接:Nikolay Mironov。可空引用不会保护您,这是证明。