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の重複排除と並んで頻出する文字列処理の置き換え

以上!

執筆者

バイブス父さん — 業務 SE 7 年 (正社員 2 / フリーランス 5)。 現職は SEO 直轄部の AI アドバイザー兼 PL、 副業で中小 SIer の CTO。 SES 複数社・フリーランスエージェント複数経由の経験ベースで「業務 SE 視点」 の技術 + キャリア記事を書いています。

🐦 X: @hiro_progra0524 (日々の現場メモ更新中)
📝 About Me で経歴詳細を見る

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

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

コメント

コメントする

CAPTCHA


目次