Swift Macros 実践:copyメソッドを作って機能拡張+ボイラープレート削減してみた

に公開

はじめに

本記事では、Swiftに標準で存在しないcopyメソッドをマクロによって自動生成し、Swift機能拡張ボイラープレート削減 の両方を実現する手法について実際の実装をもとに紹介します。
Swift 5.9 / Xcode 15 以降で動かすことを前提にしています。

Swift Macros の概要

  • Swift 5.9 以降で導入されたコード生成機能
  • コード補完、メソッド追加、構造体の自動拡張などが可能
  • Freestanding Macro / Attached Macro の2種類に分かれる
    • Freestanding Macro -> #から始まるマクロ
    • Attached Macro -> @から始まるマクロ
  • 今回は Attached Macro を活用して copyWith メソッドを生成します

SwiftMacrosの詳細については割愛しますが、公式ドキュメントは以下となります。

copyメソッドについて

ここでいう copy メソッドは、手元の struct から変えたい項目だけを指定して新しいインスタンスを作り直す仕組みです。すべての引数を optional にしておき、値を渡さなかった箇所は self の元の値をそのまま使います。

ただ Swift には、Dart Freezed の copyWith() や Kotlin の copy() のように部分的な値だけ差し替えて新しいインスタンスを返す標準APIが用意されていません。

そのため、

  • 状態の差分更新が煩雑になりがち
  • プラットフォーム間で共通のアーキテクチャを組む際、Swift にだけ copy 関数がなくて冗長になる
  • テストデータ生成で一部値だけ変えたい場合も毎回全プロパティを初期化する羽目になる

といった課題が発生します。

各 struct に個別で func copyWith(...) を書いても目的は達成できますが、そのたびに引数リストや初期化コードを複製することになり、プロパティが増えるたびメソッドを修正する手間も発生します。型安全に差分更新したいだけなのに、コピペで壊れやすいコードを量産するのは避けたいところです🤔

そこで Swift Macros を使って copy メソッドを自動生成 してしまえば、このボイラープレートとメンテコストをまとめて削減できます✨

ちなみに最近まで Flutter で copyWith() を使っていて、Swift に戻ったら標準の copy がない――それなら作ってしまおう、が今回のモチベです😆

Swift Macrosでcopy メソッドを開発する準備

1. Swift Package の新規作成

  1. File > New > Package... を選択
  2. テンプレート一覧から Other > Swift Macro を選択

2. ディレクトリ構造

パッケージを作成すると以下のようなディレクトリ構成になります

CopyWithMacro/
├── Package.swift
├── Sources/
│   ├── CopyWithMacro/
│   ├── CopyWithMacroClient/
│   └── CopyWithMacroMacros/
└── Tests/
    └── CopyWithMacroTests/

各ディレクトリの役割:

  • CopyWithMacro/ - マクロの公開インターフェース。他のプロジェクトからインポートして使用する際のエントリーポイント
  • CopyWithMacroClient/ - マクロを実際に使用する実行可能なクライアントコード。マクロの動作確認やサンプルコードを記述する場所
  • CopyWithMacroMacros/ - マクロの実装ロジック。コード生成の実際の処理を行う
  • CopyWithMacroTests/ - マクロのテストを書くモジュール

3. マクロの公開インターフェース定義

Sources/CopyWithMacro/CopyWithMacro.swift にマクロの定義を記述します:

import Foundation

@attached(member, names: named(copyWith))
public macro CopyWith() = #externalMacro(module: "CopyWithMacroMacros", type: "CopyWithMacro")
  • @attached(member, ...) でAttached Macroであることを示す(member型でメンバーを追加)
  • names: named(copyWith) で生成するメソッド名を指定
  • #externalMacro で実際の実装モジュールと型を指定

4. マクロの実装

Sources/CopyWithMacroMacros/CopyWithMacroMacro.swift に、copyWithメソッドを生成するロジックを実装します。詳細な実装方法は後述の「マクロのコード詳説」で解説します。

