📌

LINQ to Objectsを使う前におさえておくべき3つのこと

2022/03/27に公開1

はじめに

LINQ to Objects(以後LINQ)は、Listや配列など、コレクションに対するフィルターやグルーピング・加工処理を宣言的に記述する仕組みです。

従来の手続き的な手法に比較して、宣言的に記述することで生産性と品質の双方を向上できます。

実際に「intの配列から偶数を取り出し、小さい順に並べ替えられたListへ変換する」コードで比較してみましょう。

下記が従来の手続き的に記述されたコードです。

int[] numbers = { 5, 10, 8, 3, 6, 12 };

List<int> evenNumbers = new();
foreach (var number in numbers)
{
    if (number % 2 == 0)
    {
        evenNumberList.Add(number);
    }
}
evenNumbers.Sort((x, y) => x - y);

対して下記がLINQを使って宣言的に記述されたコードです。

int[] numbers = { 5, 10, 8, 3, 6, 12 };

var evenNumbers =
    numbers
        .Where(x => x % 2 == 0)
        .OrderBy(x => x)
        .ToList();

Whereで偶数にフィルターして、OrderByで並べ替えてからListに変換しているんだな?というのが 「文章で仕様を定義するように、宣言的に記述されている」 ことが見て取れるかと思います。

LINQは難しいというイメージが先行していますが、それは誤った認識です。

手続き的な記述から、宣言的な記述へ

新しい概念を理解する必要がありますが、LINQを使いこなすことで生産性と品質のどちらも向上できます。

しかし同時に、私はこれまでLINQの実装をレビューする過程で、初学者に共通する好ましくない実装をいくつか見てきました。

それは多くの初学者向けのLINQ記事が機能的な実現についてのみ記述されており、実際に利用する前におさえておくべきポイントについて、十分に共有されていないことが一因ではないかと考えました。

そこで本稿では、LINQを実際に使いう前におさえたい3つのポイントを解説します。

おさえておきたい3つのこと

  1. 正しい仕様を宣言する
  2. ToListなどで、過度にコレクションを生成しない
  3. ToListなどで、適切にコレクションを生成する

この3つを理解しておくことで、LINQを使う際の多くの問題を回避できます。

正しい仕様を宣言する

LINQでは、異なる実装で、同じ結果を得られるケースがあります。

その場合、第一に考えるべきことは「正しい仕様を宣言する」ということです。

具体的な例を見てみましょう。ユニークなIdをプロパティに持つ、アイテムクラスがあったとします。

public class Item
{
    public Item(int id)
    {
        Id = id;
    }

    public int Id { get; }
}

そして、Idの重複しないItemのListが存在します。

List<Item> items = new[] { 5, 10, 8, 3, 6, 12 }
    .Select(x => new Item(x))
    .ToList();

ここからIdが3のItemを取り出すとした場合、つぎの4つのいずれで実装しても「結果的には」等しくなります。

// 1個だけ存在するものを取得する
// - 条件に該当するものが存在しない場合、例外をスローする
// - 条件に該当するものが複数存在する場合、例外をスローする
var item = items.Single(x => x.Id == 3);

// 0または1個存在するものを取得する
// - 条件に該当するものが存在しない場合、Default値を返す
// - 条件に該当するものが複数存在する場合、例外をスローする
var item = items.SingleOrDefault(x => x.Id == 3);

// 1個以上存在するものを取得する
// - 条件に該当するものが存在しない場合、例外をスローする
// - 条件に該当するものが複数存在する場合、先頭のオブジェクトを返す
var item = items.First(x => x.Id == 3);

// 0個以上存在するものを取得する
// - 条件に該当するものが存在しない場合、Default値を返す
// - 条件に該当するものが複数存在する場合、先頭のオブジェクトを返す
var item = items.FirstOrDefault(x => x.Id == 3);

では、どれを使うのが「正しい」でしょうか?

たとえばFirstOrDefaultで取得した場合、今回のケースであれば該当するitemがないとnullが返されます。したがって、戻り値がnullではない場合のみ処理すれば「落ちにくい」プログラムが書けそうです。またSingle系の場合、重複する結果があったら例外をスローする仕様となっているため、itemsの最後まで探索する必要があります。対してFirst系であれば、条件に該当するものが見つかり次第処理を中断できるため、計算量も少なくて済みそうです。

では、つねにFirstOrDefaultを利用しておくことが正しいのでしょうか?

