CodeGym /课程 /C# SELF /订阅的详细拆解 ( +=)

订阅的详细拆解 ( +=)

C# SELF
第 53 级, 课程 0
可用

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 捕获的变量。很容易捕获到不是你预期的值,导致行为异常。

2
任务
C# SELF, 第 53 级, 课程 0
已锁定
动态订阅和查看订阅者
动态订阅和查看订阅者
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION