Swift Digest
SE-0474 | Swift Evolution

Yielding accessors

Proposal
SE-0474
Authors
Ben Cohen, Nate Chandler, Joe Groff
Review Manager
Steve Canon
Status
Accepted

01 何が問題だったのか

Swiftのcomputed propertyやsubscriptは getset で定義しますが、この組み合わせでは本質的に「値を一度コピーして取り出し、手元で書き換え、書き戻す」という三段階の動作になります。見た目は in-place な変更のようでも、実体は一時コピーを経由した更新です。

struct GetSet {
  var x: String = "👋🏽 Hello"

  var property: String {
    get { print("Getting", x); return x }
    set { print("Setting", newValue); x = newValue }
  }
}

var getSet = GetSet()
getSet.property.append(", 🌍!")
// Getting 👋🏽 Hello
// Setting 👋🏽 Hello, 🌍!

この構造には二つの深刻な問題があります。

copy-on-write型でのパフォーマンス低下

StringArray のような copy-on-write を使う型は、コピー時にはバッファの参照だけを共有し、書き換え時にバッファがユニーク参照でなければ複製してから変更します。ところが上の例のように get でコピーを取り出すと、その時点でバッファの参照が増え、ユニークでなくなります。続く append はバッファを複製してから書き換え、最後に set が書き戻して元のバッファは破棄されます。

つまり、見かけ上の in-place な変更1回につき、バッファの完全コピーが発生します。これをループで繰り返すと計算量は線形ではなく二乗オーダーになり、気付きにくいパフォーマンスの落とし穴になります。

noncopyable型では実装自体が不可能

プロパティの型が noncopyable な場合、get の最初のステップである「コピーして返す」が成立しません。次のコードは self が borrow されているだけで x の所有権を渡せず、かつ UniqueStringCopyable でないためコピーもできず、get そのものが書けません。

struct UniqueString: ~Copyable { ... }

struct UniqueGetSet: ~Copyable {
  var x: UniqueString

  var property: UniqueString {
    get { // error: 'self' is borrowed and cannot be consumed
      x
    }
    set { x = newValue }
  }
}

この問題は単に書き換えだけでなく、読み取り専用の get でも同じように起こります。read-onlyにしても self から x の所有権を取り出して返すことができないため、noncopyable な値のプロパティを「参照するだけ」のアクセサすら通常の get では表現できません。

consuming get にすれば所有権ごと取り出せますが、今度は container 側が破壊されてしまい、複数のフィールドを順番に覗き見るような自然な使い方ができなくなります。

必要なのは、値をコピーせずに呼び出し側へ貸し出すためのアクセサです。所有権の移動を伴わずに、一時的にアクセスだけを譲り、アクセスが終わったら元に戻る仕組みが求められていました。なお、この機能自体は Swift 5.0 以降 _read / _modify という非公式キーワードで利用可能でしたが、正式なSwift Evolutionを経ていない実験的な状態のままでした。

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

computed property / subscript 用のアクセサとして、yielding borrowyielding mutate の2種類を追加します。これらのアクセサの本体は通常のメソッドではなく yield-once コルーチン で、新しい contextual keyword yield を使って実行を一時停止し、値を呼び出し側に貸し出します。呼び出し側が借用を終えると実行が再開し、yield の後の処理が走ります。

所有権を動かさず「借用だけ」を実現するため、コピーを介さずに in-place な参照・変更が可能になり、noncopyable な型のプロパティも素直に書けるようになります。

この機能は Swift 5.0 以降 _read / _modify として非公式に存在していたもので、正式APIとして yielding borrow / yielding mutate に昇格させる形です。

yielding borrow で値を借用して読み取る

yielding borrowself を borrow したまま、内部のフィールドをコピーせず呼び出し側に immutable に貸し出します。noncopyable な値のプロパティは、consuming get で container ごと消費したくない場面では、この yielding borrow が基本形になります。

struct UniqueString: ~Copyable { ... }

struct UniqueBorrow: ~Copyable {
  var x: UniqueString

  var property: UniqueString {
    yielding borrow {
      yield x
    }
  }
}

yielding borrow を持つプロパティ / subscript は、同時に get を持てません。プロトコル要件としても get と同じ位置に書けます。

protocol Containing {
  var property: UniqueString { yielding borrow }
}

yielding borrow 要件は、stored property、yielding borrow アクセサ、そして型がcopyableであればgetterからも満たせます。逆にプロトコル側で get と書いた場合は「所有権を渡せる値を返せる」ことが要件となり、noncopyable な型では stored property や yielding borrow では満たせないことに注意してください。

yielding mutate で値を借用して書き換える

yielding mutate は値を呼び出し側に mutable に貸し出します。yield には inout 引数渡しと同様に & を付けます。

struct GetMutate {
  var x: String = "👋🏽 Hello"

  var property: String {
    get { print("Getting", x); return x }
    yielding mutate {
      print("Yielding", x)
      yield &x
      print("Post yield", x)
    }
  }
}

var getMutate = GetMutate()
getMutate.property.append(", 🌍!")
// Yielding 👋🏽 Hello
// Post yield 👋🏽 Hello, 🌍!

ポイントは次のとおりです。

  • 書き換えの場面では get は呼ばれず、yielding mutate だけで処理が完結します
  • yieldreturn に似ていますが、呼び出し側の append が終わると制御が戻ってきます
  • newValue は登場せず、yield した値が呼び出し側で直接書き換えられます
  • コピーを介さず貸し出すため、copy-on-write のバッファ複製が起きません

yielding mutate だけでも代入は可能ですが、set を併記することもできます。その場合、値全体を置き換える代入では set のほうが優先され、事前に値を準備して yield するよりも効率的になります。

struct GetSetMutate {
  var x: String = "👋🏽 Hello"

