5.1さらうどん

@giginetの技術ブログ。ゲーム開発、iOS開発、その他いろいろ

swift-play-experimentalで遊ぼう

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が楽しみですね!