テックノート

Swift 5.5の新機能とは?
【Async/await編】

Swiftに非同期(async)関数を導入し、複雑な非同期コードをほとんど同期のように実行できるようになりました。
代表者 ロジャース リチャード 投稿者: ロジャース リチャード
xCode 13+ iOS 15+ macOS 12+ Swift 5.5+

Swift 5.5には、async/await、actors, throwingプロパティなど、膨大な改良が施されています。あまりにも多くのことが変わっているので、「Swift 5.5で新しくないものは何か」と尋ねるのが初めて簡単になったかもしれません。

概要

この記事では、コードサンプルを使ってそれぞれの変更点を説明し、実際にどのように動作するかを確認していただきます。これは、非常に多くの巨大な Swift Evolution の提案が、これほど緊密にリンクされた初めてのケースです。そのため、これらの変更を首尾一貫した流れの中で整理しようとしましたが、並行処理の作業のいくつかの部分は、いくつかの提案を読んだ後でなければ本当に理解できません。

ヒント: コードサンプルを自分で試してみたい方は、XcodeのPlaygroundとしてダウンロードすることもできます。

Async/await

SE-0296 はSwiftに非同期(async)関数を導入し、複雑な非同期コードをほとんど同期のように実行できるようにしました。これは、C#やJavaScriptなどの他の言語と同様に、非同期関数を新しいasyncキーワードでマークし、awaitキーワードを使って呼び出すという2つのステップで行われます。

async/awaitが言語にどのように役立つかを見るためには、以前に同じ問題をどのように解決したかを見ることが役に立ちます。完了ハンドラは、関数が戻ってきた後に値を送り返すことができるように、Swiftのコードでよく使われていますが、後述するようにトリッキーな構文を持っていました。

例えば、10万件の天気予報をサーバーから取得し、それらを処理して過去の平均気温を算出し、結果の平均値をサーバーにアップロードするコードを書こうと思ったら、次のように書いていました。

func fetchWeatherHistory(completion: @escaping ([Double]) -> Void) {
    // 複雑なネットワークコードは、100,000のランダムな温度を送り返すだけです。
    DispatchQueue.global().async {
        let results = (1...100_000).map { _ in Double.random(in: -10...30) }
        completion(results)
    }
}

func calculateAverageTemperature(for records: [Double], completion: @escaping (Double) -> Void) {
    // 配列を合計し、配列サイズで割る
    DispatchQueue.global().async {
        let total = records.reduce(0, +)
        let average = total / Double(records.count)
        completion(average)
    }
}

func upload(result: Double, completion: @escaping (String) -> Void) {
    // より複雑なネットワークコード。"OK "を返すだけにする
    DispatchQueue.global().async {
        completion("OK")
    }
}

実際のネットワークコードを偽の値で代用したのは、ネットワークの部分はここでは関係ないからです。そこで、関数の実行をブロックして直接値を返すのではなく、完了クロージャーを使って準備ができたときにだけ値を返すようにしています。

そのコードを使用する際には、以下のように、連鎖的に1つずつ呼び出して、それぞれに補完クロージャを用意して連鎖を続ける必要があります。

fetchWeatherHistory { records in
    calculateAverageTemperature(for: records) { average in
        upload(result: average) { response in
            print("Server response: \response")
        }
    }
}

この方法の問題点がお分かりいただけたと思います。

  • これらの関数では、完了ハンドラを複数回呼び出したり、完全に呼び出し忘れたりすることがあります。
  • パラメータ構文の @escaping (String) -> Void は読みにくいことがあります。
  • 呼び出しサイトでは、完了ハンドラごとにコードがどんどんインデントされていく、いわゆる「運命のピラミッド」ができあがります。
  • Swift 5.0で Result型 が追加されるまでは、完了ハンドラでエラーを送り返すことは困難でした。

Swift 5.5からは、以下のように完了ハンドラに頼るのではなく、非同期に値を返すようにマークすることで、関数をきれいにすることができるようになりました。

func fetchWeatherHistory() async -> [Double] {
    (1...100_000).map { _ in Double.random(in: -10...30) }
}

