🍇

Swiftマクロの種類とその作用範囲

2023/10/16に公開

はじめに

Swift 5.9でマクロ機能がリリースされました。
マクロを使えば、機械的な実装を自動化したりまったく新しい機能を作れたりします。マクロにはソースコードを無秩序に書き換えるイメージがあるかもしれませんが、Swiftのマクロは厳密に型定義されており、自由度が高くありつつ秩序を守れる仕組みとして導入されています。

Swiftのマクロには大きくFreestanding MacroとAttached Macroの2種類があり、それぞれが更に何種類か細部化されています。
これらの種類は、マクロが利用できる場所と、その作用範囲が異なっています。
本記事ではこれらマクロについて、利用できる箇所と作用できる範囲についてまとめたいと思います。

また、マクロの宣言時にはnamesパラメータが必要なものがあり、これについても解説します。

資料

環境

$ swift --version
swift-driver version: 1.87.1 Apple Swift version 5.9 (swiftlang-5.9.0.128.108 clang-1500.0.40.1)
Target: arm64-apple-macosx13.0

Freestanding Macro

Freestanding Macroは、#で始まることが特徴です。
自由な場所に記述でき、引数として受け取ったパラメータを使うことができます。逆にいえばマクロの引数以外の情報は利用できません
コード中のブロックごとに利用できるマクロは異なり、ブロックに合った文法を展開できます。

DeclarationMacro

宣言マクロです。グローバルなブロックや、型宣言のブロックで利用できます。
structなどの型宣言や、varなどのプロパティ宣言、関数宣言を出力できます。

展開前
struct Student {
    #PhantomID(UUID.self)
}
展開後
struct Student {
    struct ID: RawRepresentable, Hashable, Sendable {
        init?(rawValue: UUID) {
            self.rawValue = rawValue
        }
        var rawValue: UUID
    }
    
    var id: ID
}

注意点として、関数スコープ内でこのマクロを利用した場合は専用にスコープが区切られて展開されます。
つまり、上の例のマクロは次のようなイメージで展開されます。

展開前
func foo() {
    #PhantomID(UUID.self)
    let value = ID(rawValue: UUID())
}
展開後
func foo() {
    do {
        struct ID: RawRepresentable, Hashable, Sendable {
            init?(rawValue: UUID) {
                self.rawValue = rawValue
            }
            var rawValue: UUID
        }
    }    
    let value = ID(rawValue: UUID()) // IDは見つからずエラーになる
}

専用スコープが切られているため、マクロで生成した型や変数、deferをうまく使うことは難しいです。

ExpressionMacro

式マクロです。関数ブロック内で式として利用できます。
1つの式を出力できます。
イメージとしては普通のグローバル関数にかなり近いです。

展開前
func testFoo() {
    let x: Double = 1.0
    let y: Int = 42
    let result = #implicitCast(x * y)
}
展開後
func testFoo() {
    let x: Double = 1.0
    let y: Int = 42
    let result = (Double(x) * Double(y))
}

swift-power-assertはこのマクロです。

CodeItemMacro

関数ブロック内で利用できます。
関数ブロックで記述できるあらゆるコードを複数出力でき、インナータイプやguard文も出力できて表現力が高いです。

しかしこのマクロはSwift 5.10時点ではexperimentalのようです。開発中の6.0も同様で正式なプロポーザルもなく、状況がよくわかりません。

展開前
button.onTap = { [weak self] in
    #guard(self)
}
展開後
button.onTap = { [weak self] in
    guard let self else { return }
}

Attached Macro

Attached Macroは、@で始まることが特徴です。
何らかの宣言に対して付与します。
Freestanding Macroと違って自由な場所に記述することはできませんが、付与された対象の型定義や変数定義をマクロ内で活用でき、既存コードを拡張する方向で力を発揮します。

AccessorMacro

プロパティ宣言に対して付与でき、getterやsetterなどアクセサースコープ要素を出力できます。
プロパティに処理を割り込んだり値の保存先を変えるなど、Property Wrapperのようなことができます。
getsetを定義すれば値の保存先を変えることができますが、代わりの保存先は用意する必要があります。保存先を追加することはAccessorMacroだけではできませんが、後述するPeerMacroやMemberMacroと組み合わせることで行なえます。

展開前
@Trace
var value: Int
展開後
var value: Int {
    willSet {
        print(newValue)
    }
}

MemberMacro

様々な型宣言に付与でき、そのスコープ内に別の宣言を出力できます。
付与された型に存在する多くの情報を利用できるため、自由度が高いです。

展開前
@CaseDetection
enum Animal {
    case dog
    case cat(curious: Bool)
}
展開後
enum Animal {
    case dog
    case cat(curious: Bool)

    var isDog: Bool {
        if case .dog = self {
            return true
        }
        return false
    }

    var isCat: Bool {
        if case .cat = self {
            return true
        }
        return false
    }
}

MemberAttributeMacro

基本は上述したMemberMacroと同じですが、こちらは既存の変数や関数に対して追加のattributeを出力するためのものです。
この際に別のマクロも付与でき、組み合わせて使うことができます(マクロは複数回展開されるため、正しく動作します)。
Swiftのマクロは基本的にコードを追加するのみで、既存コードを編集することができません。
attributeの追加はMemberMacroではできないため、こちらが用意されています。

