Nested IEnumerators not running as I expect

100 Views Asked by At

If I call Run() multiple times, I expect to see the messages "One", "Two" and "Three", but it's not what is happening, I can see the first one and then the MoveNext() returns false. What am I missing here?

public class Test
{
    private IEnumerator<Object> routine;

    public void Setup()
    {
        routine = CountOne();
    }
    
    public void Run()
    {
        routine.MoveNext();
    }
    
    private IEnumerator<Object> CountOne()
    {
        Console.WriteLine("One");
        yield return CountTwo();
    }

    private IEnumerator<Object> CountTwo()
    {
        Console.WriteLine("Two");
        yield return CountThree();
    }

    private IEnumerator<Object> CountThree()
    {
        Console.WriteLine("Three");
        yield return null;
    }
}
3

There are 3 best solutions below

8
Guru Stron On

It seems that this comes from Unity and I have not worked with it for a while, but try enumerating the enumerators:

public class Test
{
    private IEnumerator routine;
    
    public void Run()
    {
        routine ??= CountOne();

        while (routine.MoveNext())
        {
            // no-op
        }
    }
    
    private IEnumerator CountOne()
    {
        Debug.Log($"One");
        var countTwo = CountTwo();
        while (countTwo.MoveNext())
        {
            yield return countTwo.Current;
        }
    }

    private IEnumerator CountTwo()
    {
        Debug.Log($"Two");
        var countThree = CountThree();
        while (countThree.MoveNext())
        {
            yield return countThree.Current;
        }
    }

    private IEnumerator CountThree()
    {
        Debug.Log($"Three");
        yield return null;
    }
}

Otherwise in CountOne you just return a single item which is the next enumerator without actually processing it (i.e. return type of CountOne is effectivelyIEnumerator<IEnumerator>).

yield return supports returning IEnumerator and IEnumerable interchangeably. And from the docs:

The call of an iterator doesn't execute it immediately...

... when you start to iterate over an iterator's result, an iterator is executed until the first yield return statement is reached. Then, the execution of an iterator is suspended and the caller gets the first iteration value and processes it. On each subsequent iteration, the execution of an iterator resumes after the yield return statement that caused the previous suspension and continues until the next yield return statement is reached. The iteration completes when control reaches the end of an iterator or a yield break statement.

8
StriplingWarrior On

It might help to better understand things if you avoid using Object:

public class Test
{
    private IEnumerator<int> routine;

    public void Setup()
    {
        routine = CountOne();
    }

    public void Run()
    {
        routine.MoveNext();
    }

    private IEnumerator<int> CountOne()
    {
        Console.WriteLine("One");
        yield return CountTwo(); // Cannot implicitly convert type ...
    }

    private IEnumerator<int> CountTwo()
    {
        Console.WriteLine("Two");
        yield return CountThree(); // Cannot implicitly convert type ...
    }

    private IEnumerator<int> CountThree()
    {
        Console.WriteLine("Three");
        yield return 0;
    }
}

This will have a few compile-time errors when you try to yield return CountTwo(); or yield return CountThree();. That's because these functions return IEnumerable<int>s and you're only allowed to yield return an int from a method returning an IEnumerator<int>.

That error wasn't happening in your original code because technically an IEnumerator<Object> is an Object and can be cast as one, even though that's not the intent you had when you yield returned.

To get the behavior you're looking for, change these from yield return to just return, or create a foreach loop to yield return each item in the IEnumerator returned from those methods.

2
Joel Coehoorn On

What's going on here is yield keyword messes with the declared return types of the methods.

When we put yield in a method declared to return IEnumerator<Object>, it expects the result of the yield expressions to be Object. If we put yield in a method declared to return IEnumerator<string>, (a la "One", "Two", "Three"), it expects the results of the yield expressions to be string (not IEnumerator<string>!

But that's not what you have. Instead, the nested CountTwo() and CountThree() methods also return IEnumerator types, and the only reason the original code compiles at all is those types are assignable to Object.

It's also important that these are three different enumerators, and not three yields in the same enumerator. When the code first calls Run(), only the CountOne()'s enumerator is involved, at which point it is completed, since it only has the one element.

If I call Run() multiple times, I expect to see the messages "One", "Two" and "Three"

Let's make that happen! Here are two examples:

public class Test
{
    private IEnumerator routine;
    
    public void Setup()
    {
        routine = (new string[] {"One", "Two", "Three"}).GetEnumerator();
    }
    
    public void Run()
    {
        routine.MoveNext();
        Console.WriteLine(routine.Current);
    }
}

// Designed to look more like the original
public class Test2
{
    private IEnumerator routine;
    
    public void Setup()
    {
        routine = CountOne();
    }
    
    public void Run()
    {
        routine.MoveNext();

        // queue up the next enumerator
        routine = (IEnumerator)routine.Current;
    }
    
    public IEnumerator<IEnumerator<IEnumerator<string>>> CountOne()
    {
        Console.WriteLine("One");
        yield return CountTwo();
    }
    
    public IEnumerator<IEnumerator<string>> CountTwo()
    {
        Console.WriteLine("Two");   
        yield return CountThree();
    }
    
    public IEnumerator<string> CountThree()
    {
        Console.WriteLine("Three");
        yield return null;
    }
}

See them work here:

https://dotnetfiddle.net/08hfBz