01 何が問題だったのか
SE-0389 で導入された attached macro は、peer / member / accessor / memberAttribute など、宣言の周辺にコードを足すためのロールを備えていました。しかし、関数の本体そのもの を合成したり書き換えたりするロールは用意されていませんでした。
そのため、次のような「関数の本体を作る・包む・置き換える」という用途はマクロでは実現できませんでした。
- 宣言とメタデータから本文を組み立てる。たとえば、引数をまとめてリモート呼び出しに渡す実装を自動生成したい。
- 既存の本文にコードを足す。関数の出入りでログやトレースを出す、事前条件を確認する、不変条件を確立するなど。
- 既存の本文を包む。
MainActor.assumeIsolated { ... }やwithSpan("...") { ... }のようなラッパーに本文を入れ直す。 - 本文を別の表現として解釈し直す。たとえば、本文をドメイン固有言語として受け取り、実行可能コードに「lower」する。
これらの用途は、peer マクロで関数を新しく生やすような回避策では自然に書けず、attached macro に新しいロールを追加する必要がありました。
02 どのように解決されるのか
attached macro に新しく body ロールを追加し、関数・イニシャライザ・デイニシャライザ・アクセサの本文をマクロで生成・置き換えできるようにします。マクロ宣言には @attached(body) を付けます。
@attached(body) macro Remote() = #externalMacro(...)
@attached(body) macro Logged() = #externalMacro(...)
@attached(body) macro AssumeMainActor() = #externalMacro(...)
他の attached macro と同じく、body マクロには戻り値型はありません。
使い方のパターン
本文を丸ごと合成する例として、引数をそのままリモート呼び出しに渡す @Remote が考えられます。
@Remote
func f(a: Int, b: String) async throws -> String
この宣言は、たとえば次のように展開されます。
func f(a: Int, b: String) async throws -> String {
return try await remoteCall(function: "f", arguments: ["a": a, "b": b])
}
既存の本文をそのまま残して前後にコードを足す例が @Logged です。
@Logged
func g(a: Int, b: Int) -> Int {
return a + b
}
は、次のように展開できます。
func g(a: Int, b: Int) -> Int {
log("Entering g(a: \(a), b: \(b))")
defer {
log("Exiting g")
}
return a + b
}
既存の本文をラップして置き換える例が @AssumeMainActor です。@MainActor を付けられない nonisolated 関数の中身を MainActor.assumeIsolated { ... } に入れ直す、というよくあるパターンをマクロとして抽象化できます。
extension MyView: SomeDelegate {
@AssumeMainActor
nonisolated func onSomethingHappened(event: Event) {
myView.title = newTitle(processing: event)
}
}
は次のように展開されます。
extension MyView: SomeDelegate {
nonisolated func onSomethingHappened(event: Event) {
MainActor.assumeIsolated {
myView.title = newTitle(processing: event)
}
}
}
アクセサとプロパティ短縮構文
body マクロはアクセサにも付けられます。その場合はアクセサそのものに付けます。
var area: Double {
@Logged get {
return length * width
}
}
get-only プロパティの短縮構文を使うときは、プロパティ自体に付けることもできます。
@Logged var area: Double {
return length * width
}
実装側: BodyMacro プロトコル
body マクロの実装は BodyMacro プロトコルに適合する型として書きます。expansion は、付属先の宣言と属性の構文ノードを受け取り、新しい本文となる [CodeBlockItemSyntax] を返します。
public protocol BodyMacro: AttachedMacro {
static func expansion(
of node: AttributeSyntax,
providingBodyFor declaration: some DeclSyntaxProtocol & WithOptionalCodeBlockSyntax,
in context: some MacroExpansionContext
) throws -> [CodeBlockItemSyntax]
}
付属先が既に本文を持っていた場合、その本文は暗黙に無視され、expansion が返したコードで置き換わります。元の本文を活かしたければ、マクロ実装側で declaration の構文から取り出して組み込みます。
合成の制約
body マクロはひとつの関数に最大1つ しか付けられません。複数の body マクロを合成することはできず、必要なら片方のマクロ実装内で、もう片方に相当する展開も行う形になります。
他のロール(peer など)との併用は可能で、各ロールの expansion はそれぞれ独立に元の宣言を受け取ります。
元の本文は型検査されない
body マクロが付いた関数について、ユーザーが書いた本文は syntactic well-formed(Swiftの文法に合致している)ことだけが要求され、型検査は行われません。マクロ展開後の新しい本文だけが型検査の対象になります。
これは他の attached macro と同じ方針で、マクロ実装は「型検査前の素の構文木」を受け取ります。この仕組みによって、たとえば関数本文をSQL文として解釈して書き換えるような @SQL マクロも原理的には書けます。
@SQL
func employees(hiredIn year: Int) -> [String] {
SELECT
name
FROM
employees
WHERE
YEAR(hire_date) = year;
}
ただし、あくまでもSwiftの文法として通る範囲 に限られます。Swiftの文として解釈できない書き方、たとえば1行に複数文が並ぶような形はパースの時点でエラーになります。
@SQL
func employees(hiredIn year: Int) -> [String] {
SELECT name FROM employees // error: consecutive statements on a line must be separated by ';'
WHERE YEAR(hire_date) = year;
}
この「文法としては通る必要がある」という制約が、body マクロの過度な濫用を抑えつつ、既存のソースコードツール(フォーマッタやエディタ)が壊れずに動くことも助けています。
03 今後の見通し
将来の拡張として次の方向性が挙げられています。いずれも今回のProposalの範囲外で、実現を約束するものではありません。
クロージャへの適用
現状の body マクロは宣言された関数・イニシャライザ・デイニシャライザ・アクセサに限定されています。クロージャにも付けられるよう拡張する方向が検討されています。
@Traced(z) { (x, y) in
x + y
}
ただし、クロージャは式の中に現れ、マクロ引数自体が同じ式の型推論に絡む可能性があります。たとえば次のように z が外側のスコープから来ている場合、z の型推論と body マクロの展開が相互作用します。
f(0) { z in
@Traced(z) { (x, y) in
x + y
}
}
マクロは「同じマクロを複数回展開しない」ことが前提になっており、型検査器がどのマクロをいつ展開すべきか判断できなくならないよう既存の制限が置かれています。クロージャへ広げるには、この型検査の問題を解く必要があり、フリースタンディング宣言マクロなどの他の制限緩和とまとめて扱われる可能性も示されています。
preamble マクロ
関数の先頭にだけ共通コードを差し込む preamble ロールが、初期レビュー時のProposalには含まれていました。body マクロでも「既存の本文の前にコードを足す」展開は書けるため preamble マクロは厳密には必須ではありませんが、次のような利点があります。
- 複数の preamble マクロを合成できる(body マクロは合成できません)。
- ユーザーが書いた本文をそのまま残すので、診断やコード補完など編集体験が良くなる。
そうした利点はあるものの、preamble マクロが追加する複雑さに見合うだけの表現力を持つかが不確かなこと、後述の wrapper マクロのほうがより筋の良い落としどころになり得ることから、Future Directions に回されました。
wrapper マクロ
withSpan("...") { ... } のように、本文をクロージャに包んで別の関数に渡すパターン専用のロールです。たとえば @TracedWithSpan のようなマクロを考えると、
@TracedWithSpan("Doing complicated math")
func h(a: Int, b: Int) -> Int {
return a + b
}
を次のように展開したい、という用途です。
func h(a: Int, b: Int) -> Int {
withSpan("Doing complicated math") {
return a + b
}
}
wrapper マクロでは、ユーザーが書いた本文を型検査済みの関数値としてマクロに渡し、マクロはそれを不透明な値として呼び出すだけ、という形を取ります。イメージとしては次のような展開です。
func h(a: Int, b: Int) -> Int {
withSpan("Doing complicated math", body: h-impl)
}
このアプローチには次の利点があります。
- 元の本文が事前に型検査されるため、body マクロの「本文が型検査されない」というデメリットを避けられる。
- 元の本文の型検査は1回だけで済む。
- マクロが返すのも関数値として扱えるため、複数のラッパーを合成できる。
Pythonのデコレータが関数の振る舞いをカスタマイズするのに使われているのと同じ発想で、Swiftにも持ち込めるか、という方向性です。