作为C#程序员,肯定对“未将对象引用设置到对象的实例”这样的报错很熟悉,每个人(不是几乎)都肯定遇到过这个异常,而很多时候,解决方案也非常简单,在访问这个对象之前加一个“是否为空”的检查,类似
invoice.payment() //忘记检查invoice是否为空了
//解决方案也就是在访问成员前加上检查
if(invoice == null) {
//notice, exception, return, etc
}
invoice.payment()
Null的两种语义
出现null
通常有两种情况:
- 函数签名规定了返回值,但是没有成功计算出这个返回值,我们想通过返回
null
告诉调用者,我做不到。所以也是期盼调用者来检查。 - 本来是想返回一个值类型,比如
decimal CaculatePrice()
函数签名要求返回的是值类型,但是计算中出错了,比如调用第三方API出错了,返回任何值都不是非常合适,那么我就想明确返回一个null
来告诉调用者,出异常
了。通常做法也是使用Nullable<T>
来修改签名为decimal? CaculatePrice()
对于第二种情况,正确的做法是使用异常(Exception),这也正是BCL常见的做法,比如在看MSDN上的某个函数文档的时候你会发现专门有一节就是Exceptions,用来列出可能会出现的异常,比如下面是WebClient.DownloadString
的Exceptions文档内存:
这个表示当你调用DownloadString
方法的时候你需要显示检查着三种异常。但是异常是很重的装备,没有多少人会想自己手动这么细致的处理异常,也就是导致了:要么我们不检查异常,要么我们try-catch-all
来捕获所有的异常。
值类型和引用类型
- 值类型有默认值,比如
int
的默认值是0
- 值类型可以通过
Nullable<T>
来修饰可空值类型,这样可以设置为null
,比如int?
- 引用类型默认是
null
CSharp 8.0 可空值类型(Nullable Reference Type)
在C#的8.0版本中设计者统一了值类型和引用类型的可空处理:
- 值类型,默认不能为
null
,可以通过?
修饰类变为可空类型。 - 引用类型,默认不能为
null
,可以通过?
修饰类变为可空类型。
这相当于一个Breaking changes,所以默认的Visual Studio中的C#静态分析器会将可能是引用类型可能为null
的标记为warning
,可以在设置中打开和关闭这样的warning
。
最终达到的目标就是你在程序中检查了可能为null的地方,直到你显示(explict)地标记一个引用类型可能为null
。相当于说:编译器和分析器你别管了,风险我来担。而不是“偷偷”地将这些风险推延到运行时触发:未将对象引用设置到对象的实例。
为什么在报空异常的时候难以调试
当发生空异常的时候,我们希望知道是哪一行代码,哪个对象导致的NRE(Null Reference Exception)空异常,但是在生产环境中,我们只能得到调用栈信息,而没有更多的上下文给我们判断到底是哪个对象导致的空引用异常。如果导致异常的方法特别大,我们其实很难判断到底是哪里出现了异常。
为什么出现空异常没有给我们更到的调式信息?
- 空引用异常是一个硬件异常(trap)
- 有CPU在执行指令时候发现访问的内存在64K一下地址,这个地址不可以访问,导致AccessViolation
- 粗略的过程:AccessViolation(CPU) -> CLR -> at this point is very little context for the exception
为什么编译器不在访问前插入指令来防止这样的异常? 理论上是可以的,但是这会导致生成低效的代码,编译器在这里确实可以给到帮助,比如C# 8.0的可空类型就是帮助我们在编译期间找到这些可能出现的空引用异常。
项目中我们怎么防止空引用异常? 我个人推荐的还是显式检查异常,当然你可以打开C# 8.0的可空类型检查,但是这样的方式在实践中会出现:
- 觉得太繁琐,大量使用
!
来让编译器忽略这些空引用异常的警告 - 警告太多了,而自动忽略了这些警告
显式空引用检查,可以使用下面一些类库:
- BCL
System.Diagnostics.Trace.Assert(result != null);
- CommunityToolkit.Diagnostics
Guard.IsNotNull()
- https://github.com/ardalis/GuardClauses
Guard.Against.Null(order, nameof(order));
Comments: