C#8.0中的可空引用类型和静态分析

图片9


微软从事C#语言的第8版已经有一段时间了,这不是什么秘密。 新的语言版本(C#8.0)在Visual Studio 2019的最新版本中已经可用,但仍处于beta版本。 这个新版本将以一些不太明显或相当出乎意料的方式实现一些功能。 可空引用类型就是其中之一。 宣布此功能是与Null参考异常(NRE)对抗的一种手段。

很高兴看到该语言不断发展并获得新功能以帮助开发人员。 巧合的是,不久前,我们大大增强了PVS-Studio的C#分析仪检测NRE的能力。 现在,我们想知道一般的静态分析器,尤其是PVS-Studio是否仍然应该费心去诊断潜在的空引用,因为至少在将要使用Nullable Reference的新代码中,这样的引用将变得“不可能”? 让我们尝试清除它。

新功能的优缺点


在继续之前,有一个提醒:撰写本文时可用的最新beta版C#8.0默认情况下禁用了Nullable引用类型,即,引用类型的行为未更改。

那么,如果启用此选项,在C#8.0中什么是完全可为空的引用类型? 它们基本上与旧的引用类型相同,只是现在您必须添加“?” 在类型名称(例如string? )之后,类似于Nullable <T> ,即可空值类型(例如int? )。 如果没有'?',我们的字符串类型现在将被解释为不可为空的引用,即不能分配为null的引用类型。

空引用异常是进入程序的最令人烦恼的异常之一,因为它并没有过多说明其源代码,尤其是当throwing方法连续包含许多取消引用操作时。 禁止对引用类型的变量进行空值分配的功能看起来很酷,但是将空值传递给方法具有一些依赖于该方法的执行逻辑的情况又如何呢? 当然,除了null之外 ,我们还可以使用文字,常数或简单地“不可能”的值,这些值在逻辑上不能分配给其他任何地方的变量。 但这带来了用“无声的”但执行错误的程序替换崩溃的风险,这通常比立即解决错误要糟糕。

那么抛出异常呢? 在发生问题的位置抛出的有意义的异常总是比堆​​栈上方或下方的NRE更好。 但这仅对您自己的项目有用,您可以在其中插入try-catch块来纠正使用者,这完全是您的责任。 当使用(非)Nullable Reference开发库时,我们需要保证某种方法总是返回一个值。 毕竟,即使在您自己的代码中,也不总是可能(或至少很容易)用异常抛出来代替返回null (因为这可能会影响太多代码)。

Nullable Reference可以在全局项目级别通过添加NullableContextOptions属性并为其启用值来启用,也可以在文件级别通过preprocessor指令启用

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

可空引用功能将使类型更具信息性。 方法签名为您提供了有关其行为的线索:是否具有空检查,是否可以返回 。 现在,当您尝试使用可为空的引用变量而不对其进行检查时,编译器将发出警告。

当使用第三方库时,这非常方便,但是也增加了误导库用户的风险,因为仍然可以使用新的允许值的运算符(!)传递null 。 也就是说,仅添加一个感叹号可能会破坏使用此类变量的接口的所有进一步假设:

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

是的,您可以辩称这是不好的编程,没有人会真正写出像这样的代码,但是,只要有可能做到这一点,您就不能仅仅依靠给定方法的接口所施加的约定而感到安全(说它不能返回null )。

顺便说一句,您可以使用几个编写相同的代码 运算符,因为C#现在允许您这样做(并且这样的代码是完全可编译的):

 cantBeNull = canBeNull!!!!!!!; 

通过这样写,我们可以强调这个想法,“看,这可能是空的 !!!” (在我们的团队中,我们将此称为“情感”编程)。 实际上,在构建语法树时,编译器(来自Roslyn)解释了 运算符与解释正则括号的方式相同,这意味着您可以编写尽可能多的!! 随便-就像用括号一样。 但是,如果您编写了足够多的代码,则可以“关闭”编译器。 也许这将在C#8.0的最终版本中得到解决。

同样,在访问可为空的引用变量时,无需检查即可避免编译器警告:

 canBeNull!.ToString(); 

让我们增加更多情绪:

 canBeNull!!!?.ToString(); 

但是,您几乎看不到实际代码中的语法。 通过编写可为null的运算符,我们告诉编译器“此代码可以,不需要检查。” 通过添加猫王运算符,我们告诉它: 让我们检查一下以防万一。”

