🔗

作って学ぶLINQのしくみ

2020/11/04に公開

LINQはお好きですか?便利でステキですよね。
しかし便利で簡単に使えるがゆえに、何となく雰囲気で使っているという方もいらっしゃるようです。雰囲気でも使えるのは素晴らしいことですが、しくみから理解した上で使えばさらに威力を発揮してくれるのではないでしょうか。

しくみを理解するには、作ってみるのがいちばん!というわけで、LINQを作るのに必要な知識をおさらいしつつ、実際に作ってみましょう。

foreachの作り方

LINQの話に行く前に、まずはforeachを理解しましょう。
foreachはどんなコレクション(配列、List、Dictionaryなど)でも1つずつ要素を取り出して、処理を記述できる構文です。

var array = new int[] { 0, 1, 2, 3, 4 };
foreach (var element in array)
{
    Console.WriteLine(element);
}

「どんなコレクションでも」と書いてしまいましたが、1つ重要な前提条件があります。それは「IEnumerableインタフェースを持つこと」です。

  • IEnumerableインタフェースは、GetEnumerator()IEnumeratorインタフェースを実装したオブジェクトを取得する機能を提供する
  • IEnumeratorインタフェースは、MoveNext()で次の値への参照を取得し、その返り値がtrueならばCurrentプロパティで値を得られる、という機能を提供する

これらを利用して、foreach相当の処理は次のように書けます。本来は以下のように書くべきところを、コンパイラが自動的に同等のコードを生成してくれています。

var array = new int[] { 0, 1, 2, 3, 4 };
var enumerator = array.GetEnumerator();
while (enumerator.MoveNext())
{
    System.Console.WriteLine(enumerator.Current);
}

IEnumerableだと列挙するオブジェクトの型を規定できないので、実際にはIEnumerable<T>インタフェースが用いられています。全てのコレクションがIEnumerable<T>インタフェースを統一的に実装しているおかげで、foreachやLINQが使えるわけです。(逆に言えば、コレクションと名乗るものを作るならこれらの実装は必須と言えます)

LINQの作り方

IEnumerator<T>の機能が理解できたならば「MoveNext()で素直に1つずつ次に進めるのではなく、条件を満たすものまで進めるようにする」とか「Currentにセットするものを、異なる型の値に変換するようにしたら」などと妄想が膨らみます。膨らみませんか?膨らませましょう。

それです!それがLINQです!

MyWhere

ではまず、偶数の値だけ列挙するIEnumerator<int>を作ってみましょう。

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

public class OddEnumerator : IEnumerator<int>
{
    // 列挙の元になるコレクションのEnumeratorを保持する
    private readonly IEnumerator<int> _source;

    // Currentは元のコレクションの値をそのまま返す
    public int Current => this._source.Current;

    // 型引数無し版インタフェースのための実装
    object IEnumerator.Current => this.Current;

    // コンストラクタでEnumeratorを取得して保持する
    public OddEnumerator(IEnumerable<int> source)
    {
        this._source = source.GetEnumerator();
    }

    // IDisposableも継承しているので、DisposeでソースのEnumeratorを破棄する
    public void Dispose()
    {
        this._source.Dispose();
    }

    // リセット処理もソースのEnumeratorのResetだけでよい
    public void Reset()
    {
        this._source.Reset();
    }
    
    // ここがポイント!
    public bool MoveNext()
    {
        // 必ず1回はMoveNextする(初期状態は先頭要素の手前を指すため)
        while (this._source.MoveNext())
        {
	    // Currentが条件に合致したらtrueを返す
            if (this._source.Current % 2 == 0)
            {
                return true;
            }
        }

        // 条件に合致せず、終端まで行ったらfalseを返す
        return false;
    }
}

このEnumeratorを返すEnumerableと、それを簡単に作れる拡張メソッドを用意します。

public class OddEnumerable : IEnumerable<int>
{
    private readonly IEnumerable<int> _source;

    public OddEnumerable(IEnumerable<int> source)
    {
        this._source = source;
    }

