1.1 十亿美元错误与Kotlin登场


1.1 十亿美元错误与 Kotlin 登场

本节摘要:null 引用由 Tony Hoare 在 1965 年引入语言设计,他后来称之为"十亿美元错误"。Java 继承了这一设计且没有任何类型层面的约束,安卓时代海量崩溃由此而来。本节讲清 NPE 的语言设计根源、它在安卓上的典型事故形态,以及 Kotlin 用"可空性进入类型系统"这一根本决策回应事故的思路。

先看一份事故报告

任何一个做过线上安卓应用的人,对下面这种崩溃堆栈都不会陌生:

java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String android.location.Address.getLocality' on a null object reference at com.example.app.profile.LocationBadge.render(LocationBadge.java:87) at com.example.app.profile.ProfileActivity.onResume(ProfileActivity.java:210)

这行 Java 代码大概长这样:

String city = geocoder.getFromLocation(lat, lng, 1).get(0).getLocality();

四个链式调用,任何一环返回 null 都直接崩溃——getFromLocation 在地址解析失败时返回 null 或空列表,这是 Android SDK 文档里写了、但编译器完全不管的运行时约定。崩溃率统计平台上,这类崩溃常年前列。Google 自己在 2018 年的开发者大会上给过一组数字:安卓应用崩溃里相当比例与 null 直接相关,而其中大部分发生在"开发者以为不可能为 null"的链路上。

把责任归给"写代码的人不小心"是轻松的,也是无用的。当一个事故在几十年的时间里、在几百万开发者身上反复发生,答案只能是:工具的设计留了陷阱。要理解这个陷阱,得回到 null 进入类型系统的那天。

事故的源头:1965 年的那个决定

1965 年,图灵奖得主 Tony Hoare 在设计 ALGOL W 的引用类型时,做了一个"实现起来最容易"的决定:任何引用类型的变量都可以指向 null。他自己后来在 2009 年的公开演讲里把它称为"十亿美元错误"(null reference被他自己称作 my billion-dollar mistake)——因为此后四十多年里,它造成的崩溃、漏洞和经济损失难以计数。

null 的问题不在"空"这个概念本身——"暂时没有值"是真实业务里必然存在的状态——问题在于它不受类型系统约束。一个声明为 String 的 Java 变量,类型告诉你"这里面是字符串",但运行时它完全可能是 null。类型的承诺是假的:

String name = findUser(id).getName(); // 类型说是 String int len = name.length(); // 运行时炸出 NPE

更麻烦的是 null 的传染性:一旦一个函数可能返回 null,所有调用它的代码都必须"猜"这件事,而编译器不给任何提示。防御式写法层层叠叠:

User user = findUser(id); if (user != null) { Profile p = user.getProfile(); if (p != null) { Address a = p.getAddress(); if (a != null) { city = a.getCity(); } } }

四层嵌套只为取一个城市名。这还算是好的——更多的时候,开发者懒得写全检查,或者漏判了中间某一环。null 检查在 Java 里是道德义务,而道德义务从来挡不住事故

安卓把这件事放大了一百倍

服务端 Java 遇到 NPE,一个请求 500,网关重试,日志报警,修了就完。安卓没有这个缓冲:

  • 崩溃直接到达用户。NPE 发生在主线程就是应用闪退,用户看到的可能是白屏一闪然后回到桌面,连"出了什么错"都不知道;
  • 生命周期放大了空态。Activity 与 Fragment 的生命周期回调多达七八个,视图在 onDestroy 后置空、异步回调晚于页面销毁到达,这些"时间上的空窗"在服务端没有对应物;
  • 系统 API 大量返回 nullgetExternalFilesDirgetSystemService 的各种返回、Intent 附带的 Extra,SDK 文档里散落着"可能为 null"的约定,全靠人肉记忆;
  • 碎片化设备加剧偶现。同一份代码,在内存紧张的低端机上 onDestroy 更早触发、后台回调更晚到达,NPE 变成"测试不复现、线上偶现"的悬案。

Kotlin 的回应:把可空性写进类型

2010 年前后,JetBrains 内部面对一个具体问题:他们写了几十万行 Java,天天被 NPE 和样板代码消耗。评估了 Scala 等替代品后(太重、编译慢),决定自研一门"编译到 JVM、与 Java 完全互操作、把常见事故在编译期消灭"的语言。2011 年公开,2016 年发布 1.0,设计目标清单的第一条就是空安全。

