Swift MacroってXcodeから使えないの?
Swift 5.9からSwift Macroが実用段階になったが、WWDCの動画でも、公式ドキュメントでもSwift Packageから作成することが前提となっている。
targets: [ // Macro implementation that performs the source transformations. .macro( name: "MyProjectMacros", dependencies: [ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax") ] ), // Library that exposes a macro as part of its API. .target(name: "MyProject", dependencies: ["MyProjectMacros"]), ]
Xcodeで作成したtargetに組み込むときもパッケージの依存を組み込み、マクロフレームワークをimportするだけで利用可能になるようだ。
しかしXcodeのGUIではMacroを含むモジュールに依存関係を設定する方法はない。通常の依存関係と異なり、Macroはビルドdestinationが特殊なので、これを内部的にどうやって設定しているのか気になった。 (例えばiOSアプリにMacroを組み込むとき、アプリケーション自体はiOS向けにビルドするが、Macroは常にmacOSで実行可能な形でビルドされなくてはならない)
XcodeやxcodebuildのレイヤーではMacroを依存として指定する方法がないのだが、swiftcのレイヤーでは何らかの方法でmacroへの依存関係を指定しているはずだ。
-load-plugin-executable
調べてみたところ、どうやら -load-plugin-executable
というオプションがswiftcに生えていた。
この辺の記事にほとんど書いていた。
$ swiftc --help -load-plugin-executable <path>#<module-names> Path to an executable compiler plugins and providing module names such as macros
この -load-plugin-executable
でmacroターゲットのバイナリを指定すると良いらしい。Xcodeでは OTHER_SWIFT_FLAGS
にこれを指定する。
実際に、適当なMacroを含むパッケージをXcodeプロジェクトに追加し、利用してみたときのログを見ていると、同じフラグを渡していることが確認できる。
Special Thanks : mtj0928/userinfo-representable
Swift MacroをSwift PackageなしでXcodeだけで作る
そして、調べてみると、SwiftPMにおける.macro
ターゲットは、実は単なるmacOS向けの executableTarget
とほぼ同等なものであることがわかった。
-load-plugin-executable
で要求されるバイナリは、実は単に CompilerPlugin
に適合した型をエントリーポイントとしたexecutableを要求しているだけのようだ。
あとでswiftc側でどういうバイナリを想定しているかの内部実装も読んでみたい。
ここまでわかると、Swift Packageを使わずとも、Xcodeだけでマクロを捏造できるのでは?と思って試してみた。
Macroターゲット側 (MyMacro
)
ここで実装するマクロはなんでもよいので、公式ドキュメントのサンプルにある fourCharacterCode
の実装を使うことにした。
重要なのは、executableのエントリーポイントとして、CompilerPlugin
を指定することだ。
import SwiftCompilerPlugin @main struct MyMacro: CompilerPlugin { var providingMacros: [Macro.Type] = [FourCharacterCode.self] }
アプリターゲット側 (MyApp
)
- Build Phaseのdependenciesに
MyMacro
を追加する- 単にビルド前にexecutableを作っておきたいだけ
OTHER_SWIFT_FLAGS
に-load-plugin-executable $BUILT_PRODUCTS_DIR/../$CONFIGURATION/MyMacro#MyMacro
を指定- これは
BUILT_PRODUCTS_DIR
に入っているMyMacro
の成果物を指定している - 単に
$BUILT_PRODUCTS_DIR
だとアプリ側のdestinationになってしまうので../$CONFIGURATION
でmacOS側のdestinationにする - この指定は特殊で、
swiftc --help
によると、<executable_file_path>#<target_name>
らしい
- これは
- シンボルの解決のために
#externalMacro
を仮宣言する- これをすることで、依存解決時はMacroが存在する体で扱い、実際にビルドするときに2で指定したバイナリを実行する
@freestanding(expression) macro fourCharacterCode<T>(for value: T) -> UInt32? = #externalMacro(module: "MyMacro", type: "FourCharacterCode")
こうすることで、自作のMacroをXcodeターゲットだけで実装することができた 🎉
Swift Macroのビルドキャッシュ
では、このような複雑な構造を取って一体どんなメリットがあるかというと、ビルドキャッシュの利用という面でXcodeターゲットで管理していた方が扱いやすい面があるのではないかなと思う。
現状、Swift Macroはswift-syntaxへの依存が必須で、どの方法で作ってもswift-syntaxを依存として持つ必要がある。swift-syntaxはデカいので、単純なマクロを1つ作っただけで、ビルド時間が激増してしまうという問題があった。
僕の開発しているScipioなどで、swift-syntaxをprebuiltなXCFrameworkとして維持しておくことができるので、Xcodeで作成したMacroターゲットがprebuilt版のswift-syntaxに依存するようにしておけば、ビルド時間を大きく改善できそう。
また、Xcodeを使わないにしても、swift build
で作成したexecutableをキャッシュしておいて、-load-plugin-executable
で利用することで、毎回swift-syntaxやmacroをビルドする必要がなくなる(アーキテクチャの問題はあるが)
ScipioはSwift PackageからXCFrameworkを生成するためのビルドツールだが、現在、macroを使ったターゲットからフレームワークを作ることができないという問題がある。理論的には今回紹介した方法で対応できそう。(先にmacroターゲットをexecutableとしてビルドして、framework側からビルドフラグを立てる)
いかがでしたか
業務上で調査して面白かったのでちょっと記事を書いてみた。ここ数年全く技術記事を書いていないという危機感があり今年はこれぐらいの技術記事を定期的に書いていきたいと思っていたのでちょうど良い。 気合入れると続かないので30分以内でガッと書けるやつを目指していきたい。
純粋な技術記事としては8年ぶりの投稿かも・・・・・・