Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Swift Macrosの作り方

こんにちは!技術本部 Mobile ApplicationグループでiOSエンジニアをしている長﨑です。

Sansanアプリでは自分たちで定義したSwift Macrosを開発に導入し始めています。Swift Macrosについての勉強会も社内で実施しており、せっかくなので勉強会のコンテンツを記事にしてみます。
この記事では、Swift Macrosを開発するに当たって必要となる基礎知識からマクロの実装方法、CocoaPodsを使ったプロジェクトへの組み込み方法について、解説していきます。

Swift Macrosについての基礎知識

まずSwift Macrosを開発するに当たって必要となる知識をまとめたいと思います。

Swift Macrosって何?

Swift MacrosはWWDC 2023で発表された技術で、Xcode15以上が必須になります。
サポートしているOSバージョンについては公式な記述を見つけられなかったのですが、少なくともiOS13以上で利用可能なようです。 *1

Swift Macrosはボイラープレートを削減することを目的としています。コードベースにボイラープレートが増えていくのはありがちなことだと思いますが、ボイラープレートは可読性の低下を招いたり、何かしら変更を加える時に修正漏れのリスクを発生させたりします。Swift Macrosは最小限のコードでボイラープレートと同等のコードを擬似的に出力してくれる技術といえるでしょう。

どういうことなのか具体例を用いて説明してみます。

Xcode15以上では標準でいくつかの組み込みマクロが利用可能になっていて、例えば #Preview というマクロがあります。
#Preview マクロは、Xcode PreviewsによるUIプレビュー用のコードを提供するマクロです。Swift Macrosによる恩恵をより感じて欲しいので、ここではボイラープレートが多めのUIKitでのUIプレビューを例に見ていきましょう。

UIKitのViewをプレビューするためには、Swift Macros以前は次のようなコードが必要でした。

struct Wrapper: UIViewRepresentable {
    func makeUIView(context: Context) -> SomeView { // <code>SomeView</code> は <code>UIView</code> の子クラスとします
        SomeView()
    }

    func updateUIView(_ uiView: SomeView, context: Context) {
    }
}

struct SomeView_Previews: PreviewProvider {
    static var previews: some View {
        Wrapper()
    }
}

Swiftの言語仕様上、これらが必要なことはわかるのですが、やりたいことに比してコード量が多いと思ってしまいます。

これが #Preview を使うことで次のように書けます。

#Preview {
    SomeView()
}

最小限の記述だけになり、本質的な部分にフォーカスできていることがわかります。

これをSwift Macrosはどのように実現しているかというと、ビルド時にマクロの呼び出しに従って、以前のコードと同等の役割を持つコードをコンパイラの内部的に出力することで実現しています。

マクロが出力するコードを実際に見てみると、より理解が進むと思います。これはXcode上で確認可能です。Swift Macrosの使用箇所を右クリックして「Expand Macro」を選択すると、実際にマクロにより展開されるコードを表示できます。

expand-macro

Swift Macrosの種類

次にマクロの種類を説明します。Swift Macrosには大きく分けて2種類のマクロがあります。

  • Attached Macro
    • 付属型マクロ
    • @Observable のように、何らかの宣言に付属することで機能を追加するマクロ。
    • @ から始めるのが特徴。
  • Freestanding Macro
    • 自立型マクロ
    • #Preview のように、単体で独立しているマクロ。
    • # から始めるのが特徴。

ここからさらにどういう出力をするかで種類が分かれます。

  • ExpressionMacro: 式を返す
  • DeclarationMacro: 定義を追加する

マクロの種類の一覧はSwiftSyntaxMacrosのプロトコル一覧にあります。すべてを説明すると長くなってしまうので、ここではこのレベルの説明にとどめさせてください。

Swift Macrosには独立したモジュールが必要

Swift Macrosを定義するには独立したモジュールが必要です。公式に紹介されている方法としては、SwiftPMでパッケージを作ることになります。独立したパッケージとして構成し、ライブラリの形でアプリのプロジェクトに組み込むことで利用可能となります。

よって、アプリへの組み込み方法としては、SwiftPMを利用するのが簡単です。
ただ、CocoaPodsで組み込む方法もありますので、後ほどご紹介します。

Swift Macrosを開発してみる

ここからは実際にどうやってSwift Macrosを開発するのかを説明していきます。

Swift Macros Packageを作る

まずは何はともあれプロジェクトを作りましょう。前述の通り、SwiftPMでパッケージを作ります。

Xcodeから作成する方法とコマンドラインを利用する方法がありますが、Xcodeから作成する方法で説明していきます。

XcodeのFile > New > Package… を選択します。

ダイアログで Swift Macro を選択し、Nextを押下します。

new-swift-macro-package

