Swift Digest
Blog | Swift.org Blog

Swift の library evolution

Library Evolution in Swift

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

この記事の要点

背景: バイナリフレームワークを安全に進化させる

Swift 5.0 では Apple プラットフォームで ABI 安定性 が達成され、アプリは OS に同梱された Swift ランタイム・標準ライブラリを利用できるようになりました。続く Swift 5.1 では、配布・共有できるバイナリフレームワークを実現するために、バイナリ安定性に関わる 2 つの機能が追加されました。

モジュール安定性は通常 library evolution サポートを必要とし、配布用のバイナリフレームワークをビルドするときは両方をまとめて有効にするのが一般的です。

library evolution サポートを有効にすべき場合

library evolution サポートはデフォルトで無効です。常に一緒にビルド・配布されるフレームワーク(Swift Package Manager のパッケージや、アプリ内部のバイナリフレームワークなど)では、有効にすべきではありません。

library evolution サポートを使うべきなのは、フレームワークがクライアントとは別々にビルド・更新される場合だけです。 このとき、古い版のフレームワークに対してビルドされたクライアントは、再コンパイルなしで新しい版のフレームワークとともに実行できます。

このようなフレームワークを出荷する予定があるなら、遅くとも最初のリリースから、できれば開発・テストの早い段階から library evolution を有効にしておきます。理由は次のとおりです。

有効化の方法

Xcode

Apple プラットフォーム向けに Xcode で開発する場合は、フレームワークのターゲットに BUILD_LIBRARY_FOR_DISTRIBUTION ビルド設定を指定します。この設定で library evolution とモジュール安定性の両方が有効になります。Debug・Release の両ビルドで設定してください。

コンパイラを直接呼び出す場合

コマンドラインや別のビルドシステムから swiftc を直接呼び出す場合は、-enable-library-evolution-emit-module-interface フラグを渡します。

$ swiftc Tack.swift Barn.swift Hay.swift \
    -module-name Horse \
    -emit-module -emit-library -emit-module-interface \
    -enable-library-evolution

これにより、module interface ファイル Horse.swiftinterface と、共有ライブラリ libHorse.dylib(macOS)または libHorse.so(Linux)が生成されます。

resilient な変更とは

library evolution は、バイナリ互換性を壊さずに行えるフレームワークの変更を可能にします。新しい版が古い版とソース互換かつバイナリ互換であるとき、その変更を resilient であると言います。

どの変更が resilient かを理解するには、ABI-public な宣言 という概念が必要です。これは別の Swift モジュールから参照できる宣言のことです。

ABI-public でない宣言を特に区別したいときは ABI-private と呼びます。@usableFromInline の付かない private / fileprivate / internal の宣言がこれにあたります。

また、@frozen 属性も library evolution に関わります。これは ABI-public な struct や enum のバイナリインターフェースを、より多くの実装詳細を公開する形に変えます。将来 resilient に行える変更の幅を制限する代わりに、柔軟性と引き換えに追加の性能を得られます。

resilient な変更の例

より網羅的な一覧は、Swift コンパイラのソースリポジトリにある LibraryEvolution.rst に記載されています。

resilient でない変更の例

次のような変更はバイナリ互換性を壊すため、避ける必要があります。

library evolution を部分的にオプトアウトする

library evolution は、コンパイル済みのクライアントとフレームワークの間に抽象化の層を入れることで、性能と引き換えに柔軟性を得ます。多くの場合は将来の柔軟性を残すのが正しいデフォルトですが、ごく単純で将来も変化しようがないデータ型もあります。たとえば 2 次元グラフィックスの Double 型の x / y を持つ点を表す struct などです。

そうした宣言が将来も変化しないことをコンパイラに伝えると、クライアントがその宣言を扱うコードをより効率的に生成できます。これを担うのが @inlinable@frozen です。これらの属性は慎重に使うべきものですが、特定の文脈では非常に有用です。

@inlinable 関数

@inlinable 属性は、「現在の関数定義が将来のライブラリバージョンでも正しく動作し続ける」というライブラリ作者からの約束です。この約束により、コンパイラはクライアントコードのビルド時に関数本体を参照できます。名前に反して、インライン化が必ず起きるわけではありません。コンパイラは特殊化したコピーをクライアント内に出力することも、フレームワーク内の元の関数を呼び続けることも選べます。

