Swift Digest
SE-0390 | Swift Evolution

Noncopyable structs and enums

Proposal
SE-0390
Authors
Joe Groff, Michael Gottesman, Andrew Trick, Kavon Farvardin
Review Manager
Stephen Canon
Status
Implemented (Swift 5.9)

01 何が問題だったのか

Swiftにおけるすべての値型はこれまでコピー可能で、任意の値について同一で交換可能な複製をいくつでも作れました。しかしコピー可能な structenum は、一意に所有されるリソース をモデル化するには向いていません。たとえばファイルディスクリプタや確保済みメモリ、ロックのような、「同時に複数の持ち主が存在してはいけない」資源がこれに当たります。

クラスはオブジェクトに一意のアイデンティティがあるため一意な資源を表現できますが、クラスへの参照自体は自由にコピーできるため、結果として常に共有所有権 を要求します。その共有所有権には、ヒープ確保や参照カウント、共有アクセスに伴うAPIの複雑化や追加のオーバーヘッドといったコストが付いて回ります。また、資源解放のタイミングも参照カウントに委ねられるため、「使い終わったらただちに閉じる」といった要件を型の側から保証するのも難しくなります。

さらに、Swiftには deinit を持てるのは class だけで、値型には後始末のフックがありません。ファイルハンドルや malloc で確保したバッファのように、値の寿命の終わりに確実にリソースを解放したい場面でも、値型の形では deinit を書けず、クラスに包む以外の選択肢がありませんでした。

SE-0377borrowing / consuming といったパラメータ修飾子が導入され、値の所有権の受け渡しを明示する素地はすでに用意されています。足りなかったのは、「そもそもコピーできない値型」そのものを定義する手段でした。

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

structenumnoncopyable(コピー不可)として宣言できるようにします。noncopyable な値は常に一意に所有され、Swiftの暗黙のコピー機構ではコピーできません。また、クラスと同様に deinit を書けるため、値の寿命の終わりで自動的に後始末を走らせられます。

Copyable 制約と ~Copyable

標準ライブラリに新たな汎用制約 Copyable が導入されます。既存のほぼすべての型は暗黙に Copyable を満たし、ジェネリック型パラメータ・existential・プロトコル・associated type要件も暗黙に Copyable を要求します。Copyable を明示的に書くこともできますが、既存コードに対しては効果を変えません。

struct Foo<T: Copyable>: Copyable {}

structenum を noncopyable として宣言するには、新しい要件の抑制構文 である ~Copyable を使います。

struct FileDescriptor: ~Copyable {
  private var fd: Int32

  init(fd: Int32) { self.fd = fd }

  deinit {
    close(fd)
  }
}

struct が noncopyable な stored property を持つ場合、あるいは enum が noncopyable な関連値を持つケースを含む場合、その容れ物自体も ~Copyable でなければなりません。

struct SocketPair: ~Copyable {
  var input, output: FileDescriptor
}

enum FileOrMemory: ~Copyable {
  case file(FileDescriptor)
  case memory([UInt8])
}

// ERROR: copyable な値型は noncopyable なメンバーを持てません
struct FileWithPath {
  var file: FileDescriptor
  var path: String
}

クラスは参照のretain/releaseで扱われるため、noncopyable な stored property を持っていてもクラス自身はコピー可能のままです。逆に、クラスを ~Copyable として宣言することはできません。

ジェネリクスでの制約

今回の提案では、ジェネリック型パラメータや associated type は依然として暗黙に Copyable を要求します。そのため、noncopyable な型自身はジェネリック型引数として使えません。具体的には noncopyable な型は、

  • Sendable を除いてプロトコルに適合できない
  • associated type の witness として使えない
  • ジェネリック型・ジェネリック関数の型引数として使えない
  • Any や任意の existential にキャストできない
  • リフレクションで扱えない
  • タプルに入れられない

