烦人的 null
经常写程序的童鞋一定会遇到 null Exception
,但是大部分情况下出现 null Exception
就意味着错误地使用了 null
null 是什么
null 被设计为用于表达应用类型的缺失。可是不得不说这个设计是失败的。因为 null 用起来方便了所以很多地方都用 null 而不是异常 (Exception) 。
通常程序出错都是与 null 相关的 null Exception
。这是自然的,如果数据正常,也就是输入是正确的,自然就不会有问题。总不可能要求数据时时刻刻都是正确的吧?
再谈到这个问题时,先看一下另一种类型—— 值类型 是怎么解决这个问题的。
值类型从来都不会出错。因为,很简单,值类型没有 null 的情况。 值类型拥有默认值。比如 int
的默认值是 0 ,所以程序上只会出逻辑问题,而不是运行时错误(异常)。值类型出错最常见的场景是,这个值怎么会是0啊?哦,原来是这个 int
类型没有初始化。值类型没有 null 所以不会去做空检查(null Check)。比如值类型 bool
只有 true
和 false
,所以使用 bool
类型的时候通常是这样的。
1 | bool isFinish = IsDownloadFinish(); |
而不是
1 | bool isFinish = IsDownloadFinish(); |
这种画蛇添足的做法。反之如果引用类型不做空检查就直接使用值,出问题程序很可能直接崩溃。
1 | List<int> studentNos = GetStudentNos(); |
null is nothing but everything
null 就像是病毒,能够躲过类型检查。一旦某个地方使用了 null 它就像病毒一样扩散开来。最后整个程序不得不充满空检查。
null 是二义的。它即可表示变量没有初始化,也可以表示值不存在。
null 什么都不是,它既不是 List<T>
类型,也不是 String
类型。甚至没有一个类型是 null。
null 又什么都是,它可以充当 List<T>
,也可以充当 String
类型。比如从字符串数组中查找以特定的字符串结尾时。
1 | String FindStudent(String[] Students,char c) |
这个函数定义的最后返回的 null 是最常见的一种误用 null 的情况。而这个函数可以编译通过,代表着编译器认可 null 是 String
类型。
事实上并不是只有 String
类型会是这样,几乎所有的引用类型编译器都允许返回 null。 所以在使用数据的时候必须做空检查,因为不知道这个数据是否为空。这看起来是很正常的事情,毕竟输入有时候就是错的。但是问题的关键不在于不允许错误的输入,而在于使用了错误的方式来表达数据的异常。
没有 null 的日子
null 看起来是现代编程语言的标配。其实不然,有不少语言是没有 null 的。比如以纯函数式编程而著称 Haskell
是没有 null 的。那么 Haskell
是怎么应对数据异常,或者说怎么表达数据不存在这种情况的呢?
联合类型 (Union Type)
这个联合类型指的不是C语言里面的Union类型。而是由多种类型组成的类型。在传统的编程语言类型是互斥的。例如一个变量要么是 String
类型要么是 int
类型。绝对不会出现即使 String
类型又是 int
类型的情况。
在 Haskell
中引入一种联合类型 Maybe
用于表达变量可能有值,也可能没有值。
因为缺少对值缺失的表达方式,在使用 int
类型的时候通常使用 -1 来表示数据异常也就是值缺失的情况。在值不会取到 -1 情况下这是没问题的。万一值可以取到 -1 呢?无计可施。
在 Haskell
中 Maybe
的 Just
类型用于表示有某种值类型, None
用于表示没有值类型。
1 | Maybe a = get a |
看起来和使用 null 表示值缺失的情况并没有什么区别。实际上天差地别。 Maybe
中的 None
是有类型的,类型就是 None
。而 null 却不是。null 是无类型的,它的类型不是 null。最重要的是,使用 Maybe
类型编译器会强制你去检查两种类型,迫使你去应对各种情况。换言之,使用 Maybe
检查和取值是原子操作,不可能取值而不检查。而 null 不是。编译器允许返回 null ,而且允许在使用的时候不检查即可取值。没错说了这么多,这就是 null 的万恶之源。理论上只要每次使用引用类型时做空检查就不会出现程序崩溃的问题。但是懒惰是人的天性,明明可以直接使用为什么还要做复杂的空检查。比如
1 | Class Student |
那么直接使用值不做空检查。等到有问题直接报异常就可以了。为什么不这么做呢。首先可能有 null 出现的地方直接使用,出现异常,而忽略是一种很被动的编程方式。相当于知道了问题的存在但是我不管,问题就是这样,爱咋咋地。其次在移动平台上异常如果没有被捕获(Catch),整个应用很可能就直接崩溃了。就会上演经典的找Bug场景:找了半天的Bug,原来是这个地方为 null。如果一开始检查 null 然后做出应对,比如强制退出,或者界面提示错误码,又或者继续运行。都不会增加无意义的调试时间,用户的体验也更好。
解决方案
解决方案很简单:避免 null 或 合理地使用 null
避免 null
无论是 Haskell
的 Maybe
还是 Java8
的 Optional
在其他语言都有相应的实现。选择喜欢的即可。但是请一定要检查和取值一起操作
1 | // Good |
后者的做法跟直接空检查后再使用没有什么区别。
合理地使用 null
合理地使用 null 从合理地返回 null 开始
null 表达的是一种值缺失的情况而且值缺失是很正常的情况。 比如从列表中查找某些元素,找不到是很正常的情况,所以这可以返回 null。又比如获得人物的性别,如果文件读写出错了,应该抛出异常,而不是返回 null。因为出错了就不是一种正常的情况。 null 只应该承担正常的值缺失的情况,出错了就应该用异常去承担。
合理地使用 null 还需合理地初始化引用类型。
有些编程语言中引用类型的默认值是 null ,其实在业务逻辑中可以做的更好。比如 String
类型可以初始化为空字符串 ""
,List<T>
类型可以初始化为空列表 new List<T>()
。在业务逻辑允许的情况下,函数出错时可以返回空字符串或者空列表。这样就不必做空检查了。搭配 Foreach
使用,如果是空列表,foreach
里面的逻辑不会运行,也就不会出错。初始化时没有引入 null ,调用函数时也没有引入 null ,自然就不会出现 null 的情况。
尽可能消除 null 避免 null 的传播。
比如查找一个人的地址,认为找不到是可接受的,就返回一个空字符串,如果认为找不到是不可接受的,请直接抛出异常。这个时候也不会返回 null。 自然不会产生 null 也不用做 null Check
(但是要捕捉异常) 。
除此之外还有些小技巧可以用上。
如果你使用面向对象编程,可以引用某种类型相应的 null 类型。
1 | Class Student |
如果你有福能使用C# 6.0
,你可以使用新的操作符 ?.
。这个操作符只会在不是 null 的情况下执行。
1 | //Before C# 6.0 |
但是请注意,在第一次使用某个值得时候最后手动做空检查,如果为 null 就报错(出现这种情况其实应该使用异常)。这样方便调试。之后第二次使用某个值时就直接使用 ?.
吧。
最后吐槽
null 真是一个烦人的小妖精。