你可能不知道的陷阱:C#委托和事件的困惑
. 问题引入
        通常,一个C语言学习者登堂入室的标志就是学会使用了指针,而成为高手的标志又是玩转指针。指针是如此奇妙,通过一个地址,可以指向一个数,结构体,对象,甚至函数。最后的一种函数,我们称之为函数指针(和指针函数writeline函数可不一样!)就像如下的代码:
int func(int x); /* 声明一个函数 */
    int (*f) (int x); /* 声明一个函数指针 */
   f=func; /* 将func函数的首地址赋给指针f */
      C语言因为函数指针获得了极强的动态性,因为你可以通过给函数指针赋值并动态改变其行为,我曾在单片机上写的一个小系统中,任务调度机制玩的就是函数指针。
  在.NET时代,函数指针有了更安全更优雅的包装,就是委托。而事件,则是为了限制委托灵活性引入的新委托(之所以为什么限制,后面会谈到)。同样,熟练掌握委托和事件,也是C#登堂入室的标志。有了事件,大大简化了编程,类库变得前所未有的开放,消息传递变得更加简单,任何熟悉事件的人一定都深有体会。
  但你也知道,指针强大,高性能,带来的就是危险,你不知道这个指针是否安全,出了问题,非常难于调试。事件和委托这么好,可是当你写了很多代码,完成大型系统时,心里是不是总觉得怪怪的?有当年使用指针时类似的感觉?
  如果是的话,请看如下的问题:
1.  若多次添加同一个事件处理函数时,触发时处理函数是否也会多次触发?
2.  若添加了一个事件处理函数,却执行了两次或多次取消事件,是否会报错?
3.   如何认定两个事件处理函数是一样的? 如果是匿名函数呢?
4.  如果不手动删除事件函数,系统会帮我们回收吗?
5.  在多线程环境下,挂接事件时和对象创建所在的线程不同,那事件处理函数中的代码将在哪个线程中执行?
6.   当代码的层次复杂时,开放委托和事件是不是会带来更大的麻烦?
      列下这些问题,下面就让我们讨论这些尖酸刻薄的问题。
. 事件订阅和取消问题
    我们考虑一个典型的例子:加热器,加热器内部加热,在达到温度后通知外界加热已经完成 尝试写下如下测试类:
+ View Code
  OK,简单了,下面是main函数:
class Program
    {
        static void Main(string[] args)
        {
            var test = new Heater();
            test.OnBoiled += TestOnBoiled;
            test.OnBoiled += TestOnBoiled;
            test.Begin();
            Console.ReadKey();
        }
        static void TestOnBoiled(object sender, EventArgs e)
        {
            Console.WriteLine("Hello事件被调用");
        }
    }
 
    我们有意将事件挂载了两次,看看执行效果:
    很明显,如果多次挂载同一事件处理函数,函数将会执行多次。
    这就是第一个问题的答案
接下来,我们将上文中main函数中红代码替换成如下蛋疼的代码:
test.OnBoiled += TestOnBoiled;
test.OnBoiled -= TestOnBoiled;
test.OnBoiled -= TestOnBoiled;
 
  在实际开发中,这种情况是很普遍的,谁都有可能取消订阅多次,结果如何呢?
  在执行过程中,删除两次事件没有报错,但当触发事件时,由于事件订阅列表为空,所以,第二个问题的答案:
  多次删除同一事件是不会报错的,即使事件只被订阅了一次。若出现订阅三次,取消订阅两次时,依旧执行一次。
  这个事情是好理解的,事件列表,实际上就是List,最简单的增删问题。
. 有了匿名函数后?
      自从学习匿名函数后,笔者就特别喜欢用它,除非代码量特别长,否则十行之内的事件订阅,我都会用匿名函数。可是事情变得有意思了,写了匿名函数后,几乎没人记得取消订阅,那么,发生了什么事情呢?
      和上次一样,我们将前面红代码改成下面的样子:
test.OnBoiled += (s, e) => Console.WriteLine("加热完成事件被调用");<br>test.OnBoiled -= (s, e) => Console.WriteLine("加热完成事件被调用");<br>test.Bein();
Resharper直接给我画了灰线,如下图:
我估计情况不太乐观,执行之后:
   
      果然!加热完成事件还是被调用了,也就是说,看着形式完全一致的两个匿名函数,编译器生成的方法签名是不一致的,根本就是两个不同的函数。因此,匿名函数完全没法取消订阅! 这是第三个问题的答案。
      事件不能被取消订阅!这下可惨了,我真的要取消怎么办?没办法,只能乖乖的写完整的事件函数。匿名方法虽好,千万别用过头。
      但是,真正麻烦的问题来了,一个复杂的动态系统中,一定随时会有大量的对象生成和销毁,你也一定会给它订阅一些事件,当你用匿名函数后,这些函数是不是就像死神一样,一直掐着你的脖子? 如果事件处理函数涉及重要操作,比如给对方付款,执行多次你是不是就要哭死了?
  . 垃圾回收和事件
  垃圾回收机制搀和进来后,故事变的更有意思了。
   殷切的希望,垃圾回收器会帮我解决第三节最后一段谈到的问题,帮我收拾掉那些函数,那真实的情况呢?我们做个试验:
  同样的,替换掉红部分:
test.OnBoiled += (s, e) => Console.WriteLine("加热完成事件被调用");
test=new Heater();
GC.Collect();  //强制垃圾回收实际上可有可无
test.Bein();
  下面是执行结果: