.NET開発者中心 厳選ブログ記事

LINQの仕組み&遅延評価の正しい基礎知識
―― ブログ「neue cc」より ――

河合 宜文
2011/08/10

「.NET開発者中心 厳選ブログ記事」シリーズでは、世界中にある膨大なブログ・コンテンツの中から、特にInsider.NET/.NET開発者中心の読者に有用だと考えられるブログ記事を編集部が発掘・厳選し、そのブログ記事を執筆したブロガーの許可の下、その全文を転載・翻訳しています。この活動により、.NET開発者のブログ文化の価値と質を高め、より一層の盛り上げに貢献することを目指しています。

本稿は、ブログ記事「neue cc: LINQの仕組みと遅延評価の基礎知識」に簡単な校正・加筆を行ったうえで転載したものです。第2回のブロガー「尾上 雅則 氏」によるお勧めブログ記事です。

 本稿では、LINQについて基礎から理解することを目的に、その仕組みと遅延評価について最初から解説します(「何をもって最初/基礎とするか」は人により異なると思いますが、本稿の解説はあくまで、わたしなりの基準における基礎です)。

 なお、ここではLINQ to ObjectsとIEnumerable<T>オブジェクトの連鎖についてのみ扱います。すべてのコード例はC#で記述します。

メソッド・チェーン != return this

 単純なコードで、IEnumerable<T>オブジェクトに対するLINQ to Objectsのメソッド・チェーンの例を示します。

var query = Enumerable.Range(1, 10).Select(i => i * i).Take(5);
foreach (var item in query)
{
  Console.WriteLine(item);
  // 1
  // 4
  // 9
  // 16
  // 25
}
IEnumerable<T>オブジェクトに対するLINQ to Objects(メソッド構文)のメソッド・チェーンの例

 「1〜10をそれぞれ二乗した10個の数値のうちの、先頭の5つを出力する」という、それだけのコードです。

 LINQ文をドット(.)でつなげていると、実体が隠れてしまいがちなので、下記のように分解します。

var rangeEnumerable = Enumerable.Range(1, 10);
var selectEnumerable = rangeEnumerable.Select(i => i * i);
var takeEnumerable = selectEnumerable.Take(5);
foreach (var item in takeEnumerable)
{
  Console.WriteLine(item);
}
LINQ to Objects(メソッド構文)のメソッド・チェーンの分解

 変数だらけでゴチャゴチャしているので、余計に分かりにくい。よく分からないものは、図にしましょう。

分解されたLINQ to Objects(メソッド構文)と各変数の図

 こうなっています。中に、1つ前のものを内包している新しいオブジェクトを返しています。

 「メソッド・チェーン」というと、いわゆるビルダ的な、もしくはjQueryのような仕組みを想像してしまうのではないでしょうか? 例えば、チェーンごとに内部の状態が変化して、それを「return this」として返却するか、もしくは完全に新しいオブジェクトを生成して返す(例えば、「array.filter.map」したら「.filter」のチェーンで完全に新しい配列が生成され、それが戻り値として返り、「.map」のチェーンでも同じように別の新規オブジェクトが作成されるなど)という仕組みを想像してしまう方も少なくないでしょうが、実はそのどちらでもありません。

 LINQのメソッド・チェーンは、元の包み(=オブジェクト)を中にしまい込んで新しい包みを返します。この仕組みは、次の画面のようにVisual Studioのデバッガで確認できます。

Visual Studioのデバッガで確認できる、LINQのメソッド・チェーンの仕組み
特に、各変数の型(=各メソッドの戻り値)と、各変数に所属するsourceフィールド変数の型を確認してください。

 Takeメソッドの戻り値であるTakeIterator型のオブジェクトは、そのフィールド変数「source」として、中にSelectメソッドの戻り値であるWhereSelectEnumerableIterator型のオブジェクトを抱えており、Selectメソッドの戻り値は、Rangeメソッドの戻り値であるRangeIterator型のオブジェクトを、中に抱えています。このような連鎖が成り立っていることが、上の画面で示したデバッガにより、しっかりと確認できました。

 余談ですが、この結果で興味深いのは、Selectメソッドの戻り値の型が「WhereSelectEnumerableIterator」となっていて、名前のとおり、WhereとSelectが統合されていることです。これは、「Where」->「Select」が頻出パターンなので、それらを統合することでパフォーマンスを向上させるためでしょう。

遅延評価と実行

 xxxEnumerable変数(=前述の「selectEnumerable」/「takeEnumerable」などのIEnumerable<T>オブジェクト)の中に包まれている状態では、まだ何も実行は開始されていません。そう、LINQは遅延評価されます!

 このままWhereメソッドやSkipメソッドをつないでも、新たなxxxEnumerable変数で包んで返されるだけで、クエリの実行はされません。「では、いつ実行されるか?」といえば、IEnumerable<T>オブジェクト以外の、何らかの結果を要求したときです。それは、例えばToArrayメソッドであったり、Maxメソッドであったり、foreach文であったりが、呼び出されたときです。具体的には、メソッドやforeach文がIEnumerable<T>オブジェクトのGetEnumeratorメソッドを呼ぶときです。上記に示したコードでは、foreach文の時点でクエリ処理が実行されます。

 foreach文を実行したときのLINQによるクエリ実行の流れを、図で見ると、次のようになります。

