这一节,我们来聊聊把 async
加入 Swift 之后,对函数定义都产生了哪些影响。
Let's make dinner
为了接下来方便讨论,我们先来创建一个可以实际编译执行的例子。在准备这部分的内容的时候,我使用的是 6 月 14 日发布的 Swift 5.5 社区版,这个版本的 Swift 比 Xcode 13 beta1 自带的 Swift 5.5 要新一些。之所以要使用社区版,是因为 beta 1 的 Swift 5.5 包含了一些已经过期的和并发相关的 API,我们尽可能避免受这些内容的影响。
另外,由于 LLDB 的 bug,导致 Xcode 13 无法使用社区版 Swift 编译 macOS 的命令行项目。因此,简单起见,我们就使用 SPM 创建工程,然后通过命令行执行就好了。新建一个 MakeDinner 的目录,表示一个做晚餐的项目,在其中执行下面的命令:
swift package init --type=executable
完成之后,我们打开 Package.swift,把 .executableTarget
的部分改成这样:
.executableTarget(
name: "MakeDinner",
dependencies: [],
swiftSettings: [
.unsafeFlags([
"-parse-as-library",
"-Xfrontend", "-disable-availability-checking",
"-Xfrontend", "-enable-experimental-concurrency",
])
]),
其中:
-parse-as-library
是一个妥协的选项,它来自于 SR-12683。主要是为了避免 Swift 编译器无法识别全局空间中的@main
语句;-disable-availability-checking
是为了关闭 Swift 编译器的版本检查,在现阶段,有些和并发相关的 API 暂时使用了 9999 作为系统版本。为了避免每次在调用的时候设置版本号,我们索性就临时关掉检查就好了;-enable-experimental-concurrency
是为了使用尚处在实验阶段的 Swift 并发 API;
总之,以上这些编译选项,完全是为了现在我们可以正常学习而使用的。未来某个版本的 Swift 5.5,我们应该可以不用设置它们。
设置好了编译选项,我们打开 SPM 默认创建的 main.swift,把其中的代码改成这样:
func chopVegetable() async {
print("Chopping vegetables")
}
@main
struct MyDinner {
static func main() async {
await chopVegetable()
print("Dinner is served.")
}
}
在上面的代码里,我们用 async
定义了一个异步函数 chopVegetable
表示切蔬菜。稍后我们会说到,Swift 只允许在异步环境中调用异步函数,因此,我们用 @main
这种形式定义了程序的入口点,main
变成了 MyDinner
中的一个静态方法,如果你熟悉 Java 或者 C#,对这种形式一定不陌生。值得注意的是,main
函数现在也变成了一个异步函数。只有在这里,我们才可以使用 await chopVegetable()
的形式调用它。表示等待异步函数 chopVegetable()
执行完后,再执行接下来的 print
函数。
直接使用 swift run
编译并执行,就可以在控制台看到 Chopping vegetables\nDinner is served.
的结果了。
这时,回过头再看看上面的代码,你可能会觉得:等等!这里也没什么异步的事情啊。的确,这就是我们要说第一点:Swift 中的 async/await
自身,其实和异步执行是没有直接关系的。它只是从程序字面上告诉编译器,被 async
标记的函数有可能会异步执行,被 await
标记的调用需要等待完成。真正的异步执行,是通过 Swift 结构化并发模型实现的,这个我们以后再说。
有了这个可以实验的基础环境之后,我们回到这一节开始的问题:把 async
加入 Swift 之后,对函数定义都产生了哪些影响呢?
那些函数可以定义成异步的
第一个问题是究竟哪些有函数性质的元素可以被标记为 async
呢?。毫无疑问,全局函数是可以的,刚才我们已经试过了。第二类可以标记为 async
的,是自定义类型的 init
或者普通方法,例如,我们把刚才的代码改成这样:
struct Dinner {
init() async {
print("Plan the menu.")
}
func chopVegetable() async {
print("Chopping vegetables")
}
}
但要特别注意的是:deinit
,下标操作符以及属性的 getter 和 setter 都不能标记为 async
。这里有两个主要原因:
- 对于
deinit
,我们都是要求它必须发生在明确的时间,并且按照既定的顺序完成,异步的deinit
显然和这个要求是冲突的; - 那 setter 呢?如果我们允许一个属性拥有异步 setter,这就意味着,当我们传递这个属性的引用时,我们就需要跟踪传递路径上的每一个变量,因为这些变量的 setter 实际上也都是异步的。但从使用的角度上来说,我们会习以为常的认为对它们的赋值都是可以立即完成的。因此,在我们没有想清楚这个问题之前,让 setter 和 getter 都只能是同步的是一个相对简洁且合理的决定;
了解了这些内容之后,接下来,简单起见,我们就把所有可以标记为 async
的元素,统称为异步函数了。
定义可以抛出异常的异步函数
对于一个异步函数来说,相比同步函数,它有更大的概率会发生错误。那么,我们的第二问题就是:该如何定义既异步执行,又可能抛出异常的函数呢?虽然 async
和 throws
并无优先级的区别,Swift 要求我们 async
必须写在 throws
前面,例如这样:
struct Dinner {
func chopVegetable() async {
print("Chopping vegetables")
}
func marinateMeat() async throws {
print("Marinate meat")
}
}
但是,提前说一句,对于 async throws
的函数,调用的时候,try/try?/try!
必须在 await
的前面:
await dinner.chopVegetable()
try await dinner.marinateMeat()
print("Dinner is served.")
用 Swift 开发者的话说就是:给出一个明确的定义,可以避免日后基于所谓风格问题的不必要争吵。
异步 init 方法在继承关系中的用法
第三个问题,和派生类有关。如果派生类的 init
方法是异步的,它还会自动调用基类的同步 init
方法么?
答案是:会。但条件有二:
- 条件一,在异步
init
方法里,我们没有自己调用基类的init
方法; - 条件二,基类存在默认的,同步的 designated initializer;
来看下面这个例子:
class Tableware {
var color: String
var shape: String
init(color: String = "white", shape: String = "round") {
self.color = color
self.shape = shape
print("Prepare tableware: Color:\(color), Shape:\(shape)")
}
}
class Dish: Tableware {
init() async {
print("Disinfection the dish before use.")
}
}
其中,Dish
满足条件一,Tableware
满足条件二。只要把之前的 main
函数改成这样:
@main
struct MyDinner {
static func main() async throws {
_ = await Dish()
}
}
就可以看到下面这样的提示:
Disinfection the dish before serving.
Prepare tableware: Color:white, Shape:round
也就是说,异步的 init
方法调用了基类中同步的默认 init
方法。
但这时你可能会想,如果基类存在异步的默认 init
方法呢?
首先我们得知道,async
修饰不能单独用作函数重载的条件。因此,基类中不会同时存在同步和异步的 init
方法。其次,Swift 要求调用 async
函数不能隐性发生,必须有明确的调用标记,也就是 await
语句。因此,基类的异步 init
方法是不会被自动调用的。
如果基类只有异步的默认 init
,而我们在派生类的 init
方法中又没有明确调用它,就会发生类似下面这样的编译错误:
error: missing call to superclass's initializer; 'super.init' is 'async' and requires an explicit call
init() async {
async
对函数重载的影响
我们的第四个问题,可以继续顺着 init
说下去。虽然 async
不能用作函数重载的标记,但还是有可能存在着用起来形式完全一样的同步和异步函数,这时该怎么解析呢?来看下面这个例子:
struct Dinner {
func preheatOven(_ completionHandler: (() -> Void)? = nil) {
print("Preheat oven with completion handler.")
}
func preheatOven() async {
print("Preheat oven asynchronously.")
}
}
我们给 Dinner
添加了两个预热烤箱的方法,一个是基于回调函数的,一个是异步的。由于它们都有默认参数,对于 preheatOven()
这样的表达式,Swift 应该调用哪个呢?
聪明的你可能会说,有 await
就调用异步的,否则就调用同步的呗。对于这个解释,你至多只能得 50 分:
- 一方面,在后面的内容中我们就会看到,调用异步函数不一定要在每一个异步函数前面使用
await
,因此它不足以作为函数调用判断的依据; - 另一方面,在有
async
之前,Swift 对重载函数的选择标准是优先选择参数少的。因此,在上面的例子中,异步的函数看起来才是正确的选择。但默认选择异步的版本会带来源代码兼容性问题,毕竟在有async
之前,编译器默认选择的都是同步的函数;
为此,Swift 采取了更加精致的策略,编译器会判断调用函数时的上下文环境。在同步环境中选择同步的版本,异步的环境中选择异步的版本。因此,在我们的异步 main
函数中,默认就会调用异步的版本。大家可以自己试试同步的例子。
和 async
相关的类型转换
关于异步函数的第五个问题:两个函数类型,如果它们的区别只是 async
,这两个类型的变量之间可以互相赋值么?其实,这个问题的答案,和 throws
有些类似:同步可以向异步转换,反之则不行。在 SE-0296 中,有一段代码解释了这个转换过程:
struct FunctionTypes {
var syncNonThrowing: () -> Void
var syncThrowing: () throws -> Void
var asyncNonThrowing: () async -> Void
var asyncThrowing: () async throws -> Void
mutating func demonstrateConversions() {
// Okay to add 'async' and/or 'throws'
asyncNonThrowing = syncNonThrowing
asyncThrowing = syncThrowing
syncThrowing = syncNonThrowing
asyncThrowing = asyncNonThrowing
// Error to remove 'async' or 'throws'
syncNonThrowing = asyncNonThrowing // error
syncThrowing = asyncThrowing // error
syncNonThrowing = syncThrowing // error
asyncNonThrowing = syncThrowing // error
}
}
用一句话总结就是:同步的函数可以当作异步的来用,没异常的函数可以当成有异常的来用。反之则不行。
协议中的 async
约束
和 async
函数有关的最后一个问题,是 Swift 协议中的 async
约束。简单用一句话总结就是:如果约束中带有 async
,那么这个约束可以由同步或异步函数实现。否则,就只能由同步方式实现。例如:
protocol Cook {
func chopVegetable() async
func marinateMeat() async
func preheatOven() async
func serve() // Can only be satisfied by a synchronous function
}
其中,前三个烹饪的方法,用异步或同步方法实现都可以,但最后的上菜环节,则只能由同步函数完成。知道为什么要这样,其实和刚才我们说过的函数类型的变量赋值是类似的,我们就不重复了。
What's next?
好了,说到这里,关于 async
到底给函数定义带来了哪些变化这个话题,也就差不多了。其实,为编程语言加入一个新特性,往往比我们想象中要复杂的多。下一节,我们来聊聊函数的好基友:closure,看看 async
给它带来了什么变化。