Swiftの並行計算で型付きエラーを保持するには、各子タスクから型付きResultを返し、収集後にアンラップします。このMacの例では、入力順、明示的な失敗、そしてバッチ全体が成功するかエラーになるかという契約を保ちながら、合成した矩形のピクセル数を計算します。

タスクグループの境界で型付きの結果を使う理由

複数の矩形のピクセル数を計算するデスクトップ向けグラフィックスプレビューを作成しているとします。各計算では、無効な寸法を拒否したり、整数のオーバーフローを報告したりすることがあります。呼び出し側には、順序が保たれた完全な配列か、特定の計算エラーが必要です。そうすれば、対応するすべての失敗を明示的に処理できます。

Swift 6.0では型付きthrowsが導入されました。throws(CalculationError)のような宣言を使うと、その関数が投げる値を指定したエラー型に限定できます。実装と呼び出し側が一緒に進化する、小規模で閉じた計算契約に便利です。SE-0413: Typed throwsを参照してください。

並行処理APIの境界が重要です。Appleが公開しているwithThrowingTaskGroupのシグネチャは、その本体にThrowingTaskGroup<ChildTaskResult, any Error>を渡します。このジェネリックに見えるグループ型では、そのファクトリを通じて具体的なドメインエラー型を指定できません。したがって、計算関数に型付きthrowsを追加しただけでは、このグループAPI全体で具体的なエラー型が確立されるわけではありません。withThrowingTaskGroupを参照してください。

対応する方法のひとつは、withTaskGroupを使い、各子タスクがResult<Int, CalculationError>を返すようにすることです。タスクは、成功または失敗を表す値を返して正常に完了します。値を収集した後、バッチ関数はget()を呼び出します。その公開シグネチャはthrows(Failure)です。これにより、実行時キャストなしで型付きthrowsに戻せます。AppleのResultのドキュメントとget()のドキュメントを参照してください。

並行処理コードを書く前にバッチの契約を定める

この例では、各入力にピクセル単位の整数の幅と高さが含まれます。計算するのはその積です。すべて合成データで、メモリ内にとどまります。プログラムは画像を描画せず、アプリケーションデータを読み込んだり保存したりもしません。

以下の方針は、この例の設計上の選択です。返される値と選択されるエラーの両方を予測可能にします。

状況 バッチの動作
すべての矩形が有効 入力順にピクセル数を返す
幅または高さが0以下 該当する矩形を無効として報告する
積がIntに収まらない 該当する矩形のオーバーフローを報告する
複数の矩形で失敗 入力インデックスが最も小さい失敗を報告する
バッチのチェックポイントでキャンセルが検出された .cancelledを報告する
入力配列が空 キャンセルが検出されない限り、空の配列を返す

入力インデックスが最も小さいものを選ぶには、計算エラーを選ぶ前にすべての子タスクの結果を収集する必要があります。Swiftでは、タスクグループのスケジュール順序は保証されず、完了順が投入順と異なることもあります。各結果にインデックスを付けることで、収集側が意図した順序に戻せます。TaskGroupを参照してください。

この方針は、一貫したフィードバックが重要な小規模プレビューのバッチに適しています。意図的にすべての計算を待ちます。実行中に最初に検出した失敗を報告する設計では別のトレードオフが生じ、実行ごとに異なる矩形が選ばれる可能性があります。

Macでローカルの実行例を完成させる

インストール済みのSwift 6以降のツールチェーンを使います。Terminalでswift --versionを実行して確認してください。Swift.orgのGetting Startedに確認方法が記載されており、Install Swift — macOSにはMacへのインストール手順があります。

インストールページに記載された手順で、小さな実行可能パッケージを作成します。

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

生成されたSources内のSwiftエントリーファイルを、以下の3つのコードブロックで順に置き換え、ひとつのファイルにまとめます。実行エントリーポイントはひとつだけにしてください。この例では、パッケージの通常のデフォルトアクター隔離を使っています。デフォルトでメインアクター隔離を設定したプロジェクトでは、計算用の型と関数をその隔離の外に配置する必要があります。

