Tuples Conform to Equatable, Comparable, and Hashable
01 何が問題だったのか
Swift のタプルは、言語組み込みの構造でありながら プロトコルに適合できない という制約がありました。この制約のせいで、要素同士を比較したりハッシュ化したりすれば済むはずの素朴なコードが、そのままでは書けません。
let points = [(x: 128, y: 316), (x: 0, y: 0), (x: 100, y: 42)]
let origin = (x: 0, y: 0)
// error: type '(x: Int, y: Int)' cannot conform to 'Equatable';
// only struct/enum/class types can conform to protocols
if points.contains(origin) {
// ...
}
// error: type '(x: Int, y: Int)' cannot conform to 'Comparable'; ...
let sortedPoints = points.sorted()
// error: type '(x: Int, y: Int)' cannot conform to 'Hashable'; ...
let uniquePoints = Set(points)
contains(_:) や sorted()、Set の初期化といった標準的な操作は、要素が Equatable / Comparable / Hashable に適合していることを要求します。要素の Int 同士は当然これらに適合しているのに、それを束ねたタプルがプロトコルに適合できないという理由だけで、これらの操作がエラーになってしまうのです。
この制約はタプルをフィールドに持つ型にも波及します。次のように、位置情報をタプルで表した型は、そのままでは Equatable や Hashable への自動合成による適合ができません。
struct Restaurant {
let name: String
let location: (latitude: Int, longitude: Int)
}
// error: type 'Restaurant' does not conform to protocol 'Equatable'
extension Restaurant: Equatable {}
// error: type 'Restaurant' does not conform to protocol 'Hashable'
extension Restaurant: Hashable {}
その結果、利用者は「本来ならタプルで十分な軽い集約」にもわざわざ構造体を定義せざるを得ず、タプルが事実上の二級市民になってしまっていました。タプルごとに構造体を作ることはバイナリサイズの増加にもつながり、言語としての一貫性という観点でも望ましくない状況でした。
02 どのように解決されるのか
要素がすべて Equatable / Comparable / Hashable に適合しているタプルに対して、タプル自身もそれらのプロトコルに適合 するようにします。これは「任意のタプルを任意のプロトコルに適合させる」汎用的な仕組みではなく、これら3つのプロトコルに限定した特別扱いです。代わりに、ランタイム側で適合を実装することで、Swift 5.0 / 5.1 / 5.2 向けにビルドされた既存バイナリにもバックデプロイされます。
いずれのプロトコルについても、タプルのラベルは無視 して要素の型と位置だけで比較・ハッシュが行われます。これは SE-0015 で導入されたタプルの比較演算子の挙動を踏襲しています。
Equatable
要素がすべて Equatable なら、タプル自身も Equatable になります。要素のいずれかが Equatable でなければ、そのタプルは Equatable ではありません。
// OK: Int はすべて Equatable
(1, 2, 3) == (1, 2, 3) // true
struct EmptyStruct {}
// error: type '(EmptyStruct, Int, Int)' does not conform to protocol 'Equatable'
(EmptyStruct(), 1, 2) == (EmptyStruct(), 1, 2)
// ラベルは比較に影響しない
(x: 0, y: 0) == (0, 0) // true
Comparable
要素がすべて Comparable なら、タプル自身も Comparable になります。比較は 先頭の要素から順に辞書式 で行われ、等しい要素はスキップして最初に差がついた位置で結果が決まります。すべての要素が等しい場合、< や > は false、<= や >= は true になります。
let origin = (x: 0, y: 0)
let randomPoint = (x: Int.random(in: 1 ... 10), y: Int.random(in: 1 ... 10))
// 先頭要素が 0 対 1..10 なので origin の方が小さい
print(origin < randomPoint) // true
// ラベルは比較に影響しない
(x: 0, y: 0) < (1, 0) // true
Hashable
要素がすべて Hashable なら、タプル自身も Hashable になります。ハッシュ値はすべての要素をハッシャーに順に流し込んで計算されます。これにより、タプルを Set の要素や辞書のキーとして直接扱えます。
let points = [(x: 0, y: 0), (x: 1, y: 2), (x: 0, y: 0)]
let uniquePoints = Set(points)
// タプルをキーにした辞書
var grid = [(x: Int, y: Int): Entity]()
for point in uniquePoints {
grid[point]?.move(up: 10)
}
Equatable と同様にラベルはハッシュ値に影響しないため、ラベル付きで登録したキーをラベルなしのタプルで参照することもできます。
(x: 0, y: 0).hashValue == (0, 0).hashValue // true
grid[(x: 100, y: 200)] = Entity(name: "Pam")
print(grid[(100, 200)]) // Entity(name: "Pam")
Future Directions
speculative な見通しとして、Proposal では次の方向性が示されています。いずれも本 Proposal のスコープ外で、実現を約束するものではありません。
Codableなど他のプロトコルへのタプルの適合や、メタタイプのHashable適合、existential のEquatable/Hashable適合といった、構造的な型に対する同様の拡張。- 将来的にタプルの extension や variadic generics が入り、タプルが通常のプロトコル適合を書けるようになった段階で、標準ライブラリ側で同等の適合を実装する。その時点で新しいコードは標準ライブラリの実装を使い、既存のバイナリは本 Proposal のランタイム実装を使い続ける想定。
現状のステータスについて
本 Proposal はいったん採択されたものの、実装上の問題が見つかって巻き戻され、ステータスは Returned for revision となっています。現在の Swift ではタプルを Equatable / Comparable / Hashable として直接扱うことはできず、引き続き構造体などで包む必要があります。