マクロのコード詳説

copyWithの仕様

今回のcopyWithメソッドは以下のルールで実装しました

  • 適用対象:
    • struct にのみ適用可能
    • 型注釈が明示されているストアドプロパティのみ
    • 計算プロパティは除外される
    • static プロパティは除外される

例として、以下の struct がある場合(挙動を把握しやすいようシンプルな例にしています👍):

@CopyWith
struct User {
    let name: String
    let age: Int
    static let maxAge = 100  // 除外される
    var fullName: String { "\(name)" }  // 除外される
}

生成されるメソッドは以下のようになります:

func copyWith(name: String? = nil, age: Int? = nil) -> User {
    return User(name: name ?? self.name, age: age ?? self.age)
}

copyWith の実装全体

// 全トリビア(コメントや余分な空白)を除去する Rewriter
private final class TriviaCleaningRewriter: SyntaxRewriter {
    override func visit(_ node: TokenSyntax) -> TokenSyntax {
        node
            .with(\.leadingTrivia, Trivia())
            .with(\.trailingTrivia, Trivia())
    }
}

public struct CopyWithMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let structDecl = declaration.as(StructDeclSyntax.self) else {
            return []
        }

        func normalizedTypeString(from typeSyntax: TypeSyntax) -> String {
            let rewriter = TriviaCleaningRewriter()
            let stripped = rewriter.rewrite(typeSyntax)
            return stripped.description
        }

        let typedProperties: [(name: String, typeString: String)] =
            structDecl.memberBlock.members.compactMap { member in
                guard
                    let varDecl = member.decl.as(VariableDeclSyntax.self),
                    // static変数を除外
                    varDecl.modifiers.contains(
                        where: {
                            $0.name.tokenKind == .keyword(.static)
                        }
                    ) == false,
                    let binding = varDecl.bindings.first,
                    // 計算プロパティを除外
                    binding.accessorBlock == nil,
                    // 変数名の取得が可能なもののみ
                    let ident = binding.pattern.as(
                        IdentifierPatternSyntax.self
                    ),
                    // 型注釈があるもののみ
                    let typeSyntax = binding.typeAnnotation?.type
                else {
                    return nil
                }

                let cleanType = normalizedTypeString(from: typeSyntax)
                return (ident.identifier.text, cleanType)
            }

        // パラメータ定義
        let params =
            typedProperties
            .map { "\($0.name): \($0.typeString)? = nil" }
            .joined(separator: ", ")

        // 代入式
        let assigns =
            typedProperties
            .map { "\($0.name): \($0.name) ?? self.\($0.name)" }
            .joined(separator: ", ")

        let typeName = structDecl.name.text
        let copyFunc = """
            func copyWith(\(params)) -> \(typeName) {
                return \(typeName)(\(assigns))
            }
            """

        return [DeclSyntax(stringLiteral: copyFunc)]
    }
}

@main
struct CopyWithMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        CopyWithMacro.self
    ]
}

実装の重要なポイント解説

MemberMacroプロトコル

public struct CopyWithMacro: MemberMacro

MemberMacro は、Attached Macro の一種で、型(struct/class/enum)にメンバー(メソッドやプロパティ)を追加するマクロです。expansion メソッドで [DeclSyntax] を返すことで、生成するコードを定義します。

expansion メソッドの役割

public static func expansion(
    ...
    providingMembersOf declaration: some DeclGroupSyntax,
    ...
) throws -> [DeclSyntax]
  • declaration: マクロが適用された宣言(struct の構文情報)
  • 戻り値: 生成するコードのリスト(今回は copyWith メソッド1つ)

プロパティの抽出(VariableDeclSyntax)

let varDecl = member.decl.as(VariableDeclSyntax.self)

SwiftSyntax では、コードは構文木(AST)として表現されます。VariableDeclSyntax は変数宣言を表す構文ノードで、これを使ってプロパティの情報(名前、型など)を取得します。

