跳转到内容

第 6 章 · 空值:None、null、undefined、nil

解决什么问题:怎样表示“这里没有值”,又怎样不让程序在没有值的地方崩溃,或者把“没有”和“空”混为一谈。

四种语言的态度:Python 只有 None;JS 有 null 和 undefined 两种;C# 有 null 并在编译期追踪可空性;Go 让每个类型都有零值,只有少数类型能是 nil。

显示用户昵称。四个用户:alice 的昵称是“小艾”;bob 没设置昵称;carol 这个用户根本不存在;dave 的昵称被设成了空字符串。四种情况各显示一种提示。最容易出的错是把 dave 的空字符串和 bob 的“没有”当成一回事,或者把 carol 的“用户不存在”和 bob 的“没昵称”混在一起。

1965 年 Tony Hoare 设计 ALGOL W 时加入了空引用(null reference),只因为实现起来很容易。2009 年他在 QCon 伦敦大会上称之为“十亿美元的错误”(billion-dollar mistake):任何引用都可能为空,而类型上看不出来,忘了检查的代码要到运行时访问它才崩溃。

“没有值”有三种表示法。一是特殊值:拿一个专门的值(null、None、nil)表示没有,它能塞进任何引用类型的变量。二是零值(zero value):变量总有一个默认值,数字是 0、字符串是空串,代价是分不出“没设”和“设成了零”。三是可选类型(option type):把“可能没有”写进类型本身,Option<string> 和 string 是两个不同的类型,不先拆开检查就拿不到里面的值。

判定昵称属于哪种情况

图里从上往下要过三道关,每道关对应一种“没有”:用户不存在、昵称没设、昵称是空串。旁边标出了四种语言分别用什么来区分。

让空值变安全有两条路。编译期追踪:类型系统区分“可能为空”和“一定不空”,没检查就使用会被编译器指出。运行时提供安全访问运算符:a?.b 在 a 为空时直接得到空值而不是崩溃,a ?? b 在 a 为空时换成默认值。两条路可以同时走。

Python 只有一个空值 None,它是 NoneType 唯一的实例。可空性只能用 str | None 这样的标注写给类型检查器看,运行时不检查;也没有 ?. 和 ??(PEP 505 提过,至今是延后状态)。

from dataclasses import dataclass
@dataclass
class User:
nickname: str | None = None # 标注只给检查器看
users = {"alice": User("小艾"), "bob": User(), "dave": User("")}
def describe(name: str) -> str:
user = users.get(name) # 键不存在时返回 None
if user is None:
return "(用户不存在)"
if user.nickname is None:
return "(未设置昵称)"
if user.nickname == "": # 写成 `user.nickname or …` 会把 dave 也当成没设置
return "(昵称为空字符串)"
return user.nickname
for name in ["alice", "bob", "carol", "dave"]:
print(f"{name}: {describe(name)}")
# 输出:
# alice: 小艾
# bob: (未设置昵称)
# carol: (用户不存在)
# dave: (昵称为空字符串)

dict.get 在键不存在时返回 None,这里够用,因为字典里的值从不是 None;值本身可能是 None 时,要用 name in users 区分。判断空值用 is None,不用 == None,因为后者会调用对象自己的 __eq__。

底层怎么实现。 None 是解释器启动时就建好的单例(singleton)对象,所有“没有”都指向同一个地址,所以 is 比较只是比两个指针。3.12 起它是永生对象(immortal object),引用计数固定不变,增减计数不再写内存。在 None 上取属性抛 AttributeError,和取一个不存在的属性走的是同一条路。

  • x or "默认" 按真假判断,空串、0、空列表都会被换成默认值,dave 会显示成“未设置”。
  • 标注写了 str | None,不跑 mypy 或 pyright 就没人检查;漏掉的 None 照样在运行时抛 AttributeError。

JavaScript 有两个空值:undefined 表示“没有赋过值”,读不存在的属性、没传的参数都得到它;null 表示“有意设为空”。ES2020 加入了可选链 ?. 和空值合并 ??。TypeScript 打开 strictNullChecks(strict 包含它)后,null 和 undefined 不再能赋给 string,要写成 string | undefined。

interface User {
nickname?: string; // 可选属性:可以不存在
}
const users = new Map<string, User>([
["alice", { nickname: "小艾" }],
["bob", {}],
["dave", { nickname: "" }],
]);
function describe(name: string): string {
const user = users.get(name); // 类型是 User | undefined
if (user === undefined) return "(用户不存在)";
const nickname = user.nickname ?? "(未设置昵称)"; // ?? 只认 null 和 undefined
return nickname === "" ? "(昵称为空字符串)" : nickname; // 换成 || 时 dave 会被当成没设置
}
for (const name of ["alice", "bob", "carol", "dave"]) {
console.log(`${name}: ${describe(name)}`);
}
// 输出:
// alice: 小艾
// bob: (未设置昵称)
// carol: (用户不存在)
// dave: (昵称为空字符串)

