(转).NET面试题系列[7] - 委托与事件

2019-02-25  本文已影响0人  aslbutton

委托和事件

委托在C#中具有无比重要的地位。

C#中的委托可以说俯拾即是,从LINQ中的lambda表达式到(包括但不限于)winform,wpf中的各种事件都有着委托的身影。C#中如果没有了事件,那绝对是一场灾难,令开发者寸步难行。而委托又是事件的基础,可以说是C#的精髓,个人认为,其地位如同指针之于C语言。

很多开发者并不清楚最原始版本的委托的写法,但是这并不妨碍他们熟练的运用LINQ进行查询。对于这点我只能说是微软封装的太好了,导致我们竟可以完全不了解一件事物的根本,也能正确无误的使用。而泛型委托出现之后,我们也不再需要使用原始的委托声明方式。

CLR via C#关于委托的内容在第17章。委托不是类型的成员之一,但事件是。委托是一个密封类,可以看成是一个函数指针,它可以随情况变化为相同签名的不同函数。我们可以通过这个特点,将不同较为相似的函数中相同的部分封装起来,达到复用的目的。

回调函数

回调函数是当一个函数运行完之后立即运行的另一个函数,这个函数需要之前函数的运行结果,所以不能简单的将他放在之前的函数的最后一句。回调函数在C#问世之前就已经存在了。在C中,可以定义一个指针,指向某个函数的地址。但是这个地址不携带任何额外的信息,比如函数期望的输入输出类型,所以C中的回调函数指针不是类型安全的。

如果类型定义了事件成员,那么其就可以利用事件,通知其他对象发生了特定的事情。你可能知道,也可能不知道事件什么时候会发生。例如,Button类提供了一个名为Click的事件,该事件只有在用户点击了位于特定位置的按钮才会发生。想象一下如果不是使用事件,而是while轮询(每隔固定的一段时间判断一次)的方式监听用户的点击,将是多么的扯淡。事件通过委托来传递信息,可以看成是一个回调的过程,其中事件的发起者将信息通过委托传递给事件的处理者,后者可以看成是一个回调函数。

委托的简单调用 – 代表一个相同签名的方法

委托可以接受一个和它的签名相同的方法。对于签名相同,实现不同的若干方法,可以利用委托实现在不同情况下调用不同方法。

使用委托分为三步:
1. 定义委托
2. 创建委托的一个实例,并指向一个合法的方法(其输入和输出和委托本身相同)
3. 同步或异步调用方法

在下面的例子中,委托指向Select方法,该方法会返回输入list中,所有大于threshold的成员。

//1.Define
    public delegate List<int> SelectDelegate(List<int> aList, int threshold);

    class Program
    {
        static void Main(string[] args)
        {
            var list = new List<int>();
            
            //Add numbers from -5 to 4
            list.AddRange(Enumerable.Range(-5, 10));

            //2.Initialize delegate, now delegate points to function 'Predicate'
            SelectDelegate sd = Select;

            //3.Invoke
            list = sd.Invoke(list, 1);

            //Only member > 1 are selected
            Console.WriteLine("Now list has {0} members.", list.Count);
        }

        public static List<int> Select(List<int> aList, int threshold)
        {
            List<int> ret = new List<int>();
            foreach (var i in aList)
            {
                if (i > threshold)
                {
                    ret.Add(i);
                }
            }
            return ret;
        }
    }

委托的作用 – 将方法作为方法的参数

在看完上面的例子之后,可能我们仍然会有疑惑,我们直接调用Select方法不就可以了,为什么搞出来一个委托的?下面就看看委托的特殊作用。我个人的理解,委托有三大重要的作用,提高扩展性,异步调用和作为回调。

首先来看委托如何实现提高扩展性。我们知道委托只能变身为和其签名相同的函数,所以我们也只能对相同签名的函数谈提高扩展性。假设我们要写一个类似计算器功能的类,其拥有四个方法,它们的签名都相同,都接受两个double输入,并输出一个double。此时常规的方法是:

