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の重複排除と並んで頻出する文字列処理の置き換え
以上!
執筆者
バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。
🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る


コメント