C# でリストの重複を一意にする3つの書き方(Distinct / GroupBy / HashSet)
DataAdapter で取ってきた DataTable を List<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つの書き方の使い分けは、どんなキーで一意にしたいか で決まる。
- Distinct ──「全プロパティが完全一致」の単純重複(値型・string)に最強
- GroupBy + Select(g => g.First()) ──「特定のキーだけで一意」「同じキーの中で代表行を選びたい」業務系の定番
- 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 の山田
IEqualityComparer も IEquatable<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
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 の重複排除と並んで頻出する文字列処理の置き換え
以上!


コメント