访问修改闭包(2)

101

这是一个扩展自Access to Modified Closure的问题。我只是想确认以下内容是否足够安全,以便投入生产使用。

List<string> lists = new List<string>();
//Code to retrieve lists from DB    
foreach (string list in lists)
{
    Button btn = new Button();
    btn.Click += new EventHandler(delegate { MessageBox.Show(list); });
}

每次启动时我只运行上述代码一次。目前来看似乎可以正常工作。正如Jon所提到的,在某些情况下可能会出现违反直觉的结果。那么我需要注意什么?如果列表被多次运行,是否还能正常工作?


18
恭喜,您现在已成为Resharper文档的一部分。 http://confluence.jetbrains.net/display/ReSharper/Access+to+modified+closure - Kongress
1
这个有点棘手,但上面的解释让我明白了: 这看起来可能是正确的,但实际上,每当单击任何按钮时,只会使用 str 变量的最后一个值。原因是 foreach 展开为 while 循环,但迭代变量在此循环之外定义。这意味着在显示消息框时,str 的值可能已经被迭代到字符串集合中的最后一个值。 - DanielV
1个回答

159
在C# 5之前,你需要在foreach内部重新声明变量,否则它是共享的,所有的处理程序都将使用最后一个字符串:
foreach (string list in lists)
{
    string tmp = list;
    Button btn = new Button();
    btn.Click += new EventHandler(delegate { MessageBox.Show(tmp); });
}

值得注意的是,从C# 5开始,这种情况已经改变了,特别是在foreach的情况下,您不再需要这样做:问题中的代码将按预期工作。
为了证明没有这种改变会导致代码无法正常工作,请考虑以下内容:
string[] names = { "Fred", "Barney", "Betty", "Wilma" };
using (Form form = new Form())
{
    foreach (string name in names)
    {
        Button btn = new Button();
        btn.Text = name;
        btn.Click += delegate
        {
            MessageBox.Show(form, name);
        };
        btn.Dock = DockStyle.Top;
        form.Controls.Add(btn);
    }
    Application.Run(form);
}

在 C# 5 之前运行上述代码,尽管每个按钮显示的名称不同,但单击按钮四次后都会显示 "Wilma"。

这是因为语言规范(ECMA 334 v4,第 15.8.4 节)在 C# 5 之前定义了:

foreach (V v in x) embedded-statement is then expanded to:

{
    E e = ((C)(x)).GetEnumerator();
    try {
        V v;
         while (e.MoveNext()) {
            v = (V)(T)e.Current;
             embedded-statement
        }
    }
    finally {
        … // Dispose e
    }
}
请注意变量v(即您的list)在循环之外被声明。因此,根据捕获变量的规则,列表的所有迭代将共享捕获变量持有者。
从C#5开始,这已经改变:迭代变量(v)被限定在循环内部。我没有规范参考资料,但基本上是这样的:
{
    E e = ((C)(x)).GetEnumerator();
    try {
        while (e.MoveNext()) {
            V v = (V)(T)e.Current;
            embedded-statement
        }
    }
    finally {
        … // Dispose e
    }
}

关于取消订阅;如果您想积极地取消匿名处理程序的订阅,诀窍是要捕获处理程序本身:

EventHandler foo = delegate {...code...};
obj.SomeEvent += foo;
...
obj.SomeEvent -= foo;

同样的,如果您想要一个只触发一次的事件处理程序(如Load等):
EventHandler bar = null; // necessary for "definite assignment"
bar = delegate {
  // ... code
  obj.SomeEvent -= bar;
};
obj.SomeEvent += bar;

这是现在自动退订的;-p

如果是这种情况,临时变量将会一直保存在内存中,直到应用程序关闭,以便为委托提供服务。如果变量占用大量内存,则不建议在非常大的循环中执行此操作。我说得对吗? - faulty
1
它会留存在内存中,只要还有带有该事件的物品(按键)。有一种方法可以取消订阅一次性委托,在此帖子中我会加上。 - Marc Gravell
2
但是就您的观点而言,捕获变量确实可以增加变量的作用域。您需要小心不要捕获您没有预期的东西... - Marc Gravell
1
请问您能否根据C# 5.0规范的变化更新您的答案?这将使它成为关于C#中foreach循环的绝佳维基文档。已经有一些很好的答案涉及到C# 5.0编译器对待foreach循环的变化http://bit.ly/WzBV3L,但它们并不是类似维基百科的资源。 - Ilya Ivanov
但我认为for循环在5.0中仍然保持其变量作用域,并且仍需要本地变量解决方法,这正确吗? - Kos
1
@Kos 是的,for 在5.0中没有改变。 - Marc Gravell

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