はじめに
C#erなら誰しもがLINQを使っていますよね?
WhereとかSelectとかよく使うと思います。あれって魔法みたいに見えますが、__その実ただのIEnumerable<T>の拡張メソッドでしかない__んですよね。
てことは自分で作れます。しかも、幸いにもC#にはyield returnがあるのでものすごく簡単に実装できます。
試しにWhereとSelectを自前で実装してみましょう。
Whereを自前実装する
Whereは受け取ったソースシーケンスの中から、条件に合致したものだけフィルタリングするオペレータです。
これを簡易的に自前で実装したコードは以下になります。
public static IEnumerable<T> MyWhere<T>(this IEnumerable<T> source, Func<T, bool> filter)
{
foreach (var item in source)
{
if (filter(item)) yield return item;
}
}
たったのこれだけで、Whereオペレータを再現することができます。
中身を見れば非常に単純ですよね。ソースをforeachして、フィルターが通ったものだけyield returnしてるだけです。
yield returnによって遅延評価なIEnumerable<T>がかんたんに生成できるので、たったのこれだけのコードでWhereオペレータが実装できてしまいました。
Selectを自前で実装する
同様にSelectも自前で実装してみましょう。
Whereと同様にyield returnを使うと、以下のようにかんたんに実装することができます。
public static IEnumerable<TResult> MySelect<TSource, TResult>(this IEnumerable<TSource> source, Func<TSource, TResult> selector)
{
foreach (var item in source)
{
yield return selector(item);
}
}
Selectの場合、コレクションの型を変換することになるので、型引数が2つ必要になります。
変換前のコレクションの型を表すTSourceと、変換後のコレクションの型を表すTResultですね。
そのため、受け取る第一引数はIEnumerable<TSource>、戻り値はIEnumerable<TResult>になっていることがわかると思います。
第二引数はFunc<TSource, TResult>になっています。TSourceからTResultに変換する処理を受けるためですね。
そして中身の実装はこれまた非常に単純で、ただ単にforeachですべての要素を変換処理かけてyield returnするだけです。とてもかんたんです。
使ってみる
自前で用意したMyWhereとMySelectを使ってみます。
class Program
{
static void Main(string[] args)
{
IEnumerable<int> source = Enumerable.Range(1, 10);
source.MyWhere(i => i > 5).Print();
source.MySelect(i => $"{i}です").Print();
}
}
ちなみにPrintは受け取ったコレクションの要素をすべてConsole.WriteLineするメソッドです。
これも拡張メソッドで書きました。
public static void Print<T>(this IEnumerable<T> source)
{
foreach (var item in source)
{
Console.WriteLine(item);
}
}
結果
6
7
8
9
10
1です
2です
3です
4です
5です
6です
7です
8です
9です
10です
正常に動作していることがわかります。
LINQオペレータの自作は簡単にできる
このように、yield returnがあるおかげでLINQオペレータはかんたんに自作できます。
LINQオペレータ自作のポイントをもう一度整理すると次のようになります。
-
IEnumerable<T>の拡張メソッドとして定義する - 戻り値も
IEnumerable<T>にする1 - 拡張メソッドの実装は
foreachしてyield returnが基本
特にこの__yield returnを使う部分はとても重要で、これを使わないと実装が非常に面倒になる or 遅延評価じゃなくなります__。これについては記事の最後で触れたいと思います。
完全自作オペレータCombineを作ってみる
LINQオペレータの作り方がわかったので、以下のような挙動の完全自作オペレータCombineを作ってみます。
- 2つのシーケンスを合成して1つのシーケンスにして返す。
- 合成の仕方を指定できる。
- 合成元の2つのシーケンスの要素数が異なる場合は、要素数が大きい方に合わせる。
シーケンスの合成というとZipが思い浮かびますが、Zipは合成元シーケンスの要素数が異なる場合には要素数が小さい方に合わせ、要素数が大きい方の溢れた要素は破棄されます。
それに対して今回新しく作るCombineは、要素数の大きい方に合わせ、不足分はdefaultで補完されるようにします。
動作イメージとしては、次のようなものを想定します。
//1~10の10個の要素
IEnumerable<int> ints = Enumerable.Range(1, 10);
//0.5~2.5の5個の要素
IEnumerable<double> doubles = Enumerable.Range(1, 5).Select(n => n * .5);
//intsとdoublesをそれぞれ掛け合わせた式と答えを表示
IEnumerable<string> strings =
ints.Combine(doubles, (i, d) => $"{i}×{d}={i * d}です");
1×0.5=0.5です
2×1=2です
3×1.5=4.5です
4×2=8です
5×2.5=12.5です
6×0=0です
7×0=0です
8×0=0です
9×0=0です
10×0=0です
シグネチャの決定
まず最初に、新しく作るCombineオペレータのシグネチャを決定します。
第一引数はひとつめの合成元シーケンスとしてthis IEnumerable<TSource1>とします。
第二引数はふたつめの合成元シーケンスとしてIEnumerable<TSource2>ですね。
合成する2つのシーケンスは型が異なっても良いので、型引数名を分けておきます。
第三引数には合成の仕方を指定できるようにするため、型はFunc<TSource1, TSource2, TResult>とします。
TResultは合成後のシーケンスの型です。したがって戻り値はIEnumerable<TResult>となります。
以上からCombineオペレータのシグネチャは以下のように決定します。
public static IEnumerable<TResult> Combine<TSource1, TSource2, TResult>(
this IEnumerable<TSource1> source1,
IEnumerable<TSource2> source2,
Func<TSource1, TSource2, TResult> combiner);
中身の実装
シグネチャが決まれば、あとはyield returnを活用して中身をゴリゴリ実装するだけですね。
以下のような実装になりました。
public static IEnumerable<TResult> Combine<TSource1, TSource2, TResult>(
this IEnumerable<TSource1> source1,
IEnumerable<TSource2> source2,
Func<TSource1, TSource2, TResult> combiner)
{
if (source1 is null) throw new ArgumentNullException(nameof(source1));
if (source2 is null) throw new ArgumentNullException(nameof(source2));
if (combiner is null) throw new ArgumentNullException(nameof(combiner));
var enumerator1 = source1.GetEnumerator();
var enumerator2 = source2.GetEnumerator();
while (true)
{
TSource1 element1;
TSource2 element2;
bool still1 = enumerator1.MoveNext();
bool still2 = enumerator2.MoveNext();
if (!still1 && !still2) yield break;
element1 = still1 ? enumerator1.Current : default;
element2 = still2 ? enumerator2.Current : default;
yield return combiner(element1, element2);
}
}
このようにして、System.Linq名前空間には存在しない自作のLINQオペレータを実装することができます。
追記:ArgumentNullExceptionを即時スローする
追記:上記コードではnullチェックも遅延評価されるため、標準のLINQオペレータの挙動とは若干異なる模様。詳細はここをクリックして表示してください。
@laughter さんにコメント頂きました。
上記実装のArgumentNullExceptionのスローの仕方では、例えば以下のようなコードを実行しても例外がスローされないようです。
var sq1 = Enumerable.Range(1, 10);
var sq2 = Enumerable.Range(1, 10).Select(n => n.ToString());
Func<int, string, string> nullFunc = null;
//第二引数はnullだが、例外がスローされない
var sq3 = sq1.Combine(sq2, nullFunc);
Console.WriteLine("ここまできたよ");
メソッド自体が遅延評価なゆえ、上記のように最後まで評価されないケースでは、引数がnullだとしても判定が発生しない。
(もちろんforeach等で評価すればきちんと例外はスローされます。)
引数にnullが指定された時点で即例外をスローするには、オペレータの拡張メソッド自体は__nullチェックのみに特化して即時評価__されるようにし、別途遅延評価されるオペレータのアルゴリズムが実装されたメソッドを呼び出してreturnする必要があるようです。
// オペレータの拡張メソッド本体。nullチェックのみに特化することで即時評価される
public static IEnumerable<TResult> Combine<TSource1, TSource2, TResult>(
this IEnumerable<TSource1> source1,
IEnumerable<TSource2> source2,
Func<TSource1, TSource2, TResult> combiner)
{
if (source1 is null) throw new ArgumentNullException(nameof(source1));
if (source2 is null) throw new ArgumentNullException(nameof(source2));
if (combiner is null) throw new ArgumentNullException(nameof(combiner));
// 遅延評価されるオペレータのアルゴリズム本体
return _Combine(source1, source2, combiner);
}
// 遅延評価されるオペレータの実装
private static IEnumerable<TResult> _Combine<TSource1, TSource2, TResult>(
IEnumerable<TSource1> source1,
IEnumerable<TSource2> source2,
Func<TSource1, TSource2, TResult> combiner)
{
var enumerator1 = source1.GetEnumerator();
var enumerator2 = source2.GetEnumerator();
while (true)
{
TSource1 element1;
TSource2 element2;
bool still1 = enumerator1.MoveNext();
bool still2 = enumerator2.MoveNext();
if (!still1 && !still2) yield break;
element1 = still1 ? enumerator1.Current : default;
element2 = still2 ? enumerator2.Current : default;
yield return combiner(element1, element2);
}
}
こうすれば無事にIEnumerable<T>が評価されなくても引数にnullを指定した時点でArgumentNullExceptionがスローされました。
yield returnの重要性
最後にyield returnの重要性について触れたいと思います。
この記事では当然のようにyield returnを使ってきましたが、仮にyield returnを使わずにLINQオペレータを実装しようとするとかなり大変 or 遅延評価じゃなくなります。
具象クラスにすると遅延評価じゃなくなる
当然ですがオペレータ内部でIEnumerable<T>を具象クラスにするとその時点で即時評価となるのでNGです。
public static IEnumerable<T> ImmediateMyWhere<T>(this IEnumerable<T> source, Func<T, bool> filter)
{
List<T> list = new List<T>();
foreach (var item in source)
{ //この時点で即時評価される
if (filter(item)) list.Add(item);
}
return list;
}
yield returnを使わずに遅延評価なWhereを実装する
遅延評価なWhereをyield returnを使わずに実装するには、下記のように専用の列挙子を実装する必要があり、かなり大変です。
public class WhereEnumerable<T> : IEnumerable<T>
{
private readonly Func<T, bool> filter;
private readonly IEnumerator<T> source;
public WhereEnumerable(IEnumerator<T> source, Func<T, bool> filter)
{
(this.filter, this.source) = (filter, source);
}
public IEnumerator<T> GetEnumerator() => new WhereEnumerator(source, filter);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public class WhereEnumerator : IEnumerator<T>
{
private readonly Func<T, bool> filter;
private readonly IEnumerator<T> source;
public WhereEnumerator(IEnumerator<T> source, Func<T, bool> filter)
{
(this.filter, this.source) = (filter, source);
}
public T Current => source.Current;
object IEnumerator.Current => Current;
public void Dispose()
{
}
public bool MoveNext()
{
//filterの結果がtrueになるまでMoveNextを繰り返す
do
{
if (!source.MoveNext()) return false;
} while (!filter(source.Current));
return true;
}
public void Reset()
{
throw new NotImplementedException();
}
}
}
そしてオペレータの拡張メソッドで専用の列挙子を持ったIEnumerable<T>なクラスをnewして返す必要があります。
public static IEnumerable<T> NonYieldMyWhere<T>(this IEnumerable<T> source, Func<T, bool> filter)
{
return new WhereEnumerable<T>(source.GetEnumerator(), filter);
}
かなーり大変です。
言い換えれば、これだけのことをyield returnはやってくれているということがわかります。
さいごに
Unity用ですが自作LINQオペレータいろいろ作って下記にアップしてます。
いわゆるオレオレライブラリなので使用は自己責任ですがなにかの参考になればと
自作LINQメソッドの実装は楽しいですがハマりすぎると本来の作業が疎かになるので注意。。
追記:ArgumentNullExceptionが即時スローされない件、時間を見つけて上記ライブラリも修正対応していきます。
-
もちろん
Tは必要に応じて変えて下さい ↩