Table of Contents

  1. Algorithm
  2. Review
    1. 概述
    2. 连接一个发布者到一个订阅者
    3. 用操作改变 Output 类型
    4. 用操作自定义发布者
    5. 当需要时取消发布
  3. Tips
    1. 定义和调用异步函数
    2. 异步顺序
    3. 并行调用异步函数
    4. 任务和任务组
    5. 任务取消
    6. 非结构化并行
    7. Actor
    8. 可发送类型
  4. Share

Algorithm

Leetcode 1049: Last Stone Weight II

https://dreamume.medium.com/leetcode-1049-last-stone-weight-ii-dc844919f6b5

Review

Receiving and Handling Events with Combine

概述

Combine 框架提供一个应用程序如何处理事件的声明式方案。不是潜在地实现多个代理回调或完成处理闭包,你可对一个给定事件源创建一个单处理链。链的每个部分是一个 Combine 操作从接收到的上一个步骤中执行一个在元素上执行一个不同的行为

考虑一个应用程序需要过滤一个表或基于文本框内容的 collection view。在 AppKit 中,文本框的每个敲键产生一个通知你可用 Combine 订阅。在收到通知后,你可使用操作来改变内容和发起事件转发,且使用最后的结果更新你的应用程序用户接口

连接一个发布者到一个订阅者

为用 Combine 接收文本框的通知,访问 NotificationCenter 的缺省实例并调用它的 publisher(for:object:) 方法。这个调用携带通知名称和你想要通知的源对象,并返回产生通知元素的一个发布者

let pub = NotificationCenter.default.publisher(for: NSControl.textDidChangeNotification, object: filterFeild)

你使用一个订阅者来接收来自发布者的元素。订阅者定义一个相关类型,Input,来声明它接收的类型。发布者也定义一个类型,Output,来声明它产生的结果。发布者和订阅者都定义一个类型,Failure,来显示它们产生或接收的错误类型。为连接一个订阅者到一个生产者,Output 必须匹配 Input,且 Failure 类型也必须匹配

Combine 提供两个内建的订阅者,其自动匹配其接触的发布者的 output 和 failure 类型:

  • sink(receiveCompletion:receiveValue:) 带两个闭包。第一个闭包当它接收到 Subscribers.Completion 时执行,其是一个列举显示是否发布者正常结束或失败。第二个闭包当它从发布者接收到一个元素时执行
  • assign(to:on:) 立即赋值它接收到的每个元素到一个给定对象的一个属性,使用键路径表示其属性

例如,你可使用 sink 订阅者在发布者完成时记日志,且每次它接收一个元素:

let sub = NotificationCenter.default.publisher(for: NSControl.textDidChangeNotification, object: filterField).sink(receiveCompletion: { print ($0) }, receiveValue: { print ($0) })

sink(receiveCompletion:receiveValue:) 和 assign(to:on:) 订阅者都从它们的发布者那请求无限制数目的元素。为控制你接收到元素的速度,需要通过实现订阅者协议创建你自己的订阅者

用操作改变 Output 类型

前面提到的 sink 订阅者在 receiveValue 闭包里执行它所有的工作。这可能有点繁重如果它在接收元素或维持调用状态间需要执行大量自定义工作。Combine 的优点是组合操作来自定义事件转发

例如,NotificationCenter.Publisher.Output 在回调中不是一个方便的类型如果所有你需要的是文本框的字符串值。因为一个发布者的 output 时间上的一系列元素,Combine 提供序列修改操作如 map(_ :), flatMap(maxPublishers:_ :) 和 reduce(_: _:)。这些操作的行为和它们在对应的 Swift 标准库中相似

为改变发布者的 output 类型,你添加一个 map(_ :) 操作,其返回一个不同的类型。这样,你可获得通知对象为一个文本框,然后获得文本框的字符串值

let sub = NotificationCenter.default.publisher(for: NSControl.textDidChangeNotification, object: filterField).map( {($0.object as! NSTextField).stringValue }).sink(receiveCompletion: { print ($0) }, receiveValue: { print ($0) })

