Attached Macros
01 何が問題だったのか
SE-0382 で導入された式マクロは、#名前(...) の形で単独の式として展開される「freestanding」なマクロでした。これはソースコード中の式を別の式へ書き換える仕組みとしては強力ですが、Swiftには式以外にも拡張したい場面が数多くあります。たとえば次のようなケースです。
asyncな関数に対応するcompletion handler版をもう一つ自動で生成する、といったラッパー関数の追加。- 型の定義を元に、ビットフラグを並べた
enumからOptionSetに適合するstructを組み立てるような、メンバーの自動追加。 - stored property に対してアクセサを付け、実際には辞書や別のバッキングストレージから読み書きさせるような書き換え。これは property wrapper(SE-0258)の一部の用途と重なります。
- 型やエクステンションに
@objcMembers風の属性を付けると、そのメンバーすべてに@objcが自動で付くといった、まとめての属性付与。
こうした拡張は、従来はプロパティラッパー・リザルトビルダー・グローバルアクター・Codable の自動合成のように、個別の言語機能として都度コンパイラに組み込まれてきました。しかし、そのたびに言語側に専用の仕組みが必要になるのは拡張性が低く、ライブラリ作者が同種の「宣言に紐付く拡張」を一般的な形で定義する手段は存在しませんでした。
式マクロだけでは、式の位置以外には展開できず、また「付加的な宣言を生やす」こともできません。宣言そのものやその周辺を書き換えたいという用途を、言語機能の追加ではなくライブラリ側で扱えるようにする仕組みが求められていました。
02 どのように解決されるのか
特定の宣言に「付属する」形で展開されるマクロ、attached macroを導入します。利用側では property wrapper と同じカスタム属性構文(@MyMacro(...))で宣言に付け、コンパイラが対応する展開を挟み込みます。マクロ宣言には @attached(...) 属性を付け、どのロールとして働くかを示します。提案されるロールは次の4つです。
peer: 付属先の隣に別の宣言を追加する。member: 付属先の型やエクステンションにメンバーを追加する。accessor: stored property にアクセサ(getter/setter や observer)を付ける。memberAttribute: 付属先の型やエクステンションの各メンバーに属性を付け加える。
加えて、レビュー段階では conformance(プロトコル適合を追加する)ロールも提案されています(後続のProposalで extension ロールに整理されます)。
ひとつのマクロ宣言に複数の @attached(...) を重ねることで複数のロールを兼ねられ、# を使う freestanding マクロと同じく #externalMacro(...) 等で実装を指し示します。各ロールの実装は、swift-syntax の構文ノードを受け取り、追加するコードを構文ノードとして返す関数として書きます。コンパイラとは別プロセスで動き、サンドボックス内で実行される点は式マクロと同じです。
ロール1: peer マクロ(例: @AddCompletionHandler)
peer マクロは、付属先の宣言の隣に新しい宣言を追加します。たとえば async 関数に付けるとcompletion handler版を生成するマクロは次のように宣言できます。
@attached(peer, names: overloaded)
macro AddCompletionHandler(parameterName: String = "completionHandler")
names: overloaded は、「付属先と同じベース名を持つ宣言を生やす」という意味です。後述のとおり、attached macro が導入する名前はあらかじめ申告する必要があります。
@AddCompletionHandler(parameterName: "onCompletion")
func fetchAvatar(_ username: String) async -> Image? { ... }
// 展開されて、次の関数が peer として追加されます。
func fetchAvatar(_ username: String, onCompletion: @escaping (Image?) -> Void) {
Task.detached {
onCompletion(await fetchAvatar(username))
}
}
実装は PeerMacro プロトコルに適合した型で書きます。expansion は、マクロ属性の構文ノードと付属先宣言の構文ノードを受け取り、追加する宣言の配列 [DeclSyntax] を返します。
public protocol PeerMacro: AttachedMacro {
static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax]
}
ロール2: member マクロ(例: @OptionSetMembers)
member マクロは、付属先の型やエクステンションにメンバーを追加します。次の @OptionSetMembers は、フラグを並べた enum Option を持つ struct に、rawValue と各オプションに対応する static property を生やします。
@attached(member, names: named(rawValue), arbitrary)
macro OptionSetMembers()
@OptionSetMembers
struct MyOptions: OptionSet {
enum Option: Int {
case a
case b
case c
}
}
// 展開後のイメージ
struct MyOptions: OptionSet {
enum Option: Int { case a, b, c }
var rawValue: Int = 0
static var a = MyOptions(rawValue: 1 << Option.a.rawValue)
static var b = MyOptions(rawValue: 1 << Option.b.rawValue)
static var c = MyOptions(rawValue: 1 << Option.c.rawValue)
}
名前として named(rawValue) を明示しつつ、enum のケース名から派生する名前は事前に予測できないため arbitrary も添えています。実装は MemberMacro プロトコルに適合させ、付属先の DeclGroupSyntax(struct・class・enum・extension などの本体)を元に、追加するメンバーの [DeclSyntax] を返します。
ロール3: accessor マクロ(例: @DictionaryStorage)
accessor マクロは、property や subscript にアクセサを付け、stored property を computed property に変えたり、observer(willSet / didSet)を追加したりできます。例として、プロパティ名をキーにして辞書にアクセスさせるマクロを考えます。
@attached(accessor)
macro DictionaryStorage(key: String? = nil)
struct MyStruct {
var storage: [String: Any] = [:]
@DictionaryStorage
var name: String
@DictionaryStorage(key: "birth_date")
var birthDate: Date?
}
// 展開後のイメージ
struct MyStruct {
var storage: [String: Any] = [:]
var name: String {
get { storage["name"]! as! String }
set { storage["name"] = newValue }
}
var birthDate: Date? {
get { storage["birth_date"] as Date? }
set {
if let newValue {
storage["birth_date"] = newValue
} else {
storage.removeValue(forKey: "birth_date")
}
}
}
}
property wrapper ではバッキングストレージが外側の self.storage にアクセスできませんが、accessor マクロは付属先の周囲のコードを前提とした展開ができるため、こうした用途に向いています。
実装は AccessorMacro プロトコルに適合させ、[AccessorDeclSyntax] を返します。willSet か didSet のいずれかを名前として申告した場合は observer を追加する扱いになり、stored property のまま残ります。そうでない場合は展開結果が computed property になり、元の stored property に書かれていた初期化式はコンパイラ側で取り除かれるため、必要ならマクロ側で展開結果に組み込むか、診断を出す必要があります。
ロール4: memberAttribute マクロ(例: @MyObjCMembers)
memberAttribute マクロは、付属先の型やエクステンションに直接書かれた各メンバーに対して、属性を追加します。自前でメンバーを生やすのではなく、「書かれたメンバーに属性を被せる」ことで他のマクロや組み込み属性と組み合わせる、合成のためのロールです。
@attached(memberAttribute)
macro MyObjCMembers()
@MyObjCMembers extension MyClass {
func f() { }
var answer: Int { 42 }
@objc(doG) func g() { }
@nonobjc func h() { }
}
// 展開後: 既に @objc / @nonobjc が付いているもの以外に @objc が付加される
extension MyClass {
@objc func f() { }
@objc var answer: Int { 42 }
@objc(doG) func g() { }
@nonobjc func h() { }
}
実装は MemberAttributeMacro プロトコルに適合させ、どのメンバーにどの属性を付けるかを返します。
ロールの合成: property wrapper 風の @Clamping
ひとつのマクロ宣言に複数の @attached(...) を重ねると、各ロールが独立に展開されます。たとえば property wrapper の Clamping 相当の挙動は、peer(バッキングストレージを生やす)と accessor(min/max でクランプする getter/setter を付ける)の組み合わせで表現できます。
@attached(peer, prefixed(_))
@attached(accessor)
macro Clamping<T: Comparable>(min: T, max: T) =
#externalMacro(module: "MyMacros", type: "ClampingMacro")
struct Color {
@Clamping(min: 0, max: 255) var red: Int = 127
@Clamping(min: 0, max: 255) var green: Int = 127
@Clamping(min: 0, max: 255) var blue: Int = 127
@Clamping(min: 0, max: 255) var alpha: Int = 255
}
prefixed(_) は「付属先の名前の先頭に _ を付けた名前を導入する」という指定で、ここでは _red のような private なバッキングストレージが生える想定です。property wrapper と違い、バッキング側に min / max を保持する必要がなく追加のストレージを持たない、新しい型定義も不要、といった利点があります。
マクロ側の同じ型が PeerMacro と AccessorMacro の両方に適合して、それぞれ独立に expansion を実装します。
enum ClampingMacro { }
extension ClampingMacro: PeerMacro {
static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] { /* _name を持つ private 変数を返す */ }
}
extension ClampingMacro: AccessorMacro {
static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] { /* _name を読み書きする get/set を返す */ }
}
あるロールが付属先に対して意味をなさない場合(たとえば関数に accessor ロールは無意味)、そのロールは単に無視されます。どのロールも当てはまらない場合はill-formedです。
名前の事前申告
attached macro が新しく導入する宣言の名前は、@attached(...) の names: 引数であらかじめ申告します。指定できる形式は次のとおりです。
named(<name>): 固定の名前。overloaded: 付属先と同じベース名(オーバーロード)。prefixed(<prefix>): 付属先の名前の先頭に固定のプレフィックスを付けた名前。$を特別に許容し、property wrapper の projected value 相当の命名ができます。suffixed(<suffix>): 付属先の名前の末尾に固定のサフィックスを付けた名前。arbitrary: 事前に予測できない任意の名前。
コンパイラやツールは、名前検索時に「その名前を生み得ないマクロは展開しなくてよい」と判断できるため、インクリメンタルビルドやIDE応答が高速になります。逆に arbitrary を指定したマクロは常に展開が必要です。特に top-level の peer マクロに arbitrary を許すと型検査と名前検索が循環しやすいため、top-level 宣言に付く peer マクロは arbitrary を指定できません。
申告した名前のすべてを必ず生成する必要はありません(たとえば OptionSetMembers は rawValue が既に書かれていれば生成を省略できます)。一方、申告していない名前や MacroExpansionContext.createUniqueName で作った一意名以外の名前を生やすことはできません。関数やクロージャの本文内で attached macro を使った場合は、そのスコープ内に新しい名前を見せる手段がないため、createUniqueName で作った名前しか導入できません。
名前の可視性と展開順
マクロが生やした名前は、同じスコープおよびその外側のスコープにあるマクロ引数からは見えません。マクロ引数は展開前に型検査されるため、引数の解決が自マクロや兄弟マクロの展開結果に依存すると循環や順序依存が生じるためです。一段内側のスコープに入ったマクロからは、外側のマクロが生やした名前が見えます。言い換えると、同じスコープに並ぶマクロ群は「同時に」展開されると考えておくと安全です。
また、同じ宣言に複数の attached macro やロールが付いたときでも、各展開は互いに独立に行われます。各 expansion に渡される構文木は、他のマクロの展開結果を反映していない「元のまま」のものです。そのため「Aが先に展開されたらBはこう動く」といった順序依存は原則として生じず、順序を気にせず合成できます。
展開可能な宣言の種類
attached macro の展開結果には、通常のSwiftコードで書ける宣言はおおむね登場できます。ただし次は禁止されています。
import宣言。ソーススキャンだけでimport解決できる前提を崩さないため。@mainの型。エントリポイントの有無を展開前に知るため。extension宣言。効果が広すぎるため、member / conformance など細粒度のロールに任せる方針です。operator/precedencegroup宣言。既存コードの演算子解釈に影響しうるため。macro宣言。無限再帰を避けるため。IntegerLiteralTypeなど top-level のデフォルトリテラル型の上書き。
Future Directions(speculative)
このProposalが提供するのは4種類(レビュー時点では5種類)の基本ロールで、マクロのvisionドキュメントでは他にも様々なロールが構想されています。今後、新しいロールを追加するときは、@attached(...) に新しい種別を足し、対応する protocol を追加する形で拡張されていくことが想定されています。