LoginSignup
154

More than 1 year has passed since last update.

基礎から学ぶLINQの仕組み

Last updated at Posted at 2016-12-20

C# Advent Calendar 2016の記事です。社内勉強会の資料をリライトしました。新人研修を終えてそれなりにC#が書ける程度の新人が想定読者です。

このテキストについて

LINQを自力で実装するために必要なC#の知識をまとめています。LINQの詳細な動作を知ると様々な応用がききます。ぜひ知っておきましょう。

具体的には次のコードが理解できるようになるのが目標です。

public static IEnumerable<TResult> Select<TSource, TResult>(
    this IEnumerable<TSource> source,
    Func<TSource, TResult> selector)
{
    foreach (var item in source) {
        yield return selector(item);
    }
}

短いコードに様々な要素が詰め込まれています。このテキストを読むとこのコードの意味がわかるようになります。そのために以下の機能を順に紹介します。

  • Generics
  • 拡張メソッド
  • デリゲート
  • IEnumerable<T>とIEnumerator<T>
  • イテレータ構文

プログラム初心者へ

勉強会に参加したりWebで情報収集をしていると知らない単語や概念が頻繁に出てきます。一度に全て理解しようとするのは難しいと思います。

まずはその技術で何ができるのかを大まかに覚えてください。それが必要になったときに「そういえばあんな機能があったな」と思い出せるようになりましょう。詳しい使い方はそのときに検索して調べれば大丈夫です。

このテキストでもそれぞれの機能で何ができるかを中心に紹介しています。詳細な文法が必要になったら自分で調べてください。

Generics

Genericsを使うと型をパラメータ化できます。それによって同じアルゴリズムを違う型に適用できます。

Clampメソッドを実装する例

具体的にClampメソッドを実装しながらGenericsを説明します。

Clampメソッドは、指定された数値を最小値と最大値の範囲に丸めて返すメソッドです。第1引数に「範囲内に丸めたい数」、第2引数に「最小値」、第3引数に「最大値」をそれぞれ指定します。例えば以下のように使えます。

const int MIN = 0;
const int MAX = 100;

var inputValue = 130;
inputValue = Clamp(inputValue, MIN, MAX); // inputValueは最大値の100になる

var inputValue2 = 50;
inputValue2 = Clamp(inputValue2, MIN, MAX); // inputValue2は範囲内なのでそのまま

var inputValue3 = -100;
inputValue3 = Clamp(inputValue3, MIN, MAX); // inputValue3は最小値の0になる

Genericsなしで実装

まずはGenericsを使わずに実装してみます。例えば以下のようになります。

public static int Clamp(int value, int min, int max)
{
    if (max < min) {
        throw new ArgumentOutOfRangeException(nameof(max), "max must be greater than min");
    }
    if (max < value) {
        return max;
    }
    if (value < min) {
        return min;
    }
    return value;
}

このメソッドは引数がintの場合にしか使えません。doubleでも使うには、intをdoubleに変えただけのメソッドを別に定義しなければなりません。さらにfloatやdecimalなど他の型でも使おうとするたびに新しいメソッドが必要です。似たようなコードが何度も書かれていることは悪いコードの特徴です。

このように、中身がほとんど同じコードをいろいろな型で使いたいときにGenericsを使います。

Genericsありで実装

Genericsを使った実装は以下になります。

public static T Clamp<T>(T value, T min, T max)
    where T : IComparable<T>
{
    if (min.CompareTo(max) > 0) {
        throw new ArgumentOutOfRangeException(nameof(max), "max must be greater than min");
    }
    if (value.CompareTo(max) > 0) {
        return max;
    }
    if (min.CompareTo(value) > 0) {
        return min;
    }
    return value;
}

Clamp<T>のように<>で囲って型パラメータを書きます。ここではTを型パラメータとして使うことを宣言しています。これよって、このメソッドでTを抽象的な型として使えます。このTは実際にメソッドを呼び出す際にintやdoubleなどの具体的な型に置き換わると考えてください。

where T : IComparable<T> はTの制約条件です。ただのTでは抽象的すぎて何もできません。Clampメソッドを実装するにはT型のvalue, min, maxの値の大きさを比較する必要があります。比較ができるように「TはIComparable<T>インターフェースを実装している」という制約条件をつけています。IComparable<T>を実装していない型ではClmapメソッドを使えなくなってしまいますが、intやdoubleなどC#組み込みの数値型はIComparalbe<T>を実装しているので問題ありません。

メソッドの中では値を比較するためにCompareToを使っています。CompareToはIComparable<T>で宣言されているためT型のvalue, min, maxでも使えます。

