委托

前言

  委托和事件是c#基础中两个重要的知识,平时工作中也会经常用到。接下来我会写两篇我对委托和事件的理解,欢迎拍砖。

  回调函数是一种非常有用的编程机制,许多语言都对它提供了支持。回调函数是一个通过函数指针调用的函数。通常,我们会把回调函数作为参数传递给另一个函数,当某些事件发生或满足某些条件时,由调用者执行回调函数用于对该事件或条件进行响应。简单来说,实现回调函数有如下步骤:

  1. 定义一个回调函数。

  2. 将回调函数指针注册给调用者。

  3. 在某些事件或条件发生时,调用者通过函数指针调用回调函数对事件进行处理。

  回调机制的应用非常多,例如控件事件、异步操作完成通知等等;.net 通过委托来实现回调函数机制。相比其他平台的回调机制,委托提供了更多的功能,例如它确保回调方法是类型安全的,支持顺序调用多个方法,以及调用静态方法和实例方法。

一、初识委托

  在开始接触委托前,相信很多人都会感觉它用起来怪怪的,有些别扭。理解它的本质后,就知道许多时候其实是编译器在背后“搞鬼”;编译器做了大量的工作,目的是为了减少代码的编写以及让代码看起来更优雅。接下来就让我们逐步深入理解委托。

  先看一段简单的代码:  

        //1.定义一个委托类型
        delegate void TestDelegate(int value);

        static void Main(string[] args)
        {   
            //2.传递null
            ExecuteDelegate(null, 10);
   
            //3.调用静态方法
            TestDelegate test1 = new TestDelegate(StaticFunction);
            ExecuteDelegate(test1, 10);

            //4.调用实例方法
            Program program = new Program();
            TestDelegate test2 = new TestDelegate(program.InstanceFunction);
            ExecuteDelegate(test2, 10);

            //5.调用多个方法
            TestDelegate test3 = (TestDelegate)Delegate.Combine(test1, test2);
            ExecuteDelegate(test3, 10);
        }

        //静态方法
        static void StaticFunction(int value)
        {
            Console.WriteLine("Call StaticFunction: " + value.ToString());
        }

        //实例方法
        void InstanceFunction(int value)
        {
            Console.WriteLine("Call InstanceFunction: " + value.ToString());
        }

        //执行委托
        static void ExecuteDelegate(TestDelegate tg, int value)
        {
            if (tg != null)
            {
                tg(value);
            }
        }

  第1步,用delegate关键字定义了一个委托类型,名称为TestDelegate。它的签名为:1. 返回值为void 2. 有一个int类型的参数。回调函数的签名必须与之一样,否则编译会报错。

  第2步,调用执行委托的方法并传递了null,实际上什么也没做。这里说明了委托可以作为参数,可以为null,似乎与引用类型相似。

  第3步,用 new 创建了一个TestDelegate的变量test1, 并将静态方法作为参数,它符合委托的签名。通过new 来创建,我们基本可以推测TestDelegate是一个引用类型。

  第4步,与3类似,只不过它传递的参数是一个实例方法,所以需要先创建方法的对象Program。

  第5步,调用了Delegate.Combine()方法,通过名称可以指定它用于将多个委托组合起来,调用test3时,会按照它的参数顺序执行所有方法。这种方式有时候非常有用,因为我们很可能在某个事件发生时,要执行多个操作。  

  通过上面的代码,我们基本可以知道委托是用来包装回调函数的,对回调函数的调用其实是通过委托来实现的,这也是很符合【委托】的称呼。那么委托到底是一种什么样的类型?为什么它可以将函数名称作为参数?为什么可以像tg(value)这样来执行?Delegate.Combine内部的实现机制又是怎样的?接下来让我们一一解答。

二、委托揭秘

  上面提到,c#编译器为了简化代码的编写,在背后做了很多处理。委托的确是一种用来包装函数的引用类型,当我们用delegate定义上面的委托时,编译器会为我们生成一个class TestDelegate的类,这个类就是用来包装回调函数的。通过ILDasm.exe查看上面的IL代码可以很清晰看到这个过程:

  可以看到,编译器为我们生成了一个 TestDelegate  的class 类型,并且它还继承了MulticastDelegate。实际上,所有的委托都会继承MulticastDelegate,而MulticastDelegate又继承了Delegate。Delegate有2个重要的非公共字段:

1. _target: object类型,当委托包装的是实例方法时,这个字段引用的是实例方法的对象;如果是静态方法,这个字段就是null。