users.get(name) 的返回类型自带 | undefined,不检查就读 user.nickname,编译器报错。检查之后,TS 的控制流分析(control flow analysis)把 user 收窄为 User。?. 能把几步合成一步:users.get(name)?.nickname 在用户不存在时得到 undefined,但这样 carol 和 bob 就分不开了,所以本例先单独判断用户。

底层怎么实现。 strictNullChecks 只存在于编译期,生成的 JS 里没有任何检查。?. 和 ?? 是运行时语法:目标低于 ES2020 时,TS 把 a?.b 改写成”a 等于 null 或 undefined 就得到 undefined,否则取 a.b“的条件表达式。=== null 和 === undefined 本质上是比较地址。

  • || 和 ?? 混用:port || 8080 把 0 换成 8080,name || "匿名" 把空串换掉;只想替换空值时要写 ??。
  • 属性不存在和属性值是 undefined 读出来一样,都是 undefined;要区分得用 "nickname" in user 或 Object.hasOwn(user, "nickname")。
  • typeof null 是 "object"(来历第 3 章讲过);判断对象时要先排除 null。

C# 分两类处理。值类型(value type,如 int、结构体)本来不能为 null,要写成 int?,即 Nullable<int>。引用类型一直可以为 null;C# 8(2019 年)加入可空引用类型(nullable reference types),string 表示不该为空,string? 表示可能为空,编译器据此分析并发出警告。新项目模板和 dotnet run x.cs 默认打开这项检查。运算符有 ?.、??、??=,以及抑制警告的 !。

using System;
using System.Collections.Generic;
var users = new Dictionary<string, User>
{
["alice"] = new User("小艾"),
["bob"] = new User(null),
["dave"] = new User(""),
};
string Describe(string name)
{
if (!users.TryGetValue(name, out var user)) return "(用户不存在)";
string nickname = user.Nickname ?? "(未设置昵称)"; // 去掉 ?? 直接赋值,编译器警告 CS8600
return nickname == "" ? "(昵称为空字符串)" : nickname;
}
foreach (var name in new[] { "alice", "bob", "carol", "dave" })
{
Console.WriteLine($"{name}: {Describe(name)}");
}
record User(string? Nickname);
// 输出:
// alice: 小艾
// bob: (未设置昵称)
// carol: (用户不存在)
// dave: (昵称为空字符串)

TryGetValue 用返回值说明键在不在,用 out 参数交出值,“不存在”和“值为 null”天然分开。Nickname 声明为 string?,直接赋给 string 会得到警告。

底层怎么实现。 两种可空走完全不同的路。Nullable<T> 是真实的结构体,里面是一个 HasValue 标志加一个 T 值,int? 比 int 多占空间;装箱(boxing)时,没有值的 int? 变成 null 引用,有值的只装箱里面的 int。可空引用类型则只是编译器的流分析(flow analysis):string? 和 string 在运行时是同一个类型,可空信息以特性(attribute)的形式记在元数据里,供编译器和别的程序集读取,运行时不检查。

  • ! 只骗过编译器:user.Nickname!.Length 不生成任何检查代码,值真是 null 时照样抛 NullReferenceException。
  • 可空检查只出警告,老项目或关掉检查的项目里 string 照样可以是 null;来自反射、反序列化的值编译器也看不见。
  • 数组和 default 绕过分析:new string[3] 的三个元素都是 null,结构体用 default 创建时,声明为不可空 string 的字段也是 null,编译器都不警告。

Go 的核心是零值:每个变量声明后都有可用的默认值,string 是空串,int 是 0,结构体是各字段的零值。只有指针、映射、切片、通道、函数和接口的零值是 nil。Go 没有 ?. 和 ??,也不在编译期追踪 nil。要表示“没设置”,常见做法是用指针,nil 表示没有,指向空串表示设成了空串。

package main
import "fmt"
type User struct {
Nickname *string // nil 表示没设置;指向 "" 表示设成了空串
}
func ptr(s string) *string { return &s }
var users = map[string]User{
"alice": {Nickname: ptr("小艾")},
"bob": {},
"dave": {Nickname: ptr("")},
}
func describe(name string) string {
user, ok := users[name] // comma ok:区分"键不存在"和"值是零值"
if !ok {
return "(用户不存在)"
}
if user.Nickname == nil {
return "(未设置昵称)"
}
if *user.Nickname == "" {
return "(昵称为空字符串)"
}
return *user.Nickname
}
func main() {
for _, name := range []string{"alice", "bob", "carol", "dave"} {
fmt.Printf("%s: %s\n", name, describe(name))
}
}
// 输出:
// alice: 小艾
// bob: (未设置昵称)
// carol: (用户不存在)
// dave: (昵称为空字符串)

users["carol"] 不会出错,而是返回 User 的零值,和 bob 一模一样;只有 user, ok := users[name] 的第二个返回值能说明键在不在。这种“comma ok”写法也用于类型断言和从通道接收。

