Conditional conformances
01 何が問題だったのか
ジェネリック型は、型引数が特定の条件を満たすときにだけ、あるプロトコルに自然に適合します。たとえば Array は、要素型が Equatable のときに限り自分自身も Equatable として振る舞えるのが自然です。ところが Swift 3 までは、制約付きのextensionからプロトコル適合を宣言できないという制限があり、こうした「条件付きの適合」を直接表現する手段がありませんでした。
// Swift 3 まではエラー
extension Array: Equatable where Element: Equatable {
static func ==(lhs: Array<Element>, rhs: Array<Element>) -> Bool { ... }
}
// error: extension of type 'Array' with constraints cannot have an inheritance clause
この制限は、ジェネリクスの合成性に次のような穴を空けていました。
配列の配列が比較できない
標準ライブラリには、要素型が Equatable な配列同士を比較する == 演算子が用意されていました。したがって [Int] == [Int] はコンパイルできます。しかし Array 自体は Equatable に適合していないため、配列の配列を比較する [[Int]] == [[Int]] は通りません。== が個別に用意されているだけでは、ジェネリックアルゴリズムや再帰的な合成には使えなかったのです。
ラッパー型ごとにクラスを量産する必要があった
ジェネリックなアダプタ型を設計するときにも、この制限は深刻でした。標準ライブラリの lazy はその典型です。lazy は引き数の能力(Sequence か Collection か、あるいは双方向・ランダムアクセス可能か)に応じて、より多くの機能を持つラッパーを返したいのですが、条件付きの適合が書けないため、次のように4 種類の別々の型を用意してオーバーロードで振り分けるしかありませんでした。
extension Sequence {
var lazy: LazySequence<Self> { ... }
}
extension Collection {
var lazy: LazyCollection<Self> { ... }
}
extension BidirectionalCollection {
var lazy: LazyBidirectionalCollection<Self> { ... }
}
extension RandomAccessCollection {
var lazy: LazyRandomAccessCollection<Self> { ... }
}
この構造では、より能力の高い型が、より能力の低い型の API をすべて再実装(あるいは転送)する必要があり、重複が膨大になります。ReversedCollection / ReversedRandomAccessCollection や Slice 系の派生型も同じ理由で分裂しており、ライブラリ側にも利用者側にも不必要な複雑さを強いていました。
動的な振る舞いも表現できなかった
実行時の as? による適合チェックも、条件付きの適合が表現できないために不完全でした。ある値を Any として受け取り「これは P に適合するか?」を判断したいときに、たとえ要素型が P に適合する Array であっても、Array 自身が P に適合する手段がない以上、ランタイムもそれを肯定的に答えることができません。ジェネリックな合成が静的にも動的にも途切れてしまうのが問題でした。
02 どのように解決されるのか
struct / enum / class の制約付きextension(where 句付きのextension)が、プロトコル適合を宣言できるようにします。新しい構文は追加されず、これまでエラーになっていた既存の書き方が、そのまま条件付き適合(conditional conformance) として解釈されるようになります。
extension Array: Equatable where Element: Equatable {
static func ==(lhs: Array<Element>, rhs: Array<Element>) -> Bool { ... }
}
この一行で、Array は「要素型が Equatable のとき、かつそのときに限り Equatable」になります。結果として [[Int]] == [[Int]] のような再帰的な比較も自然にコンパイルできるようになります。
型引数が条件を満たさないとき
条件付き適合は、付随する制約が満たされている場面でだけ有効です。制約を満たさない型引数に対しては、そのプロトコルに適合していないものとして扱われます。
func f<T: Equatable>(_: T) { ... }
struct NotEquatable { }
func test(a1: [Int], a2: [NotEquatable]) {
f(a1) // OK: Int は Equatable なので [Int] も Equatable
f(a2) // error: NotEquatable は Equatable ではないので [NotEquatable] も適合しない
}
実行時の振る舞い
条件付き適合は、as? による動的な適合チェックにも反映されます。実行時には、対象の型の型引数が制約を満たしているかが追加でチェックされ、その結果で P への適合が成立するかが決まります。
protocol P {
func doSomething()
}
struct S: P {
func doSomething() { print("S") }
}
// Array は要素型が P のときに限り P に適合
extension Array: P where Element: P {
func doSomething() {
for value in self {
value.doSomething()
}
}
}
func doSomethingIfP(_ value: Any) {
if let p = value as? P {
p.doSomething()
} else {
print("Not a P")
}
}
doSomethingIfP([S(), S(), S()]) // "S" が 3 回出力される
doSomethingIfP([1, 2, 3]) // "Not a P" が出力される
同じプロトコルへの複数の適合は禁止
Swift はもともと「ある型が同じプロトコルに二重に適合すること」を禁止していました。条件付き適合でもこの方針は維持され、条件の違う複数の適合を同じプロトコルに対して宣言することはできません。制約同士が一見排他的に見える場合も同様です。
struct SomeWrapper<Wrapped> {
let wrapped: Wrapped
}
extension SomeWrapper: Equatable where Wrapped == Int { ... }
// error: SomeWrapper already stated conformance to Equatable
extension SomeWrapper: Equatable where Wrapped == String { ... }
重なりうる複数の適合(overlapping conformances)を許すと、ランタイムの動的キャストや曖昧性解決が大きく複雑化するため、本提案ではこの可能性自体を閉じています。将来的に別提案で扱われる可能性はあります。
継承プロトコルへの適合は暗黙に導出されない
通常の(制約なしの)適合宣言では、プロトコルが継承する上位プロトコルへの適合は暗黙に補われます。しかし条件付き適合の場合、どの制約を使って上位プロトコルへ適合させるべきかが一意に決まらないため、上位プロトコルへの適合は自動では導出されません。明示的に書く必要があります。
protocol P { }
protocol Q: P { }
protocol R: P { }
struct X<T> { }
extension X: Q where T: Q { }
extension X: R where T: R { }
// error: X does not conform to protocol P;
// extension X: P where <#constraints#> { ... } を書く必要がある
2 つの適合の制約(T: Q と T: R)が disjoint であるため、片方を選ぶと他方を破ってしまいます。明示的に X: P を、意図する制約(多くの場合は T: P)のもとで宣言するのが正解です。
extension X: P where T: P { }
上位プロトコルへの適合の制約を、より緩いものにしたいケースも多く(たとえば T: R ではなく T: P にしたい)、暗黙に導出すると後から緩めることが API/ABI 破壊になってしまいます。そのため明示を要求するほうが安全、という判断です。
標準ライブラリへの波及
本提案に伴い、標準ライブラリの多くの型に条件付きの Equatable / Hashable 適合が入ります。
extension Optional: Equatable where Wrapped: Equatable { ... }
extension Array: Equatable where Element: Equatable { ... }
extension ArraySlice: Equatable where Element: Equatable { ... }
extension ContiguousArray: Equatable where Element: Equatable { ... }
extension Dictionary: Equatable where Value: Equatable { ... }
extension Optional: Hashable where Wrapped: Hashable { ... }
extension Array: Hashable where Element: Hashable { ... }
extension ArraySlice: Hashable where Element: Hashable { ... }
extension ContiguousArray: Hashable where Element: Hashable { ... }
extension Dictionary: Hashable where Value: Hashable { ... }
extension Range: Hashable where Bound: Hashable { ... }
extension ClosedRange: Hashable where Bound: Hashable { ... }
また、能力差のために分裂していたラッパー型群を一本化する整理も入ります。たとえば ReversedRandomAccessCollection は条件付き適合で ReversedCollection に吸収されます。
extension ReversedCollection: RandomAccessCollection where Base: RandomAccessCollection { }
@available(*, deprecated, renamed: "ReversedCollection")
public typealias ReversedRandomAccessCollection<T: RandomAccessCollection> = ReversedCollection<T>
同じ方向性で Slice、LazySequence、DefaultIndices、Range などの派生型も整理され、標準ライブラリ自体もシンプルになります。
効果
条件付き適合により、「配列の配列を比較する」「Optional を Hashable な場所で使う」といった、従来は書けなかった自然な合成が可能になります。ライブラリ設計者にとっても、能力差ごとに型を量産する必要がなくなり、ひとつのジェネリック型が型引数の能力に応じてスケールする、という表現がそのまま書けるようになります。