1. 委托的本质为什么说它是方法的“快递单”很多刚接触C#的初学者看到“委托”这个词就头大总觉得它抽象、不好理解。我当年刚入行时也这样直到后来被一个老前辈用“快递单”做了类比才一下通了。委托本质上就是方法的引用类型它解决的是“把方法当作参数来传递”这个需求。就好比快递单本身不装货物但货物到了哪个中转站、由哪个快递员派送全都写在单子上委托也是它自己不实现逻辑但它记录了“哪个方法、签收什么参数、返回什么东西”调用委托的时候实际上就是通过这张单子把真正的活儿交给底下的方法去干。这个需求在UI开发里特别常见比如一个按钮的点击事件你不可能在按钮组件内部写死某段业务逻辑而是把“某个方法”塞给按钮的Click事件点击发生时按钮帮你去调用。这就是广义的“回调”。在C#里委托就是回调的基础设施事件机制更是直接建立在委托之上。理解了这一点你就明白为什么各类框架、库都在用委托和事件做扩展点——调用方和被调用方解耦逻辑可以自由组合。我在实际项目里经常看到有人问既然有接口Interface也有抽象类为什么还要委托答案很简单接口只能约束“对象”能干什么而委托约束的是“方法”本身。比如一个排序函数它不关心你传进来的对象是什么只关心“如何比较两个对象”这个行为——你把比较逻辑封装成方法传进来就行。用接口就得定义一个比较器类用委托只需要一个方法轻量得多。所以说委托是方法级别的抽象接口是类型级别的抽象各有各的用武之地。说到这还得提一个关键点委托在CLR层面就是个特殊类继承自System.MulticastDelegate所以它也有类型、也有方法只不过使用起来像函数指针。你可以把它当成“安全版的函数指针”编译器会帮你校验方法的签名匹不匹配跑飞的风险比C/C裸指针小得多。加上C#后来的Lambda、Func/Action内置泛型委托日常写回调再也不需要每次都手写一个delegate类型了。这正是“委托的简单方法”里最核心的演进方向——让大多数场景用最简单的方式完成委托的声明和使用。2. 委托的三种基础写法从手写类型到Lambda的演进2.1 经典写法声明、实例化、调用三步走要深入理解委托我建议你把最原始的写法先在脑子里过一遍因为内置泛型委托本质上就是它的语法糖。三步走声明一个委托类型相当于定一张“快递单模板”注明方法签名参数列表和返回类型。实例化这个委托把具体方法“填”进模板里。调用委托实际上就是调用引用的方法。// 第一步声明委托类型 public delegate int MathOperation(int a, int b); // 第二步实例化把方法装进委托 MathOperation add new MathOperation(Add); // 或者简化为MathOperation add Add; // 第三步调用 int result add(3, 5); static int Add(int x, int y) { return x y; }这个例子里MathOperation是自定义委托类型Add是普通静态方法。注意委托类型定义在类外面或类里面都可以常见做法是定义在namespace层级和类并列。实例化时传进去的方法名不带括号因为传的是“引用”而不是“调用结果”。这一步很多初学者会搞错写成new MathOperation(Add(3, 5))那等于把结果传进去了编译直接报错。调用委托还有两种等价姿势add(3, 5)和add.Invoke(3, 5)。Invoke是编译器自动生成的同步调用方法二者没什么区别。重点在于委托内部有一个调用列表InvocationList正常情况只有一个方法但你可以通过和-运算符往里加、减方法形成多播委托后面我会专门说。2.2 匿名方法与Lambda省略声明的实战写法光有上面的写法每次要定义一个方法还得单独写个静态函数代码很容易碎成一地。所以C# 2.0引入了“匿名方法”C# 3.0又升级出Lambda表达式。匿名方法的写法是MathOperation add delegate (int a, int b) { return a b; };Lambda更进一步把delegate关键字和参数类型都省掉MathOperation add (a, b) a b;你用起来的时候IDE的智能提示还能帮你推断a和b是什么类型因为委托类型已经定了。Lambda的优点是代码内聚逻辑写在调用处不用跳到另一个方法里去看。尤其是事件订阅场景button.Click (s, e) { ... }这种写法已经成了行业标准。但我得提醒一句Lambda省事可别过度滥用。如果同一个Lambda逻辑在多个地方重复出现或者逻辑很长超过十行就该抽成命名方法。否则调试的时候你会发现断点打进去全是Lambdas调用栈信息非常难读维护成本直线上升。我自己踩过这种坑把一个几十行的Lambda嵌在事件里三个月后回来改需求整个人都是懵的。2.3 多播委托一次调用多个方法排队执行委托实例用或把多个方法串起来调用时会按添加顺序依次执行每个方法。这个机制在事件里最典型一个按钮的Click事件可以绑定多个处理方法触发时全部跑一遍。MathOperation op Add; op Subtract; // op现在会先执行Add再执行Subtract op(8, 3); // Add(8,3)11, Subtract(8,3)5但返回值取最后一个方法的多播委托有两个很关键的坑。第一个返回值非void时最终拿到的是调用列表里最后一个方法的返回值前面几个方法的返回值会被丢弃。所以我强烈建议只要涉及返回值能不用多播就不用多播宁可自己写一个发布订阅的容器。第二个如果执行过程中某个方法抛异常后面的方法不会继续执行而且你在外层捕获到的只是那个异常很难定位到底前面执行了几个。真要严格做“逐个通知、互不影响”的广播就用事件或者自己遍历GetInvocationList()逐个调用每个包一层try-catch。总的来说多播委托是“简单方法”里的重要一环但也是初学者最容易翻车的地方。我的建议是一开始就建立正确的使用边界——多播适合“通知类”场景不适合“计算类”场景。3. 内置泛型委托Func、Action、Predicate的选用标准你打开一个现代C#项目几乎很难看到自定义的delegate类型大家清一色用的是Func、Action、Predicate这三个内置泛型委托。它们其实就是微软把最常见的几种委托签名模板化省得每个人重复造轮子。委托类型目的参数返回值Action执行操作0~16个参数voidFunc执行计算0~16个参数非void泛型返回值Predicate做判定1个参数bool选型的逻辑很简单方法体不返回值选Action方法体需要返回结果选Func泛型参数最后一格填返回类型方法体是对某个对象做真假判断选Predicate它其实就是FuncT, bool的语义化别名。比如说ListT.FindAll的参数就要求一个PredicateT而LINQ里的Select接收FuncTSource, TResult。举个例子你要对一个整数数组做加工Funcint, int square x x * x; Actionstring logger msg Console.WriteLine($[LOG] {msg}); Predicateint isEven n n % 2 0; var nums new[] { 1, 2, 3, 4 }; foreach (var n in nums) { if (isEven(n)) { logger(${n} - {square(n)}); } }如果你只想处理无返回值的一段操作那就没必要用Func硬凑一个返回值反而把代码弄得别扭。我看到有些同事写Funcint, int, bool当比较器其实完全可以用Comparisonint或者Funcint, int, bool但语义上远不如写Predicateint来的直白。选委托类型的时候读代码的人第一眼要能从类型看出来“这个方法大概干什么”比少打几个字重要得多。另外异步方法在委托里要注意一个细节async void和async Task是有本质区别的。委托类型用Action承接async void方法异常没法被正常捕捉到调用方而FuncTask承接async Task方法可以await、可以try-catch。在事件处理器里比如按钮的Click事件因为事件委托签名是void (object, EventArgs)你写async void是逃不掉的那就必须在方法内部自己try-catch兜底。但在普通回调场景里能选FuncTask就尽量不选Action这个习惯能替你省掉无数个“偶发异常导致进程崩溃”的深夜线上事故。4. 事件委托与注释规范让别人一眼看懂的写法4.1 事件为什么必须基于委托事件本质上是“受限制的委托字段”限制在两步外部只能通过和-订阅/退订不能像字段那样直接赋值或直接调用事件只能在声明它的类内部触发。这种限制非常关键它保证了发布者对外暴露的是“通知入口”而不是“收发两用接口”。public class OrderService { // 事件字段底层就是委托 public event EventHandlerOrderCreatedEventArgs OrderCreated; protected virtual void OnOrderCreated(OrderCreatedEventArgs e) { OrderCreated?.Invoke(this, e); } public void CreateOrder() { // 业务逻辑... OnOrderCreated(new OrderCreatedEventArgs { OrderId 123 }); } }事件方法命名和触发方法命名是有惯例的事件本身叫OrderCreated触发它的受保护虚方法叫OnOrderCreated参数事件类型叫OrderCreatedEventArgs。这套约定是.NET社区多年沉淀下来的新人不理解时会觉得多此一举但老手一看就懂——有On前缀的方法是子类重写逻辑的钩子事件本身只负责对外通知。4.2 XML注释委托注释的完整姿势委托本身也是一种成员自然要写在XML注释里。IDE里输入///会自动生成注释模板但你得知道模板里的标签各自是干什么的尤其是泛型委托和事件注释信息写全了别人调用你的API时智能提示才会显灵。/// summary /// 数学操作委托用于封装带两个整数参数并返回整数的运算方法。 /// /summary /// param namea第一个运算数。/param /// param nameb第二个运算数。/param /// returns运算结果。/returns public delegate int MathOperation(int a, int b); /// summary /// 订单创建成功事件。 /// /summary /// remarks /// 在订单持久化完成之后触发订阅者可在此执行通知、日志、积分赠送等后续操作。 /// /remarks public event EventHandlerOrderCreatedEventArgs OrderCreated;事件注释有个特别容易忽略的点事件的注释里最好说明“在什么时机触发”、“订阅者可以做什么”以及“触发是否和主流程同步”。因为事件是异步解耦的入口调用方无法从方法签名里看出触发时机只能靠注释来传递约定。比如上面写了“持久化完成之后触发”那订阅者就知道此时数据已经落库可以放心做通知类操作。还有一种更细的注释是做“包注释”和“文档级注释”对一个assembly或namespace做总览说明。C#里一般用一个专门的类加上/// summary再配[System.Runtime.CompilerServices.CompilerGenerated]特性让它别进编译结果或者用NamespaceDoc的经典命名约定。这样生成API文档时整个命名空间就会有一段完整的介绍文字而不是光秃秃的一堆类型列表。提到文档生成现在主流工具是DocFX和SandcastleDoxygen也能处理C#但用得少了。这些工具会把XML注释转换成HTML/PDF文档。注释写得好文档生成就顺畅注释写得乱生成的文档比不看还难受。这跟热词里提到的“python 文档注释 sphinx”、“文档级doxygen 注释”是一个逻辑不同语言、不同工具链但“写给人看、工具可提取”的目标是统一的。4.3 注释里容易犯的错词不达意与编码乱码注释的几个高频问题我逐个整理成速查表问题表现原因解决办法注释与代码逻辑不符改代码没同步更新注释把注释当作代码的一部分每改逻辑必看注释Code Review时专门检查param名称与参数不一致手写注释时拼错或重构参数名后没更新用IDE自动生成注释模板重命名方法参数时顺手重装注释注释全是废话“获取用户”只写了“干什么”没写“怎么用、边界是什么”写“何时调用、有什么前提、可能有什么副作用、返回值含义”中文注释乱码文件编码和编译器编码不匹配统一保存为UTF-8vscode右下角切编码检查项目文件charset设置注释里放URL和专有名词没加标签XML注释解析时链接不生成使用see cref.../、paramref name.../不要写纯文本我见过最头疼的案例是注释里写着“注意此方法会阻塞调用线程”但后来换了异步实现阻塞点没了注释却没改。结果新来的同事一看注释如临大敌到处加线程包装反而拖垮了性能。注释的信息价值是一票否决制的——写错比不写更糟糕。所以在团队里我一直强调注释的关键不是“数量”而是“准确性与信息量”。C#里专有名词注释还有个常被忽略的标签exception。委托方法被回调时如果可能抛出特定异常一定要在注释里声明。比如/// summary /// 执行数学操作。 /// /summary /// exception crefDivideByZeroException当b为0且操作是除法时抛出。/exception public int Execute(MathOperation op, int a, int b) { return op(a, b); }这样调用方看到智能提示里有异常说明就会主动做防护而不是在线上炸了才排查。5. 常见问题与排查从“能跑”到“跑得明白”5.1 委托与空引用最典型的NullReferenceException很多人在事件触发时直接OrderCreated(this, args);如果没有人订阅这行代码直接抛空引用。老手都会用?.InvokeOrderCreated?.Invoke(this, e);?.在C# 6.0引入就是为了解决这类“回调可能为空”的痛点。它不仅是判空还保证了“判空和调用之间不会被人为插入其他订阅”因为?.Invoke在底层做的是“取一次引用非空则调用”的原子操作。这个细节很重要多线程环境下如果你写成if (OrderCreated ! null) OrderCreated(...)两行之间可能有另一个线程改变了订阅状态导致竞争问题。5.2 重复订阅与内存泄漏一时爽退订火葬场用事件做订阅时随手一写很自然但忘掉-就会积累问题。特别是静态事件引用实例方法或者短生命周期对象订阅长生命周期对象的属性变更通知都会导致“被订阅者通过委托链持有引用”垃圾回收器永远收不掉你那个短生命周期对象的内存。这就是经典的事件泄漏。我见过一个后台服务每个用户连接进来都注册了一个UserChanged事件处理器连接断开时忘了退订最终内存里堆积了几百万个委托响应越来越慢。排查时得用dotnet-dump或PerfView分析托管堆看Delegate对象的引用链才定位到是哪个事件泄漏。规避方案很简单谁订阅谁负责创建和退订配对。UI事件偶尔忘了问题不大因为UI对象生命周期本身和窗体绑定但全局静态总线、订阅发布容器这类必须时刻记得退订。还有一个更稳的思路用WeakEventManager或现代的弱事件模式让事件持有弱引用不至于强引用卡住对象生命周期。5.3 委托链中的异常屏障调用列表的中断效应如果委托链里有多个方法其中一个抛异常后续的全部不跑。这在业务上会造成“一部分通知成功一部分通知失败”的脏状态。解决思路是不要直接调用委托而是遍历GetInvocationList()对每个方法单独try-catchforeach (MathOperation op in myDelegate.GetInvocationList()) { try { op(a, b); } catch (Exception ex) { Console.WriteLine($其中一个处理方法失败{ex.Message}); } }这样业务的容错性显著提升通知A失败通知B照常进行并且每条失败都有独立日志。这个模式在实现“领域事件分发器”时几乎是必备逻辑你在框架源码里也经常能看到类似的写法。5.4 委托同步调用与异步调用从BeginInvoke到Task.Run的进化以前.NET Framework时代委托有个BeginInvoke可以直接把方法投到线程池异步执行。但从.NET Core 3.0开始这个API被移除了因为设计复杂、容易坑人异步调用统一推荐走Task.Run或async/await。我前面特别提到过委托异步还得看返回类型如果方法体是async Task用FuncTask承接调用方可以await如果是async void用Action承接异常会顺着线程池通道飞走很难捕获。FuncTask asyncOperation DoAsyncWork; await asyncOperation(); // 正常等待异常可捕获 Action asyncVoid async () { await Task.Delay(100); }; asyncVoid(); // 异常可能丢失慎用现代异步方法还有一个“热词”场景mvc 某个方法特殊路由指定与异步方法结合比如在ASP.NET Core里某个Action返回TaskIActionResult。如果你要在过滤器中做“异步委托调用”过滤器基类的OnActionExecutionAsync本身就是异步方法签名里面再用FuncTask做扩展点代码会干净很多public class TimingFilter : IAsyncActionFilter { public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var sw Stopwatch.StartNew(); var result await next(); // next本身就是一个FuncTaskActionExecutedContext委托 sw.Stop(); Console.WriteLine(${context.ActionDescriptor.DisplayName} 耗时 {sw.ElapsedMilliseconds}ms); } }ActionExecutionDelegate这个类型本质上就是框架定义的一个委托public delegate TaskActionExecutedContext ActionExecutionDelegate();。你看委托在框架内部无处不在理解了它的原理读框架源码也轻松得多。5.5 注释与文档生成给未来同事的交接信最后我特别想说一个我个人很深的体会很多人写委托、写方法时脑子里只想着“怎么跑通”根本不想注释的事。等代码进入维护期看着一个自己三个月前写的public void Process(Funcint, bool filter)没有注释没有示例完全想不起来当时的参数约定和边界条件。这时候你就知道注释不是写给公司看的是写给“三个月后的自己”看的。我推荐一个写注释的“五问框架”这个委托/方法负责什么一句话说清楚。参数之间有什么关系有没有约束比如“第一个参数必须大于0”。返回值代表什么有没有约定比如“返回null表示未找到”。有没有副作用比如“内部会加缓存”、“内部会修改传入对象的状态”。使用示例是什么尤其在委托和事件上给一个的实际注册示例比写一万个字都管用。/// summary /// 判断用户是否有权限进入指定房间。 /// /summary /// param nameuser用户信息不能为null。/param /// param nameroomId房间号大于0。/param /// returnstrue表示允许进入false表示拒绝。/returns /// remarks /// 示例 /// code /// FuncUser, int, bool visitorPolicy (user, roomId) user.Level 3; /// bool access visitorPolicy(currentUser, 1024); /// /code /// /remarks public FuncUser, int, bool BuildRoomAccessPolicy() { // ... }这样的注释配合XML文档生成工具别人调用同一个方法时IDE会直接把上面整段展示出来包括示例代码。你说省了多少口头沟通和IM来回。6. 实战案例一个完整的委托注释落地组合为了让你看得更实在我把一次“订单模块通知改造”的实战过程复盘一下。背景之前订单创建成功后代码里直接硬编码调用了短信服务、邮件服务、内部BI埋点三个类每次改动都要动主流程。后来要增加合作方Webhook推送我开始把三处调用改成事件通知主流程只留一行OnOrderCreated。第一步定义事件参数类型。/// summary /// 订单创建完成的事件参数。 /// /summary public class OrderCreatedEventArgs : EventArgs { /// summary /// 订单编号。 /// /summary public long OrderId { get; set; } /// summary /// 客户标识。 /// /summary public string CustomerNo { get; set; } }第二步定义事件并实现触发方法。/// summary /// 订单创建成功事件在数据落库后触发。 /// /summary public event EventHandlerOrderCreatedEventArgs OrderCreated; /// summary /// 触发订单创建事件子类可重写此方法扩展触发逻辑。 /// /summary /// param namee事件参数。/param protected virtual void OnOrderCreated(OrderCreatedEventArgs e) { OrderCreated?.Invoke(this, e); }第三步原调用方改为订阅。orderService.OrderCreated (sender, e) { _smsSender.Send(e.CustomerNo, $您的订单 {e.OrderId} 已创建成功); }; orderService.OrderCreated async (sender, e) { await _bizTracker.TrackAsync(order_created, e.OrderId); };重建之后主流程干净了后续加新通知渠道时只需要新增一个订阅方法一行都不用改主流程。但是前面说的“异步lambda事件处理器”又回来了——第二个订阅是async void外部异常无法被调用方捕获。我实际做法是给所有事件处理器包了一层通用的执行器本质上就是遍历委托链表加异常隔离。这个“订阅-分发-容错”的模型在真实复杂业务里非常有用。第四步把“通知异常不能影响主流程”这个需求落地为通用扩展方法public static class EventExtensions { /// summary /// 安全触发事件逐个调用订阅者并隔离异常。 /// /summary /// typeparam nameT事件参数类型。/typeparam /// param namehandler事件委托实例。/param /// param namesender事件发送者。/param /// param namee事件参数。/param public static void SafeInvokeT(this EventHandlerT handler, object sender, T e) where T : EventArgs { if (handler null) return; foreach (EventHandlerT single in handler.GetInvocationList()) { try { single(sender, e); } catch (Exception ex) { // 建议在这里接入真正的日志框架例如ILogger或NLog Console.WriteLine($事件处理器异常{ex}); } } } }调用时用OrderCreated.SafeInvoke(this, e);替代?.Invoke既保留了判空又隔离了异常。这套模式我用了好几年线上出问题从来不会因为“某个通知坏了”而把“订单创建主流程”拖垮。7. 委托背后的设计思想回调、控制反转与可测试性说完了具体用法我想再往上看一层。委托在架构层面的价值不只是“少写几个类”而是它直接支撑了控制反转和面向扩展点编程。你写一个排序算法把“比较规则”通过委托抛给调用方那么算法本身就不需要知道具体类型的长相只需要按规则交换位置。这既满足开闭原则又让核心逻辑保持纯粹测试时传一个固定规则就能验证。而事件作为委托的进阶形态把“反转”更进一步对象在状态变化时主动通知外部外部不再“轮询”状态。今天很多框架的核心机制都是这种思路比如ASP.NET Core管道里中间件之间的next委托Entity Framework的SaveChanges事件甚至React里onClick回调本质都是同一种思想调用时机由框架决定具体逻辑由使用方注入。理解了这一层你再去看开源项目时会轻松很多。比如热词里有“工厂方法”工厂模式在C#里经常配合委托实现“可配置的创建策略”用一个Funcstring, IProduct类型的工厂委托比定义一个接口加上十几个实现类要轻便得多。又比如“特征提取方法”这类词如果放在机器学习管线里特征提取器经常就是一组FuncTData, TFeature的管道组合每一步用委托串联清晰又容易调试。所以我一直认为委托不是C#里一个孤立的语法点它是一种经典的设计哲学的语言体现。你把这个思想想通了看任何框架、任何语言的回调机制都会一通百通。8. 从“会用”到“用对”几条我反复强调的习惯最后不整什么大总结就说几条我在实操里反复栽过跟头、最后沉淀下来的习惯。第一能用内置委托就别自定义委托。自定义委托在多播、泛型、类型转换上经常出幺蛾子Func和Action已经被CLR特殊优化智能提示和泛型推断也更顺滑。只有在“语义显著特殊”时才考虑自定义比如安全性关键的委托想用强命名校验时。第二事件处理器里永远不要裸奔。用一个类似于上面写的SafeInvoke统一入口或者至少给每个订阅包一层try-catch。事件触发是在发布者的线程上同步执行的一个订阅者的异常足以让发布者的关键流程崩掉。你不想因为一个短信接口偶尔超时导致用户订单创建失败吧。第三委托传参时用不可变类型做边界。如果回调要传入集合对象订阅者可能无意中修改集合内容这会造成非常隐蔽的bug。规范做法是传入IReadOnlyCollectionT或直接传拷贝让订阅者只读。实在没办法改就在XML注释里显著标注“订阅者不得修改集合内容”。第四凡是公开的委托和事件必须有XML注释和调用示例。这一点我在团队里强制要求过很多次因为公开API一旦被其他模块/其他团队引用注释缺失或错误会乘着耦合链传播。注释在这个场景下不是“锦上添花”是“合同文本”。第五调试委托链时善用“调用堆栈”窗口和“并行堆栈”。如果发现事件被调了两次甚至三次优先检查是不是同一个方法被了多次。典型原因订阅代码放在了一个被反复执行的方法里比如OnEnable方法。我当时排查一个重复扣费问题就是靠断点观察调用堆栈发现事件订阅在构造函数里做了一次、在初始化方法里又做了一次。啰嗦了这么多其实核心就一句话委托很简单但简不简单取决于你愿不愿意把它背后的设计意图、使用边界和注释习惯都当作“方法”的一部分来对待。代码跑通只是及格线能让下一个人包括三个月后的你自己一眼看清“委托干什么、何时订阅、怎么取消、异常怎么兜底”才是真正的高手风范。希望你下次写委托的时候也能顺手把注释写好把边界想清楚。
阅读完成 · 觉得有帮助?