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

图片9


微软已经在相当长的一段时间内致力于第八版C#的发布,这已不是什么秘密。 在Visual Studio 2019的最新版本中,该语言的新版本(C#8.0)已经可用,但到目前为止仅是beta版本。 这个新版本的计划具有几个功能,其实现可能不太明显,或者不太理想。 这些创新之一是使用Nullable Reference类型的能力。 这项创新的明确含义是与Null参考异常(NRE)的斗争。

我们很高兴该语言正在开发中,新功能应能为开发人员提供帮助。 巧合的是,在我们用于C#的PVS-Studio分析器中,检测代码中完全相同的NRE的功能相对较新。 我们问自己-静态分析器,尤其是PVS-Studio,现在是否有可能尝试寻找对空引用的潜在取消引用,如果至少在使用Nullable Reference的新代码中,这种取消引用将变得“不可能” ? 让我们尝试回答这个问题。

创新的利与弊


首先,值得回顾的是,在撰写本文时,在最新的C#8.0 beta版中,Nullable Reference在默认情况下处于关闭状态,即 引用类型的行为不会改变。

如果包含C#8.0中的可空引用类型,它们是什么? 这是一个很好的旧引用类型,区别在于此类型的变量现在必须标记为“?” (例如string? ),类似于已为Nullable <T>完成的操作,即 可为空的有效类型(例如int? )。 但是,现在相同的字符串没有'?' 已经开始被解释为不可为空的引用,即 这是一个引用类型,其变量不能包含值。

空引用异常是最令人讨厌的异常之一,因为它很少说明问题的根源,特别是如果在该方法中的行中多次抛出异常时会引发异常。 禁止将null传递给类型的引用变量的功能看起来不错,但是如果将更早的null传递给该方法,并且进一步执行逻辑与此相关,那么我现在该怎么办? 当然,您可以传递文字,常数或简单的“不可能”值代替null ,根据程序的逻辑,该值不能在其他任何地方分配给此变量。 但是,整个程序的崩溃可以由进一步的“静默”错误执行来代替。 这并不总是比立即看到错误更好。

而如果抛出异常? 在发生问题的地方,有意义的异常总是比堆​​栈中较高或较低的NRE更好。 但是,如果我们要谈论我们自己的项目,可以修复使用者并插入try-catch块,那么这很好当使用(非)Nullable Reference开发库时,我们要负责某种方法总是返回值。 而且,即使在本机代码中也不总是(至少很简单)用返回null代替引发异常(可能会影响太多代码)。

您可以通过在整个项目级别上添加带有启用值的NullableContextOptions属性来在整个项目级别上启用Nullable Reference,也可以使用preprocessor指令在文件级别上启用Nullable Reference:
#nullable enable string cantBeNull = string.Empty; string? canBeNull = null; cantBeNull = canBeNull!; 

现在,类型将更加直观。 通过方法的签名,可以确定其行为,无论是否包含对null的检查,它是否可以返回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(); 

实际上,在实际项目中很难想象这种语法,因此我们会向编译器放一个允许空值的运算符:这里一切都很好,不需要验证。 我们添加一个elvis运算符:我们说:但是一般来说,这可能不正常,让我们检查一下。

现在出现了一个合理的问题-为什么,如果非空引用类型的概念暗示该类型的变量不能包含null ,我们还能在其中这么容易地编写它吗? 事实是,在IL代码级别的“幕后”,我们的不可为空的引用类型仍然...都是相同的“普通”引用类型。 整个可空性语法实际上只是编译器中内置的静态分析器的注释(在我们看来,这不是最方便的分析器,而稍后会介绍更多)。 我们认为,仅将新语法包含在该语言中作为第三方工具的注释(即使它是内置在编译器中)也不是最“美丽”的解决方案,因为 对于使用这种语言的程序员来说,这仅仅是一个注解可能根本不明显-毕竟,非常相似的可空结构语法以完全不同的方式工作。

回到如何“打破”可空引用类型的可能性。 在撰写本文时,如果解决方案中有多个项目,则当从一个项目中声明的方法传递一个引用变量(例如String类型到另一个启用了NullableContextOptions的项目中的方法时编译器将确定它已经是一个不可为空的String,并且不会发出警告。 而且,尽管在打开Nullable Reference的情况下,在IL代码中的每个字段和类方法中添加了大量[Nullable(1)]属性,但仍存在这种情况 顺便说一句,如果您通过反射使用一系列属性,则应考虑这些属性,仅依靠您自己添加的那些属性的存在。

当将大型代码库转换为可空引用时,这种情况可能会带来其他问题。 此过程最有可能逐个项目逐步进行。 当然,采用有效的更改方法,您可以逐渐切换到新功能,但是,如果您已经有了有效的草案,那么对其进行任何更改都是危险且不可取的(可行-请勿触摸!)。 这就是为什么使用PVS-Studio分析仪时无需编辑源代码或以某种方式对其进行标记以检测潜在的NRE的原因 。 要检查NullReferenceException可能发生的位置您只需要启动分析器并查看V3080警告。 无需更改项目属性或源代码。 无需添加指令,属性或运算符。 无需更改您的代码。

借助PVS-Studio分析器中可空引用类型的支持,我们面临一个选择-分析器是否应将不可空引用变量解释为始终为非零值? 在研究了“打破”这种保证的可能性的问题之后,我们得出的结论是,没有-分析仪不应做出这样的假设。 的确,即使项目中各处都使用了不可为空的引用类型,分析器也可以通过发现这种变量中可能出现值的情况来补充其使用。

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


C#分析仪PVS-Studio中的数据流机制在分析过程中监视变量的可能值。 特别是,PVS-Studio还执行过程间分析,即 它尝试确定该方法以及在此方法中调用的方法等返回的可能值。 除其他事项外,分析器还会记住可能为null的变量。 如果将来分析器在不检查此类变量的情况下看到取消引用,则再次在正在检查的当前代码中或在此代码调用的方法内部,将发出警告V3080关于潜在的空引用异常。

同时,此诊断的主要思想是分析器仅在看到某个变量分配了null的情况下才会发誓。 这是此诊断程序的行为与内置于可空引用类型的编译器中的分析器之间的主要区别。 内置于编译器中的分析器会在对未验证的可为空的类型的引用变量进行任何取消引用时都发誓,除非该分析器被操作员“诱骗”!以任何其他方式,绝对可以使用任何分析仪,特别是如果您设定了这样的目标,PVS-Studio也不例外)。

