IEnumerator<T>、MoveNext()がfalseを返す状態でCurrentにアクセスした際の挙動は未定義


 この投稿では、IEnumerator<T>を実装したオブジェクトが、MoveNext()メソッドでfalseを返す状態でCurrentプロパティにアクセスするとどうなるかを紹介します。またそれにまつわる問題を紹介します。

問題です!

 IEnumerator<int>インターフェースを実装したクラスのオブジェクトenumeratorがあります。

IEnumerator<int> enumerator = GetExampleEnumerable().GetEnumerator();

 今このオブジェクトが、MoveNext()メソッドを呼び出すとfalseを返す状態だとします。

while(enumerator.MoveNext()){
    ;
}
// whileを抜けたので、`enumerator.MoveNext()`はfalseを返した

 この状態でCurrentプロパティに次のようにアクセスするとどうなるでしょうか?

var current = enumerator.Current;

 答えの候補としては、

  • 例外を投げる
  • 型の規定値default(T)が返る
  • 最後の要素か型の規定値default(T)が返る
  • 未定義

 などが考えられますね。

正解は?

 正解は未定義です。
 いいですか未定義です。
 もう一度言いますよ、未定義です。

 以下、MSDN(日本語)より抜粋です。

MoveNext がコレクションの末尾を過ぎると、列挙子はコレクションの最後の要素の後ろに配置され、MoveNext は false を返します。 列挙子がこの位置にある場合、以降、MoveNext を呼び出しても false が返されます。 MoveNext への最後の呼び出しで false が返された場合は、Current が未定義です。 Current を、再度、コレクションの最初の要素に設定することはできません。列挙子の新しいインスタンスを作成する必要があります。

 未定義ですね。

 未定義、つまりIEnumerator<T>の実装によってどのような挙動になるかが変わります。(クラスによって違うし、もしかしたらMonoと.NET Frameworkで違うかもしれません。)

 ちなみに実際に下記のそれぞれの挙動をするものが存在します。

  • 例外を投げる
  • 型の規定値default(T)が返る
  • 最後の要素か型の規定値default(T)が返る
コード例(Mono4.2.3)

// 例外を投げる例
[Test ()]
public void TestArray ()
{
    IEnumerable<int> enumerable = new int[]{ 0, 1, 2 };
    IEnumerator<int> enumerator = enumerable.GetEnumerator ();
    MoveEnumeratorToNoNext (enumerator); // このメソッドで

    Assert.Throws<InvalidOperationException> (() => {
        var current = enumerator.Current;
    });
}

// 型の規定値を返す例
[Test ()]
public void TestList ()
{
    IEnumerable<int> enumerable = new List<int> { 0, 1, 2 };
    IEnumerator<int> enumerator = enumerable.GetEnumerator ();
    MoveEnumeratorToNoNext (enumerator);

    Assert.AreEqual (default(int), enumerator.Current);
}

// 最後の要素か型の規定値default(T)が返る例
[Test ()]
public void TestYield ()
{
    IEnumerable<int> enumerable = YieldParams<int> (0, 1, 2);
    IEnumerator<int> enumerator = enumerable.GetEnumerator ();
    MoveEnumeratorToNoNext (enumerator);

    Assert.AreEqual (enumerable.LastOrDefault (), enumerator.Current);
}

static void MoveEnumeratorToNoNext<T> (IEnumerator<T> enumerator)
{
    while (enumerator.MoveNext ()) {
        ;
    }
}

static IEnumerable<T> YieldParams<T> (params T[] args)
{
    foreach (T it in args) {
        yield return it;
    }
}

 参考コード全文はこちら

で、どういう問題があるの?

 「IEnumerator<T>MoveNext()falseを返す状態でCurrentにアクセスした結果が未定義」ですが、実際のコーディングではどう気を付けなければいけないでしょうか?

 IEnumerator<T>は、foreach文の内部やLINQの内部など多くの場面で使われています。しかしそれらの利用者がIEnumerator<T>を意識しなくても使えるようになっていますね。

 気を付けなければいけないのは、コレクションライブラリやLINQライクの拡張関数を作成する時です。この仕様を見落としてしまうと思わぬバグを作ってしまうかもしれません。

 次のよくないコードを見て下さい。どこがよくないかわかりますか?

