Swift Digest
SE-0352 | Swift Evolution

Implicitly Opened Existentials

Proposal
SE-0352
Authors
Doug Gregor
Review Manager
Joe Groff
Status
Implemented (Swift 5.7)

01 何が問題だったのか

SE-0335 で existential type には any を明示するようになり、SE-0309 で Self や associated type を持つプロトコルも existential type として書けるようになりました。しかし、existential type には依然として「ジェネリクスと組み合わせにくい」という本質的な問題が残っていました。

例えば、次のようなコードはコンパイルエラーになります。

protocol P {
  associatedtype A
  func getA() -> A
}

func takeP<T: P>(_ value: T) { }

func test(p: any P) {
  takeP(p) // error: protocol 'P' as a type cannot conform to itself
}

any P はジェネリックパラメータ T に束縛しようとしますが、any PP に適合していないため、「プロトコル P をそのまま型として使うと P 自身には適合しない」というエラーになります。これは、any P が保持する具象型は実行時までわからず、associatedtype A が何になるかも値ごとに違いうるため、any P という「箱」そのものを P に適合する型として扱えないことに起因します。

この制限のため、一度 any P の値を持ってしまうと、そこからジェネリックな世界に戻るのが非常に難しくなります。具体例として、次のように any Costume の配列を走査してジェネリック関数に渡したい場合を考えます。

protocol Costume {
  func withBells() -> Self
  func hasSameAdornments(as other: Self) -> Bool
}

func hasBells<C: Costume>(_ costume: C) -> Bool {
  return costume.hasSameAdornments(as: costume.withBells())
}

func checkFinaleReadiness(costumes: [any Costume]) -> Bool {
  for costume in costumes {
    if !hasBells(costume) { // error: protocol 'Costume' as a type cannot conform to the protocol itself
      return false
    }
  }
  return true
}

回避するには、呼び出し元から any Costume を使っている箇所すべてを some Costume / <C: Costume> に書き換えるか、自前で type eraser を用意する必要があり、既存コードへの影響が大きくなりがちでした。「existential type には簡単に入れるのに、そこから抜けられない」という、いわゆる existential trap です。

一方で、existential type の値に対して プロトコルのメンバを呼ぶ とき、Swift は実は裏側で existential を「開いて」Self を具象型に束縛する処理を行っています。次のような書き方が動くのはその仕組みのおかげです。

extension Costume {
  var hasBellsMember: Bool { hasBells(self) }
}

// costume.hasBellsMember は OK(Self が裏で束縛される)

つまり「existential を開いて具象型を一時的に名付ける」仕掛けはすでに存在しており、それがメンバ呼び出しに限定されていたことが、ジェネリック関数の引数に対しても同じ扱いをできない理由でした。

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

この提案は、existential 値をジェネリックパラメータに渡すとき、コンパイラが自動的に existential を開き、そのジェネリックパラメータを existential の underlying type(箱の中に入っている実際の型)に束縛するようにします。開いた型の名前は呼び出しの内側でだけ有効で、戻ってくる値は必要に応じて元の existential type へ type erase されます。この振る舞いはユーザにはほぼ見えず、これまで 'P' as a type cannot conform to itself で失敗していた呼び出しが、そのまま通るようになる、という形で現れます。

先ほどの checkFinaleReadiness は、書き換えなしでコンパイルが通るようになります。

func checkFinaleReadiness(costumes: [any Costume]) -> Bool {
  for costume in costumes {
    if !hasBells(costume) { // OK: C が costume の underlying type に束縛される
      return false
    }
  }
  return true
}

ループの各反復で、その時点の costume が持つ具象型に C が束縛されます。反復ごとに具象型が異なっていても構いません。

any から some への移行

SE-0341 の some パラメータと組み合わせると、この機能は「any を使っているコードをジェネリックに移行する」際の大きな助けになります。some パラメータの関数に any を渡せるようになるため、呼び出し側を一斉に書き換える必要がなくなります。