foreach文を実行したとき(=遅延評価されたとき)の、LINQ実行の流れ

上の図の左端:
 まず最初は、最外周のtakeEnumerable変数に対してGetEnumeratorメソッドを実行し、IEnumerator<T>オブジェクトを取り出します。そして、取り出したIEnumerator<T>オブジェクトに対してMoveNextメソッド(=列挙子が持つ、次の要素に移動するメソッド)を実行すると、そのメソッド内ではまた、中に抱えたIEnumerable<T>オブジェクトに対してGetEnumeratorメソッド実行でIEnumerator<T>オブジェクトを取り出し……、という連鎖が、大本(この場合はrangeEnumerable変数)に届くまで続きます。

上の図の中央:
 大本まで届いたら、いよいよMoveNextメソッドの結果(=true:移動できたか、false:移動できなかったか)が返されます。trueの場合は、通常は即座に現在値(=Currentプロパティの値)の取得も行うので、Currentプロパティの値が大本から最外周まで降りていくイメージとなります。

上の図の右端:
 あとは、どこかのMoveNextメソッドがfalseを返してくるまで、上記の流れを繰り返します。

 今回は、Rangeオブジェクトが10個の数値を出力し、Takeメソッドがそこから先頭の5個を出力します(このため、Rangeオブジェクトが生成する数値は5個分が余ります)。Takeメソッドは、列挙を途中で打ち切るわけですが、6回目のMoveNextメソッドの戻り値でfalseを流して、クエリの実行を終了させます。SumメソッドやCountメソッドなど、戻り値として値(=IEnumerable<T>オブジェクト以外の、何らかの結果)を返すものは、falseが届いたら、そのメソッドの処理結果を返します。しかし、今回はforeach文なので、「void」、つまり何も返却なしで終了します。

イテレータの実装

 より理解を深めるため、以下ではLINQのクエリ動作の実態である「イテレータ」を実装します。

 下記のコード例は、0〜10までの数値を返すだけの、単純なイテレータの実装例です。

using System;
using System.Collections;
using System.Collections.Generic;

public class ZeroToTenIterator : IEnumerator<int>
{
  private int current = -1;

  public int Current
  {
    get { return current; }
  }

  public bool MoveNext()
  {
    return ++current <= 10;  // 「11」以降はfalseを返す
  }

  // ※この例ではリソース解放は不要なので空にしている
  public void Dispose() { }

  // Tではない方のCurrentプロパティでは、上記のTの方のCurrentプロパティ値を返すようにするだけでOK
  object IEnumerator.Current { get { return Current; } }

  // Resetメソッドは、実装不要です。というのも、「yield return」によるイテレータの自動生成がNotSupportedException例外を発生させるため、現実的には使用不可能なメソッドとなっているからです。理想をいえばインターフェイスからも削られてほしいぐらい
  public void Reset() { throw new NotImplementedException(); }
}
LINQのクエリ動作の実態であるイテレータの実装例

 上記のイテレータを使うときは、例えば下記のようなコードになるでしょう。

