C# Tips | コレクション・LINQ:比較用EqualityComparer

C# C#
スポンサーリンク

はじめに:「比較用EqualityComparer」は“何をもって同じとみなすか”をコードにする道具

LINQ やコレクションを使っていると、こういう場面がよく出てきます。

「ユーザーIDが同じなら同じユーザーとして扱いたい」
「名前と生年月日が同じなら同一人物とみなしたい」
「大文字小文字を無視して文字列を比較したい」

でも、C# の標準の「等価比較」は、クラスだと「参照が同じかどうか」、文字列だと「大文字小文字を区別して一致するかどうか」です。
ここを自分のルールに変えたいときに使うのが 比較用EqualityComparer(IEqualityComparer<T>) です。

「何をもって同じとみなすか」をクラスとして切り出しておくと、
Distinct、GroupBy、Except、Dictionary、HashSet などで、
“業務的な同一性”をそのまま使えるようになります。


基本:IEqualityComparer<T> とは何か

Equals と GetHashCode をセットで定義する

IEqualityComparer<T> は、ざっくり言うと「T 型同士を比較するルール」を表すインターフェースです。

public interface IEqualityComparer<T>
{
    bool Equals(T? x, T? y);
    int GetHashCode(T obj);
}
C#

Equals で「同じかどうか」を決め、
GetHashCode で「同じものは同じハッシュ値になるように」整数を返します。

重要なのは、この 2 つが矛盾しないことです。
Equals が true を返す 2 つの値は、必ず同じハッシュコードを返さなければいけません。


例1:ユーザーIDで比較する EqualityComparer

モデルクラスを用意する

まず、よくある User クラスを用意します。

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}
C#

標準の比較では、「同じ Id でも、別インスタンスなら別物」です。
これを「Id が同じなら同じユーザー」とみなしたいとします。

IEqualityComparer<User> を実装する

public class UserIdEqualityComparer : IEqualityComparer<User>
{
    public bool Equals(User? x, User? y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (x is null || y is null) return false;
        return x.Id == y.Id;
    }

    public int GetHashCode(User obj)
    {
        return obj.Id.GetHashCode();
    }
}
C#

Equals では null や同一参照を丁寧に扱い、
最後に「Id が同じなら true」としています。
GetHashCode では Id のハッシュコードをそのまま返しています。

ここでの重要ポイントは、「Equals の条件と GetHashCode の計算が“同じキー(ここでは Id)”に基づいていること」です。
これがズレると、Dictionary や Distinct の動きがおかしくなります。


EqualityComparer を LINQ で使う

Distinct に渡して「業務的な重複排除」をする

ユーザー一覧に、同じ Id のユーザーが重複しているとします。

var users = new[]
{
    new User { Id = 1, Name = "Alice" },
    new User { Id = 1, Name = "Alice (duplicate)" },
    new User { Id = 2, Name = "Bob" },
};
C#

標準の Distinct は「参照が同じかどうか」で見てしまうので、全部別物として扱われます。
ここで UserIdEqualityComparer を渡すと、「Id が同じものは 1 つにまとめる」ことができます。

var distinct = users
    .Distinct(new UserIdEqualityComparer())
    .ToList();
C#

この distinct には、Id=1 のユーザーが 1 件、Id=2 が 1 件だけ残ります。

ここでの重要ポイントは、「Distinct、Except、Intersect、GroupBy など、多くの LINQ 演算子は IEqualityComparer<T> を受け取れる」ということです。
“何をもって同じとみなすか”を差し替えられます。

GroupBy に渡して「業務キーでグルーピング」する

例えば、「Id ごとにユーザーをまとめたい」場合。

var groups = users
    .GroupBy(x => x, new UserIdEqualityComparer());
C#

キーに User 自体を渡しつつ、Comparer で「Id が同じなら同じグループ」とみなすことができます。
もちろん、普通に GroupBy(x => x.Id) と書いてもいいですが、
複数プロパティで比較したいときなどに EqualityComparer が活きてきます。


例2:複数項目で比較する EqualityComparer

名前と生年月日が同じなら同一人物とみなす

public class Person
{
    public string Name { get; set; } = "";
    public DateTime BirthDate { get; set; }
}
C#

これを「Name と BirthDate が同じなら同じ人」とみなす Comparer を作ります。

