C# Tips | コレクション・LINQ:順序保持Distinct

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

はじめに:「順序保持 Distinct」は“最初に出てきた順だけを残す”技

「重複を消したいけど、元の並び順は崩したくない」——業務でデータを扱っていると、かなり頻繁に出てきます。

ログの順番はそのままに、同じユーザー ID は 1 回だけにしたい
画面に表示するタグ一覧で、最初に出てきた順番をそのまま見せたい
履歴データから「初めて登場した順」にユニークな値を取りたい

こういうときに使えるのが「順序保持 Distinct」です。
実は、C# の LINQ の Distinct() は、もともと「最初に出てきた順」を保ったまま重複を消してくれる、という性質があります。

ここから、

Distinct() の基本と「順序がどうなるか」
「最初に出てきたものだけ残す」という意味をしっかり理解する
特定のキーで順序保持 Distinct したいときの書き方(DistinctBy / 自作)
業務でよくある使いどころ

を、初心者向けにかみ砕いて説明していきます。


Distinct の基本:最初に出てきた順番をそのまま残す

まずは動きをコードで確認する

using System;
using System.Linq;

var names = new[]
{
    "Alice",
    "Bob",
    "Alice",
    "Charlie",
    "Bob",
    "Alice"
};

var distinct = names.Distinct();

foreach (var name in distinct)
{
    Console.WriteLine(name);
}
C#

出力はこうなります。

Alice
Bob
Charlie

ここで注目してほしいのは、「ソートされていない」ということです。
アルファベット順なら Alice, Bob, Charlie なので一見同じに見えますが、
Distinct() は「ソートしている」のではなく、

“最初に出てきた順に、重複を取り除いている”

だけです。

順番を変えてみると、もっと分かりやすくなります。

var names = new[]
{
    "Charlie",
    "Alice",
    "Bob",
    "Alice",
    "Charlie",
    "Bob"
};

var distinct = names.Distinct();

foreach (var name in distinct)
{
    Console.WriteLine(name);
}
C#

出力はこうなります。

Charlie
Alice
Bob

ここでの重要ポイントは、

「Distinct は“最初に登場した順番”を保ったまま、
 “2 回目以降の同じ値”を落としているだけ」

ということです。


「順序保持 Distinct」のイメージをしっかりつかむ

頭の中でシミュレーションしてみる

さきほどの配列を、1 つずつ見ていくイメージで追ってみます。

入力: Charlie, Alice, Bob, Alice, Charlie, Bob

1 個目: Charlie → 初登場なので採用
2 個目: Alice → 初登場なので採用
3 個目: Bob → 初登場なので採用
4 個目: Alice → すでに出ているので捨てる
5 個目: Charlie → すでに出ているので捨てる
6 個目: Bob → すでに出ているので捨てる

結果:

Charlie, Alice, Bob

これが「順序保持 Distinct」の正体です。

ここでの重要ポイントは、

「Distinct は“ユニークな集合”を作るというより、
“最初に出てきたものだけを残すフィルター”だと考えると分かりやすい

ということです。


特定のプロパティで順序保持 Distinct したい(DistinctBy)

.NET 6 以降なら DistinctBy が使える

オブジェクトの「特定のプロパティ」で重複を消したい、というのも業務ではよくあります。

例えば、ユーザーのリストから「メールアドレスが重複しているものは 1 件にまとめたい。ただし、最初に出てきたユーザーを優先したい」というケース。

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

var users = new[]
{
    new User { Id = 1, Email = "a@example.com" },
    new User { Id = 2, Email = "b@example.com" },
    new User { Id = 3, Email = "a@example.com" }, // Email 重複
    new User { Id = 4, Email = "c@example.com" },
    new User { Id = 5, Email = "b@example.com" }, // Email 重複
};
C#

.NET 6 以降なら、DistinctBy が使えます。

using System.Linq;

var distinctByEmail = users
    .DistinctBy(u => u.Email);

foreach (var u in distinctByEmail)
{
    Console.WriteLine($"Id={u.Id}, Email={u.Email}");
}
C#

出力はこうなります。

Id=1, Email=a@example.com
Id=2, Email=b@example.com
Id=4, Email=c@example.com

ここでの重要ポイントは、

DistinctBy も「最初に出てきた順」を保つ

ということです。
Email が同じユーザーが複数いても、「最初に登場したユーザー」が採用されます。

.NET 5 以前や DistinctBy がない環境なら自作する

DistinctBy が使えない環境では、自分で拡張メソッドを作るのが定番です。

using System;
using System.Collections.Generic;

public static class DistinctExtensions
{
    public static IEnumerable<TSource> DistinctBy<TSource, TKey>(
        this IEnumerable<TSource> source,
        Func<TSource, TKey> keySelector)
    {
        var seen = new HashSet<TKey>();

        foreach (var item in source)
        {
            if (seen.Add(keySelector(item)))
            {
                yield return item;
            }
        }
    }
}
C#

