Posted at

SwiftのbeginAsyncにthrowsが要らない理由

More than 1 year has passed since last update.

iOSDC 2018 Japan の一昨日の @yimajo さんのセッション、 "Swiftの生みの親によるasync/await for Swiftを徹底解説し、新しい非同期処理の手法を理解する" で「 beginAsync の引数に渡すクロージャの型が () async throws -> Void だけれども、この throws はない方がいいんじゃないか」という趣旨の質問をしました。その意図について説明したいと思います。


beginAsync って何?

beginAsync は Swift の async/awaitプロポーザルドラフトに中で提案されている関数です。プロポーザルが通れば Swift の標準ライブラリに追加されます。

beginAsync のシグネチャは次の通りです。

func beginAsync(_ body: () async throws -> Void) rethrows -> Void

throws な関数は throws な関数の中でしか使えない(か do/catch で囲まないといけない)のと同じように、 async な関数は async な関数の中でしか使えません。しかし、そうすると最初に非同期処理を開始する箇所はどうすれば良いでしょうか。 main 関数まで async にしないといけなくなってしまいます。 asyncない の中で async な関数を開始するための関数が beginAsync です。

たとえば、次のような download 関数を考えてみます。

func download(from url: URL) async -> Data

ダウンロードは非同期処理なので async が付与されています。ボタンを押したらダウンロードを開始するという処理の場合、次のように書きたくなります。( throws な関数を呼ぶのに try の付与が必須なように、 async な関数を呼びのには await の付与が必須です。)

@IBAction func pressButton(_ sender: UIButton) {

self.data = await download(from: url)
}

しかし、 pressButtonasync でないただのメソッドなので、このメソッドの中で直接 download を呼びことはできません。そこで beginAsync を使って次のように書きます。

@IBAction func pressButton(_ sender: UIButton) {

beginAsync {
self.data = await download(from: self.url)
}
}

beginAsync に渡されるクロージャの型は () async throws -> Void なので、このクロージャ式の中では async な関数をコールできるわけです。


何が問題なの?

前述の通り、 beginAsync のシグネチャは次のようになっていますが、

func beginAsync(_ body: () async throws -> Void) rethrows -> Void

僕は、↓であるべきだと考えています。

func beginAsync(_ body: () async -> Void) -> Void

一見、 throws が付与されているから非同期エラー処理ができるように見えるんですが、 そんなことはありません

たとえば、次のようなコードを考えてみましょう。

func foo() {

do {
beginAsync {
let data = await download(self.url)
try data.write(to: pathURL) // ここでエラーが発生したらどうなる?
}
} catch let error {
// エラー処理
}
}

もし try data.write(to: pathURL) でエラーが発生した場合、外の catch でエラー処理することはできるでしょうか?

この foo が実行された場合、 beginAsync が呼ばれると、 beginAsync に渡されたクロージャに同期的に突入します。なので、 beginAsync のコールが終わる前に await download(self.url) が実行されます。

しかし、ここで await があるので、この後の処理は非同期化されます。同じスレッドで実行される保証すらありません。そのため、 download が呼ばれた直後に beginAsync を抜けて、当然この時点ではエラーは発生していないので catch 節に入ることもなく、そのまま foo を抜けることになります。

その後、ダウンロード処理が完了すると結果が data に代入され、ようやく try data.write(to: pathURL) が実行されます。ここでエラーが発生したとしても、もう foodo/catch はとっくに抜けた後です。 beginAsync の外側にエラーを rethrow することはできません。結果、発生したエラーは黙殺するか、クラッシュさせるしかなくなります。コンパイル時に静的に検査できない危険なコードになってしまいました。

もし、↓のように throws がなければどうなるでしょうか?

func beginAsync(_ body: () async -> Void) -> Void

beginAsync には throws なクロージャを渡せなくなるので、クロージャの中で throws な関数をコールすることもできなくなります。そのため、次のようなコードはコンパイルエラーとなります。

beginAsync {

let data = await download(self.url)
try data.write(to: pathURL) // try できないのでコンパイルエラー
}

もしエラー処理をしたければ次のように書くことになります。

beginAsync {

do {
let data = await download(self.url)
try data.write(to: pathURL) // `do/catch` の中なので OK
} catch let error {
// エラー処理
}
}

これなら beginAsync の中でエラー処理が完結しているので OK です。コンパイルエラーによって正しくエラー処理を促すことができました。素晴らしいですね!

というわけで、 throws 要らないんじゃないかということでした。いかがでしょうか?