SwiftのHashableとは?Equatableとの違いと自動合成の条件・hash(into:)の実装

SwiftのHashableとは?Equatableとの違いと自動合成の条件・hash(into:)の実装

SwiftでSetの要素や辞書のキーに自作の型を使おうとすると、コンパイラは Hashable への準拠を求めてきます。: Hashable と書き足すだけで通る型もあれば、エラーが消えない型もあります。境界を決めているのはSwift 4.1で入った自動合成の条件で、classはそもそも対象外です。本記事では、SE-0185とSE-0206の記述、そしてSwift 6.3.3での実行結果をもとに、準拠の条件・hash(into:)の書き方・契約を破ったときに実際に何が起きるかまでを整理します。

まとめ:SwiftのHashable実装で押さえる6点

  • Hashableは Equatable を継承したプロトコルで、hash(into:) に加えて==の要件も満たす必要があります。条件を満たせば、これらは自動合成されます。契約は「==がtrueになる2つの値は、同じ値を同じ順序でHasherへ渡す」ことです。
  • 自動合成の対象は struct の格納プロパティと enum の連想値までです。静的プロパティと計算プロパティは無視され、classは合成されません。
  • 合成が効くのは型の元の宣言か、同じファイル内のextensionに : Hashable を書いたときだけです。別ファイルのextensionではコンパイルエラーになります。
  • hashValue を自分で実装する書き方はSwift 4.2以降は非推奨で、コンパイラが警告を出します。
  • ==とhash(into:)が食い違うコードは、壊れ方が実行ごとに変わります。同じバイナリを20回動かすと、正常終了した16回の結果が7通りに割れ、残る4回はランタイムで異常終了しました。
  • ハッシュ値はプロセス起動ごとに乱数シードで変わります。DBやキャッシュキーに保存してはいけません。

HashableとEquatableの役割分担

Equatableは「2つの値が等しいか」だけを決めるプロトコルです。Hashableはそれを継承したうえで、値を整数に畳み込む手段を追加します。SetやDictionaryは、まずハッシュ値で格納位置(バケット)を絞り込み、そのうえで候補同士を == で突き合わせます。探索が速いのは前者、結果が正しいのは後者の担当です。

2つのプロトコルの宣言と継承関係

以下は、現行のプロトコル宣言から主要な要件を抜粋したものです。~Copyable と ~Escapable はノンコピー型・ノンエスケープ型も準拠できることを示す抑制付き指定で、通常の struct や enum を書くぶんには意識する必要はありません。

protocol Equatable: ~Copyable, ~Escapable {
    static func == (lhs: borrowing Self, rhs: borrowing Self) -> Bool
}

protocol Hashable: Equatable & ~Copyable & ~Escapable {
    var hashValue: Int { get }              // 要件としては残るが実装は非推奨
    func hash(into hasher: inout Hasher)    // 準拠時に実装するのはこちら
}

Hashableに準拠すると宣言した時点でEquatableの要件も背負うため、==を自分で書く場合はhash(into:)も自分で書くのが原則になります。片方だけ手書きする場合は、もう片方の合成実装と比較・ハッシュの材料が一致しているか確認してください。

等価性とハッシュ値の一致条件

Appleのドキュメントは契約を「Two instances that are equal must feed the same values to Hasher in hash(into:), in the same order.」と書いています。等しい値は同じ値を同じ順序でHasherに渡す、という要求です。渡す順序まで指定されている点が見落とされがちで、hasher.combine(a); hasher.combine(b) と hasher.combine(b); hasher.combine(a) は、aとbが異なれば入力順序が変わり、一般に異なるハッシュ値になります。ただし、衝突しない保証はありません。

逆向きは要求されていません。ハッシュ値が一致しても==がfalseになるのは正常な衝突で、SetもDictionaryもその前提で==による再確認を行います。したがってハッシュ関数に求められるのは一意性ではなく、==の判断材料と一致していることです。

自動合成が効く条件と効かない条件