2. _methodPtr: IntPtr类型,一个整数值,用于标识回调方法。

所以对于实例方法,委托就是通过实例对象去调用所包装的方法的。Delegate还公开了两个属性,Target和Method分别表示实例对象(静态方法为null)和包装函数的元信息。

  可以看到经过编译器编译后生成的这个类有4个函数,.ctor(构造函数),BeginInvoke, EndInvoke, Invoke。BeginInvoke/EndInvoke 是Invoke的异步版本,所以我们主要关注.ctor和Invoke函数。

  .ctor构造函数有两个参数,一个object类型,一个int类型。但当我们new一个委托对象时,传递却是一个方法的名称。实际上,编译器知道我们要构造的是委托对象,所以会分析源代码知道要调用的是哪个对象和方法;对象引用就是作为第一个参数(如果静态就为null),而从元数据获取用于标识函数的特殊值就作为第二个参数,从而调用构造函数。这两个参数分别保存在 _target 和 _methodPth字段中。

  Invoke 函数顾名思义就是用来调用函数的,当我们执行tg(value)时,编译器发现tg引用的是一个委托对象,所以生成的代码就是调用委托对象的Invoke方法,该方法的签名与我们签名定义的签名是一致的。生成的IL代码如: callvirt  instance void TestDelegate2.Program/TestDelegate::Invoke(int32)。

  至此,我们知道定义委托就是定义类,这个类用来包装回调函数。通过该类的Invoke方法执行回调函数。

三、委托链

  前面说到所有的委托类型都会继承MulticastDelegate。MulticastDelegate表示多路广播委托,其调用列表可以拥有多个委托,我们称之为委托链。简单的说,它拥有一个委托列表,我们可以顺序调用里面所有方法。通过源码可知,MulticastDelegate有一个_invocationList字段,用于引用一个委托对象数组;我们可以通过Delegate.Combine将多个委托添加到这个数组当中,既然有Combine就会有Remove,对应用来从委托链中移除指定的委托。接下来我们来看这个具体的过程。如下代码:

            TestDelegate test1 = new TestDelegate(StaticFunction); //1
            TestDelegate test2 = new TestDelegate(StaticFunction); //2
            TestDelegate test3 = new TestDelegate(new Program().InstanceFunction); //3
            TestDelegate result = (TestDelegate)Delegate.Combine(test1, test2); //4
            result = (TestDelegate)Delegate.Combine(result, test3); //5
            Delegate.Remove(result, test1); //6

  当执行1~3行时,会创建3个TestDelegate对象,如下所示:

  

  执行第4行时,会通过Delegate.Combine创建一个具有委托链的TestDelegate对象,该对象的_target和_methodPtr已经不是我们想关注的了,_invocationList引用了一个数组对象,数组有test1,test2两个元素。如下:

  

  执行第5行代码时,同样会重新创建一个具有委托链的TestDelegate对象,此时_invocationList具有3个元素。需要注意的是,由于Delegate.Combine(或者Remove)每一次都会重新创建委托对象,所以第4行的result引用的对象不再被引用,此时它可以被回收了。如:

  执行Remove时,与Combine类似,都会重新创建委托对象,此时从数组移除test1委托对象,这里就不在重复。

  通过上面的分析,我们知道调用方法实际就是调用委托对象的Invoke方法,如果_invocationList引用了一个数组,那么它会遍历这个数组,并执行所有注册的方法;否则执行_methodPtr方法。Invoke伪代码看起来也许像下面这样:

        public void Invoke(Int32 value)
        {
            Delegate[] delegateSet = _invocationList as Delegate[];
            if (delegateSet != null)
            {
                foreach (var d in delegateSet)
                {
                    d(value);
                }
            }
            else
            {
                _methodPtr.Invoke(value);
            }
        }

  _invocationList毕竟是内部字段,默认情况下会按顺序调用,但有时候我们想控制这个过程,例如按某些条件执行或者记录异常等。MulticastDelegate有一个GetInvocationList()方法,用于获取Delegate[]数组,有了该数组,我们就可以控制具体的执行过程了。