public enum Operator
    {
        Add, Subtract, Multiply, Divide
    }

    public class Program
    {
        static void Main(string[] args)
        {
            double a = 1;
            double b = 2;

            Console.WriteLine("Result: {0}", Calculate(a, b, Operator.Divide));
        }

        public static double Calculate(double a, double b, Operator o)
        {
            switch (o)
            {
                case Operator.Add: 
                    return Add(a, b);
                case Operator.Subtract: 
                    return Subtract(a, b);
                case Operator.Multiply: 
                    return Multiply(a, b);
                case Operator.Divide: 
                    return Divide(a, b);
                default:
                    return 0;
            }
        }

        public static double Add(double a, double b)
        {
            return a + b;
        }
        public static double Subtract(double a, double b)
        {
            return a - b;
        }
        public static double Multiply(double a, double b)
        {
            return a * b;
        }
        public static double Divide(double a, double b)
        {
            if (b == 0) throw new DivideByZeroException();
            return a / b;
        }
    }

我们通过switch分支判断输入的运算符号,并调用对应的方法输出结果。不过,这样做有一个不好的地方,就是如果日后我们再增加其他的运算方法(具有相同的签名),我们就需要修改Calculate方法,为switch增加更多的分支。我们不禁想问,可以拿掉这个switch吗?

如何做到去掉switch呢?我们必须要判断运算类型,所以自然的想法就是将运算类型作为参数传进去,然而传入了运算类型,就得通过switch判断,思维似乎陷入了死循环。但是如果我们脑洞开大一点呢?如果我们通过某种方式,传入add,subtract等方法(而不是运算类型),此时我们就不需要判断了吧。

也就是说代码就是如下的样子:

            double a = 1;
            double b = 2;

            //Parse function as parameter
            Console.WriteLine("Result: {0}", Calculate(a, b, Add));
            Console.WriteLine("Result: {0}", Calculate(a, b, Subtract));

我们假设电脑十分聪明,看到我们传入Add,就自动做加法,看到传入Subtract就做减法,最后输出3和-1。这种情况下我们当然不需要switch了。那么现在问题来了,这个 Calculate方法的签名是怎么样的?我们知道a和b都是double,那么第三个参数是什么类型?什么样的类型既可以代表Add又可以代表Subtract?我想答案已经呼之欲出了吧。

第三个参数当然就是一个委托类型。首先委托本身由于要和方法签名相同,故委托的定义只能是:

public delegate double CalculateDelegate(double a, double b);

第三个参数的签名也只能是:

public static double Calculate(double a, double b, CalculateDelegate cd)

完整的实现:

        static void Main(string[] args)
        {
            double a = 1;
            double b = 2;

            //Parse function as parameter
            Console.WriteLine("Result: {0}", Calculate(a, b, Add));
            Console.WriteLine("Result: {0}", Calculate(a, b, Subtract));
        }

        //Invoke delegate and return corresponding result
        public static double Calculate(double a, double b, CalculateDelegate cd)
        {
            return cd.Invoke(a, b);
        }

        public static double Add(double a, double b)
        {
            return a + b;
        }
        public static double Subtract(double a, double b)
        {
            return a - b;
        }
        public static double Multiply(double a, double b)
        {
            return a * b;
        }
        public static double Divide(double a, double b)
        {
            if (b == 0) throw new DivideByZeroException();
            return a / b;
        }

我们看到,我们彻底摈弃了switch这个顽疾,使得代码的扩展性大大增强了。假设哪天又来了第五种运算,我们只需要增加一个签名相同的方法:

        public static double AnotherOperation(double a, double b)
        {
            //TODO
        }

然后调用即可:
Console.WriteLine("Result: {0}", Calculate(a, b, AnotherOperation));

扩展阅读:函数式编程

