Swift Digest
SE-0073 | Swift Evolution

Marking closures as executing exactly once

Proposal
SE-0073
Authors
Félix Cloutier, Gwendal Roué
Review Manager
Chris Lattner
Status
Rejected

01 何が問題だったのか

Swift には、withUnsafePointer(to:)autoreleasepool のように、クロージャを受け取ってその実行中だけ特別な文脈を提供する関数が数多くあります。これらの関数では、呼び出し側のロジックとしては「クロージャが必ず一度だけ実行される」ことを前提にしたいケースがよくありますが、現行の Swift ではコンパイラがそれを知る手段がありません。

一度だけ実行されるはずなのに var と初期値が必要になる

たとえば、あるクロージャの中で変数を初期化したい、という場面を考えます。呼び出し先の関数がクロージャをちょうど一度だけ同期的に実行する仕様であっても、コンパイラはそれを保証できないため、変数を let として未初期化のまま宣言することは許されません。結果として、本来はイミュータブルな値にしたいにもかかわらず、var で宣言して何らかのダミー値を入れておく必要があります。

var x: Int = 0 // var 宣言 + 意味のないダミーの初期値
f { x = 1 }
print(x)

この事情は autoreleasepool のような標準的な API でも同じで、「クロージャは一度しか呼ばれない」という契約を持つ関数であっても、コンパイラはそれを読み取れず、呼び出し側に var と初期値を強いてしまいます。クロージャと「インラインに書いたコード」との間には、意味的な差以上に構文的・初期化規則上のギャップがあり、この提案はそのギャップを縮めようとするものでした。

既存の @noescape では「一度だけ」までは表現できない

SE-0049 によって @noescape は型属性となり、「クロージャが関数の外に逃げない」ことはすでに表現できるようになっていました。しかし @noescape はあくまでエスケープの有無を示すだけで、呼び出されるのが何回か(0 回かもしれないし複数回かもしれない)までは何も言いません。そのため、「このクロージャは必ず一度だけ呼ばれる」という強い性質に基づく最適化 ── 特に「この変数は必ずこのクロージャの中で初期化される」という推論 ── を行う余地がありませんでした。

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

この提案は Rejected(却下) となりました。現在の Swift にも「クロージャが必ず一度だけ実行される」ことをコンパイラに伝える属性は存在せず、冒頭のような場面では引き続き var とダミーの初期値、あるいは即時実行クロージャ(let x = { ... }())などの回避策を使う必要があります。

提案されていた内容(却下されたもの)

提案は、@noescapeonce という引数を加えた @noescape(once) という属性を導入するものでした。この属性が付いたクロージャ引数は、「関数から正常に return するすべての経路において、ちょうど一度だけ呼び出される」ことがコンパイラによって強制されます。

func f(closure: @noescape(once) () -> ()) {
    closure()
}

この保証があれば、呼び出し側では「そのクロージャの中で変数が必ず一度だけ書き込まれる」ことが静的に分かります。したがって、次のようにイミュータブルな let を未初期化で宣言し、クロージャ内で初期化する書き方が許されるようになる予定でした。

let x: Int // 未初期化
f { x = 1 }
print(x)   // ここでは必ず初期化済み

SE-0061 と組み合わせれば、autoreleasepool などを使ったコードは次のように段階的に整理できるはずでした。

// 当時の Swift:
var x: Int = 0
autoreleasepool {
    x = 1
}

// SE-0061 が入ったあと:
let x = autoreleasepool {
    return 1
}

// さらに本提案が入っていれば:
let x: Int
let y: String
autoreleasepool {
    x = 1
    y = "foo"
}

@noescape(once) に課される規則

@noescape(once) クロージャには、通常の @noescape の制約に加えて次のルールが課される予定でした。

  • 関数が正常に return するすべての経路で、クロージャは呼び出されるか、あるいは同じ型の @noescape(once) 引数として別の関数に渡されなければなりません(渡すことが「呼び出した」ものとして数えられます)。
  • throw する経路では、クロージャを呼び出してはいけません。throw 経路で呼ばないことを保証する方針にすることで、「ロックが取れなかったのでクロージャを実行せずに抜ける」といった実装が書きやすくなります。
do {
    let foo: Int
    try withLock(someLock, timeout: 0.5) {
        foo = sharedThing.foo
    }
} catch {
    print("couldn't acquire lock fast enough")
}
  • 戻ってこない関数(Never を返す関数)へ至る経路については、呼び出し要件はありません。
  • 複数の @noescape(once) 引数を取る関数では、どちらが先に実行されるかの順序をコンパイラは仮定できません。したがって、一方で初期化した let 変数を、もう一方のクロージャが読むような使い方は不正です。
func f(a: @noescape(once) () -> (), b: @noescape(once) () -> ()) { /* snip */ }

let x: Int
f(a: { x = 1 }, b: { print(x) }) // 不正: x が初期化されている保証はない

却下の理由

Swift core team は提案そのものには好意的な評価をしつつも、Swift 3 の段階での採用を次の 2 点の理由で見送りました。

  • @noescape まわりの表層構文が再検討中だったこと@noescape を命名規約に合わせて @nonescaping に改名する(あるいは非エスケープを既定にして @escaping に反転させる)議論が進行中で、それが決着する前に @noescape(once) という形で固定するのは時期尚早だと判断されました。仮にエスケープを明示する側に反転するなら、この機能の名前は単に @once になった可能性があります。
  • 実装コストが Swift 3 のタイムフレームに見合わなかったこと。条件付きで初期化される変数のために、definite initialization パスが内部的に真偽値のフラグを生成し、それをクロージャのキャプチャリストに含める必要が出てくるなど、コンパイラ側の変更が大きく、Swift 3 のスケジュールでは実現困難と見られました。加えて、この機能は将来的に、クロージャ側から break / continue / throw / return などで外側の制御フローを操作できるような、より一般的な仕組みやマクロシステムに吸収され得る可能性があり、それを待ったほうが良いという見方もありました。

現在の Swift での書き方

「一度だけ実行される」ことをコンパイラに伝える属性が無いため、クロージャ内で値を用意して外側の let に渡したい場合は、クロージャの戻り値を使う形で書くのが基本です。autoreleasepool など、SE-0061 以降ジェネリックな戻り値を返すように整理された API では、次のように書けます。

let x: Int = autoreleasepool {
    return 1
}

複数の値をまとめて用意したい場合は、タプルで返す、ローカルな構造体にまとめる、即時実行クロージャでブロックを括る、といった書き方で代用します。いずれにしても、クロージャ呼び出し回数に関する静的な保証はコンパイラから得られないため、「クロージャ内で外側の let を初期化する」という書き方は現在もできません。

今後の見通し(speculative)

提案の Future Directions では、@noescape(once) の効果を本当に引き出すには autoreleasepoolwithUnsafeBufferPointerdispatch_sync といった標準・コアライブラリの関数側も順次この属性を採用していく必要があると述べられていました。却下の議論の中でも、より一般的な「制御フローを司るクロージャ」や、マクロシステムによって同等以上のことを表現できる可能性が示唆されています。現時点でこの方向に直接対応する機能は入っていませんが、将来的に別の形で取り上げられる余地はあると言えそうです(実現を約束するものではありません)。