Genericsのまとめ

  • 同じアルゴリズムを複数の型に適用したいときに使います。
  • 似たようなメソッドやクラスを1箇所にまとめて書けます。同じメソッドを何個も書くよりコード量が減って可読性や保守性が高まります。

拡張メソッド

拡張メソッドはクラス、インターフェース、列挙型のような型の定義の外からメソッドを追加できます。

Direction列挙型を実装する例

拡張メソッドの使い方を説明するため、上下左右の方向を表すDirection列挙型と指定された方向から右を向くTurnRightメソッドを実装してみます。

拡張メソッドなしで実装

拡張メソッドを使わなければ以下のように実装できます。

// 向いている方向を表す
public enum Direction
{
    Left,
    Up,
    Right,
    Down
}

public static class DirectionHandler
{
    // 指定された方向から90度右を向いた方向を返す
    public static Direction TurnRight(Direction dir)
    {
        switch(dir)
        {
            case Direction.Left:  return Direction.Up;
            case Direction.Up:    return Direction.Right;
            case Direction.Right: return Direction.Down;
            case Direction.Down:  return Direction.Left;
            default: throw new NotImplementedException();
        }
    }
}

C#では列挙型にメソッドを定義できません。そのため、TurnRightメソッドをDirectionHandlerという別クラスに定義しています。

これは以下のように使えます。

// 左向きから2回右を向く使用例
Direction dir = Direction.Left;
dir = DirectionHandler.TurnRight(dir); // LeftからUpになる
dir = DirectionHandler.TurnRight(dir); // UpからRightになる

右を向くだけのことにdir = DirectionHandler.TurnRight(dir)というのは長すぎます。もし列挙型にメソッドを定義できてdir = dir.TurnRight()と書ければ便利です。拡張メソッドを使うとそれが可能です。

拡張メソッドありで実装

拡張メソッドを使った実装は以下になります。拡張メソッドなしの実装から変更したのは1箇所だけです。TurnRightメソッドの引数がthis Direction dirと先頭にthisがついています。これによって、TurnRightメソッドがDirection型の拡張メソッドになります。

// 向いている方向を表す
public enum Direction
{
    Left,
    Up,
    Right,
    Down
}

public static class DirectionHandler
{
    // 指定された方向から90度右を向いた方向を返す
    public static Direction TurnRight(this Direction dir)
    {
        switch(dir)
        {
            case Direction.Left:  return Direction.Up;
            case Direction.Up:    return Direction.Right;
            case Direction.Right: return Direction.Down;
            case Direction.Down:  return Direction.Left;
            default: throw new NotImplementedException();
        }
    }
}

これは以下のように使えます。

// 左向きから2回右を向く使用例
Direction dir = Direction.Left;
dir = dir.TurnRight(); // LeftからUpになる
dir = dir.TurnRight(); // UpからRightになる

コードが短くわかりやすくなりました。

拡張メソッドは「対象の型の定義の外にあるstaticメソッド」をインスタンスメソッドのように呼び出せるようになる機能です。先ほどの例ではTurnRightメソッドはDirection型の外に定義されています。しかしdir.TurnRight()とDirection型のメソッドのように呼び出せています。

拡張メソッドの利点の1つとして、拡張メソッドはインテリセンスで補完できます。ここでは「dir.」を打ち込んだ時点でTurnRightが自動補完の候補として表示されます。Direction型を使うときは、本当はDirectionHandlerというクラスがあることすら知らずにすみます。

また、TurnRight()メソッドを複数回呼び出す際に dir.TurnRight().TurnRight() のようにつなげて記述できます。拡張メソッドなしではDirectionHandler.TurnRight(DirectionHandler.TurnRight(dir))のようになります。

さらにTurnLeft(左回り)やTurnAround(回れ右)のようなメソッドを追加したとしましょう。拡張メソッドではdir.TurnRight().TurnAround().TurnLeft()のように自然な形でメソッドを組み合わせられます。同じ処理を拡張メソッドなしで書くとDirectionHandler.TurnLeft(DirectionHandler.TurnAround(DirectionHandler.TurnRight(dir)))になります。拡張メソッドの方が短くわかりやすいはずです。メソッドが実行される順番とコード上で現れる順番が一致するのもわかりやすくなるポイントです。TurnRightしてからTurnAroundしてさらにTurnLeftする、ということがそのまま読み取れます。

既存のインターフェースを拡張する例

次はインターフェースの拡張メソッドの例です。Genericsの説明でも使ったIComparable<T>に拡張メソッドを追加します。