展開前
@memberDeprecated
struct SomeStruct {
    typealias MacroName = String

    var oldProperty: Int = 420

    func oldMethod() {
        print("This is an old method.")
    }
}
展開後
struct SomeStruct {
    @available(*, deprecated)
    typealias MacroName = String

    @available(*, deprecated)
    var oldProperty: Int = 420

    @available(*, deprecated)
    func oldMethod() {
        print("This is an old method.")
    }
}

PeerMacro

様々な宣言に対して付与でき、その宣言と同じスコープに対して別の宣言を出力できます。
protocol定義から具体型を出力したり、変数に対する独自のsetter関数を定義するなど用途は多岐に渡ります。
MemberAttributeMacroと組み合わせて使われることも多いと思います。

展開前
@AddAsync
func send(request: Request, completion: @escaping @Sendable (Result<String, Error>) -> Void) {
    completion(.success("OK"))
}
展開後
func send(request: Request, completion: @escaping @Sendable (Result<String, Error>) -> Void) {
    completion(.success("OK"))
}

func send(request: Request) async throws {
    try await withCheckedThrowingContinuation { continuation in
        send(request: request) { result in
            switch result {
            case .success(let value):
                continuation.resume(returning: value)
            case .failure(let error):
                continuation.resume(throwing: error)
            }
        }
    }
}

ExtensionMacro

型宣言に対して付与でき、新たなextensionを出力できます。
前述したPeerMacroの、extension専用のものです。PeerMacroでextensionを生やそうとすると後述するnamesパラメータの成約をうまく満たすことができないため、専用のものが用意されています。

展開前
@equatable
final class Message {
    let text: String
}
展開後
final class Message {
    let text: String
}

extension Message: Equatable {
}

注意点として、extensionはマクロを付与した型のみにしか行なえません。マクロによる拡張がコンパイラが予測可能な範囲に抑えられています。

NG例展開前
@TypeName
struct Message {}
NG例展開後
struct Message {}

// ⚠️Message以外に対してextensionを追加することはできない
extension String {
    static var Message: String { 
        return "\(Message.self)"
    }
}

ConformanceMacro

種類だけ定義されていますが、まだ実装がありません。
プロポーザルには記載があり、型定義を見る限りでは各種型宣言に対して利用でき、特定のprotocolに適合させることができそうです。

ExtensionMacroと役割が被りそうにも思いますが、詳細は調べていません。

追記

ExtensionMacroのプロポーザルによると、ExtensionMacroはConformanceMacroより拡張性の高い上位の存在で、ConformanceMacroは不要となったようです。そのために実装されていないのだと思います。

マクロ宣言時の追加パラメータについて

Swiftのマクロには新しいIdentifier(型、変数、関数など)を出力する際はそれを明示的にしなければならない制約があります。
出力するIdentifierの命名規則をマクロ宣言に記載する必要があります。
登録されていないものが出力された場合はコンパイルエラーになります。

names

DeclarationMacro、MemberMacro、PeerMacro、ExtensionMacroで新しい型や変数、関数を生やす際に必要です。

@freestanding(declaration, names: named(ID), named(id))
public macro PhantomID<T: Hashable & Sendable>(_: T.Type) = #externalMacro(...)

names:の値としては以下の5種類の値が使えます。

意味
named(name) 固定の名前(name
prefixed(prefix) 元の名前に接頭辞を付与した名前(元がFooの場合、prefixFoo
suffixed(suffix) 元の名前に接尾辞を付与した名前(元がFooの場合、Foosuffix
overloaded 元と同じ名前でオーバーロードする
arbitrary 自由な名前。これはグローバルスコープでは使用不可

arbitraryは名前を事前に宣言するルールを破ることができますが、グローバルなスコープにこれで出力するとエラーになるという制約があります。
もし自由な名前で何かを宣言したい場合、一度別の名前空間をsuffixed(suffix)で生やすなどして、その中に宣言する、などのテクニックが必要となります。

conformances

ExtensionMacroで必要です。
上述したnames:とほぼ同じで、conformする対象のprotocolをconformances:で指定が必要になります。

@attached(extension, conformances: Equatable)
public macro equatable() = #externalMacro(module: "MacroExamplesImplementation", type: "EquatableExtensionMacro")

おまけ

「宣言」の文法とは

SwiftSyntaxのコードを見る限り、以下のようです。

  • 各種型宣言
actor A {}
class C {}
struct S {}
enum E {}
protocol P {}
macro M() = ...
  • accociatedtype宣言
protocol P {
    accociatedtype A // ← これ
}
  • case宣言
enum E {
    case foo // ← これ
}
  • 各種関数宣言とプロパティ宣言
class C {
    func f() {}
    deinit {}
    subscript(_ i: Int) {} 
    var value: Int
}
  • マクロ利用宣言

Freestanding Macroの利用も宣言となっていました。

#assert()
  • その他
import Foundation // ← これ
extension S {} // ← これ
typealias A = ... // ← これ

他にもオペレータ関係の普段使わないものが含まれていますが、割愛します。

Discussion