Discardable result use in Task initializers
01 何が問題だったのか
Task.init や Task.detached など、unstructured なタスクを生成する API には、これまで一律に @discardableResult が付けられていました。そのため次のように、タスク内で投げられたエラーを握り潰してしまうコードでも警告が出ませんでした。
Task { // 警告なし
try boom()
}
print("Yay!")
このようなシンプルなコードなら気づけるかもしれませんが、実際のコードベースではノイズに紛れて「エラーが何事もなく無視されている」ことが見落とされがちです。この挙動は、エラーを明示的に扱うことを求めるSwiftのエラーハンドリングモデルと整合しておらず、気づきにくいバグの温床になっていました。
Task {} はもともと fire-and-forget 用途を主に想定した設計だったという経緯がありますが、その後typed throwsが導入され、Task.init / Task.detached / Task.immediate / Task.immediateDetached では throw される型(throws(Failure))が型情報として取れるようになっています。この情報を活かして、「throwする可能性のあるunstructuredなタスクの結果だけは握り潰させない」仕組みが求められていました。
02 どのように解決されるのか
Task.init などの unstructured なタスク生成APIから @discardableResult を外すのではなく、typed throws の Failure 型を見て警告を出す専用の診断を新設します。Failure が Never でない(つまりthrowしうる)場合に、戻り値を使わずに捨てていると警告が出ます。非スローのタスクは従来どおり、結果を無視してもよい fire-and-forget として扱われ、警告は出ません。
Task { }
// 警告なし
Task { throws in ... }
// warning: Unstructured throwing task was not used, which may accidentally
// ignore errors thrown inside the task [#NoUseUnstructuredThrowingTask]
// note: To silence this warning, handle the error inside the task,
// or store/discard the task value explicitly
Task { throws(Boom) in ... }
// 同じ警告
Task { throws(Never) in ... }
// 警告なし
同じ扱いは Task.detached や Task.immediate、Task.immediateDetached といった、他のunstructuredなタスク生成APIにも適用されます。
警告の黙らせ方
警告は、タスクの結果を「意図的に無視している」ことを明示するか、実際に結果を使うことで解消できます。明示的に捨てる場合は _ = で受けます。
_ = Task { try boom() }
結果を観測したい場合は、タスクを変数に束縛して value を try await すれば、タスク内で発生したエラーがそのまま伝播します。
let task = Task { throws in ... }
let value = try await task.value // タスクが失敗していればここで throw される
設計上の判断
以前のイテレーションでは、Failure が Never のときだけ @discardableResult を付けたオーバーロードを別に用意する案も検討されました。しかしそれではtyped throws導入によって減らしたはずのオーバーロードが再び増えてしまうため、代わりに Failure 型を見て発火する専用の警告という形でこの問題に対処しています。
また、非スローの Task.init から @discardableResult を外すことは提案されていません。結果を返さない、あるいは結果に興味がないなら fire-and-forget として使うのが通常の意図であり、そちらでは警告を出さずに済ませる方が実用的だからです。本Proposalはソース互換で、影響は新しい警告が増える点に限られます。