仅当PVS-Studio看到null (在本地上下文中或来自方法)时,才发誓。 同时,即使该变量是不可为空的参考变量,分析器的行为也不会改变-如果它看到向其写入了null,它仍然会发誓。 在我们看来,这种方法更正确(或至少对分析仪用户方便),因为 它不需要通过检查来“覆盖”整个代码以查找潜在的取消引用-以前可以这样做,例如,没有空引用,例如具有相同的合同。 此外,分析器现在可以用于对相同的非空参考变量进行附加控制。 如果“诚实地”使用它们,并且它们永远不会被分配为空-分析器将保持沉默。 如果分配了null且未检查就取消了对该变量的引用,那么分析器将通过消息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本身的代码中触发V3080诊断的一些示例。 我们不久前检查了这个项目 ,但是这次我们将只考虑以前的文章中未提到的潜在的Null Reference Exception触发器。 让我们看看PVS-Studio分析器如何找到潜在的空引用反引用,以及如何使用新的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变量可以为null。 然后将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; } 

分析器是否应该考虑这样的Assert实际上是一个有争议的话题(我们的一些用户希望这样做),因为例如System.Diagnostics.Contracts的合同现在已考虑在内。 我只告诉您一个小例子,它是我们在分析仪中实际使用同一罗斯林的例子。 最近, 我们支持Visual Studio的新版本,同时将Roslyn分析器更新为版本3。 之后,分析器在检查以前未崩溃的特定代码时开始掉落。 同时,分析器开始不属于我们的代码内,而是属于Roslyn本身的代码内-带有Null Reference Exception。 进一步的调试显示,在Roslyn现在跌落的地方,恰好在上面的几行中,通过Debug.Assert进行了相同的null检查。 正如我们所见,她没有保存。

这是Nullable Reference问题的一个很好的例子因为编译器认为Debug.Assert在任何配置中都是有效的检查。 也就是说,如果仅启用#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 ,但使用该结果时将不进行检查( effectiveRuleset.GeneralDiagnosticOption )。 WithEffectiveAction方法的主体(可以返回null)被写入到validRuleset变量中:
 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。 但是,面对挑战,我们发现拦截率很高,其后果可能是无法预测的。 让我们看一下消费者变量有效 规则集-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; } } 

如您所见,这是一个用于两个枚举的简单开关,可能的枚举值为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!= Null ,但是在非零链接的情况下,代码将如下所示:
 #nullable enable public static string? RemoveExtension(string path) { .... } string simpleName; 

调用将如下所示:
 simpleName = PathUtilities.RemoveExtension( PathUtilities.GetFileName(sourceFiles.FirstOrDefault().Path)) ?? String.Empty; 

结论


可空引用类型在规划从头开始构建的体系结构方面可以提供很大帮助,但是对现有代码进行重新处理可能会需要大量时间和精力,因为它可能会导致许多细微的错误。 在本文中,我们的目标并不是阻止任何人在我们的项目中使用Nullable Reference类型。 我们相信这种创新通常对语言有用,尽管它的实现方式可能会引起疑问。

您应该始终牢记此方法固有的局限性,并且打开了“可空引用”模式不能避免由于对空链接进行解引用而导致的错误,如果使用不当,甚至可能导致错误。 值得考虑使用现代静态分析器,例如支持过程间分析的PVS-Studio,将其作为附加工具与Nullable Reference一起使用,可以保护您免于引用空引用。 这些方法中的每一种-深入的过程间分析和方法签名的注释(本质上使之成为可空引用)都有其优点和缺点。 分析仪将使您获得潜在危险场所的列表,并且在更改现有代码时,还可以查看此类更改的所有后果。 如果在任何情况下都分配null ,则分析器应立即向所有使用者指示变量,在取消引用之前不检查该变量。

您既可以在所考虑的项目中,也可以自己搜索其他错误。 为此,您只需下载并试用PVS-Studio分析仪。



如果您想与讲英语的读者分享本文,请使用以下链接:Paul Eremeev,Alexander Senichkin。 C#8.0中的可空引用类型和静态分析

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


All Articles