IComparable<T>のCompareToメソッドは、自身と引数に与えられた値を比較して、自身の方が大きければ「0より大きい整数」、同じなら「0」、小さければ「0より小さい整数」を返します。なんだかわかりづらくないでしょうか。1わかりやすい名前を拡張メソッドで付けてみます。これは以下のようになります。

public static class ComparableExtension
{
    // aがbよりも大きければtrueを返す
    public static bool IsBiggerThan<T>(this T a, T b) where T : IComparable<T>
    {
        return a.CompareTo(b) > 0;
    }

    // aとbが同じ大きさならtrueを返す
    public static bool IsEqual<T>(this T a, T b) where T : IComparable<T>
    {
        return a.CompareTo(b) == 0;
    }

    // aがbよりも小さければtrueを返す
    public static bool IsLessThan<T>(this T a, T b) where T : IComparable<T>
    {
        return a.CompareTo(b) < 0;
    }
}

この拡張メソッドは以下のように使えます。

var x = 120;
var y = 100;
x.IsBiggerThan(y); // true
x.IsEqual(y);      // false
x.IsLessThan(y);   // false

IComparable<T>の定義は全く変えず、外からメソッドを追加できました。

拡張メソッドのまとめ

  • 列挙型やインターフェースのようなメソッドを持てない型にメソッドを追加できます。
  • IComparable<T>のような既存の型に対してメソッドを追加できます。
  • インスタンスメソッドのように呼び出せるおかげで可読性の高いコードになることがあります。

デリゲート

デリゲートを使うとメソッドを変数に代入できます。

演算表を出力する例

説明のために掛け算の表と足し算の表を出力するプログラムを実装してみます。

デリゲートを使って実装する

デリゲートを使うと以下のようになります。PrintOperationTableメソッドは第1引数にメソッドを受け取り、それを使って計算した結果を表として出力します。実行したときの出力もすぐ下に載せています。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("掛け算の表を出力");
        PrintOperationTable(Multiply);

        Console.WriteLine("足し算の表を出力");
        PrintOperationTable(Add);
    }

    // 引数にint2つをとってintを返すメソッドのデリゲート
    public delegate int Operator(int x, int y);

    // 引数に渡された演算の演算表を出力する
    public static void PrintOperationTable(Operator op)
    {
        for (var x = 1; x < 4; ++x) {
            for (var y = 1; y < 4; ++y) {
                Console.Write("{0,3}", op(x, y));
            }
            Console.WriteLine();
        }
    }

    public static int Add(int x, int y) { return x + y; }
    public static int Multiply(int x, int y) { return x * y; }
}
掛け算の表を出力
  1  2  3
  2  4  6
  3  6  9
足し算の表を出力
  2  3  4
  3  4  5
  4  5  6

PrintOperationTableの引数にAddを渡すかMultiplyを渡すかでPrintOperationTableの出力結果が変わっています。

public delegate int Operator(int x, int y); がデリゲートの宣言です。Operatorを「int x, int yという2つの引数を受け取ってintを返すメソッドのデリゲート」として宣言しています。この宣言したデリゲートをPrintOperationTableの引数に使ってメソッドを受け取ります。

Funcを使って改良する

Operatorデリゲートに代入できるのは引数と戻り値が一致するメソッドのみです。例えば引数がint3つになれば他のデリゲートを宣言しなければなりません。

デリゲートを使うたびにいちいち宣言するのは面倒です。そのため、よく使う種類のデリゲートはC#のライブラリで用意されています。これを使うと以下のように書き換えられます。変更点は2箇所です。Operatorの宣言がなくなり、PrintOperationTableの引数もOperatorからFunc<int, int, int>になっています。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("掛け算の表を出力");
        PrintOperationTable(Multiply);

        Console.WriteLine("足し算の表を出力");
        PrintOperationTable(Add);
    }

    // 引数に渡された演算の演算表を出力する
    public static void PrintOperationTable(Func<int, int, int> op)
    {
        for (var x = 1; x < 4; ++x) {
            for (var y = 1; y < 4; ++y) {
                Console.Write("{0,3}", op(x, y));
            }
            Console.WriteLine();
        }
    }

    public static int Add(int x, int y) { return x + y; }
    public static int Multiply(int x, int y) { return x * y; }
}

Func<int, int, int>はintの引数を2つ受け取りintを返すメソッドのデリゲートです。<>の中に引数と戻り値の型を指定して様々なデリゲートを定義できます。Func<bool>なら引数なしでboolを返すメソッド、Func<double, float>なら引数がdouble1つでfloatを返すメソッドのデリゲートになります。

ここでは使ってませんが、Actionというデリゲートも用意されています。これはFuncの戻り値が無いバージョンです。Actionなら引数なし、Action<int, bool>なら引数にintとboolをとるメソッドのデリゲートです。

