这篇文章将介绍将C# 7类库升级到C# 8(支持可空引用类型)的一个案例。本案例中使用的项目Tortuga Anchor由一组MVVM风格的基类、反射代码和各种实用程序函数组成。之所以选择这个项目,是因为它很小,并且同时包含了惯用和不常用的C#模式。
关键要点
- 为每个项目启用可空引用类型。
- 使用泛型时,可能需要禁用可空引用类型。
- 可以通过在本地变量中缓存属性来修复警告。
- 公开方法仍然需要进行Null参数检查。
- .NET Framework和.NET Core的反序列化方式是不一样的。
这篇文章将介绍将C# 7类库升级到C# 8(支持可空引用类型)的一个案例。本案例中使用的项目Tortuga Anchor由一组MVVM风格的基类、反射代码和各种实用程序函数组成。之所以选择这个项目,是因为它很小,并且同时包含了惯用和不常用的C#模式。
项目设置
目前,可空引用类型仅适用于.NET Standard和.NET Core项目。在Visual Studio 2019发布时,应该也支持.NET Framework。
在项目文件中,添加或修改以下配置:
\u0026lt;/PropertyGroup\u0026gt; \u0026lt;LangVersion\u0026gt;8.0\u0026lt;/LangVersion\u0026gt; \u0026lt;NullableContextOptions\u0026gt;enable\u0026lt;/NullableContextOptions\u0026gt;\u0026lt;/PropertyGroup\u0026gt;
在保存文件后,应该会看到可空性错误。如果没有看到,请尝试构建项目。
指示一个类型可以为空
在接口方法GetPreviousValue中,返回类型可以为空。为了显式地说明这一点,可以在object后面跟上可空类型修饰符(?)。
object? GetPreviousValue(string propertyName);
使用这个类型修饰符注解变量、参数和返回类型,就可以解决项目中的很多编译器错误。
延迟加载属性
如果一个属性的求值成本非常高,可以使用延迟加载模式。在使用这个模式时,如果私有字段为空,表示尚未生成字段的值。
C# 8可以很好地处理这种情况。在不改变代码的情况下,它能够正确地分析代码,以确定getter的结果将始终非空,尽管返回的变量可以为空。
string? m_CSharpFullName;public string CSharpFullName{ get { if (m_CSharpFullName == null) { var result = new StringBuilder(m_TypeInfo.ToString().Length); BuildCSharpFullName(m_TypeInfo.AsType(), null, result); m_CSharpFullName = result.ToString(); } return m_CSharpFullName; }}
需要注意的是,这里存在潜在的竞态条件。理论上,另一个线程可以将m_CSharpFullName的值设置回null,而编译器无法检测到。因此,在处理多线程代码时要特别小心。
一个变量的可空性由另一个变量决定
在下一个代码示例中,当且仅当m_ItemPropertyChanged不为空时,m_ListeningToItemEvents才为true。编译器无法知道这个规则。如果是这种情况,你可以将(!)附加到变量(在本例中为m_ItemPropertyChanged)后面,表示它在这个时间点不会为空。
if (m_ListeningToItemEvents){ if (item is INotifyPropertyChangedWeak) ((INotifyPropertyChangedWeak)item).AddHandler(m_ItemPropertyChanged!); else if (item is INotifyPropertyChanged) ((INotifyPropertyChanged)item).PropertyChanged += OnItemPropertyChanged;}
使用显式强制转换纠正误报
在下一个示例中,编译器错误地报告了m_Base的可空性。Values与IEnumerable的值不兼容。要移除这个警告,我添加了显式强制转换。
readonly Dictionary\u0026lt;ValueTuple\u0026lt;TKey1, TKey2\u0026gt;, TValue\u0026gt; m_Base;IEnumerable\u0026lt;TValue\u0026gt; IReadOnlyDictionary\u0026lt;ValueTuple\u0026lt;TKey1, TKey2\u0026gt;, TValue\u0026gt;.Values{ get { return (IEnumerable\u0026lt;TValue\u0026gt;)m_Base.Values; }}
请注意编译器将该行标记为具有冗余强制转换。这是正常的编译器消息,而不是警告,但希望在发布时能够得到更正。
使用临时变量或条件强制转换纠正误报
在下一个示例中,编译器指出CancelEdit所在行存在一个错误。虽然前面的if语句证明item.Value不为空,但编译器不相信下次读取item.Value时它仍然是不为空。
foreach (var item in m_CheckpointValues){ if (item.Value is IEditableObject) ((IEditableObject)item.Value).CancelEdit();}
我们可以将item.Value保存在一个临时变量中。
foreach (var item in m_CheckpointValues){ object? value = item.Value; if (value is IEditableObject) ((IEditableObject)value).CancelEdit();}
对于这种情况,我们可以通过使用条件转换(as操作符)后面跟上一个条件方法调用(?.操作符)进一步简化它。
foreach (var item in m_CheckpointValues){ (item.Value as IEditableObject)?.CancelEdit();}
泛型和可空类型
如果你经常使用泛型,可能会遇到一个有问题的可空类型。看一下这个delegate:
public delegate void ValueChanged\u0026lt;in T\u0026gt;(T oldValue, T newValue);
这个delegate的预期设计是oldValue和newValue都可以为空。所以,你会认为加几个问号就可以解决问题。但是,这样做会返回下面这样的错误消息:
Error CS8627 可空类型参数必须是值类型或非可空的引用类型。可以考虑添加“class”、“struct”或类型约束。
如果你需要同时支持值类型和引用类型,那么这个问题就没那么容易解决。由于你无法在类型约束中表达“or”,你需要一个用于类的delegate和一个用于结构体的delegate。
public delegate void ValueChanged\u0026lt;in T\u0026gt;(T? oldValue, T? newValue) where T : class;public delegate void ValueChanged\u0026lt;T\u0026gt;(T? oldValue, T? newValue) where T : struct;
但是,这样不起作用,因为两个delegate具有相同的名称。你可以给它们起不一样的名称,但你必须复制使用它们的代码。
所幸的是,C#有一个转义值。你可以使用#nullable指令恢复成C #7的语义,这样就可以达到预期的效果。
#nullable disablepublic delegate void ValueChanged\u0026lt;in T\u0026gt;(T oldValue, T newValue);#nullable enable
这种方法并非没有缺陷。禁用可空引用可能是个好东西,但也可能什么都不是。你无法用它来让oldValue变成可空或让newValue变成不可空。
构造函数、反序列化器和初始化方法
对于下一个示例,你必须知道序列化器的一些技巧。有一个鲜为人知的函数用来绕过一个叫作FormatterServices.GetUninitializedObject的类构造函数。一些序列化器(如DataContractSerializer)使用它来提高性能。
如果你总是要运行构造函数中的逻辑,应该怎么办?这个时候需要用到OnDeserializing属性。这个属性充当在GetUninitializedObject之后调用的代理构造函数。
为了减少冗余和出错的可能性,开发人员通常会使用常见的初始化方法,如下面的代码所示。
protected AbstractModelBase(){ Initialize();} [OnDeserializing]void _ModelBase_OnDeserializing(StreamingContext context){ Initialize();}void Initialize(){ m_PropertyChangedEventManager = new PropertyChangedEventManager(this); m_Errors = new ErrorsDictionary();}
这对null检查器来说是个问题。由于构造函数中没有显式地设置上述两个变量,因此它会把它们标记为未初始化。这意味着需要进行一些复制粘贴工作来移除这个错误。
还有一个风险,那就是忘记包含OnDeserializing方法。由于null检查器不理解OnDeserializing方法,因此如果出现意外空值就无法提醒你。
大多数开发人员发现这种行为令人困惑。因此,在.NET Core中,DataContractSerializer将调用构造函数。但这意味着如果你的目标是.NET Standard,则需要使用.NET Framework和.NET Core测试反序列化代码,以理解不同的行为。
可空参数和CallerMemberName
这个库大量使用了CallerMemberName模式。根据它使用的属性命名,基本思想是在方法的末尾添加一个可选参数。编译器将看到CallerMemberName,并隐式地为该参数提供一个值。
public override bool IsDefined([CallerMemberName] string propertyName = null)
从理论上讲,propertyNameparameter可以显式设置为null,但人们普遍认为不应该这样做,因为这样可能会发生意外的错误。
将这行代码转换为C# 8时,可能会想要将参数标记为可空。这样具有误导性,因为这个方法实际上并不是为处理空值而设计的。相反,你应该用空字符串替换null。
public override bool IsDefined([CallerMemberName] string propertyName = \u0026quot;\u0026quot;)
还需要空参数检查吗?
如果要构建公共库(即NuGet),那么是的,所有公开方法仍然需要检查空参数。使用库的应用程序可能不一定会使用可空引用类型。事实上,他们甚至可能根本不使用C# 8。
如果你的所有应用程序代码都使用了可空引用类型,那么答案仍然是“可能是”。虽然从理论上讲,你不会看到任何意外的空值,但由于动态代码、反射或误用(!)操作符,它们仍然可能会出现。
结论
在一个只有不到60个类文件的项目中,其中24个类文件需要更改。但没有一个是特别重要的,整个过程花了不到一个小时。总之,这是一个无痛的过程,大多数事情都像预期的那样。我希望大多数项目都能从这个特性中获益,并且在C# 8发布后就应该使用这个特性。
关于作者
Jonathan Allen在90年代后期开始为一家医疗诊所做MIS项目,逐步将Access和Excel应用到企业解决方案中。在花了五年时间为金融行业编写自动化交易系统之后,他成为了多个项目的顾问,其中包括机器人仓库的UI、癌症研究软件的中间层,以及一家大型房地产保险公司对大数据的需求。在他的空闲时间,他喜欢学习和写作与16世纪武术相关的东西。
英文原文:https://www.infoq.com/articles/csharp-nullable-reference-case-study