yield returnを使わずにIEnumerable<T>を生成する


やりたいこと

yield return を使わずに、かつなるべく楽して IEnumerable<T> を返すメソッドを実装したい

本題

C#、で、例えばこんな感じの拡張メソッドを定義すると、これをLinq to Objectのメソッドチェーンの中で利用することができる。

linqex.cs
/// <summary>
/// シーケンスから、同一のキーを持つ要素の重複を取り除く
/// </summary>
public static IEnumerable<T> DistinctBy<T, TKey>(this IEnumerable<T> source, Func<T, TKey> keySelector)
{
    var done = new HashSet<TKey>();
    foreach (var item in source)
    {
        if (done.Add(keySelector(item)))
            yield return item;
    }
}

これと同じことを yield return を使わずに実現しようと思うと、正攻法では IEnumerable<T>IEnumerator<T> を実装したクラスをそれぞれ用意して、という感じで結構面倒くさい。

だったらこんな感じにすればいいんじゃないの?と思ってやってみると、

linqex.cs
public static IEnumerable<T> DistinctBy<T, TKey>(this IEnumerable<T> source, Func<T, TKey> keySelector)
{
    var done = new HashSet<TKey>();
    return source.Where(x => done.Add(keySelector(x)));
}

一見うまくいくんだけど、下の例みたいに生成された IEnumerable<T> を複数回列挙すると2回目以降の結果がおかしくなる。
こういう使い方をするのはレアなケースではあるのだけれど。

test.cs
[Test]
public void EnumerateTwice()
{
    var ret = new [] {1, 2, 3, 1, 2}.DistinctBy(x => x);
    Assert.That(ret.ToArray(), Is.EqualTo(new [] {1, 2, 3}));  // PASS
    Assert.That(ret.ToArray(), Is.EqualTo(new [] {1, 2, 3}));  // FAIL ret.ToArray() は空の配列になる
}

要は初期化(この場合はHashSetの生成)処理をメソッドチェーンの外でやってるのが問題なので、これをメソッドチェーンに埋め込んでしまえばいい訳で、こんな感じに変えてやると意図したとおりに動くようになる。

linqex.cs
public static IEnumerable<T> DistinctBy<T, TKey>(this IEnumerable<T> source, Func<T, TKey> keySelector)
{
    return new[] { source }.SelectMany(s =>
        {
            var done = new HashSet<TKey>();
            return s.Where(x => done.Add(keySelector(x)));
        });
}

別の例

linqex.cs
/// <summary>
/// シーケンスを指定されたサイズごとに分割する.
/// [1, 2, 3, 4, 5, 6, 7].EachSlice(3) -> [1, 2, 3], [4, 5, 6], [7]
/// </summary>
public static IEnumerable<T[]> EachSlice<T>(this IEnumerable<T> source, int size)
{
    return new[] { source }.SelectMany(s =>
        {
            var r = Enumerable.Repeat(s.GetEnumerator(), size).ToArray();
            return Enumerable.Repeat(0, int.MaxValue)
                             .Select(_ => r.TakeWhile(e => e.MoveNext()).Select(e => e.Current).ToArray())
                             .TakeWhile(arr => arr.Length > 0);
        });
}

おとなしくyield使えばいいんじゃないの?

デスヨネー
Visual Studio 2010でVBを書くとかそういう縛りプレイを強いられてる時にはそれなりに役に立つ気がする。
C#なら、まあyield使いますよね。はい。