という制約があります。結果として、Optional<FileDescriptor>[FileDescriptor]print(fd)(内部で Any への変換が必要)などは現時点では書けません。例外として Sendable にだけは適合でき、noncopyable な型を並行処理で使えます(ただし any Sendable にキャストすることは依然としてできません)。

これらの制約を緩めてジェネリクスや Optional へ段階的に対応していくことは、Future Directionsで言及されていますが、今回の提案のスコープ外です(実現を約束するものではありません)。

noncopyable な値の扱い

noncopyable な値に対する操作は、borrow(共有して借りる)・consume(所有権を受け取って無効化する)・mutate(排他的に借りて書き換える)のいずれかに分類されます。copyable な値では必要に応じて暗黙にコピーを挟むことでこの区別を隠せましたが、noncopyable な値ではコピーが使えないため、これらの区別が表に出てきます。

たとえば、copyable な型ではひとつの値を同じ呼び出しの中で「borrowする引数」と「consumeする引数」の両方に渡せます(暗黙のコピーで解決されます)が、noncopyable ではコンパイルエラーになります。

func borrow(_: borrowing FileDescriptor, and _: borrowing FileDescriptor) {}
func consume(_: consuming FileDescriptor, butBorrow _: borrowing FileDescriptor) {}

let x = FileDescriptor(fd: 0)
borrow(x, and: x)         // OK: 複数のborrowは同時に可能
consume(x, butBorrow: x)  // ERROR: borrow中にxを consume できない