// IEnumerator<T>オブジェクト利用時は、リソース解放を行えるusing文による記述を忘れないように……
using (var e = new ZeroToTenIterator())
{
  while (e.MoveNext())
  {
    Console.WriteLine(e.Current);
    // 0
    // 1
    // 2
    // 3
    // 4
    // 5
    // 6
    // 7
    // 8
    // 9
    // 10
  }
}
上記のイテレータの利用例

 IEnumerator<T>インターフェイスの実装ですが、ここまで見てきたとおり、中核となるのはMoveNextメソッドとCurrentプロパティです。といっても、Currentプロパティはキャッシュした値を中継するだけなので、実質的に実装しなければならないのはMoveNextメソッドだけ(場合により、Disposeメソッドも実装が必要です)。

 MoveNextメソッドは、見たとおりに1行で記述された超単純なコード内容です。具体的には、「10」を超えるまでインクリメントされて(戻り値として)「true」を返し、「10」を超えたら「false」を返します。これだと、戻り値が「false」になるときにMoveNextメソッドを呼んだら、呼び出すたびにCurrentプロパティの値がどんどん増加していってしまいます。こんないい加減な実装内容で大丈夫なのか、というと、全然問題ありません。なぜなら、それは利用側の問題であり、実装側が気にする必要はないからです。

 MoveNextメソッドを呼び出す前のCurrentプロパティの値は保証されていないので使うな、であり、MoveNextメソッドが「false」を返した後の、Currentプロパティの値は保証されてないので使うな、です。これは、(IEnumerator<T>インターフェイスの実装を直接、利用するうえで)大事なお約束です。

 このお約束を守れない人は、生イテレータを使うべからず。LINQのクエリ演算子やforeach文は、そんなお約束を考えないで済むようになっているので、それらを使いましょう。生イテレータを取得したら、負けです(拡張メソッド定義時は除く、つまりライブラリ的な局面以外では避けましょう)。

 ちなみに、String型オブジェクトのイテレータは、列挙前/列挙後のCurrentプロパティへのアクセスで例外が発生します。また、List<T>オブジェクトは、列挙前のCurrentプロパティ値は「0」、列挙後も「0」にリセットされ、Enumerable.Rangeメソッドで取得できるオブジェクトでは、列挙後は最後の値が返ります。このように、イテレータの挙動はバラバラです。

 実装側が守らなければならないルールは、MoveNextメソッドが一度「false」を返したら、以後はずっと「false」を返し続けること。

 その観点で、このZeroToTenIteratorクラスの実装内容を見直すと、(先ほどは「全然問題ない」と書きましたが)実のところ正確には全然ダメです。例えば、MoveNextメソッドがint.MaxValue回呼び出されると、currentフィールド変数の値がオーバーフローしてint.MinValueになって、つまりはMoveNextメソッドの結果も「false」から「true」に変わってしまいます。実装内容が腐っているわけで、本来なら、もっと洗練させるべきです。

 今回はイテレータを理解していただくために実装しましたが、今どきはイテレータの手実装なんて、する必要はありません! シンプルなものならばLINQの組み合わせで実現できますし、そうでないものは「yield return」を使えばいいので。手実装しなければならないシチュエーションは、ほとんどないでしょう。

まとめ

 以上、本稿で説明したのは、

  1. 「LINQのメソッド・チェーンは、『return this』ではなくて、以前のオブジェクトを包含した新しいオブジェクトを返している」
  2. 「クエリ結果は、配列的なイメージで扱えるけれど、実体はストリームに近い」
  3. 「Visual Studioのデバッガは素晴らしい」
  4. 「生イテレータは禁止」

の4点でした。

 LINQ to ObjectsのJavaScript移植である「linq.js」も同じ仕組みでクエリ処理を実現しているので、そちらのコードの方がブラック・ボックスでなく、また、素直に書いているので、理解するには分かりやすい面もあるかもしれません。

 ところで、「基礎からのLINQ」といえば、紹介したいシリーズが1つあります。

 英国ロンドンのGoogle所属の開発者で、かつMicrosoft MVPで、さらに『C# in Depth』の著者で、英語圏で有名な技術系Q&Aサイト「Stack Overflow」ですさまじい回答量を誇るJon Skeet氏が、Blogで「Reimplementing LINQ to Objects(英語)」と称して、これまたすさまじい勢いでLINQの再実装&超詳細な解説を行っているので必見です。

 LINQは、今後「ますます」重要になるので、しっかり土台を固めて、未来へ向かおう!End of Article

【筆者プロフィール】
河合 宜文

 Microsoft MVP for Visual C#。特にLINQとその周辺技術(Reactive Extensionsなど)が好みで追いかけている。LINQ好きが高じてJavaScriptに移植したライブラリ「linq.js」も作成、公開中。


インデックス・ページヘ  「.NET開発者中心 厳選ブログ記事」

@IT Special

- PR -

TechTargetジャパン

Insider.NET フォーラム 新着記事
  • 配列のコピーを1行でするには? (2017/4/26)
     配列をコピーするには、for/foreachループを使う方法もあるが、ArrayクラスのCopyメソッドを使うのが一番簡単で速度の面でも有利である
  • Microsoft Small Basic (2017/4/25)
     Microsoft Small Basicは学習を目的としてマイクロソフトが提供しているBASICの処理系。シンプルな言語仕様、習得が容易、簡潔な記述がその特徴
  • 第2回 Visual Studio 2017の基礎を知る (2017/4/21)
     開発環境Visual Studio 2017を使ったプログラミングに不可欠な知識とは? ソリューションの概念から画面構成まで基礎を説明
  • XmlSerializerでシリアライズ/デシリアライズする (2017/4/19)
     XmlSerializerクラスでシリアライズ/デシリアライズを行うと、デシリアライズに失敗することがある。その回避策を含め、XmlSerializerクラスの使い方を説明する
@ITメールマガジン 新着情報やスタッフのコラムがメールで届きます(無料)
- PR -

イベントカレンダー

PickUpイベント

- PR -

アクセスランキング

もっと見る

ホワイトペーパーTechTargetジャパン

注目のテーマ

業務アプリInsider 記事ランキング

本日 月間
ソリューションFLASH