C# でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet)

C# でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet)

DataAdapter で取ってきた DataTableList<T> に詰め替えて、画面に表示するためにマージしたら 同じ顧客IDが2回出てくる ──。業務系で List の重複を取り除く処理は、毎週のように出てくる地味な悩みどころだ。Linq の Distinct を素直に呼んだら、カスタムクラスのインスタンスが意図通り潰れない。GroupBy で書き直したら今度は性能が落ちた。HashSet を使うと IEquatable<T> を実装しないとダメで気が重い。

俺もこのあたりで何回も詰まったので、今回は C# の List から重複を一意にする3つの書き方(Distinct / GroupBy / HashSet) を、コピペで動くコードと選び方の判断軸つきで整理しておく。.NET Framework 4.7.2 / C# 7.3 で動く書き方を中心に、モダンC#(レコード型)の補足も末尾に置く形で書く。

目次

結論:3パターンの使い分けはほぼ「キーの形」で決まる

先に答えを置いておく。3つの書き方の使い分けは、どんなキーで一意にしたいか で決まる。

  1. Distinct ──「全プロパティが完全一致」の単純重複(値型・string)に最強
  2. GroupBy + Select(g => g.First()) ──「特定のキーだけで一意」「同じキーの中で代表行を選びたい」業務系の定番
  3. HashSet<T> ── 大量データ(万件以上)を高速に潰したい時、または重複検知に使う

逆に言うと、List<Customer> を「顧客ID で一意にして最新の1件を残す」ような業務系の典型処理は、ほぼ GroupBy 一択。Distinct を IEqualityComparer ありきで書くより、GroupBy の方が読み手にも意図が伝わる。

パターン1: Distinct ── 単純な重複を1行で潰す

Distinct() は Linq の中で一番シンプル。値型・string のリストならそのまま動く。

using System.Linq;

var ids = new List<int> { 1, 2, 2, 3, 3, 3, 4 };
var unique = ids.Distinct().ToList();
// 結果: [1, 2, 3, 4]

var names = new List<string> { "tanaka", "yamada", "tanaka", "suzuki" };
var uniqueNames = names.Distinct().ToList();
// 結果: ["tanaka", "yamada", "suzuki"]

ここまでは何の問題もない。

罠:参照型のリストで Distinct は動かない

問題はカスタムクラスのリスト。

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
}

var customers = new List<Customer>
{
    new Customer { Id = 1, Name = "田中" },
    new Customer { Id = 1, Name = "田中" },  // 同じ内容
    new Customer { Id = 2, Name = "山田" },
};

var unique = customers.Distinct().ToList();
Console.WriteLine(unique.Count);
// 期待: 2
// 実際: 3 ← 重複が消えていない

Distinct は 既定で Object.ReferenceEquals ベースで比較する ので、new Customer {...} で別々に作ったインスタンスは「中身が同じでも別物」と判定される。これが Distinct の最大のハマりポイントで、俺もこれで30分溶かしたことがある。

対処は2つ。

// 対処A: IEqualityComparer<T> を渡す
public class CustomerComparer : IEqualityComparer<Customer>
{
    public bool Equals(Customer x, Customer y) =>
        x?.Id == y?.Id && x?.Name == y?.Name;
    public int GetHashCode(Customer obj) =>
        (obj?.Id, obj?.Name).GetHashCode();
}

var unique = customers.Distinct(new CustomerComparer()).ToList();
// 対処B: クラス側で IEquatable<Customer> を実装
public class Customer : IEquatable<Customer>
{
    public int Id { get; set; }
    public string Name { get; set; }

    public bool Equals(Customer other) =>
        other != null && Id == other.Id && Name == other.Name;
    public override bool Equals(object obj) => Equals(obj as Customer);
    public override int GetHashCode() => (Id, Name).GetHashCode();
}

var unique = customers.Distinct().ToList();  // これで動く

欠点: どちらの対処も「比較ロジックを別の場所に書く」必要があって、ぱっと見の可読性が落ちる。「キーで一意にしたい」だけなら次の GroupBy のほうが読みやすい。

パターン2: GroupBy + Select ── キーで一意にする業務系の定番

業務系のリストで圧倒的に出番が多いのがこれ。「顧客ID で一意にしたい」「同じキーの中で最初の1件 or 最新の1件を残したい」みたいなシナリオは、Distinct より GroupBy のほうが意図が直接出る。

var customers = new List<Customer>
{
    new Customer { Id = 1, Name = "田中", UpdatedAt = new DateTime(2025, 1, 10) },
    new Customer { Id = 1, Name = "田中(旧)", UpdatedAt = new DateTime(2024, 6, 1) },
    new Customer { Id = 2, Name = "山田", UpdatedAt = new DateTime(2025, 2, 5) },
};

// Id で一意にして「最新の UpdatedAt の1件」を残す
var unique = customers
    .GroupBy(c => c.Id)
    .Select(g => g.OrderByDescending(c => c.UpdatedAt).First())
    .ToList();

// 結果: Id=1 の田中(最新), Id=2 の山田

