Noncopyable structs and enums
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 へ段階的に対応していくことは、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)returnswitch/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な型」に限られます(今後の緩和はFuture Directionsで検討)。
さらに、メソッドのいずれかのパスで 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
}
}
}
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 することを許す方向への整理