四、泛型委托

  我们可能会在多个地方用到委托,例如在另一个程序集,我们可能会定义一个 delegate void AnotherDelegate(int value); 这个委托的签名和签名的是一样的。实际上.net内部就有许多这样的例子,平时我们也经常看到。例如:

            public delegate void WaitCallback(object state);
            public delegate void TimerCallback(object state);
            public delegate void ParameterizedThreadStart(object obj);

  上面只是这种签名的形式,另外一种形式也可能出现大量的重复,这将给代码维护带来很大的难度。泛型委托就是为了解决这个问题的。

  .net 已经定义了三种类型的泛型委托,分别是 Predicate、Action、Func。在使用linq的方法语法中,我们会经常遇到这些类型的参数。

  Action 从无参到16个参数共有17个重载,用于分装有输入值而没有返回值的方法。如:delegate void Action<T>(T obj);

  Fun 从无参到16个参数共有17个重载,用于分装有输入值而且有返回值的方法。如:delegate TResule Func<T>(T obj);

  Predicate 只有一种形式:public delegate bool Predicate<T>(T obj)。用于封装传递一个对象然后判断是否满足某些条件的方法。Predicate也可以用Func代替。

  有了泛型委托,我们就不用到处定义委托类型了,除非不满足需求,否则都应该优先使用内置的泛型委托。

五、c#对委托的支持

5.1 +=/-= 操作符

  c#编译器自动为委托类型重载了 += 和 -= 操作符,简化编码。例如要添加一个委托对象到委托链中,我们也可以 test1 += test2; 编译器可以理解这种写法,实际上这样写和调用test1 = Delegate.Combine(test1, test2) 生成的 IL 代码是一样的。

5.2 不需要构造委托对象

  在一个需要使用委托对象的地方,我们不必每次都new 一个,只传递要包装的函数即可。例如:test1 += StaticFunction; 或者 ExecuteDelegate(StaticFunction, 10);都是直接传递函数。编译器可以理解这种写法,它会自动帮我们new 一个委托对象作为参数。

5.3 不需要定义回调方法

  有时候回调方法只有很简单的几行,为了代码更紧凑和方便阅读,我们不想要定义一个方法。这个时候可以使用匿名方法,如:

ExecuteDelegate(delegate { Console.WriteLine("使用匿名方法"); }, 10);

  匿名方法也是用delegate关键字修饰的,形式为 delegate(参数){方法体}。匿名方法是c#2.0提供的,c#3.0提供了更优雅的lambda表达式来代替匿名方法。如:

ExecuteDelegate(obj => Console.WriteLine("使用lambda表达式"), 10);

  实际上编译器发现方法的形参是一个委托,而我们传递了lambda表达式,编译会尝试随机为我们生成一个外部不可见的特殊方法,本质上还是在源码中定义了一个新的方法,我们可以通过反编译工具看到这个行为。lambda提供的更方便的实现方式,但在方法有重用或者实现起来比较复杂的地方,还是推荐重新定义一个方法。

五、委托与反射

  虽然委托类型直接继承了MulticastDelegate,但Delegate提供了许多有用的方法,实际上这两个都是抽象类,只要提供一个即可,可能是.net设计的问题,搞了两个出来。Delegate提供了CreateDelegate 和 DynamicInvoke两个关于反射的方法。CreateDelegate提供了多种重载方式,具体可以查看msdn;DynamicInvoke参数数一个可变的object数组,这就保证了我们可以在对参数未知的情况下对方法进行调用。如:

            MethodInfo methodInfo = typeof(Program).GetMethod("StaticFunction", BindingFlags.Static | BindingFlags.NonPublic);
            Delegate funcDelegate = Delegate.CreateDelegate(typeof(Action<int>), methodInfo);
            funcDelegate.DynamicInvoke(10);

  这里我们只需要知道方法的名称(静态或实例)和委托的类型,完全不用知道方法的参数个数、具体类型和返回值就可以对方法进行调用。

  反射可以带来很大灵活性,但效率一直是个问题。有几种方式可以对其进行优化。基本就是:Delegate.DynamicInvoke、Expression(构建委托) 和 Emit。从上面可以看到,DynamicInvoke的方式还是需要知道委托的具体类型(Action<int>部分),而不能直接从方法的MethodInfo元信息直接构建委托。当在知道委托类型的情况下,这种情况下是最简单的实现方式。

  使用委托+缓存来优化反射是我比较喜欢的方式,相比另外两种做法,可以兼顾效率和代码的可读性。具体的实现方式大家可以在网上找,或者参考我的Ajax系列(还没写完,囧)后续也会提到。

  委托和事件经常会联系在一起,一些面试官也特别喜欢问这个问题。它们之间究竟是一个什么样的关系,下一篇就对事件展开讨论。

posted @ 2015-10-30 11:43  我是攻城狮  阅读(4044)  评论(4编辑  收藏  举报