Swift SE-0411 隔离默认值表达式(Isolated Default Value Expressions)完整解析

原创2026-09-22 23:50:111,400 阅读
文章标签:文档编程语言

Swift SE-0411 隔离默认值表达式(Isolated Default Value Expressions)完整解析

导读

本文以 Swift Evolution 提案 SE-0411:Isolated default value expressions 为核心,系统讲解 Swift 5.10 中默认值表达式(默认参数与存储属性初始值)的 Actor 隔离规则为何被统一、如何被推断与强制,以及这些规则如何在消除数据竞争的同时提升表达力。读完本文,你将掌握:存储属性默认值导致数据竞争的根本原因、默认参数与函数/存储属性“同隔离”的推断算法、跨隔离域调用时 await 与实参求值顺序的精确语义,以及 IsolatedDefaultValues 特性开关下的迁移路径。

提案概览

SE-0411 由 Holly Borla 提出、Doug Gregor 负责评审,状态为 Implemented(Swift 5.10),是 Swift 并发模型(Actor 隔离)体系的重要一环。其核心要点:

  • 实现状态:已在 Swift 5.10 落地实现;
  • 特性开关(Upcoming Feature Flag):IsolatedDefaultValues,用于以源码兼容的方式分阶段启用更严格的存储属性隔离规则;
  • 关联提案:与 SE-0327:On Actors and Initialization(Actor 与初始化)直接衔接——SE-0327 中关于存储属性初始值隔离的规则因影响面过大而未实施,最终被 SE-0411 取代(见 SE-0327 文档第 740 行注记);
  • 上游基础:Actor 隔离模型本身由 SE-0306:Actors 引入,SE-0411 是对其默认值场景的收口与完善。

背景与动机:为什么默认值表达式的隔离规则需要统一

默认值表达式(default value expressions)可以出现在两类位置:默认参数(default arguments)与存储属性的默认初始值(default stored property values)。提案指出,在此之前,这两类位置的 Actor 隔离规则存在三个问题:

  1. 存储属性的隔离规则会引入数据竞争;
  2. 默认参数的隔离规则过于严格(一律视为 nonisolated);
  3. 不同位置之间的规则不一致,导致 Actor 隔离模型难以理解。

问题一:存储属性默认值允许了非法隔离调用

在提案之前,如下代码是合法的,但它实际上存在数据竞争:

@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() {} // okay???
}

这段代码允许任何上下文通过一个同步的、nonisolated 的 init 创建 C() 实例。初始化器会同步调用 requiresMainActor() 与 requiresAnotherActor(),二者分别隔离于 @MainActor 与 @AnotherActor。这直接违反了 Actor 隔离检查——因为这两个全局 Actor 上的代码可能与其他代码并发执行,而同步初始化却绕过了隔离域切换(executor hop)。

问题二:默认参数一律 nonisolated 过于严格

默认参数在旧规则下不会引入数据竞争,但总是被视为 nonisolated,这阻碍了合法的 @MainActor 隔离调用。例如下面的代码本是完全安全的,却被拒绝:

@MainActor class C {}

@MainActor func f(c: C = C()) {} // error: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

@MainActor func useFromMainActor() {
  f()
}

默认参数 C() 调用的是 @MainActor 隔离的初始化器,而旧规则强制默认参数在 nonisolated 上下文求值,导致一个明显安全、且程序员本意就是在 @MainActor 上下文使用的调用被误报。

解决方案:让默认值表达式与“宿主”共享隔离

SE-0411 的核心主张是:允许默认值表达式拥有与所在函数或对应存储属性相同的隔离。具体规则为:

  • 默认值表达式在调用者未处于被调用方隔离域时,必须异步求值并显式标注 await;
  • 对于存储属性的隔离默认值,其隐式初始化只发生在具有相同隔离的 init 函数体内。

效果一:非法示例在 nonisolated 初始化器处报错

应用新规则后,开篇的存储属性示例在 nonisolated 初始化器处变为非法:

@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() {} // error: 'self.x1' and 'self.x2' are not initialized
}

因为 x1、x2 的默认初始值分别要求 @MainActor 与 @AnotherActor 隔离,nonisolated init() 无法在自身隔离域内同步完成这两个赋值,于是编译器不再为它生成隐式初始化,改而要求程序员显式初始化。显式调用并标注 await 即可解决:

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() async {
    self.x1 = await requiresMainActor()
    self.x2 = await requiresAnotherActor()
  }
}