func calculateAverageTemperature(for records: [Double]) async -> Double {
    let total = records.reduce(0, +)
    let average = total / Double(records.count)
    return average
}

func upload(result: Double) async -> String {
    "OK"
}

これにより、非同期に値を返すための構文の多くが削除されましたが、コールサイトではさらにすっきりしています。

func processWeather() async {
    let records = await fetchWeatherHistory()
    let average = await calculateAverageTemperature(for: records)
    let response = await upload(result: average)
    print("Server response: \(response)")
}

ご覧のように、クロージャやインデントがすべてなくなり、「直線的なコード」と呼ばれるものになりました - awaitキーワードを除けば、同期コードと同じように見えます。

非同期関数の動作方法には、わかりやすく具体的なルールがあります。

  • 同期関数は、単に非同期関数を直接呼び出すことはできません - それは意味をなさないので、Swiftはエラーを投げます。
  • 非同期関数は、他の非同期関数を呼び出すことができますが、必要に応じて通常の同期関数を呼び出すこともできます。
  • 同じ方法で呼び出すことができる非同期と同期の関数がある場合、Swift は現在のコンテキストに一致する方を優先します。呼び出しサイトが現在非同期の場合、Swift は非同期関数を呼び出し、そうでない場合は同期関数を呼び出します。

この点は重要で、ライブラリの作者は、非同期関数に特別な名前を付けることなく、同期版と非同期版の両方を提供することができます。

async/awaitの追加は、try/catchと一緒に完全にフィットし、async関数とイニシャライザが必要に応じてエラーを投げることができることを意味します。ここでの唯一の注意点は、Swiftがキーワードの特定の順序を強制し、その順序がコールサイトと関数の間で逆になっていることです。

例えば、サーバーから多数のユーザーを取得し、ディスクに保存しようとする関数がありますが、いずれもエラーが発生して失敗する可能性があります。

enum UserError: Error {
    case invalidCount, dataTooLong
}

func fetchUsers(count: Int) async throws -> [String] {
    if count > 3 {
        // あまり多くのユーザーを取得しようとしないこと
        throw UserError.invalidCount
    }

    // 複雑なネットワークのコードがありますが、`count`個のユーザーまで送り返します。
    return Array(["Antoni", "Karamo", "Tan"].prefix(count))
}

func save(users: [String]) async throws -> String {
    let savedUsers = users.joined(separator: ",")

    if savedUsers.count > 32 {
        throw UserError.dataTooLong
    } else {
        // 実際の保存コードはこちらになります。
        return "Saved \(savedUsers)!"
    }
}

ご覧のように、これらの関数はどちらもasync throwsと書かれています。非同期の関数であり、エラーが発生する可能性があります。

それを呼び出すときには、キーワードの順番を反転させて、await tryではなくtry awaitとし、このようにします。

func updateUsers() async {
    do {
        let users = try await fetchUsers(count: 3)
        let result = try await save(users: users)
        print(result)
    } catch {
        print("Oops!")
    }
}

つまり、関数の定義では「非同期、スローイング」となっていますが、呼び出し先では「スローイング、非同期」となっており、スタックを巻き戻すようなイメージです。try awaitの方がawait tryよりも少し自然に読めるだけでなく、実際に起こっていることをより反映しています:ある作業が完了するのを待っていて、完了したときにはスローイングになるかもしれません。

Async/awaitがSwift自体に搭載されたことで、Swift 5.0で導入されたResult型の重要性はかなり低くなりました。それは、Resultが役に立たないということではなく、後で評価するために操作の結果を保存する最良の方法であるからです。

重要: 関数を非同期にしたからといって、魔法のように他のコードと並行して実行されるわけではありません。

これまで見てきた全てのasync関数は、順番に他のasync関数によって呼び出されてきましたが、これは意図的なものです。このSwift Evolutionの提案は、それ自体では、同期コンテキストから非同期コードを実行する方法を実際には提供していません。代わりに、この機能は別のStructured Concurrency提案で定義されていますが、私たちがFoundationにもいくつかの大きなアップデートを見ることができることを期待しています。