IEqualityComparerIEquatable<T> も実装せずに済むのが大きい。「キーで束ねて、その中で代表を選ぶ」という発想は業務系で再利用しやすく、保守する人にも意図が伝わる。

複合キーで一意にしたい時

// 顧客ID + 取引日 で一意にする
var unique = transactions
    .GroupBy(t => new { t.CustomerId, t.TransactionDate })
    .Select(g => g.First())
    .ToList();

匿名型を GroupBy のキーに渡すと、C# が自動で Equals / GetHashCode を生成してくれる。複合キーの一意化はこれが一番楽

欠点: 大量データ(数十万件以上)を一意化する場面では、内部でキー → リストの辞書を作るので、メモリ使用量が次の HashSet より多くなる。100件・1000件レベルなら気にする必要なし。

パターン3: HashSet<T> ── 大量データ・重複検知に使う

HashSet<T> 経由は、性能が一番効く書き方。ただし用途が少しズレている。

// 単純な重複排除
var ids = new List<int> { 1, 2, 2, 3, 3, 3, 4 };
var unique = new HashSet<int>(ids).ToList();
// 結果: [1, 2, 3, 4]

string に対して 大文字小文字を無視して 一意化したい時は StringComparer を渡せる。

var emails = new List<string> { "Foo@example.com", "foo@example.com", "Bar@example.com" };
var unique = new HashSet<string>(emails, StringComparer.OrdinalIgnoreCase).ToList();
// 結果: ["Foo@example.com", "Bar@example.com"]

カスタムクラスでも IEqualityComparer<T> を渡せば動く。Distinct と書き味は近いが、重複検知(追加して既にあったかを true/false で受け取る) という使い方が HashSet 独自の強み。

var seen = new HashSet<int>();
foreach (var id in inputIds)
{
    if (!seen.Add(id))
    {
        Console.WriteLine($"重複検知: {id}");
    }
}

HashSet.Add()追加に成功したかを bool で返す ので、ループの中で「初めて見た値か / 既に見た値か」を分岐できる。Distinct や GroupBy では取れないこの挙動が、ログ・監査・バッチ処理で効く。

欠点: 順序が保持されない。HashSet<T> の列挙順は実装依存で、入力順と一致しない場合がある。順序が重要な業務系の表示用途には向かない。

機能比較表

観点 Distinct GroupBy + Select HashSet<T>
値型・string の単純重複 ◎ 1行で書ける ○ 書けるが冗長 ◎ 高速
参照型・カスタムクラス △ Comparer or IEquatable 要 ◎ キー指定で直感的 △ Comparer or IEquatable 要
「キーで一意化」 △ Comparer 必須 ◎ 直感的 △ Comparer 必須
同キー内で代表行を選ぶ × 不向き g.First() / g.OrderByDescending() × 不向き
複合キー △ Tuple/匿名型を Comparer に ◎ 匿名型を直接渡せる △ Tuple/匿名型を Comparer に
大量データ性能 △ 辞書を内部生成 ◎ 最速
重複検知(bool 返り) × × Add() で取れる
順序保持 ○ 入力順を維持 ○ キーごとに保持 × 実装依存

パフォーマンス比較(10万件の int リスト)

.NET Framework 4.7.2 で実測したざっくりの傾向。マイクロベンチなので実際の業務コードでの差は環境依存。

書き方 所要時間(相対値) メモリ使用量
new HashSet<int>(list).ToList() 1.0(基準)
list.Distinct().ToList() 1.1〜1.3倍
list.GroupBy(x => x).Select(g => g.Key).ToList() 1.8〜2.5倍

数万件レベルでは Distinct と HashSet はほぼ差がない。10万件を超えるあたりから HashSet が明確に速くなる。GroupBy はキーごとに IGrouping を作るので、純粋な重複排除目的では一番遅い。

ただし業務系のリスト処理は 数百〜数千件レベルが大半 で、この差は誤差。可読性のほうが圧倒的に重要なので、「パフォーマンスを根拠に GroupBy を避ける」必要はほぼない。1万件超えで重い処理だけ HashSet を検討する、くらいの温度感で問題ない。

ハマりポイント:知らないと地味に時間を溶かすやつ

ここからは、3パターンを使い始めた で踏む典型的なハマり。

IEqualityComparer の GetHashCode 実装漏れ

Equals だけ実装して GetHashCode を実装し忘れると、Distinct も HashSet も 動くけど性能が落ちる

// NG:GetHashCode が常に同じ値を返す
public int GetHashCode(Customer obj) => 0;  // ← 全て同じハッシュなので O(N) 比較になる

HashSet<T> の高速性は「ハッシュで O(1) に近いバケット検索」が前提なので、GetHashCode が雑だと内部的に線形検索に劣化する。(obj.Id, obj.Name).GetHashCode() くらいで十分なので、合わせて実装する。

大文字小文字の扱い

メールアドレスや顧客コードを一意化する時、大文字小文字を無視したいケースが多い。Distinct だと IEqualityComparer<string> が必要になるが、HashSet なら StringComparer.OrdinalIgnoreCase を渡すだけで済む。

// HashSet の方が圧倒的に楽
var unique = new HashSet<string>(emails, StringComparer.OrdinalIgnoreCase).ToList();

