Swift Digest
SE-0309 | Swift Evolution

Unlock existentials for all protocols

Proposal
SE-0309
Authors
Anthony Latsis, Filip Sakel, Suyash Srijan
Review Manager
Joe Groff
Status
Implemented (Swift 5.7)

01 何が問題だったのか

プロトコルを「型として」使ったもの(= existential 型)は、任意の適合型の値を動的に入れておける箱のような値です。しかし Swift では、プロトコルが次のいずれかに該当すると、プロトコル自体を型として使うこと自体が丸ごと禁止 されていました。

  1. associated type を持っている。
  2. メソッド・プロパティ・subscript・initializer のいずれかの要件の型に、共変(covariant)でない位置Self が現れる。

該当するプロトコルを型として書くと、次のような有名なエラーが出ます。

// 'Identifiable' は associated type 要件を持つ。
public protocol Identifiable {
  associatedtype ID: Hashable
  var id: ID { get }
}

// 'Equatable' は非共変位置(パラメータ位置)に Self を持つ。
public protocol Equatable {
  static func == (lhs: Self, rhs: Self) -> Bool
}

let x: Identifiable = ... // error: Protocol 'Identifiable' can only be used as
                          // a generic constraint because it has Self or
                          // associated type requirements

この制約の何が問題なのか

そもそもこの2つの条件は、本来まったく別の意味を持っています。

  • (1) associated type 要件がある というのは、かつてプロトコル witness テーブルの実装が未完成で、associated type のメタデータを動的に取り出せなかった時代の名残です。技術的に必要だった制約であり、プロトコルの型としての使い勝手そのものに本質があるわけではありません。
  • (2) 非共変位置に Self が現れる のは、その 特定のメンバー を existential 経由で呼び出したときに型安全性が壊れる、という局所的な話です。existential 値の動的な Self 型を外から書き表す手段は「Self を適当な上位型(たとえばそのプロトコル自身)に型消去する」ことしかなく、型消去は共変位置でしか安全に行えません。

実際、プロトコル 拡張 のメンバーについてはすでに「プロトコル全体を禁止する」ではなく「そのメンバーだけ使えない」というオンデマンドなエラーで扱われています。

protocol P {}
extension P {
  func method(_: Self) {}
}

func callMethod(p: P) {
  p.method // error: member 'method' cannot be used on value of protocol type 'P';
           // use a generic constraint instead
}

プロトコル要件(プロトコル本体で宣言された方)のときだけプロトコルごと禁止にするのは、拡張メンバーの扱いと一貫していません。

また、associated type がある場合でも、プロトコル全体の機能のうちの一部Self や associated type にまったく依存しないか、共変な形でしか依存せず、existential からでも安全に呼べることがあります。あるいは Self を返すだけのメソッドのように、共変な Self を自動で型消去することで existential に対しても呼び出せるものもあります。現行ルールは、こうした「本来は使えるはずのメンバー」ごと切り捨ててしまっていました。

さらに、associated type に対して same-type 制約で具体型が固定されているケース(下記)でも、一律に「associated type 要件あり」とみなされて existential として使えない、という直感に反する挙動もありました。

protocol Animal: Identifiable where ID == String {}
extension Animal {
  var name: String { id }
}

let a: Animal = ... // それでも型としては使えない

付随する問題

  • プロトコルに associated type 要件を追加したり、既存プロトコルを他のプロトコルでリファインしたりするだけで、利用側が壊れる(型として使っているコードがコンパイルできなくなる)おそれがあり、ライブラリ進化の観点でも不便でした。
  • AnySequence などの手書きの type-erasing wrapper を書かざるを得ない状況が生じていました。元のプロトコルに合わせて wrapper を追従して保守し続ける必要があり、resilient なモジュールでは特に負担でした。

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

「associated type 要件があるだけで、あるいは非共変な Self を持つ要件があるだけで、プロトコル全体を型として禁止する」という型レベルの制限を撤廃し、代わりに 個々のメンバーごと に existential からアクセス可能かどうかを判定するようにします。拡張メンバーに対して従来から行われていた扱いを、プロトコル要件にも揃える形です。

どのメンバーが existential から使えるか

プロトコル/プロトコル拡張のメンバー(メソッド・プロパティ・subscript・initializer。storage については各 accessor)は、そのメンバーの型(base 型を代入して見たもの)が、Self または Self をルートとする associated type を非共変位置に含まないかぎり existential 値から呼び出せます。

ここでの「共変位置」は次のように定義されます。

  • 関数型の戻り値の位置
  • タプル型の各要素の位置
  • Swift.OptionalWrapped の位置
  • Swift.ArrayElement の位置
  • Swift.DictionaryValue の位置

つまり、Self や associated type がこうした位置だけに現れるメンバーは existential から使える、ということです。

共変な Self と associated type の自動型消去

共変位置の Self はすでに「base となる existential 型そのもの」に自動で型消去されて呼び出せていました。

