Swift Digest
SE-0185 | Swift Evolution

Synthesizing Equatable and Hashable conformance

Proposal
SE-0185
Authors
Tony Allevato
Review Manager
Chris Lattner
Status
Implemented (Swift 4.1)

01 何が問題だったのか

EquatableHashable は、値の比較や Set の要素・Dictionary のキーとして使うために多くの型が適合すべき基本的なプロトコルです。しかし SE-0185 より前のSwiftでは、これらへの適合を得るために型ごとにボイラープレートを手書きする必要があり、これが Swift で値型を設計するうえでの大きな摩擦になっていました。

struct に対する == の手書き

例えば複数のプロパティを持つ structEquatable にするには、次のようにすべてのプロパティを並べた == を自分で書く必要がありました。

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 は、一定の条件を満たす structenum について、コンパイラが 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 を自分で実装することになります。