Kotlin 的根本决策只有一句话:可空性成为类型的一部分

val name: String = "阿禾" // 类型 String,承诺永不为 null val nickname: String? = null // 类型 String?,明确可能为 null

StringString? 是两个不同的类型。后者的成员不能直接访问——必须先"消解"可空性:

val len = nickname.length // 编译错误:nickname 可能为 null val len2 = nickname?.length // 安全调用:null 时整个表达式为 null val len3 = nickname?.length ?: 0 // Elvis:null 时取默认值 0

于是防御变成了语法义务而不是道德义务。漏写处理,编译器直接拒绝出包——事故从线上运行时挪到了本地编译期。这不是"更好的 code review",是类型系统的数学保证:在纯 Kotlin 代码里,String 类型的值不可能为 null,编译器在字节码层插入了 Intrinsics.checkNotNull 断言兜底互操作场景。

Google 的两次官宣

语言再好,也需要平台背书。安卓生态对 Kotlin 的接纳分两步:

  • 2017 年 5 月,Google I/O 宣布 Kotlin 成为安卓官方支持语言。当天 Android Studio 3.0 预览版内置 Kotlin 插件,开发者当晚就能在现有工程里新建 Kotlin 文件;
  • 2019 年 5 月,Google 进一步宣布 Kotlin 优先:此后新的 Jetpack API 将以 Kotlin 为主,官方示例、文档、Codelab 默认 Kotlin。协程在 2019 年进入 kotlinx 官方序列,2021 年后 Google 的架构指南直接以协程与 Flow 为默认异步方案。

JetBrains 侧的演进同样关键:2018 年 Kotlin/Native 与多平台立项,2023 年 Kotlin 2.0 换装 K2 编译器,编译速度大幅提升。但对安卓开发者而言,真正的转折点是 2019——从那以后,"要不要用 Kotlin"在安卓上不再是问题,问题只剩"怎么用好"。

这门语言到底带来了什么

回到本册的主线。Kotlin 对 Java 安卓开发的改造,可以归纳成五类"事故—机制"配对:

Java 时代的事故 Kotlin 的机制 生效阶段
NPE 崩溃 可空类型系统 编译期
并发修改、竞态 val 默认、只读集合接口 编译期收缩
新增分支漏处理 密封类加 when 穷举 编译期
回调地狱、忘记取消 协程结构化并发 结构强制
工具类泛滥 扩展函数、作用域函数 组织性防御

值得强调的是最后一列的三个词。Kotlin 不是魔法,它的安全性来自把运行时约定提升为编译期契约——这正是不安全消除术与"多写点判空"的区别。判空是运行时打补丁,类型是结构上免疫。

也要提前泼一盆冷水:Kotlin 消灭的是"纯 Kotlin 世界里的 NPE"。真实工程里还有大量 Java 依赖、还有平台类型这个"法外之地",还有 !! 这种自毁式操作符。第 2 章讲完兵器谱之后,第 7 章会专门处理边界问题。安全消除术的第一课就是:知道防线的位置,和知道防线的缺口一样重要。

动手验证一下

如果你已经在用 Android Studio,可以做这个五分钟实验,直观感受"编译期拦截":新建一个 Kotlin 文件,写一个返回 String? 的函数,然后直接对结果调用 .length。IDE 会立刻在调用处标红,提示"只有 String? 的安全或非空断言调用才允许"。把鼠标悬停看完整错误信息,再依次换用 ?.?:let 三种方式消解——这三种消解方式正是第 2 章的主角。

Java 侧做对照:同样的逻辑,javac 只会默默编译通过,然后在某次线上运行时把崩溃堆栈发给你。

候选语言里为什么是 Kotlin

"在 JVM 上消灭 Java 的坑"这个需求,Kotlin 不是第一个应征者。Scala 早在 2004 年就给出了函数式与空安全的答卷,为什么 JetBrains 不直接用它?他们公开过的理由很务实:Scala 编译慢、工具链支持弱、与 Java 混用的心智负担重——而 JetBrains 是一家靠 IDE 用户体验吃饭的公司,一门"IDE 难以支持"的语言从一开始就不在候选名单前列。Kotlin 的设计约束由此定型:编译速度向 Java 看齐、任何 Java 代码不做修改即可调用、IDE 支持是第一公民。这三条约束解释了后续章节里很多"为什么这样设计"的取舍——比如扩展函数选择静态解析而不是动态派发(第 6 章),比如可空性用类型系统而不是 Option 包装容器实现(不引入分配开销,第 2 章),比如协程不做语言级关键字而走库实现(保持字节码兼容,第 5 章)。

