要在并发 Swift 计算中保留类型化错误,请让每个子任务返回类型化的 Result,并在收集后将其解包。这个 Mac 示例会计算虚构矩形的像素数量,同时保持输入顺序、明确报告失败,并保证批处理要么全部成功,要么整体报错。

为什么要在任务组边界使用类型化结果?

假设你正在构建一个桌面图形预览工具,需要计算多个矩形的像素数量。每次计算都可能因尺寸无效而拒绝处理,也可能报告整数溢出。调用方需要获得完整且有序的数组,或某个明确的计算错误,这样就能显式处理所有受支持的失败情况。

Swift 在 Swift 6.0 中引入了类型化抛错。像 throws(CalculationError) 这样的声明会将函数抛出的值限制为该错误类型。对于实现和调用方共同演进的小型封闭计算契约,这很有用。参见 SE-0413:类型化抛错。

并发 API 的边界很重要。Apple 文档中的 withThrowingTaskGroup 签名会向其闭包提供一个 ThrowingTaskGroup<ChildTaskResult, any Error>。这个看似泛型的组类型并未通过该工厂提供具体的领域错误类型参数。因此,仅给你的计算函数添加类型化抛错,并不能让整个组 API 都建立在具体的抛错类型之上。参见 withThrowingTaskGroup。

一种受支持的做法是使用 withTaskGroup,并让每个子任务返回 Result<Int, CalculationError>。任务会正常完成,并返回一个表示成功或失败的值。收集这些值后,批处理函数会调用 get(),其文档中的签名是 throws(Failure)。这样无需运行时类型转换,就能恢复类型化抛错。参见 Apple 的 Result 文档和 get() 文档。

编写并发代码之前先确定批处理契约

在本示例中,每个输入都包含一个以像素为单位的整数宽度和高度。计算结果是两者的乘积。所有数据都是虚构的,并保留在内存中;程序既不渲染图像,也不加载或保存应用数据。

以下策略是本示例的设计选择。它们能让返回值和选定的错误都可预测:

情况 批处理行为
所有矩形都有效 按输入顺序返回像素数量
宽度或高度为零或负数 报告该矩形无效
乘积无法用 Int 表示 报告该矩形溢出
多个矩形失败 报告输入索引最小的那个失败
在批处理检查点检测到取消 报告 .cancelled
输入数组为空 返回空数组,除非检测到取消

选择输入索引最小的失败项,需要先收集所有子任务的结果,再选择计算错误。Swift 不保证任务组的调度顺序,完成顺序也可能不同于提交顺序。给每个结果附加索引,收集器就能恢复预期顺序。参见 TaskGroup。

对于需要可重复反馈的小型预览批处理,这种策略很合适。它会有意等待所有计算完成。在执行期间报告第一个观察到的失败,则会采取不同的权衡,并且不同运行中选中的矩形可能不同。

在 Mac 上构建完整的本地示例

使用已安装的 Swift 6 或更高版本工具链。在 Terminal 中运行 swift --version 检查版本。Swift.org 在入门指南中记录了此检查,并在安装 Swift — macOS中提供 Mac 安装说明。

按照安装页面记录的工作流程创建一个小型可执行程序包:

mkdir TypedCalculations
cd TypedCalculations
swift package init --name TypedCalculations --type executable

将 Sources 下生成的 Swift 入口文件替换为以下三个代码块,并按顺序放在同一个文件中。只保留一个可执行入口点。本示例使用程序包通常的默认 actor 隔离;如果项目配置为默认使用主 actor 隔离,则需要将计算类型和函数放在该隔离之外。

定义输入和具体错误

错误枚举会保留结构化信息:case 标识问题,其关联索引标识输入项。Foundation 的 LocalizedError 协议提供可选的 errorDescription 供展示。下面的英文消息只是示例字符串;遵循该协议并不会自动翻译它们。参见 LocalizedError。

import Foundation

struct Rectangle: Sendable {
    let width: Int
    let height: Int
}

enum CalculationError: Error, Sendable, LocalizedError {
    case invalidDimensions(index: Int)
    case overflow(index: Int)
    case cancelled

    var errorDescription: String? {
        switch self {
        case .invalidDimensions(let index):
            return "Rectangle \(index + 1) needs positive dimensions."
        case .overflow(let index):
            return "Rectangle \(index + 1) has too many pixels for Int."
        case .cancelled:
            return "Calculation cancelled."
        }
    }
}

struct IndexedOutcome: Sendable {
    let index: Int
    let result: Result<Int, CalculationError>
}

计算、收集并恢复类型化抛错

算术运算使用 multipliedReportingOverflow(by:)。Apple 文档说明,只有当 overflow 为 false 时,其 partialValue 才是完整乘积;否则其中包含的是截断后的乘积。下面的检查可防止该截断值被当作成功的像素数量。参见 multipliedReportingOverflow(by:)。

func pixelCount(
    for rectangle: Rectangle,
    index: Int
) throws(CalculationError) -> Int {
    if Task.isCancelled {
        throw .cancelled
    }

    guard rectangle.width > 0, rectangle.height > 0 else {
        throw .invalidDimensions(index: index)
    }

    let product = rectangle.width.multipliedReportingOverflow(
        by: rectangle.height
    )
    guard !product.overflow else {
        throw .overflow(index: index)
    }
    return product.partialValue
}