许多人初学委托无法理解的一个重要原因是,总是把变量和方法看成不同的东西。方法必须输入若干变量,然后对它们进行操作,最后输出结果。但是实际上,方法本身也可以看成是一种特殊类型的变量。

相同签名的方法具有相同的类型,在C#****中,这个特殊的类型有一个名字,就叫做委托。如果说double****代表了(几乎)所有的小数,那么输入为double****,输出为double****的委托,代表了所有签名为****输入为double****,输出为double****的方法。所以,方法是变量的一种形式,方法既然可以接受变量,当然也可以接受另一个方法。

函数式编程是继面向对象之后未来的发展方向之一。简单来说,就是在函数式编程的环境下,你是在写函数,将一个集合通过函数映射到另一个集合。例如f(x)=x+1就是一个这样的映射,它将输入集合中所有的元素都加1,并将结果作为输出集合。由于你所有的函数都是吃进去集合,吐出来集合,所以你当然可以pipeline式的进行调用,从而实现一连串操作,既简单又优雅。

许多语言,例如javascript,C#都有函数式编程的性质。在以后的文章中,我们可以看到LINQ有很多函数式编程的特点:pipeline,currying等。有关函数式编程的内容可以参考:http://coolshell.cn/articles/10822.html以及http://www.ruanyifeng.com/blog/2012/04/functional_programming.html

委托的作用 – 异步调用和作为回调函数,委托的异步编程模型(APM)

通过委托的BeginInvoke方法可以实现异步调用。由于委托可以代表任意一类方法,所以你可以通过委托异步调用任何方法。对于各种各样的异步实现方式,委托是其中最早出现的一个,在C#1.0就出现了,和Thread的历史一样长。

异步调用有几个关键点需要注意:

实现一个简单的异步调用首先我们需要一个比较耗时的任务。在这里我打算通过某种算法,判断某个大数是否为质数。

        public static bool IsPrimeNumber(long number)
        {
            if (number == 1) throw new Exception("1 is neither prime nor composite number");
            if (number % 2 == 0) return false;

            //int sqrt = (int) Math.Floor(Math.Sqrt(number));
            for (int i = 2; i < number; i++)
            {
                if (number%i == 0) return false;
            }
            return true;
        }

上面的算法中我故意撤去了计算平方根这步,使得算法的性能大大变差了,达到耗时的目的。为了拖慢时间,我们找一个巨大的质数1073676287,这样,整个for循环要全部运行一次才会结束,而不会提早break。

为了异步调用,要先声明一个和方法签名相同的委托才行:

    public delegate void ClongBigFileDelegate(string path);

然后,我们就在主程序中简单的异步调用。我们发现BeginInvoke的参数数目比Invoke多了两个,不过现在我们先不管它,将它们都设置为null:

  IsPrimeNumberDelegate d = new IsPrimeNumberDelegate(IsPrimeNumber);
  d.BeginInvoke(1073676287, null, null);
       Console.WriteLine("I am doing something else.");
      Console.ReadKey();

这样虽然实现了异步调用(主程序会马上离开BeginInvoke打印下面的话),但也有很多问题:

你可能也想到了,BeginInvoke后两个神秘的输入参数可能能帮你解决上面的问题。

通过EndInvoke获得异步委托的执行结果

我们可以通过EndInvoke获得委托标的函数的返回值:

    IAsyncResult ia = d.BeginInvoke(1073676287, null, null);           
    Console.WriteLine("I am doing something else.");
    var ret = d.EndInvoke(ia);
    Console.WriteLine("Calculation finished. Number is prime number : {0}", ret == true ? "Yes" : "No");
    Console.ReadKey();

这解决了第一个问题和第三个问题。现在你再运行程序,程序会阻塞在EndInvoke,你手贱敲了任意一个键,程序也不会结束。另外,我们还获得了异步委托的结果,即该大数是质数。

