新版C#编译器关于函数闭包的一处更改
在Visual Basic.NET中,如果你写下类似下面的代码:
Public Sub Test()
For i = 0 To 100
Dim func = Function(x) x * i
Next
End Sub
Visual Studio会给出一个警告,说在lambda表达式(即匿名函数)中直接使用循环变量可能导致意料之外的结果,建议程序员先将循环变量复制一份,然后再使用。
直接使用循环变量究竟会产生什么意外结果呢?本人并没有用VB.NET尝试过,但是在多年的C#开发中屡次碰到类似问题,以至于向下属定下规矩:循环变量用于匿名函数必须复制一份。在C#中,在匿名函数中直接使用循环变量并不会像VB.NET那样给出警告,所以你往往根本不会意识到程序的运行可能与预想不一致。
看下面的例子。创建一个WPF应用程序,在窗口中摆放10个Button,并且写上1-10的数字。我们程序的逻辑很简单,就是当用户单击按钮时,弹出一个消息框,显示所单击按钮上的数字。熟悉WPF和C#函数式语法的童鞋很快就能写出下面的代码。
//MainWindow.xaml
<Window x:Class="CSharpClosureTest.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="300" Width="300" Loaded="Window_Loaded">
<StackPanel Name="LayoutRoot">
</StackPanel>
</Window>
//MainWindow.xaml.cs
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void Window_Loaded(object sender, RoutedEventArgs e)
{
AddButtons();
}
private void AddButtons()
{
var list = Enumerable.Range(1, 10).ToList();
foreach (var i in list)
{
Button button = new Button { Content = i };
button.Click += (sender, e) => MessageBox.Show(i.ToString());
LayoutRoot.Children.Add(button);
}
}
}
在这个代码中,很明显,我们在匿名函数中直接使用了循环变量。然而若离开本文的环境,您恐怕很难留意到这个细节。运行程序,将会得到什么结果呢?
我们在VS2012中生成、运行程序。单击一些按钮,似乎程序运行完全正确,没有什么异常情况。
然而,如果你用VS2010打开代码,重新生成并运行,就会发现出问题了。无论你单击哪个按钮,消息框弹出的数字永远是10。
这样的结果令人惊异。相同的代码、相同的.NET Framework版本,仅仅因为在不同的VS版本中编译,程序的运行结果截然不同。
我们知道,.NET框架本身是不理解函数式编程结构的,C#编译器把匿名函数编译成一些名字很怪的嵌套类型,并且把匿名函数上下文中的变量捕获下来,作为嵌套类型的私有成员变量,这就是闭包。闭包变量的捕获发生在编译时。显然,两个C#编译器对闭包变量捕获的处理不同。
为了一探究竟,验证我们的猜测,我们使用Reflector对两个VS生成的exe进行反编译。以下是得到的C#代码,注意我们已经把Reflector优化模式改为.NET1.1版,以便查看匿名函数的真实情况。
VS2012版:
private void AddButtons() { List<int> list = Enumerable.Range(1, 10).ToList<int>(); using (List<int>.Enumerator CS$5$0000 = list.GetEnumerator()) { while (CS$5$0000.MoveNext()) { RoutedEventHandler CS$<>9__CachedAnonymousMethodDelegate2 = null; <>c__DisplayClass3 CS$<>8__locals4 = new <>c__DisplayClass3(); CS$<>8__locals4.i = CS$5$0000.Current; Button <>g__initLocal0 = new Button(); <>g__initLocal0.Content = CS$<>8__locals4.i; Button button = <>g__initLocal0; if (CS$<>9__CachedAnonymousMethodDelegate2 == null) { CS$<>9__CachedAnonymousMethodDelegate2 = new RoutedEventHandler(CS$<>8__locals4.<AddButtons>b__1); } button.Click += CS$<>9__CachedAnonymousMethodDelegate2; this.LayoutRoot.Children.Add(button); } } }
VS2010版:
private void AddButtons() { List<int> list = Enumerable.Range(1, 10).ToList<int>(); using (List<int>.Enumerator enumerator = list.GetEnumerator()) { RoutedEventHandler handler = null; <>c__DisplayClass3 class2 = new <>c__DisplayClass3(); while (enumerator.MoveNext()) { class2.i = enumerator.Current; Button button2 = new Button(); button2.Content = class2.i; Button element = button2; if (handler == null) { handler = new RoutedEventHandler(class2.<AddButtons>b__1); } element.Click += handler; this.LayoutRoot.Children.Add(element); } } }
果不其然,二者存在重大差异。在VS2010的结果中,闭包对应的嵌套类型只被实例化了一次,于是在匿名函数执行时,循环变量也就是嵌套类型的私有成员保持了循环最后一次执行时被赋予的值。而在VS2012的结果中,嵌套类型被循环实例化,多个匿名函数各自对应独立的私有成员。
在大多数情况下,你我期望的都会是VS2012给出的直观的结果。我实在想象不出VS2010及之前版本给出的结果有什么应用场景。从这个意义上讲,VS2012的这个改动可以算作一个bug修复。
这个差异是我无意中发现的。当时有一段代码出现了循环变量用于匿名函数的情况,然而我自己忽略了自己定下的规矩,没有复制一份循环变量。由于是VS2012,程序一切正常。当我改用VS2010时,发现程序死活不对。排查了半天,才发现是由于这个坑爹的问题,进而发现VS2012与VS2010表现不同。我认为这个修复具有重大意义,毕竟,留心复制变量是比较别扭的,也容易遗忘。
不过,本人仍有一些疑惑,特在此向广大园友请教。
C#编译器csc.exe是随.NET Framework一同安装的,也就是说,当项目的.NET版本一致时,所使用的编译器应当是同一个。既然如此,又为何会出现不同VS版本编译出的程序不同的情况呢?