Swift Digest
SE-0390 | Swift Evolution↗

noncopyableなstructとenum

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)

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。

01 何が問題だったのか

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

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

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

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

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

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

Copyable 制約と ~Copyable

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

struct Foo<T: Copyable>: Copyable {}

struct や enum を 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 へ段階的に対応していくことは、今回の提案のスコープ外です。

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 型のメソッドは、mutating や consuming と宣言されていなければ暗黙に 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 でき、set は newValue を consuming として受け取ります。アクセサにも consuming / borrowing を付けられ、consuming get はラッパー型から中身を取り出す場合に便利です。ただし容れ物が ~Copyable の場合、consuming get と setter は共存できません(getterが容れ物そのものを consume するため)。

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

deinit

noncopyable な struct / enum は deinit を書け、値の寿命が終わる時点で自動的に呼ばれます。クラスの 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 self で deinit を抑制する

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な型」に限られます(この制限の緩和は将来の検討課題です)。

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

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
    }
  }
}

03 今後の見通し

今回の提案では、noncopyable な型を最低限自己完結した形で導入することに集中しており、以下のような拡張は将来の構想として残されています。いずれも方向性として示されているもので、実現を約束するものではありません。

noncopyable なタプル

タプルの要素のいずれかが noncopyable なら、そのタプル全体も noncopyable になるという形で、タプルへの対応を広げる構想があります。タプルの構造は静的に分かるため、inout で要素を独立に書き換えられる現状と同様に、要素ごとに独立して borrow / mutate / consume できるようにすることも検討されています。ただし動的なexclusivityチェックの制約上、クラスのプロパティ・グローバル変数・エスケープするクロージャのキャプチャに置かれたタプルでは、要素単位の独立アクセスは難しいと見込まれています。

noncopyable な Optional

ジェネリクスへの全面対応より先に、Optional を特例として noncopyable に対応させる構想があります。今回のルールでは、クラスの stored property は consume できないため、クラスが保持する noncopyable な資源を別の場所へ動的に渡すことができません。Optional を noncopyable に対応させると、たとえば次のような take() で、値を取り出した瞬間に nil を書き戻すことで動的に所有権を移せるようになります。

extension Optional where Self: ~Copyable {
  mutating func take() -> Wrapped {
    switch self {
    case .some(let wrapped):
      self = nil
      return wrapped
    case .none:
      fatalError("trying to take from an Optional that's already empty")
    }
  }
}

これがないと、各 noncopyable 型が独自に「nil 相当の状態」を用意することになりがちで、不正な状態を表現不能にしておきたいというSwift本来の方針に反するため、優先度の高い拡張として位置づけられています。

ジェネリクスへの対応

今回の提案ではジェネリック型パラメータや associated type は暗黙に Copyable を要求するため、noncopyable な型はほとんどのジェネリックAPIや Optional / Array などの標準ライブラリ型と組み合わせられません。ジェネリクスシステム側に noncopyable の概念を統合するのは大きな設計課題で、Swiftランタイムや標準ライブラリにも変更が必要になる見通しです。後方互換性の制約も伴うため、まずは noncopyable 型を単体機能として導入しておき、ジェネリクスへの統合は段階的に進める方針が示されています。

条件付きで copyable な型

Pair<T, U> のように、ジェネリック引数次第で copyable にも noncopyable にもなる型を表現する構想です。条件付き適合のような形で、たとえば次のように書けるようにすることが想定されています。

struct Pair<T: ~Copyable, U: ~Copyable>: ~Copyable {
  var first: T
  var second: U
}

extension Pair: Copyable where T: Copyable, U: Copyable {}

~Constraint による暗黙適合の抑制

~Copyable の構文を一般化し、暗黙に合成されるプロトコル適合を抑制する ~Constraint の導入が構想されています。たとえば、関連値を持たない enum は暗黙に Hashable(および Equatable)に適合し、内部の struct / enum は構成要素がすべて Sendable なら暗黙に Sendable に適合します。~Equatable や ~Sendable を書けるようになれば、こうした暗黙の適合を明示的に止められるようになります。

enum Candy: ~Equatable {
    case redVimes, twisslers, smickers
}

// ERROR: `Candy` does not conform to `Equatable`
print(Candy.redVimes == Candy.twisslers)

~Constraint はあくまで暗黙の派生を止めるもので、エクステンションで適合を改めて与え直すことは引き続き可能です。

deinit での self の mutate / consume

今回の提案では deinit 内の self はイミュータブルとして扱われますが、本来 deinit は self の唯一の所有者なので、self を mutate / consume することを許す余地があります。ただし、mutating メソッドが内部で consuming メソッドを呼ぶなどして暗黙の deinit 呼び出しが連鎖し、deinit から再び deinit に入ってしまう無限再帰が起きやすい点が課題です。これを避けるために、deinit 内では値全体ではなく個別フィールドに対してだけ mutate / consume を許す案や、deinit から呼べるメソッドを静的に解析して警告・エラーを出す案などが検討されています。

consuming メソッドや deinit での部分的な分解

現状、noncopyable な値は(init を除き)常に「完全に初期化済み」か「完全に破棄済み」のどちらかで、consuming メソッドや deinit の中でも部分的に消費していくことはできません。init の definite initialization と対をなす形でデータフロー解析を行い、deinit や consuming メソッドが self を部分的に無効化できるように拡張する構想があります。たとえば noncopyable な要素を持つ容れ物から、一方の所有権だけを呼び出し側に返し、もう一方は通常どおり破棄する、といった操作が自然に書けるようになります。

discard self の一般化

今回の提案では discard self は trivial な型に限定されています。これを、クラスやジェネリック、existential、他の noncopyable 型を含むフィールドを持つ型にも広げる構想があります。一般化にあたっては、discard self が個々のフィールドの後始末をどう扱うか(discard した時点で全フィールドの後始末を走らせるのか、deinit だけを抑制してフィールドはそれぞれの寿命の終わりに後始末するのか)といった設計上の論点が残されており、提案者らはRustの mem::forget のように全フィールドをリークさせるのではなく、後者の挙動が望ましいと考えています。

computed property のための read / modify アクセサコルーチン

現状の get / set は値の受け渡しにコピーを介するため、noncopyable 型では「stored property を直接公開しつつ getter / setter をかぶせる」ような computed property が書けません。Swiftが内部で使っているアクセサコルーチン(値を yield して borrow / mutate を仲介する仕組み)をユーザーコードに開放することで、コピーを介さずに値をその場で借用・書き換えできる computed property を書けるようにする構想があります。これは copyable な値型にとっても性能上の利点があります。

所有権修飾子付き関数値の static cast

現状、関数値の as などによる static cast では、noncopyable なパラメータの所有権修飾子は変えられません。一方で、borrowing のクロージャを inout を要求する関数型へキャストするような、能力を増やすだけの安全なキャストもあります。copyable な型にも関わるテーマでもあるため、今回はスコープ外とされていますが、将来的にこうした安全な変換を許容する方向が検討されています。なお、consuming から borrowing へのキャストはコピーが必要になるため copyable な型にしか適用できません。