public class PersonIdentityComparer : IEqualityComparer<Person>
{
    public bool Equals(Person? x, Person? y)
    {
        if (ReferenceEquals(x, y)) return true;
        if (x is null || y is null) return false;

        return x.Name == y.Name
            && x.BirthDate == y.BirthDate;
    }

    public int GetHashCode(Person obj)
    {
        return HashCode.Combine(obj.Name, obj.BirthDate);
    }
}
C#

GetHashCode では HashCode.Combine を使うと、複数項目をきれいにまとめられます。

ここでの重要ポイントは、「Equals で使う全ての項目を、GetHashCode にも必ず反映する」ことです。
一部だけをハッシュに使うと、衝突が増えたり、動きが直感とズレたりします。


Dictionary / HashSet での EqualityComparer 利用

Dictionary のキーとして「業務キー」を使う

Dictionary<TKey, TValue> は、キーの比較に IEqualityComparer<TKey> を使います。
コンストラクタで渡すことができます。

var dict = new Dictionary<User, string>(
    new UserIdEqualityComparer());

dict[new User { Id = 1, Name = "A" }] = "First";
dict[new User { Id = 1, Name = "B" }] = "Second";
C#

ここでは「Id が同じなら同じキー」とみなされるので、
2 行目の代入は 1 行目を上書きします。

HashSet も同様です。

var set = new HashSet<User>(new UserIdEqualityComparer());
set.Add(new User { Id = 1, Name = "A" });
set.Add(new User { Id = 1, Name = "B" }); // 追加されない(同じとみなされる)
C#

ここでの重要ポイントは、「Dictionary や HashSet に“業務的な同一性”を持ち込むには、EqualityComparer を渡すのが王道」ということです。


例3:文字列の大文字小文字を無視する Comparer

既製品を知っておく:StringComparer

文字列の比較でよくあるのが、「大文字小文字を無視したい」という要件です。
これは自分で IEqualityComparer<string> を書かなくても、StringComparer という既製品があります。

var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

set.Add("ABC");
set.Add("abc"); // 追加されない(同じとみなされる)
C#

LINQ でも同様に使えます。

var words = new[] { "Apple", "apple", "Banana" };

var distinct = words
    .Distinct(StringComparer.OrdinalIgnoreCase)
    .ToList();
C#

ここでの重要ポイントは、「よくあるパターン(文字列の比較など)は、まず既製の Comparer がないか探す」ということです。
自分で書くのは、それでも足りないときで十分です。


EqualityComparer をユーティリティとしてまとめる

静的プロパティで「共通の比較ルール」を共有する

毎回 new UserIdEqualityComparer() と書くのは少し面倒なので、
クラス側に静的プロパティとして持たせるのもよくあるパターンです。

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    public static IEqualityComparer<User> IdComparer { get; }
        = new UserIdEqualityComparer();
}
C#

使う側はこう書けます。

var distinct = users.Distinct(User.IdComparer).ToList();
var set = new HashSet<User>(User.IdComparer);
C#

ここでの重要ポイントは、「Comparer を“どこに置くか”も設計の一部」ということです。
ドメインに密着した比較ルールなら、モデルクラスの近くに置いておくと見通しが良くなります。


まとめ:「比較用EqualityComparer」は“業務ルールをそのまま比較ロジックにする”ための鍵

比較用EqualityComparer の本質は、

「何をもって同じとみなすか」という業務ルールを、
IEqualityComparer<T> としてコード化し、
LINQ や Dictionary / HashSet に差し込めるようにする

ことです。

押さえておきたいポイントは次の通りです。

Equals と GetHashCode をセットで実装し、同じキーに基づかせる
Distinct / GroupBy / Except / Intersect など、多くの LINQ 演算子が Comparer を受け取れる
Dictionary / HashSet に Comparer を渡すと、「業務キーでの重複判定」ができる
複数項目で比較するときは HashCode.Combine を使うと書きやすい
よくあるパターン(文字列の大文字小文字無視など)は StringComparer など既製品も活用する

ここまで理解できていれば、
「なんとなく標準の比較に任せる」段階から抜け出して、
“業務の同一性”をそのままコードに落とし込める C# エンジニアに近づけます。

タイトルとURLをコピーしました