Swift Digest
SE-0432 | Swift Evolution

Borrowing and consuming pattern matching for noncopyable types

Proposal
SE-0432
Authors
Joe Groff
Review Manager
Ben Cohen
Status
Implemented (Swift 6.0)

01 何が問題だったのか

SE-0390~Copyablestruct / enum(non-copyable 型)を宣言できるようになりましたが、switch 文での扱いには大きな制約がありました。non-copyable な値に対する switch常に consuming 扱いで、パターンマッチが走った時点で値は消費され、マッチ後には同じ値を再び使えなくなってしまうのです。

これはとくに non-copyable な enum の表現力を大きく損ねます。enum の関連値にアクセスする手段は switch のパターンマッチしかなく、それが常に値を消費してしまうと、たとえば「状態を確認したいだけ」「関連値を覗き見たいだけ」といった用途でも元の値を手放さざるを得ません。

struct NC: ~Copyable {}

enum NoncopyableEnum: ~Copyable {
    case copyable(Int)
    case noncopyable(NC)
}

var foo: NoncopyableEnum = ...
switch foo {            // SE-0390 時点ではエラー。consume foo と明示しないと通らない
case .copyable(let n):
    print(n)
case .noncopyable(let x):
    // ...
}
// foo はこの switch で消費されてしまい、以降使えない

必要だったのは、non-copyable な値を借りたままパターンマッチできる仕組みと、パターンごとにどの所有権モード(借りるのか、消費するのか)で動くかを自然に決められるモデルです。

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

non-copyable な値に対する switch は、もはや常に consuming ではなく、対象式とパターンの組み合わせから所有権モードを推論するようになります。加えて、パターン中に borrowing なバインディングを明示的に書けるようにし、所有権の意図をパターン側から制御できるようにします。

switch の3つの所有権モード

switch の振る舞いは次の3つのいずれかに分類されます。右に行くほど厳しい(強い所有権を要求する)モードです。

  • copying: 対象はセマンティクス上コピーされ、必要に応じてさらに複製も作られる。copyable な値に対するベースラインです。
  • borrowing: 対象は switch ブロックの間だけ借りられる。
  • consuming: 対象は switch ブロックで消費される。

コンパイラはパターンを再帰的に見て、より厳しい所有権を要求するパターンが現れるたびにモードを強めていきます。

ベースラインモードの決まり方

non-copyable な対象のベースラインは、対象式の形で決まります。

  • 変数や stored property(および実験的な _read / _modify / unsafeAddress アクセサを持つプロパティ・サブスクリプト)を参照していて、かつ consume 演算子で明示的に消費していない場合は borrowing
  • それ以外(関数の戻り値などの一時値)は consuming

copyable な型が baseline copying なのは従来と同じです。

パターンと所有権モードの例

copyable な enum の場合、let バインディングはすべて copying のままです。

enum CopyableEnum {
    case foo(Int)
    case bar(Int, String)
}

case let x:                 // copying
case .foo(let x):           // copying
case .bar(let x, let y):    // copying

non-copyable な enum の場合、ベースラインと各 let バインディングの型に応じてモードが決まります。

struct NC: ~Copyable {}

enum NoncopyableEnum: ~Copyable {
    case copyable(Int)
    case noncopyable(NC)
}

var foo: NoncopyableEnum    // stored variable

switch foo {
case let x:                      // borrowing
case .copyable(let x):           // borrowing(x: Int は copyable)
case .noncopyable(let x):        // borrowing
}

func bar() -> NoncopyableEnum { ... }   // 一時値を返す

switch bar() {
case let x:                      // consuming
case .copyable(let x):           // borrowing(x: Int は copyable で、対象の一部だけ取り出せば済む)
case .noncopyable(let x):        // consuming
}

SE-0390 時点では switch foo のような書き方はエラーでしたが、本提案では上記のとおり borrowing として正しく動作します。これまでのように switch consume foo と明示すれば、従来どおり consuming として振る舞わせることもできます。

マッチ中に対象を mutate / consume できない

