Swift Digest
SE-0366 | Swift Evolution

consume operator to end the lifetime of a variable binding

Proposal
SE-0366
Authors
Michael Gottesman, Andrew Trick, Joe Groff
Review Manager
Holly Borla
Status
Implemented (Swift 5.9)

01 何が問題だったのか

Swiftは参照カウントとcopy-on-write(COW)によって値セマンティクスを実現しており、通常は開発者がパフォーマンスやメモリ管理を細かく気にする必要はありません。しかしパフォーマンスに敏感なコードでは、COWデータ構造の一意性を保ちたい、retain/releaseの呼び出しを減らしたい、といった要求が出てきます。

たとえば次のように Array を構築して別の関数に渡し、そのあと別の値で置き換えるコードを考えます。

func test() {
  var x: [Int] = getArray()
  x.append(5) // この時点で x のバッファは一意

  doStuffUniquely(with: x)

  x = [] // 以降は古い値を使わない
  doMoreStuff(with: &x)
}

func doStuffUniquely(with value: [Int]) {
  var newValue = value
  newValue.append(42)
  process(newValue)
}

この xdoStuffUniquely(with:) に渡したあと再代入されるだけで、古い値は二度と使いません。したがって理想的には、x の所有権(ownership)をそのまま doStuffUniquelyforward してコピーなしで渡し、さらに受け取った側も valuenewValue にforwardして、一意なバッファのまま破壊的変更を行いたいところです。

コンパイラは条件が揃えばこうした最適化を自動的に行えますが、複数の解析が噛み合う必要があり、実際に効いているかは書き方に依存します。開発者としては、「この値はここで使い終わり」「以降は使わない」という意図を明示して最適化を保証させ、あとから誰かがコードを変更してその前提を壊したときにはコンパイルエラーで気づけるようにしたい、という要求があります。

また、Swiftには「local変数のライフタイムをスコープで短くする」ことは以前から可能でしたが、スコープだけに頼るとネストが深くなり、ライフタイムが交互に重なる場合や、関数パラメータ・inout パラメータのライフタイムを途中で終わらせたい場合には対応できません。

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

新しく consume 演算子を導入し、ローカルの let / var や関数パラメータといった 静的なライフタイムを持つbinding の現在の値をその場で消費(consume)してライフタイムを終わらせられるようにします。consume x はランタイム上は単に x の現在の値に評価されますが、コンパイル時にはその時点で x の所有権がbindingから転送されたものとして扱われ、以降で x を参照するコードはエラーになります。

func test() {
  var x: [Int] = getArray()
  x.append(5)

  doStuffUniquely(with: consume x) // ここで x のライフタイム終了

  x = [] // var なので再代入すればまた使える
  doMoreStuff(with: &x)
}

受け側でも、consuming パラメータとして受け取った値をさらに別のローカル変数に consume でforwardすることで、コピーを挟まずに一意なバッファを引き継いで変更できます。

func doStuffUniquely(with value: consuming [Int]) {
  var newValue = consume value // value からバッファを奪って newValue に引き継ぐ
  newValue.append(42)
  process(newValue)
}

あとから保守でうっかり consume のあとに元のbindingを使うコードを足してしまっても、コンパイラが検出してくれます。

func test() {
  var x: [Int] = getArray()
  x.append(5)

  doStuffUniquely(with: consume x)

  // ERROR: x used after being consumed
  doStuffInvalidly(with: x)

  x = []
  doMoreStuff(with: &x)
}

consume できるbinding

consume のオペランドは 静的なライフタイムを持つbinding への参照でなければなりません。具体的には次のいずれかです。

  • 直接囲んでいる関数のローカル let
  • 直接囲んでいる関数のローカル var
  • 直接囲んでいる関数のパラメータ
  • mutating または __consuming メソッドの self

さらに、そのbindingが次のいずれかに当てはまる場合は consume できません。

  • @escaping クロージャやネストした関数にキャプチャされている
  • property wrapperが適用されている
  • get / set / didSet / willSet / _read / _modify などのアクセサが付いている
  • async let である

computed propertyや格納プロパティへの consume は将来の拡張として残されています(後述)。

bindingに対する操作であること

consume は値ではなくbindingに対して働きます。同じ値を別のbindingに束ねておけば、元のbindingを consume したあとでもそちらは使い続けられます。

func f() {
  let x: SomeClassType = ...
  useX(x)
  let other = x       // other は x のライフタイムを延長する新しいbinding
  _ = consume x       // x のライフタイムはここで終了
  useX(other)         // other はまだ使える
  useX(other)         // other もまだ使える
}

もちろん other のほうも独立に consume でき、それぞれのbindingに対して個別に診断が行われます。

フロー依存の解析と var の再初期化

解析はフロー依存です。条件分岐の一方だけで consume した場合、その分岐の後ろでだけ使えなくなります。

if condition {
  let y = consume x
  useX(x) // ERROR: Use after consume
} else {
  useX(x) // OK
}
useX(x) // ERROR: 両方のパスで x が生きているとは限らない

var の場合は、consume したあとに再代入すれば再び使えるようになります。

if condition {
  _ = consume x
  useX(x)      // ERROR
  x = newValue // 再初期化
  useX(x)      // OK
} else {
  useX(x)      // OK(このパスでは consume していない)
}
useX(x) // OK(if で再初期化済み、else では consume していない)

inout パラメータ

inout パラメータも consume できます。解析は var と同様ですが、関数から戻るとき(return でも throw でも)にはパラメータに値が入っていなければならないため、consume したあとは関数を抜ける前に必ず再代入する必要があります。

func f(_ buffer: inout Buffer) { // error: 'buffer' not reinitialized after consume!
  let b = consume buffer
  b.deinitialize()
  // ... buffer に再代入せずに return してしまうとエラー
}

defer を使って、早期リターンや throw を含むすべての経路で再初期化するのが定石です。

func f(_ buffer: inout Buffer) {
  let b = consume buffer
  defer { buffer = getNewInstance() }
  try b.deinitializeOrError()
  // ...
}

戻り値を捨てるときは _ = を明示する

consume はbindingのライフタイムを終わらせるだけでなく、現在の値を評価結果として返します。戻り値を使わずに consume x と書くと、戻り値のある関数呼び出しを捨てたときと同様に警告になります。値を単に破棄したいときは _ = consume x と明示的に書きます。

構文上の注意

consume は文脈依存のキーワードです。consume という名前の関数やプロパティと衝突しないよう、オペランドは識別子から始まる必要があり、識別子またはpostfix式に限定されます。

consume x       // OK
consume [1, 2, 3] // consume という property への subscript アクセスとして解釈
consume (x)     // consume という関数の呼び出しとして解釈
consume x.y.z   // 構文上はOK
consume x[0]    // 構文上はOK
consume x + y   // (consume x) + y として解釈

Future directions

以下は今回のスコープ外で、今後の検討対象として言及されています(実現を約束するものではありません)。

  • エスケープしたローカル変数やクラスの格納プロパティなど、動的なライフタイムを持つbindingに対する consume(ランタイムチェックが必要になるため、別構文にする可能性があります)。
  • @frozen なstructやタプルに対して、フィールドごとに独立に consume する拡張。
  • computed propertyやproperty wrapperに対する consume(”consuming getter” のような新しいアクセサの導入が必要になる可能性があります)。
  • borrow 演算子や @noImplicitCopy 属性など、implicit copyを抑制する一連の機能との連携(consume はその一部に位置づけられています)。
  • パラメータ側で受け取り方を明示する borrow / consume ownership modifier(func foo(x: consuming T) など)との組み合わせ。