使い方は同じです。

var distinctByEmail = users.DistinctBy(u => u.Email);
C#

ここでの重要ポイントは、

HashSet を使って「すでに見たキー」を覚えながら、
“初めて見るキーの要素だけ yield する”ことで、
順序を保った DistinctBy を実現している

ということです。


GroupBy との違いをはっきりさせる

GroupBy は「グループごとに全部残す」

GroupBy も「キーごとにまとめる」ので、よく混同されますが、目的が違います。

var groups = users.GroupBy(u => u.Email);

foreach (var g in groups)
{
    Console.WriteLine($"Email: {g.Key}");

    foreach (var u in g)
    {
        Console.WriteLine($"  Id={u.Id}");
    }
}
C#

これは「同じ Email のユーザーを全部グループにまとめる」処理です。
一方、DistinctBy は「グループの代表(最初の 1 件)だけを残す」イメージです。

ここでの重要ポイントは、

「全部見たいなら GroupBy、代表だけでいいなら Distinct / DistinctBy」

という使い分けを意識することです。


実務での具体例 1:タグ一覧を「初登場順」でユニークにする

記事に付いているタグから、表示用のタグ一覧を作る

public class Article
{
    public string Title { get; set; } = "";
    public string[] Tags { get; set; } = Array.Empty<string>();
}

var articles = new[]
{
    new Article { Title = "A", Tags = new[] { "C#", "LINQ", "業務" } },
    new Article { Title = "B", Tags = new[] { "LINQ", "設計" } },
    new Article { Title = "C", Tags = new[] { "C#", "テスト" } },
};
C#

「画面上部にタグ一覧を出したい。順番は“初めて登場した順”にしたい」という場合。

var allTags = articles
    .SelectMany(a => a.Tags)
    .Distinct();   // ここで順序保持 Distinct

foreach (var tag in allTags)
{
    Console.WriteLine(tag);
}
C#

出力はこうなります。

C#
LINQ
業務
設計
テスト

ここでの重要ポイントは、

「タグが最初に登場した順」がそのまま一覧の順番になっている

ということです。
ユーザーにとっても、「よく出てくる順」に近い自然な並びになります。


実務での具体例 2:履歴から「初回登場順」のユーザー一覧を作る

ログイン履歴から「初めてログインした順」にユーザーを並べる

public class LoginHistory
{
    public DateTime Time { get; set; }
    public int UserId { get; set; }
}

var logs = new[]
{
    new LoginHistory { Time = new DateTime(2024, 1, 1, 9, 0, 0), UserId = 1 },
    new LoginHistory { Time = new DateTime(2024, 1, 1, 9, 5, 0), UserId = 2 },
    new LoginHistory { Time = new DateTime(2024, 1, 1, 9, 10, 0), UserId = 1 },
    new LoginHistory { Time = new DateTime(2024, 1, 1, 9, 15, 0), UserId = 3 },
    new LoginHistory { Time = new DateTime(2024, 1, 1, 9, 20, 0), UserId = 2 },
};
C#

「その日、どのユーザーがどの順番で初めてログインしたか」を知りたいとき。

var orderedUsers = logs
    .OrderBy(x => x.Time)      // 念のため時間順に並べる
    .Select(x => x.UserId)
    .Distinct();               // 順序保持 Distinct

foreach (var userId in orderedUsers)
{
    Console.WriteLine(userId);
}
C#

出力はこうなります。

1
2
3

ここでの重要ポイントは、

「時間順に並べたあとで Distinct することで、
“初回登場順”のユニークなユーザー一覧が取れる

ということです。
「最初に出てきた順を保つ」という Distinct の性質が、業務ロジックときれいに噛み合っています。


まとめ:「順序保持 Distinct ユーティリティ」は“最初の 1 回だけを大事に残す道具”

順序保持 Distinct の本質は、

「同じ値が何度も出てきても、
最初に出てきた 1 回だけを残し、
その“登場順”をそのまま保つ」

ことです。

押さえておきたいポイントを整理すると、

LINQ の Distinct() は、もともと「最初に出てきた順」を保つ
「順序保持 Distinct」とは、「最初の登場だけ残すフィルター」だと考えると分かりやすい
オブジェクトの特定プロパティでやりたいときは DistinctBy(または自作拡張メソッド)
全部の要素を見たいなら GroupBy、代表だけでいいなら Distinct / DistinctBy
「タグ一覧」「初回ログイン順」「初登場順のユニーク値」など、業務での出番はかなり多い

ここまで腹落ちしていれば、
「とりあえず Distinct してから OrderBy でソートする」だけの世界から一歩進んで、
“最初の 1 回だけを大事に残す、順序保持 Distinct ユーティリティ”を、意図を持って使いこなせるようになります。

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