1. 介绍
在 C# 里,事件不是随便可以赋值的变量。它们是一个受保护的处理器列表,只有事件的所属者能触发执行;其他人只能添加(+=)或移除(-=)自己的响应。
举个例子,订阅事件看起来像这样:
worker.WorkCompleted += Worker_WorkCompleted;
乍一看这像普通的加法,但实际并非如此。底层事件保存着一个调用链(invocation list)——一组要在事件触发时调用的 delegates。你写下 += 的时候,就是把新的处理器加进这个列表。
下面我们更详细地看看内部发生了什么,链是如何形成的,以及订阅时会碰到哪些细节。
什么是 delegate-链?
回忆一下,C# 里的 delegate 可以是“多播”的(multicast):它可以包含多个方法,如果 delegate 被调用,这些方法会顺次执行。事件就是利用这个机制:事件的值本质上就是一个带有处理器列表的 delegate。
用代码来说明:
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
当有人订阅时:
worker.WorkCompleted += MyHandler;
在 C# 底层大概会做这样的事:
- 取出当前的 delegate(处理器列表)。
- 用 Delegate.Combine 把新的处理器合并进去。
- 把更新后的链写回事件变量。
示意:
| 操作 | 事件的内部处理器列表 |
|---|---|
| 之前 | null 或 [Handler1] |
| 在做 += Handler2 之后 | [Handler1, Handler2] |
| 再做一次 += H3 之后 | [Handler1, Handler2, Handler3] |
有意思的地方:multicast delegate 并不是“魔法”,它底层就是把要调用的方法放在一个数组/列表里,按顺序调用。
2. 订阅是如何工作的:通俗解释
我们来看一个具体示例。假设有一个发布者(Worker)和两个订阅者(Logger, Notifier):
public class Worker
{
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
public void DoWork()
{
// ... 工作 ...
OnWorkCompleted("任务完成!");
}
protected virtual void OnWorkCompleted(string message)
{
WorkCompleted?.Invoke(this, new WorkCompletedEventArgs { Message = message });
}
}
public class Logger
{
public void LogWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("日志: " + e.Message);
}
}
public class Notifier
{
public void ShowNotification(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("通知: " + e.Message);
}
}
在 Main 中:
var worker = new Worker();
var logger = new Logger();
var notifier = new Notifier();
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += notifier.ShowNotification;
// 启动工作
worker.DoWork();
当 OnWorkCompleted 被调用时,事件会先调用 logger.LogWorkCompleted,然后调用 notifier.ShowNotification(调用顺序与订阅顺序一致)。
3. 有用的细节
可视化:事件如何存储处理器
+---------------------+
| Worker |
|---------------------|
| WorkCompleted Event | ---> [ LogWorkCompleted, ShowNotification ]
+---------------------+
每次订阅,新的处理器会“挂”到已有列表上。触发事件时,delegate 会按顺序逐个调用订阅的方法。
同一个方法被多次订阅
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += logger.LogWorkCompleted; // 两次!
在这种情况下,处理器会被调用多少次就看你订阅了多少次——上面会连续调用两次。
用 lambda 表达式订阅
worker.WorkCompleted += (sender, e) => Console.WriteLine("匿名处理器: " + e.Message);
如果多次订阅相同的 lambda —— 同样会被调用多次。但注意:每个 lambda 都会生成一个独立的 delegate 对象,所以直接取消订阅可能没有想象的那么简单(这在后面的讲义 260 及以后会详细讲)。
订阅的内部实现:低级拆解
事件其实是特殊的属性,带有两个访问方法(add/remove),当你用 += 和 -= 时会调用它们。简化一下,编译器大致会生成类似这样的代码:
// 大概是这样(简化版)
public event EventHandler<WorkCompletedEventArgs> WorkCompleted
{
add { /* 添加处理器的代码 */ }
remove { /* 移除处理器的代码 */ }
}
默认实现会使用 Delegate.Combine 来合并 delegate,用 Delegate.Remove 来移除。
这也是 event 的保护作用:外部不能直接触发事件(不能写 worker.WorkCompleted(...);),只能订阅或取消订阅。
订阅机制示意图
+----------------------+
| |
v v
+--------------------+ +----------------------+
| LogWorkCompleted | | ShowNotification |
+--------------------+ +----------------------+
^ ^
\______________________/
^
|
WorkCompleted Event
这就是所谓的 "Invocation List" —— 调用链。
为什么重要:实战意义
理解订阅机制是管理对象之间关系的关键。你可以搭建动态订阅/取消订阅的系统,组件间不会形成紧耦合。这在 UI 框架、游戏引擎、服务器应用里很常见,甚至在微服务架构里理念也类似(虽然那层通常是消息队列,但思想一致)。
面试里常会问 C# 的事件模型是怎么工作的、为什么事件能让系统更松耦合,以及如何正确管理订阅的生命周期。
4. 类外能做和不能做的事
能做的
- 订阅事件(+=)
- 取消订阅(-=)
不能做的
- 直接触发事件
- 直接给事件赋值(比如 worker.WorkCompleted = ... — 会报错!)
这些限制由关键字 event 实现。如果你把 delegate 当普通字段声明:
public EventHandler<WorkCompletedEventArgs> WorkCompleted; // 不是 event!
— 那么任何人都能做任何事,包括把处理器清空,这会导致混乱和潜在 bug。所以几乎总是只用 event!
可以用同一个方法订阅多个事件吗?
可以,这叫做 "multisubscribe"。例如:
worker.WorkCompleted += logger.LogWorkCompleted;
anotherWorker.WorkCompleted += logger.LogWorkCompleted;
同一个方法订阅了不同对象的事件,那么两个事件都会触发这个处理器。在处理器里可以通过 sender 参数判断是谁触发的。
真实场景:动态订阅
假设你的应用支持并行运行多个任务。每创建一个新任务就 new 一个 Worker,并把同一个处理器(比如 logger.LogWorkCompleted)订阅到它的事件:
var allWorkers = new List<Worker>();
for (int i = 0; i < 10; i++)
{
var w = new Worker();
w.WorkCompleted += logger.LogWorkCompleted;
allWorkers.Add(w);
}
这样任何任务完成时,logger 都会收到通知并记录发生了什么和什么时候发生的。
实用技巧:如何看到事件的订阅者
在普通代码里,你没法直接查看事件上订阅了多少处理器(事件封装了 delegate)。但在发布者类内部,你可以直接操作事件的 delegate,比如查看它的 GetInvocationList():
// 仅在发布者类内部可用
var handlers = WorkCompleted?.GetInvocationList();
if (handlers != null)
Console.WriteLine($"订阅者数: {handlers.Length}");
这在做自定义分发逻辑或调试时挺有用(不过建议只在学习/调试用例时这样做)。
5. 常见错误和注意点
没有订阅的处理器?不会被调用!
如果你没有订阅,处理器就永远不会被调用。触发事件前最好检查是否为 null(否则会抛出 NullReferenceException)。
重复订阅
如果你多次对同一事件用同一个方法订阅——处理器会被调用相应次数。有时候这会是个坑:一次意外的重复 += 可能把一个通知变成多个相同的通知。
静态方法 / 实例方法
你可以订阅静态方法或实例方法,关键是签名要对:
public static void StaticHandler(object? sender, WorkCompletedEventArgs e) { /* ... */ }
worker.WorkCompleted += StaticHandler;
在循环里用 lambda 订阅
如果你在循环里用 lambda 订阅,注意 lambda 捕获的变量。很容易捕获到不是你预期的值,导致行为异常。
GO TO FULL VERSION