Controlling function definition visibility in clients
01 何が問題だったのか
Swift では、呼び出し側が関数の 本体(定義) を見えているかどうかで、コンパイラがどこまで最適化できるかが大きく変わります。定義が見えていれば、呼び出し箇所に合わせたジェネリクスの特殊化(specialization)、インライン化、引数が実際に破壊・共有されないと分かれば値をヒープではなくスタックに置く、といった最適化が可能になります。一方で定義を呼び出し側に見せてしまうと、関数本体を差し替えても呼び出し側も再ビルドしない限り変更が反映されなくなる、バイナリ互換性を維持したまま行える変更が狭まる、といったコストが生じます。
@inlinable だけでは足りない
SE-0193 の @inlinable は「呼び出し側に定義を見せる」指示ですが、同時にモジュール側のバイナリにも呼び出し可能なシンボルを生成します。用途によっては「定義は見せたいがシンボルは要らない」「シンボルは出したいが定義は絶対に見せたくない」といった、より細かい制御が必要です。
これを埋めるために、Swift には以下のようなアンダースコア付きの非公式属性が使われてきました。
@_alwaysEmitIntoClient: 定義は呼び出し側に見せるが、モジュール側には呼び出し可能なシンボルを出さない。標準ライブラリで、ABI を広げずに機能を追加し、古い OS にバックデプロイするために多用されている。@_neverEmitIntoClient: 呼び出し可能なシンボルは必ず出し、定義は呼び出し側には一切見せない。@mainなどで使われてきた。
コンパイルモードで挙動がぶれる
関数定義がクライアントに見えるかどうかは、最適化モードに強く依存します。
- 通常の
-O/ WMO では、public関数は@inlinableを付けない限り定義は外部に見せません。 - 一方、Embedded Swift は積極的なクロスモジュール最適化(CMO)を前提とし、
internalやprivateも含めて ほぼすべての関数 の定義をクライアントから見えるようにします。
この差は、たとえば次のような影響を生みます。
- ABI に載せたい関数を ABI に載せられない: Embedded Swift では「外部から参照される必要があるのでシンボルを必ず出してほしい」関数や「リンク時に差し替え可能にしたい」関数があっても、コンパイラがシンボルを省略してしまう可能性がある。そもそもジェネリック関数のように、単一のシンボルを持てない(呼び出しごとに特殊化が必要な)関数もある。
- 実装詳細が漏れる:
privateな関数secret()をpublicな関数f()から呼んでいる場合、Embedded Swift や積極的 CMO 下ではf()の定義がクライアントに見えるため、secret()の本体までクライアントに露出してしまう。privateという可視性は保たれていても、コンパイラから見た定義は漏れている。 - transitive な依存関係が漏れる: モジュール
Bが@_implementationOnly(あるいは内部 import)でモジュールAを取り込み、B.g()の実装の中でA.f()を呼んでいる場合、Library Evolution 下ではCがBを import してもAを知る必要はありません。ところが Embedded Swift ではB.g()の定義がCに見えるため、CもAを参照しなければならなくなり、実装詳細であるはずの依存が外に漏れます。
こうした問題を解くには、「シンボルをバイナリに出すか」「定義をクライアントに見せるか」という二つの軸を、コンパイルモードや最適化設定に左右されない形で 明示的に 制御する手段が必要でした。
02 どのように解決されるのか
関数の「呼び出し可能なシンボルをバイナリに出すか」「定義をクライアントに見せるか」を明示的に制御する新しい属性 @export を導入します。@export は引数にいずれかひとつを取ります。
@export(interface): バイナリに呼び出し可能なシンボルを 必ず 生成し、定義はクライアントに 一切見せない。@export(implementation): 定義をクライアントに利用可能にする(特殊化・インライン化・解析に使える)。モジュール側に呼び出し可能なシンボルは出さないため、利用するクライアントは自分で定義のコピーをエミットする。
どちらの指定もコンパイルモード(Library Evolution か、WMO か、Embedded Swift か)に左右されず、作者の意図を固定します。@export は引数なしでは使えず、interface と implementation を同時に指定することもできません。
既存属性との対応
新属性は、既存のアンダースコア付き属性と @inlinable の役割を整理し直します。
@_alwaysEmitIntoClient→@export(implementation)に統合されます。@_neverEmitIntoClient→@export(interface)に統合されます。@inlinable/@usableFromInlineは@exportと 部分的に 重なります。@inlinableは Library Evolution 下ではシンボル生成を保証しますが、それ以外では保証しません。@usableFromInlineもシンボル生成を保証せず、しかも定義の露出を禁止することもできません。@export(interface)/@export(implementation)はこれらを保証します。
@export は @inlinable / @usableFromInline / @_alwaysEmitIntoClient / @_neverEmitIntoClient のいずれとも 併用できません。
アクセス制御とは直交
@export はアクセス制御(public / internal / private など)とは独立した軸です。ソースコードから参照できるかはアクセス制御が決めますが、コンパイラから定義が見えるかどうかは最適化モード次第で変わるため、そこを別に固定するのが @export の役割です。
// モジュール A
private func secret() { /* ... */ }
public func f() {
secret()
}
// モジュール B
import A
func g() {
f()
}
モジュール B から secret をソースで呼ぶことはできませんが、Embedded Swift や積極的 CMO の下では f() の定義を介して secret まで B 側でインライン化される可能性があります。これを防ぎたい場合は、たとえば次のように書けます。
fに@export(interface)を付け、fの定義自体をクライアントに見せない。- あるいは
secretに@export(interface)と@inline(never)を付け、必ず独立したシンボルを持たせてfにも一切インライン化させない(privateのままソースからは触れない)。
@inline(always) / @inline(never) との関係
@inline(always) / @inline(never)(SE-0496)は、コンパイラに「インライン化するかどうか」を指示する属性で、「シンボルを出すか」「定義をクライアントに見せるか」とは直交した概念です。@export と組み合わせることができます。主な組み合わせの意図はたとえば次のとおりです。
@export(implementation)+@inline(always): ABI には載せない、性能のため必ずインライン化したい関数。@export(implementation)+@inline(never): ABI には載せないが、インライン化する必要はない関数。@export(interface)+@inline(never): 実装を完全にカプセル化し、リンク時に差し替え可能にしたい関数。他のコードに影響せず実装だけを入れ替えられる。@export(interface)+@inline(always): モジュール内ではインライン化され、モジュール外からはシンボル経由で呼ばれる。
Embedded Swift での制約
Embedded Swift はすべてのジェネリック関数を呼び出しごとに 具体型で特殊化(monomorphize) してコードを生成します。ジェネリック関数は単一のシンボルで済ませられないため、@export(interface) と 組み合わせることはできません。
// モジュール A
public func fGeneric<T>(_ value: T) { /* ... */ }
// モジュール B
func h() {
fGeneric(MyType()) // fGeneric<MyType> に特殊化する必要がある
}
stored property や型への適用
@export は関数だけでなく、stored property や型にも付けられます。
- stored property / 型 +
@export(interface): ストレージ用シンボルや型のメタデータを、使われた時点で遅延生成するのではなく eager に 生成する。Embedded Swift では通常こうしたシンボルは参照されるまで出力されないため、この指定で明示的に出力を強制できる。 - 型 +
@export(implementation): Library Evolution 下の@frozenに近い意味合い(レイアウト情報をクライアントに見せる)になるが、Library Evolution の外では@frozenに相当する概念がないため、現時点では限定的。
Future Directions(参考)
提案では、今回のスコープ外として以下の方向性が speculative に挙げられています。いずれも将来的な可能性であって、実現を約束するものではありません。
- 可視性の拡張: GCC の
visibility属性や Visual C++ のdllimport/dllexportに相当する指定を、@export(interface, visibility: hidden)のような形で追加する方向。 - internal / private import と組み合わせた実装隠蔽の自動化: SE-0409 の future direction として示されている「非 resilient なモジュールでも transitive な依存を自動で隠す」挙動が入れば、今回
@export(interface)で手動に行っている transitive 依存の遮断を、明示せずとも得られるようになる。 @export(interface, implementation)の解禁: 当初は両方同時指定も認めていましたが、Library Evolution 下では@inlinableと同じで、それ以外の状況では@inlinableを置き換えるほどの用途がなかったため、今回は禁止されています。将来具体的な用途が見つかれば、この制限を外すことはできます。