ラムダ式を使って改良する

PrintOperationTableにAddメソッドとMultiplyメソッドを渡していますが、実際に渡している箇所と定義部分が離れていて少しわかりづらいです。ラムダ式を使うとメソッドをインラインで書けるようになります。これを使うと以下のように書き換えられます。Add, Multiplyメソッドがなくなり、PrintOperationTableを呼び出すときにラムダ式を使っています。

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("掛け算の表を出力");
        PrintOperationTable((x, y) => x * y);

        Console.WriteLine("足し算の表を出力");
        PrintOperationTable((x, y) => x + y);
    }

    // 引数に渡された演算の演算表を出力する
    public static void PrintOperationTable(Func<int, int, int> op)
    {
        for (var i = 1; i < 4; ++i) {
            for (var j = 1; j < 4; ++j) {
                Console.Write("{0,3}", op(i, j));
            }
            Console.WriteLine();
        }
    }
}

コードが短くなり、一連の処理が1箇所にまとまって読みやすくなりました。

なお、(x, y) => x + y は最大限に省略した書き方です。これを省略せずに書くと以下のようになります。

(int x, int y) => { return x + y; }

ここではFunc<int, int, int>として渡すことが分かっているので引数の型であるintは省略できます。省略すると次のようになります。

(x, y) => { return x + y; }

さらに、ラムダ式の中身がreturnの一文だけの場合はブロックの{}とreturnを省略できます。すると(x, y) => x + yになります。

デリゲートのまとめ

  • デリゲートはメソッドを代入できる型です。
  • FuncやActionを使うとデリゲートの宣言を省略できます。
  • ラムダ式を使うとデリゲートに代入する箇所に直接メソッドの内容を書けます。

IEnumerable<T>とIEnumerator<T>

IEnumerable<T>, IEnumerator<T>インターフェースではデータを順番に取り出すためのメソッドとプロパティが宣言されています。ListやDictinoaryのようなコレクションはIEnumerable<T>を実装しており、要素を取り出す処理を共通に使えるようになっています。

IEnumerable<T>

IEnumerable<T>ではGetEnumeratorメソッドのみ宣言されています。このメソッドは引数なしで戻り値がIEnumerator<T>型のオブジェクトになります。詳しくは次のIEnumerator<T>と一緒に説明します。

IEnumerator<T>

IEnumerator<T>には1つのプロパティと2つのメソッドが含まれます。簡略化すると次のような形です。

public interface IEnumerator<T>
{
    // 現在の位置のデータを表します。
    T Current { get; }

    // 現在の位置から次の位置に移動します。
    // 次の位置にデータがあればtrue、なければfalseを返します。
    bool MoveNext();

    // 最初の位置に戻ります。
    // LINQ to Objectではほぼ使われません。
    void Reset();
}

IEnumerable<T>とIEnumerator<T>の機能を使うと、Listの要素を最初から順に出力するコードは以下のようになります。

var list = new List<int> { 2, 3, 5, 8 };   // 例として使うリストを用意

IEnumerator<int> e = list.GetEnumerator(); // リストからEnumeratorを取得
while (e.MoveNext()) {                     // MoveNextがfalseを返す(=最後の位置まで移動する)まで繰り返す
    Console.WriteLine(e.Current);          // 現在の位置の要素を出力する
}

この例の通り、IEnumerable<T>, IEnumerator<T>は一般に以下の流れで使います。これはListに限らずIEnumerable<T>を実装しているクラスなら共通に使えます。

  1. データを取得したいコレクションのGetEnumeratorを呼び出してIEnumerator<T>を取得します。
  2. 取得したIEnumerator<T>のMoveNextを呼び出して最初のデータに進みます。データがなければこの時点でfalseが返ります。
  3. 現在の位置のデータをCurrentプロパティで取得します。
  4. 再びMoveNextを呼び出して次のデータに進みます。最後の位置に進むまで3と4を繰り返せば全てのデータを取得できます。

foreach

foreachはこの1~4を自動的に実行してくれます。先ほどのListの要素を順に出力するコードをforeachで書き換えると以下になります。

var list = new List<int> { 2, 3, 5, 8 }; // 例として使うリストを用意

foreach (var item in list) {
    Console.WriteLine(item); // itemに現在の位置の要素が入っているので出力する
}

コード上に書かれてはいませんが、実際にはGetEnumeratorやMoveNextが呼び出されます。

コレクションを実装する例

