Swift Digest
SE-0376 | Swift Evolution

Function Back Deployment

Proposal
SE-0376
Authors
Allan Shortlidge
Review Manager
Frederick Kellison-Linn
Status
Implemented (Swift 5.8)

01 何が問題だったのか

Apple プラットフォームの SDK に含まれるような ABI 安定なライブラリは、dynamic library として OS に同梱されて配布されます。新しい API はその OS バージョン以降でしか利用できず、ライブラリの著者は @availableintroduced: のバージョンを指定します。

例えば、toasterOS 2.0 で ToastermakeBatchOfToast(_:) が追加されたとします。

extension Toaster {
  @available(toasterOS 2.0, *)
  public func makeBatchOfToast(_ slices: [BreadSlice]) -> [Toast] {
    var toast: [Toast] = []
    for slice in slices {
      toast.append(makeToast(slice))
    }
    return toast
  }
}

この関数の実装は完結していて、仮に toasterOS 1.0 上でもそのまま動作します。それでも toasterOS 1.0 をサポートするアプリは #available で分岐して、古い OS 用に同等の処理を自前で用意しなければなりません。

let slices: [BreadSlice] = ...
if #available(toasterOS 2.0, *) {
  let toast = toaster.makeBatchOfToast(slices)
  // ...
} else {
  // ... makeBatchOfToast(_:) 相当を自前で実装する
}

本来は、実装を古い OS にも配布できれば呼び出し側は無条件で使えるはずです。

非公式の @_alwaysEmitIntoClient を使えば関数本体をクライアント側に埋め込めるため、同様の効果を得ること自体は可能です。しかしこの方法には次の弱点があります。

  • 関数の本体が常にクライアントに埋め込まれるため、クライアントの deployment target が十分新しくてライブラリ側の実装が必ず利用可能な場合でもコードサイズが増えます。
  • 後からライブラリ側の実装がバグ修正や性能改善、セキュリティ修正のために差し替えられても、クライアントを新しい SDK で再コンパイルしない限り、古い埋め込みコピーが使われ続けます。

back deployment 専用の仕組みであれば、次の 2 点を両立すべきです。

  1. ランタイムでライブラリ側の実装が利用可能ならそちらを優先する。
  2. 決して使われない場合はクライアントのバイナリにフォールバックコピーを含めない。

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

@backDeployed(before: ...) 属性を導入します。before: で指定した OS バージョン よりも前 で実行する場合に備えて、関数本体のフォールバックコピーをクライアントに埋め込みます。

extension Toaster {
  @available(toasterOS 1.0, *)
  @backDeployed(before: toasterOS 2.0)
  public func makeBatchOfToast(_ breadSlices: [BreadSlice]) -> [Toast] { ... }
}

この宣言で makeBatchOfToast(_:) は toasterOS 1.0 以降で利用可能になり、クライアントは #available で分岐せずそのまま呼び出せます。

コンパイラが生成する thunk

コンパイラは @backDeployed 関数の呼び出しを、ライブラリ側のオリジナル実装とクライアント側のフォールバック実装を振り分ける thunk 経由の呼び出しに置き換えます。概念的には次のコードが生成されるイメージです。

extension Toaster {
  func makeBatchOfToast_thunk(_ breadSlices: [BreadSlice]) -> [Toast] {
    if #available(toasterOS 2.0, *) {
      return makeBatchOfToast(breadSlices) // ライブラリのオリジナルを呼ぶ
    } else {
      return makeBatchOfToast_fallback(breadSlices) // クライアント内のコピーを呼ぶ
    }
  }

  func makeBatchOfToast_fallback(_ breadSlices: [BreadSlice]) -> [Toast] {
    // ... ライブラリから取り込んだ関数本体
  }
}

クライアントの deployment target が toasterOS 2.0 以上であれば #available の分岐が定数で決まり、コンパイラは fallback 側を未使用関数として除去できます。そのため @_alwaysEmitIntoClient と違い、不要なときはフォールバックコピーによるコードサイズのオーバーヘッドが発生しません。ライブラリ側の実装が将来差し替えられても、新しい OS で動作するクライアントはそのまま恩恵を受けられます。

適用できる宣言

@backDeployed は関数、メソッド、subscript に付けられます。プロパティにも、stored property でなければ付けられます。複数プラットフォームをまとめて扱えるよう、before: はカンマ区切りで複数のバージョンを受け取ります。

extension Temperature {
  @available(toasterOS 1.0, ovenOS 1.0, *)
  @backDeployed(before: toasterOS 2.0, ovenOS 2.0)
  public var degreesFahrenheit: Double {
    return (degreesCelsius * 9 / 5) + 32
  }
}

extension Toaster {
  @available(toasterOS 1.0, *)
  @backDeployed(before: toasterOS 2.0)
  public subscript(fitsBagelsAt index: Int) -> Bool {
    get { return index < 2 }
  }
}

一方で、次のような制限があります。

  • 他モジュールから使われてこそ意味があるため、宣言は public または @usableFromInline でなければなりません。
  • 静的ディスパッチで呼べることが必要です。インスタンスメソッドやクラスメソッドに付ける場合は final である必要があり、動的ディスパッチを伴う @objc とは併用できません。
  • 宣言自体は @backDeployed で指定した before: のバージョンより古くから利用可能である必要があります(そうでなければフォールバックが呼ばれることがありません)。
  • @_alwaysEmitIntoClient@_transparent とは併用できません(いずれも「常にクライアントへ埋め込む」性質を持ち、@backDeployed の目的と矛盾するためです)。
  • @inlinable との併用は可能ですが、@inlinable の性質上、オプティマイザの判断でライブラリ側の実装が利用可能なときでもクライアント側コピーが使われることがあります。

関数本体に対する制約

本体に書ける内容は @inlinable と同じ扱いです。参照できるのはクライアントからアクセス可能な宣言(public@usableFromInline)に限られ、参照先の宣言は back deploy された関数と同じかそれ以上に早く利用可能である必要があります。そうでない場合は if #available で扱います。本体の型検査は、ライブラリの deployment target を無視して行われます(コピー先であるクライアントの deployment target はライブラリ側から見えないためです)。

今後の方向性

このプロポーザルの範囲では back deploy できるのは関数類に限られますが、将来的に enum や struct、プロトコル適合などの宣言を丸ごと back deploy できるように拡張する方向性も議論されています(実現を約束するものではありません)。