武田晴海
Articles5
Tags11
Categories6

Categories

一言

Archive

对C#与C++中事件机制的解析

对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#也内置了几种泛型委托提供了更简洁的委托声明方式,我们可以使用ActionFunc简化上面的几种声明格式,其中第四种就是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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//声明委托
public Action handler1;

//无参数,void返回类型的函数声明
public void ConsoleString()
{
Console.Writeline("调用handler1");
}

//将方法添加到委托中。
handler1 += ConsoleString;

//调用委托
//handler1();
//显式调用委托
handler1?.Invoke();

//输出:
调用handler1

从对于第一种委托的声明与使用,我们可以发现似乎委托就是函数指针,调用该委托等价于调用该函数方法。在单播的情况下确实是这样,考虑下面这种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//声明委托
public Action handler1;

//无参数,void返回类型的函数声明
public void ConsoleString()
{
Console.Writeline("调用handler1");
}
public void ConsoleNumber()
{
Console.WriteLine(114514);
}

//将方法添加到委托中。
handler1 += ConsoleString;
handler1 += ConsoleNumber;

//显式调用委托
handler1?.Invoke();

//输出:
调用handler1
114514

这就是委托的多播,我们可以发现委托会调用所有已添加的方法,据此我们也可以推测委托类的实际实现,它可能包含如下几个部分:

  • 对目标方法的引用;
  • 如果目标方法是实例方法,那它应该还需要持有包含目标方法的对象的引用;
  • 支持多播委托的链表容器;为什么选择链表呢,首先是为了支持动态大小,并且链表的删除是O(1),我们如果需要频繁添加和移除方法链表的效率肯定最高,最后是链表的结构很灵活,链表节点可以存储任意类型的方法引用,并且链式调用可以通过修改指针来改变调用顺序。

事实上在远古时期的.Net Framework中,委托的实现是基于函数指针的,但是函数指针的缺点很明显,不支持多播委托,并且安全性较弱,从.Net Framework2.0之后,委托的实现就变成了一个委托类,在编译时自动生成,而委托类的实现.Net并没有公开,但是实际核心实现跟我们推测的,应该是大差不差的。

对于上面的代码,我们还可以使用lambda表达式来简化这两个函数的声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
//声明委托
public Action handler1;

//利用lambda表达式将方法添加到委托中。
handler1 += () => { Console.WriteLine("调用MyDelegate1"); };
handler1 += () => { Console.WriteLine(114514); };

//显式调用委托
handler1?.Invoke();

//输出:
调用handler1
114514

再来看一下有返回值类型的Func委托,对于我们定义的第三种委托进行使用,同样采取lambda表达式简化函数声明过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 public Func<int, string, int> handler3;

public static x = 0;

handler3 += (param1, param2) =>
{
int sum = param1 + x++;
Console.WriteLine(param2 + ":" + sum);
return sum;
};
//在只添加一个函数的情况下调用
handler3?.Invoke(0, "填入的数字加上x后是");

handler3 += (param1, param2) =>
{
int sum = param1 + x++;
Console.WriteLine(param2 + ":" + sum);
return sum;
};
//在添加了两个一模一样的函数的情况下调用
var t = handler3?.Invoke(0, "填入的数字加上x后是");
//输出返回值
Console.WriteLine("最终返回值是:"+t);

//全部输出:
填入的数字加上x后是:0
填入的数字加上x后是:1
填入的数字加上x后是:2
最终返回值是:2

对于这个Func委托,我们添加了两个一模一样的方法,即对于同一个方法添加两次。我们发现在添加了这个方法两次的时候,我们执行委托将会执行两遍这个函数,然而委托的返回值并不是一个函数返回值列表int[],而仅仅是一个int,从这里我们可以知道,对于一个有返回值的委托,它的最终返回值将会是最后一次调用的方法的返回值。最后我们来讲一下委托的缺点:

