Swift Digest
SE-0035 | Swift Evolution

Limiting inout capture to @noescape contexts

Proposal
SE-0035
Authors
Joe Groff
Review Manager
Chris Lattner
Status
Implemented (Swift 3.0)

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 引数にシャドーコピーを用意する必要がなくなります。今回の変更後にも受理されるコードについては、従来も最適化で取り除かれていた分なので、観測可能な差異は生じません。