Swift Digest
SE-0493 | Swift Evolution

Support async calls in defer bodies

Proposal
SE-0493
Authors
Freddy Kellison-Linn
Review Manager
Holly Borla
Status
Implemented (Swift Next)

01 何が問題だったのか

defer 文は Swift 2 で導入された、スコープを抜けるときに必ず実行される後始末用の構文です。同じスコープに複数の defer がある場合は宣言の逆順で実行され、関数内の例外パスを含むあらゆる出口で確実に呼ばれることが保証されます。これにより、後始末コードを対応するセットアップのすぐそばに書きながら、すべての退出パスに後始末を書き並べる手間と書き漏らしのリスクを避けられます。

func sendLog(_ message: String) async throws {
  let localLog = FileHandle("log.txt")

  // throw しても必ず実行される
  defer { localLog.close() }

  localLog.appendLine(message)
  try await sendNetworkLog(message)
}

しかし、defer の本体はこれまで非同期処理を行えませんでした。囲んでいる関数が async であっても、defer の中で await を書くとエラーになります。

func f() async {
  await setUp()
  // error: 'async' call cannot occur in a defer body
  defer { await performAsyncTeardown() }

  try doSomething()
}

そのため、後始末自体に非同期処理が必要な場合には、いずれも妥協を強いられていました。

  • 関数のすべての退出パスに手動で後始末を書く(書き漏らしのリスクを defer で消したかったのに、結局そのリスクが戻ってくる)。
  • defer の中で新しい Task を起こして後始末を投げる(その後始末が実際にいつ終わるかを呼び出し側からは制御できず、関数の戻り時点で完了している保証もない)。
defer {
  // いつか終わるはず…
  Task { await performAsyncTeardown() }
}

defer 本来の「スコープ終了までに後始末が確実に終わっている」という保証を、非同期処理に対しても素直に得られる手段が必要でした。

02 どのように解決されるのか

囲んでいる文脈が async であれば、defer の本体に await を書けるようにします。スコープを抜ける時点では、これまで通り宣言の逆順で defer の本体が順に実行され、その中で非同期処理を含むものは暗黙に await され、関数が return する前に完了まで待たれます。

func f() async {
  await setUp()
  defer { await performAsyncTeardown() } // OK

  try doSomething()
}

defer 自体に defer async のような新しいマーカーは導入しません。defer の本体に書かれた await がそのまま「この defer には中断ポイントがある」ことを示し、囲んでいる関数やクロージャ側でも従来通り async であることが必要になります。

囲み文脈は async でなければならない

defer の本体で await を使うには、囲んでいる関数やクロージャが(明示的または推論によって)async である必要があります。同期関数の中の deferawait を書くことはできません。

func f() {
  // error: 'async' call in a function that does not support concurrency
  defer { await g() }
}

クロージャのように async が推論される位置では、defer 本体の中の await だけでも囲みクロージャを async と推論するに足ります。

// 'f' は '() async -> ()' と推論される
let f = {
  defer { await g() }
}

isolation は囲み側を引き継ぐ

非同期な defer の本体は、常に囲んでいるスコープの isolation をそのまま引き継ぎます。そのため、defer の本体が呼び出す関数自体に由来する中断ポイント以上に、追加の中断ポイントが発生することはありません。

暗黙の await とキャンセル

スコープ終了時の defer 本体の呼び出しは暗黙に await されます。これは、関数の途中の tryawait のような明示的なマーカーがスコープ退出側には付かないことを意味しますが、defer 本体の中の中断ポイントには必ず await が書かれているので、ソース上に await が一切現れないまま中断する、ということは起こりません(この点は async let よりむしろ素直です)。

タスクがキャンセルされている状態で defer 本体に入った場合の扱いも、特別なことはしません。defer 本体から呼ばれたコードは、関数本体から呼んだ場合と同じく Task.isCancelledTask.checkCancellation() でキャンセル状態を観測します。defer だからといって自動でキャンセルが解除される、といった挙動は導入されません。

これは、同期版の defer がすでにキャンセル状態をそのまま観測する挙動になっていることと整合させるためでもあります。defer 本体に await を1つ足しただけで、同じ defer の中の他のコードのキャンセルに対する振る舞いまで変わってしまうのは望ましくない、という判断です。Task.sleep や HTTP / ファイルシステム操作のようにキャンセルに敏感な API を後始末で使いたい場合は、defer 専用の特殊ルールではなく、別途 withCancellationIgnored { ... } のような汎用機能で扱うべき領域とされています。