Enum cases as protocol witnesses
01 何が問題だったのか
Swift のプロトコル適合では、プロトコルの要件(requirement)を満たす実装(witness)は、要件とほぼそのままの形で書かれている必要があります。このため、static プロパティや static メソッドの要件を持つプロトコルに対して、enum の case を witness として使うことはできませんでした。
たとえば、次のようにデコード時のエラーを表現するプロトコルを考えます。
protocol DecodingError {
static var fileCorrupted: Self { get }
static func keyNotFound(_ key: String) -> Self
}
このプロトコルに enum を適合させようとして、要件と同じ名前・同じ引数の case を書いても、case は static な要件の witness とみなされず、コンパイルエラーになっていました。
enum JSONDecodingError: DecodingError {
case fileCorrupted // error: witness にならない
case keyNotFound(_ key: String) // error: witness にならない
}
これは直感に反する挙動です。Swift では enum の case は、文法的にも意味的にも static プロパティや static メソッドと区別がつきません。たとえば associated value を持たない case は Self を返す static な get-only プロパティのように振る舞い、associated value を持つ case は引数を取り Self を返す static 関数のように振る舞います。
enum Foo {
case bar(_ value: Int)
case baz
}
let f = Foo.bar // f は (Int) -> Foo 型の関数
let bar = f(2) // Foo を返す
let baz = Foo.baz // Foo を返す
それにもかかわらず、プロトコル要件のマッチングの場面だけは case が認められないため、回避策として case と static な実装の両方を並べて書く必要がありました。
enum JSONDecodingError: DecodingError {
case _fileCorrupted
case _keyNotFound(_ key: String)
static var fileCorrupted: Self { return ._fileCorrupted }
static func keyNotFound(_ key: String) -> Self {
return ._keyNotFound(key)
}
}
この書き方には次のような問題があります。
- 要件と同じ名前を case に付けられず、
_fileCorruptedのように別名を付けざるを得ません。適切な命名が難しくなります。 - enum の名前空間に case と static 要件の両方が並ぶため、補完候補が乱雑になります。
- 本来書く必要のない橋渡しコードの保守が発生します。
言語の他の場面では case と static プロパティ/メソッドが事実上同じものとして扱われているのに、プロトコル要件のマッチングだけが例外になっている、という一貫性の欠如が問題でした。
02 どのように解決されるのか
enum の case が static な要件の witness となることを認めます。具体的には次の 2 つのルールで、case が要件を満たせるようになります。
- 戻り値の型が enum 自身または
Selfのstaticな get-only プロパティ要件は、associated value を持たない case で満たせます。 - 戻り値の型が enum 自身または
Selfのstatic関数要件は、要件の引数リストと一致する associated value を持つ case で満たせます。
これにより、モチベーションのコードはそのままコンパイルできます。
protocol DecodingError {
static var fileCorrupted: Self { get }
static func keyNotFound(_ key: String) -> Self
}
enum JSONDecodingError: DecodingError {
case fileCorrupted // OK
case keyNotFound(_ key: String) // OK
}
従来どおり static var / static func で手動実装することも可能なので、既存のコードはそのまま動きます。選択肢として case による直接的な witness が加わった、という位置づけです。
マッチングの具体例
引数ラベルや戻り値の型が一致する必要があるため、すべての case が自動的に witness になるわけではありません。次の例を見ると、マッチする条件がわかりやすくなります。
protocol Foo {
static var zero: FooEnum { get }
static var one: Self { get }
static func two(arg: Int) -> FooEnum
static func three(_ arg: Int) -> Self
static func four(_ arg: String) -> Self
static var five: Self { get }
static func six(_: Int) -> Self
static func seven(_ arg: Int) -> Self
static func eight() -> Self
}
enum FooEnum: Foo {
case zero // OK
case one // OK
case two(arg: Int) // OK
case three(_ arg: Int) // OK
case four(arg: String) // NG: ラベルが一致しない(要件は _ arg:)
case five(arg: Int) // NG: プロパティ要件に対して associated value を持つ
case six(Int) // OK
case seven(Int) // OK
case eight // NG: () -> Self 要件に対して引数のない case
}
最後の case eight は、static func eight() -> Self という「引数なしで Self を返す関数」要件には対応しません。Swift では case eight() のように空の引数リストを持つ case を書く方法がないためです。このような要件は、実用的には static var eight として表現するのが自然です。
適用できる場面
この緩和によって、既存の型を簡潔にプロトコルへ適合させられる場面が広がります。たとえば DispatchTimeInterval のような enum を Combine の SchedulerTimeIntervalConvertible に直接適合させる、といった書き方が可能になります。
extension DispatchTimeInterval: SchedulerTimeIntervalConvertible {
public static func seconds(_ s: Double) -> Self {
return DispatchTimeInterval.seconds(Int((s * 1000000000.0).rounded()))
}
// 残りの要件は既存の case がそのまま満たす
}
case と static プロパティ/メソッドの切り替えに関する注意
実装側で witness を case から static プロパティ/メソッドへ(あるいは逆へ)切り替えることは、ABI や name mangling の観点では互換性を保つ変更ではありません。バイナリ互換性を壊したり、クライアントがその case をパターンマッチで使っている場合にはソース互換性も壊れたりするため、ライブラリで一度公開した形を後から入れ替える際には注意が必要です。