【C#】よく使うLINQメソッドと注意点

C#

私は仕事でよく C# を書いているのですが、他の言語と比較して強力な機能として LINQ が挙げられると思います。
LINQ を使えば、あるデータ集合(正確には IEnumerable<T> を実装するインスタンス)に対しての操作を直観的に書けるだけでなく、後から見直した時にコードの意図を掴みやすいという利点があります。

この記事では LINQ に関する基本的な事柄は割愛します。
私が業務でよく利用しているLINQメソッドと、利用する際の注意点についてまとめます。

使用頻度: 高

使用頻度が高い LINQ メソッドについてまとめます。

Where(フィルター)

Where は指定した条件を満たす要素のみを採用した IEnumerable<T> を返すメソッドです。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.Where(x => x.Length < 6);
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
apple
grape
*/

上記コードでは「文字列の長さが 6 より小さい要素を返す」ようにしているので、apple と grape が出力されます。

Select(変換)

Select は要素に対して変換を実行した結果を新たな要素とした IEnumerable<T> を返します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source
    .Where(x => x.Length < 6)
    .Select(x => x.ToUpper());
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
APPLE
GRAPE
*/

上記コードでは「文字列の長さが 6 より小さい要素を返す」ようにした後、「要素を大文字に変換」しているので、APPLE と GRAPE が出力されます。

Any(条件に合う要素が存在するか)

Any は指定した条件に合う要素が存在するかを bool で返します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.Any(x => x.Length > 6);
Console.WriteLine(query);
/*
False
*/

上記コードでは「文字列の長さが 6 より大きい要素が存在するか」を判定しているので、False(偽) が出力されます。

また、「全ての要素が指定した条件を満たしているか」を判定する All というメソッドもあります。

FirstOrDefault(最初の要素またはデフォルト値を返す)

FirstOrDefault は指定した条件に合う最初の要素を返します。
条件に合う要素が存在しない場合、デフォルト値を返します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query1 = source.FirstOrDefault(x => x.Length == 6);
var query2 = source.FirstOrDefault(x => x.Length == 7);
Console.WriteLine(query1);
Console.WriteLine(query2);
/*
banana
(null)
*/

上記コードでは「文字列の長さが 6(7) と等しい最初の要素(またはデフォルト値)」を返しているので、「banana」と「null」が返されます。
なお、null は実際には何も出力されません。

注意点 その1(First を利用すべき場面は少ない)

FirstOrDefault とよく似たメソッドで First がありますが、First は条件に合う要素が存在しない場合に例外が発生します

アプリケーションは基本的に例外を発生させるべきではないので、本当に例外を発生させたいときのみ First を利用することを推奨します
たとえば、前処理で条件に合う要素が存在することが確定している場合、FirstOrDefault でデフォルト値が返されるのは未知の不具合になります。
このような場合には First を利用して、例外を発生させた方が良いでしょう。

注意点 その2(LastOrDefault の利用は非推奨)

FirstOrDefault とよく似たメソッドとして LastOrDefault がありますが、このメソッドの利用は非推奨です

LastOrDefault は条件に合う最後の要素を返すメソッドですが、性質上全ての要素に対して判定を実行します。
そのため、処理コストが高くなる可能性があります。

LastOrDefault を利用したくなった場合、Reverse を使って source の順序を逆にすることで回避できます

var source = new[] { "apple", "banana", "orange", "grape" };
var query1 = source.LastOrDefault(x => x.Length == 6);
var query2 = source
    .Reverse()
    .FirstOrDefault(x => x.Length == 6);
Console.WriteLine(query1);
Console.WriteLine(query2);
/*
orange
orange
*/

上記コードでは query1 と query2 は同じ結果となりますが、query2 の方が処理速度的に安全なコードとなります。

ToArray(IEnumerable を配列に)

ToArray は IEnumerable<T> を T[] に変換します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source
    .Where(x => x.Length < 6) // この時点では IEnumerable<string>
    .ToArray();               // この時点で string[] になる
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
apple
grape
*/

上記コードでは「文字列の長さが 6 より小さい要素」のみを抽出した後、配列にしています。

注意点(積極評価を行いたい場合に使う)

この項目は、やや高度な知識を必要とします。

ToArray の使い所ですが、query の積極評価を行いたい場合に利用します

LINQ は基本的に遅延評価です。つまり、結果が必要になるときまで評価が後回しにされます
遅延評価は良い面が多いのですが、LINQ に関しては意図せず処理負荷を上げてしまう原因にもなり得ます

具体的には、同じ query を複数回利用する際に問題となります。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source
    .Where(x =>
           {
               Console.WriteLine(" --> Where");
               return x.Length < 6;
           });

// 2回表示してみる
foreach(var element in query.Concat(query))
{
    Console.WriteLine(element);
}
/*
 --> Where
apple
 --> Where
 --> Where
 --> Where
grape
 --> Where
apple
 --> Where
 --> Where
 --> Where
grape
*/