在发布者链产生你想要的类型后,替换 sink(receiveCompletion:receiveValue:) 为 assign(to:on:)。以下例子获得从发布者链中的字符串并赋值它们到自定义 view model 对象的 filterString:

let sub = NotificationCenter.default.publisher(for: NSControl.textDidChangeNotification, object: filterField).map( {($0.object as! NSTextField).stringValue }).assign(to: \MyViewModel.filterString, on: myViewModel)

用操作自定义发布者

你可用一个操作执行行为来扩展发布者实例,否则需要手动写代码。有三种办法你可使用操作来改进事件处理链:

  • 相比用输入到文本框的任意字符串更新视图模型,你可以使用 filter(_ :) 操作来忽略某个长度以下的输入或拒绝非字母数字字符
  • 如果过滤操作成本高,例如,如果它查询一个大型数据库 - 你可能想要等待用户停止输入。这样,debounce(for:scheduler:options:) 操作让你设置一个小的时间区间必须达到发布者才能触发一个事件。RunLoop 类提供方便的方法用秒或毫秒指定时间间隔
  • 如果结果更新了 UI,你可转发回调到主线程通过调用 receive(on:options:) 方法。通过 RunLoop 类指定的Scheduler 实例作为第一个参数,你可告诉 Combine 在主 RunLoop 上调用你的订阅者

这样的发布者声明如下:

let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.map( {($0.object as! NSTextField).stringValue })
.filter( {$0.unicodeScalars.allSatisfy({CharacterSet.alphanumerics.contains($0)}) } )
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.receive(on: RunLoop.main)
.assign(to: \MyViewModel.filterString, on: myViewModel)

当需要时取消发布

一个发布者持续触发直到它正常完成或失败。如果你不再想要订阅发布者,你可取消订阅。sink(receiveCompletion:receiveValue:) 和 assign(to:on:) 创建的订阅者类型都实现了 Cancellable 协议,其提供一个 cancel() 方法

sub?.cancel()

如果你创建一个自定义订阅者,当你第一次订阅它时发布者发送一个订阅对象。存储这个订阅对象,且当你想要取消订阅时调用它的 cancel() 方法。当你创建一个自定义订阅者,你应该实现 Cancellable 协议,且你的 cancel() 实现要转发调用到存储的订阅对象

Tips

Concurrency

Swift 有内建的支持以结构性的方式写异步和并行代码。异步代码可被悬挂并之后再开始,每次只有程序的一个片段在执行。在程序中悬挂和开始代码让它在短时间操作上继续进展比如更新 UI 来运行长时间操作比如获取网络上的数据或分析文件。并行代码意味着多块代码同时运行 - 例如,一个 4 核计算机可同时运行 4 块代码,每个核运行一个任务。一个程序使用并行和异步代码一次执行多个操作,且它在等待一个外部系统时悬挂操作

并行或异步代码额外的调度灵活性也带来复杂度增加的代价。Swift 让你以一种方法表达你的意图启动一些编译期时间检查 - 例如,你可使用 actor 来安全地访问可修改的状态。然而,增加的并行性让代码变慢或出现有问题的代码不保证将变得更快或正确。事实上,添加并行可能甚至让你的代码更加难以调试。然而,使用 Swift 语言并行支持需要并行意味着 Swift 可帮你在编译器发现问题

本章下面的部分使用并行术语来指代异步和并行代码的组合

虽然使用不支持 Swift 语言写并行代码是可能的,但代码会更难阅读。例如,如下代码下载图片名列表,下载列表中第一个图片,并显示图片给用户:

listPhotos(inGallery: "Summer Vacation") { photoNames in
    let sortedNames = photoNames.sorted()
    let name = sortedNames[0]
    downloadPhoto(named: name) { photo in
        show(photo)
    }
}

即使在这样简单的例子中,因为代码不得不写成一系列完成处理函数,你需要写嵌套的闭包。在这样的风格中,更复杂的代码将深度嵌套可很快变得笨拙

定义和调用异步函数

一个异步函数或异步方法是一种特殊的函数或方法可悬挂。跟普通的函数相比,同步函数和方法要么运行到结束,要么扔出一个错误或者永不退出。一个异步函数或方法也是这样,但它可以在中间暂停。在异步函数或方法的内部,你可以每个这样执行的地方都能暂停

