はじめに
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
は必要に応じて変えて下さい ↩