コンパイラは @inlinable 関数の本体に重要な制約を課します。本体からは ABI-public な宣言(public または @usableFromInline)しか参照できません。@usableFromInline 属性は、inlinable なコードから使うためのヘルパー関数を、public なインターフェースとして直接呼べないようにしつつ定義するために存在します。もし @inlinable 関数が private な関数や型を参照できてしまうと、それらがバイナリインターフェースの一部になり、将来の進化を妨げてしまうためです。

バイナリ互換性の観点では、@usableFromInline 宣言は実質的に public 宣言と同じです。一度公開したら、削除も非互換なインターフェース変更もできません。@inlinable の詳細は SE-0193: Cross-module inlining and specialization を参照してください。

@frozen な struct

@frozen 属性を struct に付けると、その stored property のレイアウトがクライアントに公開されます。@frozen な struct の stored property の追加・削除・並び替えはバイナリ非互換な変更です。柔軟性を失う代わりに、コンパイラはモジュール境界をまたいで一定の最適化を行えます。

@frozen な struct には 2 つの言語上の制約があります。

@frozen が制約するのは stored property の集合だけで、メソッドや computed property の追加・並び替えは自由です。ただし computed property と stored property を相互に変えてはいけません。property wrapper や lazy プロパティは内部的に stored property として実装される点にも注意が必要です。

さらに、struct への @frozen の追加・削除自体がバイナリ非互換な変更です。struct は「最初から frozen」であるか、「ずっと resilient のまま」のどちらかでなければなりません。詳細は SE-0260: Library evolution for stable ABIs を参照してください。

@frozenenum

enum@frozen にできます。これはケースを追加・削除・並び替えしないという約束です(ABI-public な enum からのケース削除は、@frozen でなくてもバイナリ互換性を壊す点に注意してください。すべてのケースが ABI-public だからです)。@frozenenum の追加・削除もバイナリ非互換です。

frozen な enum に対する switch は、すべてのケースを網羅していれば exhaustive とみなされます。一方、non-frozen な enum に対する switch は常に default@unknown のケースを用意しなければなりません。これが、library evolution サポートを有効にすることで導入される唯一のソース非互換性です。switch の exhaustive 性の挙動は SE-0192: Non-exhaustive enums で詳述されています。

プラットフォームのサポート

Swift コンパイラが異なるコンパイラバージョン間のバイナリ互換性を保証するのは、現時点では Apple プラットフォームのみです。Linux などでは、異なるバージョンのコンパイラでビルドされたアプリとライブラリは、必ずしも正しくリンク・動作するとは限りません。

ただし、安定した module interface と library evolution 自体は Swift がサポートするすべてのプラットフォームで使えます。Apple 以外のプラットフォームでも、すべてのバイナリが同じバージョンのコンパイラでビルドされていれば、クライアントを再コンパイルせずに同じライブラリの複数バージョンを使えます。

Objective-C との相互運用

以下は Apple プラットフォームのみに当てはまります。フレームワークが open な class を定義し、クライアントがそれをサブクラス化する場合、基底クラスへの resilient な変更(stored property の追加や superclass の挿入など)に対応するため、サブクラスは実行時の初期化を必要とします。この初期化は Swift ランタイムが裏側で処理します。

ただし、実行時初期化を要する class が Objective-C ランタイム から見えるのは、十分新しいプラットフォームバージョンで動作している場合に限られます。実用上の帰結として、古いプラットフォームでは NSClassFromString() などに基づく機能がこうした class に対して期待どおり動作しません。フレームワークの class が Objective-C の動的機能と組み合わせて使われないと確信できる場合を除き、フレームワークとクライアントの両方で最小デプロイターゲットを macOS 10.15 / iOS 13.0 / tvOS 13.0 / watchOS 6.0 以降にしておくのが最も安全です。

-enable-testing との関係

-enable-testing フラグでビルドすると、他のモジュールが @testable 属性付きでフレームワークを import できるようになり、internal な宣言が見えるようになります。-enable-library-evolution-enable-testing と併用でき、テスト用にフレームワークをビルドするときは両方を渡すのが推奨されます。

ただし、その場合に resilient なのは public API への変更に対してのみです。通常どおりフレームワークを import するクライアントはバイナリ互換のままですが、@testable import を使うコード(フレームワーク自身のユニットテストなど)はアクセス制御を回避し、ビルド対象となった特定バージョンの非 resilient な実装詳細に依存します。このため、テストは常にフレームワークと一緒にビルドすべきです。

関連リンク