委托的缺点:

  • 委托会造成内存泄漏:委托会引用一个方法,如果这个方法是一个实例方法(非静态方法)的话,那么这个方法必须隶属于一个对象,拿一个委托引用这个方法,那么这个对象必须存在在内存中,即便没有其他引用变量引用这个对象了,这个对象的内存也不能被释放,因为一旦释放,内存就不能再间接调用对象的方法了。
  • 多播委托同样在维护上存在一定的困难性,容易导致所有的方法重置。我们在上面所有的例子中给委托添加方法都是使用的+=,但是委托可以使用=赋值,一旦使用了等号赋值,委托之前添加的所有函数将会被清空,也就是会从多播变成单播,并且不会有任何的报错,而如果大项目中出现了这样的小错误,排查起来也是需要费点功夫的。
  • 嵌套调用可读性差

讲到这里,C#中的委托差不多就讲完了,那什么又是C#中的事件呢,委托看起来已经很已经很方便了,为什么还要事件呢?

C#中的Event

事件的本质就是委托字段的包装器,声明的关键词为event。对于显式声明的委托,事件的声明方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//定义一个不含参数列表,没有返回值的委托
public delegate void MyDelegate1();
//声明该委托的字段
public MyDelegate1 handler;
//声明该委托类型的事件
public event MyDelegate1 actionEvent;

//定义一个含有两个参数,返回值为int类型的委托
pulic delegate int MyDelegate2(int param1,param2);
//声明该委托的字段
public MyDelegate2 handler;
//声明改委托类型的事件
public event MyDelegate2 funcEvent;

同样可以使用ActionFunc来简化声明:

1
2
3
4
5
//声明一个不含参数列表,没有返回值的事件
public event Action actionEvent;

//声明一个含两个参数,返回值为int类型的事件
public event Func<int,int,int> funcEvent;

事实上,我们仅仅是多加了一个event关键词来声明该委托的事件类型,事件的出现就是为了解决多播委托的维护问题,事件只允许通过+=或者-=来访问进行方法的添加与删除,而不允许通过=赋值,实际上事件完整的声明方式类似属性,有addremove两个事件处理器,并且事件更加安全,不允许外部去触发事件,事件还可以使用!=这个语法糖来进行判空。C#中的事件就是对委托实例的阉割,让它更安全。

这些大概就是C#中委托与事件的大部分内容,在实际开发中,他们有着非常广泛的使用场景,比如回调函数的实现,消息的传递,观察者模式的实现,异步方法与面向事件的设计模式……利用好事件系统可以为项目提供很大的灵活性,是开发中不可或缺的一部分。

C++中的事件机制

我们在上面反复提到一个词:函数指针,它是在C++中实现委托与事件机制的关键。C++并没有像C#那样内置的委托类型来处理事件,而需要自己去管理函数指针,并且利用回调函数的方式来实现。考虑最简单的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义函数指针类型:传入一个int参数,无返回值的函数指针
typedef void (*EventHandler)(int);
//声明一个函数指针对象eventHandler
private EventHandler eventHandler;

// 定义格式匹配的事件处理函数
void MyEventHandler(int arg)
{
std::cout << "Event Handler: " << arg << std::endl;
}
//为函数指针赋值
eventHandler = MyEventHandler;
//执行事件
eventHandler(10);

//输出
Event Handler:10

直接使用函数指针有不少的缺点:

  • 无法捕获上下文信息,函数指针只能指向静态函数或者全局函数;
  • 不支持多态,即函数指针只能指向固定的函数签名;
  • 语法相对繁琐,可读性差;
  • 安全性差,函数指针没有类型检查与空指针检查。

在C11之后,标准库提供了一个通用的函数封装类:std::function<返回值类型(参数类型1,参数类型2,...)> 变量名,它可以说是升级版函数指针,可以用于存储、传递和调用各种类型的可调用(函数)对象(例如函数指针、成员函数指针、lambda 表达式等),所以上面的代码可以改成如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//声明function对象
std::function<void(int)> eventHandler;

// 定义格式匹配的事件处理函数
void MyEventHandler(int arg)
{
std::cout << "Event Handler: " << arg << std::endl;
}
//赋值
eventHandler = MyEventHandler;
//执行事件
eventHandler(10);

//输出
Event Handler:10