フィルタリング処理

structDecl.memberBlock.members.compactMap {...} で、copyWithメソッドに含めるプロパティのみを抽出しています。

  • staticプロパティは除外(modifiersstatic が含まれていないか確認)
  • 計算プロパティは除外(accessorBlock が存在しないか確認)
  • 型注釈が明示されているプロパティのみ対象

TriviaCleaningRewriter の役割

private final class TriviaCleaningRewriter: SyntaxRewriter

Trivia とは、コードの空白、改行、コメントなどの「重要ではない部分」のことです。TriviaCleaningRewriter は、型の文字列表現から余分な空白やコメントを除去し、クリーンな型名文字列を生成するために使用されます。
このロジックを実装しないと自動生成されるコード内にコメントや余分な空白がそのまま混ざってしまい、下記のようなケースでコンパイルエラーになることがあります。

例:

  • String ? // 名前の型String?
  • Int Int

コード生成の仕組み

let copyFunc = """
func copyWith(\(params)) -> \(typeName) {
    return \(typeName)(\(assigns))
}
"""
return [DeclSyntax(stringLiteral: copyFunc)]

文字列リテラルで生成するコードを組み立て、DeclSyntax(stringLiteral:) で構文ノードに変換します。これにより、コンパイラが生成されたコードを認識できるようになります。

作成したSwift Macrosの使用方法

Swift Package Manager(SPM)で依存関係を追加し、プロジェクトでimportするだけで使用可能になります

使用例

import CopyWithMacro

enum ViewState {
    case loading
    case complete(HogeData)
    case error(Error)
    
    @CopyWith
    struct HogeData {
        let items: [Item]
        let page: Int
        let loadMore: Bool
    }
}

HogeDataというstructに対して@CopyWithマクロを付与することで、コンパイル時にcopyWithメソッドが自動生成されます。

上記のHogeDataに対しては、以下のようなメソッドが自動生成されます:


Xcode上で生成されたコードを確認するには、@CopyWithマクロにカーソルを合わせて、右クリック > Expand Macroを選択します。

このメソッドにより、既存のインスタンスの一部のプロパティだけを変更した新しいインスタンスを簡単に作成できるようになります。すべての引数はoptionalでデフォルト値がnilのため、変更したいプロパティだけを指定し、それ以外は元の値がそのまま使用されます。

var currentState: ViewState = .complete(
    HogeData(
        items: [Item(id: 1), Item(id: 2)],
        page: 1,
        loadMore: true
    )
)

// ページング処理などでpageだけを更新したい場合
if case .complete(let data) = currentState {
    currentState = ViewState.complete(
        data.copyWith(page: 2)
    )
}

// PullToRefreshなどで新しく取得したitemと追加ローディング可能かどうかだけ更新したい
if case .complete(let data) = currentState {
    let newItems = [Item(id: 3), Item(id: 4), Item(id: 5)]
    currentState = ViewState.complete(
        data.copyWith(
            items: newItems,
            loadMore: newItems.count >= 20
        )
    )
}

おわりに

本記事では、Swift Macrosを使ってDart FreezedのcopyWith()やKotlinのcopy()のような機能を実現する方法を紹介しました。Swift Macrosを使うことで、ボイラープレートコードの削減とメンテナンス性の向上を同時に実現できます。

Swift Macrosは比較的新しい機能ですが、コード生成の可能性を大きく広げてくれる強力なツールです。今回紹介した実装にも改善の余地があるかもしれませんが、マクロを実装すること自体は、始める前のイメージよりはそこまで大変ではない印象でした。読んでくださった方も、マクロで実装してみたいアイデアがあれば、ぜひ試してみると面白いと思います✌️

参考文献

https://zenn.dev/iceman/articles/4613ef478c9691

https://techblog.glpgs.com/entry/2023/11/30/125549

Discussion