結論は「背景による」です。

もう少し仕様を具体的にしましょう。

  1. あるアプリケーションでは、Idの重複しないItemを複数登録します
  2. アプリケーションでは登録されたItemの一覧を表示します
  3. 一覧から任意の行を選択すると、該当のItemの詳細を閲覧できます
  4. 登録されたItemは、削除されません

仕様が前述の通りであった場合、FirstOrDefaultでも期待通りに動作します。

むしろItemの登録系機能に不具合があり、Idの重複したItemが登録されていても落ちず、それなりに動作します。そして動作も早いかもしれない。ではやはりFirstOrDefaultを利用するのが正しいでしょうか?

いいえ。むしろ FirstOrDefaultは、もっとも利用してはいけません。 なぜか?

それは障害を一時的に隠ぺいしてしまうからです。

SingleもしくはSingleOrDefaultで実装されていれば、Idの重複があれば例外が発生し、その時点で登録系機能に不具合が存在することを検知できます。

しかしFirst系を利用してしまうと障害の検知が遅れます。最悪はリリースして矛盾したデータを作りまくった後に判明し、利用者のすべての労力を無に帰す可能性さえあります。

したがって「仕様が前述の通りであった場合」、First系を利用してはいけません。

ではSingleとSingleOrDefaultどちらを使うべきでしょうか?もうお分かりですね?

Singleを使うべきです。なぜなら「仕様通り実装されているなら」条件に該当しないということはあり得ない為です。

上記のケースでnullのための条件分岐があることは、コードを無意味に複雑化します。将来だれかがそのコードを見たときに、「Itemがない場合も存在するのだろうか?」という錯誤さえ生みます。

また扱う値がたとえばint型だった場合、~OrDefaultで条件に合致する値がないと0が返されます。これは条件に該当するものがなかったのか、それとも0がヒットしたのか判断できないという問題があります。

~OrDefaultを必要ないときに使うことは、LINQを使う上での最大の負債の1つになりえます。

またSingle系の速度が問題になるような状況であれば、そもそもLINQの使用自体を再検討すべきです。LINQは性能だけを見た場合、最善ではない可能性があります。ただし、ほとんどの場合は無視できるレベルです。

LINQを使う場合、「正しい仕様を宣言する」ように留意しましょう。

それ以外の基準に則って手段を選択すると、不具合などの課題を後送りにします。不具合は可能な限り早い段階で検出し、対応するのが基本的に正しいスタイルだからです。

ToListなどで、過度にコレクションを生成しない

LINQの学びたては、つぎのようなサイクルで実装しがちです。

  1. Webを参照して実装方法を調べる
  2. デバッグなどで動作を確認する

これを頻繁に繰り返して目的のコードを実装します。もちろん、このサイクルが悪いわけではありません。

ただ、このようなサイクルの中で過度にコレクションが生成されてしまうコードになってしまっていることをよく見かけます。

たとえば「Itemの配列からIdが偶数のものを取り出し、Idを昇順でコンソールへ出力する」処理を実装するとしましょう。

おそらく最所は前半の「intの配列から偶数を取り出す」処理を実装しようとし、実装の動作確認のためにつぎのように記述することでしょう。

List<Item> items = new[] { 5, 10, 8, 3, 6, 12 }
    .Select(x => new Item(x))
    .ToList();

var evenItems =
    items
        .Where(x => x.Id % 2 == 0)
        .ToList();

デバッグして変数を確認し、確かに偶数だけが取得されていることが確認できるでしょう。やったね!

では次だということで、「小さい順に」ソートします。

List<Item> items = new[] { 5, 10, 8, 3, 6, 12 }
    .Select(x => new Item(x))
    .ToList();

var evenItems =
    items
        .Where(x => x.Id % 2 == 0)
        .ToList()
        .OrderBy(x => x.Id)
        .ToList();

再び動作を確認した後、「コンソールへ出力する」でしょう。

List<Item> items = new[] { 5, 10, 8, 3, 6, 12 }
    .Select(x => new Item(x))
    .ToList();

var evenItems =
    items
        .Where(x => x.Id % 2 == 0)
        .ToList()
        .OrderBy(x => x.Id)
        .ToList();

foreach (var item in evenItems)
{
    Console.WriteLine(item.Id);
}

このコードは正しく動作します。しかし好ましいコードではありません。なぜか?

それは過剰にToListを呼び出しているからです。

