Swift Digest
SE-0353 | Swift Evolution

Constrained Existential Types

Proposal
SE-0353
Authors
Robert Widmann
Review Manager
Joe Groff
Status
Implemented (Swift 5.7)

01 何が問題だったのか

SE-0335 で existential type には any を明示するようになり、SE-0309 で associated type を持つプロトコルも any で使えるようになりました。さらに SE-0346 によって、プロトコルに primary associated type を宣言して some Collection<Int> のように 主要な associated type を軽量な構文で固定する ことができるようになりました。

しかし SE-0346 の時点では、この軽量構文は opaque type(some)とジェネリック制約に限定されており、existential type(any)には適用できませんでした。結果として、existential type の表現力はジェネリクスと比べて一段劣る状態が続きます。

例えば、イベントの producer と consumer をそれぞれ型消去して保持したい場合、次のようにプロトコルを定義しておきたくなります。

protocol Producer {
  associatedtype Event
  func poll() -> Self.Event?
}

protocol Consumer {
  associatedtype Event
  func respond(to event: Self.Event)
}

struct EventSystem {
  var producers: [any Producer]
  var consumers: [any Consumer]

  mutating func add(_ producer: any Producer) {
    self.producers.append(producer)
  }
}

この書き方だと any Producerany ConsumerEvent が何の型なのかをコンパイラは一切知らないため、producer から得たイベントを consumer に渡すといった組み合わせは安全に書けません。そこで EventSystem をイベント型について generic にすると、今度は producer や consumer を existential で保持できなくなり、次のように個別の type eraser(AnyProducer<Event> / AnyConsumer<Event>)を用意する必要が出てきます。

struct EventSystem<Event> {
  var producers: [AnyProducer<Event>]
  var consumers: [AnyConsumer<Event>]

  mutating func add<P: Producer>(_ producer: P)
    where P.Event == Event
  {
    self.producers.append(AnyProducer<Event>(erasing: producer))
  }
}

欲しいのは「producer / consumer の具象型はなんでもよい(= existential)けれど、扱うイベントの型だけは揃えたい(= 制約)」という表現です。SE-0346 の primary associated type の仕組みを some だけでなく any 側にも開放してほしい、というのがここでの動機です。標準ライブラリの Collection でも、「要素型が Int の任意の Collection」を受け取る関数シグネチャを any で表現する手段が無く、同様のギャップがあります。

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

SE-0346 で導入された primary associated type の軽量構文を、existential type でも使えるようにします。any P<Arg1, Arg2, ...> と書くと、any P かつ P の primary associated type が Arg1, Arg2, ... と same-type 要件で結ばれた existential 型になります。

protocol P<T, U, V> { }

var xs: [any P<B, N, J>] // any P かつ P.T == B, P.U == N, P.V == J と等価

これで冒頭の EventSystem は、type eraser を書かずに次のように表現できます。

protocol Producer<Event> {
  associatedtype Event
  func poll() -> Self.Event?
}

protocol Consumer<Event> {
  associatedtype Event
  func respond(to event: Self.Event)
}

struct EventSystem<Event> {
  var producers: [any Producer<Event>]
  var consumers: [any Consumer<Event>]

  mutating func add(_ producer: any Producer<Event>) {
    self.producers.append(producer)
  }
}

any Producer<Event> は「Producer に適合し、かつ Event associated type が指定の Event と等しい」任意の具象型を保持できる箱として振る舞います。

キャストと型の同一性

非制約の existential と制約付き existential の間、および制約の異なる制約付き existential の間では、キャストが可能です。primary associated type を具体化するキャストは、実行時に associated type を照合します。

var x: any Sequence<Int>
_ = x as any Sequence              // 常に成功
_ = x as! any Sequence<String>     // 実行時に Sequence.Element を確認

型の同一性については、論理的には等価でも構文上の並びが違うと別の型として扱われることがある点に注意します。例えば any P & Q<Int>any P<Int> & Q は、associated type 同士が一致するケースでも異なる型とみなされることがあります。ただし両者の間には暗黙変換が入るため、実用上の支障はほとんどありません。一方、ジェネリック文脈で T == Int のときの any P<T>any P<Int> のように、同じ「形」で書かれた制約付き existential の置換結果は常に同一の型になります。

分散(variance)

標準ライブラリの具象コレクションでは、要素型の共変変換が組み込みで許されています(例えば [NSView][Any] として返す)。一方、制約付き existential は 不変(invariant) として扱われるため、同様の共変変換はできません。

func up(from values: any Collection<NSView>) -> any Collection<Any> {
  return values // error: 不変なので受け付けない
}

これを共変にすると、内部的に Array への再ラップが必要になり、標準ライブラリの ABI に Array 固定の挙動が焼き付いてしまうため、通常のジェネリック型と同じ不変ルールが採用されました。

共変位置での associated type の erase

SE-0309 では、any P の値に対してメンバを呼ぶとき、Self や associated type が 共変位置 に現れていれば、その associated type は upper bound へ erase されることになっていました。例えば Collectionfirst: Element?any Collection に対しては Any? に erase されます。

一方、不変位置 に associated type が現れるメンバは、その型が具体化されていない限り呼び出せませんでした。代表例が RangeReplaceableCollection.append で、any RangeReplaceableCollection に対しては従来エラーになります。

制約付き existential があれば、この壁の一部が外れます。primary associated type を具体化した existential に対しては、その associated type は「もはや不変位置でも問題ない具象型」として扱われるため、該当メンバをそのまま呼べるようになります。

var intCollection: any RangeReplaceableCollection<Int> = [1, 2, 3]
intCollection.append(4) // OK: Element は Int に固定されている

また、戻り値側で Self.Element が登場する共変位置も、より精密に erase されるようになります。

extension Sequence {
  func eagerFilter(_ isIncluded: @escaping (Element) -> Bool) -> [Element] { ... }
  func lazyFilter(_ isIncluded: @escaping (Element) -> Bool) -> some Sequence<Element> { ... }
}

func doFilter(sequence: any Sequence, intSequence: any Sequence<Int>) {
  let e1 = sequence.eagerFilter { _ in true }    // error: Element が不変位置で使われている
  let e2 = intSequence.eagerFilter { _ in true } // OK、戻り値は [Int]
  let l1 = sequence.lazyFilter { _ in true }     // error: 同上
  let l2 = intSequence.lazyFilter { _ in true }  // OK、戻り値は any Sequence<Int>
}

SE-0352 の implicit opening(ジェネリック関数に existential を渡したときの暗黙の「開き」)と組み合わせたときも、戻り値の type erase で制約付き existential が活用されます。

Future Directions

本Proposalは primary associated type を使った単純な same-type 制約に限定しており、汎用的な制約構文(例えば any Collection<.Index == String.Index> のような書き方)や、opaque 型と組み合わせた any Collection<some View>、さらに任意の型パラメータをスコープに開く any<T: View> Collection<T> といった拡張は、今後の検討課題として言及されています。いずれも speculative な見通しであり、本Proposalで採択されたものではありません。