跳到正文
我的博客
返回

从函数指针到 event——C# 委托学习笔记

目录

从函数指针到 event——C# 委托学习笔记

先把结论摞这儿,下面再一条条摊开:

第三条我琢磨了很久,因为我自己写过一版”不用 event 也能订阅”的代码,看着哪儿都不缺。后来才发现差了三件事,而且这三件事互不相干——分别是权限、并发、契约。这也是这篇文章真正想记下来的部分。

一个方法,怎么就变成了一个类

delegate 修饰的是个方法签名。但过一遍编译器,这个”方法”会变成一个实实在在的类,挂在 System.MulticastDelegate 下面。所谓用委托,就是直接拿这个类型去声明变量:

public delegate void Function_Test();
private Function_Test? test1;

public void beginTest(){
    test1 += outputContent;
    test1.Invoke();
}

public void outputContent(){
    Console.WriteLine("AAA变量批发商");
}

刚声明出来的委托变量是 null,还没指向任何东西。这点特别容易忘——没人订阅的时候直接 Invoke(),就是一个 NullReferenceException。上面这段之所以平安无事,纯粹是因为顺序恰好是先 += 后调用。稳一点的写法是 test1?.Invoke():没人订阅就安静跳过,别炸。

?. 这个看着不起眼的操作符其实有门道——它会把结果整体提升成可空类型,bool 一摸就变成 bool?,if (x?.Flag) 是编译不过的。详见《C# 的语法糖有多甜?》。

多播:别把它想成数组

delegate 还是多播的。多播是什么意思:

public void beginTest(){
    test1 += outputContent;
    test1 += outputContent222;
    test1.Invoke();
}

一次 Invoke(),挨个往下跑。

写过 C/C++ 的人看到这里多半会愣一下:test1 明摆着是个类类型的变量,结果它接受的竟然是个方法名。那不就是函数指针么?是,就那个意思。区别只在 delegate 做了类型安全,把这层指针包装得更结实也更省事。

但把调用列表想象成”一个装着函数指针的数组,+= 就是往格里追加”,是会踩坑的。委托对象是不可变的,这条后面要反复用到。+= 的真实动作是:

test1 = Delegate.Combine(test1, outputContent);   // 这才是 += 的全貌

Combine 返回另一个全新的委托实例,它的调用列表等于旧的加上新的;原来那个对象一个字节都没动。与其叫数组,不如理解成”每次追加都换一条新链表”。

方法既然能被塞进变量、能被当成参数传递,思绪就飘回最早碰到回调的时候了——那会儿在学 JavaScript,头一回看见函数可以当参数传进去,等调用方把活干完再回头调它……一晃眼时间过得好快。。。

懒得自己声明?C# 备好了三套

每回都手写一行 public delegate ... 也确实啰嗦。那编译出来的这个类,怎么跟挂上去的方法的参数、返回值对上号?下面这三个是现成的:

内置委托含义例子
Action / Action<T1,T2...>无返回值,最多 16 个参数Action<string> log = s => Console.WriteLine(s);
Func<T1,T2...,TResult>有返回值,最后一个泛型参数是返回类型Func<int,int,int> f = (a,b) => a+b;
Predicate<T>固定返回 bool,用来筛选Predicate<int> isEven = x => x % 2 == 0;

认的时候抓住一点就够:Func 带返回值,所以它把返回类型排在最后一个泛型参数上,前面那些全是入参。

lambda 同样挂得上去,调用也还有第二种写法:

Action notify = () => Console.WriteLine("① 保存数据");
notify += () => Console.WriteLine("② 写入日志");
notify += () => Console.WriteLine("③ 发送通知");

notify();   // 这就是 Invoke() 的糖,等价于 notify.Invoke()
            // 依次输出 ①②③,按添加顺序往下走

两个坑得提前知道:

  1. 有返回值的委托做多播时,只有最后一个方法的返回值留得下来,前面那些全丢了。
  2. 链上某个方法抛出异常,后头就不跑了——想要挨个兜住,得自己遍历 GetInvocationList() 逐个 try-catch。

event 到底图什么

有些要执行的东西是在类里头定义的,具体形态就是一个多播委托。类被实例化之后,我就不希望外面还能随便改这里面的内容。不封装的话,别的类拿着这个实例的引用,可以随手触发它(哪怕这会儿根本不该跑)、随手加减里面的方法、甚至直接干成 null——那不就完犊子了吗。

