Why is it bad to use an iteration variable in a lambda expression
Consider this code:
List<Action> actions = new List<Action>();
for (int i = 0; i < 10; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (Action action in actions)
{
action();
}
What would you expect this to print? The obvious answer is 0...9 - but actually it prints 10, ten times. It's because there's just one variable which is captured by all the delegates. It's this kind of behaviour which is unexpected.
EDIT: I've just seen that you're talking about VB.NET rather than C#. I believe VB.NET has even more complicated rules, due to the way variables maintain their values across iterations. This post by Jared Parsons gives some information about the kind of difficulties involved - although it's back from 2007, so the actual behaviour may have changed since then.
Assuming you mean C# here.
It's because of the way the compiler implements closures. Using an iteration variable can cause a problem with accessing a modified closure (note that I said 'can' not 'will' cause a problem because sometimes it doesn't happen depending on what else is in the method, and sometimes you actually want to access the modified closure).
More info:
http://blogs.msdn.com/abhinaba/archive/2005/10/18/482180.aspx
Even more info:
http://blogs.msdn.com/oldnewthing/archive/2006/08/02/686456.aspx
http://blogs.msdn.com/oldnewthing/archive/2006/08/03/687529.aspx
http://blogs.msdn.com/oldnewthing/archive/2006/08/04/688527.aspx
Theory of Closures in .NET
Local variables: scope vs. lifetime (plus closures) (Archived 2010)
(Emphasis mine)
What happens in this case is we use a closure. A closure is just a special structure that lives outside of the method which contains the local variables that need to be referred to by other methods. When a query refers to a local variable (or parameter), that variable is captured by the closure and all references to the variable are redirected to the closure.
When you are thinking about how closures work in .NET, I recommend keeping these bullet points in mind, this is what the designers had to work with when they were implementing this feature:
- Note that "variable capture" and lambda expressions are not an IL feature, VB.NET (and C#) had to implement these features using existing tools, in this case, classes and
Delegate
s. - Or to put it another way, local variables can't really be persisted beyond their scope. What the language does is make it seem like they can, but it's not a perfect abstraction.
-
Func(Of T)
(i.e.,Delegate
) instances have no way to store parameters passed into them. - Though,
Func(Of T)
do store the instance of the class the method is a part of. This is the avenue the .NET framework used to "remember" parameters passed into lambda expressions.
Well let's take a look!
Sample Code:
So let's say you wrote some code like this:
' Prints 4,4,4,4
Sub VBDotNetSample()
Dim funcList As New List(Of Func(Of Integer))
For indexParameter As Integer = 0 To 3
'The compiler says:
' Warning BC42324 Using the iteration variable in a lambda expression may have unexpected results.
' Instead, create a local variable within the loop and assign it the value of the iteration variable
funcList.Add(Function()indexParameter)
Next
For Each lambdaFunc As Func(Of Integer) In funcList
Console.Write($"{lambdaFunc()}")
Next
End Sub
You may be expecting the code to print 0,1,2,3, but it actually prints 4,4,4,4, this is because indexParameter
has been "captured" in the scope of Sub VBDotNetSample()
's scope, and not in the For
loop scope.
Decompiled Sample Code
Personally, I really wanted to see what kind of code the compiler generated for this, so I went ahead and used JetBrains DotPeek. I took the compiler generated code and hand translated it back to VB.NET.
Comments and variable names mine. The code was simplified slightly in ways that don't affect the behavior of the code.
Module Decompiledcode
' Prints 4,4,4,4
Sub CompilerGenerated()
Dim funcList As New List(Of Func(Of Integer))
'***********************************************************************************************
' There's only one instance of the closureHelperClass for the entire Sub
' That means that all the iterations of the for loop below are referencing
' the same class instance; that means that it can't remember the value of Local_indexParameter
' at each iteration, and it only remembers the last one (4).
'***********************************************************************************************
Dim closureHelperClass As New ClosureHelperClass_CompilerGenerated
For closureHelperClass.Local_indexParameter = 0 To 3
' NOTE that it refers to the Lambda *instance* method of the ClosureHelperClass_CompilerGenerated class,
' Remember that delegates implicitly carry the instance of the class in their Target
' property, it's not just referring to the Lambda method, it's referring to the Lambda
' method on the closureHelperClass instance of the class!
Dim closureHelperClassMethodFunc As Func(Of Integer) = AddressOf closureHelperClass.Lambda
funcList.Add(closureHelperClassMethodFunc)
Next
'closureHelperClass.Local_indexParameter is 4 now.
'Run each stored lambda expression (on the Delegate's Target, closureHelperClass)
For Each lambdaFunc As Func(Of Integer) in funcList
'The return value will always be 4, because it's just returning closureHelperClass.Local_indexParameter.
Dim retVal_AlwaysFour As Integer = lambdaFunc()
Console.Write($"{retVal_AlwaysFour}")
Next
End Sub
Friend NotInheritable Class ClosureHelperClass_CompilerGenerated
' Yes the compiler really does generate a class with public fields.
Public Local_indexParameter As Integer
'The body of your lambda expression goes here, note that this method
'takes no parameters and uses a field of this class (the stored parameter value) instead.
Friend Function Lambda() As Integer
Return Me.Local_indexParameter
End Function
End Class
End Module
Note how there is only one instance of closureHelperClass
for the entire body of Sub CompilerGenerated
, so there is no way that the function could print the intermediate For
loop index values of 0,1,2,3 (there's no place to store these values). The code only prints 4, the final index value (after the For
loop) four times.
Footnotes:
- There's an implied "As of .NET 4.6.1" in this post, but in my opinion it's very unlikely that these limitations would change dramatically; if you find a setup where you can't reproduce these results please leave me a comment.
"But jrh why did you post a late answer?"
- The pages linked in this post are either missing or in shambles.
- There was no vb.net answer on this vb.net tagged question, as of the time of writing there is a C# (wrong language) answer and a mostly link only answer (with 3 dead links).