为显示一个函数或方法是异步的,你在它定义的参数之后写 async 关键字,跟写 throws 来标记一个抛异常的函数一样。如果函数或方法返回一个值,async 要写在箭头(->)之前。例如,这是一个你在一个画廊获取图片名的代码:

listPhotos(inGallery name: String) async -> [String] {
    let result =                // ... some asynchronous networking code ...
    return result
}

对异步并抛异常的函数或方法,async 要写在 throws 之前

当调用一个异步方法,执行悬挂直到该方法返回。你在调用前写 await 来标记可能的悬挂点。这像当调用一个抛异常函数时写 try 一样。在异步方法的内部,只有当你调用另一个异步方法时执行流被悬挂 - 悬挂绝不是间接的或抢占式的 - 即意味着每个可能的悬挂点被 await 标记。在代码中标记所有可能的悬挂点使得并行代码更容易阅读和理解

例如,以下代码获取画廊里所有图片的名字并然后显示第一个图片

let photoNmaes = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNmaes.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)

因为 listPhotos(inGallery:) 和 downloadPhoto(named:) 函数都需要网络请求,它们可能要相对长时间来完成。在返回箭头前写 async 使得它们异步让应用程序的代码保持运行当等待图片准备好时

为理解并行例子,这里是一个可能的执行顺序:

  1. 代码从第一行开始运行到第一个 await。它调用 listPhotos(inGallery:) 函数并悬挂执行等待那个函数返回
  2. 当这个代码执行被悬挂,相同程序的其他并行代码运行。例如,可能一个长时间运行的后台任务继续更新一个新图片画廊的列表。该代码也运行直到下一个悬挂点,或它执行完成
  3. 在 listPhotos(inGallery:) 返回后,这个代码继续执行悬挂点之后的代码。它赋值给返回的 photoNames
  4. sortedNames 和 name 的定义是常规代码,不会被悬挂
  5. 下一个 await 标记在调用 downloadPhoto(named:) 函数。这个代码又悬挂执行直到该函数返回,给其他并行代码一个运行的机会
  6. 在 downloadPhoto(named:) 返回后,它的返回值赋值给 photo 且然后作为一个参数传递给 show(:) 函数

代码中用 await 标记的可能悬挂点暗示当前代码片段可能暂停执行等待异步函数或方法返回。这也称为让出线程,Swift 在当前线程中悬挂代码的执行并在该线程上运行其他代码。因为 await 的代码需要能够悬挂执行,只有你程序里某些地方能调用异步函数或方法:

  • 在异步函数、方法、属性代码内部
  • 在一个结构、类或枚举用 @main 标记的静态 main() 方法代码里
  • 在一个未结构化子任务代码里,如下所示

你可通过调用 Task.yield() 方法直接插入一个悬挂点

func generateSlideshow(forGallery gallery: String) async {
    let photos = await listPhotos(inGallery: gallery)
    for photo in photos {
        // ... render a few seconds of video for this photo ...
        await Task.yield()
    }
}

假设渲染视频的代码是同步的,它不包含任何悬挂点。渲染视频的工作可能也要花费一点时间。然而,你可定期调用 Task.yield() 来直接添加悬挂点。这样结构化长时间运行代码让 Swift 在这个任务进度之间做出平衡,且让你的程序的其他任务有进展

Task.sleep(for:tolerance:clock:) 方法在写简单代码学如何并行工作时很有用。这个方法悬挂当前的任务给定的时间段。这是一个 listPhotos(inGallery:) 函数的版本使用 sleep(for:tolerance:clock:) 来模拟等待一个网络操作:

func listPhotos(inGallery name: String) async throws -> [String] {
    try await Task.sleep(for: .seconds(2))
    return ["IMG001", "IMG99", "IMG0404"]
}

listPhotos(inGallery:) 的这个版本异步也抛异常,因为调用 Task.sleep(until:tolerance:clock:) 会抛一个异常。当你调用这个版本函数时,要如下调用:

let photos = try await listPhotos(inGallery: "A Rainy Weekend")