上記コードは先程の例と基本構造は変わりませんが、以下の点で異なります。

  1. Where の呼び出し時に ” –> Where” を出力する
  2. query の ToArray を削除
  3. query を2回繋げたものを foreach で反復する

つまり、Where の出力回数が判定の評価が行われた回数となります。
結果を見ると、Where は8回出力されており、(source の要素数)×2回評価が行われていたことが分かります。
これは、query の結果が必要となるまで評価が遅延されたため発生した現象です。

ここで、ToArray を利用して即座に query を評価することで、結果がどうなるか見てみましょう。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source
    .Where(x =>
           {
               Console.WriteLine(" --> Where");
               return x.Length < 6;
           })
    .ToArray(); // <- ToArray() を追加しました

// 2回表示してみる
foreach(var element in query.Concat(query))
{
    Console.WriteLine(element);
}
/*
 --> Where
 --> Where
 --> Where
 --> Where
apple
grape
apple
grape
*/

Where は4回しか評価されていません。つまり、その分処理負荷が下がっていることが分かります。

また、Where が表示されるタイミングを見ると、ToArray 実行時に評価されていることが分かります。
このように、ToArray は積極評価を行いたい場合に利用します。

使用頻度: 中

時々使う機会のある LINQ メソッドについてまとめます。

Count(条件に合った要素をカウント)

Count は指定した条件に合った要素数を返します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.Count(x => x.EndsWith("e"));
Console.WriteLine(query);
/*
3
*/

上記コードでは「文字列の最後が e で終わる要素の個数」を出力しています。

Distinct(重複要素を削除)

Distinct は重複要素を除去した IEnumerable<T> を返します。

var source = new[] { "apple", "banana", "orange", "apple", "grape" };
var query = source.Distinct();
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
apple
banana
orange
grape
*/

上記コードでは source で apple が重複していますが、出力では重複が除去されています。

注意点(Distinct は早めに実行する)

Distinct は実行後に要素数が source の要素数以下になります。
そのため、できるだけ早めに実行することで処理速度を向上させることが可能です

たとえば、「source の中から文字数が 6 より小さい要素を、重複無く列挙したい」とします。
これを愚直に実装すると、以下の query1 のようになりますが、Where で5要素チェックした後に Distinct で再び5要素チェックしていることになります。
ところが、順番を逆にして query2 のようにすれば、Distinct で5要素チェックした後に Where で4要素チェックするだけで済み、処理速度が向上します。

var source = new[] { "apple", "banana", "orange", "apple", "grape" };
var query1 = source
    .Where(x => x.Length < 6)
    .Distinct(); // こっちの方が遅い
var query2 = source
    .Distinct()
    .Where(x => x.Length < 6); // こっちの方が速い

同様のことは Where にも言えますね。

OrderBy(ある変換を Key として昇順ソート)

OrderBy は指定した変換を Key として昇順ソートした IEnumerable<T> を返します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.OrderBy(x => x.Length);
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
apple
grape
banana
orange
*/

上記コードでは「文字列の長さが短い順」で source を出力しています。

注意点(降順ソートには OrderByDescending を使う)

降順にソートしたい場合は OrderByDescending を使いましょう。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.OrderByDescending(x => x.Length);
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
banana
orange
apple
grape
*/

GroupBy(ある変換を Key として要素をまとめ直す)

GroupBy は指定した変換を Key として要素を IEnumerable<T> にまとめ直します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.GroupBy(x => x.Length);
foreach(var element in query)
{
    Console.WriteLine(element.Key);
    foreach(var elementInElement in element)
    {
        Console.WriteLine(elementInElement);
    }
}
/*
5
apple
grape
6
banana
orange
*/

少しややこしいので、詳しく解説します。
上記コードでは「文字列の長さ」で source をまとめ直しています。
source には5文字の要素が2つ、6文字の要素が2つあるので、query は「5文字のグループ」「6文字のグループ」と2つの要素を持つ IEnumerable<T> を返します。

ここで、query が返す要素は Key をプロパティとして持ち、IEnumerable<T> を実装します。
つまり、要素自体を foreach の対象とすることで、グループ内のメンバーを取得できます

ToDictionary(IEnumerable を Dictionary に)

ToDictionary は指定した変換を Key, Value として要素を Dictionary に変換します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.ToDictionary(x => x[0], x => x);
foreach(var element in query)
{
    Console.WriteLine($"{element.Key} -> {element.Value}");
}
/*
a -> apple
b -> banana
o -> orange
g -> grape
*/

上記コードでは「文字列の先頭文字」から source の辞書を作成しています。
ToDictionary は Key と Value の2つの変換を渡すのが特徴です。

注意点(Key が重複すると例外が発生!)

ToDictionary は Key が重複すると例外が発生します
非常によく引っかかる罠なので、気をつけましょう。

Key が重複する可能性があり、見つかった最初の要素を採用するのみで良いのなら、GroupBy を用いることで例外発生を回避できます。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source
    .GroupBy(x => x.Length)
    .ToDictionary(x => x.Key, x => x.FirstOrDefault());
