1. 为什么我专门为"空值"写一章:一次线上事故的复盘
1.1 事故经过:一个不起眼的null
前年冬天,我负责的一个内部推荐服务在凌晨两点挂了。当时值班的同学拉我起来看日志,报错信息特别简单,就一句话:Cannot read property of undefined。那一刻真的很沮丧,因为这不是什么高深的技术问题,就是一个对象为空,代码没做判断,直接调了它的方法。
具体链路是这样:推荐服务依赖上游的一个用户画像接口,我把返回的 JSON 解析成 DTO 对象以后,直接取了用户的昵称字段去拼推荐语。问题是,那个用户在配置页面里压根没填昵称,上游接口对这个字段返回的就是 null。而我解析之后,拿着这个 null 对象去调方法,程序运行到这里直接崩了,整条推荐链路一起瘫痪。
这种事故,在座的各位可能都遇到过。大部分人的第一反应是"我加个判空不就行了",但我想说的是:你这一次加了判空,下一次另一个接口的另一个字段照样给你返回 null,你照样会忘。问题的根子不在于你会不会判空,而在于你的类型系统根本没有把"这个值可能为空"这件事情告诉你。
1.2 本质原因:类型系统没有约束"空"这个状态
业内把 null 引用称为"十亿美元的错误",我觉得一点都不过分。在我用过的各种语言里,只要哪一天松懈了,空值问题就会从最意想不到的地方冒出来。你明明记得自己在入口处做了判空,结果中间经过一个工具函数,它转手返回了 null,后面的代码全崩了。
这里真正值得思考的问题是:类型系统的职责是什么?我的理解是,类型系统存在的意义,就是让计算机在程序运行之前帮我们做一次大规模的逻辑自检。比如你声明一个函数接收整数,传进去一个字符串,编译器直接报错,这样你根本不需要等程序跑起来才发现问题。
但传统类型系统有一个巨大的盲区:它默认所有对象类型的变量都可以是 null,又不告诉你哪些地方会真的出现 null。于是你写代码就变成了"猜",猜这个接口会不会返回 null,猜这个参数会不会是 null,猜错了就是线上事故,猜对了也只是运气好。所以这一章讲类型系统时,我把可空类型单独拎出来,不是小题大做,而是因为可空类型是类型系统从"能检查类型错误"进化到"能检查空值错误"的关键一步。
2. 类型系统的基本盘:静态与动态、强与弱的两条坐标轴
2.1 两条坐标轴,四个象限,别再把"动态语言"和"弱类型"混为一谈
很多人聊类型系统,喜欢直接把语言分为两类:静态类型和动态类型。但我个人的经验是,如果不把"静态/动态"和"强/弱"拆成两条坐标轴,很多语言特性根本说不清楚。
先厘清概念。静态类型指的是编译阶段就能确定变量的类型,比如我写int x = 10,编译器知道 x 是整数,后续你给它赋一个字符串,编译器会直接拒绝。动态类型则相反,变量的类型要到运行时才确定,Python 里我可以写x = 10,再写x = "hello",程序完全不会抗议。
而强/弱类型,说的是类型之间的转换是否允许隐式进行。强类型语言里,我很难把一个字符串直接当数字用,比如 Python 里"1" + 2会直接报错;弱类型语言就比较宽容,比如 JavaScript 里"1" + 2得到的是字符串"12"。
这两条坐标轴组合起来,就很有意思了:
| 维度 | 静态 + 强 | 动态 + 强 | 动态 + 弱 | 静态 + 弱 |
|---|---|---|---|---|
| 代表语言 | Java、Kotlin、C#、Swift | Python、Ruby | JavaScript | C、C++ |
| 类型检查时机 | 编译期 | 运行时 | 运行时 | 编译期 |
| 隐式类型转换 | 少 | 少 | 多 | 多 |
我见过不少同事把 Python 叫弱类型,其实 Python 是动态类型+强类型,它只是不用你写类型标注,不代表它对类型不严格。JavaScript 才是真的弱类型,"1" + 2这种表达式放在 Python 里早就报错了。
2.2 类型推导:现代语言给类型系统打的补丁
静态类型语言有个被吐槽很多年的点:写起来太啰嗦。声明一个 HashMap 要写一长串泛型,读代码的人还没看到业务逻辑,先被类型声明淹没了。
现代语言的解法是类型推导。C++ 的auto、C# 的var、Kotlin 的val/var、Go 的:=,核心思想都一样:编译器能自己算出来的类型,你就别写了。这不是把静态类型变成动态类型,而是把类型标注的负担从人转移到编译器。
我举个例子,在 Kotlin 里写:
val userMap = HashMap<String, String>()编译器自动推导出userMap的类型是HashMap<String, String>,后面你写userMap.put(123, "abc"),它照样会报错,因为第一个参数期望的是 String 而不是 Int。这就是类型推导的价值:写起来像动态语言一样省事,检查起来还是静态语言的严格程度。
这一小节之所以先铺垫类型推导,是因为可空类型同样依赖这种"编译器主动替你分析"的能力。没有类型推导做底子,可空类型写起来会非常痛苦——你每写一个变量都要在后面加个问号,代码会变得又长又难读。等后面你看到String?这种写法能像var一样自然地被推导出来,就能体会到语言设计环环相扣的感觉。
3. 可空类型的设计哲学:把"可能没有"变成类型的一部分
3.1 不可空类型是默认值:把错误挡在编译期
在设计可空类型之前,主流语言的处理方式非常粗暴:所有引用类型的变量,默认都可空。你声明一个String,它既可能是正常的字符串,也可能是 null,编译器完全不区分这两种情况。这就好比你的衣柜里只有一个隔层,干净的脏的全都塞一起,洗衣服的时候才发现问题。
可空类型革命性的地方在于:它把"可能没有"这个状态显式地编码进类型系统。还是以 Kotlin 为例:
var a: String = "hello" // 非空类型,绝不能赋 null var b: String? = null // 可空类型,允许 null这两行的区别是本质性的。a被声明为非空类型之后,如果你写a = null,编译器直接报错,根本轮不到运行阶段。而b虽然可以被赋成 null,但你在调用b.length之前,编译器会强制你处理空值的情况,最常见的写法是:
val len = b?.length ?: 0?.是安全调用:如果b是 null,整个表达式直接返回 null,不再继续调用length。?:是空值合并:左边是 null 的话,使用右边的默认值 0。
设计哲学的核心是:错误被发现得越早,修复成本越低。可空类型把空值错误从"运行时崩溃"提前到"编译期报错",这就是它最大的价值。
3.2 几种主流语言的可空类型落地对比
不同语言对可空类型的实现方式差别很大,我把自己实际用过的几种放在一起对比,方便大家看得更清楚:
| 语言 | 可空类型写法 | 非空类型是否默认 | 核心机制 |
|---|---|---|---|
| Kotlin | String? | 是 | 编译期强制检查,语法糖丰富 |
| C# | Nullable<T>/string? | 分情况 | 值类型用Nullable<T>,引用类型从 C# 8.0 开始支持可空标注 |
| TypeScript | string | null | 需要开启strictNullChecks | 通过联合类型表达可空 |
| Swift | String? | 是 | Optional本质是枚举 |
| Java | Optional<String> | 否 | 容器类型,非语言级支持 |
C# 的情况比较特殊。它在 C# 2.0 就引入了Nullable<T>,但那是给值类型用的。比如int?表示可空的整数,底层是一个泛型结构,HasValue属性告诉你到底有没有值。而引用类型一直到 C# 8.0 才引入可空引用类型,通过string?这样的标注告诉编译器"这个变量可能为 null",如果你在可能为 null 的变量上调用成员,编译器会给出警告。注意这里只是警告而不是错误,因为要兼容海量老代码,强推编译错误代价太大。
TypeScript 的设计思路也值得一提。它没有发明新的类型,而是利用联合类型把 null 变成一个显式的组成部分:string | null就是"要么是 string,要么是 null"。但这里的核心开关是strictNullChecks,不开这个选项,null 可以赋给任何类型,开了以后,null 就只能是 null 或联合类型里明确包含 null 的类型。这个开关建议所有新项目都开。
Swift 的 Optional 则走了一条更底层的路线。在 Swift 里,String?其实是Optional<String>的语法糖,而Optional本身是一个枚举,有两个 case:some(String)和none。这种设计让可空性成为一种可以被模式匹配的一等公民,语法层面反而更简洁。
Java 的Optional不算真正的语言级可空类型,它更像一个容器,用来包装可能为空的返回值,提醒调用者"这里可能没有值"。可惜的是它只解决了返回值的表达问题,参数、字段、局部变量照样可以随意赋 null,加上一些老框架不兼容,实用性打了不少折扣。
4. 空值处理的实战工具箱:从判null到Option模式
4.1 判空代码的演进:从if到语法糖
最早的判空代码长这样:
if (user != null && user.address != null && user.address.city != null) { console.log(user.address.city); }这种连续判空在业务代码里非常常见,而且写起来特别烦。你每访问一层属性都要担心这一层是不是 null,嵌套深层以后,代码的缩进越来越深,核心逻辑被淹没在一堆判空里。
语法糖的出现就是为了解决这个问题。Kotlin 的?.安全调用可以把上面的代码压缩成一行:
println(user?.address?.city)整个表达式在任何一个环节为 null 时都会短路,返回 null,不会再往下访问。TypeScript 也有类似的可选链操作符?.,JavaScript 从 ES2020 开始原生支持:
console.log(user?.address?.city);而空值合并运算符则负责处理"为 null 时用什么默认值"的问题。Kotlin 用?:,TypeScript / JS 用??:
const city = user?.address?.city ?? "未知城市";这里我特别提醒一点:??和||的区别。||判断的是 falsy 值,0、空字符串、NaN、false 都会被当成"没有",而??只判断 null 和 undefined。业务代码里如果有个字段是数字 0 或空字符串,用||会错误地把它们替换成默认值,用??才是正确选择。
4.2 三种处理模式的选型逻辑
语法糖解决的是书写问题,真正的设计问题是:面对一个可能为空的值,你到底该怎么处理?我总结出三种模式,分别对应不同的场景。
第一种:快速失败(Fail Fast)。如果这个值为 null 意味着系统处于异常状态,那最佳策略是尽早抛异常,不让错误扩散。比如一个缓存服务,从配置中心拿到的连接串为 null,这明显是配置错误,你应该直接抛异常让应用启动失败,而不是默默用默认值掩盖问题。
第二种:优雅降级(Fallback)。如果这个值为 null 是可以接受的业务场景,就给它一个合理的默认值。比如用户头像为空,显示默认头像;商品描述为空,显示"暂无介绍"。这种情况用?:/??就够了,不需要额外分支。
第三种:显式建模(Option / Result)。当空值本身携带业务含义,需要后续逻辑做不同处理时,用容器类型把它显式表达出来。比如 Java 里的Optional,你在一个方法返回Optional<User>时,调用者一眼就知道"这个用户可能不存在",他要么用orElse提供默认值,要么用ifPresent做有值时的处理,要么用map链式转换。相比直接返回User让调用者猜,Optional至少把"可能会没有"这件事写在了方法签名里。
函数式语言里还有一个空对象模式(Null Object Pattern)也很实用。与其返回 null 表示"没有",不如返回一个什么都不做的实现。比如日志系统里有个NullLogger,它的每个方法都是空实现,调用方拿到的始终是一个合法的 logger,完全不需要判空。这个模式的适用场景是:对象的语义可以定义"无操作"版本,并且调用方不关心具体实现。但要注意别滥用,如果"没有"这个状态需要被显式感知,空对象反而会掩盖问题。
5. 从老代码迁移到可空类型:我踩过的五个坑
5.1 坑一:外部接口边界,null 的来源
这是我在迁移一个老服务到严格可空类型时遇到的第一个坑。那个服务有几十个 RPC 接口,依赖内部的各种 SDK。我信心满满地给实体类加了可空标注,结果编译是过了,一跑起来全是新警告,为什么?因为上游 SDK 返回的对象,类型标注全都缺,编译器不知道它到底可不可空。
这种情况的处理原则是:把外部接口视为"不可信边界"。要么在服务入口做一次严格的校验和转换,要么用包装类把上游返回值包起来。我的做法是写了一个转换层,统一把上游返回的 DTO 映射到内部带可空标注的领域模型,宁可在这里多写几个判空,也不把不确定性扩散到业务代码深处。
5.2 坑二:泛型与可空类型的相互作用
泛型是可空类型最容易翻车的地方。比如List<String>在 Java 里只表示集合里装的是 String,但它不保证集合里没有 null 元素。你遍历的时候照样可能拿到 null。Kotlin 在这方面更严格,List<String>表示元素全部非空,List<String?>才表示元素可能为空。
但这里有个隐蔽的问题:泛型擦除。JVM 平台上的 Kotlin,编译期能检查非空约束,但到了字节码层面,类型信息已经擦除,反射和序列化时可能绕过编译期检查。我在一个序列化库的封装里就吃过亏:反序列化的框架不认识 Kotlin 的可空标注,直接把 null 塞进了声明为非空的字段里,编译期完全不报错,运行时才爆雷。解决方法是:在反序列化边界做手动校验,不要盲目相信编译器。
5.3 坑三:数据库字段与代码模型的同步问题
数据库的表结构有一个 NOT NULL 约束,但你的代码模型声明的是可空类型,这属于"代码比数据库更宽松"。反过来更危险:数据库字段允许为 NULL,代码却声明成非空,一旦真有 NULL 进来,反序列化时就出问题。
我处理这些问题的经验是专门建一个数据库类型映射清单,把每条记录的字段、数据库约束、代码类型列成表格,逐条核对。特别是老项目上线时间久,数据库里的脏数据比想象中多得多,某个字段你以为是 NOT NULL,结果历史数据里就躺着几条 NULL,一查一个准。
5.4 坑四:第三方库缺少可空标注
像 Java 生态里大量的老第三方库,方法参数和返回值都没有可空标注。在 Kotlin 里使用这些库的时候,情况比较特殊:编译器把这类未标注类型视为"平台类型",也就是编译期既不报错也不警告,可空性完全靠你自己把握。
这里我的处理原则是:把平台类型当成可空类型来对待。每次调用第三方库的方法,先去读文档或看源码,确认它会不会返回 null。源码看清楚之前,一律按可能为 null 处理,加上判空或使用安全调用。虽然代码会显得啰嗦,但安全永远是第一位的。
5.5 坑五:团队协作时"渐进式迁移"与编译警告的博弈
老代码库迁移到可空类型最大的阻力不是技术,是团队习惯。项目里有大量历史代码没做可空标注,编译器整天吐警告,有的团队成员选择无视警告,有的新人照着旧代码的模式写出了新的警告,结果开启了严格检查等于没开。
我的建议是搞渐进式迁移,别指望一次搞定。先把新写的代码全程要求可空标注,把编译警告数当成一个性能指标,每周盯一次,数值只降不升。代码评审的时候,把"调用方法前有没有处理可空性"作为必查项,比事后扫警告有效得多。我在某个团队推行了三个月,新代码里的空指针问题基本归零。
6. 最后说几句
这一章讲了类型系统的坐标轴、可空类型的设计哲学,以及实战里的几个处理模式。但说实话,纸面上讲再多,都不如你打开一个项目,把所有对象属性过一遍,想想哪些真的可能为 null,哪些只是理论上可空。我自己做完这个梳理之后,对代码的信心提升了一个档次,因为你终于不需要靠"记忆"来判断哪里可能会崩了,类型系统已经在编译期替你兜住了底。
如果你现在接手的是老项目,对空值问题感到头疼,我的建议是从一个模块开始试点:把它的外部接口边界、数据库模型、第三方库调用点全部列出来,逐一对齐可空标注。试点跑通以后,你会有一整套环境配置、代码规范、评审检查单可以直接复制到其他模块。等你母校的师弟师妹毕业了到我这个年纪,回头看这段迁移经历,大概也会觉得:提前花时间处理好空值,比在线上事故现场抢救代码,划算太多。