效果二:合法的默认参数示例恢复有效

因为默认参数与所在函数同为 @MainActor 隔离,前面的 f(c: C = C()) 示例在 SE-0411 下变为合法:

@MainActor class C {}

@MainActor func f(c: C = C()) {} // 合法:默认参数与函数同属 @MainActor

@MainActor func useFromMainActor() {
  f()
}

详细设计(一):默认值隔离需求的推断

默认值表达式总是在同步上下文求值,因此求值过程中发生的所有调用也必须是同步的。若被调用方是隔离的,则默认值表达式必须已经处于同一隔离域,才能同步完成调用。由此得出推断规则:

对于一个给定的默认值表达式,其推断出的隔离 = 其子表达式所要求的隔离。

例如:

@MainActor func requiresMainActor() -> Int { ... }

@MainActor func useDefault(value: Int = requiresMainActor()) { ... }

value 的默认参数调用了 @MainActor 隔离的 requiresMainActor(),因此该默认参数要求 @MainActor 隔离。

闭包字面量的隔离推断

求值一个闭包字面量本身可以在任意隔离域进行;闭包的 Actor 隔离只在调用闭包时才生效。一个 Actor 隔离的闭包可以让其函数体在自身隔离域内同步调用成员。对于默认值表达式中未显式标注隔离的闭包字面量,其推断隔离为:

闭包体中所有同步调用目标的隔离的并集(union)。

示例:

@MainActor func requiresMainActor() -> Int { ... }

@MainActor func useDefaultClosure(
  closure: () -> Void = {
    requiresMainActor()
  }
) {}

useDefaultClosure 的默认参数是一个闭包字面量,闭包体同步调用了 @MainActor 隔离的函数,因此该闭包自身必须被推断为 @MainActor 隔离。

提案特别强调:默认参数中的闭包字面量永远不会被推断为“隔离到某个 Actor 实例”,理由有二:

  1. 要隔离到某个 Actor 实例,闭包必须要么拥有自己(显式声明的)隔离参数,要么从外层上下文捕获一个隔离参数;
  2. 默认参数中的闭包字面量不能捕获值。

因此,唯一让默认参数闭包隔离到 Actor 实例的途径,是显式书写带隔离参数(isolated parameter)的闭包。

限制规则

推断结果必须与宿主一致,否则报错:

  • 如果函数或类型自身具有 Actor 隔离,则其默认值表达式的要求隔离必须与宿主共享同一 Actor 隔离。例如 @MainActor 隔离的函数不能拥有隔离到 @AnotherActor 的默认参数。注意:隔离默认值与 nonisolated 默认值混用始终是允许的;
  • 如果函数或类型是 nonisolated,则其默认值表达式的要求隔离必须是 nonisolated。

详细设计(二):默认参数隔离的强制执行与实参求值顺序

默认参数的隔离在“调用方”处强制

默认参数的隔离要求在调用方处被检查:若调用方不在要求隔离域内,则默认参数必须异步求值并显式标注 await。

@MainActor func requiresMainActor() -> Int { ... }

@MainActor func useDefault(value: Int = requiresMainActor()) { ... }

@MainActor func mainActorCaller() {
  useDefault() // okay:调用方与默认参数同为 @MainActor
}

func nonisolatedCaller() async {
  await useDefault() // okay:跨隔离域调用,显式 await

  useDefault() // error: call is implicitly async and must be marked with 'await'
}

如提案所述,这些规则已经自然从“调用 Actor 隔离函数”的语义中推导出来——默认参数的隔离要求相当于把整个调用变成了一个隔离调用。

实参求值顺序(无隔离要求时)

对一次普通调用,实参按如下顺序求值:

  1. 从左到右求值显式的右值实参(explicit r-value arguments);
  2. 从左到右求值默认参数与形式访问实参(formal access arguments)。

示例与输出:

nonisolated var defaultVal: Int { print("defaultVal"); return 0 }
nonisolated var explicitVal: Int { print("explicitVal"); return 0 }
nonisolated var explicitFormalVal: Int {
  get { print("explicitFormalVal"); return 0 }
  set {}
}

func evaluate(x: Int = defaultVal, y: Int = defaultVal, z: inout Int) {}

evaluate(y: explicitVal, z: &explicitFormalVal)

输出顺序为:

explicitVal
defaultVal
explicitFormalVal