Swift 4.1で実装されたSE-0185により、条件を満たす型は : Hashable と宣言するだけで==とハッシュの実装が生成されます。提案は合成をオプトインと定めており、宣言を書かない限り合成は起きません(連想値を持たないenumだけは例外で、宣言なしでもHashableになります)。条件は型の種類ごとに違います。

型 自動合成 判定に使われるもの
struct 可 格納インスタンスプロパティ全部がHashable
enum(連想値あり) 可 全caseの連想値がHashable
enum(連想値なし) 可(宣言不要) なし
enum(caseなし) 可 SE-0185では対象外・現行版では合成される
class 不可 継承と参照同一性のため対象外
タプル 不可 SE-0185の対象外

structは格納プロパティだけが対象

SE-0185はstructについて「synthesis of P’s requirements is based on the conformances of only its stored instance properties」と書いています。静的プロパティも計算プロパティも合成の対象外です。次のコードは、合成されたハッシュ値が格納プロパティ a だけをHasherに渡したものと一致することを確かめたものです。

struct S: Hashable {
    let a: Int
    static let shared = S(a: 0)   // 静的プロパティは対象外
    var doubled: Int { a * 2 }    // 計算プロパティも対象外
}

var h = Hasher()
h.combine(3)
print(S(a: 3).hashValue == h.finalize())   // true

つまり合成に任せると、格納プロパティが増えた時点で自動的にハッシュの材料も増えます。あとから内部用のフラグを1つ足しただけで、以前は同一だった2つの値が別物として扱われる場合があります。プロパティを追加するときは、それが同一性の判断材料かどうかをその場で決めてください。

classの自動合成対象外という制約

classに : Hashable と書くだけではコンパイルが通りません。Swift 6.3.3が出すメッセージは次のとおりです。

error: type 'Box' does not conform to protocol 'Hashable'
note: automatic synthesis of 'Hashable' is not supported for class declarations
error: type 'Box' does not conform to protocol 'Equatable'
note: automatic synthesis of 'Equatable' is not supported for class declarations

SE-0185は理由を「We do not synthesize conformances for class types.」と述べ、継承階層では==を動的ディスパッチ可能なインスタンスメソッド経由で実装する必要があること、参照型ではメンバーごとの等価が必ずしも同一を意味しないことを挙げています。final を付けても合成は有効になりません。classで準拠させるには==とhash(into:)を両方手書きします。

extensionの宣言位置と自動合成の制約

SE-0185は合成の宣言位置を「This conformance must be part of the original type declaration or in an extension in the same file」と限定しています。Appleのドキュメントは「original declaration」としか書いていませんが、実際には同一ファイルのextensionでも合成されます。別ファイルに分けた場合だけエラーになります。

// A.swift
struct B { let x: Int }

// B.swift
extension B: Hashable {}

error: extension outside of file declaring struct 'B' prevents
automatic synthesis of 'hash(into:)' for protocol 'Hashable'

型定義をモデル層に置き、準拠をまとめて別ファイルで宣言する設計にしていると、この制約に必ずぶつかります。ファイルを分けたいなら、その型についてhash(into:)を手書きし、==も既存の実装がなければ手書きする必要があります。

hash(into:)の手動実装とHasherの使い方

合成が使えない型、または同一性の定義を自分で決めたい型では、hash(into:)を書きます。Hasherは内部状態を持つstructで、combine(_:) で材料を流し込み、SetやDictionaryが finalize() を呼んで整数を取り出します。hash(into:)の実装側でfinalize()を呼ぶことと、渡されたHasherを別のインスタンスに差し替えることは、標準ライブラリのドキュメントで禁止されています。

import Foundation

final class Account: Hashable {
    let id: UUID
    var displayName: String   // 表示名は同一性の材料にしない

    init(id: UUID, displayName: String) {
        self.id = id
        self.displayName = displayName
    }