IEnumerable<T>とIEnumerator<T>のさらなる理解のため、簡単なコレクションクラスを実装してみます。仕様は以下の通りです。

  • クラス名はDozenIntegersとする。
  • intを12個格納できる。初期状態は12個全て0。
  • void Set(int index, int value)メソッドを持つ。指定した位置に指定した数値を格納できる。
  • int Get(int index)メソッドを持つ。指定した位置に格納された数値を返す。

実装は以下になります。

// 12個のintデータを格納するコレクション
public class DozenIntegers
{
    // 格納できるデータの数
    private const int MAX = 12;

    // データは配列に格納する。初期状態は全て0
    private int[] data = new int[MAX];

    // 第1引数で指定された位置に、第2引数で指定されたデータを格納する
    public void Set(int index, int value)
    {
        if (index >= MAX || 0 > index) { throw new ArgumentOutOfRangeException("index"); }
        data[index] = value;
    }

    // 指定された位置のデータを返す
    public int Get(int index)
    {
        if (index >= MAX || 0 > index) { throw new ArgumentOutOfRangeException("index"); }
        return data[index];
    }
}

このクラスは以下のように使えます。

var data = new DozenIntegers();

data.Set(0, 10); // 0番に10を格納
data.Set(1, -4); // 1番に-4を格納

int n = data.Get(1); // 1番のデータを取得

foreachに対応する

IEnumerable<T>を実装すればforeachが使えるようになります。DozenIntegersクラスで実装してみましょう。しかし、実装する前にもう少しIEnumerable<T>とIEnumerator<T>について知らなければなりません。

まず、IEnumerable<T>はIEnumerableを継承しています。そして、IEnumerator<T>はIEnumeratorとIDispose<T>を継承しています。つまりIEnumerable<T>を実装するにはIEnumerableも実装する必要があり、IEnumerator<T>を実装するにはIEnumeratorとIDisposeも実装する必要があります。これらのインターフェースについて説明します。

IEnumerableインターフェース

IEnumerableはIEnumerable<T>と同じくGetEnumeratorメソッドのみが宣言されています。IEnumerable<T>.GetEnumeratorの戻り値はIEnumerator<T>でしたが、IEnumerable.GetEnumeratorの戻り値はIEnumeratorになります。

IEnumeratorインターフェース

IEnumeratorはIEnumerator<T>と同じくMoveNext, ResetメソッドとCurrentプロパティが宣言されています。IEnumerator<T>とIEnumeratorの唯一の違いは、Currentプロパティの型がIEnumerator<T>ではT、IEnumeratorではobjectになっている点です。

IDisposeインターフェース

IDisposeでは唯一Disposeメソッドが宣言されています。戻り値と引数はありません。Disposeはそのオブジェクトを使い終わったときに呼ぶことになっています。使用したリソースの解放のような後処理をするメソッドです。

以上をまとめると、これから実装するforeach対応のDozenIntegersクラスは以下のようなクラス図として表せます。IEnumerator<T>はDozenIntegers.Enumeratorクラスとして実装します。GetEnumeratorとCurrentは戻り値の型が異なる2つのバージョンを実装する必要があります。

class_diagram.png

実装

実装は以下になります。必要なメソッドが多くコードが長いです。しかし内容は簡単なので読んでみてください。GetEnumeratorメソッドと、そこで返すためのEnumeratorクラスが追加されています。

// 12個のintデータを格納するコレクション
public class DozenIntegers : IEnumerable<int>
{
    // 格納できるデータの数
    private const int MAX = 12;

    // データは配列に格納する。初期状態は全て0
    private int[] data = new int[MAX];

    // 第1引数に指定された位置に、第2引数に指定されたデータを格納する
    public void Set(int index, int value)
    {
        if (index >= MAX || 0 > index) { throw new ArgumentOutOfRangeException("index"); }
        data[index] = value;
    }

    // 指定された位置のデータを返す
    public int Get(int index)
    {
        if (index >= MAX || 0 > index) { throw new ArgumentOutOfRangeException("index"); }
        return data[index];
    }

    // 自身のEnumeratorを返す
    public IEnumerator<int> GetEnumerator()
    {
        return new Enumerator(this);
    }

