Library Evolution for Stable ABIs
01 何が問題だったのか
Swift 5 で標準ライブラリが ABI 安定を達成したのに続き、3rd-party ライブラリでも ABI 安定なバイナリを配布したいというニーズがあります。特に Apple の OS に同梱されるライブラリのように、クライアントアプリを再コンパイルせずに後から差し替えたいケースでは、ライブラリの実装をあとから改変できる余地を残しておくことが重要です。
C / C++ 流のアプローチの限界
ところが、C や C++ では構造体のレイアウトがそのまま ABI の一部になってしまいます。構造体の サイズ はコンパイル時に決まり、フィールドへの直接アクセスを許すならフィールドの並び順まで ABI に含まれるため、ABI 安定を宣言したあとでフィールドを追加したり並び替えたりできなくなります。
この制約を回避するために、C 系ライブラリでは構造体を「中身を隠したポインタだけを保持する型」にし、すべてのフィールドアクセスを関数呼び出し経由にする、といった手作業の工夫がよく行われます。しかしこの方式には次のような欠点があります。
- フィールドアクセスごとに間接参照と関数呼び出しのオーバーヘッドが発生する
- 本来はスタックに置けるフィールドもヒープに置かざるを得ず、参照カウントなどで寿命管理が必要になる
- 配列に並べたときに、要素のフィールドが連続したメモリに並ばなくなる
- これらの仕組みをライブラリ作者が毎回自前で用意しなければならない
enum でも同様の問題
Swift の enum についても同じような課題があります。SE-0192 で扱われたとおり、enum に新しい case を追加することはソース互換性を壊し得ますし、case の追加によって enum を格納するのに必要なサイズが増えれば、構造体にフィールドを足すのと同じように ABI にも影響します。
何を求めたいか
つまり、ABI 安定なライブラリを書きたいライブラリ作者にとっては、次の両方を満たす仕組みがほしい、ということになります。
- デフォルトでは、あとから構造体にフィールドを追加したり、enum に case を追加したりしてもクライアントのバイナリを壊さない
- それでも必要な場面では、「この型は今後レイアウトを変えない」と約束することで、直接アクセスなどの最適化を許容できる
この2つを、ライブラリ作者が手作業の迂回策を書かずに、コンパイラ任せで実現できるようにしたい、というのがこの提案の出発点です。
なお、アプリと一緒にビルドして配布されるライブラリ(SwiftPM パッケージなど)にとっては、こうした ABI 安定の仕組みは不要です。この提案が導入するモードは、あくまでバイナリ互換性を気にするライブラリのためのオプトインであり、既定の振る舞いには影響しません。
02 どのように解決されるのか
この提案は、コンパイラに新しい library evolution mode を導入し、さらに個々の型に対して「今後レイアウトを固定する」と宣言するための @frozen 属性を追加します。標準ライブラリで既に使われていた仕組みを、3rd-party ライブラリでも利用できるように正式化したものです。
library evolution mode
ライブラリを -enable-library-evolution フラグ付きでビルドすると、そのライブラリは ABI 安定 とみなされます。このモードで作られたライブラリには次のような性質が与えられます。
- struct のフィールドを追加・削除・並び替えしても、ABI 互換を壊さない
- enum に case を追加しても(associated value 付きでも)、ABI 互換を壊さない
- クライアントは、フィールドや case を直接操作するのではなく、インライン化されない関数呼び出しを介して間接的に扱う
- 型のサイズやフィールドのレイアウトは、コンパイル時には決まらず実行時に決まる
このように柔軟性を保った型は resilient な型と呼ばれます。スタックへの配置や、配列内での要素の連続配置といった最適化は、サイズが実行時に決まる前提のまま可能な限り維持されます。
library evolution mode を有効にすると、ソース側にも次の影響が出ます。
- enum の既定の扱いが non-frozen になります。ライブラリの 利用者 側から見た唯一の変化で、
switchを網羅的に書くには SE-0192 の@unknown default:が必要になります。 - インライン化可能な
initは、フィールドが確実に初期化されるように、インライン化されない別のinitに委譲しなければなりません。
このモードを切ったままビルドされたバイナリを「ABI 安定」として配布するのはサポート対象外です。また、このフラグはアプリと一緒に配布される通常のライブラリには影響しないため、そうしたライブラリは従来どおりの最適化を受けられます。
struct に対する @frozen
ライブラリ作者が「この struct には今後フィールドを追加する予定がない」と確信できる場合、その型を @frozen としてマークできます。@frozen な struct では、コンパイラは実行時呼び出しのいくつかをコンパイル時に解消し、たとえばフィールドへ間接参照なしに直接アクセスできるようになります。
@frozen
public struct Point {
public var x: Double
public var y: Double
public init(_ x: Double, _ y: Double) {
self.x = x
self.y = y
}
}
library evolution mode で struct を @frozen にできるのは、次の条件をすべて満たす場合です。
- struct が ABI-public(
publicまたは@usableFromInline)である(SE-0193 参照) - フィールドの型に登場するすべての class / enum / struct / protocol / typealias が ABI-public である
- フィールドに
willSet/didSetなどの observing accessor を持つものがない - フィールドの初期値を計算する式が、ABI-public でない型や関数を参照しない
通常の struct に比べると、@frozen な struct は次のような変更がすべて ABI を壊すことになります。
- フィールドの追加
- フィールドの並び替え
- ABI-public でないフィールドの削除
- ABI-public でないフィールドの型の変更
- stored property を computed property に変える/またはその逆
逆に、プロトコル適合やメソッドの追加、ABI-public でないフィールドのアクセス修飾子の変更、internal なフィールドを @usableFromInline や public に昇格させること、といった変更は @frozen な型でも引き続き許されます。
一方で、いったん型に付けた @frozen を 外す ことはできません。外してしまうと、既存クライアントが前提にしていたレイアウトが崩れてしまうためです。また、いまはまだ「既存の struct / enum に後から @frozen を付ける」こともバイナリ互換のままでは行えません(将来的にサポートしたい方向ではあります)。
@frozen が保証するのは、あくまで「stored property のセットが変わらない」ことだけで、C の struct 並みの性質まで約束するものではありません。具体的には次の点に注意が必要です。
- C++ の意味での trivial であるとは限りません。class 参照やクロージャを含む
@frozenstruct はコピー時・破棄時に参照カウント操作を伴います。 - ジェネリックな
@frozenstruct のレイアウトは、型引数に依存して実行時に決まり得ます。 - 具象化したインスタンスでも、フィールドに non-frozen な型を含んでいれば、サイズは実行時まで確定しません。
- C の同じ形の struct と同じレイアウトになる保証はありません。C 互換のレイアウトが必要なら C のヘッダで定義して import してください。
- フィールドが宣言順に並ぶ保証もありません。コンパイラはパディングを減らすために並びを入れ替えて構いません。
なお、「フィールドを1つだけ持つ struct の実行時メモリレイアウトは、その1フィールドを単独で置いたときと同じ」「C / Objective-C で nullable な型の nil 表現は、Swift 側でも同じ」という、Swift 1 / Swift 3 時点からの既存保証は @frozen の有無にかかわらずそのまま残ります。
enum に対する @frozen
enum に @frozen を付けた場合も、同様にコンパイラによる最適化が効くようになります。さらに enum では、ライブラリ利用者 に対して「今後 case が増えない」ことを約束する意味合いも加わります。その結果、利用者側は @unknown default: なしで網羅的に switch を書けるようになります。
一度 @frozen にした enum について、case に関するあらゆる変更(追加・削除・並び替え)は ABI を壊します。
契約を破ったときに起きること
@frozen な struct にフィールドを足したり、@frozen を剥がしたりすると、コンパイラはもはや正しいメモリ表現や呼び出し規約を保証できません。そのため、再コンパイルされていないクライアントアプリの挙動は未定義となり、unsafe 系の型を誤用したときと同じレベルでメモリ安全性や型安全性が失われます。クラッシュするだけならまだしも、意図しないコードが実行されたりスキップされたりする可能性もあります。コンパイラが自動的に止めてくれるわけではないため、ライブラリ作者側で規律を守る必要があります。
Future Directions
提案ではいくつかの将来の拡張方向にも触れられています。いずれも speculative な方向性で、実現を約束するものではありません。
- 互換性チェック:
swiftmoduleなどを使ってライブラリバージョン間で API を突き合わせるチェッカや、型のレイアウトをシンボル名にエンコードしてリンク時に差異を検出する仕組みなど、@frozenの契約違反を機械的に検出する案。 - observing accessor の許容: 現状
@frozenではwillSet/didSetを禁止していますが、アクセサを@inlinableに強制する形で将来的に解禁する余地があります。当面は、private な stored property と public な computed property を組み合わせることで同等の効果が得られます。
実際のスペル
この提案の採択時点では、-enable-library-evolution フラグはそのままの名前で、属性は struct 向けに @_fixed_layout、enum 向けに @_frozen という underscore 付きの名前で標準ライブラリに実装されていました。Swift 5.1 で、これらがこの提案に沿って統一的な @frozen として3rd-party ライブラリにも開放されました。