Expression macro as caller-side default argument
01 何が問題だったのか
Swiftには #line や #fileID のような組み込みのマジック識別子があり、関数のデフォルト引数に指定すると、呼び出し側のソース位置情報をもとに展開されます。
// MyLibrary.swift
public func greet<T>(_ thing: T, file: String = #fileID) {
print("\(file): Hello, \(thing)")
}
// main.swift
greet("World")
// "main.swift: Hello, World" と出力される
これらは公式ドキュメント上でも expression macro と位置づけられていますが、同等の挙動を自作の expression macro で再現しようとしてもコンパイルが通りませんでした。
@freestanding(expression)
public macro LabeledPrinter<T>() -> (T) -> Void = ...
public func greet<T>(
_ thing: T,
print: (T) -> Void = #LabeledPrinter
// ^ error: non-built-in macro cannot be used as default argument
) {
print(thing)
}
SE-0382 では、組み込み以外の expression macro をデフォルト引数として書くこと自体が制限されていたためです。
この制限のために、
- 組み込みマジック識別子と自作 expression macro の間に一貫性のない扱いが生まれてしまう
- 呼び出し側のソース位置やスコープに基づいて展開したい、という組み込み識別子と同じ有用なユースケースを利用者が実装できない
という問題がありました。
02 どのように解決されるのか
組み込みではない expression macro も、関数のデフォルト引数として書けるようにします。挙動は組み込みマジック識別子と揃えられ、次のようになります。
- expression macro がデフォルト引数そのものとして使われた場合は、呼び出し側のソース位置情報とコンテキストで展開される
- デフォルト引数の部分式として使われた場合は、これまで通り書かれた場所(関数宣言側)で展開される
// MyLibrary.swift
@freestanding(expression)
macro MyFileID<T: ExpressibleByStringLiteral>() -> T = ...
public func callSiteFile(_ file: String = #MyFileID) { file }
public func declarationSiteFile(_ file: String = (#MyFileID)) { file }
public func alsoDeclarationSiteFile(
file: String = callSiteFile(#MyFileID)
) { file }
// main.swift
print(callSiteFile()) // "main.swift"(呼び出し側のファイル)
print(declarationSiteFile()) // 常に "MyLibrary.swift"
print(alsoDeclarationSiteFile()) // 常に "MyLibrary.swift"
(#MyFileID) のように括弧で囲むと「部分式」扱いになり、関数宣言側での展開に切り替わる点がポイントです。
マクロ実装からソース位置を取得する
呼び出し側で展開されるとき、MacroExpansionContext.location(of:) を使えば展開点のソース位置が得られます。これを使って #fileID / #line / #column 相当のマクロを自作できます。
public struct MyFileIDMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
context.location(
of: node, at: .afterLeadingTrivia, filePathMode: .fileID
)!.file
}
}
public struct MyLineMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
context.location(of: node)!.line
}
}
デフォルト引数としての型検査
デフォルト引数に書かれた expression macro は、関数宣言の時点では展開せずに型検査だけを行います。展開後の式が関数宣言のスコープでは参照できない宣言を使う可能性があるためです。関数宣言の時点では、次の条件だけが検査されます。
- マクロがその関数と同等以上の可視性を持つこと
- マクロの戻り値型がパラメータの型と一致すること
- マクロに引数がある場合、その引数は文字列補間を含まないリテラルであること
呼び出し側での型検査と展開
関数が呼び出されるたびに、そのデフォルト引数の expression macro は呼び出し側のソース位置で展開され、呼び出し側のコンテキストで型検査されます。つまり、マクロ展開後の式が呼び出し側のスコープにある変数や宣言を参照していても、その場で解決されます。
@freestanding(expression)
// foo + bar に展開される
public macro VariableReferences() -> String = ...
public func preferVariablesFromCallerSide(
param: String = #VariableReferences
) {
print(param)
}
// 別ファイル
var foo = "hi "
var bar = "caller"
preferVariablesFromCallerSide() // "hi caller"
これにより、組み込みマジック識別子が持っていた「呼び出し側のコンテキストに応じて値が変わる」という挙動を、利用者が自分で定義した expression macro でも再現できるようになります。
Future Directions
今後の方向性として、デフォルト引数に使う expression macro の引数に、リテラル以外の任意の式を書けるようにすることが検討されています(speculative な見通しで、実現を約束するものではありません)。
現状では、宣言側で型検査を通すために、マクロ引数で参照される宣言が関数宣言のスコープにも存在している必要があります。しかし展開後の式は呼び出し側で型検査されるため、呼び出し側に同名の宣言があればそちらが使われ、宣言側に用意した宣言は使われない、といった直感に反する挙動になり得ます。そのためこの一般化は意図的に今回のスコープ外とされています。