protocol Copyable {
  func copy() -> Self
}

func test(_ c: Copyable) {
  let x = c.copy() // OK, x の型は 'Copyable'
}

本提案では、これと同じ扱いを Self をルートとする associated type にも拡張します。共変位置に現れる associated type は、そのプロトコル上の制約から決まる上限型(class、プロトコル、プロトコル合成、あるいは Any)に型消去されます。

func test(_ collection: RandomAccessCollection) {
  // func dropLast(_ k: Int = 1) -> SubSequence
  let x = collection.dropLast() // OK, x の型は 'RandomAccessCollection'
}

associated type に「既知の実装」がある場合

associated type が same-type 制約などで具体型に固定されているとき、それは 既知の実装(known implementation) を持つとみなされ、Self をルートとする associated type 参照であってもメンバーアクセスを妨げません。

既知の実装は次のいずれかで与えられます。

  • 明示的な same-type 制約(例: A == Int
  • superclass 制約など、他の経路から associated type に具体的な実装が見つかる場合
protocol IntCollection: RangeReplaceableCollection where Self.Element == Int {}
extension Array: IntCollection where Element == Int {}

var array: any IntCollection = [3, 1, 4, 1, 5]

array.append(9) // OK, 'Self.Element' は 'Int' と分かっている。

superclass 制約と組み合わせた例です。

class Class: P {
  typealias A = Int
}

protocol P {
  associatedtype A
}
protocol Q: P {
  func takesA(arg: A)
}

func testComposition(arg: Q & Class) {
  arg.takesA(arg: 0) // OK, 'A' は 'Int' と分かっている。
}

使えないメンバーの例

associated type やプロトコルを型として使えるようになっても、全ての要件が existential から呼べるわけではない 点には注意が必要です。たとえば Equatable== は両辺の動的型が一致する保証がないため、existential 同士では呼び出せません。

let lhs: Equatable = "Paul"
let rhs: Equatable = "Alex"

lhs == rhs // error

if let ownerName = lhs as? String, let petName = rhs as? String {
  print(ownerName == petName) // OK, false
}

Sequenceenumerated()RangeReplaceableCollectionappend のように、Self や associated type を非共変位置に含むメンバーも existential 経由では呼べません。その場合は、ジェネリック制約に置き換えるとよい、というエラーが出ます。

func printEnumerated(s: Sequence) {
  // error: member 'enumerated' cannot be used on value of protocol type 'Sequence'
  // because it references 'Self' in invariant position; use a conformance constraint
  // instead. [fix-it: printEnumerated<S: Sequence>(s: S)]
  for (index, element) in s.enumerated() {
    print("\(index) : \(element)")
  }
}

let collection: RangeReplaceableCollection = [1, 2, 3]
// error: member 'append' cannot be used on value of protocol type
// 'RangeReplaceableCollection' because it references associated type 'Element'
// in contravariant position; use a conformance constraint instead.
collection.append(4)

関数・subscript のパラメータを起点にしたメンバーアクセスに対しては、コンパイラがそのパラメータをジェネリックパラメータに置き換える fix-it を提案します(ローカルコンテキストなどで generic function が書けない場合は fix-it は出ません)。

適合不能な existential

この変更に伴い、「その型自体に適合する型が原理的に存在しない」existential も書けるようになります。たとえば同じ associated type を別々の具体型に固定するプロトコルどうしを合成した P2 & Q2 のような型です。

protocol P1 { associatedtype A }
protocol P2: P1 where A == Int {}

protocol Q1 { associatedtype A }
protocol Q2: Q1 where A == Bool {}

func foo(_: P2 & Q2) {} // 適合可能な型は存在しない

こうした型を使うコード自体はデッドコードであり実行時に害はありません。ソース互換性のため、これらはエラーではなく、ジェネリックパラメータに対する同種の診断と同じく警告に留めます。

API 進化への効果

プロトコルに(デフォルト実装付きの)新しい要件を追加することが、常にソース互換かつバイナリ互換な変更になります。従来は「associated type 要件や非共変 Self を追加するとプロトコルを型として使っていたコードが壊れる」という落とし穴がありましたが、今後は個々のメンバー単位での判定になるため、既存の利用に影響しません。

Future Directions

本提案自体は generalized existentials に向けた足場づくりの位置付けで、以下のような発展が議論されています(いずれも将来の方向性であり、この提案で実現が約束されるものではありません)。

  • AnyHashableAnyCollection といった標準ライブラリの type-erasing wrapper の実装を、本提案の成果を使って簡素化する。
  • existential 型を generic 引数に渡したときに自動で「開いて」扱えるようにする(self-conforming existentials)。
  • AnyHashableinit(_ box: Hashable) を追加し、自動開放が入るまでのつなぎとする。
  • some P と対をなす any P という明示的な existential 構文を導入する。
  • any Collection<Self.Element == Int> のように existential に制約を付けて書けるようにする。