异步函数和异常函数有相似性:当你定义一个异步函数或异常函数,你要标记 async 或 throws,且你调用时标记 await 或 try。一个异步函数可调用另一个异步函数,就像异常函数可调用另一个异常函数

然而,有一个非常重要的不同点。你可包装异常函数在 do-catch 块里并处理异常,或使用 Result 来存储在其他地方处理它的错误。这种方式让你从非抛出代码里调用抛异常函数,例如:

func availableRainyWeekendPhotos() -> Result<[String], Error> {
    return Result {
        try listDownloadedPhotos(inGallery: "A Rainy Weekend")
    }
}

相反,没有安全的方法来包装异步代码使得你可以在同步代码中调用它并等待结果。Swift 标准库意图忽略这种不安全的功能 - 你自己尝试实现可导致问题如子竞争、线程问题和死锁。当添加并行代码到一个现存的工程,从顶到低的方式。特别地,开始转换最上层的代码使用并行,并然后开始转换它调用的函数和方法,每次在工程结构的一层中进行。没有从低到顶的处理方式,因为同步代码不能调用异步代码

异步顺序

前面的 listPhotos(inGallery:) 函数一次在所有数组元素准备好后异步返回整个数组。另一个处理是使用异步顺序一次返回集合的一个元素。下面是迭代异步顺序:

import Foundation

let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
    print(line)
}

例子中 for 循环里用了 await 关键字。就像当你调用一个异步函数或方法,写 await 关键字意味着一个可能的悬挂点。一个 for await 循环可能在每次迭代的开始被悬挂执行,当它等待下一个元素有效时

相同的方法你可在 for 循环中使用你自己的类型通过适配 Sequence 协议,你可通过实现 AsyncSequence 协议在 for await 循环中使用你自己的类型

并行调用异步函数

用 await 调用一个异步函数一次只运行一个代码片段。当异步代码运行时,被调用者在运行下一行代码之前等待前面的代码完成。例如,从一个画廊中获取三个图片,你可 await 三个 downloadPhoto(named:) 函数地调用:

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

这个处理有一个重大的缺陷:虽然下载是异步的且让下载时让其他代码执行,但一次只有一个 downloadPhoto(named:) 函数被调用。在下一个开始下载前之前的下载要先完成。然而,不需要这些操作等待 - 每个图片可独立下载,或甚至同时

为调用一个异步函数且让它并行运行,写 async 关键字在 let 关键字之前,然后写 await 在每次你使用该常量时

async let firstPhoto = await downloadPhoto(named: photoNames[0])
async let secondPhoto = await downloadPhoto(named: photoNames[1])
async let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

这个例子中,所有三个 downloadPhoto(named:) 函数调用开始时不会等待前一个结束。如果有足够的系统资源,它们可同时运行。这些函数调用没有用 await 标记因为其不支持等待函数结果。执行继续直到 photos 变量被定义 - 这时,程序需要这些异步调用的结果,这样你写 await 来暂停执行直到所有三个图片完成下载

如下是这两个处理之间的不同:

  1. 用 await 调用异步函数当你的代码依赖该函数的结果时。这使得工作是顺序的
  2. 用 async let 调用异步函数当你当前不需要结果时,这使得其工作是并行的
  3. await 和 async let 都允许当它们被悬挂时其他代码执行
  4. 这两种情况,你用 await 标记可能的悬挂点来显示执行将被暂停,直到异步函数返回

你也可在相同的代码中混用这两种处理

任务和任务组

一个任务是一个工作单位可作为你程序的一部分异步运行。所有异步代码作为任务的一部分运行。一个任务本身一次只做一件事,但当你创建多个任务时,Swift 可调度它们同时运行

之前的 async let 语法描述间接创建一个子任务 - 这个语法当你已经知道你程序需要运行的是什么任务时工作很好。你也可创建一个任务组(一个 TaskGroup 实例)并直接添加子任务到该组,其给你更多有关优先级和取消的控制,且让你创建一个动态数量的任务