  var property: String {
    get { x }
    yielding mutate { yield &x }
    set { print("Setting", newValue); x = newValue }
  }
}
var getSetMutate = GetSetMutate()
getSetMutate.property = "Hi 🌍, 'sup?"
// Setting Hi 🌍, 'sup?

yielding mutatestruct / enum のプロパティでは set と同じく既定で mutating です(明示的に nonmutating yielding mutate とも書けます)。mutatingyielding mutate は、yield 中のサスペンド期間を含めて self への排他アクセスを持ち続けるため、その間だけ構造体を一時的に整合性の崩れた状態にしておくことが安全に許されます。

yielding mutate による前処理・後処理

yielding mutate では yield を挟んで前後に任意の処理を書けるので、getset で同じ条件判定が重複するようなケースを一本化できます。たとえば Array.first を書き換え可能にする拡張は次のように書けます。

extension Array {
  var first: Element? {
    get { isEmpty ? nil : self[0] }
    yielding mutate {
      var tmp: Optional<Element>
      if isEmpty {
        tmp = nil
        yield &tmp
        if let newValue = tmp {
          self.append(newValue)
        }
      } else {
        tmp = self[0]
        yield &tmp
        if let newValue = tmp {
          self[0] = newValue
        } else {
          self.removeFirst()
        }
      }
    }
  }
}

Dictionary の defaulting subscript と同様に、nil 代入で要素を削除、値が入っていれば更新や追加、という挙動を一箇所にまとめられます。isEmpty の判定結果がコルーチンの状態として yield を跨いで保持されるため、get / set ペアで必要だった二重判定が不要です。

さらに、mutatingyielding mutateself への排他アクセスを握ったままになることを利用すると、バッファから要素を一旦 move して yield し、呼び出し側が戻ってきたら書き戻す、といった copy-on-write を誘発しない実装も可能になります。yield 中は一時的に配列の先頭スロットが未初期化状態になりますが、排他アクセスのおかげで他のコードがその状態を観測することはありません。

yield は必ず1回

yield-once コルーチンのルールとして、どの経路を通っても ちょうど1回 yield が実行されることを、コンパイラが静的に保証できる必要があります。let 変数の遅延初期化と同様、if / else の両方に yield を置くなどして経路ごとに過不足なく yield されるように書きます。到達不能な経路は fatalError() で示すことで回避できます(yield 無しで fatalError に落ちる経路は、関数を通り抜ける経路ではないとみなされます)。

呼び出し側で throw された場合

yield 中に呼び出し側が throw した場合でも、コルーチンの本体は通常通り yield の後から再開します。inout 引数と同じく、貸し出された値は関数終了時に有効な状態でなければならないというSwiftのルールが働くため、throw 経路でも値は必ず有効で、yielding mutate は yield 後のクリーンアップ処理を実行できます。

try? myArray.first?.throwingMutatingOp()

上のコードで throwingMutatingOp が throw しても、Array.first { yielding mutate } の後半(書き戻しや要素削除)はきちんと実行されます。

getsetyielding アクセサの使い分け

どちらを使うべきかは性質ごとに判断します。

  • yielding mutate が望ましい場面: copy-on-write 型で頻繁に in-place に書き換えられる、setup / teardown が必要でその間に状態を跨いで保持したい、あるいは noncopyable でそもそも set が書けない場合。ただし全体を一気に置き換えるケースも想定されるなら、併せて set も用意した方が効率的なことが多いです
  • yielding borrow が望ましい場面: サイズの大きな値をコピーせずに貸し出したい、アクセス前後に setup / teardown を挟みたい(withSomething { ... } 相当のスコープを、ネストを深くせずに提供できます)、noncopyable な値を所有権ごと渡さず借用だけで済ませたい、など
  • get が望ましい場面: 毎回新しい値を計算して返す場合や、copyable な値で小さな関数呼び出しとして表現した方が軽いことが明らかな場合。コルーチンは通常の関数呼び出しより多少オーバーヘッドがあるためです

プロトコル要件やABI安定な公開APIで noncopyable な型を返すなら、yielding borrow を要件にしておくほうが柔軟です。yielding borrow の要件は、実装側で yielding borrow でも get でも満たせる(後者の場合はコンパイラがgetterをラップする yielding borrow を合成する)ためです。

ソース互換性に関する注意

borrowmutate を contextual keyword として扱うため、既存のコードでプロパティのimplicit getter内に borrow { ... } / mutate { ... } という trailing closure 呼び出しを書いているごく稀なケースでは、解釈が変わる可能性があります。このProposalでは、そうした記述はアクセサ宣言として解釈する方向に合わせ、将来の非 yieldingborrow / mutate アクセサの導入にも備えます。

Future Directions

このProposalのスコープではありませんが、今後の見通しとして、いくつかの方向性が示されています(いずれもspeculativeで実現を約束するものではありません)。

  • yield-once function: 関数自体が yield できるようにして、アクセサ以外でもコルーチン形式で値を貸し出せるようにする案
  • yieldingborrow / mutate: コルーチンを介さずに、基底の値と同じ寿命を持つborrow / mutateを返すアクセサ。yielding borrow では貸し出された値の寿命がコルーチン再開時点で切れてしまうため、~Escapable な値を元のオブジェクトと同じ寿命で返したい場面で制約になります。非 yielding 版は cleanup を必要としないケースに限って、その制約を取り除けます
  • yielding borrowconsuming get の併存: 借用と所有権取得の両方を、同じプロパティ / subscript から呼び出し側の使い方に応じて提供する、いわゆるperfect forwardingへの拡張
  • アクセサ間の移行: 当初 yielding borrow で公開したAPIを後から get に昇格させる(または逆)といった、ABIを保ったままの進化を許すための仕組み