实参求值顺序(存在隔离默认参数时)

与显式实参列表不同,隔离的默认参数必须在“被调用方的隔离域”内求值。因此,只要任一实参值要求被调用方的隔离,求值顺序就变为:

  1. 从左到右求值显式的右值实参;
  2. 从左到右求值形式访问实参;
  3. 跳转到被调用方的隔离域(hop to the callee's isolation domain);
  4. 从左到右求值默认参数。

示例与输出:

@MainActor var defaultVal: Int { print("defaultVal"); return 0 }
nonisolated var explicitVal: Int { print("explicitVal"); return 0 }
nonisolated var explicitFormalVal: Int {
  get { print("explicitFormalVal"); return 0 }
  set {}
}

@MainActor func evaluate(x: Int = defaultVal, y: Int = defaultVal, z: inout Int) {}

nonisolated func nonisolatedCaller() {
  await evaluate(y: explicitVal, z: &explicitFormalVal)
}

调用 nonisolatedCaller() 的输出为:

explicitVal
explicitFormalVal
defaultVal

注意与无隔离场景的关键差异:defaultVal 被推迟到跳转隔离域之后才求值,保证它真正运行在 @MainActor 上;而 explicitVal 与 explicitFormalVal 属于显式实参,仍在跳转前于调用方上下文求值。这一改动(把默认参数求值挪到形式访问实参之后)正是提案修订历史中明确记录的一项语义调整,它使“跨隔离域使用隔离默认参数”成为可能。

详细设计(三):存储属性的默认初始值

存储属性默认初始化表达式的隔离要求在初始化器函数体内生效:

若某个 init 与初始化表达式(默认初始值)的隔离不匹配,则该存储属性的隐式初始化不会被注入到该 init 的开头,程序员必须在 init 函数体内显式初始化该属性。

完整示例:

@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }

class C {
  @MainActor var x1: Int = requiresMainActor()
  @AnotherActor var x2: Int = requiresAnotherActor()

  nonisolated init() {} // error: 'self.x1' and 'self.x2' aren't initialized

  nonisolated init(x1: Int, x2: Int) { // okay
    self.x1 = x1
    self.x2 = x2
  }

  @MainActor init(x2: Int) { // okay
    // 'self.x1' 被赋值为默认值 'requiresMainActor()'
    self.x2 = x2
  }
}

分析:

  • 无参的 nonisolated init() 非法:它没有初始化 self.x1 与 self.x2,而这两个属性的默认初始值要求不同的 Actor 隔离,因此这些默认值不会被用于 nonisolated 初始化器;
  • 带参的 nonisolated init(x1:x2:) 合法:全部显式赋值;
  • @MainActor init(x2:) 合法:self.x1 由默认值 requiresMainActor() 隐式初始化(两者同为 @MainActor),self.x2 显式赋值。

详细设计(四):初始化器中的存储属性隔离

禁止跨隔离域初始化隔离存储属性

跨隔离边界初始化一个隔离的存储属性是非法的:

class NonSendable {}

class C {
  @MainActor var ns: NonSendable

  init(ns: NonSendable) {
    self.ns = ns // error: passing non-Sendable value 'ns' to a MainActor-isolated context.
  }
}

该代码违反 Sendable 保证:从 nonisolated 上下文初始化 @MainActor 隔离属性 self.ns,本质上相当于把非 Sendable 值传递跨越了隔离边界。为防止这一类数据竞争,提案要求:

任何初始化了全局 Actor 隔离存储属性的 init,自身也必须隔离到该全局 Actor。

提案明确说明,这一规则并非默认值特例,但它是规定编译器合成初始化器中默认值行为所必需的(例如合成的 memberwise 初始化器要决定自身隔离)。

编译器合成初始化器中的默认值隔离

对于结构体,存储属性的默认初始化表达式会作为默认参数传给编译器生成的 memberwise 初始化器;对于拥有编译器生成无参初始化器的结构体和类,这些默认初始化表达式也会用于合成的 init() 函数体。

合成初始化器的隔离遵循如下规则:

规则一:如果类型存在非 Sendable 类型且被 Actor 隔离的存储属性,或任何隔离的默认初始化表达式是 Actor 隔离的,则编译器合成的初始化器也必须 Actor 隔离。示例:

class NonSendable {}

@MainActor struct MyModel {
  // @MainActor 由外层结构体注解推断而来
  var value: NonSendable = .init()