    // IEnumerableのGetEnumeratorも実装する
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator(); // 中身は同じなのでもう1つのGetEnumeratorをそのまま呼ぶ
    }

    // GetEnumeratorで返されるEnumeratorクラス
    public class Enumerator : IEnumerator<int>
    {
        DozenIntegers source; // GetEnumeratorを呼ばれたDozenIntegersオブジェクト

        int index = -1; // 現在の位置

        // GetEnumeratorを呼ばれたオブジェクトを記録しておく
        public Enumerator(DozenIntegers source)
        {
            this.source = source;
        }

        // 現在の位置から次の位置に移動する
        public bool MoveNext()
        {
            // ++indexを続けるとオーバーフローするので先にチェック
            if (index > MAX)
                return false;

            ++index;

            // 最大値を超えていなければデータがあるのでtrueを返す
            return MAX > index;
        }

        // 現在の位置のデータを取得して返す
        public int Current { get { return source.Get(index); } }

        // IEnumeratorのCurrentも実装する
        object IEnumerator.Current { get { return Current; } }

        // Enumeratorを使い終わった後の処理。ここでは何もしない
        public void Dispose() { }

        // 最初の位置に戻る。使わないので未実装
        public void Reset() { throw new NotImplementedException(); }
    }
}

簡単に実装の説明をします。GetEnumeratorメソッドはEnumeratorクラスのオブジェクトを生成して返します。以降の処理はIEnumerator<T>を実装しているEnumeratorクラスの責任になります。

IEnumerator.GetEnumeratorとしてIEnumeratorのGetEnumeratorも実装しています。このような定義の書き方をインターフェースの明示的実装といいます。動作はIEnumerable<T>のGetEnumeratorと同じなのでそのまま呼びます。

Enumeratorクラスのコンストラクタでは生成元のDozenIntegersオブジェクトを受け取ります。ここからデータを取得します。

MoveNextメソッドでは次の位置に移動します。現在の位置をindexに保持しているのでインクリメントします。

Currentプロパティではindexの位置のデータを取得して返します。

Disposeメソッドでは何もしません。Enumertorの中で解放するべきリソースなどを使っていればここで解放します。

ResetメソッドはLINQ to Objectの範囲ではまず使いません。未実装にしてあります。

foreachに対応したDozenIntegersは以下のように使えます。

var data = new DozenIntegers();

data.Set(0, 10); // 0番に10を格納
data.Set(1, -4); // 1番に-4を格納
data.Set(2, 32); // 2番に32を格納

foreach(var i in data) {
    Console.WriteLine(i); // データを順番に出力
}

IEnumerable<T>とIEnumerator<T>のまとめ

  • C#のコレクションはIEnumerable<T>を実装しています。
  • foreachではIEnumerable<T>の機能を使ってコレクションを走査しています。
  • 自作のコレクションでもIEnumerable<T>を実装すればforeachに使えます。

イテレータ構文

自作コンテナをforeachに対応するとコードが長くなり大変です。イテレータ構文を使うとforeachの対応が簡単になります。

イテレータ構文を使って改良する

foreachに対応したDozenIntegersを改良すると以下のようになります。GetEnumeratorメソッドのyield returnイテレータ構文です。

// 12個のintデータを格納するコレクション
public class DozenIntegers : IEnumerable<int>
{
    // 格納できるデータの数
    private const int MAX = 12;

    // データは配列に格納する。初期状態は全て0
    private int[] data = new int[MAX];

    // 第1引数に指定された位置に、第2引数に指定されたデータを格納する
    public void Set(int index, int value)
    {
        if (index >= MAX || 0 > index) { throw new ArgumentOutOfRangeException("index"); }
        data[index] = value;
    }

    // 指定された位置のデータを返す
    public int Get(int index)
    {
        if (index >= MAX || 0 > index) { throw new ArgumentOutOfRangeException("index"); }
        return data[index];
    }

    // 自身のEnumeratorを返す
    public IEnumerator<int> GetEnumerator()
    {
        for (int index = 0; index < MAX; ++index) {
            yield return data[index];
        }
    }

    // IEnumerableのGetEnumeratorも実装する
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator(); // 中身は同じなのでもう1つのGetEnumeratorをそのまま呼ぶ
    }
}

Enumeratorクラスが丸ごと無くなりました。これでも先ほどのDozenIntegersと同じ動作をします。

イテレータ構文の動作

イテレータ構文(yield return)の動作を説明します。

yield returnはIEnumerableを返すメソッドの中でしか使えません。ここではGetEnumerator()メソッドで使っています。

GetEnumerator()が呼び出されたときにGetEnumeratorの中身は実行されません。その代わり、yield returnなしのバージョンのGetEnumeratorと同じことが起きると考えてください。つまりIEnumerator<T>を実装したDozenIntegers.Enumeratorクラスが生成されて返されます。しかし今回のコードではDozenIntegers.Enumeratorクラスを定義していません。実はyield returnの機能として、DozenIntegers.Enumeratorに相当するクラスが自動的に定義され使われています。この自動生成されたEnumeratorクラスのMoveNext()が呼ばれたときに初めてGetEnumeratorクラスの中身が実行されます。

