Swift Digest
SE-0103 | Swift Evolution

Make non-escaping closures the default

Proposal
SE-0103
Authors
Trent Nadeau
Review Manager
Chris Lattner
Status
Implemented (Swift 3.0)

01 何が問題だったのか

関数の引数としてクロージャを受け取るとき、そのクロージャが関数本体の外に「エスケープ」できるかどうかは重要な区別です。エスケープするクロージャは、構造体のプロパティやグローバル変数に保存したり、別のスレッドに渡してあとから呼び出したりできる一方、その分だけ実装側の制約(強参照による循環参照の可能性、キャプチャのコスト、inout 引数をキャプチャできないことなど)が増えます。エスケープしないクロージャは関数から戻るまでの間にしか呼び出されないので、こうした制約を受けません。

当時のデフォルトは「エスケープする」だった

SE-0103 以前のSwiftでは、関数のクロージャ引数は デフォルトでエスケープする 扱いでした。エスケープしないことを明示するには、引数の型に @noescape 属性を付ける必要がありました。

// SE-0103 以前
func map<T>(_ transform: @noescape (Element) -> T) -> [T] { ... }
func forEach(_ body: (Element) -> Void) { ... } // デフォルトでエスケープ可能

しかし、実際に標準ライブラリや一般的なコードで定義される関数型引数の多くは、mapfilterforEach のような「クロージャを受け取ってその場で呼び出すだけ」の、本来エスケープする必要のないものでした。デフォルトがエスケープ側にあるため、そうした関数を書くたびに @noescape を書き足すボイラープレートが発生していました。

デフォルトが逆だったことによる弊害

デフォルトが「エスケープする」側に倒れていることは、次のような問題を生んでいました。

  • 純粋なSwiftで書かれた多くの関数アルゴリズムは本質的にエスケープしないクロージャを受け取るため、そのたびに @noescape を書かねばならず、記述量が増える。
  • エスケープしないクロージャにはretain cycleの心配がない・self. を省略できるといった利点があり、ユーザーにとっても本来こちらが「好ましいデフォルト」だった。
  • inout 引数をエスケープするクロージャでキャプチャすることは、当時すでに禁止される方向に進んでおり、言語全体がエスケープしないクロージャを優先する流れになっていた。
  • @autoclosure(escaping) のように、エスケープに関連する属性が他の属性の引数として表現されており、綴りが統一されていなかった。

デフォルトを逆にすれば、安全側が自然な記述になり、本当にエスケープさせたいときだけ明示する という、Swiftが一貫して採用している設計方針に揃えられます。

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

関数のクロージャ引数のデフォルトを反転させ、何も書かなければエスケープしない ようにします。エスケープさせたい場合だけ、引数の型に @escaping 属性を明示します。これにより、@noescape 属性は言語から削除されます。

新しいデフォルトの使い方

エスケープしないクロージャを受け取る関数は、単に何も書かずに関数型を書くだけで済みます。

// クロージャは関数本体の外にエスケープしない(新しいデフォルト)
func forEach(_ body: (Element) -> Void) {
  for element in self {
    body(element)
  }
}

クロージャを保存したり、後から呼び出すなどしてエスケープさせたい場合は @escaping を付けます。

var handlers: [() -> Void] = []

func register(_ handler: @escaping () -> Void) {
  handlers.append(handler) // エスケープするので @escaping が必要
}

エスケープするかどうかはコンパイラが静的に検出できるため、@escaping の付け忘れに対してはfix-it付きのエラーが出ます。逆に、かつての @noescape はそのままではコンパイルエラーとなり、「削除してください」というfix-itが提示されます。

@autoclosure との組み合わせ

@autoclosure(escaping) という書き方は廃止され、@autoclosure@escaping を並べる形に統一されます。

// 旧: @autoclosure(escaping) () -> Bool
func assertLazy(_ condition: @autoclosure @escaping () -> Bool) { ... }

一時的にエスケープさせたいときの withoutActuallyEscaping

エスケープしないクロージャを、エスケープするクロージャを要求するAPI(たとえば LazySequence のメソッド群)に一度だけ渡したい、というケースのために、標準ライブラリに withoutActuallyEscaping(_:do:) というヘルパーが追加されます。クロージャを「あたかもエスケープするかのように」扱えるスコープを作り、そのスコープを抜けるまでに本当にエスケープしていないことをランタイムで検証します。もし実際にエスケープしていた場合は trap します。

func yourFunction(fn: (Int) -> Int) {          // fn はエスケープしない
  withoutActuallyEscaping(fn) { fn in           // この fn は @escaping として扱える
    somearray.lazy.map(fn)                      // @escaping を要求するAPIに渡せる
  }
}

シグネチャは次のようになっています。

func withoutActuallyEscaping<ClosureType, ResultType>(
    _ closure: ClosureType,
    do: (fn: @escaping ClosureType) throws -> ResultType) rethrows -> ResultType

Objective-C / C APIのインポート

Cocoaのブロック引数はデリゲートのように エスケープするもの が多数派です。デフォルトを反転させるとCocoa APIの多くが壊れてしまうため、Clangインポータはインポート時に、Cの ((noescape)) 属性が明示的に付いていない限り、Objective-Cブロックや関数ポインタの引数に自動的に @escaping を補います。これにより、Cocoa APIの使用側ではSE-0103による書き換えはほとんど必要ありません。

既存コードへの影響

自分で @noescape を書いていた箇所は、単に属性を削除するだけで済みます。逆に、これまでデフォルトの扱いでエスケープさせていたクロージャ引数のうち、本当にエスケープしているものは @escaping を付け足す必要があり、コンパイラがfix-itで教えてくれます。エスケープするクロージャはエスケープしないクロージャより 制約が強い 側なので、呼び出し側には影響しません(エスケープする想定で渡されたクロージャが、実はエスケープしない関数に渡されるのは常に安全)。

Future Directions

SE-0073 として提案されていた「ちょうど一度だけ呼び出されるクロージャ」を表す @noescape(once) は、今後もし採用される場合には @once という独立した属性として整理される可能性があります(SE-0073 自体は Rejected となっており、現時点で実現を約束するものではありません)。