Synthesizing Equatable and Hashable conformance
01 何が問題だったのか
Equatable と Hashable は、値の比較や Set の要素・Dictionary のキーとして使うために多くの型が適合すべき基本的なプロトコルです。しかし SE-0185 より前のSwiftでは、これらへの適合を得るために型ごとにボイラープレートを手書きする必要があり、これが Swift で値型を設計するうえでの大きな摩擦になっていました。
struct に対する == の手書き
例えば複数のプロパティを持つ struct を Equatable にするには、次のようにすべてのプロパティを並べた == を自分で書く必要がありました。
struct Person: Equatable {
var firstName: String
var lastName: String
var birthDate: Date
static func == (lhs: Person, rhs: Person) -> Bool {
return lhs.firstName == rhs.firstName &&
lhs.lastName == rhs.lastName &&
lhs.birthDate == rhs.birthDate
}
}
プロパティを追加・削除・変更するたびに == も更新しなければならず、書き漏らしやタイプミスによってバグが入り込みやすいという問題がありました。
enum に対する == はさらに冗長
associated value を持つ enum の == はさらに冗長で、すべてのケースの組を網羅する switch を書く必要がありました。
enum Token: Equatable {
case string(String)
case number(Int)
case lparen
case rparen
static func == (lhs: Token, rhs: Token) -> Bool {
switch (lhs, rhs) {
case (.string(let l), .string(let r)):
return l == r
case (.number(let l), .number(let r)):
return l == r
case (.lparen, .lparen), (.rparen, .rparen):
return true
default:
return false
}
}
}
Hashable はさらに難しい
Hashable への適合には hashValue を自分で実装する必要がありましたが、良く分散するハッシュ関数を書くのは自明ではなく、プロパティ数が増えるほど手抜きになりがちでした。結果としてパフォーマンスが落ちたり、「== で等しい値どうしが同じ hashValue を返す」という契約を破ってしまうリスクもありました。
当時すでに存在していた部分的な合成
Swift は当時から、associated value を持たない(raw value を含む)enum については Equatable / Hashable を自動で導出していました。また SE-0166 / SE-0167 で導入された Encodable / Decodable も、条件を満たせば実装が自動合成される仕組みを持っていました。一方で、普通の struct や associated value を持つ enum にはこの恩恵がなく、もっとも頻繁に欲しくなるケースで手書きを強いられていたのが実状でした。
02 どのように解決されるのか
SE-0185 は、一定の条件を満たす struct と enum について、コンパイラが Equatable / Hashable の要件を自動合成する仕組みを導入します。ユーザーは適合を宣言するだけでよく、== や hashValue を手書きする必要はありません。
オプトインで合成する
合成は オプトイン です。型宣言(または同じファイル内の extension)で Equatable / Hashable への適合を宣言し、要件を自分で実装しなければ、コンパイラが実装を合成します。
struct Person: Equatable, Hashable {
var firstName: String
var lastName: String
var birthDate: Date
}
// == と hashValue はコンパイラが自動生成
enum Token: Equatable, Hashable {
case string(String)
case number(Int)
case lparen
case rparen
}
// ここでも == と hashValue は自動生成
== や hashValue をユーザーが自分で書いた場合は、そちらが優先され、合成は行われません。
適合を別ファイルの extension ではなく「型と同じファイル内」に書かなければならないのは、private / fileprivate なプロパティにもアクセスして memberwise に比較・ハッシュ化する必要があるためです。
合成が行われる条件
struct の場合
stored property だけが対象になります(static プロパティや computed property は無視されます)。合成されるのは次のケースです。
- stored property がひとつもない場合: 常にすべてのインスタンスが等しいとみなされ、同じハッシュ値を持ちます。
- stored property がひとつ以上ある場合: すべての stored property の型が
Equatable/Hashableに適合していれば合成されます。
生成される == は各 stored property を == で比較する memberwise な実装、hashValue は各 stored property のハッシュ値を定義順に混ぜ合わせた値になります(具体的なハッシュ関数は実装詳細として規定されません)。
enum の場合
associated value の型だけが対象になります(computed property は無視されます)。
- ケースがひとつもない
enum(インスタンス化できない)では合成されません。 - ケースがひとつ以上ある場合: すべてのケースの、すべての associated value の型が
Equatable/Hashableに適合していれば合成されます。
生成される == は「同じケースであり、かつ associated value が memberwise に等しい」場合に true を返します。hashValue は「ケースの定義順序(ordinal)」と associated value のハッシュ値を混ぜ合わせた値になります。
associated value を持たない enum の互換性
従来から associated value を持たない enum(raw value 付きを含む)は、宣言がなくても暗黙に Equatable / Hashable に適合していました。この挙動は破壊的変更を避けるため維持されます。この種の enum については、今まで通り : Equatable / : Hashable を書かなくても比較やハッシュ化ができます。
ジェネリクスと条件付き適合
ジェネリック型では、型パラメータに応じて適合条件が変わります。そのような場合は、同じファイル内の extension で conditional conformance を書くことで、条件が満たされるときにだけ合成を働かせられます。
struct Bad<T>: Equatable { // エラー: T が Equatable とは限らない
var x: T
}
struct Good<T> {
var x: T
}
extension Good: Equatable where T: Equatable {} // T: Equatable のときだけ合成される
再帰的な型
再帰的にお互いを参照する型どうしでも、「各型が自分で適合を宣言する」という形を取るため、コンパイラは個別の型について条件を確認するだけで済み、依存グラフ全体を辿る必要がありません。仮にある型が条件を満たさなければ、その型についてのエラーになるだけで、周囲の型には影響が波及しません。
スコープ外のもの
次のケースは SE-0185 の対象外です。
class(継承や参照同一性が絡み、memberwise な比較で意図通りの意味にならないため)- タプル(名前を持たない型への適合の扱いが別途必要になるため)
- stored property の一部を比較・ハッシュから除外する仕組み(キャッシュ用プロパティなど。将来的に
@transientのような属性で拡張される可能性はありますが、本提案のスコープ外です) Comparableの合成(プロパティの定義順に依存させると、並べ替えで挙動が変わってしまうため)
これらが必要な場合は、これまで通り == / hashValue を自分で実装することになります。