次のようなコードをVisual Studioのステップイン実行で追いかけると実際の動作がわかりやすいです。

var data = new DozenIntegers();

IEnumerator<int> e = data.GetEnumerator();
e.MoveNext();
Console.WriteLine(e.Current);
e.MoveNext();
Console.WriteLine(e.Current);
e.MoveNext();
Console.WriteLine(e.Current);
e.MoveNext();
Console.WriteLine(e.Current);

最初のe.MoveNext()でGetEnumerator()の中身が実行されます。しかしMoveNext()は次の要素があればtrue、なければfalseを返すメソッドのはずです。つまりGetEnumerator()の中身がそのままMoveNext()として実行されるわけではありません。GetEnumerator()の中身は、MoveNextが呼ばれたときに次の要素を生成するコードです。yield returnで返した値が次の要素として扱われます。yield returnを通らずメソッドの最後まで実行されると次の要素がないことを表します。

yield returnには次の要素を返すほかに、もう1つ重要な役割があります。値を返した時の状態を記憶しておいて、次にMoveNext()が呼ばれたときに次の行から実行を再開する機能です。2回目以降のe.MoveNext()ではGetEnumerator()の最初からではなく、前回yield returnで戻った時の状態から再開されます。これによってe.MoveNext()を呼び出すたびに次の要素を順に取り出せます。

コルーチンとしてのイテレータ構文

実行の途中で中断したり、中断した状態から再開できるメソッドをコルーチンと言います。C#のイテレータ構文はコルーチンとしての機能と要素を列挙する機能が複合していて理解しづらいです。他の言語のコルーチン機能を調べてみると理解の助けになります。(参考:PHP のコルーチンを使ってみる)

コルーチンは状態を持てるメソッドです。通常のメソッドは一度呼び出したら終わりで状態を持てません。クラスは状態を持てて何でもできます。コルーチンはメソッドとクラスの中間のようなものと考えられるかもしれません。特に「ひとまとまりの機能だが何回かにわけて実行される」ような機能を作るのに適しています。要素を順番に取り出す処理はその典型的な例です。

コルーチンの簡単な例として以下のコードを見てください。RPG風のゲームで3ターンかけてスライムに切りかかります。要素を生成する機能としては使わないのでyield returnではnullを返します。

class Program
{
    static IEnumerable Attack(string monster)
    {
        Console.WriteLine("{0}に向かって突進した", monster);
        yield return null;
        Console.WriteLine("{0}に向かって剣をふりあげた", monster);
        yield return null;
        Console.WriteLine("{0}に攻撃が当たった", monster);
    }

    static void Main(string[] args)
    {
        int turn = 1;
        var e = Attack("スライム").GetEnumerator();
        do {
            Console.WriteLine("{0}ターン目", turn);
            ++turn;
        } while (e.MoveNext());
    }
}

実行すると以下のように出力されます。

1ターン目
スライムに向かって突進した
2ターン目
スライムに向かって剣をふりあげた
3ターン目
スライムに攻撃が当たった

AttackメソッドをMoveNext()を呼ぶたびにyield returnの続きから実行できるメソッドとして使っています。イテレータ構文の機能は「MoveNext()で処理が再開するついでにyield returnの戻り値がCurrentプロパティに入る」程度のイメージの方がわかりやすいかもしれません。

同じことをコルーチンなしで実装しようとするとモンスターの名前や現在の状態をメンバ変数に覚えておかなければなりません。この程度の機能にクラスを書くのは少し面倒です。

これは簡単な例でしたが、普通に書くといくつかのメソッドに分かれてしまうような記述を1つのメソッドにまとめて書いたりできるようになります。

イテレータ構文のまとめ

  • イテレータ構文を使うとIEnumerable<T>, IEnumerator<T>を短いコードで実装できます。
  • コルーチンは状態を持ったメソッドです。処理の途中で中断と再開ができます。
  • イテレータ構文はコルーチンです。yield returnの戻り値をCurrentに入れてIEnumerable<T>, IEnumerator<T>の実装手段とされています。

LINQ to Object

LINQを理解するのに必要な機能は説明し終わりました。実際にLINQを実装してみます。

Select

Selectの実装は以下になります。このテキストの一番最初に載せたコードです。どのように動作するかわかるでしょうか。

public static IEnumerable<TResult> Select<TSource, TResult>(
    this IEnumerable<TSource> source,
    Func<TSource, TResult> selector)
{
    foreach (var item in source) {
        yield return selector(item);
    }
}