任务是分层排列的。在一个给定组中每个任务有相同的父任务,且每个任务可有子任务。因为任务和任务组之间的直接关系,这个处理被称为结构化并行。任务间直接的父子关系有几个优点:

  1. 在父任务中,你不能忘记等待子任务结束
  2. 当设置一个更高的优先级给一个子任务,父任务的优先级自动升级
  3. 当一个父任务被取消,每个它的自任务也自动取消
  4. 本地任务值能高效自动地广播到子任务

下面是下载图片处理任意数量图片的另一个版本

await withTaskGroup(of: Data.self) { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        group.addTask {
            return await downloadPhoto(named: name)
        }
    }

    for await photo in group {
        show(photo)
    }
}

以上代码创建了一个任务组,且创建了子任务来下载画廊里每个图片。Swift 按条件允许并行运行这些任务。一旦子任务完成下载,该图片会被显示。不保证子任务完成的顺序,这样这个画廊的图片可以任意顺序显示

注意如果下载图片的代码可抛出异常,你要调用 withThrowingTaskGroup(of:returning:body:) 函数

上面的代码,每个图片下载后显示,这样任务组不返回任何结果。对一个任务组返回一个结果,你添加代码在闭包中累计它的结果传递给 withTaskGroup(of:returning:body:)

let photos = await withTaskGroup(of: Data.self) { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        group.addTask {
            return await downloadPhoto(named: name)
        }
    }

    var results: [Data] = []
    for await photo in group {
        results.append(photo)
    }

    return results
}

跟之前的例子一样,这个例子对每个图片创建一个子任务进行下载。不同的是,for await 循环等待直到所有的子任务完成。最后,任务组返回一个下载图片的数组作为它的整个结果

任务取消

Swift 并行使用一个可操作的取消模型。每个任务检查在它执行时在合适的点是否已取消,且对应进行合适的取消。依赖于任务在做的什么,响应取消通常意味着以下之一:

  • 抛出一个错误如 CancellationError
  • 返回 nil 或一个空的集合
  • 返回一个部分完成的工作

下载图片可能要较长时间如果图片很大或网络慢。为让用户停止工作,不需要等待所有任务完成,任务需要检查取消并如果已取消要停止运行。任务有两个方式可这样做:通过调用 Task.checkCancellation() 方法,或通过读取 Task.isCancelled 属性。调用 checkCancellation() 当任务已取消会抛出一个错误;一个可抛出任务可广播错误到任务外面,停止所有任务工作。这个的好处是实现简单且容易理解。为更好的灵活性,使用 isCancelled 属性,其让你执行清理工作作为停止任务的一部分,例如关闭网络连接和删除临时文件

let photos = await withTaskGroup(of: Optional<Data>.self) { group in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        group.addTaskUnlessCancelled {
            guard isCancelled == false else { return nil }
            return await downloadPhoto(named: name)
        }
    }

    var results: [Data] = []
    for await photo in group {
        if let photo { results.append(photo) }
    }

    return results
}

以上代码对之前的版本做了一些修改:

  • 每个任务通过 TaskGroup.addTaskUnlessCancelled(priority:operation:) 方法添加,为避免取消后才开始新的工作
  • 每个任务在开始下载图片之前检查取消。如果它已经取消,任务返回 nil
  • 最后,任务组当收集结果时跳过 nil。通过返回 nil 处理取消意味着任务组可返回一个部分结果 - 图片在取消时间点时已下载的图片 - 而不是丢弃所有的工作

对需要理解通知取消的工作,使用 Task.withTaskCancellationHandler(operation:onCancel:) 方法。例如

let task = await Task.withTaskCancellationHandler {
    // ...
} onCancel: {
    print("Canceld!")
}

// ... some time later ...
task.cancel()                   // Prints "Canceled!"

当使用一个取消处理方法,任务取消仍然可以配合,任务要么运行完成或者检查取消并停止。因为任务在开始取消处理方法时仍然在运行,要避免在任务和它的取消处理方法之间分享状态,这样会导致死锁

非结构化并行

相对于前面描述的结构化并行,Swift 也支持非结构化并行。不像任务是任务组的一部分,一个非结构化任务没有父任务。你有完全的灵活性来管理非结构化任务以任务的方式,但你也要完全负责它们的正确性。为创建一个非结构化任务运行在一个当前的 actor 上,称为 Task.init(priority:operation:) 初始化器。为创建一个非结构化任务不是当前 actor 的一部分,更明确地说是脱离的任务,为 Task.detached(priority:operation:) 类方法。这些方法返回一个你可以交互的任务 - 例如,等待它的结果或取消它