Listを作ることは、CPUとメモリーの双方のリソースをそれなりに消費します。LINQが遅いというベンチマーク記事なんかでもこのようなコードが散見されますが、ムダなList化を減らすだけで何倍も速くなったなんてことはよくあります。

今回のケースでは、下記のようなコードが好ましいでしょう。

List<Item> items = new[] { 5, 10, 8, 3, 6, 12 }
    .Select(x => new Item(x))
    .ToList();

var evenItems =
    items
        .Where(x => x.Id % 2 == 0)
        .OrderBy(x => x.Id);

foreach (var item in evenItems)
{
    Console.WriteLine(item.Id);
}

今回の場合、ToListは1回も呼び出す必要はなく、IEnumerable<Item>のままforeachに渡すことで、小さなコストで実現できます。

調べる→デバッグ→調べる→デバッグというサイクルで徐々に実装することで、中間にうっかりToListやToArrayを残してしまっているコードを見かけますが、コレクションの生成は最小限になるよう注意しましょう。

ToListなどで、適切にコレクションを生成する

さて今度は、先とは逆のケースです。

先の例に対して、少し仕様を追加しましょう。

  • 変更前
    • Itemの配列からIdが偶数のものを取り出し、Idを昇順でコンソールへ出力する
  • 変更後
    • Itemの配列からIdが偶数のものを取り出し、件数と、Id(昇順)をコンソールへ出力する

Idを出力する前に、条件に合致するItemの件数を出力するようにします。単純に実装すると、つぎのようになるでしょう。

List<Item> items = new[] { 5, 10, 8, 3, 6, 12 }
    .Select(x => new Item(x))
    .ToList();

var evenItems =
    items
        .Where(x => x.Id % 2 == 0)
        .OrderBy(x => x.Id);

Console.WriteLine($"Count:{evenItems.Count()}");
foreach (var item in evenItems)
{
    Console.WriteLine(item.Id);
}

このコードには大きな問題があります。それを理解するためWhereにログ出力を入れてみましょう。

var evenItems =
    items
        .Where(x =>
        {
            Console.WriteLine("\"Where\" was invoked.");
            return x.Id % 2 == 0;
        })
        .OrderBy(x => x.Id);

なお上記Console出力は「LINQに副作用のあるコードを書かない」という原則に反しているため、あくまで動作確認のための利用に留めてください。(aetosさんご指摘ありがとうございました!)

すると実行結果は下図の通りとなります。

Countの表示前と、foreachのループの2か所でWhereが呼び出されていることが分かります。

これはLINQのフィルターや選択処理は、遅延実行されることがその理由です。

上記のコードではWhereとOrderByが宣言されていますが、それらが呼び出されるのはevenItemsのインスタンスが作られるときではなく、それが使われるときになります。

そのためevenItemsが2回使われる(Countとforeach)と、LINQの処理が2回実行されることになります。

これはCPUやメモリーを浪費する問題がありますし、それぞれの実行の間におおもとのデータソースが変更された場合に、出力された結果が矛盾した状態になることもあります。

そのためには、ToListなどで、適切にコレクションを生成し、スナップショットを確定させます。今回のケースでは下記の様にすると良いでしょう。

List<Item> items = new[] { 5, 10, 8, 3, 6, 12 }
    .Select(x => new Item(x))
    .ToList();

var evenItems =
    items
        .Where(x =>
        {
            Console.WriteLine("\"Where\" was invoked.");
            return x.Id % 2 == 0;
        })
        .OrderBy(x => x.Id)
        .ToList();

Console.WriteLine($"Count:{evenItems.Count()}");
foreach (var item in evenItems)
{
    Console.WriteLine(item.Id);
}

OrderByのあとにToListを呼び出すことで、つぎのようにLINQの処理を1回きりに制限できます。

さいごに

LINQをつかうことは、けして難しいことではありませんし、適切に使うことで生産性や品質を向上できます。

しかし使う際には最低限配慮するべきことがあります。

  1. 正しい仕様を宣言する
  2. ToListなどで、過度にコレクションを生成しない
  3. ToListなどで、適切にコレクションを生成する

この3つをおさえることで、多くの問題を事前に回避できます。

これらを理解して、よいLINQ生活をおくりましょう!

Discussion

t13801206t13801206

ToListなどで、

コレクションを生成するメソッドとしては
ToListToArrayに注意していればOKでしょうか?
他に気をつけるLinqのメソッドがあればご教授お願いします!