保存ダイアログが表示されるので、保存します。ここではパッケージ名をデフォルトの MyMacro と設定したものとします。

これでプロジェクトの準備はできました。

Swift Macros Packageの構成

出来上がったSwift Macros Packageの構成について説明します。

MyMacro の中には3つのモジュール+1つのテストモジュールが出来上がっています。

macro-module-structure

それぞれのモジュールの役割を以下に示します。

  • MyMacro
    • マクロをAPIとして公開するためのエンドポイントを定義するモジュールです。
  • MyMacroClient
    • プロジェクト内でマクロを動作させるためのモジュールです。実際にマクロを動かすために使用します。
  • MyMacroMacros
    • マクロの実装モジュールです。ここにマクロが実際にどう動くかを実装していきます。
  • MyMacroTests
    • テストモジュールです。テストコードでマクロを単体テストできます。

開発時は MyMacroClient のスキームを選択して、ビルドしながら動作確認するのがおすすめです。パッケージを作ったら、一度 MyMacroClient をビルドしておきましょう。

マクロを実装する

プロジェクトの準備が整ったので、マクロの実装に入っていきます。

MyMacro の中にはすでにサンプルとして、 #stringify というマクロが定義されています。
この #stringify マクロをベースにして、実装の流れを解説します。

実装の流れとしては次のようになります。

1. マクロのインプットとアウトプットを決める
2. マクロのエンドポイントを定義する
3. マクロのインプットを解析して、アウトプットを構成する

この流れに沿って、 #stringify マクロを見ていきましょう。

1.マクロのインプットとアウトプットを決める

通常のプログラミングと同様、マクロにもインプットとアウトプットがあります。まず、この設計を固めましょう。

インプットは、マクロの種類によって変わってきます。Freestanding Macroであれば独立しているため、インプットはマクロに渡した引数のみになります。Attached Macroの場合は、マクロへ渡す引数に加えて、マクロが付与された定義のコードがインプットになってくるでしょう。

アウトプットは、マクロ展開後のSwiftコードになります。

インプットとアウトプットについて、 #stringify マクロではどうなっているか確認していきます。マクロの使用例は MyMacroClient/main.swift にあります。このマクロを展開させてみましょう。

expand-stringify-macro

#stringify マクロはFreestanding Macroのため、インプットは「引数で受け取った式( a + b )」になります。

アウトプットは「引数で受け取った式そのまま( a + b )と引数を文字列化したもの( "a + b" )のタプル」です。

インプットとアウトプットが明確になったところで、次のステップに進みます。

2.マクロのエンドポイントを定義する

#stringify マクロのエンドポイントは MyMacro/MyMacro.swift に定義されています。

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String)
= #externalMacro(module: "MyMacroMacros", type: "StringifyMacro")

1行ずつ順に読み解いていきましょう。

  • @freestanding(expression)
    • #stringify マクロはFreestanding Macroのため、 @freestanding を指定します。
    • またアウトプットはタプルを式として返す ExpressionMacro であるため、 expression を指定しています。例えば、DeclarationMacroの場合は、ここが declaration になります。
  • public macro stringify(_ value: T) -> (T, String)
    • マクロを定義するのには macro というマクロ用の宣言を使います。
    • あとはファンクションのように、引数と戻り値を定義します。Expression Macro の場合は、アウトプットの式の定義を戻り値に指定する必要があります。
  • = #externalMacro(module: "MyMacroMacros", type: "StringifyMacro")
    • マクロの実装がどこにあるかを定義しています。見たままですが、 MyMacroMacros モジュールにある StringifyMacro がマクロの実装ということになります。
3.マクロのインプットを解析して、アウトプットを構成する

いよいよマクロの実装の確認に入っていきます。マクロの実装は MyMacroMacros/MyMacroMacro.swift にあります。

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("compiler bug: the macro does not have any arguments")
        }

        return "(\(argument), \(literal: argument.description))"
    }
}

@main
struct MyMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
    ]
}

ここには2つのstructが定義されています。順番に見ていきます。