我当时的疑问是这个:

类实例化出来的对象不是有自己独立堆吗?如果只是说存在随意调用的话,那这个确实没法规避,但是如果是对多播进行随意的加减,那这个独立的对象完全可以避免啊,为什么一定要 event 呢

先把我误以为是前提的那句话掰正。C# 的访问控制是按类算的,不是按对象算的。 private 的意思是”只有写在这个类里的代码能碰”,不是”只有这个对象自己能碰”:

public class A
{
    private MessageHandler? _handlers;

    // 合法,编译得过
    public void Wipe(A other) => other._handlers = null;
}

a1.Wipe(a2),a2 的订阅就没了。

再者,就算承认那份”独立”,它防的方向也是反的。每个实例确实各有一份委托字段、各有一条调用列表,a1 上加加减减跑不到 a2 头上去,这话没错。但”独立”保证的是不连累别人,不是保护自己。而真正要防的恰恰落在自己头上——a._handlers = null 伤不到任何别的对象,可 a 是你自己的类产出来的;谁订阅了、订阅了几个、这条链子还在不在,这是类该守住的状态。别人持着引用把它清了,锅还得后面跑的业务逻辑来背,出事的现场往往离那行肇事代码十万八千里。

真正的答案藏在”不可变”里

以上都还是”为什么需要封装”。真正解释”为什么字段不够用”的,是开头埋下的那句——委托不可变。既然没法原地追加,+= 就必然是一次完整赋值:

_handlers += handler;   // 编译成 _handlers = Delegate.Combine(_handlers, handler)

读出来、合并、写回去。也就是说,+= 本身就含着一次赋值。 你允许外部 +=,就等于把写权限给出去了;而同一种写权限下,= 和 = null 自然也一并敞开。字段这个粒度上,语言没地方把”追加”和”整个替换”切成两种权限。

于是 event 换了个办法:把”写”这个动作,从赋值换成调用访问器。

public event MessageHandler MessageReceived;

// 编译器造出来的等价物(简化版)
private MessageHandler _messageReceived;    // 字段被降成了 private

public event MessageHandler MessageReceived
{
    add    { _messageReceived += value; }
    remove { _messageReceived -= value; }
}

外面写 a.MessageReceived += OnMsg 的时候,编译器生成的不是赋值指令,而是对 add 访问器的一次调用。而 a.MessageReceived = null 压根编译不过——压根不存在 setter。两种”写”就这么被切开了。

回头看”event 后面跟的类型必须是委托类型”也顺理成章:访问器里干的就是委托的 += / -=。

顺带一提,类内部引用 MessageReceived 拿到的是那个 private 字段本身,所以触发权天然留在声明类里,严谨点说连子类都触发不到——这也是标准写法非要配一个 protected virtual void OnXxx() 的原因。

我手写的那版,看着哪儿都不缺

这就是当初让我产生那个疑问的代码:

// 1. 委托类型:约定"能挂进来的方法长什么样"
public delegate void MessageHandler(string message);

// 2. 类 A:持有多播委托,提供 Add / Remove
public class A
{
    private MessageHandler? _handlers;   // 多播委托字段,初始为 null

    public void Add(MessageHandler handler)
    {
        _handlers += handler;            // 等价于 _handlers = Delegate.Combine(_handlers, handler)
    }

    public void Remove(MessageHandler handler)
    {
        _handlers -= handler;            // 等价于 Delegate.Remove
    }
}

// 3. 类 B:实例化 A,把自己的方法传进去
public class B
{
    private readonly A _a;
    private readonly string _name;

    public B(A a, string name)
    {
        _a = a;
        _name = name;

        _a.Add(OnMessage);                                          // ✅ 传本类的实例方法(不加括号!)
        _a.Add(msg => Console.WriteLine($"[{_name}-lambda] {msg}")); // ✅ 也传 Lambda(带闭包)
    }

    private void OnMessage(string message)
    {
        Console.WriteLine($"[{_name}.OnMessage] {message}");
    }

    public void Unsubscribe() => _a.Remove(OnMessage);
}

这样不用 event,不也实现了外部的订阅吗?

