マクロによる async 関数の同期オーバーロードの生成
Generating synchronous overloads of async functions with a macro
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
Swift では、同じ機能を 同期版 と 非同期版(async) の両方で提供したいことがよくあります。たとえば、ユーザーが提供するクロージャを同期にも async にも受け取りたいライブラリでは、現状はまったく同じ関数を 2 回書くしかありません。著者が挙げている swift-test-kit の Property-Based Testing 用 API には次のような async オーバーロードがあり、これとほぼ同じ宣言が async / await 抜きで対をなしています。
public func XCTKForAll<each T>(
using generators : repeat Generator<each T>,
where precondition : @escaping (repeat each T) -> Bool,
examples : @autoclosure () -> [(repeat each T)] = [],
message : @autoclosure () -> String = "",
fileID : StaticString = #fileID,
file : StaticString = #filePath,
line : UInt = #line,
column : UInt = #column,
options : TestOptions? = nil,
_ property : (repeat each T) async throws -> Void
) async
{
await TKForAll(/* 引数は同じ */)
}
2 つの宣言は、async と await の有無を除けば本来まったく同じ実装です。差分の薄い 2 つの宣言を保守し続ける負担は次のように積み重なります。
- バグ修正・リファクタリング・挙動変更を片方だけに入れてしまうと、同期版と非同期版がドリフトする
- シグネチャが長いほど、デフォルト値・属性・トリビアまで正確にコピーする手間が増える
- ライブラリ内で同じパターンが繰り返されるたびに、テスト・ドキュメント・レビュー対象も倍になる
言語側の解決として、rethrows に倣った reasync キーワードを導入する案が長年議論されてきました。考え方自体は単純ですが、async 関数と非 async 関数は呼び出し規約が根本的に異なり、rethrows のように 1 つのバイナリエントリで両用途を兼ねることができません。結局はコンパイラ内部で 2 つの関数を生成する形になるため、実装コストの割に得るものが小さく、提案として進んでいないのが現状です。
一方、SE-0296 のディスカッションでは Swift コンパイラエンジニアの Doug Gregor から「peer マクロで async を取り除いた同期版を機械的に生成できれば、reasync の有用なケースは大部分カバーできるのでは」という観察も示されていました。書き手が抱えているのは「async / await を除いただけのほぼ同一のコードを 2 つ保守し続ける」という機械的な重複であり、それなら言語機能を待たずにマクロで埋められる、というのが本提案の出発点です。
02 どのように解決されるのか
@Reasync というアタッチマクロを追加し、async 関数の宣言に付けると、コンパイル時に同期版オーバーロードが peer として生成されるようにします。マクロは標準ライブラリ、または swiftlang GitHub 組織配下の公式パッケージのいずれかに置く想定で、置き場所はレビューで決められます。生成された同期版は普通の関数宣言なので、コンパイラのパース・型検査・isolation 検査はいずれも通常どおり適用されます。マクロ自体は実行時には何もしません。
マクロ宣言と基本動作
マクロは peer マクロとして次のように宣言されます。
@attached(peer, names: overloaded)
public macro Reasync()
@Reasync を async 関数に付けると、シグネチャと本体から async / await を取り除いた同期版が、同じ名前の peer 宣言として生成されます。
@Reasync
func run(
_ body: () async throws -> Void
) async rethrows
{
try await body()
}
// @Reasync によって生成される:
//
// func run(
// _ body: () throws -> Void
// ) rethrows
// {
// try body()
// }
呼び出し側では、SE-0296 で定められた既存のオーバーロード解決ルールに従い、同期コンテキストからは同期版が、async コンテキストからは元の async 版が選ばれます。@Reasync 側に新しい解決規則は持ち込まれません。
変換は本体の全域に再帰的にかかる
@Reasync の変換はシグネチャだけでなく、関数本体やネストした宣言まで広がります。具体的には次のような書き換えが行われます。
async関数のシグネチャからasyncを除く- クロージャ型のパラメータに付いた
asyncを、ネストの深さによらず除く - 式中の
awaitを除き、内側の式だけを残す async letを通常のletに変えるfor await/for try awaitからawaitを除く- ネストした関数宣言、クロージャ式、ローカル computed property のアクセサに対して同じ変換を再帰的に適用する
たとえば次の sum は、本体内の for ループと await の両方が同期版で書き換えられます。
@Reasync
func sum(
_ values : [Int],
using transform : (Int) async -> Int
) async -> Int
{
var total: Int = 0
for value in values
{
total += await transform(value)
}
return total
}
// 生成される同期版:
//
// func sum(
// _ values : [Int],
// using transform : (Int) -> Int
// ) -> Int
// {
// var total: Int = 0
//
// for value in values
// {
// total += transform(value)
// }
//
// return total
// }
属性・修飾子・generic 制約・トリビア・ドキュメントコメントは原則そのまま残るので、生成された同期版の見た目は元の async 版とほぼ揃います。元の宣言が別の宣言の本体内にネストしていた場合は、アタッチ位置のインデントに合わせて peer のインデントが正規化されます。
ネストした関数に直接 @Reasync を付けても、外側の @Reasync がすでにその関数を変換しているため、内側の @Reasync は冗長な属性として警告と「属性を取り除く」fix-it の対象になります。
strict concurrency 関連の属性の扱い
Swift 6 で実用上書かれる async 関数には、async 以外の concurrency 関連の注釈もしばしば付きます。マクロは「同期版で意味を成さなくなる」ものを取り除き、それ以外は保持する方針で扱います。
| 注釈 | 同期版での扱い |
|---|---|
async / await |
すべての位置で除去 |
@isolated(any) |
関数のパラメータ節にあるクロージャ型から除去(深さによらない) |
nonisolated(nonsending) |
すべての位置で除去 |
@concurrent |
すべての位置で除去 |
@Sendable |
関数のパラメータ節にあるクロージャ型から除去(深さによらない) |
sending |
保持 |
| グローバルアクター | 保持 |
isolated パラメータ |
保持 |
裸の nonisolated |
保持 |
@isolated(any) / nonisolated(nonsending) / @concurrent は、SE-0431 や SE-0461 の規定により、そもそも同期関数や同期関数型に付けられない、あるいは同期関数では意味を持たない注釈です。生成された同期版が成立するためには取り除く必要があります。
@Sendable をパラメータのクロージャ型から外すのは、async 版本体が async let や TaskGroup で isolation boundary を越えていたケースを同期版が解消する以上、それに伴う @Sendable 要件も同期版からは外したい、という考えからです。async 版が Task ベースで並列実行を別途仕掛けているなど、@Sendable を残すべき例外もあり得ますが、マクロは本体の意味解析ができないため、常に外す方針に統一されています。同期版で @Sendable がやはり必要だった場合は、生成された peer の strict concurrency 検査で診断が出るので、利用者はマクロを諦めて手書きの同期オーバーロードに切り替えることになります。
sending は「関数の値が isolation boundary をどう越えうるか」を表す注釈で、async か同期かに依存しません。グローバルアクター・isolated パラメータ・裸の nonisolated も「関数がどこで動くか」を表すもので async 性とは直交するため、いずれも生成される peer にそのまま引き継がれます。
診断と適用先の制限
@Reasync は適用できる場所が明確に絞られています。
- 同期関数や、関数でない宣言に付けるとエラー(
'@Reasync' can only be applied to async functions) @Reasyncを付けた関数の中にネストした関数へさらに@Reasyncを付けると、警告と fix-it を出す(外側のマクロですでに変換されているため)- プロトコルの要件に付けるとエラー(
'@Reasync' cannot be applied to protocol requirements)。これは現状のコンパイラに、生成された同期要件を protocol existential 経由で dispatch するとランタイムクラッシュするバグがあるためで、コンパイラ側の修正後にこの制限を外せる余地が残されています
マクロは AST 上の構文操作だけを行い、本体に書かれた式の意味までは見ません。「async の必要性がクロージャパラメータ以外(actor-isolated なメソッドや async 専用 API の呼び出しなど)に由来する関数」に @Reasync を付けてしまった場合、マクロ自体は同期版を生成し、その後の型検査でエラーになります。診断は展開後のソース上に出るため利用者は展開先の位置で問題箇所を受け取りますが、誤って同期化された関数がそのまま通ってしまうことはありません。
マクロ採用の影響
- すでに公開されている
async関数に@Reasyncを付けるのは、クライアントから見ると同期版オーバーロードが増えるだけなので、ソース互換に保たれます - 一度生成された同期版オーバーロードを使い始めたあとで
@Reasyncを外すのは、ソース破壊的変更になります。同期版を公開した時点で、それ自体をライブラリの公開 API として扱うべきです - マクロはコンパイル時に通常の関数宣言へ展開されるだけなので、ABI には影響しません。
@Reasyncを外して同期版を直接書き直しても、生成された peer と同じ ABI のため、クライアント側の影響なしに移行できます
03 今後の見通し
提案では次のような発展方向が挙げられています。いずれも将来の構想であり、実現を約束するものではありません。
プロトコル要件への適用
@Reasync の構文変換自体は、プロトコル要件にもそのまま適用できます。しかし、生成された同期要件を protocol existential 経由で dispatch するとコンパイラがランタイムクラッシュする問題(swiftlang/swift#89397)があるため、本提案ではプロトコル要件への適用を proactive にエラーで弾いています。コンパイラ側の問題が解決されれば、変換ロジック自体は変えずにこの制限だけを外す形での追補が可能です。
subscript のサポート
async な subscript も関数と同じくオーバーロード解決のルールに乗るため、原理的には @Reasync で同期版 subscript を生成できます。本提案は関数のケースに集中し、subscript への拡張は独立した提案として切り出される予定とされています。
isolation の表現力が拡張された場合の @isolated(any) の保持
現在 @isolated(any) がパラメータ節のクロージャ型から外されているのは、関数宣言のシグネチャでは「特定の値の .isolation に対する derivation」のような動的 isolation を表現する手段がないためです。SE-0431 で示唆されている closure isolation control の方向で、関数宣言レベルでも特定のパラメータの isolation に従う形を表現できるようになれば、同期版の peer に @isolated(any) を保持し、@isolated(any) クロージャの isolation を引き継いで動かす形に拡張できます。これは同期版オーバーロードに新しい動的 isolation 契約を持ち込む変更でもあるため、言語側の対応を前提とした発展方向として整理されています。