SwiftでSetの要素や辞書のキーに自作の型を使おうとすると、コンパイラは Hashable への準拠を求めてきます。: Hashable と書き足すだけで通る型もあれば、エラーが消えない型もあります。境界を決めているのはSwift 4.1で入った自動合成の条件で、classはそもそも対象外です。NSObjectを継承したclassはさらに別の規則に従います。本記事では、SE-0185とSE-0206の記述、Appleのドキュメント、Swift 6.4.0での実行結果をもとに、準拠の条件・hash(into:)の書き方・契約を破ったときに実際に何が起きるかまでを整理します。
まとめ:SwiftのHashable実装で押さえる7点
- Hashableは Equatable を継承したプロトコルで、
hash(into:)に加えて==の要件も満たす必要があります。条件を満たせば、これらは自動合成されます。契約は「==がtrueになる2つの値は、同じ値を同じ順序でHasherへ渡す」ことです。 - 自動合成の対象は struct の格納プロパティと enum の連想値までです。静的プロパティと計算プロパティは無視され、classは合成されません。
- 合成が効くのは型の元の宣言か、同じファイル内のextensionに
: Hashableを書いたときだけです。別ファイルのextensionではコンパイルエラーになります。 - NSObjectのサブクラスでは、==とhash(into:)ではなく
isEqual(_:)とhashをoverrideします。static func ==を書いてもSetは使いません。 hashValueを自分で実装する書き方はSwift 4.2以降は非推奨で、コンパイラが警告を出します。- ==とhash(into:)が食い違うコードは、壊れ方が実行ごとに変わります。同じバイナリを200回動かすと、正常終了した161回の結果が10通りに割れ、残る39回はランタイムで異常終了しました。
- ハッシュ値はプロセス起動ごとに乱数シードで変わります。DBやキャッシュキーに保存してはいけません。
HashableとEquatableの役割分担
Equatableは「2つの値が等しいか」だけを決めるプロトコルです。Hashableはそれを継承したうえで、値を整数に畳み込む手段を追加します。SetやDictionaryは、まずハッシュ値で格納位置(バケット)を絞り込み、そのうえで候補同士を == で突き合わせます。探索が速いのは前者、結果が正しいのは後者の担当です。
2つのプロトコルの宣言と継承関係
以下は、Swift 6.4.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 | 不可 | 継承と参照同一性のため対象外 |
| NSObjectのサブクラス | 不要(継承済み) | NSObjectの isEqual(_:) と hash |
| タプル | 不可 | 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.4.0が出すメッセージのうち、要点の4行は次のとおりです。
error: type 'Box' does not conform to protocol 'Equatable'
error: type 'Box' does not conform to protocol 'Hashable'
note: automatic synthesis of 'Equatable' is not supported for class declarations
note: automatic synthesis of 'Hashable' is not supported for class declarations
SE-0185は理由を「We do not synthesize conformances for class types.」と述べ、継承階層では==を動的ディスパッチ可能なインスタンスメソッド経由で実装する必要があること、参照型ではメンバーごとの等価が必ずしも同一を意味しないことを挙げています。final を付けても合成は有効になりません。classで準拠させるには==とhash(into:)を両方手書きします。
「同じインスタンスかどうか」だけで区別したいclassなら、参照の同一性をそのまま材料にします。標準ライブラリの ObjectIdentifier はAppleのドキュメントで「A unique identifier for a class instance, actor instance, or metatype.」と説明されている型で、Hashableに準拠しています。
final class Node: Hashable {
var label: String
init(_ l: String) { label = l }
static func == (l: Node, r: Node) -> Bool { l === r }
func hash(into h: inout Hasher) { h.combine(ObjectIdentifier(self)) }
}
let n1 = Node("a"), n2 = Node("a")
var visited: Set<Node> = [n1]
print(visited.contains(n1), visited.contains(n2)) // true false
n1.label = "changed"
print(visited.contains(n1)) // true
ラベルが同じでも別インスタンスは別物として扱われ、格納後にプロパティを書き換えても検索できます。グラフ探索の訪問済み集合のように、値ではなく個体を記録したい場面に向く書き方です。ただしドキュメントは、この識別子の比較が有効なのはインスタンスの生存期間中だけだと注記しています。解放されたオブジェクトの識別子を残しておく使い方はできません。
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'
Swift 6.4.0では、同じ理由で==の合成も止まったことを示すエラーがもう1行並びます。型定義をモデル層に置き、準拠をまとめて別ファイルで宣言する設計にしていると、この制約に必ずぶつかります。ファイルを分けたいなら、その型について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することです。表示名や更新日時であっても、==で見ているなら含めます。除外してよいのは等価性に関与しない値だけです。なお、比較材料の一部を省いても「等しい値には同じハッシュ入力」という条件は満たせますが、Appleのhash(into:)の説明は「The components used for hashing must be the same as the components compared in your type’s == operator implementation.」と、==で比較する材料と同じ材料をハッシュに使うよう求めています。省略は衝突への耐性も低下させます。深刻なのは逆方向で、==では無視している材料をハッシュに渡すと、等しい値から異なるハッシュ入力が生まれます。後の章で扱う症状はこちらです。
迷ったら、その型が業務上どの値で「同じもの」と見なされるかを先に決めてください。IDを持つ永続化モデルならIDだけで足ります。値そのものが同一性であるような座標や金額の型なら、全フィールドを渡すのが自然です。
NSObjectサブクラスとObjective-C連携での注意点
NSObjectは最初からHashableに準拠しており、その実装は isEqual(_:) と hash を呼び出します。既定の isEqual(_:) は同じインスタンスかどうかしか見ません。Swiftの書き方に合わせて static func == を足しても、SetやDictionaryはそれを使いません。
import Foundation
final class P: NSObject {
let id: Int
init(_ i: Int) { id = i }
static func == (l: P, r: P) -> Bool { l.id == r.id }
}
let a = P(1), b = P(1)
print("P == :", a == b) // true
print("P isEqual:", a.isEqual(b)) // false
print("P Set count:", Set([a, b]).count) // 2
Swift 6.4.0で実行すると、a == b と直接書いた箇所では型 P の==が選ばれてtrueになる一方、Setには2件とも入りました。コードの見た目では等しいのに、コレクションの中では別物として扱われます。単体テストで a == b だけを確かめていると気づけない不具合です。
hash(into:)を書こうとすると、overrideを付けても付けなくてもコンパイルエラーになります。
error: 'NSObject.hash(into:)' is not overridable; did you mean to override 'NSObject.hash'?
正しい書き方は、エラーの案内どおり isEqual(_:) と hash の2つをoverrideすることです。Appleのドキュメントは isEqual(_:) について「If two objects are equal, they must have the same hash value.」「Make sure you also define hash in your subclass.」と書いており、Swiftの==とhash(into:)と同じ契約を、Objective-Cの名前で満たすことになります。
import Foundation
final class Q: NSObject {
let id: Int
init(_ i: Int) { id = i }
override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? Q else { return false }
return id == other.id
}
override var hash: Int { id.hashValue }
}
let c = Q(1), d = Q(1)
print("Q == :", c == d, "Set count:", Set([c, d]).count, "NSSet count:", NSSet(array: [c, d]).count)
// Q == : true Set count: 1 NSSet count: 1
こう書くと、Swiftの==もSetもNSSetも同じ判定になります。c.hashValue と c.hash も一致しました。NSObjectの継承が要らない型なら、structにするか純Swiftのclassにして==とhash(into:)を書くほうが規則が1つで済みます。
なお、NSObjectを継承しない純Swiftのclassを NSMutableSet に入れた場合は、Swift側の==とhash(into:)がそのまま使われ、同じidのインスタンスは1件にまとまりました。一方、Kotlin Multiplatformの共有コードからSwiftのオブジェクトを扱う場合については、Kotlinの hashCode と equals がSwiftのHashableとEquatableの実装を使わないという課題(KT-44613)がJetBrainsに登録されています。2026年11月5日時点で状態は「Submitted」のままです。Swift以外の言語から等価性に頼る設計では、その言語側で比較する値(IDなど)を明示的に渡してください。
Hashableの契約を破ったときに起きること
==とhash(into:)の不整合は「バグになる可能性がある」と説明されることが多いものの、実際の壊れ方は説明されません。実行してみると、症状は毎回違います。
==とhashが食い違うコードを200回実行した結果
==は 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.4.0(x86_64のmacOS)で swiftc -O を付けてビルドし、生成された同じバイナリを200回連続で実行して、標準出力と終了状態を記録しました。結果は次のように割れました。
| 結果 | 回数 |
|---|---|
| Set 2件 / 辞書 1件 / 検索 y | 27 |
| Set 1件 / 辞書 2件 / 検索 nil | 25 |
| Set 1件 / 辞書 1件 / 検索 nil | 23 |
| Set 1件 / 辞書 1件 / 検索 y | 22 |
| 辞書への代入で異常終了 | 21 |
| Setへの挿入で異常終了 | 18 |
| Set 2件 / 辞書 1件 / 検索 nil | 15 |
| Set 1件 / 辞書 2件 / 検索 y | 14 |
| Set 2件 / 辞書 2件 / 検索 nil | 11 |
| Set 2件 / 辞書 2件 / 検索 y | 9 |
| Set 2件 / 辞書 2件 / 検索 x | 8 |
| Set 1件 / 辞書 2件 / 検索 x | 7 |
同じバイナリなのに、Setの要素数が1になるか2になるかが実行ごとに入れ替わります。辞書への照会に使う Bad(a: 1, b: 99) は、==では登録済みのキーと等しい値です。等しいキーなのにハッシュへの入力が違うため、x が返る回、y が返る回、nil になる回に割れます。辞書が1件のときに x が一度も出ないのは、2回目の代入が等しいキーへの上書きとして扱われ、値が y に置き換わったためです。つまり、起こりうる組み合わせはすべて実際に出ました。異常終了は200回中39回で、そのときのメッセージは次のとおりです。
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.
辞書側で落ちた21回は Fatal error: Duplicate keys of type 'Bad' were found in a Dictionary. でした。ここから導かれる運用上の結論は明確です。契約違反は「たまに変な値が返る」ではなく「再現しない障害」として現れます。手元で数回動かして通っても、別の実行で落ちる余地が残ります。==とhash(into:)は必ずセットで見直してください。可能なら合成に任せるのが最も安全です。
NaNを含む型の合成Equatableと反射律の例外
合成に任せても例外が1つ残ります。浮動小数点数のNaNです。Equatableのドキュメントは「a == a is always true (Reflexivity)」を求めていますが、Doubleの==はNaNどうしをfalseと判定し、合成された==もそれをそのまま使います。
struct P: Hashable { let x: Double }
let p = P(x: .nan)
print(p == p, Set([p, p]).count, Set([p, p]).contains(p)) // false 2 false
var d = [P: Int](); d[p] = 1; d[p] = 2
print(d.count, d[p] as Any) // 2 nil
3回実行して3回とも同じ結果で、異常終了はしませんでした。ただし同じ値を2回入れたSetが2件になり、入れた値そのもので検索してもヒットしません。計測値や外部から受け取った数値をキーに含める型では、NaNを取り込む前に弾くか、キーをIDなどの整数に寄せてください。
挿入後のハッシュ対象変更による検索不具合
異常終了のメッセージが併記しているもう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でも同じ問題が起こります。参照型をキーや要素にするなら、格納中は等価性とハッシュの材料を不変にするか、先に示したObjectIdentifierのように不変な材料だけでハッシュしてください。NSObjectの hash のドキュメントも、コレクションに入っている間は値を変えてはいけないと明記しています。
ハッシュ値がプロセスごとに変わる仕様
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) を保存してビルドし、同じバイナリを2回実行しています。表示する数値は出力例であり、同じ数値の再現は保証されません。
$ swiftc f.swift -o f
$ ./f
-7287208545444493686
$ ./f
1961322898644892576
この乱数化はハッシュ衝突を意図的に起こす攻撃への対策です。現行の実装は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 ./f
8880661182590738257
$ SWIFT_DETERMINISTIC_HASHING=1 ./f
8880661182590738257
ただし本番の挙動とは別物になるため、使うのはデバッグと局所的なテストに限ってください。テストで本当に確かめたいのは値そのものではなく「等しい2つの値のハッシュが一致すること」と「Setに入れたら1件になること」です。その2点なら乱数シードのままで検証できます。
Setが線形探索より速い理由と実測差
Hashableに準拠させる実利は探索コストです。10万件の要素に対して2,000回の存在確認を行い、Arrayの contains とSetの contains を比べました。Swift 6.4.0で -O を付けてビルドし、Intel Core i9-9880H(2.3GHz)のMacで1回の起動の中で3回測った結果です。Int型のidを持つItemを使い、照会値は0から1,999までの各整数に37を掛けて100,000で割った余りとし、全件がヒットする条件で比較しています。Setの構築時間は測定に含めていません。
| 探索方法 | 2,000回の合計(3回の範囲) | 比 |
|---|---|---|
| Array.contains(線形探索) | 0.054〜0.062秒 | 基準 |
| Set.contains(ハッシュ) | 0.00039〜0.00046秒 | 約125〜157倍速い |
計測に使ったコードは次のとおりで、swiftc -O perf.swift -o perf でビルドして実行しました。
import Foundation
struct Item: Hashable { let id: Int }
let n = 100_000
let arr = (0..<n).map { Item(id: $0) }
let set = Set(arr) // 構築は計測に含めない
let queries = (0..<2_000).map { Item(id: ($0 * 37) % n) }
func measure(_ body: () -> Int) -> Double {
let start = DispatchTime.now().uptimeNanoseconds
_ = body()
return Double(DispatchTime.now().uptimeNanoseconds - start) / 1e9
}
for run in 1...3 {
let ta = measure { queries.filter { arr.contains($0) }.count }
let ts = measure { queries.filter { set.contains($0) }.count }
print(run, String(format: "Array %.4f秒 / Set %.6f秒 / %.0f倍", ta, ts, ta / ts))
}
同じ処理を別の機会に実行したときは142〜246倍でした。倍率の幅は、同じマシンで測っても実行ごとにこれだけ動くということです。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・Equatableの使い分け
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'
実務では、画面遷移の行き先を表すenumを作り、そこにHashableだけを付けるのが扱いやすい形です。一覧に並べるモデル型にはIdentifiableを付け、必要なら両方に準拠させます。SwiftDataの永続化モデルのように、フレームワーク側が同一性の扱いを決めている型もあります(SwiftDataとは?@Modelの仕組みとCore Dataとの違い・2026年の採用判断)。
onChange(of:)とequatable()が求めるEquatable
Equatableだけを要求するAPIもあります。値の変化を検知する onChange(of:initial:_:) は、宣言が where V : Equatable になっており、Equatableでない型を渡すと次のエラーになります。
error: instance method 'onChange(of:initial:_:)' requires that 'Model' conform to 'Equatable'
もう1つが equatable() です。Appleのドキュメントは「Prevents the view from updating its child view when its new value is the same as its old value.」と説明しており、Equatableに準拠したViewに付けると、==がtrueを返す間は子ビューの更新が抑えられます。
import SwiftUI
struct Row: View, Equatable {
let title: String
let tapCount: Int
static func == (l: Row, r: Row) -> Bool { l.title == r.title }
var body: some View { Text(title) }
}
struct List1: View { var body: some View { Row(title: "a", tapCount: 0).equatable() } }
この例の==は tapCount を見ていません。もし body で tapCount を表示していたら、値が変わっても子ビューの更新が抑えられ、古い数字が残る可能性があります。Hashableの章と同じで、==が無視している値は「変わっても同じもの」として扱われます。==を手書きするときは、画面に出す値を全部比較に含めるか、表示に関係ない値だけを除いてください。
モデル層の同一性の設計は、画面遷移・差分更新・永続化のすべてに効いてきます。既存アプリの改修や新規開発でこの辺りから見直したい場合は、一創のiOSアプリ開発でもご相談を受けています。
よくある質問
Hashableに準拠すればEquatableは書かなくていいですか?
自動合成が効く型なら、: Hashable と書くだけで==も一緒に生成されるため不要です。合成が効かないclassや、同一性を自分で定義したい型では、==とhash(into:)の両方を書きます。片方だけ手書きする場合も、合成される実装と材料が一致していれば問題ありません。==で等しい値が異なる材料をHasherへ渡さないようにしてください。
ハッシュ値とは具体的に何を指しますか?
値を固定長の整数に畳み込んだものです。Swiftでは Int で表され、SetやDictionaryが格納位置を決めるために使います。元の値を復元することはできず、異なる値が同じハッシュ値になること(衝突)も起こります。暗号用途のハッシュとは目的が違い、Swiftの標準ハッシュはあくまでコレクションの高速化のためのものです。
classをSetの要素や辞書のキーにできますか?
できます。Hashable準拠を継承していない純Swiftのclassでは、==とhash(into:)を自分で実装します。NSObjectのサブクラスで等価性を変更する場合は、isEqual(_:) と hash をoverrideします。そのうえで、格納中は比較・ハッシュに使う状態を不変にしてください。let の参照型プロパティでも、参照先の変更には注意が必要です。挿入後に書き換えると、辞書に残っていても取り出せなくなるなど、検索結果が保証されなくなります。個体そのものを区別したいだけなら、ObjectIdentifierで参照の同一性をハッシュする方法が使えます。
NSObjectを継承したクラスでも==を書けば比較できますか?
a == b と直接書いた箇所ではその==が使われますが、SetやDictionaryは isEqual(_:) と hash で判定します。Swift 6.4.0で試すと、==がtrueを返す2つのインスタンスがSetに2件入りました。NSObjectのサブクラスでは isEqual(_:) と hash をoverrideしてください。hash(into:)はoverrideできず、コンパイルエラーになります。
すべてのプロパティをhash(into:)に渡すべきですか?
いいえ。渡すのは==で比較している材料と同じものです。==で無視している値まで渡すと契約違反になり、検索結果が壊れます。逆に、比較材料の一部だけを渡しても等しい値のハッシュ入力は一致しますが、Appleの仕様は==で比較する材料と同じ材料を渡すよう求めています。省略は衝突への耐性も低下させます。ハッシュ計算の負荷は、文字列や配列の大きさだけでなく、カスタム実装の処理内容や呼び出し回数にも左右されます。
Hashableの自動合成はSwiftのどのバージョンから使えますか?
SE-0185がSwift 4.1で実装され、struct と enum の合成が入りました。続くSwift 4.2でSE-0206によりHasherとhash(into:)が導入され、hashValueのカスタム実装が非推奨になりました。本記事の実行結果は、swift.orgが配布するSwift 6.4.0のツールチェーン(Apple Swift version 6.4)で確認しています(Swiftの最新バージョンは6.4|Xcode対応表とバージョン確認・更新手順)。