【C#】【LINQ】遅延評価の落とし穴!?

KENTEMでは開発言語の1つとして「C#」を採用しています。
そのC#の機能として「LINQ」と呼ばれる大変便利な機能があります。
便利な反面、使い方を誤ると大変なことになるやも・・・

実際にあった実例も踏まえてご紹介します。

LINQとは?

Language Integrated Queryの略になり、Microsoftでは「総合言語クエリ」と称しています。Microsoftの説明ページ等を見ると少し難解なのでChatGPTさんに要約してもらいました!

LINQ(Language-Integrated Query)はC#で提供される統合データクエリ機能で、コレクションやデータベースからデータを選択、フィルタリング、変換するための強力な言語拡張です。
LINQはクエリ式またはメソッド構文を使用してデータ操作を実現し、静的型付けと統合されているため、型セーフで効率的なコーディングが可能です。

コレクションを便利に操作できるモノだと思ってもらうと話が早いと思います。LINQの記述方法は2種類あります。

  • クエリ式
var excellentStudents = from student in students
                        where student.Score >= 80
                        orderby student.Score
                        select student;
  • メソッド構文
var excellentStudents = students.Where(x => x.Score >= 80)
                                .OrderBy(x => x.Score);

どちらもstudentsコレクションを80点以上取った生徒のみにフィルタリングして点数順に並べ替えたコードになります。
SQLが分かる方はかなり分かりやすく便利そう!と感じるかと思います。

遅延評価

LINQを使う上で「遅延評価」は避けては通れない問題になります。

簡単な例を示します。

var students = new List<Student>()
{
    new("太郎", 100),
    new("次郎", 80),
    new("三郎", 50),
    new("四郎", 20),
    new("五郎", 90),
    new("六郎", 0),
};
var excellentStudents = students.Where(x => x.Score >= 80)
                                .OrderBy(x => x.Score);

foreach (var student in excellentStudents)
    Console.WriteLine($"Name={student.Name} Score={student.Score}");

record Student(string Name, int Score);

上記のプログラムを実行すると下記の結果になります。

Name=次郎 Score=80
Name=五郎 Score=90
Name=太郎 Score=100

ここまでは特に問題ないかと思います。
このコードをちょっとだけ改変してみたいと思います!

var students = new List<Student>()
{
    new("太郎", 100),
    new("次郎", 80),
    new("三郎", 50),
    new("四郎", 20),
    new("五郎", 90),
    new("六郎", 0),
};
var excellentStudents = students.Where(x => x.Score >= 80)
                                .OrderBy(x => x.Score);

//------追加してみた。---------------------
students.Add(new("七郎", 95));
//---------------------------------------

foreach (var student in excellentStudents)
    Console.WriteLine($"Name={student.Name} Score={student.Score}");

record Student(string Name, int Score);

excellentStudentsを定義した後にリストに追加をしてみました。
結果はどうなったでしょうか・・・?

Name=次郎 Score=80
Name=五郎 Score=90
Name=七郎 Score=95   //増えてる?
Name=太郎 Score=100

追加したアイテムが増えていることが確認できるかと思います。

  • 理由
var excellentStudents = students.Where(x => x.Score >= 80)
                                .OrderBy(x => x.Score);

上記の式は「クエリ式」を定義しただけで、実はまだ実行されていない状態となっています。
実際にexcellentStudentsを呼びだした際に初めてクエリが実行されるため、定義した後に追加されたアイテムも検索の対象になったのでした。
これを遅延評価と呼びます。

即時評価

こちらは、遅延評価だと困る!という際に、クエリを即時実行したい場合に用います。

var excellentStudents = students.Where(x => x.Score >= 80)
                                .OrderBy(x => x.Score).ToArray();

ToArray()と付いただけですが、この場合は遅延ではなく即時評価され、「クエリ式」ではなく「配列」が返り値として返ってきます。
そのため、評価をした後に元のコレクションがいくら変わろうとクエリの実行は再度行われないため、値に変化はなくなります。

ほとんどの場合遅延評価で実行した方がメモリ消費量や実行効率が良くなる傾向があるため、使用用途は意識する必要があります。

遅延評価の落とし穴!?

特に問題もなさそうな遅延評価にも落とし穴があります。
実際にあった例を簡略化した形で紹介します。

var mediaList = new List<Media>();
var itemList = new List<Item>();

//メディア100枚にファイル1000個ずつ入っているようなデータを再現
for (int i = 0; i < 100; i++)
{
    //1~1000のItemを生成
    var items = Enumerable.Range(1, 1000).Select(x => new Item());
    itemList.AddRange(items);

    //mediaListの中に存在しないItemを抽出
    var ret = itemList.Where(item => 
        !mediaList.Exists(media => 
            media.ItemList.Any(x => x == item)));

    var media = new Media();
    media.ItemList.AddRange(ret);
    mediaList.Add(media);
}

public class Media
{
    public List<Item> ItemList { get; } = new();
}
public class Item { }

あくまでサンプルなので細かいツッコミはご容赦願います(笑)

こちらは一見すると特に問題がないコードに見えますが、実行すると終了するまで30分以上もの時間が掛かってしまいました・・・。

ループ回数が少ないとさほど時間は気にならないため、開発時には見落としてしまった内容になります。

  • コードの問題箇所
//mediaListの中に存在しないItemを抽出
var ret = itemList.Where(item => 
    !mediaList.Exists(media => 
        media.ItemList.Any(x => x == item)));

上記のコードですがWhere Exists Anyと3つLINQクエリを使っています。
クエリの中でさらに別のクエリが実行されるような形になり、3重ループのような形になってしまっています。
そのループの際にも遅延評価のためキャッシュなどされずに愚直に毎回クエリが実行されることになっており、悲惨なことになっていました。

※この例だとAny内部の総走査回数は脅威の3333億回!!

  • ちなみに修正結果
var ret = itemList.Except(
  mediaList.SelectMany(media => media.ItemList)
);

修正前の面影は全くありませんが最適化した結果がこうなりました。
同一の結果が得られ、実行時間は元の30分以上からなんと0.5秒にまで短縮できました!

まとめ

LINQは非常に便利なツールではありますが、例を挙げたようにきちんと理解しないまま扱うと逆にパフォーマンスの低下に陥ります。

ちょっとした違いだけでサーバーなども止まりかねないので、失敗を教訓として活かして正しい理解をしていきたいと感じました。

おわりに

KENTEMでは、様々な拠点でエンジニアを大募集しています!
建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。
hrmos.co hrmos.co hrmos.co