但这个解决方法又衍生出了一个新的问题:即程序会阻塞在EndInvoke,如果这是一个GUI程序,主线程将会卡死,给用户带来不好的体验。如何解决这个问题?

通过回调函数获得异步委托的执行结果

回调函数的用处是当委托完成时,可以主动通知主线程自己已经完成。我们可以在BeginInvoke中定义回调函数,这将会在委托完成时自动执行。

回调函数的类型是AsyncCallback,其也是一个委托,它的签名:传入参数必须是IAsyncResult,而且没有返回值。所以我们的回调函数必须长成这样子:

public static void IsPrimeNumberCallback(IAsyncResult iar)
{
}

在主函数中加入回调函数:

AsyncCallback acb = new AsyncCallback(IsPrimeNumberCallback);
d.BeginInvoke(1073676287, acb, null); 

IAsyncResult中并不包括委托的返回值。利用AsyncCallback可以被转换成AsyncResult类型的特点,我们可以利用AsyncResult中的AsyncDelegate“克隆”一个当前正在运行的委托,然后调用克隆委托的EndInvoke。因为这时委托已经执行完了所以EndInvoke不会阻塞:

        public static void IsPrimeNumberCallback(IAsyncResult iar)
        {
            AsyncResult ar = (AsyncResult) iar;
            var anotherDelegate = (IsPrimeNumberDelegate) iar.AsyncDelegate;
            var ret = anotherDelegate.EndInvoke(iar);
            Console.WriteLine("Calculation finished, Number is prime number : {0}", ret == true ? "Yes" : "No");
        }

看到这里读者大概要感慨了,使用委托异步调用获得结果怎么这么复杂。确实是比较复杂,所以之后微软就在后续版本的C#中加入了任务这个工具,它大大简化了异步调用的编写方式。

总结

使用委托的异步编程模型(APM):

多路广播

委托的本质是一个密封类。这个类继承自System.MultiDelegate,其再继承自System.Delegate。System.MulticastDelegate类中有一个重要字段_invocationList,它令委托可以挂接多于一个函数(即一个函数List)。它维护一个Invocation List(委托链)。你可以为这个链自由的添加或删除Handler函数。一个委托链可以没有函数。

由于委托可以代表一类函数,你可以随心所欲的为委托链绑定合法的函数。此时如果执行委托,将会顺序的执行委托链上所有的函数。如果某个函数出现了异常,则其后所有的函数都不会执行。

如果你的委托的委托链含有很多委托的话,你只会收到最后一个含有返回值的委托的返回值。假如你的委托是有输出值的,而且你想得到委托链上所有方法的输出值,你只能通过GetInvocationList方法得到委托链上的所有方法,然后一一执行。

委托的本质

本节大部分都是概念,如果你正在准备面试,而且已经没有多少时间了,可以考虑将它们背下来。

泛型委托

泛型委托Action和Func是两个委托,Action<T>接受一个T类型的输入,没有输出。Func则有一个输出,16个重载分别对应1-16个T类型的输入(这使得它更像数学中函数的概念,故名Func)。Func委托的最后一个参数是返回值的类型,前面的参数都是输入值的类型。

在它们出现之后,你就不需要使用delegate关键字声明委托了(即你可以忘记它了),你可以使用泛型委托代替之。

        static void Main(string[] args)
       {
            Action<int, int> a = new Action<int, int>(add);
            a(1, 2);
            //Func委托的最后一个参数是返回值的类型
            Func<int, int, int> b = new Func<int, int, int>(add2);
            Console.WriteLine(b(1, 2));
            Console.ReadLine();
        }
        //这个EventHandler不返回值
        public static void add(int a, int b)
        {
            Console.WriteLine(a + b);
        }
        //这个EventHandler返回一个整数
        public static int add2(int a, int b)
        {
            return a+b;
        }

我们可以看到使用Action对代码的简化。我们不用再自定义一个委托,并为其取名了。这两个泛型委托构成了LINQ的基石之一。