它几乎解决了所有函数指针的缺点:

  • 支持捕获上下文,可以捕获局部变量与this指针,可以实现闭包;
  • 支持多态,可以保存普通函数,函数指针,lambda表达式,成员函数等各种函数对象;
  • 声明格式清晰,可读性好;
  • 严格的类型检查;

std::function的底层实现的关键是多态技术与类型擦除,通过使用模板和虚函数来实现对不同类型的函数对象进行类型擦除和统一的调用接口。简单的一个function类型同样不支持多播,我们需要手动实现一下事件多播,考虑最简单的实现方式,我们利用一个vector容器存储function对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#头文件

// 事件处理器的类型定义
using EventHandler = std::function<void()>;

class Event
{
public:
// 添加事件处理器
void AddHandler(const EventHandler& handler)
{
handlers.emplace_back(handler);
}

// 移除事件处理函数
void RemoveHandler(const EventHandler& handler)
{
for (auto it = handlers_.begin(); it != handlers_.end(); ++it)
{
if (*it == handler)
{
handlers_.erase(it);
break;
}
}
}

// 触发事件
void Invoke()
{
for (const auto& handler : handlers)
{
handler();
}
}

private:
std::vector<EventHandler> handlers;

void OnEvent1()
{
cout << "Event 1 handled" << endl;
}

void OnEvent2()
{
cout << "Event 2 handled" << endl;
}
};

int main()
{
Event event;

// 添加事件处理器
event.AddHandler(OnEvent1);

event.AddHandler(OnEvent2);

// 触发事件
event.Invoke();

return 0;
}


//输出
Event 1 handled
Event 2 handled

在这里我们手动实现了一个Event类,并且支持了函数事件的添加与移除以及触发。当然这是最简单也最粗糙的实现,我们可以发现在对事件的移除上的时间复杂度是O(n),在涉及频繁添加和移除事件处理函数的场景中性能是比较差的,而常见的容器里面,maplist的删除性能是比较好的,参考C#的委托类的实现思路,我们可以使用list作为容器来对上面代码进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <iostream>
#include <functional>
#include <list>

using namespace std;

// 事件处理函数类型
using EventHandler = function<void()>;

class Event
{
public:
// 添加事件处理函数
void AddHandler(const EventHandler& handler)
{
handlers_.push_back(handler);
}

// 移除事件处理函数
void RemoveHandler(const EventHandler& handler)
{
handlers_.remove(handler);
}

// 触发事件
void Invoke()
{
for (const auto& handler : handlers_)
{
handler();
}
}

private:
list<EventHandler> handlers_;
};

int main()
{
Event event;

// 添加匿名事件处理函数
event.AddHandler([]() {
cout << "Event 1 handled" << endl;
});

event.AddHandler([]() {
cout << "Event 2 handled" << endl;
});

// 触发事件
event.Invoke();

// 移除匿名事件处理函数
event.RemoveHandler([]() {
cout << "Event 1 handled" << endl;
});

// 再次触发事件
event.Invoke();

return 0;
}

//第一次输出
Event 1 handled
Event 2 handled

//第二次输出
Event 2 handled

这样就算是实现了一个不错的事件对象,支持多播并且性能可观,我们可以利用它来实现C++中的消息机制。当然这里就不过多描述了,感兴趣的朋友可以自己动手试一试 。我们还能使用std::bind来进行参数绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main()
{
Event event;

int localVar = 114514; // 局部变量

// 使用 bind 将匿名函数与局部变量进行绑定
event.AddHandler(bind([](int value) {
cout << "Event handled with local variable: " << value << endl;
}, localVar));

// 触发事件
event.Invoke();

return 0;
}

//输出
Event handled with local variable:114514

利用std::functionstd::bind可以实现灵活可复用的函数对象与回调,在大部分回调函数的实现场景中我们都应该选择这样的实现。我们也可以发现,上面的多播是针对单一函数对象的多播,即返回值与参数列表都匹配。事实上,我们还可以利用模板去实现对任意参数的函数对象的多播,更甚者,还可以利用模板与元组std::tuple,实现多返回值,多参数的事件多播。虽然我们的实现非常自由,但是这样处理对代码的可读性与维护都会造成比较大的困难,在实际开发中还是需要按量实现。

