Suppressed Default Conformances on Associated Types With Defaults
01 何が問題だったのか
SE-0427(Noncopyable Generics)によって、プロトコルやジェネリックパラメータの位置で ~Copyable / ~Escapable を書いて暗黙の適合要件を抑制できるようになりました。しかし、associated type宣言の位置ではこの抑制が許されておらず、プロトコルのassociated typeには常に暗黙の Copyable / Escapable 要件が入ってしまっていました。
たとえば次のような Queue プロトコルを考えます。Queue 自身は ~Copyable を宣言していて、non-copyableな型からも適合できるはずです。
protocol Queue<Element>: ~Copyable {
associatedtype Element
associatedtype Allocator = DefaultAllocator
init()
init(alloc: Allocator)
mutating func push(_: Element)
mutating func pop() -> Element
}
ところが、Element を non-copyable な型で満たそうとするとコンパイルエラーになります。
// error: LinkedList does not conform to Queue
// note: Element is required to be Copyable
struct LinkedList<Element: ~Copyable>: ~Copyable, Queue {
// ...
}
SE-0427 のルールでは、associated type Element に対しても暗黙の Copyable / Escapable 要件が入ってしまい、しかもそれを抑制する構文がなかったためです。Queue 自身や LinkedList 側で ~Copyable を書いても、associated typeの位置の暗黙要件までは緩められません。
これは単なる書きにくさではなく、non-copyableやnon-escapableな型を使うプロトコルを そもそも設計できない という表現力の制約でした。non-copyable / non-escapableな値を扱うコレクションやリソース管理系のプロトコルを作ろうとすると、必ずこの壁に突き当たってしまいます。
02 どのように解決されるのか
~Copyable / ~Escapable による抑制構文を、associated type宣言の位置にも拡張します。これによってassociated typeの暗黙の Copyable / Escapable 要件を取り除き、non-copyable / non-escapableな型で witness できるプロトコルが書けるようになります。
// 正しい Queue プロトコル
protocol Queue<Element>: ~Copyable {
associatedtype Element: ~Copyable
associatedtype Allocator: ~Copyable = DefaultAllocator
init()
init(alloc: consuming Allocator)
mutating func push(_: consuming Self.Element)
mutating func pop() -> Self.Element
}
これで LinkedList<Element: ~Copyable> も Queue に適合できます。Allocator も ~Copyable が指定されているので、デフォルト witness として使われる DefaultAllocator は copyable でもnon-copyableでもかまいません。以下、~Copyable についての説明は ~Escapable にもそのまま当てはまります。
associated typeへの要件は、次の3つの位置のどこに書いても等価です。
protocol P { associatedtype A: ~Copyable }
protocol P { associatedtype A where Self.A: ~Copyable }
protocol P where Self.A: ~Copyable { associatedtype A }
primary associated type とデフォルトの復活
SE-0427 と同じく、本提案も progressive disclosure の考え方に従います。ライブラリ作者が ~Copyable に対応しても、一般の利用者が暗黙の Copyable 要件をいちいち意識せずに済むよう、ジェネリック要件として使われる位置では Copyable がデフォルトで復活する ようになっています。
そのうえで、associated typeに関しては次のルールが適用されます。
- primary associated type(プロトコル宣言の山括弧に書かれるもの)を抑制している場合、そのプロトコルを要件としてジェネリック要件の位置で使うと、primary associated typeに対する
Copyable/Escapableのデフォルト要件が復活する - ordinary associated type(ただのassociated type)では、デフォルトは復活しない
たとえば次の Buffer では、Data が primary、Parser がordinaryです。
protocol Buffer<Data>: ~Copyable {
associatedtype Data: ~Copyable
associatedtype Parser: ~Copyable
// ...
}
// 暗黙に: B: Copyable, B.Data: Copyable
func read<B: Buffer>(_ bytes: [B.Data], into: B) { ... }
B.Data はデフォルトで Copyable になりますが、B.Parser はそうなりません。primary associated typeは「呼び出し側が指定する型」として使われることが多く、ordinary associated typeは各conformerの実装詳細として扱われるのが普通、という SE-0346 以来の区別に沿った設計です。これによって、protocol extensionがordinary associated type経由でnon-copyableな型を排除してしまうのを防げます。
// 暗黙に: where Self: Copyable, Self.Data: Copyable
extension Buffer {
// valid は Parser が non-copyable な conformer にも提供される
func valid(_ bytes: [UInt8]) -> Bool { ... }
}
デフォルトが入った位置でも、必要なら改めて ~Copyable を書けば抑制できます。
// 暗黙に: where Self: Copyable
extension Iterable where Element: ~Copyable {}
// デフォルトを完全に外す
extension Iterable where Self: ~Copyable, Element: ~Copyable {}
デフォルトが固定されるタイミング
デフォルト要件は、宣言のgeneric signatureが確定した時点で固定されます。いったん固定されたあとは、そのシグネチャに依存する別の宣言側から抑制し直すことはできません。
protocol Pushable<Element> {
associatedtype Element: ~Copyable
}
struct Stack<Scope: Pushable> {} // Scope.Element: Copyable に固定
func push<Val>(_ s: Stack<Val>, _ v: Val)
where Val.Element: ~Copyable // error
{}
Stack のシグネチャ構築時に Scope.Element: Copyable が入って固定されるため、push 側で Val.Element を ~Copyable に戻そうとしても通りません。
一方、同じ宣言のなかでequality制約を経由して抑制が伝わる場合は許されます。
protocol Iterable<Element>: ~Copyable {
associatedtype Element: ~Copyable
}
struct Cursor<Value>: Iterable<Value> where Value: ~Copyable {}
Cursor の宣言内では Element == Value と Value: ~Copyable が同時に成り立つので、Iterable<Value> の primary associated typeから来るデフォルトがキャンセルされます。
プロトコル継承
base protocolで抑制されているassociated typeは、派生プロトコルでも基本的に抑制が引き継がれます。ただし次のどちらかに当てはまる場合、派生プロトコル側で Copyable がデフォルトとして復活します。
- そのassociated typeが base protocolの primary associated type である
- 派生プロトコル側でそのassociated typeを再宣言している
protocol Base<A> {
associatedtype A: ~Copyable
associatedtype B: ~Copyable
}
// ケース 1: A は Base の primary なのでデフォルトが復活
protocol Derived1: Base {
// A は Copyable、B は ~Copyable
}
// ケース 2: B を再宣言するとデフォルトが入り直す
protocol Derived2: Base {
// A は Copyable、B は Copyable
associatedtype B
}
// ケース 1 で復活したデフォルトを派生側で再抑制することもできる
protocol Derived3: Base where Self.A: ~Copyable {
// A も B も ~Copyable
}
ordinary associated typeを派生プロトコルでprimaryに昇格させた場合、その派生プロトコル自身ではデフォルトは入りませんが、さらにその下流のプロトコルではデフォルトが入るようになります。
existential
existentialもジェネリック要件の位置と同じ扱いになります。
protocol Source<Element>: ~Copyable {
associatedtype Element: ~Copyable
associatedtype Generator: ~Copyable
}
func ex1(_ s: any Source) {
let e = s.element() // Copyable
let g = s.generator() // ~Copyable のまま
}
// 型パラメータで制約するとデフォルトが外れる
func ex2<R: ~Copyable>(_ s: any Source<R>) {
let e = s.element() // ~Copyable
let g = s.generator() // ~Copyable
}
デフォルト witness の扱い
associated typeには、conformerが型を指定しないときに使われる デフォルト witness を書けます。抑制されたassociated typeであっても、デフォルト witnessが Copyable な型であるからといって、ジェネリック要件として使ったときに Copyable のデフォルトが追加されることは ありません。
protocol Queue<Element>: ~Copyable {
associatedtype Element: ~Copyable
associatedtype Allocator: Alloc & ~Copyable = DefaultAllocator
}
// Q.Allocator は ~Copyable のまま
func createSubQueues<Q: Queue>(_ kind: Q.Type,
n: Int,
with alloc: borrowing Q.Allocator) -> [Q] {
// ...
}
これは、デフォルト witnessが条件付き Copyable 適合を持つ場合(たとえば ForwardIterator<Item> が Item: Copyable のときだけ Copyable)に、利用者から見たデフォルト要件が場所によって変わってしまうのを避けるためです。
ライブラリ進化上の注意
プロトコルは、既存コードが壊れない範囲で新しい要件を追加できます。ただし、~Copyable なprimary associated typeを 後から追加 し、かつそのデフォルト witnessがnon-copyableだと、すでに適合している型から T.New: Copyable のデフォルト要件を満たせなくなって、呼び出し側のコードがコンパイルできなくなることがあります。デフォルト witnessは copyable な型を選ぶ、などの配慮が必要です。
また、既存の ordinary associated typeに後から ~Copyable を追加するのはソース互換性を破る変更です。利用者側で T.Resource を copyable として扱うコードが通らなくなるためです。
条件付き適合の制限
associated typeが抑制可能になったとしても、associated typeの copyability / escapabilityに依存した条件付き Copyable / Escapable 適合は、今回の提案では引き続き許されません。これはランタイム実装上の制約によるものです。
protocol Goose: ~Copyable { associatedtype Quack: ~Copyable }
struct Pond<G: Goose>: ~Copyable {}
extension Pond: Copyable where G.Quack: Copyable {} // error
今後の見通し(Future Directions)
制約付きexistentialに some を使う構文、たとえば any P<some Hashable> や any Q<some ~Copyable> のような書き方が将来の拡張候補として挙げられています。いずれもspeculativeな議論であり、実現が約束されているものではありません。