問題があるコード
IEnumerable<T> enumerable = GetTargetIEnumerable();
IEnumerator<T> enumerator = enumerable.GetEnumerator();
bool hasNext = enumereator.MoveNext();
T current = e.Current;

if(!hasNext) {
    // MoveNext()がfalseの場合の処理
}
// 以下略

 よくない点は、「MoveNext()の結果がfalseかもしれないのにCurrentプロパティにアクセスしている」という点です。MoveNext()falseを返す場合にCurrentプロパティにアクセスした場合は、例外を投げるものもあります。そのように例外を投げる場合、上記のコードのようにMoveNext()メソッドの結果がfalseの場合に処理を終えるなどの処理をしていた場合でも、意味がありません。次のようにする必要がありますね。

問題を修正したコード
IEnumerable<T> enumerable = GetTargetIEnumerable();
IEnumerator<T> enumerator = enumerable.GetEnumerator();
bool hasNext = enumereator.MoveNext();

if(!hasNext) {
    // MoveNext()がfalseの場合の処理
    // 略
}

T current = e.Current;
// 以下略

 コレクションライブラリやLINQライクなIEnumerable<T>の拡張関数を作る際はこの点に注意する必要があります。

 この問題で厄介なのは、未定義という点です。例外を投げるものもあれば、規定値を返すものもあれば、最終要素を返すものもあります。同じコードでもList<T>Set<T>そしてLINQの結果を処理対象とする時は問題は発生しないけれど、配列ならば問題が発生するということもあります。

 「メソッドに対してテストを書いていてもテストに使ったのがList<T>で問題に気がつかず、配列で問題が発生するのに気がつかなかった。」ということもありえますね。

まとめ

 IEnumerator<T>インターフェースを実装したオブジェクトがMoveNext()メソッドでfalseを返す状態でCurrentにアクセスした場合の挙動は未定義です。

 未定義ということは、例外を投げるものもあるかもしれないし、型の規定値を返すものもあるかもしれない、ということです。

 コレクションライブラリやLINQライクのライブラリを実装する際は、この点に気をつけて実装しないと思わぬバグを作りこんでしまう場合があるかもしれません。

 最後に、実際にこれが原因で不具合を作っていたIxの不具合を紹介します。

具体例

 Ix(Interactive Extensions)というライブラリがあります。LINQのようなIEnumerable<T>の拡張関数や、IEnumerable<T>を生成するメソッドが定義されています。

 問題があるのは、このIxのCatchメソッドです。(git commit revision : 2252cb4)

 ここで使っているCatchメソッドは、IEnumerable<TSource>を列挙している途中で例外が発生した場合、それ以後の列挙をやめて引数で渡したIEnumerable<TSource>を列挙するというものです。

 下記のテストは通ります。想定している挙動です。

[Test ()]
public void TestCatchFirstSecondWithoutException ()
{
    var sequence = new List<int>{ 0, 1, 2 }.Catch (second: new List<int>{ 3, 4, 5 });
    var result = sequence.SequenceEqual (new []{ 0, 1, 2 });
    Assert.True (result);
}

 しかし、次のテストは失敗します。

[Test ()]
public void TestCatchFirstSecondWithoutException ()
{
    var sequence = new int[]{ 0, 1, 2 }.Catch (second: new int[]{ 3, 4, 5 });
    var result = sequence.SequenceEqual (new []{ 0, 1, 2 });
    Assert.True (result);
}

 これはIEnumerable<TSource>の列挙の途中で想定外の例外が発生してしまい、引数で渡したIEnumerable<TSource>の列挙をするからです。ではなぜIEnumerable<TSource>の列挙の途中で想定外の例外が発生するのでしょうか。

 問題のあるコードはIxの実装のここの部分です。MoveNext()メソッドの結果にかかわらず、Currentプロパティにアクセスしていますね。これが原因でIxの現バージョン(2252cb4)のCatchメソッドを配列で使った場合、思わぬ挙動をしてしまいます。

 Catchメソッドに対してテストは書いてありましたが、配列でテストをしていなくてこのバグを見落としていたようです。

 これに関しての修正のプルリクエストを送りました。