let newPhoto =                  // ... some photo data ...
let handle = Task {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.value

Actor

你可使用任务来分解你的程序为独立的、并行的片段。任务相互隔离,这使得它们同时运行是安全的,但有时你需要在任务间分享一些信息。Actor 让你安全的在并行代码间分享信息

跟类一样,actor 是引用类型,跟类不同的是,actor 允许一次只有一个任务访问它们的可修改状态,这样在多任务里跟相同实例的 actor 交互是安全的。例如,这里有一个 actor 记录温度:

actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int

    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [masurement]
        self.max = measurement
    }
}

你用 actor 关键字引入一个 actor,跟着括号里的定义。TemperatureLogger actor 有属性其他代码可以访问,且限制 max 属性只能在 actor 内部更新

你用和结构、类相同的初始化语法创建一个 actor 实例。当你访问 actor 的一个属性或方法时,你使用 await 来标记潜在的悬挂点,例如

let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)

在这个例子中,访问 logger.max 是一个可能的悬挂点。因为 actor 允许一次一个任务来访问它的可修改状态,如果另一个任务的代码已准备和 logger 交互,它将被悬挂等待访问该属性

相反,不是 actor 一部分的代码在访问 actor 属性时不写 await。例如,这里有一个用新的 teperature 更新一个 TemperatureLogger 的方法:

extension TemperatureLogger {
    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}

update(with:) 方法已经在 actor 上运行,这样它不用 await 标记它对属性的访问。这个方法也显示原因之一为什么 actor 一次只允许一个任务和它们的可修改状态交互:一些到 actor 状态的更新临时破坏了不变性。TemperatureLogger actor 保持跟踪温度列表且最大的温度,且当你记录一个新度量时它更新最高的温度。在一个更新的中间,在添加新 measurement 但更新 max 之前,temperature logger 在一个临时不一致的状态上。防止多个任务同时交互同一个实例防止了如下顺序的事件

  1. 你的代码调用 update(with:) 方法,它先更新 measurements 数组
  2. 在你的代码能更新 max 之前,其他的代码读最大值和温度数组
  3. 你的代码通过改变 max 对它进行更新

在这个例子中,其他运行的代码会读到不正确的信息因为它对 actor 的访问在当数据无效调用 update(with:) 方法的中间被打断。你可防止这个问题使用 Swift actor 因为它们只允许一次一个操作在它们的状态上,且因为代码只在 await 制作的悬挂点处被打断。因为 update(with:) 不包含任何悬挂点,在更新的中途没有其他代码可访问数据

如果 actor 之外的代码尝试直接访问这些属性,就像访问一个结构或类的属性,你将得到一个编译期错误。例如

print(logger.max)               // Error

访问 logger.max 没有写 await 会失败因为一个 actor 的属性是该 actor 隔离本地状态的一部分。访问这个属性的代码需要作为 actor 的一部分运行,其是一个异步操作且需要写 await。Swift 保证只有运行在 actor 上的代码能访问该 actor 的本地状态。这个保证被称为 actor 隔离

Swift 并行模型的如下方面一起工作来使得它更容易共享可修改状态:

  • 可能悬挂点的代码顺序运行,没有从其他并行代码打断的可能性
  • 与一个 actor 的本地状态交互的代码只能运行在那个 actor 上
  • 一个 actor 一次只运行一段代码

因为这些保障,代码不包括 await 且在 actor 内部的可无风险地在你的程序其他地方更新而不会观察到临时无效状态。例如,下面的代码转换华氏温度到摄氏温度:

