Partial consumption of noncopyable values
01 何が問題だったのか
SE-0390 でnon-copyableな struct / enum が導入されましたが、non-copyableな集約(aggregate)の個々のフィールドを操作するのが困難でした。Swift ではnon-copyableな値はパス上で高々1回しか consume できないという制約があり、この「1回」はフィールドごとではなく値全体として数えられていたためです。
たとえば、二つのnon-copyableなフィールドを持つ Pair を考えます。
struct Unique: ~Copyable {}
struct Pair: ~Copyable {
let first: Unique
let second: Unique
}
この Pair について、中身を入れ替えた新しい Pair を返すだけの関数ですら、素直には書けませんでした。
extension Pair {
consuming func swap() -> Pair {
return Pair(
first: second, // error: cannot partially consume 'self'
second: first // error: cannot partially consume 'self'
)
}
}
first を読み出した時点で self を一度 consume したことになり、続けて second を読み出すと「同じ値を2回 consume している」と扱われてしまいます。copyableな値では当たり前に書けるこの種のパターンが、non-copyable型では回避策に頼らざるを得ませんでした。
同様に、deinit の中でさえ各フィールドを個別に手放せないため、自身が所有するリソースを deinit で開放するような自然な書き方も難しい状況でした。
02 どのように解決されるのか
「non-copyableな値はパス上で高々1回 consume できる」というルールを、各non-copyableフィールドがパス上で高々1回 consume される というルールに緩和します。これにより、deinit を持たないnon-copyable集約については、フィールドごとに個別に consume する 部分consume(partial consumption) が可能になります。
func takeUnique(_ elt: consuming Unique) {}
extension Pair {
consuming func swap() -> Pair {
return Pair(
first: second, // OK
second: first // OK
)
}
}
すべての分岐ですべてのフィールドを consume する必要はありません。あるパスでは first のみ、別のパスでは second のみを consume するといった使い方もできます。
extension Pair {
consuming func passUnique(_ front: Bool) {
if front {
takeUnique(first)
// second はここで破棄される
} else {
takeUnique(second)
// first はここで破棄される
}
}
}
フィールドの寿命の延長
あるパスでフィールドが consume されていない場合、そのフィールドの破棄は可能な限り遅らせて行われます。上の例で、first を consume しなかった分岐の末尾で first が自動的に破棄されるのはそのためです。
if/else の後ろでまとめて破棄することはできません。合流地点で破棄するには「どちらの分岐でも値が残っている」という状態をコピーで実現する必要があり、non-copyable型ではそれが許されないからです。
明示的な consume
consume キーワードを使うと、フィールドを明示的にその時点で consume でき、寿命の自動延長を上書きできます。たとえば「first の破棄は必ず second より前にしたい」という場合は次のように書けます。
extension Pair {
consuming func passUnique(_ front: Bool) {
if front {
takeUnique(first)
// second はここで破棄される
} else {
_ = consume first
takeUnique(second)
}
}
}
copyableなフィールドの扱い
non-copyable集約に含まれる copyable なフィールドは、従来どおり何度でも consume できます。部分consumeが絡んでも、この性質は変わりません。
func takeString(_ name: consuming String) {}
struct Named: ~Copyable {
let unique: Unique
let name: String
consuming func unpack() {
takeString(name)
takeString(name)
takeUnique(unique)
takeString(name)
takeString(name)
}
}
別モジュールの型は @frozen 必須
非copyableな型が他のモジュールで public / package として定義されている場合、部分consumeは @frozen な型に限って許可されます。モジュール側でフィールドが追加・削除されたり、stored property が computed property に変わったりすると、ある版で成り立っていた部分consumeが別の版では成り立たなくなる可能性があるためです。@frozen はレイアウトを将来も変えないことの約束なので、そのような心配がありません。
このルールは library evolution 下のライブラリには必須のものですが、「ビルドモードによって言語規則が変わる」という事態を避けるため、library evolution を使わない場合にも一律に適用されます。
deinit を持つ型は deinit 内でのみ部分consume可能
deinit を持つnon-copyable型を外から部分consumeするのは許されません。部分的に consume された値に対して deinit を走らせるとフィールドの一部がすでに存在しないため、整合が取れなくなるためです。deinit を抑制する仕組みは現時点では提案されていません(Future Directionsの discard 参照)。
ただし、deinit の内部に限っては自分自身を部分consumeできます。これにより、non-copyableな型が所有するリソースを deinit で個別に手放す、という自然なパターンが書けます。
struct Pair2: ~Copyable {
let first: Unique
let second: Unique
deinit {
takeUnique(first) // self を部分consume
takeUnique(second) // self を部分consume
}
}
Future Directions
今回のスコープ外として、次のような発展が示されています(speculativeなもので、実現を約束するものではありません)。
deinitを持つ型の部分consumeを、discard selfでdeinitを走らせないことを明示したうえで許可する方向性。- 部分consume後に各フィールドを再初期化して、集約を再び正当な状態に戻せるようにする方向性(copyable型では当たり前にできる
let tmp = first; first = second; second = tmpのような書き方を、non-copyable型でも可能にする)。 - copyableなフィールドの明示的なconsume、およびcopyable集約そのものの部分consumeへの一般化。