从函数指针到 event——C# 委托学习笔记
先把结论摞这儿,下面再一条条摊开:
delegate就是函数指针,带类型检查的那种delegate把一个方法”造成类”event给这个类声明出来的字段上了锁:对外只剩+=、-=两个口子
第三条我琢磨了很久,因为我自己写过一版”不用 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()
// 依次输出 ①②③,按添加顺序往下走
两个坑得提前知道:
- 有返回值的委托做多播时,只有最后一个方法的返回值留得下来,前面那些全丢了。
- 链上某个方法抛出异常,后头就不跑了——想要挨个兜住,得自己遍历
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 剩下的价值:
- 并发免费:编译器替你把 add/remove 写成 CAS 循环。
- API 面统一:订阅就是
+=,不用每个类自己发明 Add / Subscribe / Attach;而且它能写进接口——接口里放不了实例字段,event 是”通知”进契约的唯一途径。 - 元数据身份:反射里它是独立的
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