Swift Digest
SE-0429 | Swift Evolution

Partial consumption of noncopyable values

Proposal
SE-0429
Authors
Michael Gottesman, Nate Chandler
Review Manager
Xiaodi Wu
Status
Implemented (Swift 6.0)

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 selfdeinit を走らせないことを明示したうえで許可する方向性。
  • 部分consume後に各フィールドを再初期化して、集約を再び正当な状態に戻せるようにする方向性(copyable型では当たり前にできる let tmp = first; first = second; second = tmp のような書き方を、non-copyable型でも可能にする)。
  • copyableなフィールドの明示的なconsume、およびcopyable集約そのものの部分consumeへの一般化。