主な consuming 操作は以下です(いずれも操作後の x の利用はエラーになります)。

  • 新しい let / var への代入や既存の変数・プロパティへの代入
  • consuming パラメータへの引数渡し
  • consuming メソッドの呼び出し
  • consume 演算子(SE-0366
  • return
  • switch / if let / if case でのパターンマッチ(switch consume x のように consume 演算子で明示する)
  • for ループでの走査

一方、修飾子なしのパラメータへの引数渡しや、borrowing メソッドの呼び出し、_ = x などは borrowing 操作です。inout パラメータへの引数渡しや mutating メソッドの呼び出しは mutating 操作で、「law of exclusivity」に従って排他アクセスを要求します。

noncopyable パラメータの宣言

noncopyable 型のパラメータでは、borrowing / consuming / inout のいずれかを必ず 明示する必要があります。SE-0377の copyable な場合と違って、省略時のデフォルトは決まりません。

// ファイルディスクリプタを差し替える(元を排他アクセスで書き換える)
func redirect(_ file: inout FileDescriptor,
              to otherFile: borrowing FileDescriptor) {
  dup2(otherFile.fd, file.fd)
}

// 書き込み(共有アクセスで足りる)
func write(_ data: [UInt8], to file: borrowing FileDescriptor) {
  // ...
}

// クローズ(値を消費する)
func close(file: consuming FileDescriptor) {
  close(file.fd)
}

noncopyable 型のメソッドは、mutatingconsuming と宣言されていなければ暗黙に borrowing です。

プロパティの扱い

クラスや noncopyable な struct は、noncopyable 型の stored property を let / var で持てます。let は borrow のみ、var は borrow と mutate が可能です。stored property を直接 consume すると容れ物が壊れるため、原則 consume はできません。容れ物の側がミュータブルに触れる場面では、プロパティに別の値を代入して差し替えることはできます。

struct Inner: ~Copyable {}
struct Outer: ~Copyable {
  var inner = Inner()
}

let outer = Outer()
let i = outer.inner // ERROR: outerからinnerを奪えない

var mutableOuter = Outer()
mutableOuter.inner = Inner() // OK: 既存のinnerは破棄され、新しい値に置き換わる

computed property も使えます。get は所有権を返すため呼び出し側で consume でき、setnewValueconsuming として受け取ります。アクセサにも consuming / borrowing を付けられ、consuming get はラッパー型から中身を取り出す場合に便利です。ただし容れ物が ~Copyable の場合、consuming get と setter は共存できません(getterが容れ物そのものを consume するため)。

クラスの noncopyable な stored property は、動的なexclusivityチェックの対象となります。たとえば同じプロパティを同時に borrow と mutate しようとすると実行時エラーになります。let プロパティは常にborrow状態とみなされ、consume も mutate もできません。

deinit

noncopyable な struct / enumdeinit を書け、値の寿命が終わる時点で自動的に呼ばれます。クラスの deinit と同様にエラーを投げることはできず、self はbody内で borrowing メソッドのように扱われ、変更や consume はできません。

struct File: ~Copyable {
  var descriptor: Int32

  consuming func close() {
    print("closing file")
    // 関数末尾で self の寿命が尽き、deinit が走る
  }

  deinit {
    print("deinitializing file")
    closeFile(rawDescriptor: descriptor)
  }
}

do {
  let file = File(descriptor: 42)
  file.close()
}
// 出力:
//   closing file
//   deinitializing file

ローカル let / var / consuming パラメータの場合、その値が consume されなければスコープの終わりで deinit が走ります。consume された場合は、破棄責任がconsume先に引き継がれます。条件分岐の一方でだけ consume されたときは、consume されない側でできるだけ遅いタイミングで deinit が呼ばれます。型のメンバーとして noncopyable な値が含まれるときは、容れ物の deinit が先、メンバーの deinit が後の順に実行されます。

discard selfdeinit を抑制する

consuming メソッドが自分で資源の解放に相当する処理をやり切った場合、メソッドの終わりで再度 deinit を走らせると二重解放になってしまいます。これを避けるために、self の寿命を deinit を呼ばずに終わらせる演算子 discard self を導入します。

struct FileDescriptor: ~Copyable {
  private var fd: Int32

  deinit { close(fd) }

  // Cの fd を取り出し、呼び出し側に所有権を渡す(ここでは閉じない)
  consuming func take() -> Int32 {
    let fd = self.fd
    discard self
    return fd
  }
}

discard self は、型の元の定義と同じファイル内の consuming メソッドでのみ使えます。Rustの mem::forget と違って任意の場所から任意の値には使えません。また今回の提案では、discard self を使えるのは「参照カウント対象・ジェネリック・existentialを含まず、deinit を持つ型も含まない、いわゆるtrivialな型」に限られます(今後の緩和はFuture Directionsで検討)。

さらに、メソッドのいずれかのパスで discard self を使うなら、すべてのパスで discard self または _ = consume self(こちらは deinit を実行する)のどちらかを選ばなければなりません。これは、エラーパスや早期 returndiscard が抜けて二重解放になるのを防ぐための制約です。

struct MemoryBuffer: ~Copyable {
  private var address: UnsafeRawPointer

  init(size: Int) throws {
    guard let address = malloc(size) else { throw MallocError() }
    self.address = address
  }

  deinit { free(address) }

  consuming func takeOwnership(if condition: Bool) -> UnsafeRawPointer? {
    if condition {
      // 呼び出し側に所有権を渡す(free しない)
      let address = self.address
      discard self
      return address
    } else {
      // 手放さない場合は通常どおり deinit を実行して解放
      _ = consume self
      return nil
    }
  }
}

Future Directions

今回のスコープ外として、次のような拡張が構想されています(speculativeなもので、実現を約束するものではありません)。

  • noncopyable な要素を含むタプル、要素ごとの独立した borrow / mutate / consume
  • noncopyable な Optional。クラスのプロパティから値を取り出す take() パターンなど、静的な解析だけでは扱えない動的な所有権移動を可能にする
  • ジェネリクスへの全面的な対応(T: ~Copyable のような形で、ジェネリック型パラメータからも Copyable 要件を外せるようにする)
  • Pair<T, U>T / U がcopyableな場合にのみcopyableになるような、条件付きでcopyable な型(extension Pair: Copyable where T: Copyable, U: Copyable のような書き方)
  • ~Sendable~Equatable のように、暗黙に合成されるプロトコル適合を抑制する一般化された ~Constraint 構文
  • deinit の中で self を mutate / consume することを許す方向への整理