1番目のstructの StringifyMacro#stringify マクロの実装になります。

  • public struct StringifyMacro: ExpressionMacro {
    • ExpressionMacroであるため、 ExpressionMacro プロトコルへの準拠を宣言しています。
  • public static func expansion() -> ExprSyntax {
    • ExpressionMacro プロトコル準拠のために必要となるファンクション定義です。
    • シグネチャはマクロによって変わってきますが、使いたいマクロのプロトコルへの準拠宣言をしてXcodeの機能で必要な定義を取ってくれば簡単です。
  • guard let argument = node.argumentList.first?.expression else {
    • ファンクション引数の node から、マクロに渡された引数を受け取っています。マクロに渡された引数は node.argumentList に配列の形で入っています。 #stringify マクロでは引数は1つしかないため、 first でアクセスしています。
    • expressionSwiftSyntaxのExprSyntax型 を受け取っています。Swift MacrosはSwiftSyntaxの上に成り立っている技術であり、マクロのインプットおよびアウトプットはSwiftSyntaxの型になります。SwiftSyntaxの型について詳細を知りたい場合は、APIリファレンスを見れば解決できます。
  • fatalError("compiler bug: the macro does not have any arguments")
    • guardでアンラップに失敗したら fatalError を投げています。fatalError が投げられてもアプリがクラッシュするわけではなく、マクロが展開できない旨のビルドエラーになるだけなので、あまり気にすることなく fatalError を使用していって大丈夫です。
  • return "(\(argument), \(literal: argument.description))"
    • アウトプットを構成しているのが、この部分になります。
    • このファンクションの戻り値の型は ExprSyntax ですが、ここでは文字列を返しています。これは ExprSyntax が ExpressibleByStringLiteral に準拠しているためです。 ExpressibleByStringLiteral に準拠した型では、コンパイラが文字列リテラルをその型へ変換してくれます。

2番目のstructの MyMacroPlugin はマクロモジュールのエントリポイントを定義しています。
定義したマクロ定義の型を列挙して、モジュールにマクロを登録しています。

これで #stringify マクロの実装については一通り確認できました。

ただ、実践的なマクロを組む場合にはもう少しSwiftSyntaxについての知識が必要になります。この #stringify マクロを修正して、SwiftSyntaxへの理解を深めてみようと思います。

3+1.インプットを解析する

#stringify マクロではインプットをそのままアウトプットに使っていたため、インプットの解析は不要でした。マクロではインプットを解析して、その中の要素をハンドリングしたいことがあるため、その方法を解説します。

実装の流れは次の通りです。

1. インプットの構文木を把握する。
2. as(XxxSyntax.self) で構文木の内容にあうようにSwiftSyntaxの型にパースする。
3. SwiftSyntaxの型を操作して、目的の要素を取得する。

では、まずインプットの構文木を把握するところから始めます。構文木を把握するには Swift AST Explorer を使うのが便利です。 Swift AST Explorer は入力したSwiftコードをSwiftSyntaxの構文木として表示してくれるWebサービスです。

Swift AST Explorer を開いて、左のペインに #stringify マクロの呼び出しイメージを貼り付けてください。そうすると、右側のペインにそのコードの構文木が表示されます。

swift-ast-explorer

黒太字で元のソースコードが表示され、それぞれがどういった構造で格納されているかがわかるかと思います。この構造に沿ってSwiftSyntaxの型にパースしていきます。

例えば、引数 a + b の左辺 a だけを取得したい場合を考えてみましょう(実用性はないのであくまで練習のためです)。

引数は node.argumentList.first で取得できるので、そこまでは構文木の探索をスルーできます。 LabeledExpr がラベル付き引数を表しているので、その下の InfixOperatorExpr が引数 a + b を表していることがわかります。

ハンドリングするために引数を InfixOperatorExpr にパースします。SwiftSyntaxの型へのパースには as(XxxSyntax.self) というファンクションが用意されています。 as の引数はSwiftSyntaxの型です。
InfixOperatorExpr はSwiftSyntaxの型としては末尾に Syntax をつけます。
従って InfixOperatorExprSyntax.selfas へ渡す引数になります。

guard let argument = node.argumentList.first?.expression,
      let infixOperatorExpr = argument.as(InfixOperatorExprSyntax.self) // 👈
else {
    fatalError("compiler bug: the macro does not have any arguments")
}

これで InfixOperatorExprSyntax 型にパースできました。

InfixOperatorExprSyntax から左辺を取り出すには、APIリファレンスを確認します。

InfixOperatorExprSyntax

Children に書かれている要素にプロパティとしてアクセスできるので、 leftOperand で左辺が取得できそうです。

guard let argument = node.argumentList.first?.expression,
      let infixOperatorExpr = argument.as(InfixOperatorExprSyntax.self)
else {
    fatalError("compiler bug: the macro does not have any arguments")
}

let leftOperand = infixOperatorExpr.leftOperand // 👈

これで左辺 a を取得できました。

このように、インプットを解析して要素を取得し、欲しいアウトプットの形に構成していくことがマクロの実装には必要になります。

なお、構文木の解析には SyntaxVisitor を使って構文木を走査する方法もありますが、詳細についてはここでは割愛させてください。

CocoaPodsによる配布

最後に、できあがったSwift Macrosライブラリの配布について説明します。

SwiftPMでの配布であれば、とても簡単です。SwiftPMでライブラリを追加して、 MyMacro モジュールをimportすれば使用可能です。

ただ、プロジェクト方針などによりSwiftPMの使用に支障がある場合などもあるので、CocoaPodsでも配布したいことがあります。
CocoaPodsで配布できるようにする手順としては、次の3つになります。

1. マクロ実装モジュールのバイナリをビルドする
2. バイナリをレポジトリに含める
3. Podspecファイルを追加する

順番に見ていきます。

1.マクロ実装モジュールのバイナリをビルドする

ただのSwiftPMのパッケージなので、次のコマンドでビルドできます。

swift build -c release

これで .build/release の下にバイナリができあがります。いくつかファイルができていますが、必要になるものはマクロ実装モジュールのバイナリなので、例でいうと MyMacroMacros になります。

2.バイナリをレポジトリに含める

Podライブラリの中にバイナリを含めて配布する形式になるため、ビルドしたバイナリをレポジトリ内に含めてしまいます。 .build 配下からどこか適当な場所にバイナリをコピーして、コミットすればOKです。例として Builds/MyMacroMacros へコピーしたことにします。

3.Podspecファイルを追加する

Podspecファイルをレポジトリに追加すれば配布準備完了になります。

サンプルを以下に示します。

Pod::Spec.new do |spec|
  spec.name         = "MyMacro"
  spec.version      = "0.0.1"
  spec.summary      = "A short description of MyMacro."
  spec.description  = <<-DESC
  DESC
  spec.homepage     = "http://EXAMPLE/MyMacro"
  spec.license      = "MIT (example)"
  spec.author       = { "xxx" => "xxx@example.com" }
  spec.source       = { :git => "https://EXAMPLE/MyMacro.git", :tag => spec.version.to_s }

  spec.ios.deployment_target = 'FIXME'

  spec.source_files = ['Sources/MyMacro/**/*.swift']
  spec.swift_version = '5.9'

  spec.preserve_paths = ['Builds/MyMacroMacros']

  spec.pod_target_xcconfig = {
    'OTHER_SWIFT_FLAGS' => '-load-plugin-executable ${PODS_ROOT}/MyMacro/Builds/MyMacroMacros#MyMacroMacros'
  }

  spec.user_target_xcconfig = {
    'OTHER_SWIFT_FLAGS' => '-load-plugin-executable ${PODS_ROOT}/MyMacro/Builds/MyMacroMacros#MyMacroMacros',
    'LD_RUNPATH_SEARCH_PATHS' => '${PODS_CONFIGURATION_BUILD_DIR}/MyMacro'
  }
end

ポイントになるのが、 preserve_paths pod_target_xcconfig user_target_xcconfig の3点です。

  • preserve_paths
    • ここに先ほどコピーしたバイナリのパスを指定します。
    • この指定によりバイナリを含めて配布されるようになります。
  • pod_target_xcconfig
    • この指定によりPodターゲットの OTHER_SWIFT_FLAGS ビルド設定に -load-plugin-executable オプションを設定しています。-load-plugin-executable オプションによってマクロ実装モジュールがコンパイラプラグインとしてロードされるようになります。
  • user_target_xcconfig
    • こちらの指定は pod_target_xcconfig と同じ OTHER_SWIFT_FLAGS 設定をPodを組み込む側のターゲットにも設定しています。
    • また、アプリ実行時にもライブラリを見つけられない旨のエラーが発生したため、 LD_RUNPATH_SEARCH_PATHS も設定しています。

これで実装したマクロをCocoaPodsで配布できるようになりました。

最後に

長文となってしまいましたが、最後まで読んでいただきありがとうございました!

Swift Macrosを使うことで、プロジェクト内で頻発する冗長なボイラープレートを減らすのに効果があると感じています。コード自動生成など他にもアプローチはありますが、個人的には以下がSwift Macrosの強みだと思います。

  • マクロ利用時に補完が効くこと
    • 普通のコードと同じくXcodeが補完候補を表示してくれるので、どういう使い方するんだったっけ?と悩むことがないです。
  • テスト可能であること
    • 記事中で触れられていない点で恐縮ですが、XCTestで容易に単体テストできるので、品質を高めやすいです。
  • Swift標準機能であること
    • ファーストパーティ提供による安心感は代えがたいです。

ボイラープレートが多くて読みにくいなぁと感じたら、ぜひSwift Macrosの導入にトライしてみてください!

Sansanでは、共にSansan / Eightのモバイルアプリを開発していく仲間を募集中です!
選考評価無しで現場のエンジニアのリアルな声が聞けるカジュアル面談もありますので、ご興味ありましたらぜひ面談だけでもお越しいただければ幸いです!

open.talentio.com

20240312182329

20240315190344

*1: Swift Macrosを開発するためのパッケージを作ると、生成されたPackage.swiftの中で platforms: [iOS(.v13)] が設定されています。このため、少なくともiOS13以上では利用可能と思われます。

© Sansan, Inc.