swift-play-experimental
数日前にAppleからswift-play-experimentalというパッケージが公開された。
これは、ライブラリ内の実装を簡易的に実行するための#Playground
マクロを提供するパッケージのようだ。
func fibonacci(_ n: Int) -> Int { if n <= 1 { return n } return fibonacci(n - 1) + fibonacci(n - 2) } import Playgrounds #Playground("Fibonacci") { for n in 0..<10 { print("fibonacci(\(n)) = \(fibonacci(n))") } }
このように、libraryターゲット内で#Playground
マクロを使うことで、executableを作成することなくマクロ内のスニペットを実行できる。
リポジトリの公開と同時に、Swift ForumにおいてPitchも公開されている。
Playgroundを実行する
従来は、libraryにはエントリポイントを持たせることができず、このようなスニペットを実行するためには、パッケージにexecutableTarget
を作成し、そこからtargetをリンクしてexecutableを作成する必要があった。
READMEによると、将来的にSwift Package Managerにswift play
コマンドが追加され、そこからPlaygroundを実行できるようになるようだ。
a prototype Swift Package Manager branch adds a swift play command...
と書かれている。upstreamのswift-package-managerを探しても見つからなかったが、forkにてサンプル実装が公開されていた。
その他、#Playground
マクロを実行する方法として、提供されているAPIをexecutableから呼び出すこともできる。同じパッケージに適当なexecutableを作り、__run
メソッドを呼んであげる。
import Playgrounds @main struct MyApp { static func main() async throws { let foundPlaygrounds = __Playground.__allPlaygrounds() print("Found playgrounds:") for playground in foundPlaygrounds { print("* \(playground.__displayName)") } if let playground = __Playground.__getPlayground(named: "Fibonacci") { try await playground.__run() } } }
動いた!
Found playgrounds: * Fibonacci fibonacci(0) = 0 fibonacci(1) = 1 fibonacci(2) = 1 fibonacci(3) = 2 ...
この方法では結局Playgroundの実行にexecutableターゲットが必要なため全然意味はない。(直接fibonacci
関数をimportしてコールするのと一緒)
何に使うの?
将来的にplay
コマンドが追加された場合、どのようなユースケースが考えられるだろうか。パッケージの開発者にとっては、簡易的な実装を動かしてみて、デバッグや試行錯誤のために利用できるだろう。
また、Pitchを見るところ、Hot reloadのような仕組みもサポートされるそうだ。
パッケージ利用者は、環境を作らなくてもcloneしてきたパッケージでplayコマンドを実行して使用例を試すこともできる。開発者が予めいくつかのPlaygroundを同梱しておき、機能カタログ的に使うのも考えられそう。
#Playground
マクロはどうやって動いているの?
それではこの仕組みはどのように実行されているのだろうか。ソースコードを読んだり、o3に聞きながら調べてみた。
#Playground
マクロがバイナリ内にスニペットを埋め込む
まず、#Playground
をExpand Macroで展開してみる。マクロ自体の実装を読んでも良い。
ポイントはここの部分;
#if hasFeature(SymbolLinkageMarkers) #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) @_section("__DATA_CONST,__swift5_tests") @_used #endif @available(*, deprecated, message: "This property is an implementation detail of the playgrounds library. Do not use it directly.") private let $s11MyFramework0020Fibonacciswift_odAJdfMX9_0_33_016D2464F8D9E1944D5E52372BC11EDCLl10PlaygroundfMf_23PlaygroundContentRecordfMu_: Playgrounds.__PlaygroundsContentRecord = ( 0x706c6179, /* 'play' */ 0, { outValue, type, hint, _ in Playgrounds.__store( "Fibonacci", $s11MyFramework0020Fibonacciswift_odAJdfMX9_0_33_016D2464F8D9E1944D5E52372BC11EDCLl10PlaygroundfMf_15PlaygroundEntryfMu_, at: outValue, asTypeAt: type, withHintAt: hint ) }, 0, 0 )
@_section
, @_used
はコンパイラを制御する特殊な非公開attributeで、シンボルを指定したセクションに強制的に配置することができる。@_used
はdead stripを抑制する属性のようだ。要はこの指定により、マクロ内に書かれたスニペットをライブラリ内のバイナリにシンボルとして埋め込んで、あとから復元している。
ここまで理解すると、バイナリの中にどのようにシンボルが埋まっているか気になる。
埋め込まれたシンボルを探してみよう
生成されたバイナリの中を、nm
で探索し、試行錯誤していると、__DATA_CONST
セグメントの__const
セクションにPlaygroundのシンボルが埋まっていることがわかった。(ここで@_section
で指定されている__swift5_tests
というセクション名になっていないのはなぜかわかっていない)
$ nm -m -a -s __DATA_CONST __const .build/arm64-apple-macosx/debug/libMyFramework.dylib 0000000000020980 (__DATA_CONST,__const) external _$s11MyFramework00147$s11MyFramework0020Fibonacciswift_odAJdfMX9_0_33_016D2464F8D9E1944D5E52372BC11EDCLl10PlaygroundfMf_36__$PlaygroundContentRecordContainerfMu__CyGEJlO11Playgrounds02__C22ContentRecordContainerAAWP
otool
で参照してみると、play(0x706c6179)
というマーカーが埋め込まれていることが確認できた。
$ otool -v -s __DATA_CONST __const .build/arm64-apple-macosx/debug/libMyFramework.dylib .build/arm64-apple-macosx/debug/libMyFramework.dylib: Contents of (__DATA_CONST,__const) section 0000000000020960 706c6179 00000000 00001c00 00300000
Playgroundは、ランタイムでバイナリに埋め込まれた関数ポインタを、このマーカーを元に探索し、ロードして実行する仕組みのようだ。
シンボル内のマーカーを探索して関数ポインタを取得する
次にランタイムの実装を見てみる。ランタイムはswift-testingのテストケースの収集と全く同じ仕組みを用いていて、_TestDiscovery
というSwift Testing向けの内部パッケージが再利用されている。
この辺の実装は難しくて読めていないが、先ほど埋め込んだセクションを探索し、PlaygroundsContentRecord
というタプルにcastしている。ここにはPlayground名やソースコード上の位置、そしてマクロに記述した関数ポインタが含まれている。
dlopenの初期化処理でPlaygroundを探索し実行する
最後に、ToolsAPIを読んでみよう。これは将来的にswift play
コマンドで実行されるエントリポイントで、埋め込まれたシンボルをロードして実行する部分だ。
// Load the specified dylib guard let image = dlopen(libPath, RTLD_LAZY | RTLD_FIRST) else { let errorMessage: String = dlerror().flatMap { String(validatingCString: $0) } ?? "An unknown error occurred." fatalError("Failed to open target library at path \(libPath): \(errorMessage)") } defer { dlclose(image) }
playコマンドは、dlopen
を用いて、dylibをロードし、初期化関数をフックして、埋め込まれたシンボルをメモリ上にロードするようだ。シンボルの探索には前述のTestDiscoveryの仕組みが用いられている。dlopen
でダイナミックライブラリを展開すると、フックされた処理でメモリを確保し、その領域にPlaygroundの関数ポインタをロードする。
このような黒魔術を経て、無事にフィボナッチ数列が出力できた。よかったよかった。
ローダ全くわからない
今回、改めてコードリーディングをしたことで、ローダに関しての不理解を自覚できた。難しすぎる。今回のコードリーディングにはChatGPT o3先生の協力が不可欠だった。ログを読むとシンギュラリティを感じる。
今年もWWDCがやってくる!
この時期にこのようなパッケージが公開されたのは、十中八九WWDC25へ向けての仕込みだろう。現在のXcode Playgroundは、Swift黎明期から存在し、長らく見捨てられていた。現在常用している人はほぼいないのではないだろうか。
WWDC25では、Brand new Developer Toolsとか言って、バーンとXcode 17に統合された新しいPlaygroundツールがお出しされるのだと想像できる。今年もWWDCが楽しみですね!