はじめに
Dictionary<TKey, TValue>やHashSet<T>は、キーのGetHashCode()とEquals()の結果に依存して要素を探します。この2つの実装が噛み合っていないと、「入れたはずの値が見つからない」「同じキーが重複して入る」といった不具合が起きますが、コンパイルは通り、要素数が少ないテストでは再現しないことも多い点が厄介です。本記事では、実務でよく遭遇する3つの落とし穴を扱います。
前提知識 / 用語整理
-
GetHashCode(): オブジェクトを整数に変換するメソッド。ハッシュ系コレクションは、まずこの値でバケット(格納先)を絞り込む -
Equals(): 同じバケット内で、実際に同じキーかを判定するメソッド -
等価性の契約:
a.Equals(b)がtrueなら、a.GetHashCode() == b.GetHashCode()でなければならない(逆は成り立たなくてよい) -
IEqualityComparer<T>: キーの比較方法を差し替えるためのインターフェース。DictionaryやHashSetのコンストラクタに渡せる
落とし穴1: EqualsだけオーバーライドしてGetHashCodeを実装しない
再現コード
public class ProductCode
{
public string Value { get; }
public ProductCode(string value) => Value = value;
// ❌ Equalsだけオーバーライドしている
public override bool Equals(object? obj) =>
obj is ProductCode other && Value == other.Value;
}
var stock = new Dictionary<ProductCode, int>
{
[new ProductCode("A-001")] = 10
};
var found = stock.TryGetValue(new ProductCode("A-001"), out var count);
// found => false になる場合がある
なぜ起きるか
Dictionaryは最初にGetHashCode()の値でバケットを決め、同じバケット内だけでEquals()を呼びます。GetHashCode()をオーバーライドしないと、既定実装(参照ベースのハッシュ値)が使われるため、値が同じでも別インスタンスなら異なるバケットに入り、Equals()が呼ばれる前に「存在しない」と判定されます。Equalsだけを書いた場合、コンパイラは警告(CS0659)を出しますが、警告を無視するか抑制していると見落とされます。
正しい書き方
// ◎ EqualsとGetHashCodeを必ずセットで実装する
public class ProductCode
{
public string Value { get; }
public ProductCode(string value) => Value = value;
public override bool Equals(object? obj) =>
obj is ProductCode other && Value == other.Value;
public override int GetHashCode() => Value.GetHashCode();
}
// ◎ 値としての等価性だけが必要なら、record(または record struct)に任せる
public record ProductCode(string Value);
record型は、プロパティの値に基づくEqualsとGetHashCodeをコンパイラが自動生成するため、手書きによる実装漏れを避けられます(ただし、recordは参照型のプロパティを含む場合、そのプロパティのEqualsに依存する点に注意してください)。
落とし穴2: キーに使ったオブジェクトを後から変更して検索できなくなる
再現コード
public class UserKey
{
public string Name { get; set; } = "";
public override bool Equals(object? obj) =>
obj is UserKey other && Name == other.Name;
public override int GetHashCode() => Name.GetHashCode();
}
var key = new UserKey { Name = "Taro" };
var dict = new Dictionary<UserKey, string> { [key] = "admin" };
key.Name = "Jiro"; // ❌ キーとして登録済みの状態でプロパティを変更
var found = dict.TryGetValue(key, out var role);
// found => false(Dictionaryの中には存在するのに見つからない)
なぜ起きるか
Dictionaryは登録時点のGetHashCode()の値でバケットを決め、その後は再計算しません。Nameを変更するとGetHashCode()の結果が変わり、検索時に別のバケットを探しに行くため、登録済みの要素に辿り着けなくなります。この要素はCountには含まれ、列挙すれば出てくるにもかかわらず、キー指定では取得も削除もできない状態になり、原因の特定が難しくなります。
正しい書き方
// ◎ キーに使う型は不変(イミュータブル)にする
public record UserKey(string Name);
var key = new UserKey("Taro");
var dict = new Dictionary<UserKey, string> { [key] = "admin" };
// key.Name = "Jiro"; // コンパイルエラーになるため、書き換えそのものが起きない
var renamed = key with { Name = "Jiro" }; // 変更したい場合は新しいインスタンスを作る
ハッシュ系コレクションのキーには、GetHashCode()の結果が変わらない型(string、int、Guid、recordなど)を使うのが原則です。やむを得ず可変オブジェクトを使う場合は、「キーとして登録している間はハッシュ値に影響するプロパティを変更しない」という規約をコメントやレビューで徹底する必要があります。
落とし穴3: 文字列キーの大文字小文字比較をToLower()で済ませる
再現コード
// ❌ キーを小文字化して大文字小文字を無視しようとする
var settings = new Dictionary<string, string>();
settings["Content-Type".ToLower()] = "application/json";
var key = userInput.ToLower();
if (settings.TryGetValue(key, out var value))
{
// ...
}
なぜ起きるか
ToLower()は実行環境のカルチャに依存します。たとえばトルコ語カルチャ(tr-TR)では、"I"を小文字化すると"i"ではなく"ı"(ドットなしのi)になり、"TITLE".ToLower()が"tıtle"になって、英語圏のカルチャで作ったキーと一致しなくなります。開発環境が日本語・英語カルチャの場合は一致して動くため、サーバーのカルチャ設定が異なる環境に配置されて初めて不具合になります。また、比較のたびにToLower()で新しい文字列を割り当てるため、不要なメモリ確保も発生します。
正しい書き方
// ◎ コンストラクタでStringComparerを指定し、比較ルールをコレクション側に持たせる
var settings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Content-Type"] = "application/json"
};
settings.TryGetValue("content-type", out var value); // 大文字小文字を区別せずに一致する
StringComparer.OrdinalIgnoreCaseはカルチャに依存せず、文字コードに基づいて大文字小文字を無視した比較を行うため、実行環境が変わっても同じ結果になります。HTTPヘッダー名・設定キー・識別子のように、カルチャ依存の比較が不要なキーでは、OrdinalIgnoreCaseを選択するのが基本です(ユーザーが読む文字列の並べ替えなど、言語ごとの規則が必要な場面ではCurrentCultureIgnoreCaseを使い分けます)。
ベストプラクティスまとめ
-
Equalsをオーバーライドしたら、必ずGetHashCodeもセットで実装する(CS0659の警告を抑制しない) -
値による等価性だけが必要な型は、手書きせず
record/record structを使う -
Dictionary/HashSetのキーには不変な型を使い、登録後にハッシュ値が変わる変更をしない -
文字列キーの大文字小文字を無視する場合は、
ToLower()ではなくStringComparer.OrdinalIgnoreCaseをコンストラクタに渡す -
独自の比較ルールが必要な場合は、
IEqualityComparer<T>を実装してコレクションに渡す
おわりに
ハッシュ系コレクションの不具合は、要素数が少ない単体テストでは偶然通ってしまい、本番のデータ量や実行環境で顕在化する傾向があります。EqualsとGetHashCodeの契約、キーの不変性、比較ルールの明示という3点を押さえておくと、この種の不具合は設計段階でほぼ防げます。