    public IEnumerator<int> GetEnumerator()
    {
        return new OddEnumerator(this._source);
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }
}

public static class MyLinq
{
    public static IEnumerable<int> WhereOdd(this IEnumerable<int> source)
    {
        return new OddEnumerable(source);
    }
}

ここまでできれば、後はLINQのメソッドと同じように使うだけです!

var array = new int[] { 0, 1, 2, 3, 4 };
foreach (var element in array.WhereOdd())
{
    System.Console.WriteLine(element);
}

0,2,4 という出力結果が得られるはずです。やったー!

しかし、賢い皆さんなら思うはずです。偶数を抽出するだけで毎度毎度これだけのコードを書くのはバカらしい、と。抽出条件に関数を自由に突っ込めれば、そもそもint専用ってなんでやねん、と。

じゃあやりましょう。条件を関数デリゲートで指定できるようにしつつ、型をジェネリックで自由にします。

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

public class WhereEnumerator<T> : IEnumerator<T>
{
    private readonly IEnumerator<T> _source;
    private readonly Func<T, bool> _predicate;

    public T Current => this._source.Current;
    object IEnumerator.Current => this.Current;

    public WhereEnumerator(IEnumerable<T> source, Func<T, bool> predicate)
    {
        this._source = source.GetEnumerator();
        this._predicate = predicate;
    }

    public void Dispose()
    {
        this._source.Dispose();
    }

    public void Reset()
    {
        this._source.Reset();
    }

    public bool MoveNext()
    {
        while (this._source.MoveNext())
        {
            if (this._predicate(this._source.Current))
            {
                return true;
            }
        }

        return false;
    }
}

public class WhereEnumerable<T> : IEnumerable<T>
{
    private readonly IEnumerable<T> _source;
    private readonly Func<T, bool> _predicate;

    public WhereEnumerable(IEnumerable<T> source, Func<T, bool> predicate)
    {
        this._source = source;
        this._predicate = predicate;
    }

    public IEnumerator<T> GetEnumerator()
    {
        return new WhereEnumerator<T>(this._source, this._predicate);
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return _source.GetEnumerator();
    }
}

public static class MyLinq
{
    public static IEnumerable<T> MyWhere<T>(this IEnumerable<T> source, Func<T, bool> predicate)
    {
        return new WhereEnumerable<T>(source, predicate);
    }
}

public class Program
{
    public static void Main()
    {
        var array = new int[] { 0, 1, 2, 3, 4 };
        foreach (var element in array.MyWhere(i => i % 2 == 0).MyWhere(i => i >= 2))
        {
            System.Console.WriteLine(element);
        }

        var strArray = new string[] { "ABC", "BCD", "CDE", "DEF", "EFG" };
        foreach (var element in strArray.MyWhere(s => s.Contains("CD")))
        {
            System.Console.WriteLine(element);
        }
    }
}

多段に差せて、異なる型でも使えます。これで使用感、書き味ともにSystem.LinqのEnumerable.Whereと同じになりました。

MySelect

ではもう1つ、値の変換を行うSelectも完コピしてみましょう。
MyWhereで利用したジェネリックを更に応用し、変換後の型も型引数で受けるようにします。

public class SelectEnumerator<T, U> : IEnumerator<U>
{
    private readonly IEnumerator<T> _source;
    private readonly Func<T, U> _mapper;

    // Currentで値を取得する際に変換を噛ませる
    public U Current => this._mapper(this._source.Current);
    object IEnumerator.Current => this.Current;

    public SelectEnumerator(IEnumerable<T> source, Func<T, U> mapper)
    {
        this._source = source.GetEnumerator();
        this._mapper = mapper;
    }

    public void Dispose()
    {
        this._source.Dispose();
    }

    public void Reset()
    {
        this._source.Reset();
    }

    public bool MoveNext()
    {
        return this._source.MoveNext();
    }
}

MoveNext()がソースの実行結果を返すだけになっているぶん、MyWhereよりもシンプルになっています。ポイントは、変換前の型をT、変換後の型をUとして、Enumerator自体はIEnumerator<U>を継承して実装することと、Currentプロパティに関数デリゲート_mapperによる変換を噛ませることです。