switch のパターン評価順は未規定で、マッチが最終的に決まるまでは「どのケースが当たったか」が確定しません。non-copyable な型はコピーで逃げられないので、マッチが確定するまで対象を mutate したり消費したりすることはできません

多くのパターン(enum ケースや、将来的に対応予定の non-copyable タプルなど)は、「条件を判定する部分」と「バインディングを作るために値を消費する部分」を分けて扱えるため、let / var バインディングを含んでいても問題なく動きます。

extension Handle {
    var isReady: Bool { ... }
}

let x: MyNCEnum = ...
switch consume x {
// 複数ケースに `let y` があっても、マッチが確定してからバインディングのための
// 消費が行われるので OK
case .foo(let y) where y.isReady:
    y.close()
case .foo(let y):
    y.close()
}

一方、where 節ではバインディングを消費できませんwhere の評価後にマッチが失敗しうる以上、その時点で値を消費すると後続のケースに値が残らなくなるためです。case 本体では消費できます。

extension Handle {
    consuming func tryClose() -> Bool { ... }
}

switch consume x {
// error: `where` 節では `y` を消費できない
case .foo(let y) where y.tryClose():
    y.close()
case .foo(let y):
    y.close()  // 本体での消費は OK
}

同じ理由で、~= 演算子が対象を消費するような式パターンも non-copyable な対象には使えません。

extension Handle {
    static func ~=(identifier: Int, handle: consuming Handle) -> Bool { ... }
}

switch consume x {
// error: マッチ確定前に対象を消費する `~=` は使えない
case .foo(42):
    ...
case .foo(let y):
    ...
}

なお、non-copyable 型はまだ動的キャストに対応していませんが、将来 is T / as T を受け入れるときも同じ方針になります。is T は borrowing のまま判定でき、as T は消費を伴うため consuming な switch に限られ、かつ p as T の中の p は irrefutable で where 節を持てない、といった制約が付く見込みです。

case 条件(if / while / for / guard

if case <pattern> = <subject> などの case 条件は、「そのパターンだけを持ち、マッチ成功がバインディング付きの case、失敗が default に対応する switch」と考えれば同じ規則で動きます。対象への所有権の取り方も、そのパターンの要求に従います。

ソース互換性

SE-0390 は non-copyable 変数への switchconsume 演算子を要求していました。この書き方は引き続き動きます(ただし実際のマッチが borrowing で済むケースでも、バインディングの寿命は consume したタイミングで終わる点が従来の実装と若干異なります)。enum にはまだ deinit を書けず、non-copyable タプルも未対応、deinit を持つ struct は部分消費できず全体として消費するしかない、といった現状から、実挙動への影響はほぼ無いと見込まれています。

また、従来は理論上は consuming な ~=where 節での消費が可能でしたが、本提案ではこれらを明確に禁止します。既存実装でもこれらを実用に乗せるのは難しかったため、実害は無いと判断されています。

Future Directions

将来の方向性としては次のようなアイデアが挙げられています(いずれも speculative で、実現を約束するものではありません)。

  • inout パターン: パターンから排他参照を取って部分的にその場で mutate する、borrowing と consuming の中間の mutating な switch モードです。
  • let の自動借用: copyable な型で let の形式的コピーが最適化で借用に変わるのと同じ発想を non-copyable な let バインディングにも広げ、「消費が必要でなければ自動で borrow」という「do what I mean」的な挙動にする方向です。合わせて、明示的な consuming バインディングをパターンに書けるようにする案もあります。
  • enumdeinit: borrowing switch が可能になったことで、enum にも deinit を許す素地ができました。ただし consuming switch で分解すると deinit を迂回してしまうため、deinit を持つ enum は consuming な分解を禁止する、といった制約付きになる見込みです。
  • 明示的な borrow 演算子: switch borrow x のように、copyable な値や一時値でも明示的に借用として switch できるようにする案です。
  • パターン中の borrowing バインディング: 関数のローカル束縛や nonescapable 型のフィールドとしての borrowing / inout バインディングが導入された後に、パターン中でも明示的な borrowing バインディングを書けるようにし、copyable なバインディングの暗黙コピーを抑える、といった用途が考えられています。