Galileo Figaro

常に初陣!

LINQ の遅延評価

LINQ 初学者の認識はおそらくこう...

C#LINQ には、コレクションを処理する とても便利なメソッドがそろっています。 次のコードを見てみましょう。

var input = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var result = input
    .Select(x => x * x)
    .Where(x => x % 2 == 1)
    .Take(3);

foreach (var num in result)
{
    Console.WriteLine(num);
}

LINQ を触ったことのある人にとっては、なんてことはないですね。

  1. int 型配列を用意し
  2. 各要素を 2 乗し、
  3. そのうち、奇数だけを抜き出し
  4. 最初の 3 個を取り出し

しているだけです。 したがって、画面には「1」と「9」と「25」が表示されます。

ここで、LINQ の各メソッドを、メソッドチェインを使わずに書いてみます。 また、型を明示的に書いてみます。

var input = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
IEnumerable<int> result1 = input.Select(x => x * x);
IEnumerable<int> result2 = result1.Where(x => x % 2 == 1);
IEnumerable<int> result3 = result2.Take(3);

foreach (var num in result3)
{
    Console.WriteLine(num);
}

当然、表示される結果は変わりません。

ここで、LINQ の各メソッドから返ってくる、 IEnumerable<int> 型の戻り値は一体何なのでしょうか。 LINQ のメソッドによって作られたコレクションが返ってくる、 と答えたアナタ、おそらく下記のようなイメージで 考えているのではないでしょうか。

IEnumerable<int> result1 = input.Select(x => x * x);
// ↑ 返ってきた result1 には { 1, 4, 9, 16, 25, 36, 49, 64, 81, 100 } が入っている。

IEnumerable<int> result2 = result1.Where(x => x % 2 == 1);
// ↑ 返ってきた result2 には { 1, 9, 25, 49, 81 } が入っている。

IEnumerable<int> result3 = result2.Take(3);
// ↑ 返ってきた result3 には { 1, 9, 25 } が入っている。

そして、このイメージは残念ながら違います。

LINQ のメソッドに、コレクションを入力すると、 何らかの処理が施されたコレクションが返ってくる、 という考え方は確かに直感的であるため、 前述のようなイメージを持ってしまいがちです。 C# を学び始めるときは、その方がとっつきやすいですが、 慣れてきたらその考えを改めましょう。

遅延評価とは

遅延評価は、「必要になる時まで評価しない」という考え方です。 LINQ 関係のメソッドは、すべてこの考え方で処理を行います。

また IEnumerable<T> は、直訳すると「列挙可能」であることを 表すインターフェイスですが、まぁよく分かりませんよね。

この IEnumerable<T> は、配列やリストや辞書のような、 複数の値を格納しておくものではありません。 そういう意味で、「コレクションのようでコレクションにあらず」です。 そのため、コレクションと区別して「シーケンス」と言ったりします。

実際には「コレクションをどのように操作するかを表したもの」 が近いと思います。

冒頭サンプルの正しい動き

前章で IEnumerable<T> はコレクションでないと言ったばかりですが、 ここでは、IEnumerable<T> の中身を { 1, 2, 3 } のように 書いて説明するとします。 その方が概念分かりやすいので...

さて、冒頭サンプルを実行すると、上から順に処理が進み、 SelectWhereTake が呼ばれ、 最後に foreach でループが回されます。

foreach の直前までたどり着いた時点の、 各メソッドの戻り値を示してみます。

var input = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

IEnumerable<int> result1 = input.Select(x => x * x);
// result1 は {  }
IEnumerable<int> result2 = result1.Where(x => x % 2 == 1);
// result2 は {  }
IEnumerable<int> result3 = result2.Take(3);
// result3 は {  }

// ★
foreach (var num in result3)
{
    Console.WriteLine(num);
}

「★」までたどり着いた時点では、 IEnumerable<T> の中身は空っぽです。 なぜなら、「まだ必要とされてない」からです。

その後 foreach 文に入り、result3 から最初の 1 つ目を取り出そうとします。 ここで初めて、シーケンスの 1 つ目の要素が要求されます。

すると、

  1. input の最初の「1」が取り出され
  2. 1 * 1 が評価され 1 となり
  3. 1 % 2 == 1 の条件もクリアし
  4. Take(3) の条件もクリアし

ループ内変数 num1 が入ってきます。 ここまでの各メソッドの戻り値の内容は、 次のようになっています。

var input = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

IEnumerable<int> result1 = input.Select(x => x * x);
// result1 は { 1 }
IEnumerable<int> result2 = result1.Where(x => x % 2 == 1);
// result2 は { 1 }
IEnumerable<int> result3 = result2.Take(3);
// result3 は { 1 }

foreach (var num in result3)
{
    Console.WriteLine(num); // →「1」と表示
}

次のループでは、result3 から、 2 つ目の要素を取り出そうとします。 すると、

  1. input から「2」が取り出され、
  2. 2 * 2 が評価され 4 になります。

