Swift Digest
SE-0476 | Swift Evolution

Controlling the ABI of a function, initializer, property, or subscript

Proposal
SE-0476
Authors
Becca Royal-Gordon
Review Manager
Holly Borla
Status
Implemented (Swift 6.2)

01 何が問題だったのか

ABI 安定なライブラリの保守では、公開済みの宣言を後から手直ししたくなることがあります。たとえば次のようなケースです。

  • 既存の宣言に新しい言語機能を取り入れたい(@Sendablesending に置き換える、など)
  • ソース互換は保ったまま、ABI 的には別物になる形に置き換えたい(rethrows メソッドを typed throws で書き直す、など)
  • 不適切な @escaping を外す、後から Sendable 制約を足す、といった修正を加えたい
  • 致命的にわかりづらい API 名をリネームしたい

こうした変更のうち、呼び出し側が生成する機械コードに影響があるもの(たとえば <T>Hashable 制約を足すと witness table の受け渡しが発生する)は本質的にソース互換しか保てません。一方で、次の二つの宣言のように、IR レベルではまったく同じ呼び出し規約・同じパラメータ構成で、違いが「マングルされた名前」と「コンパイル時チェック」だけ、というケースもあります。

// T must be Sendable
func fn<T: Sendable>(_: T) {}

// T parameter must be sending
func fn<T>(_: borrowing sending T) {}

このような「呼び出し側から見て実質互換で、名前のマングルだけが違う」ケースでは、マングル名さえ元のままに保てれば ABI 互換を維持したまま宣言を差し替えられるはずです。

既存手段の限界

従来はこの用途に使える道具が二つありましたが、どちらも手に余るか手抜きでした。

一つ目は @preconcurrency です。これは「Swift 5 互換のチェックを許し、かつ並行性関連の注釈をマングル名から取り除く」という二つの効果をまとめて持つ属性で、既存 API に sendability 検査を後付けする用途にはよく合います。ただし、効果の粒度が粗く、「特定のパラメータの Sendable 指定だけマングルから外す」といった細かい調整や、並行性以外の機能への応用はできません。

二つ目は、コンパイラ内部用の @_silgen_name です。これはマングル名を任意の文字列で上書きする抜け穴で、たとえば標準ライブラリで Collection.map(_:)rethrows から typed throws へ移行した際に、互換性ラッパを次のように実装するのに使われてきました。

extension Collection {
    @_silgen_name("$sSlsE3mapySayqd__Gqd__7ElementQzKXEKlF")
    @usableFromInline
    func __rethrows_map<T>(
        _ transform: (Element) throws -> T
    ) throws -> [T] {
        try map(transform)
    }
}

ただし @_silgen_name は、

  • コンパイル時の整合性チェックが一切ない
  • 関数にしか使えず、opaque return type や @backDeployed と併用できない
  • マングル規則と呼び出し規約に精通していないと正しく書けない

という重い欠点があり、実質的にコンパイラ/ランタイム開発者でなければ扱えない代物でした。そのため Swift Evolution には提案されず、一般利用も推奨されてきませんでした。

ライブラリ保守者に必要だったのは、@preconcurrency より柔軟で、@_silgen_name より安全で扱いやすい、その中間に位置する道具です。

02 どのように解決されるのか

新しい属性 @abi を導入します。@abi は、マングル名を決めるためだけの「別バージョンの宣言」を引数として受け取り、本来の宣言とは別に ABI 側の姿を指定できるようにするものです。引数に入る宣言は、本体({ ... })や初期値式を持たない以外は構文上完全な宣言で、関数・イニシャライザ・var / let・サブスクリプトに対応します。

たとえば先ほどの __rethrows_map(_:) は、@_silgen_name を使わずに次のように書き直せます。

extension Collection {
    @abi(
        func map<T>(
            _ transform: (Element) throws -> T
        ) rethrows -> [T]
    )
    @usableFromInline
    func __rethrows_map<T>(
        _ transform: (Element) throws -> T
    ) throws -> [T] {
        try map(transform)
    }
}

@abi の中に書かれた宣言がマングルに使われ、コンパイラのそれ以外の処理は外側の宣言(ここでは __rethrows_map)を使います。本体の中で呼ばれている map(_:) も、@abi の中のものではなく外側スコープに解決され、新しい typed throws 版の map(_:) にたどり着きます。

@_silgen_name と違って、@abi の中身は外側の宣言と呼び出し互換かどうかコンパイラがチェックしてくれます。たとえば誤って throws を落とすと、戻り値の扱いが ABI 的に変わるのでエラーになります。

extension Collection {
    @abi(
        func map<T>(
            _ transform: (Element) throws -> T
        ) rethrows -> [T]       // error: 'rethrows' doesn't match API
    )
    @usableFromInline
    func __rethrows_map<T>(
        _ transform: (Element) throws -> T
    ) -> [T] {                  // throws か rethrows が要るはず
        try map(transform)
    }
}

役割とカウンターパート

@abi を付けた宣言は、構文上二つの宣言が関わります。

  • ABI-only 宣言: @abi(...) の中に書かれた宣言。マングル名を決めるだけの役割を持つ
  • API-only 宣言: @abi が付いた外側の宣言。ソースコード上の見た目・本体・呼び出し規則を決める

どちらにも属さない普通の宣言は normal declaration と呼ばれ、自分自身が両方の役割を兼ねます。ABI-only 宣言と API-only 宣言は互いに カウンターパート として紐付き、コンパイラは役割に応じて自動で参照先を使い分けます。名前解決も通常は API 側しか見えないので、他の宣言を呼ぶときは API 名を書きます。