  /* 编译器合成的 memberwise init 为 @MainActor
  @MainActor
  init(value: NonSendable = .init()) {
    self.value = value
  }
  */
}

规则二:如果类型没有非 Sendable 且 Actor 隔离的存储属性,且所有默认初始化表达式都不要求 Actor 隔离,则编译器合成的初始化器为 nonisolated。示例:

@MainActor struct MyView {
  // @MainActor 由外层结构体注解推断而来
  var value: Int = 0

  /* 编译器合成的 'init' 均为 'nonisolated'

  nonisolated init() {
    self.value = 0
  }

  nonisolated init(value: Int = 0) {
    self.value = value
  }
  */

  // @MainActor 由外层结构体注解推断而来
  var body: some View { ... }
}

规则三(冲突即错误):如果两个不同的默认值要求不同的 Actor 隔离,则报错——因为这些默认值永远不可能被合法使用。使用两个具有不同隔离的初始值表达式来初始化某个类型实例,必须通过 async 初始化器完成,并用 await 显式标注挂起点(即前文 nonisolated init() async 的写法)。

这些规则共同保证了编译器合成初始化器中的默认值表达式永远合法:只要默认值表达式要求 Actor 隔离,其所在的合成初始化器就必然共享相同的 Actor 隔离。

兼容性影响

源码兼容(Source Compatibility)

存储属性初始值的 Actor 隔离规则比 Swift 5 模式当前接受的规则更严格(以消除数据竞争)。存储属性的隔离规则将通过 IsolatedDefaultValues upcoming feature 标识分阶段启用,即开发者可以显式开启该特性来提前获得更严格的检查,同时保持默认模式下的源码兼容性。

ABI 兼容

这是对 Actor 隔离检查的修改,对 ABI 没有任何影响。

采纳影响(Adoption)

该特性可以自由地在源码中采纳或撤回,没有任何部署约束,也不影响源码或 ABI 兼容性——这意味着它非常适合渐进式迁移:先开启特性开关定位问题,再逐个修正初始化器。

备选方案:为何不回归 SE-0327 的“全面去隔离”

提案认真评估过的唯一替代方案是:移除所有默认初始化表达式的隔离,使其一律 nonisolated(与当时的默认参数规则对齐)。

SE-0327:On Actors and Initialization 最初正是这样提议的。但该改动曾实现后被回滚,因为它影响了大量遵循常见模式的代码:

一个 @MainActor 隔离的类型,其存储属性默认值调用其他 @MainActor 隔离类型的初始化器。

在某些情况下可以把 @MainActor 类型的初始化器改成 nonisolated,但大量这类初始化器的函数体本身就会访问 @MainActor 隔离的属性和函数,根本无法去隔离。SE-0411 选择了相反且更实用的方向:让默认值表达式继承宿主的隔离,而不是强行剥夺其隔离能力。

与其他提案的关系与延伸阅读

SE-0411 处于 Swift 并发隔离规则的演进链路上,仓库中可直接对照研读的相关提案包括:

小结

SE-0411 通过“默认值表达式与宿主共享隔离”这一统一模型,一次性解决了三方面问题:消除存储属性默认值引入的数据竞争、解除默认参数 nonisolated 的过度限制、统一不同位置默认值表达式的隔离语义。其关键规则可浓缩为:

  1. 推断:默认值表达式的隔离等于其子表达式要求隔离的并集;闭包字面量的隔离来自其函数体内同步调用目标的并集,且默认参数闭包永远不能隐式隔离到 Actor 实例;
  2. 强制:默认参数隔离在调用方检查,跨隔离域必须 await;存在隔离默认参数时,实参求值顺序调整为“显式右值 → 形式访问实参 → 跳转隔离域 → 默认参数”;
  3. 存储属性:不匹配隔离的 init 不再获得隐式初始化,需显式赋值;初始化隔离存储属性的 init 必须同隔离(保证 Sendable 安全);编译器合成的初始化器随类型隔离情况自动取 @MainActor 或 nonisolated;
  4. 迁移:通过 IsolatedDefaultValues 特性开关分阶段启用,ABI 零影响,可自由采纳与撤回。

对于正在向 Swift 6 严格并发模式迁移的开发者,理解 SE-0411 是正确书写 @MainActor 类型默认值、避免“nonisolated init 报错”以及正确使用 async 初始化器的基础。

登录后查看全文
swift-evolution