相信到了这里,对于C#与C++中事件机制的处理的差异,已经非常明显了:

C#中,内置了delegateevent类型,存在多播机制,使用非常方便,而在C++中,我们需要手动管理内存,并且需要利用容器对std::function进行封装,才能实现类似C#的委托多播效果,当然它更加灵活,在性能上也是更加可控的。

写在最后:对Lambda表达式的讨论

Lambda表达式作为在C#与C++中都很常用的匿名函数实现方式,在上文中也是反复提到,我们有必要对它的实现与性能做一定的了解。

在C#中,Lambda 表达式的实现是通过编译器将 Lambda 表达式转换为委托类型或表达式树,并在运行时执行。在编译时,编译器会生成一个匿名方法,并将 Lambda 表达式转换为一个委托类型的实例,其中包含了对外部变量的引用。在运行时,Lambda 表达式可以像其他委托一样被调用,并在执行时访问捕获的外部变量的值,形成了闭包。考虑下面这种实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TestClass
{
private int instanceInt;

public void Test()
{
for (int i = 0; i < 100; i++)
{
Action act = () => {
int x = instanceInt;
};
}
}

}

在匿名方法中声明一个int变量x,去捕获实例字段instanceInt,并尝试在循环中对一个委托对象赋值,这样的写法会导致发生100次的内存分配。如果 lambda 表达式使用了实例方法或实例属性,而不是静态方法或静态属性,那么在每次执行 lambda 表达式时,都会为 lambda 表达式分配一块内存来存储实例方法或实例属性的引用。这样的做法将会引起性能问题。

如果捕获局部变量,比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestClass
{
public void Test()
{
for (int i = 0; i < 100; i++)
{
int localInt = 0;
Action act = () => {
int x = localInt;
};
}
}
}

它甚至会导致200次的内存分配,编译器会为每一次捕获的局部变量创建匿名类对象来保存该局部变量,然后使用匿名方法去创建Action对象并赋值给act

而我们可以通过增加参数数量去传递要捕获的参数来避免对外部变量的访问从而优化掉这些多余的内存分配。对于上面的代码,修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TestClass
{
public void Test()
{

for (int i = 0; i < 100; i++)
{
int localInt = 0;
CallAction(localInt,(param) =>
{
int x = param;
});
}
}
}

这样,整个过程就只会导致1次内存分配。

在C++中,Lambda表达式的使用同样有着需要注意的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
std::vector<int> myVector = {1, 2, 3, 4, 5};

// 使用引用捕获的 lambda 表达式
auto lambdaWithRefCapture = [&myVector]() {
std::cout << "Lambda with reference capture: ";
for (const auto& val : myVector) {
std::cout << val << " ";
}
std::cout << std::endl;
};

// 不使用引用捕获的 lambda 表达式
auto lambdaWithoutRefCapture = [myVector]() {
std::cout << "Lambda without reference capture: ";
for (const auto& val : myVector) {
std::cout << val << " ";
}
std::cout << std::endl;
};

虽然最终的输出一致,但是两者的性能存在差异,使用引用捕获,直接访问了外部的 myVector,没有进行复制或移动操作,而 lambdaWithoutRefCapture 没有使用引用捕获,会进行了一次 vector 对象的复制操作,从而产生了额外的内存开销。

同时在C++中,使用Lambda表达式更应该小心捕获对象的生命周期,避免访问已销毁对象,而对于指针对象,我们都应该使用智能指针进行管理,让我们更少地遇到内存泄漏这样的问题。

参考:

知乎-芯片烤电池:C++回调函数及std::function与std::bind

自然有猫仙人:C#委托与匿名方法内存分配总结

有道云笔记:委托的底层机制实现

知乎-Ruyi Y:C#委托与事件

Author:武田晴海
Link:https://wyryyds.github.io/2023/04/13/%E4%BA%8B%E4%BB%B6%E7%B1%BB%E5%9E%8B%E7%9A%84%E8%A7%A3%E6%9E%90/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可