匿名委托变量捕获的陷阱

一个简单的例子:

 1     class Program
 2     {
 3         static void Main(string[] args)
 4         {
 5             for (var i = 0; i < 3; i++)
 6             {
 7                 Task.Factory.StartNew(() => Func1(i));
 8             }
 9 
10             Console.ReadKey();
11         }
12 
13         static void Func1(int i)
14         {
15             Console.WriteLine(i);
16         }
17     }

结果不是 0 1 2,而是3 3 3

(补充:如果循环次数加大,会发现输出的并不都是相同的,但仍然不是预期)

解释:

匿名函数有捕获变量的特性

闭包是可以包含自由(未绑定)变量的代码块;这些变量不是在这个代码块或者任何全局上下文中定义的,而是在定义代码块的环境中定义。

参考资料:

简单来讲,闭包允许你将一些行为封装,将它像一个对象一样传来递去,而且它依然能够访问到原来第一次声明时的上下文。这样可以使控制结构、逻辑操作等从调用细节中分离出来。访问原来上下文的能力是闭包区别一般对象的重要特征,尽管在实现上只是多了一些编译器技巧。

我们知道,在匿名方法或者lambda中,可以访问或者修改该匿的定义范围内的变量。例如:

  1. int num = 1;   
  2. Func<int> incNum = () => ++num; 

其中lambda表达式使用了在其外部定义的变量num。我们可以认为该段lambda语句块构成了一个闭包,而这个闭包捕获了外部变量num。

好了,不说那么多让人看着难受的定义套话了。我们进入正题,看看在C#中变量是如何被捕获的。来看一个例子:

  1. public Func<String> CreateFunction()   
  2. {   
  3. String str = "我的幸运数字是";   
  4. int num = 17;   
  5. Func<String> func = () => str + num;   
  6. return func;   

在这个例子中,定义了一个返回一个函数的方法CreateFunction。返回的函数构成了一个闭包,该闭包捕获了两个变量:String类型的str和int类型的num。

好了,我们现在可以这样使用这个函数了:

  1. Func<String>   
  2. myFunc = CreateFunction();   
  3. String result = myFunc();  

我们来分析一下这两行代码实际都干了什么。第一行很容易理解,我们把方法CreateFunction生成的匿名函数赋值给了委托myFunc。

第二行更好理解,我们执行了myFunc,并将返回结果赋值给了变量result。我们再深入思考一下:在执行myFunc的时候,会访问到在CreateFunction中定义两个变量str与num。

虽然这时CreateFunction的栈帧早就被销毁了,其内部定义的变量至今也“生死不明”了,但是因为我们知道这两个变量已经被闭包所捕获了,所以我们坚信这两个变量截至目前为止还是可以访问的!

对于str对象,鉴于它是一个引用类型,所以只要有存在某个“东西”一直保存着对它的引用,它就不会被销毁。这样我们完全不用担心在我们需要它时,编译器或运行时会告诉我们它被弄丢了。

然而对于num,情况就有些不同了。num是一个值类型。我们知道值类型是存活在栈上的,我们也知道它所存在的那个栈帧(也就是CreateFunction的帧)在CreateFunction执行完毕后就会被销毁,然后其上存在的任何值类型也会被一并的销毁,这其中当然包括我们所关注的变量num了。

那么,我们为什么还能安全的访问num呢?C#中的变量捕获机制究竟有什么神奇之处,可以让值类型拥有违反常规的生存周期呢?装箱!你可能会立刻想到,把每个值类型都装到一个对象里,我们就可以让这个值类型拥有和那个包裹它的对象相同的寿命了。

不过,这并不是C#实现者所选择的方式!C#并不会对每个需要捕获的值类型变量进行装箱操作,而是把所有捕获的变量统统放到同一个大“箱子”里——当编译器遇到需要变量捕获的情况时,它会默默地在后台构造一个类型,这个类型包含了每一个闭包所捕获的变量(包括值类型变量和引用类型变量)作为它的一个公有字段。这样,编译器就可以

维护那些在匿名函数或lambda表达式中出现的外部变量了。

更进一步,如果我们使用ILDASM工具查看CreateFunction方法的IL代码,我们会发现编译器压根就没有声明num和str变量。取而代之的是声明了一个类型名和实例名都及其难看的包装对象。这个玩意儿就是我们上面所说的那个被编译器默默生成,保存了所有捕获变量的引用的对象。

我们还可以看到,在CreateFunction方法,C#源代码内所有对str和num的操作,在IL中都被转换成了对包装对象的同名公有成员的操作。顺便说一句,就连我们构造的那个lambda表达式“() => str + num”现在都被编译器转换成了这个包装对象的一个方法!

原文地址:https://www.cnblogs.com/sohobloo/p/3517604.html