对C#与C++中事件机制的解析
在对C++项目的学习中,也是慢慢接触到了C++中的事件机制。之前最早的博客里面也是有我大一曾经对C#中的委托与事件做的笔记,现在把两者重新放在一起进行比较讨论。
C#中的事件机制
在提到C#中的Event时,有必要先认识一下C#的委托(delegate):
C#中的Delegate
在C#中,委托是一种类,它包装了一个或者多个方法,并且允许以类似函数调用的方式来调用这些方法,类似C++中的函数指针。下面是委托的几种显式声明格式:
1.不含参数列表,使用void作为返回类型的委托定义与声明格式:
public delegate void MyDelegate1();
public MyDelegate1 handler1
2.接受一个int类型参数,使用void作为返回类型的委托定义与声明格式
public delegate void MyDelegate2(int param);
public MyDelegate2 handler2
3.接受一个int类型与一个string类型的参数,使用int作为返回类型的委托定义与声明格式:
public delegate int MyDelegate3(int param1,string param2);
public MyDelegate3 handler3
4.接受两个泛型参数,返回值为泛型的委托定义与声明格式:
public delegate TResult MyDelegate4<in T1, in T2, out TResult>(T1 param1, T2 param2);
public MyDelegate4<int,string,int> handler4
委托的声明方式远不止以上几种,他跟函数的声明一样的灵活,只需要加上delegate
关键词即可。C#也内置了几种泛型委托提供了更简洁的委托声明方式,我们可以使用Action
与Func
简化上面的几种声明格式,其中第四种就是Func
的一种定义:
1.不含参数列表,使用void作为返回类型的委托声明格式:
public Action handler1;
2.接受一个int类型参数,使用void作为返回类型的委托声明格式
public Action<int> handler2;
3.接受一个int类型与一个string类型的参数,使用int作为返回类型的委托声明格式:
public Func<int,string,int> handler3;
4.接受两个泛型参数,返回值为泛型的委托声明格式:
public delegate TResult Func<in T1, in T2, out TResult>(T1 param1,T2 param2);
public Func<int,string,int> handler4;
C#规定,Action
用来声明无返回值类型委托,Func
用来声明有返回值委托。在实际开发中,我们都应该尽量使用这种方式来对委托进行声明,他们的优点显而易见,一是避免了显式委托声明的繁琐,二是对委托是否具有返回值进行了界定,非常的清晰。
对上面第一种情况的委托进行使用:
1 |
|
从对于第一种委托的声明与使用,我们可以发现似乎委托就是函数指针,调用该委托等价于调用该函数方法。在单播的情况下确实是这样,考虑下面这种情况:
1 |
|
这就是委托的多播,我们可以发现委托会调用所有已添加的方法,据此我们也可以推测委托类的实际实现,它可能包含如下几个部分:
- 对目标方法的引用;
- 如果目标方法是实例方法,那它应该还需要持有包含目标方法的对象的引用;
- 支持多播委托的链表容器;为什么选择链表呢,首先是为了支持动态大小,并且链表的删除是O(1),我们如果需要频繁添加和移除方法链表的效率肯定最高,最后是链表的结构很灵活,链表节点可以存储任意类型的方法引用,并且链式调用可以通过修改指针来改变调用顺序。
事实上在远古时期的.Net Framework中,委托的实现是基于函数指针的,但是函数指针的缺点很明显,不支持多播委托,并且安全性较弱,从.Net Framework2.0之后,委托的实现就变成了一个委托类,在编译时自动生成,而委托类的实现.Net并没有公开,但是实际核心实现跟我们推测的,应该是大差不差的。
对于上面的代码,我们还可以使用lambda表达式
来简化这两个函数的声明:
1 |
|
再来看一下有返回值类型的Func
委托,对于我们定义的第三种委托进行使用,同样采取lambda表达式
简化函数声明过程:
1 |
|
对于这个Func
委托,我们添加了两个一模一样的方法,即对于同一个方法添加两次。我们发现在添加了这个方法两次的时候,我们执行委托将会执行两遍这个函数,然而委托的返回值并不是一个函数返回值列表int[]
,而仅仅是一个int,从这里我们可以知道,对于一个有返回值的委托,它的最终返回值将会是最后一次调用的方法的返回值。最后我们来讲一下委托的缺点:
委托的缺点:
- 委托会造成内存泄漏:委托会引用一个方法,如果这个方法是一个实例方法(非静态方法)的话,那么这个方法必须隶属于一个对象,拿一个委托引用这个方法,那么这个对象必须存在在内存中,即便没有其他引用变量引用这个对象了,这个对象的内存也不能被释放,因为一旦释放,内存就不能再间接调用对象的方法了。
- 多播委托同样在维护上存在一定的困难性,容易导致所有的方法重置。我们在上面所有的例子中给委托添加方法都是使用的
+=
,但是委托可以使用=
赋值,一旦使用了等号赋值,委托之前添加的所有函数将会被清空,也就是会从多播变成单播,并且不会有任何的报错,而如果大项目中出现了这样的小错误,排查起来也是需要费点功夫的。 - 嵌套调用可读性差
讲到这里,C#中的委托差不多就讲完了,那什么又是C#中的事件呢,委托看起来已经很已经很方便了,为什么还要事件呢?
C#中的Event
事件的本质就是委托字段的包装器,声明的关键词为event。对于显式声明的委托,事件的声明方式如下:
1 |
|
同样可以使用Action
与Func
来简化声明:
1 |
|
事实上,我们仅仅是多加了一个event
关键词来声明该委托的事件类型,事件的出现就是为了解决多播委托的维护问题,事件只允许通过+=
或者-=
来访问进行方法的添加与删除,而不允许通过=
赋值,实际上事件完整的声明方式类似属性,有add
与remove
两个事件处理器,并且事件更加安全,不允许外部去触发事件,事件还可以使用!=
这个语法糖来进行判空。C#中的事件就是对委托实例的阉割,让它更安全。
这些大概就是C#中委托与事件的大部分内容,在实际开发中,他们有着非常广泛的使用场景,比如回调函数的实现,消息的传递,观察者模式的实现,异步方法与面向事件的设计模式……利用好事件系统可以为项目提供很大的灵活性,是开发中不可或缺的一部分。
C++中的事件机制
我们在上面反复提到一个词:函数指针,它是在C++中实现委托与事件机制的关键。C++并没有像C#那样内置的委托类型来处理事件,而需要自己去管理函数指针,并且利用回调函数的方式来实现。考虑最简单的实现:
1 |
|
直接使用函数指针有不少的缺点:
- 无法捕获上下文信息,函数指针只能指向静态函数或者全局函数;
- 不支持多态,即函数指针只能指向固定的函数签名;
- 语法相对繁琐,可读性差;
- 安全性差,函数指针没有类型检查与空指针检查。
在C11之后,标准库提供了一个通用的函数封装类:std::function<返回值类型(参数类型1,参数类型2,...)> 变量名
,它可以说是升级版函数指针,可以用于存储、传递和调用各种类型的可调用(函数)对象(例如函数指针、成员函数指针、lambda 表达式等),所以上面的代码可以改成如下形式:
1 |
|
它几乎解决了所有函数指针的缺点:
- 支持捕获上下文,可以捕获局部变量与this指针,可以实现闭包;
- 支持多态,可以保存普通函数,函数指针,lambda表达式,成员函数等各种函数对象;
- 声明格式清晰,可读性好;
- 严格的类型检查;
std::function
的底层实现的关键是多态技术与类型擦除,通过使用模板和虚函数来实现对不同类型的函数对象进行类型擦除和统一的调用接口。简单的一个function
类型同样不支持多播,我们需要手动实现一下事件多播,考虑最简单的实现方式,我们利用一个vector
容器存储function
对象:
1 |
|
在这里我们手动实现了一个Event
类,并且支持了函数事件的添加与移除以及触发。当然这是最简单也最粗糙的实现,我们可以发现在对事件的移除上的时间复杂度是O(n),在涉及频繁添加和移除事件处理函数的场景中性能是比较差的,而常见的容器里面,map
跟list
的删除性能是比较好的,参考C#的委托类的实现思路,我们可以使用list
作为容器来对上面代码进行修改:
1 |
|
这样就算是实现了一个不错的事件对象,支持多播并且性能可观,我们可以利用它来实现C++中的消息机制。当然这里就不过多描述了,感兴趣的朋友可以自己动手试一试 。我们还能使用std::bind
来进行参数绑定:
1 |
|
利用std::function
与std::bind
可以实现灵活可复用的函数对象与回调,在大部分回调函数的实现场景中我们都应该选择这样的实现。我们也可以发现,上面的多播是针对单一函数对象的多播,即返回值与参数列表都匹配。事实上,我们还可以利用模板去实现对任意参数的函数对象的多播,更甚者,还可以利用模板与元组std::tuple
,实现多返回值,多参数的事件多播。虽然我们的实现非常自由,但是这样处理对代码的可读性与维护都会造成比较大的困难,在实际开发中还是需要按量实现。
相信到了这里,对于C#与C++中事件机制的处理的差异,已经非常明显了:
C#中,内置了delegate
与event
类型,存在多播机制,使用非常方便,而在C++中,我们需要手动管理内存,并且需要利用容器对std::function
进行封装,才能实现类似C#的委托多播效果,当然它更加灵活,在性能上也是更加可控的。
写在最后:对Lambda
表达式的讨论
Lambda
表达式作为在C#与C++中都很常用的匿名函数实现方式,在上文中也是反复提到,我们有必要对它的实现与性能做一定的了解。
在C#中,Lambda
表达式的实现是通过编译器将 Lambda
表达式转换为委托类型或表达式树,并在运行时执行。在编译时,编译器会生成一个匿名方法,并将 Lambda
表达式转换为一个委托类型的实例,其中包含了对外部变量的引用。在运行时,Lambda
表达式可以像其他委托一样被调用,并在执行时访问捕获的外部变量的值,形成了闭包。考虑下面这种实现:
1 |
|
在匿名方法中声明一个int变量x,去捕获实例字段instanceInt
,并尝试在循环中对一个委托对象赋值,这样的写法会导致发生100次的内存分配。如果 lambda
表达式使用了实例方法或实例属性,而不是静态方法或静态属性,那么在每次执行 lambda
表达式时,都会为 lambda
表达式分配一块内存来存储实例方法或实例属性的引用。这样的做法将会引起性能问题。
如果捕获局部变量,比如这样:
1 |
|
它甚至会导致200次的内存分配,编译器会为每一次捕获的局部变量创建匿名类对象来保存该局部变量,然后使用匿名方法去创建Action
对象并赋值给act
。
而我们可以通过增加参数数量去传递要捕获的参数来避免对外部变量的访问从而优化掉这些多余的内存分配。对于上面的代码,修改如下:
1 |
|
这样,整个过程就只会导致1次内存分配。
在C++中,Lambda表达式的使用同样有着需要注意的问题。
1 |
|
虽然最终的输出一致,但是两者的性能存在差异,使用引用捕获,直接访问了外部的 myVector
,没有进行复制或移动操作,而 lambdaWithoutRefCapture
没有使用引用捕获,会进行了一次 vector 对象的复制操作,从而产生了额外的内存开销。
同时在C++中,使用Lambda
表达式更应该小心捕获对象的生命周期,避免访问已销毁对象,而对于指针对象,我们都应该使用智能指针进行管理,让我们更少地遇到内存泄漏这样的问题。