func hasBells(_ costume: some Costume) -> Bool {
  return costume.hasSameAdornments(as: costume.withBells())
}

func isReadyForFinale(_ costume: any Costume) -> Bool {
  return hasBells(costume) // any Costume を some Costume パラメータに渡せる
}

これにより、「既存の any 引数を some に変えても、その呼び出し元を同時に直さなくてよい」ため、existential trap から段階的に抜け出していけます。

開けるケース・開けないケース

存在型を開いてジェネリックパラメータ T に束縛するには、その呼び出しにおいて T の underlying type が一意に決まる 必要があります。単一の existential 引数が単一のジェネリックパラメータに直接束縛されるケースでは問題なく開けます。

protocol P {
  associatedtype A
  func getA() -> A
}

func openSimple<T: P>(_ value: T) { }

func testOpenSimple(p: any P) {
  openSimple(p) // OK
}

inout パラメータも開けます。呼ばれた側は underlying type の mutating メソッドを呼んだりできますが、existential の箱自体を差し替えて 動的な型 を変えることはできません。

一方、次のようなケースでは開けません。T が underlying type として一意に定まらなかったり、存在する型を取り出せなかったりするためです。

func cannotOpen1<T: P>(_ array: [T]) { }
func cannotOpen2<T: P>(_ a: T, _ b: T) { }
func cannotOpen3<T: P>(_ values: T...) { }
struct X<T> { }
func cannotOpen4<T: P>(_ x: X<T>) { }
func cannotOpen5<T: P>(_ x: T, _ a: T.A) { }
func cannotOpen6<T: P>(_ x: T?) { }

func test(array: [any P], p1: any P, p2: any P, xp: X<any P>, pOpt: (any P)?) {
  cannotOpen1(array)         // 配列の要素ごとに underlying type が違いうる
  cannotOpen2(p1, p2)        // p1 と p2 の underlying type が一致する保証がない
  cannotOpen3(p1, p2)        // 上と同じ
  cannotOpen4(xp)            // X<any P> の中身は個別の existential 値ではない
  cannotOpen5(p1, p2.getA()) // T が 2 箇所で使われている
  cannotOpen6(pOpt)          // nil の可能性があり、underlying type が存在しない
}

any P.Type のような existential metatype も、値の existential と同じ条件で開けます。

結果値の type erase

ジェネリック関数の戻り値が TT 由来の associated type を含む場合、呼び出しが終わった時点で 共変位置(covariant position) に現れる型は元の existential 相当の上限(upper bound)へ type erase されます。SE-0309 の「associated type の共変 erase」と同じルールです。

protocol Q {
  associatedtype B: P
  func getB() -> B
}

func decomposeQ<T: Q>(_ value: T) -> (T, T.B, T.B.A) {
  (value, value.getB(), value.getB().getA())
}

func testDecomposeQ(q: any Q) {
  let (a, b, c) = decomposeQ(q) // a: any Q, b: any P, c: Any
}

戻り値の型が T?[T.B] のような共変位置であれば erase して (any Q)?[any P] になり、問題なく開けます。一方、X<T> のような不変の位置に T が現れる場合は erase できず、そもそも開くことができません。

erase の際、「upper bound が Swift の existential type では表現しきれない制約を持つ」場合は情報が欠落します。例えば B.A == Int という制約があっても、単純な any P には書き下せず any P に落ちます(SE-0353 の制約付き existential が適用できればより精密になります)。このようにオーバーロード解決に影響しうる制約が失われるケースでは、明示的に as any P を書くことが要求されます

protocol P { associatedtype A }
protocol Q { associatedtype B: P where B.A == Int }

func getBFromQ<T: Q>(_ q: T) -> T.B { ... }

func eraseQAssoc(q: any Q) {
  let b = getBFromQ(q) as any P // 制約の欠落を明示する
}

将来 existential type の表現力が増しても、既存コードの意味がうっかり変わらないようにするための安全策です。

関数型パラメータの反変 erase

戻り値は共変 erase ですが、T を含む クロージャ引数 のような反変位置では、逆方向の erase が行われます。つまり、T を参照する関数型パラメータは any P へ erase されます。

func acceptValueAndFunction<T: P>(_ value: T, body: (T) -> Void) { }

func test(p: any P) {
  acceptValueAndFunction(p) { innerValue in
    // innerValue: any P
  }
}

これにより、開いた型の名前がクロージャ引数の型として外に漏れ出さないようになっています。

ただし一つ例外があり、関数型の引数として 別のジェネリック関数への参照 を渡す場合は erase されず、その関数のジェネリックパラメータへもそのまま underlying type を束縛します。結果として、同じ existential を複数のジェネリック関数に「開いたまま」渡せます。

func takeP<U: P>(_: U) { }

func implicitOpeningArguments(p: any P) {
  acceptValueAndFunction(p, body: takeP) // T も U も p の underlying type に束縛される
}

評価順序に関する制限

existential を開くには、その引数の式を実際に評価して箱の中身を覗く必要があります。Swift は引数を左から右へ評価するため、後ろの引数を開いた結果を、その前の引数の型付けに使うことはできません。具体的には、開こうとしている existential 引数に束縛される T が、より前の引数の型にも使われている場合は開けません

func acceptFunctionStringAndValue<T: P>(body: (T) -> Void, string: String, value: T) { }

func implicitOpeningArgumentsBackwards() {
  acceptFunctionStringAndValue(body: takeP, string: hello(), value: getP()) // error
  // T が value より前の body で既に使われているため、value を開くと左→右の評価順序が壊れる
}

Swift 5 と Swift 6 での差

Swift 5 では、existential type 自身がジェネリックパラメータの制約を満たす場合、あえて開かず従来通り existential を束縛します。これは互換性のための配慮で、対象になるのは次のような特殊なケースです。

  • 制約が付いていないジェネリックパラメータ(T に束縛できる型は何でもよい場合)
  • any ErrorError 制約に渡す場合(SE-0235 で any Error: Error
  • static 要件を持たない @objc プロトコル Qany QQ 制約に渡す場合
func acceptsBox<T>(_ value: T) -> Any { [value] }

func passBox(p: any P) {
  let result = acceptsBox(p)
  // Swift 5: T は any P に束縛、返り値は [any P]
  // Swift 6: T は underlying type に束縛、返り値は [T]
}

Swift 6 言語モードでは、この抑制ルールは適用されず、上のケースでも常に開くようになります。あわせて、upcoming feature flag ImplicitOpenExistentials(Swift 6.0 で実装)を有効にすると、Swift 5 モードのまま Swift 6 相当の挙動を先取りできます。

as any P による開きの抑制

Swift 6 での挙動を明示的に抑えたいときや、オーバーロード解決を従来の意味に固定したいときは、引数に as any P / as! any P を付けると implicit opening が抑制されます。

func f1<T: P>(_: T) { }   // #1
func f1<T>(_: T) { }      // #2

func test(p: any P) {
  f1(p)            // 開いて #1 を選ぶ
  f1(p as any P)   // 開かない。#2 が選ばれる
  f1((p as any P)) // 余計な括弧で抑制を無効化。開いて #1 を選ぶ
}

括弧で囲むと抑制が解除されるので、「制約の欠落を明示するための as any P」と「開きを抑制するための as any P」が衝突したとき(前節の getBFromQ(q) as any P のようなケース)は、さらに外側にもう一組括弧を付けることで両立できます。

Future Directions

この提案は、existential を開いた結果を呼び出しのスコープに閉じ込め、戻り値は即座に type erase する設計になっています。将来的には、例えば let opened = value as some P のような構文で開いた型をローカル変数に束縛し、同じスコープ内で複数の文にまたがって使えるようにする「明示的な開き」も検討されています。この場合は開いた型自体が型システムのユーザから見える概念になるため、どこまで踏み込むかはまだ議論の余地があり、あくまで speculative な見通しで、本提案で採択されたものではありません。