return IEnumerableとyield return

static void Main(string[] args)
{
    var path = @"C:\test.txt";
    var lines = EnumerateAllLines(path).ToArray();
}

static IEnumerable<string> EnumerateAllLines(string filePath)
{
    using (var sr = new StreamReader(filePath))
    {
        return Enumerable.Repeat(sr, int.MaxValue)
            .TakeWhile(s => !s.EndOfStream)
            .Select(s => s.ReadLine());
    }
}

これの実行結果はどうなるでしょうか。答えは、「閉じているTextReaderから読み取ることはできません。」という例外が発生します。当たり前ですか? すみません。Linqばかり触っていると、ついついIEnumerableだから遅延評価だね!と単刀直入に思ってしまっていたりしたのですが、IEnumerableは決して必ずしも遅延評価であるということでは、ない。配列だってIEnumerableなんだよ!という。当然のようなことですが、すっかり頭から抜け落ちていました。反省。

何で例外が発生するかと言えば、EnumerateAllLines(path)の時点でメソッドが呼ばれ、returnで返した時点でusingを抜けてストリームが閉じられてしまう。ので、ToArray()で閉じられたストリームに対して読み込みを始めて、南無。というわけです。ではどうすればいいかというと……

static IEnumerable<string> EnumerateAllLines(string filePath)
{
    using (var streamReader = new StreamReader(filePath))
    {
        var seq = Enumerable.Repeat(streamReader, int.MaxValue)
            .TakeWhile(sr => !sr.EndOfStream)
            .Select(sr => sr.ReadLine());
        foreach (var item in seq) yield return item;
    }
}

yield returnを使ってやれば、コンパイラがイテレータを作るので、遅延実行される。EnumerateAllLines(path)の時点ではメソッド内部は一切通らない。MoveNextが呼ばれて初めてusingを通り、列挙が終わるかDisposeが呼ばれるまではusingを抜けない。という、なって欲しいであろう挙動を取ってくれるわけです。実行ファイルをReflectorで見ると、復元不可能なぐらいグチャグチャなものが出力されていて、あまりの難読化っぷりにビビりますが気にしないことにしませう。

そもそもEnumerable.Repeat(sr, int.MaxValue)のほうを改善してRepeatWithUsing作った方がいい、のではあるのですけど、まあ、それはそれということで。

無限リピート + SQL

上のはただの説明用の例でクソの役にもたたないので、もう少し実用的なものを一つ。

static void Main(string[] args)
{
    var command = new SqlCommand();
    command.CommandText = @"select hogehogehoge";
    var result = command.EnumerateAll(dr => new
    {
        AA = dr.GetString(0),
        BB = dr.GetInt32(1)
    });
}

static IEnumerable<T> EnumerateAll<T>(this IDbCommand command, Func<IDataReader, T> selector)
{
    using (var reader = command.ExecuteReader())
    {
        var seq = Enumerable.Repeat(reader, int.MaxValue)
            .TakeWhile(dr => dr.Read())
            .Select(selector);
        foreach (var item in seq) yield return item;
    }
}

static T[] ReadAll<T>(this IDbCommand command, Func<IDataReader, T> selector)
{
    return command.EnumerateAll(selector).ToArray();
}

シーケンス無限リピートをSQLの読み込みに応用してみるとかどうでしょう。Linq to Sqlのように、とまでは到底行きませんが、匿名型も使えるし、何となくそれっぽい雰囲気は出てるんじゃないかしらん。EnumerateAllの後段にTakeWhileを付けて条件で途中で止めるとか、Take(10)で10件のみ取得とか、それなりに自由に動かせます。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive