I just saw a YouTube video, where the tutor used a yield return method to open a file and read lines from it, that are yield-returned to the caller (the actual code was in a using block around the FileStream).
Then I wondered, is it OK to use "using" or "try-finally" in a yield-return method. Because my understanding is, that the method only runs as long, as values are get from it. With "Any()" for example the method is done, after the first yield return (or yield break of course).
So, if the function never runs to end, when is the finally block executed? Is it safe to use such a construct?
IEnumerator<T>
implements IDisposable
, and foreach
loops will dispose the thing they're enumerating over when they're finished (this includes linq methods which use a foreach
loop, such as .ToArray()
).
It turns out that the compiler-generated state machine for generator methods implements Dispose
in a smart way: if the state machine is in a state which is "inside" a using
block, then calling Dispose()
on the state machine will dispose the thing protected by the using
statement.
Let's take an example:
public IEnumerable<string> M() {
yield return "1";
using (var ms = new MemoryStream())
{
yield return "2";
yield return "3";
}
yield return "4";
}
I'm not going to paste the entire generated state machine, as it's very large. You can see it on SharpLab here.
The core of the state machine is the following switch statement, which tracks our progress past each of the yield return
statements:
switch (<>1__state)
{
default:
return false;
case 0:
<>1__state = -1;
<>2__current = "1";
<>1__state = 1;
return true;
case 1:
<>1__state = -1;
<ms>5__1 = new MemoryStream();
<>1__state = -3;
<>2__current = "2";
<>1__state = 2;
return true;
case 2:
<>1__state = -3;
<>2__current = "3";
<>1__state = 3;
return true;
case 3:
<>1__state = -3;
<>m__Finally1();
<ms>5__1 = null;
<>2__current = "4";
<>1__state = 4;
return true;
case 4:
<>1__state = -1;
return false;
}
You can see that we create the MemoryStream
as we enter state 2, and dispose it (by calling <>m__Finally1()
) as we exit state 3.
Here's the Dispose
method:
void IDisposable.Dispose()
{
int num = <>1__state;
if (num == -3 || (uint)(num - 2) <= 1u)
{
try
{
}
finally
{
<>m__Finally1();
}
}
}
If we're in states -3, 2, or 3, then we'll call <>m__Finally1();
. States 2 and 3 are those inside the using
block.
(State -3 seems to be a guard in case we wrote yield return Foo()
and Foo()
threw an exception: in this case we would stay in state -3 and would be unable to iterate any further. However we are still allowed to dispose the MemoryStream
in this case).
Just for completeness, <>m__Finally1
is defined as:
private void <>m__Finally1()
{
<>1__state = -1;
if (<ms>5__1 != null)
{
((IDisposable)<ms>5__1).Dispose();
}
}
You can find the specification for this in the C# Language Specification, section 10.14.4.3:
- If the state of the enumerator object is suspended, invoking Dispose:
- Changes the state to running.
- Executes any finally blocks as if the last executed yield return statement were a yield break statement. If this causes an exception to be thrown and propagated out of the iterator body, the state of the enumerator object is set to after and the exception is propagated to the caller of the Dispose method.
- Changes the state to after.