foreach(var element in query)
{
    Console.WriteLine($"{element.Key} -> {element.Value}");
}
/*
5 -> apple
6 -> banana
*/

使用頻度: 低

利用頻度は低いですが、覚えておくと役に立つ LINQ メソッドについてまとめます。

Concat(IEnumerable を合成)

Concat は IEnumerable<T> を source の後ろに結合します。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.Concat(source.Reverse());
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
apple
banana
orange
grape
grape
orange
banana
apple
*/

上記コードでは source の後ろに source を反転したものを結合しています。

SelectMany(要素が IEnumerable になる場合に平坦化する)

SelectMany は IEnumerable<IEnumerable<TSource>> の要素を平坦化し、IEnumerable<TResult> へ変換します。
平坦化した際に変換できるので、型は TSource と TResult で明確に分けました。
少しややこしく感じると思いますが、たまに利用できる場面が出てきてとても便利なので、覚えておいて損はないです。

var source = new[]
{
    new[] { "apple", "banana", "orange", "grape" },
    new[] { "hoge", "fuga", "piyo" }
};
var query = source
    .SelectMany(x => x.Select(y => y.ToUpper()));
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
APPLE
BANANA
ORANGE
GRAPE
HOGE
FUGA
PIYO
*/

上記コードでは source は IEnumerable<IEnumerable<string>> です。
SelectMany で要素である IEnumerable<string> が渡され(x)、その中の全要素を大文字に変換しています。
このラムダ式の戻り値は IEnumerable<string> で、最終結果は平坦化された IEnumerable<string> となります。

TakeWhile(条件に合う間は要素を取り続ける)

TaleWhile は 指定した条件に合う間の要素を取り続けます。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.TakeWhile(x => x.EndsWith("e"));
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
apple
*/

上記コードでは「文字列の最後が e である間」は source から要素を取り続けています。
banana で条件を満たさないので、それ以降の要素は条件を満たしていますが apple のみ返されます。

注意点(「除外し続けたい」場合は SkipWhile を使う)

TakeWhile とは逆に「指定した条件を満たす間は除外し続けたい」場合、SkipWhile を使いましょう。

var source = new[] { "apple", "banana", "orange", "grape" };
var query = source.SkipWhile(x => x.EndsWith("e"));
foreach(var element in query)
{
    Console.WriteLine(element);
}
/*
banana
orange
grape
*/

上記コードでは「文字列の最後が e である間」は除外した上で、source から要素を取り続けています。
banana は条件を満たさないので、それ以降の要素が返されています。

OfType(要素の型でフィルターする)

この項目は、やや高度な知識を必要とします。

OfType は IEnumerable<TSource> から指定した型(TResult)の要素のみを抽出し、IEnumerable<TResult> へ変換します。

IEnumerable<string> sourceElement1 = new List<string> { "list1", "list2" };
IEnumerable<string> sourceElement2 = new[] { "array1", "array2" };
IEnumerable<IEnumerable<string>> source = new[] { sourceElement1, sourceElement2 };
var query = source.OfType<List<string>>();
foreach(var element in query)
{
    foreach(var elementInElement in element)
    {
        Console.WriteLine(elementInElement);
    }
}
/*
list1
list2
*/

型の認識が大切なので、今回は source の型を明記しています。

List<string> および string[] は共に IEnumerable<string> を実装しています。
そのため、IEnumerable<IEnumerable<string>> として1つの配列にまとめることができます。
ちなみに、object は全ての型の基底となっているため、乱暴ですが source を IEnumerable<object> としても通ります(こちらの方がイメージは掴みやすいかも知れません)。

query では OfType を利用して、元々 List<string> を継承していた要素のみ抽出しています。

なお、実際は自分で定義した型・インターフェースに対して利用することが多くなると思います。

注意点(Cast は例外が発生することがある)

ところで、LINQ には Cast というメソッドもあります。
これも OfType と同じような働きをしますし、直観的には Cast の方がそれっぽい動きをしそうなので、うっかりこちらを使ってしまいがちです。

しかし、Cast は source の要素を指定した型へキャストできなかった時に例外を発生させます
また、自分の業務範囲ではあえて例外を発生させたい場面も存在しませんでした。
そのため、「Cast ではなく OfType を使う」と言い切ってしまっても良いと思います。

まとめ

この記事では、私が業務でよく利用する LINQ メソッドと利用する際の注意点をまとめました。

注意点としてまとめた内容は、かつて自分が実際に引っかかったものです。
特に、意図しない例外発生やパフォーマンス劣化が LINQ 特有の罠かと思います。

ただ、そこさえ乗り越えれば LINQ は直観的にコードを書ける道具となりますし、後から見直した時にも実装の意図を読みやすく、保守性が高くなります。
同じように罠に引っかかった方へ、何かの助けになれば幸いです。

参考資料

Enumerable Class (System.Linq) | Microsoft Docs

Microsoft 公式ドキュメントです(Methods 以下のページ)。
この記事で紹介していないメソッドもありますが、業務で使うことはそこまでありませんので割愛しました。

コメント

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