本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将介绍的是空值的处理。
问题的由来
我们知道,程序中的大部分变量,要么分配在栈区要么分配在堆区。分配在堆区的变量,总是需要一个指针来管理。在C语言中,我们可以这么写:
struct MyStruct *my_struct = (struct MyStruct *)malloc(sizeof(struct MyStruct));
来在堆区创建一个变量,并使用一个指针来管理这块空间。在C语言中,正确使用的指针一般会有两种状态,一种是正常使用的,指向某块内存空间的指针,另一种是NULL
,也就是尚未指向某块内存空间的,或者内存空间不够导致malloc
申请失败后的指针。这种设计模式也是现在大多数编程语言所贯彻的。
使用一个指针,我们就是要对其解引用,也就是操作这块指针所指向的内存区域的变量。我们可以这样使用:
void foo(struct MyStruct *my_struct) {
// equivalent to int a = (*my_struct).a;
int a = my_struct->a;
}
也就是说,将一个指针解引用,然后获取它的某个字段的值。然而,如果我们传入的指针此时的状态并不是指向一块正常的内存区域,而是NULL
,也就是foo(NULL)
,会怎样呢?这会正常的编译通过,但运行的时候,会产生运行时错误。这是因为,操作系统对NULL
位置的内存有保护,禁止对NULL
解引用。因此,在C语言中,开发者常常在函数的注释中要求传入的不能是空指针,或者在函数中进行判断:
void foo(struct MyStruct *my_struct) {
if (my_struct != NULL) {
int a = my_struct->a;
}
}
但无论如何,这种会在运行时发生重大错误的情况还是会让开发者捏把汗。
空值安全
为了解决在运行时产生这种惊悚的问题,许多现代的编程语言,如Rust,Swift,Kotlin,JavaScript/TypeScript等,引入了空值安全,也就是尽量保证程序不会对NULL
进行解引用。由谁来保证呢?除了不靠谱的程序员,那只有编译器了。编译器怎样能保证空值不会被解引用呢?那么我们就要思考,编译器能知道什么。编译器知道最多的,就是类型的信息了。因此,我们应该从类型信息的角度考虑怎样保证空值安全。
现代能够保证空值安全的语言,大多数采用的都是可选值(optional)的策略,这个策略需要语言支持tagged union特性。在Rust,Swift,Kotlin中,是这样的(以Swift为例):
enum Optional<Wrapped> {
case some(Wrapped),
case none
}
在TypeScript中,也有类似的操作:
interface MyInterface { }
type OptionalMyInterface = MyInterface | null
其本质都是一样的,把空值看作一个单独的类型,而不像C语言一样,把空值和指针的有效值都作为指针类型可以拥有的值。
接下来,我都以Swift为例做代码的介绍,其他语言实际都是类似的。在Swift中,可选值Optional<SomeType>
可以简写成SomeType?
。
我们使用了可选值,为什么就能保证空值的安全呢?有了可选值之后,我们应该这样设计API:对于可能会产生空值的函数(计算错误、空间申请失败等),如somethingMayFail
函数,我们应该返回可选值:
func somethingMayFail() -> SomeType?
我们可以用简单的模式匹配来判断这个函数到底返回成功了还是返回了空值:
switch somethingMayFail() {
case .some(let normalValue): /* returns normal value */,
case .none: /* returns null value */
}
swift为此也提供了特殊的语法支持:
if let normalValue = somethingMayFail() {
// returns normal value
} else {
// returns null value
}
而我们写用于处理的函数时,也不用再考虑空值的问题:
func foo(normalValue: SomeType)
我们接收的,永远是有意义的值,而不会是空值。
有些情况下,如果不得不需要处理可选值,我们还可以使用可选链来保证不对NULL
解引用:
struct SomeType {
a: Int
}
var somethingMayNull: SomeType?
let nullableValue = somethingMayNull?.a
在类型为可选类型的变量后加?
,和Rust在Option
类型后加and_then
一样,从函数式的角度来看就是一个Monad,它的作用是,如果该值是空值,那么后面的语句都不计算了,直接返回空值;如果该值不是空值,那么将该值从Optional
中解包,并传入后面的语句中。因此,在这里nullableValue
的类型实际上就成了Int?
。
通过可选值,我们巧妙地在编译期保证了不对NULL
解引用,也就极大程度上保障了内存安全。