为什么在lambda表达式中使用迭代变量是不好的?

55

我刚刚在编写一些快速代码时发现了这个编译器错误

在 lambda 表达式中使用迭代变量可能会导致意外结果。
相反,在循环内创建一个局部变量并将其赋值为迭代变量的值。

我知道这是什么意思,我可以很容易地解决它,不是什么大问题。
但我想知道为什么在 lambda 中使用迭代变量是个坏主意?
后面会有哪些问题呢?


相关:https://dev59.com/RXVC5IYBdhLWcg3wxEJ1 - nawfal
最好能够给出一个实际的例子来展示它确实有效并得到正确的结果!例如,请查看这里的结果http://pastebin.com/raw/FghmXkby,它是不正确的…一直产生相同的错误结果。 - barlop
有一种实现方式是如此直观,以至于有50万个问题和9000篇博客文章在描述它... 这是C++吗? - jrh
3个回答

56

考虑以下代码:

List<Action> actions = new List<Action>();

for (int i = 0; i < 10; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

foreach (Action action in actions)
{
    action();
}

你会期望这个程序输出什么?显而易见的答案是0...9 - 但实际上它输出了10,十次。因为所有的委托都捕获同一个变量。这种行为是意料之外的。

编辑:我刚看到你谈论的是VB.NET而不是C#。我相信由于变量在迭代中维护其值的方式,VB.NET有更复杂的规则。 Jared Parsons在此篇文章中提供了一些信息,解释了所涉及的困难 - 尽管它是2007年的,所以实际行为可能已经发生了变化。


6
简而言之,lambda表达式在循环时不一定会被评估,并且当它们被调用时,迭代变量可能已经超出了作用范围、未分配或带有其最终值(甚至超出了循环限制)。 - BertuPG
10
你想到的是这两个词中的哪一个?;) - Jon Skeet
4
我闻到了一道面试题。 :-) - EightyOne Unite
我知道这是一个旧答案,但无论如何... Paul Vick重置了他的博客并删除了那个链接。Jared的这篇文章可能是一个很好的替代。 - MarkJ
2
我注意到在使用VS2015和.NET 4.5.2时,VB会显示问题中提到的警告,但C#不会,尽管行为是相同的(10,十次)。不确定这是否一直是这种情况? - 41686d6564 stands w. Palestine
显示剩余2条评论

8

假设你指的是C#。

这是因为编译器实现闭包的方式。使用迭代变量可能会导致访问修改后的闭包出现问题(请注意我说的是“可能”而不是“一定”会出现问题,因为有时候它并不会发生,这取决于方法中还有什么内容,有时候你实际上想要访问修改后的闭包)。

更多信息:

http://blogs.msdn.com/abhinaba/archive/2005/10/18/482180.aspx

更多信息:

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


1
这不是“每个方法一个闭包”的问题 - 它比那更加复杂。 - Jon Skeet
1
是的,我意识到那句话读起来很糟糕 - 我试图快速转述这种情况(Raymond更详细地解释了)。已删除有问题的短语,以便人们可以查看更多信息链接。 - Greg Beech
1
看起来链接已经失效,但你仍然可以在这里找到它们:https://devblogs.microsoft.com/oldnewthing/2006/08/page/4,“匿名方法的实现及其后果”(Raymond Chen / Old New Thing博客)第123部分。 - jrh

4

.NET中的闭包理论

本地变量:范围 vs. 生命周期 (加上闭包) (存档于2010年)

(重点是我的)

在这种情况下发生的是我们使用闭包。闭包只是一个特殊的结构,存在于包含需要被其他方法引用的局部变量的方法之外。 当查询引用局部变量(或参数)时,该变量由闭包捕获,并将对变量的所有引用重定向到闭包。

当您考虑.NET中闭包的工作原理时,我建议记住以下要点,这是设计者在实现此功能时必须使用的内容:

  • 请注意,“变量捕获”和lambda表达式不是IL特性,VB.NET(和C#)必须使用现有工具(在本例中为类和Delegate)实现这些功能。
  • 或者换句话说,局部变量不能真正超出其范围。语言所做的就是使它们看起来可以,但这并不是完美的抽象。
  • Func(Of T)(即Delegate)实例没有存储传递到其中的参数的方法。
  • 尽管如此,Func(Of T)确实存储了包含该方法的类的实例。这是.NET框架用于“记忆”传递给lambda表达式的参数的途径。

那么让我们来看一下!

示例代码:

假设您编写了以下代码:

' 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

你可能期望这段代码会输出0,1,2,3,但实际上它会输出4,4,4,4。这是因为indexParameter已经被"捕获"在Sub VBDotNetSample()的范围内,而不是在For循环的范围内。

反编译的示例代码

个人而言,我很想看看编译器为此生成了什么样的代码,因此我使用了JetBrains DotPeek。我取得了编译器生成的代码并手动将其翻译回VB.NET。

注释和变量名由我添加。代码被稍微简化了一下,但不影响代码行为。

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

请注意,在 Sub CompilerGenerated 的整个主体中只有一个 closureHelperClass 实例,因此函数无法打印中间的 For 循环索引值(没有地方存储这些值)。该代码仅打印 4,即最终索引值(在 For 循环之后)四次。
注脚:
  • 在本文中,隐含着 ".NET 4.6.1 及更高版本",但我认为这些限制很少会有显著改变;如果您找到了不能重现这些结果的设置,请给我留言。

"但是 jrh,为什么你要发表一篇晚回答的文章?"

  • 本文中链接的页面或已经丢失或处于混乱状态。
  • 在这个 vb.net 标记的问题上还没有 vb.net 的答案,截至撰写本文时可用的是一个 C#(错误的语言)答案和一个主要是链接的答案(其中有三个失效链接)。

只是提醒一下,如果有人在尝试修改代码时,重命名closureHelperClass导致程序崩溃并直接退出桌面的话,看起来这是由于Visual Studio中的一个错误引起的(https://github.com/dotnet/roslyn/issues/21662),在使用重命名/重构时请经常保存! - jrh

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接