業務系で「とりあえず大文字小文字を無視して一意化」が要件なら HashSet 一択。

null を含むリストでの挙動

3パターンとも null をエラーで弾かずに「null も1つの値」として扱う(HashSet は null を1個だけ保持する)。これは便利だが、意図しない null 残留の原因にもなる。

var list = new List<string> { "a", null, "b", null, "c" };
var unique = list.Distinct().ToList();
// 結果: ["a", null, "b", "c"]  ← null が1個残る

null を排除したいなら .Where(x => x != null) を前段に挟む。「重複排除」と「null 除去」は別の操作として明示的に書く のが事故が少ない。

著者の現場メモ:判断に迷った時の決め方

ここまで3パターン書いてきたが、現場で「結局どれを書く?」と迷う時間を減らすために、俺が普段やっている判断軸を1つだけ書いておく。

「キーで一意にしたいか / 全プロパティ一致で一意にしたいか」を最初に確定する。

  • キーで一意 → GroupBy(業務系の8割はこれ)
  • 全プロパティ一致 → Distinct + IEquatable 実装
  • 大量データ・重複検知 → HashSet

これだけ覚えておくと、コードレビューで「Distinct で書いてるけど GroupBy のほうがいいんじゃない?」みたいな揺れが起きなくなる。チーム作業では「読み手にとっての意図の伝わりやすさ」が性能より大事。

モダンC# の補足

.NET 6+ / C# 9 以降なら、レコード型を使うとクラス側で Equals / GetHashCode が自動生成されるので、Distinct がそのまま動く。

// C# 9+ レコード型
public record Customer(int Id, string Name);

var customers = new List<Customer>
{
    new Customer(1, "田中"),
    new Customer(1, "田中"),  // 同じ内容
    new Customer(2, "山田"),
};

var unique = customers.Distinct().ToList();
// Count = 2  ← レコード型の値ベース等価性で動く

.NET Framework 4.7.2 の現場ではレコード型は使えないが、.NET 6+ のプロジェクトに移れる時が来たら Customer をレコード型に書き換えるだけで Distinct が素直に動くようになる、という話は頭の片隅に置いておく。レガシー業務系で class と書いている部分は、将来 record への置換余地があると考えると、移植時の選択肢が広がる。

まとめ

  • C# の List 重複排除は Distinct / GroupBy / HashSet の3パターン。選び方の主軸は「キーの形」
  • 値型・string の単純重複なら Distinct(または HashSet)が最短。参照型は IEquatable 実装が前提
  • 業務系の「キーで一意・代表行を残す」は GroupBy + Select(g => g.First()) が定石
  • 10万件超の大量データや重複検知(Add の bool 返り)は HashSet 一択
  • IEqualityComparer の GetHashCode を雑に実装すると性能が落ちる。複合タプルの GetHashCode() を毎回セットで書く
  • null と大文字小文字の扱いは別操作として明示的に書く

よくある質問

Q1. Distinct を IEqualityComparer なしでカスタムクラスに使ったら全部残るのはバグ?

仕様。Distinct は既定で参照等価性(ReferenceEquals)で比較するので、別インスタンスは別物として残る。意図通りに動かしたいなら IEquatable<T> をクラス側で実装するか、IEqualityComparer<T> を Distinct に渡す。

Q2. GroupBy + Select(g => g.First()) と Distinct どっちを書くべき?

「キーで一意」なら GroupBy / 「全プロパティ一致」なら Distinct。後者は IEquatable 実装とセットになるので、業務系のリストで「ID だけで一意にしたい」なら GroupBy のほうが楽で意図も明確。

Q3. HashSet<T> で順序を保持したい場合は?

OrderedHashSet のような型は標準にないので、Distinct().ToList() を使うか、HashSet で重複検知だけしながら別の List<T> に追加していく書き方になる。順序保持が要件なら HashSet 単体は向かない。

Q4. 大量データの重複排除で OutOfMemory が出た

HashSet<T> の内部バケットがメモリを食う。1000万件超なら、ファイルや DB に逃がしてバッチで処理するか、StreamReader + HashSet でストリーム処理にする。インメモリで完結させようとせず、データソース側でハンドリングするほうが現実的。

Q5. .NET Framework 4.7.2 でも record 型は使える?

使えない。レコード型は C# 9 / .NET 5 以降。Framework 4.7.2 のプロジェクトでは class + IEquatable<T> 手書きが必要。コードジェネレータ(VS の Generate Equals プロパティ)を使うと書く量を減らせる。

関連記事

  • C# DataAdapter.Update() で DBNull 例外が出た時の最短対処 — DataAdapter で取った行を List に詰め替える時、null と DBNull.Value の境界で詰まる前にこちらから読むと早い
  • C# DataGridView の DataSource を後から変更する全パターン — List で重複排除した後の表示更新で、DataSource 差し替えの順序を踏み外すと事故るので合わせて押さえる
  • VB.net の Right / Mid / Left を C# に翻訳する完全早見表 — 移植プロジェクトで List の重複排除と並んで頻出する文字列処理の置き換え

以上!

この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次