第1引数をthis IEnumerable<TSource> sourceとしてデータを取り出すIEnumerableを受け取ります。thisがついているのでIEnumerable<TSource>の拡張メソッドです。Genericsを使ってIEnumerable<int>やIEnumerable<double>など、いろいろな型に対して使えるようにしています。

第2引数のFunc<TSource, TResult> selectorはTSource型を受け取ってTResult型を返すメソッドのデリゲートです。IEnumerable<TSource>の要素であるTSourceを受け取り、戻り値のIEnumerable<TResult>の要素であるTResultを返すためこうなっています。

foreach (var item in source)ではsourceから順に要素を取り出してitemに入れています。IEnumerable<T>を実装していればforeachが使えることを思いだしてください。

yield return selector(item);では取り出した要素をselectorに渡して呼び出します。その戻り値は変換されたTResultを次の要素としてそのまま返します。

Where

Whereの実装は以下になります。

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source,
    Func<TSource, bool> predicate)
{
    foreach (var item in source) {
        if (predicate(item)) {
            yield return item;
        }
    }
}

ほとんどSelectと同じです。selectorで変換するのではなくpredicateで条件に合う要素を取り出します。LINQのメソッドの多くは似たような形になっているので1つ理解できれば大体わかるようになります。

メソッドチェーン

SelectとWhereで返される値はIEnumerable<T>です。よってLINQやforeachが使えます。

static void Main(string[] args)
{
    var list = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    var oddEnumerable = list.Where(x => x % 2 == 0); // => 2,4,6,8
    var doubledEnumerable = oddEnumerable.Select(x => x * 2); // => 4,8,12,16
    foreach (var item in doubledEnumerable) {
        Console.WriteLine(item);
    }
}

ここではわかりやすいようにメソッドごとに分けていますが、通常はlist.Where(x =&gt; x % 2 == 0).Select(x =&gt; x * 2)とつなげて書くことが多いです。このようにメソッドをつなげて使うことをメソッドチェーンと言います。

このコードが実行されると次のシーケンス図のような流れで動作します。foreach (var item in doubledEnumerable)で最初の要素を取得する際の図になっています。

  • foreachではGetEnumerator(), MoveNext(), Currentが呼ばれます。
  • yield returnのおかげでコード上では省略されていますが、内部的にDozenIntegers.Enumeratorのようなクラスのオブジェクトが自動生成されています。
    • 図では仮にSelectEnumerator, WhereEnumerator, ListEnumeratorという名前をつけています。
  • Selectメソッドのforeach, WhereメソッドのforeachによってもMoveNextが次々に呼ばれていきます。
    • このようにLINQの内部ではいくつものオブジェクトが連なって動作しています。

sequence_diaglam.png

独自の拡張メソッドを実装する

SelectとWhereの動きがわかったところでLINQの機能を拡張してみます。

Trace

例としてLINQの間に挟んで要素の内容を出力するTraceメソッドを実装します。例えば以下のように使えます。

var list = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var query = list.Where(x => x % 2 == 0).Trace().Select(x => x * 2);
foreach (var item in query) {}

上記ではWhereでフィルタリングされた直後の値が出力されるので2,4,6,8が出力されます。

実装は以下のようになります。

public static IEnumerable<T> Trace<T>(this IEnumerable<T> source)
{
    foreach (var item in source) {
        Console.WriteLine(item);
        yield return item;
    }
}

次の値を取得したらConsole.WriteLineで出力しているだけです。値はそのままyield returnで返します。

LINQ to Objectのまとめ

  • IEnumerable<T>の拡張メソッドとして実装されています。IEnumerable<T>を実装している全てのクラスで利用できます。
  • 動作を理解していればLINQに足りない機能を追加できます。

参考URL

  • foreach - C# によるプログラミング入門
    より正確なforeachの動作が説明されています。このテキストのforeachの説明はかなり簡略化しています。

  • Reimplementing LINQ to Objects
    WhereとSelectだけではなく全てのLINQのメソッドを解説しながら実装しています。エラー処理も含めてしっかり実装されています。LINQのメソッドはなかなか覚えきれませんが一度作れば忘れません。

  • LINQの仕組み&遅延評価の正しい基礎知識
    LINQの動作について説明されています。このテキストだけでなく他の方による説明も読むとさらに理解が深まるはずです。

  • 実際の実装
    C#のライブラリはオープンソースになっています。本物のLINQのコードもここから読めます。

脚注

  1. 最後の0まで含めてメソッドの一部として覚えましょう。a.CompareTo(b) > 0a > bという意味です。a.CompareTo(b) == 0a == ba.CompareTo(b) <= 0a <= b として解釈できます。aとbの間に演算子がそのまま入ります。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
154