しかしその次の 4 % 2 == 1 を満たさないので、 4 についてはここで評価が打ち切られます。 続いて、

  1. input から次の「3」が取り出され、
  2. 3 * 3 が評価され 9 となり、
  3. 9 % 2 == 1 の条件もクリアし、
  4. Take(3) の条件もクリアし、

ループ内変数 num9 が入ってきます。 ここまでの各メソッドの戻り値の内容は 次のようになっています。

var input = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

IEnumerable<int> result1 = input.Select(x => x * x);
// result1 は { 1, 4, 9 }
IEnumerable<int> result2 = result1.Where(x => x % 2 == 1);
// result2 は { 1, 9 }  ←「4」はこの時点で評価打ち切り
IEnumerable<int> result3 = result2.Take(3);
// result3 は { 1, 9 }

foreach (var num in result3)
{
    Console.WriteLine(num); // →「9」と表示
}

このように、foreach で次の値を要求するたびに、 最初の input から値が取り出されて、 x * xx % 2 == 1 が順に評価されていきます。

foreach で 3 つ目の値を取り出すときも同様です。 次のようになるでしょう。

var input = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

IEnumerable<int> result1 = input.Select(x => x * x);
// result1 は { 1, 4, 9, 16, 25 }
IEnumerable<int> result2 = result1.Where(x => x % 2 == 1);
// result2 は { 1, 9, 25 }  ←「16」はこの時点で評価打ち切り
IEnumerable<int> result3 = result2.Take(3);
// result3 は { 1, 9, 25 }

foreach (var num in result3)
{
    Console.WriteLine(num); // →「25」と表示
}

さて、ここまでの繰り返しで、 result3 からは 3 つの値を取り出しました。 これ以上のシーケンスの評価は無意味であり、 この時点で foreach 文を抜けます。 なぜなら、Take(3) と指定されているので、 せいぜい 3 つまでの値しか必要ないからです。

というわけで、input 配列の「6」以降の数字は、 取り出されることすらなく、プログラムを終わります。

遅延評価のメリット

無駄な評価を省ける

前述の例では、Take(3) と指定していたので、 最初の 3 つの値が求まったら、処理を打ち切っていました。

このように、不必要な計算をするのを回避することができます。

メモリ容量の節約

File.ReadLines メソッドは、 ファイルの全行を読み出すメソッドです。 しかし、戻り値は IEnumerable<string> であり、 「必要になった時点で、次の行を読み出す」 という動きになります。

例えば、下記の例では、 foreach で次の行が要求されるたびに、 ファイルから 1 行読み込まれ、 "aaa" と連結され、表示されます。

var result1 = File.ReadLines(@"d:\foo\bar.txt");
var result2 = result1.Select(line => "aaa" + line);
foreach (string line in result2)
{
    Console.WriteLine(line);
}

特に巨大なファイルを読み込む場合は、 すべての行を読み込んでから処理を行うと 多くのメモリを消費しますが、 1 行ずつ読んで処理するのであれば、 メモリ消費は少なくて済みます。

すべてのデータが揃わなくても処理を開始できる

例えばネットワークを介してデータを受け取る場合、 すべてのデータが揃うまでそれなりに時間がかかります。 すべてのデータが揃うのを待って、 データを加工しようとすると、 加工処理の着手が遅れてしまいます。

1個ずつ取り出して加工するようにすれば、 到着したデータから順次、加工処理をすることができます。

「必要になったら」とは?

前述の foreach では、値を取り出すたびに 値が一つ計算されていました。 foreach で「値が必要になった」からです。 そのほかに、評価が必要になるメソッドを考えてみましょう。

Any()、All()

Any は、要素が一つでも条件を満たしたら true、 All は、要素がすべて条件を満たしたら true となります。

これらのメソッドは bool 値を返さないといけないので、 呼んだ時点で値をひとつずつ評価していきます。

Any は条件を満たす要素が見つかったら、 All は条件を満たさない要素が見つかったら、 それ以降の評価は不要なので、そこで評価を打ち切ります。

First()、FirstOrDefault()

条件を満たす最初の要素を返すメソッドです。 条件を満たす最初の要素が見つかったら、 その時点で以降の評価を打ち切ります。

Count()

要素の個数を求めるメソッドです。 要素を個数を数えるには、 要素をすべて計算しなければなりませんので、 Count() を呼んだ時点でシーケンスの すべての要素の評価が行われます。

ToList()、ToArray()、ToDictionary()

これらはシーケンスからコレクションを生成するメソッドです。 コレクションを生成するには、 コレクションに含めるすべての要素を得る必要がありますので、 これらを呼んだ時点で、シーケンスの すべての要素の評価が行われます。

Max()、Min()、Average()、Sum()

要素の最大値、最小値、平均値、最大値を計算するメソッドですが、 それらを求めるためには要素がすべて必要ですので、 これらが呼ばれた時点で、シーケンスの すべての要素の評価が行われます。

前半おわり

以上が、LINQ の遅延評価の基本的なことです。 長くなったのでここでいったん切りましょう。

SelectWhere を呼んだ時点で 各要素が評価されるわけではなく、 「必要になった時点で評価される」 ということを意識しましょう。