Swift 6 为并发引入了许多新功能与关键字。虽然其中不少内容在日常开发中可能鲜少用到,但一旦遭遇特定场景,若对这些新概念缺乏了解,即便有 AI 辅助也可能陷入僵局。本文将通过一个在开发测试中遇到的实际并发问题,来介绍如何利用 @isolated(any) 以及 #isolation 宏,实现函数的隔离域继承,从而让编译器自动推断闭包的运行环境。
编译器忽视了我的 Default Actor?
在 Swift 6.2 推出 Default Actor Isolation(默认 Actor 隔离)功能后,我一直在积极探索其适用场景。由于我习惯通过 SPM 将不同的功能模块分配到独立的 Target 中,而 Default Actor Isolation 又是以 Target 为单位进行设定的,因此在构建视图功能集或状态/数据流功能集时,我通常会添加如下设置:
swiftSettings: [
.swiftLanguageMode(.v6),
.defaultIsolation(MainActor.self),
.enableExperimentalFeature("NonisolatedNonsendingByDefault"),
]
这确实显著减轻了编码与测试时的心智负担。一个立竿见影的效果是,在这些 Target 中,我已经很久不需要在闭包里使用显式的 @MainActor 标注了:
// 以前常需这样写,现在通常可以省略
{ @MainActor in
....
}
不过,最近在使用我自己编写的一个简易依赖注入库进行测试时,却出现了预期外的情况。我被迫必须加上 @MainActor in,否则代码无法通过编译:
@Test
func switchID() async {
let noteID = UUID()
await withDependencies {
$0.appSettings = AppSettingsTestHelpers.makeMockSettings()
} operation: {
@Dependency(\.appSettings) var settings
// 报错:Main actor-isolated property 'noteID' can not be referenced from a Sendable closure
settings.noteID = noteID
...
}
}
编译器报错提示:Main actor-isolated property ‘noteID’ can not be referenced from a Sendable closure”(无法从 Sendable 闭包中引用 MainActor 隔离属性 ‘noteID’)。
尽管可以通过手动添加 @MainActor in 解决问题,但这引发了我的困惑:在 Target 的 Default Actor 既然已经设置为 MainActor 的前提下,编译器为何无法自动推断出 operation 闭包应该运行在 MainActor 上?
分析问题
上述代码中使用的 withDependencies 是我模仿 swift-dependencies API 风格编写的简化版:TinyDependency。它仅有一百多行代码,无第三方依赖,完全满足我的个人需求。
其声明如下:
public func withDependencies<R>(
_ updateValuesForOperation: (inout DependencyValues) -> Void,
operation: () async throws -> R
) async rethrows -> R {
var dependencies = DependencyValues.current.copy()
updateValuesForOperation(&dependencies)
return try await DependencyValues.$_current.withValue(dependencies) {
try await operation()
}
}
根据我对 Default Actor Isolation 的理解,既然我在该 Target 中声明的协议、类型和方法都默认归属于 @MainActor,那么在测试方法中,withDependencies 接收的 operation 异步闭包,理应被 Swift 编译器推断为在 MainActor 上运行。
然而现实是,编译器并未按此预期进行推断。它似乎认为这个闭包可能会跨 Actor 执行,因此阻止了对 MainActor 隔离属性的访问。虽然我可以用 @MainActor in 进行运行时强制限定,但有没有办法能让编译器在编译时就自动完成推断呢?
让闭包继承隔离域
为了解决这个问题,我们可以利用 Swift 6 引入的两个新特性。
首先是 SE-0431 提出的 @isolated(any)。它的主要目的是解决函数作为值传递时,编译器缺乏足够信息来准确判定隔离域的问题。
当使用 @isolated(any) 标注一个函数类型时,该函数类型将携带其调用者所在的隔离域信息。虽然我们在代码中通常不会直接读取这个信息,但编译器会利用它来推断运行时的隔离环境。
将 withDependencies 修改如下,之前的编译错误便消失了:
public func withDependencies<R>(
_ updateValuesForOperation: (inout DependencyValues) -> Void,
operation: @isolated(any) () async throws -> R // 增加了 @isolated(any)
) async rethrows -> R {
var dependencies = DependencyValues.current.copy()
updateValuesForOperation(&dependencies)
return try await DependencyValues.$_current.withValue(dependencies) {
try await operation()
}
}
通过添加 @isolated(any),编译器在处理 operation 闭包时,会自动感知并继承调用者的隔离域。由于测试 Target 的默认隔离域是 MainActor,因此编译器推断该闭包也运行在 MainActor 上,从而通过了编译时的安全检查,无需再手动书写 @MainActor。
不过,@isolated(any) 更多是针对函数类型的修饰。如果我们要将其作为一个通用的库函数,可能不希望局限于这一种推断方式。此时,我们可以采用 SE-0420 提出的 隔离继承(Inheritance of actor isolation) 方案:
public func withDependencies<R>(
isolation: isolated (any Actor)? = #isolation, // SE-0420: 隔离继承
_ updateValuesForOperation: (inout DependencyValues) -> Void,
operation: () async throws -> R
) async rethrows -> R {
var dependencies = DependencyValues.current.copy()
updateValuesForOperation(&dependencies)
return try await DependencyValues.$_current.withValue(dependencies) {
try await operation()
}
}
通过为 withDependencies 增加一个 isolation 参数,并配合 #isolation 宏,我们能够提前明确 operation 的隔离上下文。
这里的 isolation 参数提供了三种可能性:
- nil:函数是动态非隔离的,这与未添加该参数时的默认行为一致。
- Global Actor:函数动态隔离至指定的全局 Actor。例如,传入
MainActor.shared可以代替闭包中的@MainActor in,将运行时检查提前到编译时。 - 调用者的隔离域:如果从某个 Actor 实例中调用
withDependencies,它将继承该 Actor 的隔离域。
为了实现“默认继承调用者隔离域”的效果,我们使用了 #isolation 宏作为默认参数:
/// 获取当前代码所在的 Actor 隔离引用,若代码非隔离则返回 nil。
@freestanding(expression) public macro isolation<T>() -> T = Builtin.IsolationMacro
它可以自动捕获当前的隔离信息并传递给 isolation 参数。经过上述调整,我的测试代码不仅能够正常编译运行,而且无需手动追加任何额外的隔离域标注,代码显得更加简洁干净。
冷门但有用
Swift 6 新增的众多并发关键字确实容易给开发者带来困扰。阅读 Proposal 或技术文章时,很多关键字常让人感到“莫名其妙”甚至“画蛇添足”。
以 isolated(any) 为例,我最早是通过 Matt Massicotte 的文章了解到的,甚至还在周报中推荐过。但即便如此,如果不是这次在测试中碰壁,再加上我已经习惯了不写 @MainActor in,我可能很难想起去使用它。
然而,真正上手后就会发现,这些功能确实体现了 Swift 6 极致追求编译时安全的精神。它们或许冷门,但在特定场景下,确实非常有用!