C# 从CIL代码了解委托,匿名方法,Lambda 表达式和闭包本质
前言
C# 3.0 引入了 Lambda 表达式,程序员们很快就开始习惯并爱上这种简洁并极具表达力的函数式编程特性。
本着知其然,还要知其所以然的学习态度,笔者不禁想到了几个问题。
(1)匿名函数(匿名方法和Lambda 表达式统称)如何实现的?
(2)Lambda表达式除了书写格式之外还有什么特别的地方呢?
(3)匿名函数是如何捕获变量的?
(4)神奇的闭包是如何实现的?
本文将基于CIL代码探寻Lambda表达式和匿名方法的本质。
笔者一直认为委托可以说是C#最重要的元素之一,有很多东西都是基于委托实现的,如事件。关于委托的详细说明已经有很多好的资料,本文就不再墨迹,有兴趣的朋友可以去MSDN看看http://msdn.microsoft.com/zh-cn/library/900fyy8e(v=VS.80).aspx
目录
三种实现委托的方法
从CIL代码比较匿名方法和Lambda表达式区别
从CIL代码研究带有参数的委托
从CIL代码研究匿名函数捕获变量和闭包的实质
正文
1.三种实现委托的方法
1.1下面先从一个简单的例子比较命名方法,匿名方法和Lambda 表达式三种实现委托的方法
(1)申明一个委托,当然这只是一个最简单的委托,没有参数和返回值,所以可以使用Action 委托
delegate void DelegateTest();
(2)创建一个静态方法,以作为参数实例化委托
static void DelegateTestMethod() { System.Console.WriteLine("命名方式"); }
(3)在主函数中添加代码
//命名方式 DelegateTest dt0 = new DelegateTest(DelegateTestMethod); //匿名方法 DelegateTest dt1 = delegate() { System.Console.WriteLine("匿名方法"); }; //Lambda 表达式 DelegateTest dt2 = ()=> { System.Console.WriteLine("Lambda 表达式"); }; dt0(); dt1(); dt2(); System.Console.ReadLine();
输出
命名方式
匿名方法
Lambda 表达式
1.2说明
通过这个例子可以看出,三种方法中命名方式是最麻烦的,代码也很臃肿,而匿名方法和Lambda 表达式则直接简洁很多。这个例子只是实现最简单的委托,没有参数和返回值,事实上Lambda 表达式较匿名方法更直接,更具有表达力。本文就不详细介绍Lambda表示式了,可以在MSDN上详细了解http://msdn.microsoft.com/zh-cn/library/bb397687.aspx那么Lambda表达式除了书写方式和匿名方法不同之外,还有什么不一样的地方吗?众所周知,.Net工程编译生成的输出文件是程序集,而程序集中的代码并不是可以直接运行的本机代码,而是被称为CIL(IL和MSIL都是曾用名,本文采用CIL)的中间语言。
原理图如下:
因此可以通过CIL代码研究C#语言的实现方式。(本文采用ildasm.exe查看CIL代码)
2.从CIL代码比较匿名方法和Lambda表达式区别
2.1C#代码
为了便于研究,将之前的例子拆分为两个不同的程序,唯一区别在于主函数
代码1采用匿名方法
//匿名方法 DelegateTest dt = delegate() { System.Console.WriteLine("Just for test"); }; dt();
代码2采用Lambda 表达式
//Lambda 表达式 DelegateTest dt = () => { System.Console.WriteLine("Just for test"); }; dt();
2.2查看代码1程序集CIL代码
用ildasm.exe查看代码1生成程序集的CIL代码
可以分析出CIL中类结构:
静态函数CIL代码
.method private hidebysig static void '<Main>b__0'() cil managed { .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // 代码大小 13 (0xd) .maxstack 8 IL_0000: nop IL_0001: ldstr "Just for test" IL_0006: call void [mscorlib]System.Console::WriteLine(string) IL_000b: nop IL_000c: ret } // end of method Program::'<Main>b__0'
主函数
.method private hidebysig static void Main(string[] args) cil managed { .entrypoint // 代码大小 47 (0x2f) .maxstack 3 .locals init ([0] class DelegateTestDemo.Program/DelegateTest dt) IL_0000: nop IL_0001: ldsfld class DelegateTestDemo.Program/DelegateTest DelegateTestDemo.Program::'CS$<>9__CachedAnonymousMethodDelegate1' //将静态字段的值推送到计算堆栈上。 IL_0006: brtrue.s IL_001b //如果 value 为 true、非空或非零,则将控制转移到目标指令(短格式)。 IL_0008: ldnull //将空引用(O 类型)推送到计算堆栈上 IL_0009: ldftn void DelegateTestDemo.Program::'<Main>b__0'() //将指向实现特定方法的本机代码的非托管指针(natural int 类型)推送到计算堆栈上。 IL_000f: newobj instance void DelegateTestDemo.Program/DelegateTest::.ctor(object, native int) //创建一个值类型的新对象或新实例,并将对象引用(O 类型)推送到计算堆栈上。 IL_0014: stsfld class DelegateTestDemo.Program/DelegateTest DelegateTestDemo.Program::'CS$<>9__CachedAnonymousMethodDelegate1' //用来自计算堆栈的值替换静态字段的值。 IL_0019: br.s IL_001b //无条件地将控制转移到目标指令(短格式)。 IL_001b: ldsfld class DelegateTestDemo.Program/DelegateTest DelegateTestDemo.Program::'CS$<>9__CachedAnonymousMethodDelegate1' //将静态字段的值推送到计算堆栈上。 IL_0020: stloc.0 //从计算堆栈的顶部弹出当前值并将其存储到指定索引处的局部变量列表中。 IL_0021: ldloc.0 //将指定索引处的局部变量加载到计算堆栈上。 IL_0022: callvirt instance void DelegateTestDemo.Program/DelegateTest::Invoke() //对对象调用后期绑定方法,并且将返回值推送到计算堆栈上。 IL_0027: nop IL_0028: call string [mscorlib]System.Console::ReadLine() //调用由传递的方法说明符指示的方法。 IL_002d: pop //移除当前位于计算堆栈顶部的值。 IL_002e: ret //从当前方法返回,并将返回值(如果存在)从调用方的计算堆栈推送到被调用方的计算堆栈上。 } // end of method Program::Main
2.3查看代码2程序集CIL代码
用ildasm.exe查看代码2生成程序集的CIL代码
通过比较发现和代码1生成程序集的CIL代码完全一样。
2.4分析
可以清楚的发现在CIL代码中有一个静态的方法<Main>b__0,其内容就是匿名方法和Lambda 表达式语句块中的内容。在主函数中通过<Main>b__0实例委托,并调用。
2.5结论
无论是用匿名方法还是Lambda 表达式实现的委托,其本质都是完全相同。他们的原理都是在C#语言编译过程中,创建了一个静态的方法实例委托的对象。也就是说匿名方法和Lambda 表达式在CIL中其实都是采用命名方法实例化委托。
C#在通过匿名函数实现委托时,需要做以下步骤
(1)一个静态的方法(<Main>b__0),用以实现匿名函数语句块内容
(2)用方法(<Main>b__0)实例化委托
匿名函数在CIL代码中实现的原理图
3.从CIL代码研究带有参数的委托
3.1C#代码
为了便于研究采用匿名方法实现委托的方式,将代码改为:
(1)将委托改为
delegate void DelegateTest(string msg);
(2)将主函数改为
DelegateTest dt = delegate(string msg) { System.Console.WriteLine(msg); }; dt("Just for test");
输出结果
Just for test
3.2查看CIL代码
静态函数
.method private hidebysig static void '<Main>b__0'(string msg) cil managed { .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) // 代码大小 9 (0x9) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: call void [mscorlib]System.Console::WriteLine(string) IL_0007: nop IL_0008: ret } // end of method Program::'<Main>b__0'
主函数
.method private hidebysig static void Main(string[] args) cil managed { .entrypoint // 代码大小 52 (0x34) .maxstack 3 .locals init ([0] class DelegateTestDemo.Program/DelegateTest dt) IL_0000: nop IL_0001: ldsfld class DelegateTestDemo.Program/DelegateTest DelegateTestDemo.Program::'CS$<>9__CachedAnonymousMethodDelegate1' IL_0006: brtrue.s IL_001b IL_0008: ldnull IL_0009: ldftn void DelegateTestDemo.Program::'<Main>b__0'(string) IL_000f: newobj instance void DelegateTestDemo.Program/DelegateTest::.ctor(object, native int) IL_0014: stsfld class DelegateTestDemo.Program/DelegateTest DelegateTestDemo.Program::'CS$<>9__CachedAnonymousMethodDelegate1' IL_0019: br.s IL_001b IL_001b: ldsfld class DelegateTestDemo.Program/DelegateTest DelegateTestDemo.Program::'CS$<>9__CachedAnonymousMethodDelegate1' IL_0020: stloc.0 IL_0021: ldloc.0 IL_0022: ldstr "Just for test" IL_0027: callvirt instance void DelegateTestDemo.Program/DelegateTest::Invoke(string) IL_002c: nop IL_002d: call string [mscorlib]System.Console::ReadLine() IL_0032: pop IL_0033: ret } // end of method Program::Main
3.3分析
可以看出与上一节的例子唯一不同的是CIL代码中生成的静态函数需要传递一个string对象作为参数。
3.4结论
委托是否带有参数对于C#实现基本没有影响。
4.从CIL代码研究匿名函数捕获变量和闭包的实质
匿名函数不同于命名方法,可以访问它门外围作用域的局部变量和环境。本文采用了一个例子说明匿名函数(Lambda 表达式)可以捕获外围变量。而只要匿名函数有效,即使变量已经离开了作用域,这个变量的生命周期也会随之扩展。这个现象被称为闭包。
4.1C#代码
代码如下:
(1)定义一个委托
delegate void DelTest(int n);
(2)在主函数中添加中添加代码
int t = 10; DelTest delTest = (n) => { System.Console.WriteLine("{0}", t + n); }; delTest(100);
输出结果
110
4.2查看CIL代码
分析类结构
分析Program::Main方法(主函数)
.method private hidebysig static void Main(string[] args) cil managed { .entrypoint // 代码大小 45 (0x2d) .maxstack 3 .locals init ([0] class ClosureTest.Program/DelTest delTest, [1] class ClosureTest.Program/'<>c__DisplayClass1' 'CS$<>8__locals2') IL_0000: newobj instance void ClosureTest.Program/'<>c__DisplayClass1'::.ctor() //创建一个对象 IL_0005: stloc.1 //计算堆栈的顶部弹出当前值并将其存储到索引 1 处的局部变量列表中。 IL_0006: nop IL_0007: ldloc.1 //将索引 1 处的局部变量加载到计算堆栈上。 IL_0008: ldc.i4.s 10 //将提供的 int8 值作为 int32 推送到计算堆栈上(短格式)。 IL_000a: stfld int32 ClosureTest.Program/'<>c__DisplayClass1'::t //用新值替换在对象引用或指针的字段中存储的值。 IL_000f: ldloc.1 //将索引 1 处的局部变量加载到计算堆栈上。 IL_0010: ldftn instance void ClosureTest.Program/'<>c__DisplayClass1'::'<Main>b__0'(int32) //将指向实现特定方法的本机代码的非托管指针(natural int 类型)推送到计算堆栈上。 IL_0016: newobj instance void ClosureTest.Program/DelTest::.ctor(object, native int) //创建一个对象 IL_001b: stloc.0 //计算堆栈的顶部弹出当前值并将其存储到索引 0 处的局部变量列表中。 IL_001c: ldloc.0 //将索引 0 处的局部变量加载到计算堆栈上。 IL_001d: ldc.i4.s 100 //将提供的 int8 值作为 int32 推送到计算堆栈上(短格式)。 IL_001f: callvirt instance void ClosureTest.Program/DelTest::Invoke(int32) //对对象调用后期绑定方法,并且将返回值推送到计算堆栈上。 IL_0024: nop IL_0025: call string [mscorlib]System.Console::ReadLine() IL_002a: pop IL_002b: nop IL_002c: ret } // end of method Program::Main
分析<>c__DisplayClass1::<Main>b__0方法
.method public hidebysig instance void '<Main>b__0'(int32 n) cil managed { // 代码大小 26 (0x1a) .maxstack 8 IL_0000: nop IL_0001: ldstr "{0}" //推送对元数据中存储的字符串的新对象引用。 IL_0006: ldarg.0 //将索引为 0 的参数加载到计算堆栈上。 IL_0007: ldfld int32 ClosureTest.Program/'<>c__DisplayClass1'::t //查找对象中其引用当前位于计算堆栈的字段的值。 IL_000c: ldarg.1 //将索引为 1 的参数加载到计算堆栈上。 IL_000d: add //将两个值相加并将结果推送到计算堆栈上。 IL_000e: box [mscorlib]System.Int32 //将值类转换为对象引用(O 类型)。 IL_0013: call void [mscorlib]System.Console::WriteLine(string, object) //调用由传递的方法说明符指示的方法。 IL_0018: nop IL_0019: ret } // end of method '<>c__DisplayClass1'::'<Main>b__0
4.3分析
可以看到与之前的例子不同,CIL代码中创建了一个叫做<>c__DisplayClass1的类,在类中有一个字段public int32 t,和方法<Main>b__0,分别对应要捕获的变量和匿名函数的语句块。
从主函数可以分析出流程
(1)创建一个<>c__DisplayClass1实例对象
(2)将<>c__DisplayClass1实例对象的字段t赋值为10
(3)创建一个DelTest委托类的实例对象,将<>c__DisplayClass1实例对象的<Main>b__0方法传递给构造函数
(4)调用DelTest委托,并将100作为参数
这时就不难理解闭包现象了,因为C#其实用类的字段来捕获变量(无论值类型还是引用类型),所其作用域当然会随着匿名函数的生存周期而延长。
4.4结论
C#在通过匿名函数实现需要捕获变量的委托时,需要做以下步骤
(1)创建一个类(<>c__DisplayClass1)
(2)在类中根据将要捕获的变量创建对应的字段(public int32 t)
(3)在类中创建一个方法(<Main>b__0),用以实现匿名函数语句块内容
(4)创建类(<>c__DisplayClass1)的对象,并用其方法(<Main>b__0)实例化委托
闭包现象则是因为步骤(2),捕获变量的实现方式所带来的附加产物。
需要捕获变量的匿名函数在CIL代码中实现原理图
结论
C#在实现匿名函数(匿名方法和Lambda 表达式),是通过隐式的创建一个静态方法或者类(需要捕获变量时),然后通过命名方式创建委托。
本文到这里笔者已经完成了对匿名方法,Lambda 表达式和闭包的探索, 明白了这些都是C#为了方便用户编写代码而准备的“语法糖”,其本质并未超出.Net之前的范畴。