Limiting inout capture to @noescape contexts
01 何が問題だったのか
Swift の inout 引数は、呼び出し側の変数に対して「その関数の実行中だけローカルに書き換え、終了時に書き戻す」という強い保証を持つ仕組みです。この保証のもとで inout 引数をクロージャやネストした関数の中でも使えるようにするため、当時のコンパイラは inout 引数を シャドーコピー(shadow copy)としてキャプチャし、呼び出し先が return したタイミングで元の引数に書き戻す、という妥協的な挙動を採用していました。
クロージャを escape させたときに直感に反する
この挙動は、クロージャが関数呼び出しの範囲を超えて escape しないケースでは、期待通りに動きます。
func captureAndCall(inout x: Int) {
let closure = { x += 1 }
closure()
}
var x = 22
captureAndCall(&x)
print(x) // => 23
一方で、クロージャが inout 引数のスコープを超えて escape する場合、シャドーコピーだけが生き残り、元の変数とは切り離された状態になります。
func captureAndEscape(inout x: Int) -> () -> Void {
let closure = { x += 1 }
return closure
}
var x = 22
let closure = captureAndEscape(&x)
print(x) // => 22
closure()
print("still \(x)") // => still 22
読み手からすると「inout でキャプチャしたのだから呼び出し側の x が更新されるはず」と見えるのに、実際にはシャドーコピーだけが書き換わって元の x には影響しません。この挙動は古くから継続的に混乱とバグ報告を生んでおり、IBM Swift ブログの「Seven Swift Snares & How to Avoid Them」でも落とし穴として指摘されるほど定番の問題になっていました。
@noescape が導入されて妥協の前提が崩れた
シャドーコピー方式は、そもそも「inout 引数をクロージャや @autoclosure に渡せないと使い勝手が極端に悪くなる」という前提で導入されたものでした。しかし Swift 1.2 で @noescape が導入され、標準ライブラリの該当箇所にも広く適用された結果、「クロージャが escape しないことを型で表明できる」ようになりました。つまり、escape しないことが保証されているクロージャに対してだけ inout のキャプチャを許せば、直感に反する挙動を引き起こさずに実用上の利便性も保てる、という整理ができる状況が整ったわけです。
02 どのように解決されるのか
escape しうるクロージャリテラルの中で inout 引数(および mutating メソッドの self)を暗黙にキャプチャすることをエラーにします。@noescape が付いたクロージャへのキャプチャは従来どおり許可され、escape するクロージャでキャプチャしたい場合は キャプチャリストで明示的にイミュータブルキャプチャする ことを求めます。
func escape(f: () -> ()) {}
func noEscape(@noescape f: () -> ()) {}
func example(inout x: Int) {
escape { _ = x } // error: closure cannot implicitly capture an inout parameter unless @noescape
noEscape { _ = x } // OK、クロージャが @noescape
escape {[x] in _ = x } // OK、イミュータブルにキャプチャ
}
struct Foo {
mutating func example() {
escape { _ = self } // error: closure cannot implicitly capture a mutating self parameter
noEscape { _ = self } // OK
}
}
ネストした関数にも同じルールを適用
ネストした関数宣言は、「関数への参照が値として使われた時点でクロージャが生成される」という扱いになっています。そのため、囲んでいるスコープの inout 引数を参照しているネスト関数は、escape する形で渡すことができません。@noescape 引数に渡すのは引き続き問題ありません。
func exampleWithNested(inout x: Int) {
func nested() {
_ = x
}
escape(nested) // error: nested function that references an inout cannot be escaped
noEscape(nested) // OK
}
既存コードの書き換え方
この変更で影響を受けるのは、現在のシャドーコピーの挙動に依存しているコードです。正当なユースケースと、それぞれの書き換え方は次の通りです。
1. キャプチャ後にローカルな変更だけ見たい場合: クロージャの中で inout 引数を読むだけ、あるいはキャプチャ時点の値だけを使いたい場合は、キャプチャリストでイミュータブルにキャプチャすれば済みます。
func foo(inout x: Int) -> () -> Int {
return {[x] in x } // キャプチャ時点の x の値を保持する
}
2. 動的にはスコープ内でしか呼ばれない escape クロージャ: lazy シーケンスアダプタを同じスコープ内で消費するケースや、dispatch_async で投げたジョブを抜ける前に dispatch_sync で同期するケースなど、実行時にはクロージャが inout のスコープを抜けないと分かっている場合は、シャドーコピーを明示的に書きます。
func foo(q: dispatch_queue_t, inout x: Int) {
var shadowX = x; defer { x = shadowX }
// 元の x ではなく shadowX に対して非同期に操作する
dispatch_async(q) { use(&shadowX) }
doOtherStuff()
dispatch_sync(q) {}
}
マイグレータは、キャプチャ後に変更があるかどうかを見てイミュータブルキャプチャ版の fix-it とシャドーコピー版の fix-it を使い分けます(単純化のため、常にシャドーコピーを提案する方針でも差し支えありません)。
@noescape の使用を自然に促す
この変更は、ライブラリ側が可能な箇所で @noescape を付けることの価値を高めます。@noescape が付いていれば inout 引数を素直に渡せる一方、付いていないと呼び出し側にキャプチャリストや明示的なシャドーコピーを要求することになるためです。結果として、クロージャを受け取る API は「escape するか否か」を型の上で明確に表明するスタイルが自然に根付きました。
実装面の副次効果
この制限により、コンパイラは「クロージャに渡るかもしれない」という理由で inout 引数にシャドーコピーを用意する必要がなくなります。今回の変更後にも受理されるコードについては、従来も最適化で取り除かれていた分なので、観測可能な差異は生じません。