func calculateBatch(
    _ rectangles: [Rectangle]
) async throws(CalculationError) -> [Int] {
    if Task.isCancelled {
        throw .cancelled
    }

    let outcomes: [IndexedOutcome] = await withTaskGroup(
        of: IndexedOutcome.self,
        returning: [IndexedOutcome].self
    ) { group in
        for (index, rectangle) in rectangles.enumerated() {
            group.addTask {
                let result = Result<Int, CalculationError> {
                    () throws(CalculationError) -> Int in
                    try pixelCount(for: rectangle, index: index)
                }
                return IndexedOutcome(index: index, result: result)
            }
        }

        var collected: [IndexedOutcome] = []
        for await outcome in group {
            collected.append(outcome)
        }
        return collected.sorted { $0.index < $1.index }
    }

    if Task.isCancelled {
        throw .cancelled
    }

    var counts: [Int] = []
    counts.reserveCapacity(outcomes.count)
    for outcome in outcomes {
        counts.append(try outcome.result.get())
    }
    return counts
}

在 Result 闭包上的显式 throws(CalculationError) 注解会让其错误契约清晰可见,而不依赖闭包类型推断。SE-0413 指出,原计划中完整的类型化抛错推断变更并未在 Swift 6.0 中实现。参见类型化抛错中的实现说明。

只有父级收集器会向 collected 追加元素。每个子任务接收一个矩形和一个索引,并返回自己的 Sendable 结果。这符合任务组对子任务结果值必须为 Sendable 的要求;非抛错的组闭包通过 for await 收集结果。参见 withTaskGroup。

添加可执行入口点

最后这个代码块提供所有示例输入,并调用批处理函数。catch 内的显式赋值也会在编译时检查预期的 API 契约:捕获到的值必须是 CalculationError。

@main
struct TypedCalculations {
    static func main() async {
        let examples: [(name: String, rectangles: [Rectangle])] = [
            (
                name: "Valid",
                rectangles: [
                    Rectangle(width: 640, height: 480),
                    Rectangle(width: 320, height: 200)
                ]
            ),
            (
                name: "Multiple failures",
                rectangles: [
                    Rectangle(width: 0, height: 20),
                    Rectangle(width: Int.max, height: 2)
                ]
            ),
            (
                name: "Overflow",
                rectangles: [
                    Rectangle(width: Int.max, height: 2)
                ]
            ),
            (name: "Empty", rectangles: [])
        ]

        for example in examples {
            do {
                let counts = try await calculateBatch(example.rectangles)
                print("\(example.name): \(counts)")
            } catch {
                let failure: CalculationError = error
                print("\(example.name): \(failure.errorDescription ?? "Calculation failed.")")
            }
        }
    }
}

在程序包目录中运行可执行程序:

swift run TypedCalculations

预期的应用程序输出如下,不包括构建消息。研究环境中没有 Swift 编译器,因此这里只根据检索到的 API 文档检查了该程序,并未编译或执行。

Valid: [307200, 64000]
Multiple failures: Rectangle 1 needs positive dimensions.
Overflow: Rectangle 1 has too many pixels for Int.
Empty: []

这些有效计数可由示例输入直接得出:640 乘以 480 等于 307,200,320 乘以 200 等于 64,000。在多个失败的示例中,第一个矩形会被选中,因为结果按输入顺序检查,即使第二个子任务先完成也是如此。

取消和部分结果会如何处理?

Swift 中的取消是协作式的。任务必须检查取消状态并作出响应;请求取消不会自动中断计算。结构化任务组也会等待其子任务完成后才返回。参见 Swift 语言指南的并发章节和 Apple 的 withTaskGroup 文档。

这里会在提交任务前、每次计算内部以及收集完成后检查 Task.isCancelled。如果检查点检测到取消,代码会使用现有的 .cancelled case 表示取消。读取布尔值可以避免在这个封闭契约中引入另一种抛错类型。Apple 在 Task.isCancelled中记录了这个属性。

收集完成后的检查会让已检测到的批处理取消优先于矩形计算错误。这些检查是检查点,并非原子保证:如果在最后一次检查后才请求取消,返回结果可能不会受影响。对于包含重复工作的较长计算,应在该工作内部添加检查,以便更快检测到取消。

在此实现中,某个子任务失败不会触发取消。它会返回一个正常的 Result.failure 值,收集器则继续处理。如果所有子任务都成功完成,调用方就会收到所有计数。如果解包时遇到失败,函数会抛出错误,其局部的部分组装 counts 数组不会返回。

如果桌面界面需要为每个矩形显示成功或失败状态,则应将公开的批处理契约改为返回有序结果。每个失败都应保留其索引。这与此处实现的全有或全无数组行为不同。

在不隐藏失败的情况下诊断错误类型丢失

如果改写后的代码无法编译,请按以下顺序追踪每个边界上的类型:

  1. 检查计算函数是否声明了 throws(CalculationError)。
  2. 检查子任务的值是否为 Result<Int, CalculationError>,而不是失败类型为 any Error 的结果。
  3. 如果推断扩大了错误类型,请保留闭包上显式的类型化抛错注解。
  4. 使用不抛错的 withTaskGroup 传递这些结果值。
  5. 在类型化批处理函数内使用 get() 解包。
  6. 检查新添加的抛错操作是否引入了其他错误类型。

强制类型转换无法证明这个契约。同样,丢弃失败结果或用零代替也会改变计算含义。应将失败保留为值,直到所选批处理策略决定如何报告它们。

对于这些简单的乘法,并发只是架构演示;这里没有声称它能提升性能。它带来的好处是精确的契约:成功计数保持输入顺序,多个失败有确定的选择规则,调用方会收到可直接处理的具体错误枚举。

来源