@abi の中に書く情報は最小限でよい

@abi の中は、ABI に影響する要素だけを書けば十分です。アクセス制御、@available@inlinable@backDeployedoverride@objc 系、@usableFromInline などは外側から受け継がれるので、@abi 側には書きません。デフォルト引数も ABI に関係しないので不要です。コンパイラは書かれてはいけないものが入っていれば警告・エラーで指摘し、外すよう促します。

@abi(
    // sendable 制約を付けたくなかった元のシグネチャだけを書く
    static
    func assumeIsolated<T>(
        _ operation: @MainActor () throws -> T,
        file: StaticString,
        line: UInt
    ) rethrows -> T
)
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
@usableFromInline
internal
static
func assumeIsolated<T: Sendable>(
    _ operation: @MainActor () throws -> T,
    file: StaticString = #fileID,
    line: UInt = #line
) rethrows -> T {
    // ...
}

運用としては「元の宣言をまるごと @abi(...) の中にコピーし、コンパイラに指摘された不要な部分を削っていく」のが想定ワークフローです。

書き換えられるもの・書き換えられないもの

@abi の中身は、外側の API 宣言と call-compatible(呼び出し互換) でなければなりません。大雑把には次の通りです。

  • 宣言の種類(funcfuncvarvar、など)、async の有無、戻り値・プロパティの型、パラメータ個数、inout / mutating などは一致している必要があります
  • throws / rethrows は ABI 的に同等、T...Array<T> も同等として扱われます
  • throws / rethrows の別、ジェネリック制約のうち marker プロトコル(SendableCopyableEscapableBitwiseCopyable)、sending、所有権修飾子、actor isolation などは一定の範囲で変えられます
  • 名前・引数ラベル、演算子の prefix / postfix@autoclosure@escaping、クロージャへの @Sendableisolated パラメータ、@preconcurrency 指定などはほぼ自由に変えられます

たとえば後から @Sendable を付けたい場合は、@preconcurrency と組み合わせて次のように書けます。

public struct AsyncStream<Element> {
    @abi(
        init(
            unfolding produce: @escaping /* not @Sendable */ () async -> Element?,
            onCancel: (@Sendable () -> Void)? = nil
        )
    )
    @preconcurrency
    public init(
        unfolding produce: @escaping @Sendable () async -> Element?,
        onCancel: (@Sendable () -> Void)? = nil
    ) {
        // ...
    }
}

外側には @preconcurrency が付くので型検査の互換効果は効き、一方で @abi にはその効果が及ばないのでマングル名は元のままに保たれます。

変数にも使えます。アクセサのマングル名は変数名から導出されるので、@abi(var oldName: Int) を付ければ var newName: Int のアクセサも oldName としてマングルされます。

@abi(var oldName: Int)
public var newName: Int

安全性についての注意

@abi でシグネチャを「後から厳しく」できるのは強力ですが、古いバイナリで既にコンパイルされたクライアントはその制約を満たしていないかもしれません。@Sendable を後付けする AsyncStream の例は「そのクロージャは元々並行に呼ばれていて、非 Sendable を渡すコードは最初からバグだった」ケースだから成り立つもので、逆に「元は同期的に呼ばれていたクロージャを並行呼び出しに変える」ような挙動変更には使ってはいけません。挙動自体を変えたい場合は、旧版は @usableFromInline internal として @abi でマングル名を固定しつつ別名で残し、新しい挙動の API は新しい名前で @backDeployed とともに追加する、という二段構えのパターンを採ります。@inlinable と同じく、過去のクライアントとの互換を保つ責任は開発者側にあることに注意が必要です。

宣言する変数の並び

var / let の場合、@abi 側と外側でバインドするパターン数と変数数を揃える必要があります。

@abi(var x, y: Int)
var a, b: Int              // OK

@abi(var x, y, z: Int)
var a, b: Int              // mismatch

@abi(var x, y: Int)
var a: Int, (b1, b2): (Int, Int)   // mismatch

@abi 側は初期値式からの型推論を受け取らないため、外側で推論している型も @abi 側では明示する必要があります。アクセサの内容は外側のものから継承されるので、@abi 側では列挙しません。

適用範囲と現時点の制限

この提案では func / init / var / let / subscript に対してのみ @abi を使えます。lazy とプロパティラッパーは暗黙に補助宣言を生成するため、@abi の中にも隣にも置けません。マクロも、@abi の中では attached / freestanding いずれも使えません(@abi の外側に付ける形や、freestanding マクロに対して @abi を付ける形は可能です)。

モジュールインターフェースに出力するとき、@abi は部分的に退行表現へ落とされます。@backDeployed も opaque return type も使わない func であれば等価な @_silgen_name に展開され、そうでない宣言は @available(*, unavailable) として出力され、古いコンパイラのクライアントには「新しいコンパイラが必要」というメッセージが出ます。

将来への見通し

仕様書では次のような方向性が Future Directions として挙げられています。あくまで方針であり、実現時期や形が約束されたものではありません。

  • 型・extension・enum case・アクセサへの @abi 適用
  • lazy やプロパティラッパーとの組み合わせのサポート
  • @abi(unchecked, ...) のような、互換チェックを明示的に外すモード
  • @abi(() -> Void) のような型位置で使える短縮形
  • 宣言を別のコンテキスト(extension 内外、別モジュールなど)にマッピングする用途への拡張

これらは今回のスコープ外ですが、@abi という同じ属性の延長線として段階的に拡張されうるという方向性が示されています。