image

我们看一个LINQ的例子:Where方法。


image
通过阅读VS的解释,我们可以获得以下信息:

1.Where是IEnumerable<T>的一个扩展方法
2.这个方法的输入是一个Func<T,bool>,形如Func<T,bool>的泛型委托又有别名Predicate,因其是返回一个布尔型的输出,故有判断之意。

泛型委托使用一例

下面这个问题是某著名公司的一个面试题目。其主要的问题就是,如何对两个对象比较大小,这里面的对象可以是任意的东西。这个题目主要考察的是如何使用泛型和委托结合,实现代码复用的目的。

假设我们有若干个表示形状的结构体,我们要比较它们的大小。

    public struct Rectangle
    {
        public double Length { get; set; }
        public double Width { get; set; }

        //By calling this() to initialize all valuetype members
        public Rectangle(double l, double w) : this()
        {
            Length = l;
            Width = w;
        }
    }

    public struct Circle
    {
        public double Radius { get; set; }

        public Circle(double r) : this()
        {
            Radius = r;
        }
    }

我们规定谁面积大就算谁大,此时,因为结构体不能比较大小,只能比较是否相等,我们就需要自己制定一个规则。对不同的形状,求面积的公式也不一样:

        public static int CompareRectangle(Rectangle r1, Rectangle r2)
        {
            double r1Area = r1.Length*r1.Width;
            double r2Area = r2.Length*r2.Width;
            if (r1Area > r2Area) return 1;
            if (r1Area < r2Area) return -1;
            return 0;
        }

        public static int CompareCircle(Circle c1, Circle c2)
        {
            if (c1.Radius > c2.Radius) return 1;
            if (c1.Radius < c2.Radius) return -1;
            return 0;
        }

当然,在比较大小的时候,可以直接调用这些函数。但如果这么做,你将再次陷入“委托的作用-将方法作为方法的参数”一节中的switch泥潭。注意到这些函数的签名都相同,我们现在已经熟悉委托了,当然就可以用委托来简化代码。

我们可以把规则看作一个函数,其输入为两个同类型的对象,输出一个整数,当地一个对象较大时输出1,相等输出0,第二个对象较大输出-1。那么,这个规则函数的签名应当为:

Func<T, T, int>

它可以变身为任意类型的比较函数。我们在外部再包装一下,将这个规则传入进去。那么这个外部包装函数的签名应当为:

public static void Compare<T>(T o1, T o2, Func<T, T, int> rule)
{
}

当然这里的返回值也可以是int。由于是演示的缘故,我就简单的打印一些信息:

        public static void Compare<T>(T o1, T o2, Func<T, T, int> rule)
        {
            var ret = rule.Invoke(o1, o2);
            if (ret == 1) Console.WriteLine("First object is bigger.");
            if (ret == -1) Console.WriteLine("Second object is bigger.");
            if (ret == 0) Console.WriteLine("They are the same.");
        }

主程序调用:

        static void Main(string[] args)
        {
            var r1 = new Rectangle(1, 6);
            var r2 = new Rectangle(2, 4);

            Compare(r1, r2, CompareRectangle);

            var c1 = new Circle(3);
            var c2 = new Circle(2);

            Compare(c1, c2, CompareCircle);

            Console.ReadKey();
        }

我们可以看到,对不同类型都有着统一的比较大小的方式。可以参考:http://www.cnblogs.com/onepiece_wang/archive/2012/11/28/2793530.html

什么是事件?

简单的看,事件的定义就是通知(给订阅者)。事件由三部分组成:事件的触发者(sender),事件的处理者(Event Handler,一个和委托类型相同的函数)和事件的数据传送通道delegate。delegate负责传输事件的触发者对象sender和自定义的数据EventArgs。要实现事件,必须实现中间的委托(的标的函数),并为事件提供一个处理者。处理者函数的签名和委托必须相同。

