Nonescapable Types
01 何が問題だったのか
Swift のほとんどの型は、値をローカル変数に格納する以外に、関数から返したり、グローバル変数や別のオブジェクトのプロパティに代入したり、エスケープするクロージャにキャプチャしたりと、生成されたスコープの外へ自由に「持ち出す」ことができます。クロージャの @escaping / @noescape と同じ意味での escapability は、これまで Swift のクロージャにだけ存在する概念でした。
一方で、パフォーマンスクリティカルな場面や組み込み環境では、この自由度こそが問題になります。たとえば Array の標準イテレータは、生成時に論理的に配列のコピーを作ります。実装としては reference count されたストレージへのポインタを保持し、イテレート中に元ストレージが解放されないようにしています。これはイテレータを任意にコピー・保管できるという現行の自由度を支えるための仕組みですが、ランタイムの参照カウント操作というオーバーヘッドが常に伴い、参照カウントを避けたい組み込み環境ではそもそも使えません。
より根本的な課題として、連続メモリ上に格納された配列的データへの軽量な「ビュー」となる Span のような型を安全に導入したい、という要件があります。Span は自分ではデータを所有せず、他の型が保持しているストレージを指すだけなので、そのストレージよりも長く生き残ってしまうと dangling pointer になってしまいます。コンパイル時にこの「ローカルスコープを越えて持ち出してはいけない」という制約を表現・検査する手段が、値型に対しては存在しませんでした。~Copyable(SE-0390)でコピーを禁止することも検討されましたが、イテレータのようにローカルではコピーして使いたい(が、スコープ外へは出したくない)型には合わず、また Span を将来的に ~Escapable へ拡張する余地をなくしてしまうことから、不適切だと結論付けられました。
02 どのように解決されるのか
標準ライブラリに新しい suppressible protocol Escapable を導入し、これまでのすべての型(non-escapable なクロージャを除く)に暗黙で適合させます。そのうえで ~Copyable と同じ方式で ~Escapable と書くことで、型に課されている Escapable 要件を 抑制 できるようにします。Escapable の宣言は次のようなものです。
// Escapable な型は Copyable かどうかは問わない
protocol Escapable: ~Copyable {}
Copyable と Escapable の間には依存関係はありません。コピー可能/不可能と escapable/non-escapable は独立に組み合わせられます。クラスは本質的に escape する性質を持つため、~Escapable にはできません。
non-escapable な型の定義と制約
具体型の宣言で ~Escapable を書くと、その型は non-escapable になります。
// 例: non-escapable な型
struct NotEscapable: ~Escapable {
// ...
}
non-escapable な値は、生成されたローカルコンテキストの外へ持ち出すことができません。具体的には次のような使い方が禁止されます。
- より広いスコープの束縛(グローバル変数、static 変数、クラスプロパティなど)への代入
- 現在のスコープからの return
- エスケープするクロージャへのキャプチャ
// 例: ~Escapable 型の基本的な制限
func f() -> NotEscapable {
let ne = NotEscapable()
borrowingFunc(ne) // borrowing 関数への受け渡しは OK
let another = ne // ローカルなコピーは OK
globalVar = ne // 🛑 ~Escapable な値をグローバル変数に代入できない
return ne // 🛑 ~Escapable な値を return できない
}
一方、ローカルスコープの中で閉じている限り、non-escapable な値も自由にコピーしたり、借用/消費/inout のいずれの形式でも他の関数へ渡せます。async 関数や throwing 関数へ渡すこと、async let で使うことも可能です。
func borrowingFunc(_: borrowing NotEscapable) { ... }
func consumingFunc(_: consuming NotEscapable) { ... }
func inoutFunc(_: inout NotEscapable) { ... }
func asyncBorrowingFunc(_: borrowing NotEscapable) async -> ResultType { ... }
func f() {
var value: NotEscapable
let copy = value // スコープを越えない限り OK
globalVar = value // 🛑 グローバルへの代入は不可
SomeType.staticVar = value // 🛑 static 変数への代入も不可
async let r = asyncBorrowingFunc(value) // OK
borrowingFunc(value) // OK
inoutFunc(&value) // OK
consumingFunc(value) // OK(consume される)
// 上で consume されたが Copyable ではあるので
// コンパイラが暗黙のコピーを挿入して次の呼び出しを満たす
borrowingFunc(value) // OK
}
consuming で受け取った non-escapable なパラメータは、通常の escapable な consuming パラメータと違い、関数内で実際に破棄されなければなりません(return したりインスタンスプロパティに格納したりして「逃がす」ことはできません)。
既存型への後付けと拡張
~Escapable は capability を抑制する 書き方なので、extension で後から付けることはできません。
// 例: デフォルトでは escapable
struct Ordinary { }
extension Ordinary: ~Escapable // 🛑 extension で capability を剥奪することはできない
既存の具体型に後から ~Escapable を付けるのは一般にソース互換性を壊す変更です(その型の値を escape させている既存コードが壊れるため)。逆に、~Escapable だった型から制約を外して escapable にするのは、新しい capability を追加するだけなので通常ソース互換です。
non-escapable な値を含む型
stored property や enum のペイロードに non-escapable な型を持てるのは、外側の型自身も non-escapable な場合に限られます。言い換えると、escapable な struct/enum には escapable な値しか入れられません。クラスは常に escape する性質を持つため、クラスプロパティに non-escapable な値を格納することもできません。
struct EscapableStruct {
var nonesc: Nonescapable // 🛑 escapable な struct に non-escapable な stored property は不可
}
enum EscapableEnum {
case nonesc(Nonescapable) // 🛑 escapable な enum に non-escapable なペイロードは不可
}
struct NonescapableStruct: ~Escapable {
var nonesc: Nonescapable // OK
}
enum NonescapableEnum: ~Escapable {
case nonesc(Nonescapable) // OK
}
ジェネリックコンテキストでの ~Escapable
ジェネリックパラメータに ~Escapable を書くと、「デフォルトで課されている Escapable 要件を抑制する」という意味になります。その型パラメータは escapable でも non-escapable でも構わないので、コンパイラは保守的に「escape させるかもしれない使い方」を禁止します。
func f<MaybeEscapable: ~Escapable>(_ value: MaybeEscapable) {
// `value` は Escapable かもしれないし、そうでないかもしれない
globalVar = value // 🛑 escape する可能性があるのでグローバルへの代入は不可
}
f(NotEscapable()) // non-escapable 引数で呼べる
f(7) // escapable 引数でも呼べる
ジェネリック型の側では、型引数に応じて escapability が変わる「条件付き Escapable」も、~Copyable と同様に extension で表現できます。
// 例: 条件付きに Escapable となるジェネリック型
// デフォルトでは Box 自体は non-escapable
struct Box<T: ~Escapable>: ~Escapable {
var t: T
}
// 型引数 T が Escapable のときだけ Box も Escapable になる
extension Box: Escapable where T: Escapable { }
~Copyable と組み合わせることで、中身の capability に応じて外側のコンテナの capability を切り替える一般的なパターンも簡潔に書けます。
struct Wrapper<T: ~Copyable & ~Escapable>: ~Copyable, ~Escapable { /* ... */ }
extension Wrapper: Copyable where T: Copyable, T: ~Escapable {}
extension Wrapper: Escapable where T: Escapable, T: ~Copyable {}
イニシャライザと return についての注意
non-escapable な値は return できない、というルールから素朴には次のコードは書けません。
func f() -> NotEscapable { // 🛑 non-escapable な型は return できない
let value = NotEscapable()
return value // 🛑
}
しかし struct や enum のイニシャライザは、生成した値を呼び出し側へ返さざるを得ません。この例外を健全に扱うためには「返り値の寿命を、どの引数の寿命に紐付けるか」を表明する仕組み、すなわち lifetime dependency アノテーションが必要になります。これは別提案として検討されており、本提案の段階では non-escapable 型のイニシャライザは実験的機能として提供されます。
Future Directions
本提案自体は言語機能の土台にとどまり、実際に活かされるのはこの上に積み上がる型と機能です。今後の方向性として、次のようなものが挙げられています(いずれも speculative で、実現を約束するものではありません)。
Spanファミリーの導入: 連続メモリ上の配列的データへの軽量なビューで、本提案を駆動している最大のユースケース。- lifetime dependency アノテーション: non-escapable 値の寿命を特定の引数(コンテナなど)の寿命に紐付けることで、イニシャライザや
with*クロージャ API をコンパイル時に安全化する。Iteratorのようにコンテナに依存する型も、consume containerの後で使うと 🛑 となるようなチェックが可能になる。 - 標準ライブラリ型の拡張:
Optional、Array、Set、Dictionary、Unsafe*Pointer系などに non-escapable な型引数を受け入れられるようにする。Equatable/Comparable/Hashableといった基本プロトコルは比較的容易に対応できる一方、Collection/Iterator/Sequenceまわりは既存プロトコルに組み込むか新プロトコルを別立てするかから検討が必要。 with*クロージャ API の精緻化: 引数がクロージャから外に持ち出されないことをコンパイル時に保証できるため、ロック API などの安全性が向上する。- non-escapable なクラス: 本提案ではクラスを対象外としているが、将来的には参照カウント操作を省略する手段として non-escapable なクラスを認める余地がある。
- 構造化並行への組み込み:
TaskGroupのようにもともとローカルコンテキストを越えて持ち出してはならない型を~Escapableとして表現すれば、誤用を防げるうえ最適化の余地も広がる。 - 不死(immortal)寿命を持つグローバル: 現状は non-escapable な値をグローバル/static 変数に置けないが、将来的には「static」や「immortal」といった寿命を明示することで許容する案が示されている。