Improving operator requirements in protocols
01 何が問題だったのか
Swiftではプロトコルに演算子を要求として含めることができましたが、演算子はグローバル関数として定義する必要があったため、実装は必ず型の外側に書かざるを得ませんでした。これは記述のぎこちなさだけでなく、型チェッカのパフォーマンスにも悪影響を与えていました。
型の内側と外側に分かれる演算子実装
たとえば Equatable は、プロトコル定義上は == を要求として持っています。
protocol Equatable {
func ==(lhs: Self, rhs: Self) -> Bool
}
しかし実際に型を Equatable に適合させるときは、== の実装をその型の内側に書くことができず、グローバル関数として型の外側に書く必要がありました。
extension Foo: Equatable {}
func ==(lhs: Foo, rhs: Foo) -> Bool {
// 実装
}
要求は型の内側(プロトコル内)に宣言されているのに、実装は型の外側に書かされるという不整合があり、特に既存の型を extension で演算子専用プロトコルに適合させる場面では違和感の強い書き方になっていました。
グローバル名前空間の肥大化と型チェッカの負荷
Equatable のように演算子を要求するプロトコルに適合する型が増えると、それぞれの型について == のグローバルオーバーロードが追加されることになり、グローバル名前空間に演算子オーバーロードが大量に並ぶ状態になっていました。型チェッカは演算子の呼び出しを解決するたびに、これらすべての候補を突き合わせる必要があり、適合する型が増えるほど型チェックが重くなるという問題を抱えていました。
名前付きメソッドへの委譲という回避策の問題
この型チェッカ負荷を避けるための既存の回避策として、SE-0067: Enhanced Floating Point Protocols の議論で検討されたのが「プロトコルには adding(_:) のような名前付きメソッドを要求として置き、グローバルの + はジェネリックなオーバーロード1つだけでそれらに委譲する」というパターンです。
public protocol FloatingPoint {
func adding(rhs: Self) -> Self
// ほか
}
public func + <T: FloatingPoint>(lhs: T, rhs: T) -> T {
return lhs.adding(rhs)
}
しかしこのアプローチには別の問題があります。
- 本来ユーザーが直接呼ぶことを想定していない
adding(_:)のようなメソッドが、プロトコル要求であるためにすべての適合型の公開インターフェースに現れてしまい、ドキュメントや補完候補が雑多になって、本当に使ってほしい API が埋もれてしまいます。 - 演算子そのものが「その操作を表す用語(term of art)」として十分に定着しているのに、
(2.0).adding(2.0).isEqual(to: 4.0)のようなメソッド呼び出しを2.0 + 2.0 == 4.0と同格に並べるのは収まりが悪く、オーバーロード演算子の存在意義を損ないます。 isLessThanOrEqual(to:)のようなメソッド名を API ガイドラインに沿うよう命名するだけで長い議論が必要になるなど、そもそも良い名前を付けること自体が難しい作業になります。adding(_:)と+のように、同じ操作を表す書き方が2通り存在することになり、利用者に「両者は微妙に違う意味を持つのか」と誤解される恐れもあります。
つまり「型の内側に演算子を書けない」という制約が、構文上の不整合・型チェッカの負荷・API の肥大化という3つの問題を同時に生んでいました。
02 どのように解決されるのか
プロトコルの演算子要求を static メソッドとして宣言できるようにし、適合型側もその演算子を型の内側の static メソッドとして実装できるようにします。あわせて、演算子のルックアップをグローバルスコープだけでなく型・extension 内の static メソッドも含めた「ユニバーサルルックアップ」に変更します。
プロトコル要求としての static 演算子
プロトコル内では、演算子要求を static func として書きます。
protocol Equatable {
static func ==(lhs: Self, rhs: Self) -> Bool
}
この形式は従来のグローバル関数形式(func ==(lhs: Self, rhs: Self) -> Bool をプロトコル内に書くスタイル)の置き換えとして導入され、Swift 3 では従来の非 static な演算子要求構文は削除されます。つまり、プロトコルに演算子要求を置く方法は static メソッドのみに統一されます。グローバル関数として演算子を定義すること自体(プロトコルの要求ではない通常の演算子オーバーロード)は従来どおり可能です。
適合型側の実装
適合型では、演算子を型の内側で static func として実装します(クラスの場合は static または final class メソッド)。
struct Foo: Equatable {
let value: Int
static func ==(lhs: Foo, rhs: Foo) -> Bool {
return lhs.value == rhs.value
}
}
let f1 = Foo(value: 5)
let f2 = Foo(value: 10)
let eq = (f1 == f2)
要求の宣言と実装が同じ型のスコープに収まるようになり、extension で既存型を適合させるときも自然な記述になります。
prefix・postfix・代入演算子
関数シグネチャはグローバル版と同じ形を維持します。prefix / postfix / 代入演算子もそのまま static メソッドとして書けます。
protocol SomeProtocol {
static func +=(lhs: inout Self, rhs: Self)
static prefix func ~(value: Self) -> Self
}
ユニバーサルルックアップと型チェッカの改善
演算子式を解決する際、コンパイラはグローバルスコープに加え、各型およびその extension 内で宣言された static な演算子メソッドも候補として探します。これにより、プロトコル作者が「グローバルのトランポリン関数(+ のジェネリック版から static メソッドに委譲する橋渡し用のグローバル関数)」を自前で用意せずとも、型の内側に書いた演算子がそのまま呼び出せます。
さらにこの提案では、型チェッカの負荷を下げるため、次のようなセマンティックモデルを採用します。演算子ルックアップではプロトコル内に宣言された演算子要求(たとえば Arithmetic の +)もジェネリック関数として候補に含め、そのプロトコル要求を満たすために書かれた各型の static 演算子は候補から除外する、というものです。
プロトコル要求としての演算子は、すべての適合型に対する実装の一般化(ジェネリック版)とみなせます。
<Self: Arithmetic>(Self, Self) -> Self
したがって、個別の適合型ごとのオーバーロードを候補に並べる必要はなく、このジェネリックな要求1つだけを見れば十分です。結果として、冒頭で触れた「adding(_:) のような名前付きメソッドにグローバルの + から委譲する」パターンと同等のパフォーマンス上の効果を、トランポリン関数を書かずに自動で得られるようになります。
クラスと継承
演算子はオペランドの静的型に基づいてディスパッチされます。これは現行のグローバル演算子と同じ制約で、クラスの動的ディスパッチは行われません。そのため、クラスで演算子を実装する際は static または final class とする必要があります。
動的ディスパッチが必要な用途は今回のスコープ外ですが、「サブクラスの演算子の中でスーパークラスの演算子を使う」という典型的な場面は、スーパークラスへのアップキャストで自然に書けます。
class Superclass: Equatable {
var foo: Int = 0
static func ==(lhs: Superclass, rhs: Superclass) -> Bool {
return lhs.foo == rhs.foo
}
}
class Subclass: Superclass {
var bar: String = ""
static func ==(lhs: Subclass, rhs: Subclass) -> Bool {
guard lhs as Superclass == rhs as Superclass else {
return false
}
return lhs.bar == rhs.bar
}
}
演算子名のメソッドに対する制約
演算子名を持つメソッドはユニバーサルルックアップの対象となるため、宣言にいくつかの制約が設けられます。
- クラス・構造体・列挙型・プロトコル内では
static(クラスではfinal classも可)であること。非staticな演算子メソッドはエラーです。 - シグネチャはグローバル演算子関数と同じ規則に従うこと(中置演算子は2引数、prefix / postfix は1引数、など)。
既存コードへの影響
型の内側に static 演算子を書けるようになる部分は純粋な追加機能で、既存コードには影響しません。一方、プロトコル内の演算子要求の書き方が変わるため、従来の非 static 形式で書かれていた標準ライブラリの Equatable などのプロトコルや、それに適合する型は書き直しが必要になります(Swift 3 でのブレーキング変更)。