non-Copyable 型の deinit でのミューテーションと消費
Mutation and consumption in non-Copyable type deinits
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
non-Copyable な型は、値の寿命が尽きるときに所有しているリソースを片付けるために deinit を定義できます。ところがこの提案より前は、deinit の本文の中で self はイミュータブルで、借用(borrow)することしかできませんでした。つまり、self のフィールドをミューテートしたり消費(consume)したりできなかったのです。
これは実際のコードで不便でした。non-Copyable な型は、リソースを所有する別の non-Copyable な値を組み合わせて構成されることがよくあります。そうした型の後片付けでは、各コンポーネントをどのように消費するかを制御したくなります。
struct File: ~Copyable {
consuming func close() {...}
}
struct Buffer: ~Copyable {
borrowing func flush(to file: borrowing File) {...}
consuming func release() {...}
}
struct BufferedFile: ~Copyable {
let file: File
let buffer: Buffer
deinit {
// バッファをフラッシュしてから解放する
buffer.flush(to: file)
buffer.release()
// それからファイルを閉じる
file.close()
}
}
上のように、deinit の中で buffer.release() や file.close() のような consuming メソッドを呼び出せると自然です。しかし self を借用することしかできないと、self のフィールドを消費するこれらの操作は書けませんでした。同様に、後片付けの処理を mutating メソッドに切り出して deinit から呼ぶ、といったこともできませんでした。
02 どのように解決されるのか
deinit の中で、self の フィールド をミューテートしたり消費したりできるようにします。これにより、先ほどの BufferedFile の deinit はそのまま書けるようになります。
self 全体のミューテーション・消費は引き続き禁止
一方で、self を 値全体として ミューテートしたり消費したりすることは、引き続き禁止されます。これは「値の復活(resurrection)」による事故を避けるためです。
non-Copyable な型の deinit は、値の所有権を持つ他のあらゆるコンテキストとは違う特殊な立場にあります。通常のコンテキストは値を破棄するときに暗黙的に deinit を呼び出しますが、deinit 自身はそれができません(deinit は格納プロパティや、enum であれば現在のケースを破棄するだけです)。
もし deinit が self を消費・ミューテートするメソッドに渡せてしまうと、渡された先で値が「復活」し、その値の寿命が尽きるときにふたたび deinit が呼ばれてしまいます。これは容易に無限ループを招きます。
struct Foo: ~Copyable {
deinit {
self.foo()
}
consuming func foo() {
// しまった、暗黙的に deinit を呼び戻してしまう
}
}
struct Bar: ~Copyable {
deinit {
self.bar()
}
mutating func bar() {
// しまった、self を再代入する前に、
// 古い self に対して deinit が呼ばれてしまう
self = Bar()
}
}
この危険を避けるため、self 全体のミューテーション・消費は禁止されます。その帰結として、deinit は同じ型の mutating / consuming メソッドを(self 全体に対して)呼び出せません。多くの場合、この制限は許容できるものと考えられています。
ロジックの共有は static メソッドで
deinit と他のメソッドとで後片付けのロジックを共有したい場合は、self 全体ではなくフィールドを引数に取る static メソッドに切り出します。
struct Resource: ~Copyable {
var resourceID: Int
// ID を使ってリソースを解放する共有ロジック。
// 解放時にエラーが表面化しうるが、通常の利用では無視できる。
private static func release(resourceID: Int) throws {...}
// エラーを呼び出し元に伝える consuming メソッド
consuming func release() throws {
try Self.release(resourceID: self.resourceID)
discard self
}
// エラーを握りつぶしてリソースを閉じる deinit
deinit {
do {
try Self.release(resourceID: self.resourceID)
} catch {
// エラーは無視する
}
}
}
値全体を手放したいときは明示的に「復活」させる
deinit の中で値全体の所有権をどこかへ渡したくなる場面もあります。たとえば後片付けが時間のかかる処理なら、deinit の中で即座に片付けるのではなく、死につつある値をキューに積んであとで片付けたいことがあります。
deinit は必ず型の元の宣言の中で定義されるため、その struct のレイアウトとメンバーワイズイニシャライザにアクセスできます。したがって、self のフィールドをメンバーワイズイニシャライザに渡すことで、値を明示的に「復活」させられます。
let deferredCleanupValues: ConcurrentQueue<DeferredCleanup>
struct DeferredCleanup: ~Copyable {
var resource1: Resource1
var resource2: Resource2
deinit {
// この値のリソースを即座に片付ける代わりに、同等の値をキューへ積み、
// あとでまとめて片付ける
let newSelf = Self(resource1: self.resource1, resource2: self.resource2)
deferredCleanupValues.push(newSelf)
}
consuming func runTimeConsumingCleanup() async { ... }
}
その他の制約
deinitの中でselfをクロージャにキャプチャすることは、引き続きできません。deinitが返る時点でまだ消費されていないselfのコンポーネントは、暗黙的に破棄されます。non-Copyableなコンポーネントについては、そのdeinitも実行されます。