extension TemperatureLogger {
    func convertFahrenheitToCelsius() {
        measurements = measurements.map { measurement in
            (measurement - 32) * 5 / 9
    }
}

上述代码转换度量数组,一次一个。当 map 操作在进行中,一些温度为华氏另一些为摄氏。然而,因为代码里没有 await,在这个方法里没有潜在的悬挂点。这个方法修改的状态属于 actor,其防止读或修改除了那些运行在 actor 上的代码。这意味着其他代码无法读到部分转换的温度列表当转换在进行中时

为了在一个 actor 中写代码通过忽略潜在悬挂点包含临时无效状态,你可移动代码到一个同步方法。convertFahrenheitToCelsius()方法是一个同步方法,这样它保证不会包含潜在的悬挂点。这个函数封装临时使得数据模型无效的代码,且使得它容易阅读来识别出在完成工作之前没有代码可在数据一致性存储之前运行。将来,如果你尝试添加并行代码到这个函数,包含一个可能的悬挂点,你将得到一个编译期错误而不是引入一个 bug

可发送类型

任务和 actor 让你分割一个程序为可安全并行运行的块。在任务的内部或 actor 实例内,程序部分包含可修改状态,像变量和属性,被称为一个并行域。一些类型数据不能在并行域间共享,因为数据包含可修改状态,但它不保护重叠访问

一个可从一个并行域到另一个并行域分享的类型被称为可发送类型。例如,当调用一个 actor 方法或作为一个任务的结果返回时可作为参数被传递。之前的例子没有讨论可发送性因为这些例子使用简单值类型对并行域间分享总是安全的。相反,一些类型在跨并行域时不安全。例如,一个包含可修改属性的类且不串行访问这些属性会产生不可预测且不正确的结果当你在不同任务间传递该类的实例时

你通过声明实现 Sendable 协议来使得一个类型可被发送。该协议没有任何代码需求,但它有一些 Swift 强制的语义需求。一般的,对一个类型有三种方法称为可发送的:

  • 值类型,且它的可修改状态是其他可发送数据制成的 - 例如,一个有可发送的存储属性的结构或一个有可发送类型值的枚举
  • 类型没有任何可修改状态,且它的不可修改状态是其他可发送类型组成的 - 例如,一个只有只读属性的结构或类
  • 类型有代码确保它可修改状态的安全性,像一个类用 @MainActor 或一个类在一个特殊的线程或队列串行访问它的属性

一些类型总是可发送的,像只有可发送类型的结构和只有可发送相关值的枚举。例如

struct TemperatureReading: Sendable {
    var measurement: Int
}

extension TemperatureLogger {
    func addReading(from reading: TemperatureReading) {
        measurements.append(reading.measurement)
    }
}

let logger = TemperatureLogger(label: "Tea kettle", measurement: 85)
let reading = TemperatureLogger(measurement: 45)
await logger.addReading(from: reading)

因为 TemperatureReading 是一个结构只有可发送属性,且结构不标记 public 或 @usableFromInline,它间接地可发送。这是结构的一个版本实现 Sendable 协议:

struct TemperatureReading {
    var measurement: Int
}

为直接标记一个类型为非可发送,用一个间接的对 Sendable 协议的确认,使用一个扩展:

struct FileDescriptor {
    let rawValue: CInt
}

@available(*, unavailable)
extension FileDescriptor: Sendable {}

上面代码显示部分 POSIX 文件句柄的封装。虽然文件句柄在接口上用整数来确定和交互打开的文件,且整数是可发送的,一个文件句柄在跨并行域之间发送是不安全的

上面代码,FileDescriptor 是一个结构符合 sendable 要求。然而,扩展使得它不符合 Sendable 协议,防止类型为可发送的

Share

Swift 学习一些总结

最近在学 Swfit,发现跟几年前相比,差别还是比较大的。主要有两个方面:

一是有了响应式编程需要的内建库 Combine,以前 Swift 需要引入三方的响应式编程库,现在苹果自带了。使用响应式编程对于界面开发来说是非常友好的,使得代码更少,更易懂,相比非响应式编程代码,代码量减少很明显

二是并行编程,Swift 借鉴了其他语言的特点,用了新的任务及新增了 async await 等关键字,及引入了 actor,使得并行代码比以前相比更简洁,避免了之前代码回调地狱的问题

可以遇见,以后写 Swift 代码,用这些新特性和功能是必须的,之前的代码可参考价值会减少,要多用这些新的特性来写代码,这是不可逆的趋势