现在,您可以合理地问,为什么仍然可以轻松地将null分配给不可为空的引用类型的变量,如果这些类型的确切含义暗示此类变量不能具有null值呢? 答案是,在IL代码级别的“幕后”,我们的不可为空的引用类型仍然是...很好的旧“常规”引用类型,并且整个可空性语法实际上只是编译器内置的注释。分析器(我们认为使用起来不太方便,但稍后会详细说明)。 就个人而言,我们认为将新语法作为第三方工具的注释(甚至内置于编译器中)只是一种“整洁”的解决方案,因为事实上这仅仅是一个注释,可能根本看不出来对程序员而言,因为此语法与可为空的结构的语法非常相似,但其工作方式却完全不同。

回到打破Nullable引用类型的其他方式。 在撰写本文时,当您有一个包含多个项目的解决方案时,请将引用类型的变量(例如, 字符串)从一个项目中声明的方法传递给另一个具有NullableContext的项目中的方法。编译器假定它正在处理不可为null的String,并且编译器将保持沉默。 尽管启用了Nullable References时,IL代码中的每个字段和方法都添加了大量的[Nullable(1)]属性 顺便说一下,如果您使用反射来处理这些属性并假定代码仅包含您的自定义属性,则应考虑这些属性。

当将大型代码库调整为Nullable Reference样式时,这种情况可能会引起其他麻烦。 此过程可能会逐项目运行一段时间。 当然,如果谨慎,可以逐步集成新功能,但是如果您已经有一个正在运行的项目,则对其进行任何更改都是危险且不可取的(如果可行,请不要触摸它!)。 这就是为什么我们确保您在使用PVS-Studio分析仪时不必修改源代码或将其标记为检测潜在的NRE的原因。 要检查可能引发NullReferenceException的位置只需运行分析器并查找V3080警告。 无需更改项目的属性或源代码。 无需添加指令,属性或运算符。 无需更改旧代码。

在为PVS-Studio添加可空引用支持时,我们必须决定分析器是否应假定非空引用类型的变量始终具有非空值。 在研究了打破此保证的方式后,我们决定PVS-Studio不应该做出这样的假设。 毕竟,即使一个项目一直使用不可为空的引用类型,分析器也可以通过检测这些变量的值为null的特定情况来添加此功能。

PVS-Studio如何查找空引用异常


PVS-Studio的C#分析器中的数据流机制在分析过程中跟踪变量的可能值。 这也包括过程间分析,即跟踪方法及其嵌套方法返回的可能值,等等。 除此之外,PVS-Studio还会记住可以分配值的变量。 每当发现此类变量在未经检查的情况下被取消引用时,无论是在当前正在分析的代码中,还是在此代码中调用的方法内部,都将发出V3080警告,提示可能存在Null引用异常。

该诊断背后的想法是让分析仪仅在看到分配时才会生气。 这是我们诊断行为与编译器内置分析器处理Nullable Reference类型的行为之间的主要区别。 内置分析器将指向未检查的可为空的参考变量的每个取消引用-假定没有被使用误导了 操作员,甚至只是一个复杂的检查(但是,应该注意,绝对可以将任何静态分析仪(这里也不例外)PVS-Studio可以以一种或另一种方式“混合”,特别是如果您打算这样做)。

另一方面,PVS-Studio仅在看到空值时 (无论是在本地上下文中还是在外部方法的上下文中)警告您。 即使变量是不可为空的引用类型,如果分析器看到对该变量的赋值,它也将始终指向该变量。 我们认为,这种方法更合适(或至少对用户更方便),因为它不需要使用空检查来“涂抹”整个代码以跟踪潜在的取消引用-毕竟,即使在可空引用之前,此选项仍然可用例如通过使用合同进行介绍。 而且,分析器现在可以更好地控制非空引用变量本身。 如果“公平地”使用了这样的变量并且从未将其分配为null ,则PVS-Studio不会说一个字。 如果将变量分配为null ,然后在没有事先检查的情况下将其取消引用,PVS-Studio将发出V3080警告:

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

现在,让我们看一些示例,这些示例演示了Roslyn本身的代码如何触发此诊断。 我们最近已经检查了该项目 ,但是这次我们将仅查看先前文章中未提及的潜在的Null引用异常。 我们将看到PVS-Studio如何检测潜在的NRE,以及如何使用新的Nullable Reference语法对其进行修复。

V3080 [CWE-476]方法内部可能存在空取消引用。 考虑检查第二个参数: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); 

如您所见,可以在执行分支之一中为chainedTupleType变量分配值。 然后将其传递给ConstructTupleUnderlyingType方法,并在Debug.Assert检查之后在使用。 这是Roslyn中非常常见的模式,但请记住,在发行版中已删除了Debug.Assert 。 这就是为什么分析器仍然认为ConstructTupleUnderlyingType方法内的取消引用很危险。 这是该方法的主体,在该主体中进行取消引用:

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

