C#
LINQ

[C#][LINQ] IEnumerable<T> の分割 (多分実用的)

More than 1 year has passed since last update.

まえがき

だいぶ前に [C#][LINQ] IEnumerable を要素数を指定して分割する 超デリケートな拡張メソッド という記事を書いたんですが、全く実用的でないので実用的(と思われるもの)を作ってみました。

調子にのって nuget パッケージ まで出してしまいました。。
ネーミングセンス()

30 分で書いたコードなので 変なところあったら PR とかいただけたり issue 立ててもらえたりするとめっちゃ喜びます。

github

実装の解説

今回目指したのは

  • List<T> とか使わない、配列だけでやる
  • 同じ要素は 1 回だけ列挙する

というところです。

完全な遅延評価は残念ながら提供できてません。
要素を size 個 先に列挙してしまうのは、 Split() のあとすぐに ToArray()Count() などが呼ばれた場合にもただしく動作させるためです。
(不用意に Count() をつかうのはやめましょうというのは以前書いたことがあるので興味ある方は: 【C#】【LINQ】それほんとに Count() しないとだめですか?

やり方としては分割後のサイズと同じ長さの配列 (buffer) を用意しておいてそれに詰めていき、その配列のコピーを yield return する、の繰り返しです。
最後に中途半端な要素数だった場合もちゃんと対応できてます。

List<T> を使った実装が多いですが(List 使うと ToArray するだけでラクなので)、多分配列使ったほうが効率いいだろうということで。

/// <summary>
/// コレクションを size ごとに分割して列挙します
/// </summary>
/// <param name="that">分割するコレクション</param>
/// <param name="size">分割後のサイズ</param>
public static IEnumerable<T[]> Split<T>(this IEnumerable<T> that, int size)
{
    if (that == null)
    {
        throw new ArgumentNullException(nameof(that));
    }

    if (size < 1)
    {
        throw new ArgumentOutOfRangeException(nameof(size));
    }

    return that.SplitInner(size);
}

private static IEnumerable<T[]> SplitInner<T>(this IEnumerable<T> that, int size)
{
    using (var enumerator = that.GetEnumerator())
    {
        bool hasNext = enumerator.MoveNext();

        while (hasNext)
        {
            int count = 0;
            var block = new T[size];

            while (count < size)
            {
                block[count++] = enumerator.Current;
                if (!enumerator.MoveNext())
                {
                    hasNext = false;
                    break;
                }
            }

            // 最後の block の場合は resize して返す
            if (hasNext)
            {
                yield return block;
            }
            else
            {
                Array.Resize(ref block, count);
                yield return block;
            }
        }
    }
}

コードはこれだけです。

サンプル

nuget で配布してるやつは System.Linq 名前空間内に拡張メソッド定義したので、追加の using とか必要ないです。

使うときはこんな感じで。

static void Main(string[] args)
{
    var splitted = EnumerateNumber().Take(77).Split(10);

    foreach (IEnumerable<int> chunk in splitted)
    {
        Console.WriteLine(string.Join(", ", chunk));
    }
}

private static IEnumerable<int> EnumerateNumber()
{
    int n = 0;
    while (true)
    {
        yield return n++;
    }
}

更新履歴

2017/10/13 Divide Enumerable での Array のコピーが無駄との指摘を受け、必要なときだけ Array.Resize() を行うように修正しました。ありがとうございます!

2017/10/18 @kuchikios さんにアドバイスいただき、戻り値を IEnumerable<T[]> に変更しました。せっかく Length がわかっているのに捨てるのはもったいない。

2017/10/18 下記のバグを修正しました。

// これが例外を投げないが
var nullEnumerable = (null as IEnumerable<int>).Split(10);

// ここで ArgumentNullException
nullEnumerable.Any();

これに関しては
yieldの罠 と、 LINQでBufferをやってみる話
を読むと非常にわかりやすいと思います。

@ozwk さん、 @kuchikios さん ありがとうございました :bow:

これらの修正を反映して、現在 Nuget パッケージ は v1.0.3 になっています。