代码改变世界

探索.Net中的委托

2009-10-14 14:44  横刀天笑  阅读(5816)  评论(20编辑  收藏  举报

(本篇文章为我正在写的开放电子书《.Net面面观》第二章第二小节,第一章参见这里)

废话

我本来以为委托很简单,本来只想简简单单的说说委托背后的东西,委托的使用方法。原本只想解释一下那句:委托是面向对象的、类型安全的函数指针。可没想到最后惹出一堆的事情来,越惹越多,罪过,罪过。本文后面一部分是我在一边用SOS探索一边记录的,写的非常糟糕,希望您的慧眼能发现一些有价值的东西,那我就感到无比的荣幸了。

委托前世与今生

大家可能还记得,在C/C++里,我们可以在一个函数里实现一个算法的骨架,然后在这个函数的参数里放一个“钩子”,使用的时候,利用这个“钩子”注入一个函数,注入的函数实现不同算法的不同部分,这样就可以达到算法骨架重用的目的。而这里所谓的“钩子”就是“函数指针”。这个功能很强大啊,但是函数指针却有它的劣势:不是类型安全的、只能“钩”一个函数。大家可能都知道微软对委托的描述:委托是一种面向对象的,类型安全的,可以多播的函数指针。要理解这句话,我们先来看看用C#的关键字delegate声明的一个委托到底是什么样的东西:

   1: namespace Yuyijq.DotNet.Chapter2
   2: {
   3:     public delegate void MyDelegate(int para);
   4: }

 

隐藏在背后的秘密

很简单的代码吧,使用ILDasm反编译一下:

wps_clip_image-0

奇怪的是,这么简单的一行代码,变成了一个类:类名与委托名一致,这个类继承自System.MulticastDelegate类,连构造器一起有四个成员。看看我们如何使用这个委托:

   1: public class TestDelegate
   2: {
   3:     MyDelegate myDelegate;
   4:  
   5:     public void AssignDelegate()
   6:     {
   7:         this.myDelegate = new MyDelegate(Test);
   8:     }
   9:  
  10:     public void Test(int para)
  11:     {
  12:         Console.WriteLine("Test Delegate");
  13:     }
  14: }

编译后用ILDasm看看结果:

.field private class Yuyijq.DotNet.Chapter2.MyDelegate myDelegate

发现,.Net把委托就当做一个类型,与其他类型一样对待,现在你明白了上面那句话中说委托是面向对象的函数指针的意思了吧。

接着看看AssignDelegate反编译后的代码:

   1: .method public hidebysig instance void  AssignDelegate() cil managed
   2: {
   3:   // Code size       19 (0x13)
   4:   .maxstack  8
   5: //将方法的第一个参数push到IL的运算栈上(对于一个实例方法来说,比如AssignDelegate,它的第一个参数就是“this”了)
   6:   IL_0000:  ldarg.0
   7: //这里又把this压栈了一次,因为下面一条指令中的Test方法是一个实例方法,需要一个this
   8:   IL_0001:  ldarg.0
   9: //ldftn就是把实现它的参数中的方法的本机代码的非托管指针push到栈上,在这里你就可以认为是获取实例方法Test的地址
  10:   IL_0002:  ldftn instance void Yuyijq.DotNet.Chapter2.TestDelegate::Test(int32)
  11: //调用委托的构造器,这个构造器需要两个参数,一个对象引用,就是第一次压栈的this,一个方法的地址。
  12:   IL_0008:  newobj instance void Yuyijq.DotNet.Chapter2.MyDelegate::.ctor(object,native int)
  13:   IL_000d:  stfld class Yuyijq.DotNet.Chapter2.MyDelegate Yuyijq.DotNet.Chapter2.TestDelegate::myDelegate
  14:   IL_0012:  ret
  15: }

通过上面的代码,我们会发现,将一个实例方法分配给委托时,委托不仅仅引用了方法的地址,还有这个方法所在对象的引用,这里就是所谓的类型安全。

我们再回过头来看看MyDelegate的继承链:MyDelegate->MulticastDelegate->Delegate。

奇妙的地方

而Delegate中有三个有趣的字段:

internal object _target;
internal IntPtr _methodPtr;
internal IntPtr _methodPtrAux;

对这三个字段做详细说明

_target

1、如果委托指向的方法是实例方法,则_target的值是指向目标方法所在对象的指针

2、如果委托指向的是静态方法,则_target的值是委托实例自身

_methodPtr

1、如果委托指向的方法是实例方法,则_methodPtr的值指向一个JIT Stub(如果这个方法还没有被JIT编译,关于JIT Stub会在后面的章节介绍),或指向该方法JIT后的地址

2、如果委托指向的方法是静态方法,则_methodPtr指向的是一个Stub(一段小代码,这段代码的作用是_target,然后调用_methodPtrAux指向的方法),而且所有签名相同的委托,共享这个Stub。为什么要这样一个Stub?我想是为了让通过委托调用方法的流程一致吧,不管指向的是实例方法还是静态方法,对于外部来说,只需要调用_methodPtr指向的地址,但是对于调用实例方法而言,它需要this,也就是这里的_target,而静态方法不需要,为了让这里的过程一直,CLR会偷偷的在委托指向静态方法时插入一小段代码,用于去掉_target,而直接jmp到_methodPtrAux指向的方法。

_methodPtrAux

1、如果委托指向的是实例方法,则_methodPtrAux就是0。

2、如果委托指向的是静态方法,则这时_methodPtrAux起的作用与_mthodPtr在委托指向实例方法的时候是一样的。

实际上通过反编译Delegate的代码发现,Delegate有一个只读属性Target,该Target的实现依靠GetTarget方法,该方法的代码如下:

   1: internal virtual object GetTarget()
   2: {
   3:     if (!this._methodPtrAux.IsNull())
   4:     {
   5:         return null;
   6:     }
   7:     return this._target;
   8: }

实了当委托指向静态方法时,Target属性为null。

我们来自己动手,分析一下上面的结论是否正确。

_target和_methodPtr真的如上面所说的么?何不自己动手看看。

建立一个Console类型的工程,在项目属性的“调试(Debug)”选项卡里选中“允许非托管代码调试(Enable unmanaged code debuging)”。

   1: namespace Yuyijq.DotNet.Chapter2
   2: {
   3:     public delegate void MyDelegate(int para);
   4:     public class TestDelegate
   5:     {
   6:         public void Test(int para)
   7:         {
   8:             Console.WriteLine("Test Delegate");
   9:         }
  10:         public void CallByDelegate()
  11:         {
  12:             MyDelegate myDelegate = new MyDelegate(this.Test);
  13:             myDelegate(5);
  14:         }
  15:  
  16:         static void Main()
  17:         {
  18:             TestDelegate test = new TestDelegate();
  19:             test.CallByDelegate();
  20:         }
  21:     }
  22: }

上面是作为实验的代码。

在CallByDelegate方法的第二行设置断点

F5执行,命中断电后,在Visual Studio的立即窗口(Immediate Window)里输入如下命令(菜单栏->调试(Debug)->立即窗口(Immediate)):

//.load sos.dll用于加载SOS.dll扩展

.load sos.dll

extension C:\Windows\Microsoft.NET\Framework\v2.0.50727\sos.dll loaded

//Dump Stack Objects的缩写,输出栈中的所有对象

//该命令的输出有三列,第二列Object就是该对象在内存中的地址

!dso

PDB symbol for mscorwks.dll not loaded

OS Thread Id: 0x1588 (5512)

ESP/REG Object Name

0037ec10 019928a4 Yuyijq.DotNet.Chapter2.TestDelegate

0037ed50 019928a4 Yuyijq.DotNet.Chapter2.TestDelegate

0037ed5c 019928b0 Yuyijq.DotNet.Chapter2.MyDelegate

0037ed60 019928b0 Yuyijq.DotNet.Chapter2.MyDelegate

0037ef94 019928b0 Yuyijq.DotNet.Chapter2.MyDelegate

0037ef98 019928b0 Yuyijq.DotNet.Chapter2.MyDelegate

0037ef9c 019928a4 Yuyijq.DotNet.Chapter2.TestDelegate

0037efe0 019928a4 Yuyijq.DotNet.Chapter2.TestDelegate

0037efe4 019928a4 Yuyijq.DotNet.Chapter2.TestDelegate

//do命令为Dump Objects缩写,参数为对象地址,输出该对象的一些信息

!do 019928b0

Name: Yuyijq.DotNet.Chapter2.MyDelegate

MethodTable: 00263100

EEClass: 002617e8

Size: 32(0x20) bytes

(E:\Study\Demo\Demo\bin\Debug\Demo.exe)

//该对象的一些字段

Fields:

MT Field Offset Type VT Attr Value Name

704b84dc 40000ff 4 System.Object 0 instance 019928a4 _target

704bd0ac 4000100 8 ...ection.MethodBase 0 instance 00000000 _methodBase

704bb188 4000101 c System.IntPtr 1 instance 0026C018 _methodPtr

704bb188 4000102 10 System.IntPtr 1 instance 00000000 _methodPtrAux

704b84dc 400010c 14 System.Object 0 instance 00000000 _invocationList

704bb188 400010d 18 System.IntPtr 1 instance 00000000 _invocationCount

在最后Fields一部分,我们看到了_target喝_methodPtr,_target的值为019928a4,看看上面!dso命令的输出,这个不就是Yuyijq.DotNet.Chapter2.TestDelegate实例的内存地址么。

在上面的!do命令的输出中,我们看到了MethodTable:00263100,这就是该对象的方法表地址(关于方法表更详细的讨论会在后面的章节介绍到,现在你只要把他看做一个记录对象所有方法的列表就行了,该列表里每一个条目就是一个方法)。现在我们要看看Yuyijq.DotNet.Chapter2.TestDelegate..Test方法的内存地址,看起是否与_methodPtr的值是一致的,那么首先就要获得Yuyijq.DotNet.Chapter2.TestDelegate.的实例中MethodTable的值:

!do 019928a4

Name: Yuyijq.DotNet.Chapter2.TestDelegate

MethodTable: 00263048

EEClass: 002612f8

Size: 12(0xc) bytes

(E:\Study\Demo\Demo\bin\Debug\Demo.exe)

Fields:

None

现在知道了其方法表的值为00263048,然后使用下面的命令找到Yuyijq.DotNet.Chapter2.TestDelegate..Test方法的地址:

!dumpmt -md 00263048

EEClass: 002612f8

Module: 00262c5c

Name: Yuyijq.DotNet.Chapter2.TestDelegate

mdToken: 02000003 (E:\Study\Demo\Demo\bin\Debug\Demo.exe)

BaseSize: 0xc

ComponentSize: 0x0

Number of IFaces in IFaceMap: 0

Slots in VTable: 9

--------------------------------------

MethodDesc Table

Entry MethodDesc JIT Name

.......

0026c010 00262ffc NONE Yuyijq.DotNet.Chapter2.TestDelegate.AssignDelegate()

0026c018 0026300c NONE Yuyijq.DotNet.Chapter2.TestDelegate.Test(Int32)

......

Entry这一列就是一个JIT Stub。看看,果然与_methodPtr的是一致的,因为这时Test方法还没有经过JIT(JIT列为NONE),所以_methodPtr指向的是这里的JIT Stub。

如果给委托绑定一个静态方法呢?现在我们把Test方法改为静态的,那实例化委托的时候,就不能用this.Test了,而应该用TestDelegate.Test。还是在原位置设置断点,使用与上面相同的命令,查看_target与_methodPtr的值。

MT Field Offset Type VT Attr Value Name

704b84dc 40000ff 4 System.Object 0 instance 01e928b0 _target

704bb188 4000101 c System.IntPtr 1 instance 007809C4 _methodPtr

704bb188 4000102 10 System.IntPtr 1 instance 0025C018 _methodPtrAux

你会发现这里的_target字段的值就是MyDelegate的实例myDelegate的地址。然后我们通过上面的方法,找到Test方法的地址,发现_methodPtrAux的值与该值是相同的。

实际上你还可以再编写一个与MyDelegate相同签名的委托,然后也指向一个静态方法,使用相同的方法查看该委托的_methodPtr的值,你会发现这个新委托与MyDelegate的_methodPtr的值是一致的。

刚才不是说这个时候_methodPtr指向的是一个Stub么,既然如此那我们反汇编一下代码:

!u 007809C4

Unmanaged code

007809C4 8BC1 mov eax,ecx

007809C6 8BCA mov ecx,edx

007809C8 83C010 add eax,10h

007809CB FF20 jmp dword ptr [eax]

........

.Net里JIT的方法的调用约定是Fast Call,对于Fast Call来说,方法的前两个参数会放在ECX和EDX两个寄存器中。那么mov eax,ecx实际上就是将_target传递给eax,再看看

704bb188 4000102 10 System.IntPtr 1 instance 0025C018 _methodPtrAux

_methodPtrAux的偏移是10,这里的add eax,10h就是将eax指向_methodPtrAux,然后jmp dword ptr[eax]就是跳转到_methodPtrAux所指向的地址了,就是委托指向的那个静态方法。

通过委托调用方法

如何通过委托调用方法呢:

   1: public void CallByDelegate()
   2: {
   3:    MyDelegate myDelegate = new MyDelegate(this.Test);
   4:  
   5:    myDelegate(5);
   6: }

再来看看其对应的IL代码:

   1: .method public hidebysig instance void  CallByDelegate() cil managed
   2: {
   3:   // Code size       21 (0x15)
   4:   .maxstack  3
   5:   .locals init ([0] class Yuyijq.DotNet.Chapter2.MyDelegate myDelegate)
   6:   IL_0000:  ldarg.0
   7:   IL_0001:  ldftn instance void Yuyijq.DotNet.Chapter2.TestDelegate::Test(int32)
   8:   IL_0007:  newobj instance void Yuyijq.DotNet.Chapter2.MyDelegate::.ctor(object, native int)
   9:   IL_000c:  stloc.0
  10:   IL_000d:  ldloc.0
  11:   IL_000e:  ldc.i4.5
  12:   IL_000f:  callvirt   instance void Yuyijq.DotNet.Chapter2.MyDelegate::Invoke(int32)
  13:   IL_0014:  ret
  14: }

前面的代码我们已经熟悉,最关键的就是

callvirt instance void Yuyijq.DotNet.Chapter2.MyDelegate::Invoke(int32)

我们发现,通过委托调用方法,实际上就是调用委托的Invoke方法。

多播的委托

好了,既然已经解释了面向对象和类型安全,那么说委托是多播的咋解释?

你可能已经发现,MyDelegate继承自MulticastDelegate,看这个名字貌似有点意思了。来看看下面这两行代码:

   1: MyDelegate myDelegate = new MyDelegate(this.Test);
   2: myDelegate += new MyDelegate(this.Test1);

通过IL我们可以发现,这里的+=最后就是调用System.Delegate的Combine方法。而Combine的真正实现时在MulticastDelegate的CombineImpl方法中。在MulticastDelegate中有一个_invocationList字段,从CombineImpl中可以看出这个字段是一个object[]类型的,而委托链就放在这个数组里。

 

后记

文章是想到哪儿写到哪儿,写的比较乱,也比较匆忙。非常抱歉。对于中间那段奇妙的事情,我原来真的不知道,我一直以为当委托指向一个静态方法时,_target指向null就完事儿了,没想到还有这么一番景象。看来很多东西还是不能想当然,亲身尝试一下才知道真实的情况。