分析器是否应考虑这样的Asserts(我们的某些用户希望这样做)实际上是一个争议问题-毕竟,分析器确实考虑了System.Diagnostics.Contracts的合同。 这是根据我们在自己的分析仪中使用Roslyn的经验得出的一个小实例。 在最近添加对Visual Studio最新版本的支持的同时,我们还将Roslyn更新到了其第三版。 此后,PVS-Studio开始因以前从未崩溃的某些代码而崩溃。 崩溃以及Null引用异常不会在我们的代码中发生,而是在Roslyn的代码中发生。 调试显示,Roslyn现在崩溃的代码段具有这种Debug.Assert基于null的检查行高了几行-该检查显然没有帮助。

这是一个图形示例,说明由于编译器将Debug.Assert视为任何配置中的可靠检查,因此您可能会遇到Nullable Reference的麻烦。 也就是说,如果添加#nullable enable并将标记的chainedTupleTypeOpt参数标记为可为空的引用则编译器将不会在ConstructTupleUnderlyingType方法内对取消引用发出任何警告。

转到PVS-Studio的其他警告示例。

V3080可能为空的取消引用。 考虑检查“ effectiveRuleset”。 RuleSet.cs 146

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

此警告说,对WithEffectiveAction方法的调用可能返回null ,而在使用前未检查分配给变量有效规则集的返回值( 有效 规则集.GeneralDiagnosticOption )。 这是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; } } 

通过为方法GetEffectiveRuleSet启用Nullable Reference,我们将获得两个必须更改代码行为的位置。 由于上面显示的方法可以引发异常,因此逻辑上假设对它的调用包装在try-catch块中,并且重写该方法以引发异常而不是返回null是正确的。 但是,如果您跟踪一些回叫,则会发现捕获的代码距离太远,无法可靠地预测后果。 让我们看一看有效 Ruleset变量的使用方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; } } 

如您所见,这是一个简单的switch语句,可以在两个枚举之间进行选择,其中ReportDiagnostic.Default为默认值。 因此,最好将调用重写如下:

WithEffectiveAction的签名将更改:

 #nullable enable public RuleSet? WithEffectiveAction(ReportDiagnostic action) 

该呼叫将如下所示:

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

由于IsStricterThan仅执行比较,因此可以重写条件-例如,如下所示:

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

下一个例子。

V3080可能为空的取消引用。 考虑检查“ propertySymbol”。 BinderFactory.BinderFactoryVisitor.cs 372

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

要解决此警告,我们接下来需要查看propertySymbol变量会发生什么。

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

在某些情况下, GetMemberSymbol方法也可以返回null

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

启用可空引用类型后,调用将更改为此:

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

当您知道在哪里查看时,很容易修复。 通过从所有过程调用链中收集字段的所有可能值,静态分析可以毫不费力地捕获此潜在错误。

V3080可能为空的取消引用。 考虑检查“ simpleName”。 CSharpCommandLineParser.cs 1556

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

问题出在simpleName.Length检查。 变量simpleName是通过执行一系列方法产生的,可以分配为null 。 顺便说一句,如果您好奇的话,可以查看RemoveExtension方法,以了解它与Path.GetFileNameWithoutExtension有何不同 一个simpleName!=空检查就足够了,但是对于非空引用类型,代码将变为如下所示:

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

呼叫可能如下所示:

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

结论


从零开始设计体系结构时,可空引用类型可以提供很大的帮助,但是重新编写现有代码可能需要很多时间和精力,因为它可能会导致许多难以捉摸的错误。 本文并不旨在阻止您使用Nullable Reference类型。 我们发现此新功能通常很有用,即使其具体实现方式可能会引起争议。

但是,请始终记住这种方法的局限性,并牢记启用“可空引用”模式并不能保护您免受NRE的侵害,一旦滥用,它本身就可能成为这些错误的来源。 我们建议您使用现代的静态分析工具(例如PVS-Studio)对Nullable Reference功能进行补充,该工具支持过程间分析以保护您的程序免受NRE的侵害。 这些方法中的每一种-深入的过程间分析和注释方法签名(实际上是Nullable Reference模式所做的)-都有其优点和缺点。 分析器将为您提供潜在危险位置的列表,并让您查看修改现有代码的后果。 如果某处有空分配,则分析器将指向该变量的每个使用方,在不检查的情况下将其取消引用。

您可以检查该项目或您自己的项目是否存在其他缺陷-只需下载 PVS-Studio并尝试一下。

Source: https://habr.com/ru/post/zh-CN455234/


All Articles