实现了。但把这段代码自己再看一遍,会发现它有三处跟 event 版对不上——而且这三处彼此毫无关系。

第一处:并发下订阅会丢

event 那句声明编译出来的 add 访问器,是一圈 do...while 套着 Interlocked.CompareExchange 的 CAS 重试,订阅/退订本身是线程安全的。

而我上面那版里的 _handlers += handler,是读出来 → 合并 → 写回去,三步之间没有保护。两条线程同时订阅,很可能一个把另一个刚写进去的结果覆盖掉——订阅就这么丢了,不报错不抛异常,只是悄无声息少一个监听器。上位机里”采集线程和设备状态线程各自订阅同一个事件”这种写法并不罕见,真丢一个,排查起来相当折磨。

要手写版也安全,得自己把 CAS 循环补上,或者干脆加锁。用 event 的话这件事是免费的。

丢订阅的完整时间线、CAS 循环到底该怎么写才对(很容易写错)、以及为什么 private 挡不住,单独拆了一篇出来:《委托的线程安全:+= 看着是一步,实际是三步》。

这里只留一条那边没展开的:退订不保证立刻生效。 触发那一侧如果已经把旧的调用列表读到手了,你这边 -= 改的是字段里的新值,这一次触发照样会打到你的处理器上。这是委托的正常行为,不是 bug。真碰上”退订后绝不能再被调用”的需求(接收缓冲区都已经释放了那种),得在处理器内部自己再扛一道状态判断,指望 -= 兜住是靠不住的。

第二处:它没有 Raise,而一旦补上就是个坑

注意上面那个类的注释写着”提供 Add / Remove”,可通篇没地方触发——因为没有 Raise,这个类订阅来订阅去永远不会通知任何人。

那补一个呗。但补的那一刻就会发现问题:想让外面能观察到通知,Raise 就得是 public;而一旦有了 public void Raise(...),外部代码就重新拿到了”决定何时触发”的权力——正是 event 花了那么大功夫关死的那扇门。

event 版里这件事不用纠结:字段是 private,触发只能写在声明类内部,天然出不去。

第三处:那行 lambda 订阅,退不掉

看 Unsubscribe:

public void Unsubscribe() => _a.Remove(OnMessage);

它只退了 OnMessage。而构造函数里那行 lambda 订阅呢?没有任何地方存着它的引用,也就没有任何东西能传给 Remove。

Delegate.Remove 比的是 target 加 method;就算你在别处把一模一样的 lambda 再写一遍,也不保证得到一个相等的委托。所以规矩很简单:凡是打算退订的订阅,必须先把委托存成字段,别图一行写完的痛快。

event 多出来的,总结成三句话

把上面三处反过来看,就是 event 剩下的价值:

  1. 并发免费:编译器替你把 add/remove 写成 CAS 循环。
  2. API 面统一:订阅就是 +=,不用每个类自己发明 Add / Subscribe / Attach;而且它能写进接口——接口里放不了实例字段,event 是”通知”进契约的唯一途径。
  3. 元数据身份:反射里它是独立的 EventInfo,设计器和框架(WinForms/WPF 那套事件连线)认的就是它。

真落到项目里一般是 EventHandler<TArgs> 这套现成的,拿通信调试助手举例:

public record DataReceivedEventArgs(byte[] Data) : EventArgs;

public class SerialPortService
{
    public event EventHandler<DataReceivedEventArgs>? DataReceived;

    protected virtual void OnDataReceived(DataReceivedEventArgs e)
        => DataReceived?.Invoke(this, e);   // 发布方用 ?.Invoke:有没有人订阅,轮不到它操心
}

串口收完数据在内部调 OnDataReceived 触发;UI 那边只能 +=,想手动点一下火?没门。


所以那个问题的答案最后是:手写 Add/Remove 确实能订阅,但它同时在权限、并发、契约三件事上各差一点——而这三点,用 event 全是一次声明就白拿的。回头看自己那版代码,漏的正是这三处。

参考:delegate(C# 参考)、events(C# 参考)、Delegate.Combine、Interlocked.CompareExchange


分享这篇文章:

上一篇
从 Dispatcher 的构造函数往下读——WPF 线程亲和学习笔记
下一篇
C# 的语法糖有多甜?——? 家族、=> 与属性模式的一次清点