Swift Digest
SE-0422 | Swift Evolution

caller-side default argumentとしてのexpression macro

Expression macro as caller-side default argument

Proposal
SE-0422
Authors
Apollo Zhu
Review Manager
Doug Gregor
Status
Implemented (Swift 6.0)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

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 は、関数宣言の時点では展開せずに型検査だけを行います。展開後の式が関数宣言のスコープでは参照できない宣言を使う可能性があるためです。関数宣言の時点では、次の条件だけが検査されます。

  1. マクロがその関数と同等以上の可視性を持つこと
  2. マクロの戻り値型がパラメータの型と一致すること
  3. マクロに引数がある場合、その引数は文字列補間を含まないリテラルであること

呼び出し側での型検査と展開

関数が呼び出されるたびに、そのデフォルト引数の 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 でも再現できるようになります。

03 今後の見通し

デフォルト引数の expression macro に任意の式を引数として渡せるようにする

今回のProposalでは、デフォルト引数として使う expression macro の引数は文字列補間を含まないリテラルに限定されています。これを緩和して、任意の式を引数として書けるようにする方向性が示されています。

@freestanding(expression)
// "Hello " + string に展開される
public macro PrependHello(_ string: String) -> String = ...

// デフォルト引数からマクロの引数として参照するために必要になる宣言
public var shadowedVariable: String = "World"

public func preferVariablesFromCallerSide(
  param: String = #PrependHello(shadowedVariable)
) {
  print(param)
}

ただし、この方向性には課題があります。デフォルト引数のマクロ式は関数宣言の時点で型検査されるため、引数で参照される宣言(上の例の shadowedVariable)も関数宣言側のスコープに用意しておく必要があります。一方で展開後の式は呼び出し側のコンテキストで型検査されるため、呼び出し側に同名の宣言があればそちらが使われ、宣言側に用意した宣言は実際には使われない、という直感に反する挙動が起こり得ます。

// 別ファイル
var shadowedVariable: Int = 42
preferVariablesFromCallerSide()
// #PrependHello(shadowedVariable) は呼び出し側で "Hello " + 42 に展開される
// error: binary operator '+' cannot be applied to operands of type 'String' and 'Int'

この挙動の整理がつかないため、引数の一般化は今回のスコープから意図的に外されています。あくまで将来の構想として検討されているものであり、実現を約束するものではありません。