入力と具体的な失敗を定義する

エラーの列挙型は構造化された情報を保持します。ケースが問題を示し、関連付けられたインデックスが入力を示します。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>
}

計算して収集し、型付きthrowsに戻す

算術処理にはmultipliedReportingOverflow(by:)を使います。Appleの説明によると、そのpartialValueが積全体になるのはoverflowがfalseの場合だけです。trueの場合は切り詰められた積が入ります。以下のガードにより、その切り詰められた値が成功したピクセル数として扱われるのを防ぎます。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によると、当初予定されていた型付きthrows推論の全面的な変更はSwift 6.0には実装されませんでした。Typed throwsの実装に関する注記を参照してください。

collectedに追加するのは親の収集側だけです。各子タスクは矩形とインデックスを受け取り、それぞれ独自のSendableな結果を返します。これは、子タスクの結果値がSendableであるというタスクグループの要件に沿っています。throwしないグループ本体で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です。複数の失敗がある例では、2つ目の子タスクが先に完了していても、入力順に結果を確認するため、最初の矩形が選ばれます。

キャンセルと部分的な結果はどのように扱われるか

Swiftのキャンセルは協調的です。タスクはキャンセルを検出して応答する必要があり、キャンセルを要求しても計算が自動的に中断されるわけではありません。また、構造化タスクグループは、戻る前に子タスクの完了を待ちます。Swift言語ガイドのConcurrencyの章とAppleのwithTaskGroupのドキュメントを参照してください。

ここでは、投入前、各計算の内部、収集後にTask.isCancelledを確認します。チェックポイントでキャンセルが検出されると、既存の.cancelledケースで表します。この閉じた契約に別のエラー型を追加せず、Booleanを読み取ります。AppleのTask.isCancelledにプロパティの説明があります。

収集後のチェックでは、検出されたバッチのキャンセルが矩形の失敗より優先されます。これらのチェックはチェックポイントであり、原子的な保証ではありません。最後のチェック後にキャンセルが要求された場合、返される結果に影響しないことがあります。より長い計算で処理を繰り返す場合は、その処理の内部にもチェックを追加し、キャンセルをより早く検出できるようにしてください。

この実装では、子タスクの失敗によってキャンセルは発生しません。子タスクは通常のResult.failure値を返し、収集側は処理を続けます。すべての子タスクが成功すると、呼び出し側はすべてのピクセル数を受け取ります。アンラップ時に失敗が見つかると、関数はエラーを投げます。ローカルで部分的に組み立てられたcounts配列は返されません。

デスクトップのインターフェースで矩形ごとに成功または失敗の状態が必要なら、公開するバッチ契約を変更して、順序付きの結果を返してください。各失敗にはインデックスを付けたままにします。これは、ここで実装した全件成功かエラーかの配列とは、利用者から見た動作が異なります。

エラー型が失われたときに、失敗を隠さず原因を調べる

変更したコードのコンパイルが止まったら、各境界に沿って次の順に型を確認してください。

  1. 計算関数がthrows(CalculationError)を宣言していることを確認します。
  2. 子タスクの値が、失敗型をany ErrorとするResultではなく、Result<Int, CalculationError>であることを確認します。
  3. 推論によってエラー型が広がる場合は、クロージャの明示的な型付きthrows注釈を残します。
  4. これらのResult値の受け渡しには、throwしないwithTaskGroupを使います。
  5. 型付きバッチ関数の内部でget()を使ってアンラップします。
  6. 新たに追加したthrowする処理が、別のエラー型を導入していないか確認します。

強制キャストでは、この契約を証明できません。同様に、失敗した結果を破棄したり、0に置き換えたりすると、計算の意味が変わります。選択したバッチ方針で報告方法を決める時点まで、失敗は値として保持してください。

このような小さな乗算では、並行処理は設計を示すためのものであり、性能向上を主張するものではありません。得られるのは明確な契約です。成功したピクセル数は入力順を保ち、複数の失敗は決定的なルールで選ばれ、呼び出し側は直接処理できる具体的なエラー列挙型を受け取ります。

出典