再谈 Null Safety:从语法到运行时

在 Dart 中,?(可空性)和 !(空断言)不仅仅是语法糖,它们深深植根于 Dart 的健全类型系统(Sound Static Type System)。理解它们的底层机制,需要从编译时分析与运行时检查两个维度入手。

Dart 类型系统

Dart 的 Null Safety 并不是一个孤立的功能,它本质上是对类型层级图的一次重构。 在深入探讨 Null Safety 机制之前,我们先对 Dart 健全类型系统(Sound Type System) 做一个系统性的梳理。

类型层级图

graph TD
    %% 顶层类型
    Object_Q["Object? (Top Type)"]
    
    %% 中间层与可空分支
    Null["Null"]
    Object["Object (Non-nullable)"]
    
    %% 基础类型
    num["num"]
    int["int"]
    double["double"]
    String["String"]
    bool["bool"]
    List["List<T>"]
    Function["Function"]
    Other["..."]
    
    %% 特殊类型
    dynamic["dynamic (Static Check Bypass)"]
    void["void (No return value)"]
    Never["Never (Bottom Type)"]

    %% 关系连接:从顶层向下
    Object_Q --- Object
    Object_Q --- dynamic
    Object_Q --- void
    Object_Q --- Null
    
    %% Object 的直接子类
    Object --- num
    Object --- String
    Object --- bool
    Object --- List
    Object --- Function
    Object --- Other
    
    %% num 的具体实现
    num --- int
    num --- double
    
    %% Never 的关系 (作为所有类型的最底层)
    int --- Never
    double --- Never
    String --- Never
    bool --- Never
    List --- Never
    Function --- Never
    Other --- Never
    Null --- Never

    %% 样式美化
    style Object_Q fill:#f9f,stroke:#333,stroke-width:2px
    style Never fill:#ff9,stroke:#333,stroke-width:2px
    style dynamic fill:#eee,stroke:#333,stroke-dasharray: 5 5
    style Object fill:#e1f5fe,stroke:#01579b

Object? 位于类型层级图的顶层,Never位于底层,第二层是 ObjectdynamicvoidNull。在 Dart 类型规范中,Object?dynamicvoid 通常被并列视为顶层类型(Top Types),但由于 Object?是所有类型的超集(包含 Null),将其放在最顶层作为“根节点”是非常直观且正确的理解。

在 Dart 的类型系统中,Never 是一个非常特殊且强大的存在。它被称为底层类型 (Bottom Type),意味着它是所有其他类型的子类,而它本身没有任何实例。 Never 最常见的用途是标识“永不返回”的函数:

Never fail(String message) {
  throw Exception(message); // 函数在此中断,不会返回任何值
}

?: 可空类型的底层机制

在 Dart 运行时,intint? 实际上被视为两种不同的类型。那么int? 到底位于类型层级图的哪里? 实际上 int? 则是 intNull 类型的联合类型(Union Type),只能位于类型层级图的第二层。我们可以通过下表来展示这种“联合类型”的层级位置,你会发现,int? 实际上扮演了一个中转站的角色:

graph TD
    %% 顶层
    Object_Q["Object?"]

    %% 第二层:可空包装层 (Union Types)
    subgraph Nullable_Layer [第二层:可空联合类型]
        direction LR
        int_Q["int? (int ∪ Null)"]
        String_Q["String? (String ∪ Null)"]
        bool_Q["bool? (bool ∪ Null)"]
    end

    %% 第三层:基础不可空类型
    Object["Object"]
    Null["Null"]
    int["int"]
    String["String"]
    bool["bool"]

    %% 关系连接
    Object_Q --- int_Q
    Object_Q --- String_Q
    Object_Q --- bool_Q
    Object_Q --- Object

    %% 联合类型的构成
    int_Q --- int
    int_Q --- Null
    
    String_Q --- String
    String_Q --- Null
    
    bool_Q --- bool
    bool_Q --- Null

    %% Object 的子类
    Object --- int
    Object --- String
    Object --- bool

    %% 底部
    Never["Never"]
    int --- Never
    String --- Never
    bool --- Never
    Null --- Never

    style int_Q fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 5 5
    style Nullable_Layer fill:#f9f9f9,stroke:#ccc

当你尝试将 int?类型的值 赋给 int 时,Dart 编译器会报错。这是因为在层级图中,你是在尝试向上转型(从联合类型转到更具体的子类型),这是不安全的。

当我们声明一个非空类型变量 int x,我们就向编译器做出了永久性承诺。在 AOT 编译阶段,由于确定 x 永远不会是 null,编译器可以大胆地移除所有原本需要插入的 if (x == null) 检查指令。这种 “非空检查消减(Null Check Elimination)” 让生成的机器码更加紧凑、执行路径更加单一。这也是为什么 Null Safety 能提升程序执行性能

! 的运行时代价:隐式分支检查

! 操作符(非空断言)实际上是静态检查的“逃生舱”,但它是有代价的。每当我们使用 x!,Dart 编译器会在该位置强制注入一段检查逻辑。在 CPU 执行层面,! 触发的是一次条件跳转(Conditional Branch),如果 x 恰好为 null,它会跳转到异常处理路径;虽然单次执行极快,但在高性能计算(如我们会学到的 WorkerPool)中频繁使用 ! 会干扰 CPU 的分支预测。

在编写高性能代码时,我们应尽量通过架构设计(如构造函数初始化)确保变量非空,从而利用编译器的检查消减特性提示性能,而不是依赖运行时频繁的 ! 检查。

Type Promotion(类型提升)

Type Promotion 是 Dart 编译器的一项智能特性。它指的是:当编译器能够通过逻辑流分析(Flow Analysis)确定一个变量在特定范围内绝对属于某种更窄的类型时,它会自动临时改变该变量的类型。简单来说,就是编译器帮你省去了手动进行强制类型转换(如使用 as 关键字)的麻烦。 例如:

void process(Object data) {
  if (data is List) {
    // 💡 data 从 Object 提升到了 List
    print(data.length); // 现在可以直接访问 List 的属性
  }
}

Null Safety 提升

这是 Dart 开发者每天都会遇到的。当一个变量是“可空类型”(如 String?),我们对其进行非空检查后,在该分支内它会被自动提升为“非空类型”(String)。

void printLength(String? text) {
  // print(text.length); // 报错:Property 'length' cannot be accessed on 'String?'

  if (text != null) {
    // 💡 在这个 if 块内,text 被自动提升为 String (非空)
    print(text.length); // 正常运行
  }
}

为什么 if (obj.field != null) 之后依然报错?

这是开发者最常遇到的困惑:为什么 if (obj.field != null) 之后,直接使用 obj.field 依然报错?

原因在于内存竞争与 Getter 不确定性:

  • Isolate 并发: Dart 虽然是单线程模型,多 Isolate 之间虽然不共享内存,但在某些底层交互或 FFI(Foreign function interface) 场景下,数据的状态可能改变。

  • Getter 覆盖:底层 VM 无法保证 obj.field 是一块静态内存。它可能是一个 get 函数,每次调用都返回不同的值。

解决方案:使用局部变量快照(Shadowing)。

  final val = obj.field; // 将状态“锁定”在当前栈帧
  if (val != null) {
    print(val.length);
  }

Reference