    static func == (lhs: Account, rhs: Account) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

hashValueからhash(into:)への移行

Swift 4.1以前は var hashValue: Int を自分で実装していました。SE-0206(Swift 4.2で実装)がHasherを導入し、カスタム実装は非推奨になりました。現在も書けますが、コンパイラが警告を出します。

warning: 'Hashable.hashValue' is deprecated as a protocol requirement;
conform type 'Old' to 'Hashable' by implementing 'hash(into:)' instead
[#DeprecatedDeclaration]

古い記事に出てくる x.hashValue ^ y.hashValue のような排他的論理和での合成は、今は書く必要がありません。SE-0206はこの種の手書き合成が衝突しやすい品質になりがちだった点を、Hasher導入の動機として挙げています。読み取り側で value.hashValue を参照するのは引き続き有効で、非推奨になったのは実装側だけです。

ハッシュに含めるプロパティの選び方

基準は、==で比較している材料をそのままcombineすることです。表示名や更新日時であっても、==で見ているなら含めます。除外してよいのは等価性に関与しない値だけです。なお、==で見ている材料をhash(into:)から省いても契約違反にはなりませんが、異なる値のハッシュがそろいやすくなり衝突が増えます。深刻なのは逆方向で、==では無視している材料をハッシュに渡すと、等しい値から異なるハッシュ入力が生まれます。次章の症状はこちらです。

迷ったら、その型が業務上どの値で「同じもの」と見なされるかを先に決めてください。IDを持つ永続化モデルならIDだけで足ります。値そのものが同一性であるような座標や金額の型なら、全フィールドを渡すのが自然です。

Hashableの契約を破ったときに起きること

==とhash(into:)の不整合は「バグになる可能性がある」と説明されることが多いものの、実際の壊れ方は説明されません。実行してみると、症状は毎回違います。

==とhashが食い違うコードを20回実行した結果

==は a だけを見るのに、hash(into:)は a と b の両方を渡す型を用意しました。契約違反です。

struct Bad: Hashable {
    let a: Int
    let b: Int
    static func == (l: Bad, r: Bad) -> Bool { l.a == r.a }   // b を無視
    func hash(into hasher: inout Hasher) {
        hasher.combine(a)
        hasher.combine(b)   // b を渡してしまっている
    }
}

var s = Set<Bad>()
s.insert(Bad(a: 1, b: 1))
s.insert(Bad(a: 1, b: 2))
print("count:", s.count)

var d = [Bad: String]()
d[Bad(a: 1, b: 1)] = "x"
d[Bad(a: 1, b: 2)] = "y"
print("dict count:", d.count, "lookup:", d[Bad(a: 1, b: 99)] ?? "nil")

Swift 6.3.3(x86_64のmacOS)で swiftc -O を付けてビルドし、生成された同じバイナリを20回連続で実行して、標準出力と終了状態を記録しました。結果は次のように割れました。

結果 回数
ランタイムで異常終了 4
Set 1件 / 辞書 1件 / 検索 y 4
Set 2件 / 辞書 2件 / 検索 nil 3
Set 1件 / 辞書 1件 / 検索 nil 3
Set 2件 / 辞書 2件 / 検索 x 2
Set 1件 / 辞書 2件 / 検索 x 2
Set 2件 / 辞書 1件 / 検索 y 1
Set 1件 / 辞書 2件 / 検索 nil 1

同じバイナリなのに、Setの要素数が1になるか2になるかが実行ごとに入れ替わります。辞書への照会に使う Bad(a: 1, b: 99) は、==では登録済みのキーと等しい値です。等しいキーなのにハッシュへの入力が違うため、x が返る回、y が返る回、nil になる回に割れます。異常終了したときのメッセージは次のとおりです。

Fatal error: Duplicate elements of type 'Bad' were found in a Set.
This usually means either that the type violates Hashable's requirements, or
that members of such a set were mutated after insertion.

ここから導かれる運用上の結論は明確です。契約違反は「たまに変な値が返る」ではなく「再現しない障害」として現れます。手元で数回動かして通っても、別の実行で落ちる余地が残ります。==とhash(into:)は必ずセットで見直してください。可能なら合成に任せるのが最も安全です。

挿入後のハッシュ対象変更による検索不具合

上のメッセージが併記しているもう1つの原因が、挿入後の書き換えです。参照型をキーにすると簡単に起こせます。

final class Key: Hashable {
    var name: String
    init(_ n: String) { self.name = n }
    static func == (l: Key, r: Key) -> Bool { l.name == r.name }
    func hash(into hasher: inout Hasher) { hasher.combine(name) }
}

let k = Key("a")
var d: [Key: Int] = [k: 1]
print(d[k] ?? -1)            // 1

k.name = "b"                 // 挿入後にハッシュ対象を変更
print(d[k] ?? -1)            // -1になる場合がある(結果は保証されない)
print(d.count, d.keys.map(\.name))   // 1 ["b"]

辞書が要素を1件保持し、キーの一覧にも現れていても、そのキーで検索できなくなる場合があります。変更後の検索結果は保証されません。structをキーにすると値はコピーされますが、格納プロパティが参照型なら参照先は共有されます。参照先の変更で等価性やハッシュが変わる設計では、structでも同じ問題が起こります。参照型をキーや要素にするなら、格納中は等価性とハッシュの材料を不変にしてください。let で参照を固定しても、参照先の状態まで不変になるとは限りません。

ハッシュ値がプロセスごとに変わる仕様

SE-0206は標準のハッシュ関数について「the standard hash function uses a per-execution random seed, so that generated hash values will be different in each execution of a Swift program」と定めています。シードはプロセス起動時に乱数で初期化されるため、同じ値でも実行のたびにハッシュ値が変わります。次の例では、f.swiftに struct K: Hashable { let x: Int }; print(K(x: 42).hashValue) を保存して実行しています。表示する数値は出力例であり、同じ数値の再現は保証されません。

$ swift f.swift
5231740228897698620
$ swift f.swift
-1456856474159925683

この乱数化はハッシュ衝突を意図的に起こす攻撃への対策です。現行の実装はSipHash-1-3で、SE-0206は実装詳細がリリースごとに変わりうるとも明記しています。

ハッシュ値を保存してはいけない理由

標準ライブラリのドキュメントは「Hash values are not guaranteed to be equal across different executions of your program. Do not save hash values to use in a future execution.」と警告しています。hashValueを永続キャッシュのキーやDBの識別用カラムに保存すると、再起動後に同じ値から同じキーを再生成できる保証がなくなります。永続化する識別子が必要なら、UUIDや業務上のIDのように自分で決めた値を使ってください。同じ理由で、ハッシュ値をファイル名やURLの一部に使う設計も避けます。

テスト用のハッシュシード固定

ハッシュ値そのものを検証したい場面では、環境変数でシードの乱数化を止められます。SE-0206は「defining the SWIFT_DETERMINISTIC_HASHING environment variable with a value of 1 prior to starting a Swift process」と記述しています。実際に設定すると、2回の実行で同じ値が出ます。

$ SWIFT_DETERMINISTIC_HASHING=1 swift f.swift
8880661182590738257
$ SWIFT_DETERMINISTIC_HASHING=1 swift f.swift
8880661182590738257

ただし本番の挙動とは別物になるため、使うのはデバッグと局所的なテストに限ってください。テストで本当に確かめたいのは値そのものではなく「等しい2つの値のハッシュが一致すること」と「Setに入れたら1件になること」です。その2点なら乱数シードのままで検証できます。

Setが線形探索より速い理由と実測差

Hashableに準拠させる実利は探索コストです。10万件の要素に対して2,000回の存在確認を行い、Arrayの contains とSetの contains を比べました。最適化を有効にしてビルドしたx86_64のMacでの一測定例です。Int型のidを持つItemを使い、照会値は0から1,999までの各整数に37を掛けて100,000で割った余りとし、全件がヒットする条件で比較しています。Setの構築時間は測定に含めていません。

探索方法 2,000回の合計 比
Array.contains(線形探索) 0.110秒 基準
Set.contains(ハッシュ) 0.00029秒 約380倍速い

Arrayは要素数に比例して遅くなり、Setは要素数が増えても平均的な照会コストが変わりません。少数の要素でも、照会回数や比較処理の負荷によって差が出ます。Arrayの contains をSetに置き換えるかは、件数と照会回数、そしてSetの構築費用を込みで測ってから決めてください。「Setのほうが速いから」では判断材料になりません。

重複排除も同じ仕組みで動きます。Set(array) は各要素のハッシュで突き合わせるため、要素の型がHashableでなければコンパイルできません。値の変化を監視するリアクティブな処理でも同種の制約が出ます。Combineの removeDuplicates() やRxSwiftの distinctUntilChanged() は直前の値との比較にEquatableを要求します(Combine(Swift)とは?Publisher・Subscriberの仕組みと2026年の採用判断を解説、RxSwiftとは?6.10.2の現在地とSPM導入・Observable入門)。

SwiftUIのIdentifiableとHashableの使い分け

SwiftUIのListやForEachでは、要素をIdentifiableに準拠させるか、id引数などで識別方法を指定します。値ベースの画面遷移では、遷移に使う値にHashableが必要です。名前が似ているだけで要求は別物です。Identifiableは「安定した識別子を持つ」ことを表すプロトコルで、その識別子の型がHashableであることを要求します。

protocol Identifiable<ID> {
    associatedtype ID : Hashable
    var id: Self.ID { get }
}

つまりIdentifiableに準拠しても、型そのものがHashableになるわけではありません。NavigationLink(value:) と navigationDestination(for:) による値ベースの遷移では、遷移先を表す値自体がHashableである必要があります。準拠していない型を渡すと次のエラーになります。

error: initializer 'init(_:value:)' requires that 'Dest' conform to 'Hashable'
note: where 'P' = 'Dest'

実務では、画面遷移の行き先を表すenumを作り、そこにHashableだけを付けるのが扱いやすい形です。一覧に並べるモデル型にはIdentifiableを付け、必要なら両方に準拠させます。SwiftDataの永続化モデルのように、フレームワーク側が同一性の扱いを決めている型もあります(SwiftDataとは?@Modelの仕組みとCore Dataとの違い・2026年の採用判断)。

よくある質問

Hashableに準拠すればEquatableは書かなくていいですか?

自動合成が効く型なら、: Hashable と書くだけで==も一緒に生成されるため不要です。合成が効かないclassや、同一性を自分で定義したい型では、==とhash(into:)の両方を書きます。片方だけ手書きする場合も、合成される実装と材料が一致していれば問題ありません。==で等しい値が異なる材料をHasherへ渡さないようにしてください。

ハッシュ値とは具体的に何を指しますか?

値を固定長の整数に畳み込んだものです。Swiftでは Int で表され、SetやDictionaryが格納位置を決めるために使います。元の値を復元することはできず、異なる値が同じハッシュ値になること(衝突)も起こります。暗号用途のハッシュとは目的が違い、Swiftの標準ハッシュはあくまでコレクションの高速化のためのものです。

classをSetの要素や辞書のキーにできますか?

できます。ただし自動合成が効かないので、==とhash(into:)を自分で実装します。そのうえで、格納中は比較・ハッシュに使う状態を不変にしてください。let の参照型プロパティでも、参照先の変更には注意が必要です。挿入後に書き換えると、辞書に残っていても取り出せなくなるなど、検索結果が保証されなくなります。

すべてのプロパティをhash(into:)に渡すべきですか?

いいえ。渡してよいのは==で比較している材料までです。==で無視している値まで渡すと契約違反になり、検索結果が壊れます。逆に、==で見ている材料を一部だけ渡す書き方は契約違反ではありませんが、衝突が増えます。ハッシュ計算の負荷は、文字列や配列の大きさだけでなく、カスタム実装の処理内容や呼び出し回数にも左右されます。

Hashableの自動合成はSwiftのどのバージョンから使えますか?

SE-0185がSwift 4.1で実装され、struct と enum の合成が入りました。続くSwift 4.2でSE-0206によりHasherとhash(into:)が導入され、hashValueのカスタム実装が非推奨になりました。本記事の実行結果はSwift 6.3.3(Apple Swift version 6.3.3)で確認しています(Swiftの最新バージョンは6.4|Xcode対応表とバージョン確認・更新手順)。

関連記事

お気に入りに入れた記事の一覧

資料請求

RELATED POSTS 関連記事

目次