底层怎么实现。 零值靠内存清零实现:新分配的内存一律置 0,而每个类型的零值恰好就是全 0 的位模式,所以零值不花额外代价。nil 指针是地址 0;解引用时程序访问 0 号页,操作系统报告非法访问,运行时把它转成 nil pointer dereference 的 panic。接口值在内存里是两个字:动态类型和数据指针,类型这一格为空时接口才等于 nil;装着 nil 指针时类型一格不空,所以不等于 nil。

  • 读 nil 映射返回零值,写入却会 panic:var m map[string]int; m["a"] = 1 报 assignment to entry in nil map,要先 make。nil 切片则可以直接 append。
  • 装着 nil 指针的接口不等于 nil:函数返回 error 时写 var e *MyErr; return e,接口里类型一格是 *MyErr,调用方的 err != nil 为真。
  • 零值分不出“没设”:配置里 Port int 为 0,可能是没写,也可能是写了 0;要区分就得用指针或额外的布尔字段。
维度 Python JS/TS C# Go
支持程度 原生空值,可空标注靠外部检查器 原生两种空值,TS 编译期检查 原生,值类型和引用类型分开 原生零值和 nil
实现机制 单例对象 None 特殊对象 + 编译期类型收窄 Nullable<T> 结构体 + 编译器流分析 内存清零 + 地址 0
表示“没有”的值 None undefined、null null 零值、nil
编译期是否追踪 否(mypy、pyright 可查) TS 的 strictNullChecks 是,只出警告 否
安全访问运算符 没有 ?. ?. 没有
默认值运算符 没有,or 按真假 ?? ??、??= 没有
区分“不存在”和“为零” in、is None Map.has、in、Object.hasOwn TryGetValue v, ok := m[k]

差别最大的是“表示’没有’的值”一行:前三种语言有专门的空值,Go 主要靠零值,所以“区分不存在和为零”那一行在 Go 里是每天都要写的代码。

JavaScript 的两种空值是历史遗留。Brendan Eich 1995 年仿照 Java 加了 null,常见的说法是,早期 JavaScript 没有异常机制,读不到的东西需要一个不报错的结果,于是又另设了 undefined 表示“没有赋过值”。两者从此并存,== 认为它们相等、=== 不认为。TypeScript 改变不了运行时,只能把两者都纳入类型。

Go 用零值换来“变量总有可用的值”:不存在未初始化的变量,var buf bytes.Buffer 声明完就能用,标准库很多类型都按“零值可用”设计。代价是零值本身也是合法数据,分不出“没设”和“设成零”,于是 comma ok 和指针字段成了日常写法;而少数可为 nil 的类型又带回了空值的老问题,nil 映射和接口里的 nil 指针都只在运行时出事。

C# 的引用类型从 2002 年第一版起就可以为 null。到 2019 年 C# 8 补上可空引用类型时,已有大量代码依赖 string 可以为空;如果把它做成运行时检查或编译错误,老代码就会无法编译或行为改变。所以它只能做成可选开启、只出警告的编译期分析,运行时类型不变。这让迁移可以逐个文件进行,代价是保证只到编译器看得见的范围。

Python 选择不在语言里区分可空性,和它整体不做静态检查一致;可空性和其他类型信息一样交给外部检查器。

Rust 没有空值。可能没有的值是 Option<T>,要么是 Some(值),要么是 None,必须用 match、if let 或 ? 拆开才能用里面的值,忘了处理 None 编译不过;Option<&T> 编译后仍是一个指针,None 用地址 0 表示,不多占空间。

Kotlin 把可空性写进类型:String 不能为空,String? 可以,没检查就调用方法是编译错误,?. 和 ?: 提供安全访问和默认值。

  • 只想替换空值时用 ??(TS、C#)或显式的 is None、== nil;or、|| 会把空串和 0 一起换掉。
  • 从映射里取值时用能区分“不存在”的写法:Python 的 in,TS 的 Map.has,C# 的 TryGetValue,Go 的 comma ok。
  • TS 一定打开 strict;C# 项目保持可空检查开启,把 ! 当成需要说明理由的例外。
  • Go 里设计结构体时尽量让零值可用;必须区分“没设”时,才用指针字段。

用 TS 和 Go 各写一个函数,从“服务名 → 端口号”的映射里取端口,映射里有 web 为 8080、test 为 0。依次查 web、test、db,输出:

web: 端口 8080
test: 端口 0
db: 未配置端口

提示:TS 里 ports.get(name) || 0 为什么不行?Go 里 ports["db"] 返回什么,怎样知道它其实不存在?

  • “没有值”有三种表示:特殊值(None、null、nil)、零值、可选类型;Hoare 称空引用是“十亿美元的错误”。
  • 空值安全有两条路:编译期追踪可空性(TS 的 strictNullChecks、C# 的可空引用类型),运行时安全访问运算符(?.、??)。
  • C# 的 int? 是真实的结构体,string? 只是编译器的注解;TS 的检查同样只在编译期。
  • Go 的零值让变量总有可用的值,代价是要用 comma ok 或指针区分“不存在”和“为零”。
  • 空串、0 不是“没有”:or、|| 按真假判断,会把它们当成没有。