Expression Macros
01 何が問題だったのか
Swiftには以前から #file / #line / #selector / #keyPath のように # を先頭に付けた特殊な式がいくつか存在していました。これらはコンパイラに組み込まれた個別の機能で、呼び出し位置のソース情報を取り出したり、セレクタ名を文字列から自動生成したりといった、「普通の関数では書けない」振る舞いを実現しています。一方で、これらはあくまでコンパイラが個別に面倒を見ている固定の機能であり、利用者側のライブラリが同種の拡張を定義する手段はありませんでした。
式の抽象化手段として関数は十分強力ですが、関数には「引数として渡されたソースコードそのものを参照・加工する」ことはできません。たとえば次のようなことをライブラリとして提供したくても、Swiftの言語機能の範囲内では実現できませんでした。
- 式の値と、その式のソースコード表現(
"x + y"のような文字列)を同時に取り出す - アサーション失敗時に、評価に使われた各部分式の値を表示する(いわゆるpower assertion)
- 与えられた式が特定の演算子を含まないことをコンパイル時に検査する
これらを実現するには、ソースコードを生成する外部ツールをビルドに組み込むしかなく、補完やエラー表示といった既存のツール連携がうまく働かないという不便がありました。
Swift自体にマクロを導入して解決するとしても、設計には複数の方向性があります。C言語プリプロセッサのような純粋なテキスト置換、Rustの macro_rules! のようなパターンマッチに基づく宣言的マクロ、あるいはRustの手続き型マクロやScala 3のようにコンパイラと連携するプログラムとして動作するマクロなどです。Swiftにおける式マクロは、これらの中でどの路線を採り、既存の #file などとどう統合し、型システムとどう噛み合わせるか、を決める必要がありました。
02 どのように解決されるのか
式マクロ(expression macro)という仕組みを導入します。式マクロはソースコード中で #名前(...) の形で呼び出され、与えられた引数の構文木を別の構文木へと書き換えて、その場の式として展開されます。マクロ実装は、コンパイラとは別のプログラムとして書かれ、swift-syntax の構文木を受け取って加工する形で動作します。
マクロの宣言
マクロは関数に似た文法で宣言し、式マクロであることを @freestanding(expression) 属性で示します。「freestanding」は、# を前置して単独の式として展開されるマクロを指す用語で、後続のProposalで登場する「attached macro」(宣言に付属するマクロ)と対になります。
たとえば、式の値と、そのソースコードを表す文字列リテラルの組を返す stringify マクロは次のように宣言します。
@freestanding(expression)
macro stringify<T>(_: T) -> (T, String) =
#externalMacro(module: "ExampleMacros", type: "StringifyMacro")
関数シグネチャと同じように引数の型と戻り値の型を持ち、ジェネリクスも使えます。= の右辺はマクロの実装を指定する部分で、ここには #externalMacro(module:type:) のような他のマクロ展開式のみを書けます。最終的には必ずコンパイラが提供する組み込みマクロに行き着きます。#externalMacro は、同名のマクロ実装が別モジュールの指定した型にあることをコンパイラに伝える特別な組み込みマクロです。
マクロはファイルスコープでのみ宣言でき、関数と同じ規則(引数ラベル、引数型、戻り値型のいずれかが異なる)でオーバーロードできます。
マクロの展開
利用側では、関数呼び出しに # を前置する形で書きます。
#stringify(x + y)
// 展開後:
// (x + y, "x + y")
展開は2段階で行われます。
- 型検査フェーズ: マクロを実際に展開する前に、引数が宣言通りの型に適合しているかを通常の関数呼び出しと同じ規則で検査します。引数の型が合わなければ、マクロ展開は行われず通常のエラーになります。戻り値型もコンテキストから推論され、ジェネリック引数が決まります。たとえば
let (a, b): (Double, String) = #stringify(1 + 2)ではTがDoubleと推論され、リテラル1と2はDoubleとして扱われます。 - 展開フェーズ: マクロ実装に引数の構文木が渡され、新しい構文木を返します。その構文木は、マクロ宣言の戻り値型をコンテキスト型として改めて型検査されます。
型検査を展開前に行うことには、ツールがマクロを関数と同じように扱える(補完・ハイライトなどが効く)、ill-formedなコードがマクロ実装に渡らない、型推論中にマクロを繰り返し展開せずに済む、といった利点があります。
なお、引数も戻り値も無いマクロ(例: #line)は呼び出し時に () を省略でき、#line() と書く必要はありません。マクロは第一級の値ではなく、関数のように値として取り回すことはできません。
マクロ展開は再帰できません。マクロの展開結果に同じマクロが含まれている場合、プログラムはill-formedとなり、無限展開は起きません。
マクロの実装
マクロ実装は、コンパイラとは別プロセスで動くプログラムとして書きます。Macro プロトコルを頂点とする階層があり、# で単独に展開されるマクロは FreestandingMacro を介して ExpressionMacro に適合します。
public protocol Macro { }
public protocol FreestandingMacro: Macro { }
public protocol ExpressionMacro: FreestandingMacro {
static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax
}
expansion(of:in:) は、マクロ展開式の構文木(たとえば #stringify(x + y) 全体)と、展開環境についての情報を提供する MacroExpansionContext を受け取り、置き換え後の式の構文木 ExprSyntax を返します。展開が不可能な場合は throw でエラーを返すと、コンパイラ側がそれを利用者に報告します。
stringify マクロの実装は、たとえば次のように書けます。
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let argument = node.argumentList.first?.expression else {
fatalError("compiler bug: the macro does not have any arguments")
}
return "(\(argument), \(literal: argument.description))"
}
}
ExprSyntax は ExpressibleByStringInterpolation に適合しており、文字列補間の中に構文ノードを埋め込むだけでコード片を組み立てられます(簡易な準クォート)。上のコードでは、#stringify(x + y) の引数 x + y を値として、同じものをソースコード文字列として、それぞれタプルに埋め込んでいます。
MacroExpansionContext
MacroExpansionContext は、展開時にコンパイラから提供されるコンテキストで、次のようなAPIを持ちます。
makeUniqueName(_:): 同じスコープ内の他の宣言と衝突しない一意な名前のTokenSyntaxを生成します。マクロ展開が新しい名前を導入するときに使い、呼び出し側のコードを誤って変数捕捉してしまうのを防ぎます(hygieneの向上)。diagnose(_:): 警告やエラーなどの診断(Diagnostic)を出します。fix-itや範囲ハイライト、注記などにも対応します。構文的には正しいが意味的にマクロが処理できない入力に対して、適切なエラーを出すのに使います。location(of:at:filePathMode:): 与えられた構文ノードのソースロケーションを取得します。#line/#fileID相当の情報をマクロ実装側で得たいときに使います。得られるAbstractSourceLocationはfile・line・columnを構文ノードとして持ち、そのまま展開結果の式に埋め込めます。
構文的な変換であること
マクロ展開は構文木から構文木への変換であり、ASTやコンパイラ内部表現を直接いじる方式ではありません。これは、マクロに使えるのはSwiftで書ける形に限られる、展開結果は通常のSwiftコードとして読んで理解できる、古いコンパイラ向けに展開結果を「事前展開」しておける、といった実用上の利点と引き換えに、展開後に再パース・再型検査が必要でコストが高い、hygienicではない(makeUniqueName である程度緩和)、といったトレードオフを受け入れる設計です。
また、マクロ実装はSwiftPMのビルドプラグインと同様にサンドボックス内で実行され、ファイルシステムやネットワークにアクセスできません。マクロが依存してよいのは、渡された構文ノードと MacroExpansionContext から得られる情報に限られます。
既存の # 式のマクロ化
#file / #line / #column / #function / #fileID / #filePath / #dsohandle / #selector / #keyPath / #colorLiteral などの既存の # 式は、次のように標準ライブラリ側の @freestanding(expression) なマクロ宣言として定義し直されます(実装はコンパイラが提供)。
@freestanding(expression) macro line<T: ExpressibleByIntegerLiteral>() -> T
@freestanding(expression) macro fileID<T: ExpressibleByStringLiteral>() -> T
@freestanding(expression) macro selector<T>(_ method: T) -> Selector
@freestanding(expression) macro colorLiteral<T: ExpressibleByColorLiteral>(
red: Float, green: Float, blue: Float, alpha: Float
) -> T
// ほか
利用者から見た挙動はこれまでと同じですが、言語組み込みの特殊ケースが減り、ツールから見ても単なるマクロとして統一的に扱えるようになります。
デフォルト引数での制約
#fileID / #line などの組み込みソースロケーションマクロは、関数のデフォルト引数に書かれた場合、宣言位置ではなく呼び出し位置で展開されるという特別な振る舞いを持ちます。これはSwiftに以前からある便利な挙動ですが、ユーザー定義マクロについては同じ挙動が直感的とは限らないため、ビルトインでないマクロをデフォルト引数に使うことは禁止されます(将来的に再検討される可能性あり)。
なお、マクロのパラメータ自体にデフォルト引数を持たせることはでき、その値はリテラルか他のマクロ展開に限られます。
Future Directions(speculative)
このProposalはあくまで式マクロの基盤部分で、今後の拡張方向として次のようなものが示されています(いずれも別Proposalでの検討対象で、本Proposal時点で実装されるものではありません)。
- 型情報のマクロへの提供: 現状、マクロ実装に渡されるのは構文木のみで、型検査で得られた型情報は渡されません。power assertionのように、部分式の型がわかれば実装が大幅に簡潔になるケースがあるため、
MacroExpansionContextにtype(of:)相当のAPIを追加する方向が検討されています。 - 他の種類のマクロ: 式以外の位置、たとえば関数やクロージャ本体、型やエクステンションの中(メンバー追加)、プロトコル適合の自動合成などに対応するマクロです。宣言は
macroのままで、@freestanding(expression)に代わる別の属性と、それぞれに対応するプロトコル(Macroを継承)で区別することが想定されています。