也正因为"与 Java 无缝互操作"排第一位,Kotlin 的空安全承诺才留了口子——Java 侧没有可空性信息,Kotlin 只能把这些值标记为"平台类型"放行,第 7 章会看到这个缺口的完整形态与堵法。设计从来是取舍的总和,理解约束比背特性更接近这门语言的内核。

一张时间线收拢来龙去脉

年份 事件 对安卓开发者的意义
1965 null 引用进入 ALGOL W 事故的制度性起点
1995 Java 发布,引用类型全可空 陷阱随 JVM 生态扩散
2008 Android 1.0,Java 唯一官方语言 生命周期与设备碎片化放大 NPE
2011 JetBrains 公开 Kotlin 项目 立项目标即"编译期消灭 Java 常见事故"
2016 Kotlin 1.0 发布,语言与标准库稳定 生产可用承诺
2017 Google I/O 宣布安卓官方支持 当晚即可在工程混入 Kotlin 文件
2019 Kotlin 优先政策,协程转正 新 Jetpack API 以 Kotlin 为主
2023 Kotlin 2.0 与 K2 编译器 编译速度与新语言特性基座

注意 2017 与 2019 之间的差别:前者是"允许",后者是"优先"。真正改变生态的是后者——官方文档、示例代码、架构组件从那时起默认 Kotlin,新入行的安卓工程师第一次接触的语言就是它。今天再讨论"要不要学",答案已经写在招聘要求里;值得讨论的只剩"学到什么程度",本册的答案贯穿始终:学到能说清每个机制防住了哪类事故为止

常见问题

问:Kotlin 的空安全是不是意味着写安卓就再也不会遇到空指针了?
不是,而且这个预期本身就是危险的。纯 Kotlin 代码里的 NPE 确实被压缩到极少数场景(泛型擦除的边缘情况、this 泄漏的构造期访问),但真实工程的依赖里充满 Java 库与安卓 SDK,它们返回的值不带可空性信息,Kotlin 把这些值放行为平台类型——空安全防线在边界上是有缺口的。此外 !! 非空断言是留给开发者的自毁开关,写下去的瞬间类型系统就不再保护你。正确的预期是:NPE 从"最常见的崩溃原因"降级为"边界问题",而边界问题是可以集中管理、集中审查的。

问:已经在用 Java 写了五年安卓,值得花精力换 Kotlin 吗?
从招聘市场与官方生态看已经不存在"要不要"的问题——Jetpack 新组件、官方架构示例、社区库的文档都默认 Kotlin。真正的问题是切换成本,而这恰是 Kotlin 设计约束里最照顾的一点:Java 与 Kotlin 可以在同一工程、同一包里混编,转换工具能把单个 Java 文件一键转为 Kotlin(虽然转出的第一版往往是"Java 味的 Kotlin",需要手工精化)。第 7 章给出了按模块渐进迁移的完整路线。

本节要点回顾

  • null 是设计事故:Tony Hoare 1965 年把无约束的 null 引入类型系统,自评为十亿美元错误;
  • Java 类型对 null 不设防String 类型实际可能是 null,类型承诺失真,判空成为道德义务;
  • 安卓放大了事故:崩溃直达用户、生命周期空窗、SDK 大量可空返回、设备碎片化让 NPE 偶现难查;
  • Kotlin 的根本决策:可空性进入类型系统,StringString? 是两个类型,不消解可空性无法通过编译;
  • 两次官宣:2017 年官方支持,2019 年 Kotlin 优先,Jetpack 与架构指南默认 Kotlin;
  • 五类事故五根支柱:空安全、val 不可变、密封穷举、协程、扩展函数,本册各用一章展开;
  • 防线有缺口:Java 互操作与 !! 是空安全的例外,第 7 章专门处理。

下一节我们把这套理念落进一个真实工程:建一个 Kotlin 优先的项目骨架,让每一项默认配置都成为一条防线。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U