这一节,我们来聊聊把 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 的元素,统称为异步函数了。

定义可以抛出异常的异步函数

对于一个异步函数来说,相比同步函数,它有更大的概率会发生错误。那么,我们的第二问题就是:该如何定义既异步执行,又可能抛出异常的函数呢?虽然 asyncthrows 并无优先级的区别,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 给它带来了什么变化。

所有订阅均支持 12 期免息分期

¥ 59

按月订阅

一个月,观看并下载所有视频内容。初来泊学,这可能是个最好的开始。

开始订阅

¥ 512

按年订阅

一年的时间,让我们一起疯狂地狩猎知识吧。比按月订阅优惠 28%

开始订阅

¥ 1280

泊学终身会员

永久观看和下载所有泊学网站视频,并赠送 100 元商店优惠券。

我要加入
如需帮助,欢迎通过以下方式联系我们