所以,事件必须基于一个委托。

使用事件的步骤:

委托和事件有何关系?

委托是事件传输消息的管道。事件必须基于一个委托。下图中小女孩是事件的发起者(拥有者),她通过委托(即图上的“电话线”)传递若干消息给她的爸爸(事件的处理者/订阅者)。和委托一样,事件可以有多个订阅者,这也是多路广播的一个体现。

可以借助事件实现观察者模式。观察者模式刻画了一个一对多的依赖关系,其中,当一对多中的“一”发生变化时,“多”的那头会收到信息。


image

经典例子:this.button1.Click += new System.EventHandler(this.StartButton_Click);

使用事件

使用事件需要至少一个订阅者。订阅者需要一个事件处理函数,该处理函数通常要具备两个参数:输入为object类型的sender和一个继承了EventArgs类型的e(有时候第一个参数是不必要的)。你需要继承EventArgs类型来传递自定义数据。

    public class Subscriber
    {
        public string Name { get; set; }

        public Subscriber(string name)
        {
            Name = name;
        }

        public void ReceiveMessage(object sender, MessageArgs e)
        {
            Console.WriteLine("I am {0} and I know {1}!", Name, e.Message);
        }
    }
    public class MessageArgs : EventArgs
    {
        public string Message { get; set; }
    }

当有订阅者订阅事件之后,Invoke事件会顺序激发所有订阅者的事件处理函数。其激发顺序视订阅顺序而定。

首先要定义委托和事件。委托的命名惯例是以Handler结尾:

        //1. Base delegate
        public delegate void SendMessageHandler(object sender, MessageArgs e);

        //2. Event based on the delegate
        public static event SendMessageHandler SendMessage;

事件的执行演示:

        static void Main(string[] args)
        {
            //Subscribers
            Subscriber s1 = new Subscriber("Adam");
            Subscriber s2 = new Subscriber("Betty");
            Subscriber s3 = new Subscriber("Clara");

            //Subscribe
            SendMessage += s1.ReceiveMessage;
            SendMessage += s2.ReceiveMessage;
            SendMessage += s3.ReceiveMessage;

            //Simulate a message transfer
            Console.WriteLine("Simulate initializing...");
            Thread.Sleep(new Random(1).Next(0, 1000));

            var data = new MessageArgs {Message = "Class begins"};

            if (SendMessage != null) SendMessage(null, data);

            //Unsubscribe
            SendMessage -= s1.ReceiveMessage;

            Thread.Sleep(new Random(1).Next(0, 1000));

            data.Message = "Calling from main function";
            if (SendMessage != null) SendMessage(null, data);

            Console.WriteLine("Class is over!");
            Console.ReadKey();
        }

事件的本质

委托的协变和逆变

协变和逆变实际上是属于泛型的语法特性,由于有泛型委托的存在,故委托也具备这个特性。我将在讨论泛型的时候再深入讨论这个特性。

经典文章,参考资料

有关委托和事件的文章多如牛毛。熟悉了委托和事件,将会对你理解linq有很大的帮助。

1. 张子阳的经典例子: http://www.cnblogs.com/JimmyZhang/archive/2007/09/23/903360.html
可以自行编写一个热水器的例子,测试自己是否掌握了基本的事件用法。
http://www.cnblogs.com/JimmyZhang/archive/2008/08/22/1274342.html 这是续篇。

2. 委托本质论,不过说的比较简单。这个水平也基本可以应付面试了(很少有人问这么深入),更难更全面的解释可以参考clr via c#:http://www.cnblogs.com/zhili/archive/2012/10/25/DeepDelegate.html

3. 一个生动的事件例子:http://www.cnblogs.com/yinqixin/p/5056307.html

4. 常见委托面试题目:http://www.cnblogs.com/jackson0714/p/5111347.html

上一篇下一篇

猜你喜欢

热点阅读