このEnumeratorを返すEnumerableと、そのEnumerableを生成する拡張メソッドを用意します。ジェネリックの型引数が2つになっている以外は、MyWhereの時と大差ありません。

public class SelectEnumerable<T, U> : IEnumerable<U>
{
    private readonly IEnumerable<T> _source;
    private readonly Func<T, U> _mapper;

    public SelectEnumerable(IEnumerable<T> source, Func<T, U> mapper)
    {
        this._source = source;
        this._mapper = mapper;
    }

    public IEnumerator<U> GetEnumerator()
    {
        return new SelectEnumerator<T, U>(this._source, this._mapper);
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return _source.GetEnumerator();
    }
}

public static class MyLinq
{
    public static IEnumerable<U> MySelect<T, U>(this IEnumerable<T> source, Func<T, U> mapper)
    {
        return new SelectEnumerable<T, U>(source, mapper);
    }
}

使用感はこんな感じ。

var array = new int[] { 0, 1, 2, 3, 4 };
foreach (var element in array.MySelect(i => i + "HOGE"))
{
    System.Console.WriteLine(element);
}

int型の数値を文字列に変換し、それに"HOGE"を付加したものが出力されます。

Contains, Min, Maxなどの集計系

LINQには、コレクションが持つ値に対する検証結果をbool値で得たり(All, Any, Containsなど)、最小・最大値を得たり(Min, Max)といった様々なメソッドがあります。これを本稿では「集計系メソッド」と呼ぶことにします。

集計系メソッドの実装は、WhereやSelectよりもシンプルで、拡張メソッドを1つ作るだけで事足ります。試しにContainsを実装してみましょう。

public static class MyLinq
{
    public static bool MyContains<T>(this IEnumerable<T> source, T value) where T : IEquatable<T> 
    {
        foreach (var element in source)
        {
            if (element.Equals(value))
            {
                return true;
            }
        }

        return false;
    }
}

正直にロジックを実装するだけです。Containsは等値判定が必要なので、ジェネリックの型制約でIEquatable<T>を指定しています。MinやMaxならばIComparable<T>を指定すれば良いでしょう。

作ってみてわかること

集計系はあっさりした説明になりましたが、実際に作ってみるとメソッドの系統による動作の違いに気がつくと思います。

WhereやSelectは、拡張メソッド自体の動作はIEnuerableを生成して返しているだけです。なので、この時点では関数デリゲートの呼び出しは行われていませんし、ループも回りません。

それに対して集計系の拡張メソッドでは「呼び出し時に実際にループが回る」という点が大きな違いとなります。

LINQで記述した式が評価されるタイミングは以下の通りです。

  • foreachなどで利用する
  • 集計系メソッドで結果を得る
  • ToArray(),ToList()などでコレクション化する(これらもある意味集計系メソッドと言える)

LINQを使い始めの頃は「不安なのでとりあえずToArray()/ToList()しちゃう」という方もいらっしゃると思いますが、その場で1回限りのforeachに使うだけなら不要です。フィルタした結果の集合を複数回参照する必要がある場合のみ、配列・リスト・辞書化などを行いましょう。

逆にToArray()などをせず、LINQ式によって得られるIEnumerable<T>の値を保持して複数箇所で使い回した場合、LINQ式に記述した関数がその都度評価されてループが回ることになります。これはReshaperなどでは「複数回イテレーションされるけど、処理速度効率的に大丈夫?」という意図のサジェスチョン対象になっています。コレクション化することで生じるメモリ確保とどちらがコストになるかを判断して、適切な方を選択する必要があります。

まとめ

  • IEnumerable/IEnumeratorというインタフェースのおかげでLINQが作れた
  • IEnumerableを返す拡張メソッドと、それ以外の値を返す拡張メソッドとで挙動が異なる
  • 式が評価されるタイミングを把握して、コレクション化の有無を上手に使い分けよう

それでは良きLINQライフをお楽しみください。

Discussion