从今天开始,我们将深入探讨 Dart 语言中一个非常重要的特性 ——空安全(Null Safety) 。空安全是 Dart 2.12 引入的重大特性,它从根本上改变了我们处理空值的方式,让代码更健壮、更可靠。本节课我们先理解 “为什么需要空安全” 以及它的基础概念。
一、空指针异常:程序员的 “噩梦”
在开始学习空安全之前,我们先搞清楚一个问题:为什么要费劲引入空安全?答案很简单 —— 为了消灭空指针异常(Null Pointer Exception,简称 NPE) 。
1. 什么是空指针异常?
当你试图访问一个值为 null 的变量的属性或调用其方法时,就会触发空指针异常。比如:
void main() {
String name; // 未初始化的变量(在非空安全模式下默认可为 null)
print(name.length); // 尝试访问 null 的 length 属性
}
在没有空安全的情况下,这段代码会在运行时崩溃,抛出类似 NoSuchMethodError: The getter 'length' was called on null 的错误。
2. 空指针异常的危害(生产环境案例)
空指针异常看似简单,却是生产环境中最常见的崩溃原因之一,可能造成严重后果:
-
用户体验受损:App 突然闪退,导致用户数据丢失或操作中断。
- 案例:某购物 App 在结算页面因未判断地址是否为
null,导致用户付款后订单崩溃,引发大量投诉。
- 案例:某购物 App 在结算页面因未判断地址是否为
-
经济损失:服务端程序因 NPE 崩溃,可能导致服务中断。
- 案例:某支付系统在峰值时段因空指针异常宕机 10 分钟,直接损失数百万交易流水。
-
调试困难:运行时异常往往需要结合具体场景复现,尤其是在复杂系统中,定位
null出现的位置可能耗费大量时间。
3. 为什么空指针异常如此普遍?
根本原因是:在传统编程模式中,变量是否允许为 null 是模糊的。
- 开发者可能忘记初始化变量
- 函数返回值可能在某些边缘 case 下为
null - 调用者可能误传
null作为参数
这些问题在代码量庞大或多人协作时尤为突出。
二、Dart 空安全的核心思想:“明确可空性”
Dart 空安全的解决方案可以总结为一句话:让变量的可空性变得明确,由编译器在编译时检查,而不是等到运行时崩溃。
简单说就是:
- 默认情况下,所有变量都是非可空的(不能为
null) - 如果变量可能为
null,必须显式声明为可空类型
这种设计将空指针异常的检测从 “运行时” 提前到 “编译时”,让错误在开发阶段就能被发现。
三、非可空类型与可空类型
1. 非可空类型(Non-nullable)
在空安全模式下,未加任何修饰的类型都是非可空的,必须被初始化且不能赋值为 null。
void main() {
String name; // 错误:非可空变量必须初始化
name = null; // 错误:不能将 null 赋值给非可空类型
print(name);
// 正确写法:声明时初始化
String username = "dart";
int age = 20;
bool isStudent = true;
// 正确:后续赋值也不能为 null
username = "flutter"; // ✅
// username = null; // ❌ 编译错误
}
非可空类型的变量从诞生到销毁,始终保证有一个有效值,彻底避免了因意外 null 导致的崩溃。
2. 可空类型(Nullable)
如果变量可能为 null,需要在类型后加 ? 修饰符,声明为可空类型。
void main() {
// 可空类型变量可以初始化为 null 或具体值
String? name = null; // ✅
int? age = 20; // ✅
// 可空类型变量可以后续赋值为 null
name = "dart"; // ✅
name = null; // ✅
age = null; // ✅
}
? 就像一个 “标签”,告诉编译器和其他开发者:“这个变量可能为 null,使用时要小心!”
3. 可空类型的使用限制
正因为可空类型可能为 null,Dart 对其使用施加了限制:不能直接访问可空变量的属性或方法。
void main() {
String? name;
// 错误:直接访问可空变量的属性
print(
name.length,
); // ❌ 编译错误:The property 'length' can't be unconditionally accessed because the receiver can be 'null'.
// 正确:先判断不为 null 再使用(类型提升)
if (name != null) {
print(name.length); // ✅ 此时编译器知道 name 不为 null
}
}
这个限制是关键 —— 它强制开发者处理 null 情况,避免了无意识的空指针调用。
四、编译时检查 vs 运行时检查
空安全的核心价值在于将空指针异常的检查从 “运行时” 提前到 “编译时”,这两者有本质区别:
| 维度 | 编译时检查(空安全) | 运行时检查(传统模式) |
|---|---|---|
| 发生时机 | 代码编写 / 编译阶段 | 程序运行阶段(可能在生产环境) |
| 发现方式 | 编译器直接报错,无法通过编译 | 程序崩溃,抛出异常 |
| 影响范围 | 仅开发者可见,不影响用户 | 可能导致服务中断、用户数据丢失等严重后果 |
| 修复成本 | 开发阶段即可修复,成本低 | 需复现问题、定位原因,复杂系统中修复成本高 |
| 可靠性 | 从源头避免空指针异常,代码更可靠 | 依赖开发者手动判断 null,容易遗漏边缘 case |
举例说明:
// 编译时检查(空安全模式)
void printLength(String? text) {
// 编译错误:提示 text 可能为 null
print(text.length); // ❌ 开发者立即知道需要处理 null
}
// 修复后:
void printLength(String? text) {
if (text != null) {
print(text.length); // ✅ 编译通过,运行时绝不会因 text 为 null 崩溃
} else {
print("文本为空");
}
}
在传统非空安全模式下,这段代码能正常编译,但当 text 为 null 时,会在运行时崩溃。而空安全模式下,编译器会强制开发者处理 null 情况,从根本上避免了这类错误。