1. 五步法到底拆的是什么:先弄清楚委托为什么难
1.1 难的不是语法,是"没见过这种思维"
我带过的很多新人,在学到委托的时候都会经历一段非常类似的卡壳期。背概念倒还行:"委托是方法的引用""委托是类型安全的函数指针",但一落到代码里就傻眼:这个东西到底能干嘛?为什么有了接口还不够?那些链式调用的灯红酒绿底下到底发生了什么?
我后来复盘了一下,发现大家卡住的原因高度一致:不是看不懂delegate关键字,也不是不知道+=的用法,而是脑子里缺少一条完整的"认知链条"——从底层原理到语法形态,再到真实场景,这段路没有打通。教程通常只讲了中间某一环:要么上来就讲语法,把人绕晕;要么直接抛出一堆设计模式,说委托就是回调、委托就是事件,但读者连回调在内存里是怎么转的都还模糊。
所以我才整理出这套理解委托的五步法,核心思路是:**先建立底层直觉,再学语法,再吃透实例化,然后搞清调用机制,最后把委托放回设计场景里看。**每一步都踩实了,你再回头看Func、Action、事件这些术语,会发现它们其实都是同一棵树上长出来的枝丫。
1.2 为什么是这五个步骤,而不是直接背用法
打个比方:学车的时候,教练不会第一天就让你上高速。你得先知道方向盘怎么打、离合在哪、仪表盘上每个灯什么意思,然后在封闭场地里练起步、倒库,最后才谈得上应对真实路况。委托也是一样,我见过一上来就学+=挂事件处理器的人,写出来的代码运气成分很大,能跑,但换个场景就翻车。五步法的每一步对应一个层面:
- 底层直觉层面:委托在内存里到底是什么,为什么它能把方法"装进去";
- 类型系统层面:委托声明到底定义了什么东西,怎么声明才规范;
- 实例化层面:创建委托的几种姿势,哪些是语法糖,语法糖背后发生了什么;
- 调用机制层面:单播和多播的调用链怎么回事,返回值怎么处理,异常怎么传播;
- 设计应用层面:委托在框架里解决什么问题,为什么事件、LINQ、回调都和它有关。
这五层是一层垒一层的。底层直觉解决"它是什么",类型系统解决"它长什么样",实例化解决"怎么造它",调用机制解决"它怎么跑",设计应用解决"为什么需要它"。前面任何一层没打通,后面都会出现似懂非懂的状态。
2. 第一步:把委托当成方法的"地址名片"来理解
2.1 方法在内存里的真实形态
很多教材一上来就写delegate int MathOperation(int a, int b);这样的语法,然后说"这就是一个委托"。但对一个没接触过底层的人来说,这行代码读起来就像是天书:int我懂,MathOperation我懂,(int a, int b)我懂,但把它们组合在一起,怎么就不是一个方法了?问题的根源在于,我们从来没有直观地想过:方法在以什么形式存在?
方法编译之后,本质上就是一段存储在代码段里的机器指令,每条指令都有内存地址。你调用一个方法,底层做的事情就是:把参数压栈,跳转到方法入口地址执行,执行完跳回来。所以方法入口地址,就好比这堆指令的"门牌号"。你不需要知道门牌号具体是多少,编译器替你记着,但你得理解:凡是需要"执行某段逻辑"的地方,本质上都是拿到这个入口地址,然后跳过去。
我不知道大家有没有在C或者C++里写过函数指针。如果有,这一步会轻松很多:
int add(int a, int b) { return a + b; } int (*funcPtr)(int, int) = &add; int result = funcPtr(3, 4); // result = 7这个funcPtr就是一个指向函数的指针变量。你可以把它传来传去,可以放在结构体里,可以让某个函数接受一个函数指针作为参数,从而实现"行为注入"。这个思想非常简单:函数本身是一段数据,这段数据也有地址,也值得被当作对象来操作。委托的底层直觉,就是从这里来的。
2.2 委托比函数指针多了什么
但如果你直接拿C语言里的裸指针来类比委托,会漏掉一个关键差异:裸的函数指针非常脆弱。它只保存一个地址,不知道参数是什么、返回值是什么,你一不小心把一个返回int的函数指针当成了返回void的来用,编译器可能根本不报错,运行起来直接崩。更麻烦的是,裸函数指针没法保存"对象实例"信息——如果要调用某对象的实例方法,只有函数地址是不够的,你还得知道是哪个对象上的方法。
而委托把这个事情做得非常漂亮。它在内部其实是一个类,继承自System.MulticastDelegate,里面同时保存了两样东西:方法入口地址,以及目标对象实例引用(如果指向的是实例方法)。
所以才会有那么一句经典描述:委托是"类型安全"的函数指针。类型安全的意思是,编译器会在编译期检查签名是否匹配,一个Func<int, int>类型的委托,你绝对塞不进去一个Func<string, void>方法。这就相当于门牌号上写了"这栋楼只能放整数返回值的函数",其他类型的函数哪怕地址给了也进不去。
2.3 用一个生活场景把"地址名片"焊死在脑子里
我上课的时候经常用一个例子:你把活儿外包给别人干。外包合同上写的不是"张三来干这件事",而是"具备某个技能标准的人来干这件事"。合同上写的岗位要求,就是委托的类型签名;拿这个合同来找人,找到之后填上他的名字和联系方式,就是委托实例化;真正给这个人打电话让他干活,就是调用Invoke。
委托对象就是一张"名片",名片上写着:找谁(目标方法)、去哪找(方法地址)、这人擅长什么(签名)。理解了这张名片,后面所有语法都只是这张名片的写法问题。很多同学学委托卡在第一步,就是因为跳过了"方法也有地址"这个朴素的认知,一上来就啃语法,导致脑子里没有一个可锚定的图像。
注意:这里要强调一下,委托并不是"把方法变成了一个变量"这么简单,它是把一个方法连同它所属的对象实例封装成了一个对象。这个封装动作,才是后面一切高级玩法的起点。
3. 第二步:委托类型声明到底声明了什么
3.1 自定义委托声明的完整拆解
先看最常见的自定义委托写法:
public delegate int MathOperation(int a, int b);这句话读出来的意思应该是:"我定义了一种类型,这种类型的对象可以用来引用一个方法,这个方法是接受两个整数参数、返回一个整数的方法。"注意关键词是"类型",不是"方法"。MathOperation不是方法名,而是一种可以装方法的类型。
这就好比服装店的尺码系统。你定义一个L码并不是定义一件具体的衣服,而是定义了一个"适合该码数身材"的标准。不管哪件衣服,只要胸围肩宽符合这个标准,都能归到这个码数下面。同样地,不管哪个方法,只要签名是(int, int) -> int,你都可以把它装进MathOperation这个容器里。
声明自定义委托时,有几个细节值得注意:
- 委托声明可以放在
namespace下,也可以嵌在类里面,但通常我会把常用的委托放在namespace层级,方便多处复用; - 访问修饰符
public、internal和类一样,要考虑暴露范围; - 委托可以声明为泛型,比如
public delegate TResult MyFunc<T, TResult>(T arg);,但现代C#里绝大多数场景直接用框架内置的Func/Action就够了。
3.2 内置委托:为什么你应该优先使用 Func 和 Action
. NET框架替我们把最常用的委托都预定义好了,最著名的两组就是Action和Func:
Action<string> log = Console.WriteLine; Func<int, int, int> add = (int a, int b) => a + b;区别非常简单:Action表示没有返回值的方法,Func表示有返回值的方法。Func<int, int, int>最后的int是返回值类型,前面的int是参数类型。它们俩还有各种泛型重载,从无参到多参都覆盖了。
这里我要强调一个工程上的心得:自定义委托类型原则上要尽量避免,除非你遇到的情况确实表达性不足。为什么?举一个很典型的坑:两个自定义委托类型,哪怕签名完全一样,它们之间也不能直接互转:
public delegate void D1(); public delegate void D2(); D1 d1 = () => Console.WriteLine("hello"); D2 d2 = d1; // 编译错误,无法隐式转换看到没有?D1和D2签名一模一样,但它们是两个完全不同的类型,编译器不允许直接赋值。这在实际项目中会带来大量不必要的麻烦。而Action和Func是全局统一约定的规范,你直接用它们,就不会碰到"我这个委托传不进去,因为类型不匹配"这种尴尬。
3.3 委托是引用类型,但用法却像直接调方法
还有一个容易被忽略的知识点:委托是引用类型。每次new出来的委托对象都会在堆上分配内存。但奇怪的是,你写代码的时候感觉不到这一点,因为调用委托的语法实在太像一个方法调用了:
MathOperation op = Add; int result = op(3, 4); // 实际上编译器会翻译成 op.Invoke(3, 4)op(3, 4)是op.Invoke(3, 4)的语法糖。Invoke是编译器自动生成的一个方法,它的签名和你声明的委托类型严格一致。正是因为有了这层语法糖,委托在使用的观感上"像"普通方法调用,再加上IDE智能提示的加成,很多初学者甚至忘了自己正在操作的是一个对象。
但记住这一点很重要:既然是引用类型,那就存在null的可能。你以为你拿到一个委托对象,实际上可能是null,直接调op(3, 4)就会抛NullReferenceException。这个坑在后面讲问题时单独列出来说。
4. 第三步:三种实例化方式,表面一样内里不同
4.1 传统 new 写法、方法组转换、lambda 写法
委托实例化最传统也最直观的写法是new:
public static int Add(int a, int b) { return a + b; } MathOperation op1 = new MathOperation(Add);这种写法把"创建委托对象"这件事摆在明面上,一看就知道是把Add方法包装成委托对象。但平时写代码,很少有人会这么写,因为编译器提供了更简洁的方法组转换语法:
MathOperation op2 = Add;这里Add本身其实是一个"方法组"——它可能对应多个重载方法。编译器看到目标类型是MathOperation,就会根据目标委托的签名去匹配最合适的那个重载。如果匹配不到,或者存在歧义,编译器会直接报错。这种写法更常用,因为它非常直白:把方法当成值来赋值。
第三种就是现代C#里最常见的 lambda 写法:
MathOperation op3 = (a, b) => a + b;lambda 的本质是一个匿名函数表达式。编译器收到这个表达式之后,会把它编译成委托实例。好处是你不用专门定义具名方法,逻辑写在现场,读起来跟写数学公式一样顺滑。尤其在写LINQ查询、事件订阅、异步回调的时候,lambda 几乎成了标准姿势。
4.2 方法组转换时,你想象的"赋值"和实际发生的"包装"不一样
这里有一个非常容易被忽视的底层细节:当我们写MathOperation op2 = Add;的时候,看起来像是把一个方法名直接赋值给了委托变量。但方法名本身并不是一个"值",它不是字符串、不是整数,不是一个可以在内存中直接搬运的数据。
编译器看到这行代码时,会生成一个"创建委托对象"的指令:在堆上分配一块内存,把Add方法的入口地址填进去,把这个委托对象赋给op2。也就是说,方法组转换本质上还是 new 了一个委托对象。只是为了让你写起来舒服,编译器替你把这层包装动作藏起来了。理解这一点,你就明白了"方法组"为什么会在重载场景下有时报"歧义"——因为编译器要替你做"选哪个方法打包"的决策,如果决策不出来,就只好把问题抛给你。
4.3 lambda 捕获变量时的隐藏闭包类
lambda 的简洁性背后,藏着一个所有C#开发者迟早会踩的坑:闭包。看这段代码:
int factor = 10; Func<int, int> multiply = x => x * factor; factor = 20; Console.WriteLine(multiply(5)); // 输出多少?答案是 100,不是 50许多初学 gặp这个问题都会懵:我明明是在factor = 10的时候创建的委托,为什么后来factor改成20,委托调用的结果也跟着变了?
原因是编译器把multiply这个 lambda 编译成一个隐藏的闭包类,类里面有一个字段factor。lambda 内部访问factor,实际上访问的是闭包类实例的字段,而不是一份拷贝。所以当你修改外部的factor变量时,闭包类里的字段也跟着变了——它们是同一个变量的两个视角。
这个行为在写事件回调和异步任务时特别容易引发事故。比如循环里注册回调、捕获循环变量,如果不理解闭包机制,就会得到一溜儿相同的值。后面问题排查部分我再详细展开,先在这里留个印象:lambda 不只会捕获你传入的参数,它捕获的是整个词法环境中的变量引用。
5. 第四步:把调用链和多播机制彻底搞清楚
5.1 Invoke 到底做了什么
调用委托最核心的方法是Invoke。它的执行流程分为三步:检查委托对象是否为空;遍历委托内部的调用列表;按顺序执行列表里的每个方法。这个"调用列表"概念很重要,因为一个委托对象可以同时"挂"多个方法,这就是多播委托。
看一下这个基础的多播例子:
Action log = () => Console.WriteLine("第一条日志"); log += () => Console.WriteLine("第二条日志"); log += () => Console.WriteLine("第三条日志"); log();输出会是三条日志依次打印。为什么?因为每次+=操作,实际上是把新的方法包装成委托对象,然后追加到当前委托对象的调用列表尾部。最终执行log()的时候,编译器会生成一段遍历调用列表、逐个Invoke的代码。
5.2 多播委托的返回值问题
但多播这个东西用起来有一个著名的陷阱:当你给一个带返回值的委托挂上两个以上方法,最终返回的只是最后一个方法的返回值,前面方法的返回值全部被丢弃。
Func<int, int> f = x => x + 1; f += x => x * 10; int result = f(5); // 第一个方法返回 6,被丢弃 // 第二个方法返回 50,result 最终是 50这个行为如果你不知道,写出来的代码会非常诡异:你以为委托把所有结果都"收集"起来了,实际上它只留最后一份。所以我的建议是:如果你需要合并多个方法的返回值,不要依赖多播委托的返回值,要么自己遍历GetInvocationList()逐个获取,要么干脆改用事件驱动的方式,让每个订阅方各自处理结果。
5.3 异常在多播委托里怎么传播
还有一个更隐蔽的坑:多播委托的调用列表里,如果其中一个方法抛异常,后续的方法全都不会执行。
Action act = () => Console.WriteLine("第一步"); act += () => throw new Exception("中途炸了"); act += () => Console.WriteLine("第三步"); act(); // 输出"第一步",然后抛异常,"第三步"永远不会打印这在实际项目里意味着什么?如果你用一个多播委托来广播事件通知,比如通知三个服务同步缓存,第一个服务正常,第二个服务抛了异常,第三个服务就收不到通知,整个流程看起来像是"只执行了一半"。处理这个问题通常有两种思路:第一,在事件处理器内部各自捕获异常,不允许异常外溢;第二,使用GetInvocationList()手动遍历调用列表,在每个调用外面包try-catch,这样即使某个订阅方出错,也不影响其他订阅方。
5.4 委托、事件和匿名方法之间是什么关系
学到事件的时候,很多人的脑子开始打架:事件是不是就是委托?不是,但它们的关系很密切。字段背后的事件,本质上是用委托字段实现的,只不过编译器给事件加了两层封装:一个add访问器(对应+=)和一个remove访问器(对应-=)。
为什么框架要费劲做这种封装?看下面这个典型的错误示例:
public class Button { public Action OnClick; public void Click() { if (OnClick != null) { OnClick(); } } } // 外部代码 button.OnClick = null; // 直接把所有订阅者清空了如果OnClick是一个公共委托字段,外部代码不仅可以通过+=订阅,也能直接赋值覆盖、甚至置空。这不仅会破坏封装性,还会导致程序中的订阅状态完全失控。而event关键字限制住了外部只能使用+=和-=,无法直接=赋值,也无法在类外部直接Invoke。事件是对委托字段的受限访问器封装,是为了保护委托字段不被滥用而产生的语法机制。
现代C#里还出现了一个细节:在类内部引发事件时,多线程环境下建议做一次"局部拷贝"再判空。经典的写法是:
public event EventHandler? Changed; protected void OnChanged() { Changed?.Invoke(this, EventArgs.Empty); }Changed?.Invoke是一种简写,先判空再调用。之所以有这种建议,是因为事件存在"订阅者正好在触发前的那一瞬间退订"的竞态条件,虽然实践中概率很低,但用空条件运算符可以把这个隐患降到非常低。
5.5 异步调用委托的演变
在老版本的. NET Framework里,委托类型都会自动带一组BeginInvoke和EndInvoke方法,可以异步执行委托指向的方法。但到了. NET Core,微软已经将这两个方法标记为过时,不推荐使用了,原因也很现实:直接用Task.Run包装一个委托更直观,而且还能配合async/await做真正的异步流控,何必再维护一套基于IAsyncResult的旧机制。
所以现在我的建议是,不要再去学BeginInvoke那套老古董写法了。你现在要处理"把一个委托异步跑起来"这种需求,直接用:
Task task = Task.Run(() => SomeMethod()); await task;时代变了,委托的调用模型也在演进,掌握了核心机制之后,你会发现所有这些演变都是同一件事在换不同的出租车。
6. 第五步:把委托放回设计场景,就能看穿一半框架源码
6.1 委托解决的是"行为参数化"问题
走到这一步,你已经知道委托是什么、怎么造、怎么调,接下来最关键的问题是:委托到底解决的是什么设计问题?我的答案是四个字:行为参数化。
什么叫行为参数化?举个例子,排序函数Array.Sort。它的排序规则不应该写死,不同场景可能按年龄排、按分数排、按时间排。如果不能把"如何比较两个元素"这个行为传进去,排序函数就得写无数个重载,或者用接口定义一堆策略类。有了委托,你就可以把比较逻辑作为一个参数传进去:
People[] people = ...; Array.Sort(people, (x, y) => x.Score.CompareTo(y.Score));注意,这里我传的不是"数据",而是"行为"——一个可以被调用的比较方法。这种设计模式在框架层到处都是。去读LINQ的源码,你会发现Where、Select、OrderBy这些扩展方法统统接受Func参数:
var adults = people.Where(p => p.Age >= 18);这段代码的本质是:数据集合还是那个数据集合,但"哪些算成年人"这个规则由调用方注入。框架负责遍历集合、应用规则、返回结果;调用方负责提供规则。这就是委托带来的能力:你可以把某个行为推迟到调用时刻才确定。
6.2 委托让控制流程倒过来
再往深一层说,委托是实现"控制反转"的最轻量手段之一。在传统的面向过程思维里,函数A调用函数B,控制流是"从上到下"的。而委托允许你"把决定权交给别人":你告诉框架"当某个条件满足时,请调用我提供的这个回调方法"。
事件和处理器的关系就是这样。UI框架不知道用户会在什么时候点击按钮,所以它不去预测,而是开放一个订阅入口:你来注册你的回调,将来用户点了按钮,框架替你去调用你那个回调。整个流程的控制权,从处理事件的主动方,转移到了注册事件的被动方。
这种"倒过来"的设计,在异步编程里尤其明显。当你发起一个网络请求,你不可能阻塞当前线程等结果。你能做的是:提供一个委托,告诉框架"数据到了以后,请执行我给你的这个方法"。回调函数、Promise、async/await,本质都是这个思想在不同时代的变体,只是表达方式越来越优雅而已。
6.3 策略模式、模板方法模式和委托的关系
设计模式书里经常提到策略模式:定义一族算法,把它们逐个封装起来,并且使它们可以互相替换。传统实现方式是为每种算法定义一个实现同一接口的类,然后通过注入不同实例来改变行为:
interface ICalculator { int Calc(int a, int b); } class AddCalculator : ICalculator { ... } class SubCalculator : ICalculator { ... }但如果你只有一个方法需要替换,用委托来实践策略模式要轻量得多:
Func<int, int, int> calcStrategy = (a, b) => a + b;类似地,模板方法模式是把公共流程骨架写好,把变化的部分留空供子类或委托填充。在非继承的场景下,委托充当那个"钩子"非常方便。我觉得理解这些关联有个很大的好处:你会意识到委托不是一门孤立的语法,它和接口一样,都是在做"抽象与注入"这件事,只是粒度不同。接口适合抽象一组相关的能力,委托适合抽象单独一个动作,两者并不互斥,而且经常配合使用——比如你的类可以同时允许调用方传入一个委托来进行自定义处理。
6.4 你其实每天都在用委托,只是没意识到
有一种错觉是"委托只存在于面试题和小众库里",但实际上你写的每一行LINQ查询都建立委托之上,Task.ContinueWith(t => ...)是委托,EventHandler是委托,自定义中间件里的next调用也常常以委托形式传递。所以不要总觉得委托离业务很远,它就在那一层层的语法糖底下压着。
举个非常常见的业务场景:你在做一个订单处理系统,订单状态变化时,需要给用户发短信、推送App通知、更新库存。最粗暴的写法是直接在一个方法里串行调用这三个模块,然后每来一个新需求就改这个方法。用事件重构之后,订单服务只负责"状态变化时触发事件",短信服务、推送服务、库存服务都通过订阅事件来响应。新增一个"发送优惠券"的需求,只要新加一个订阅方就行,订单服务完全不用动。这就是委托/事件在业务架构层面降低耦合的真实案例。
7. 实践中的高频问题:那些我花过真金白银时间踩出来的坑
7.1 空引用:委托未赋值就调用
新手最容易犯的错误,就是对委托变量不做判空就直接调用:
Action? onSuccess = null; // ... 中间某个逻辑忘了赋值 onSuccess(); // 运行时报 NullReferenceException解决方案有几种,按推荐程度排:
- 调用处判空:
onSuccess?.Invoke(); - 初始化时就给一个空委托:
Action? onSuccess = () => { }; - 事件场景下在触发处统一判空并拷贝局部变量。
我自己写代码的习惯,如果是类内部的事件,触发时一律用空条件运算符,这个习惯能省掉大约九成的空引用崩溃。而如果委托是从外部传入、又允许为空的话,我会倾向于在构造时校验并在关键调用点做保护,避免错误被埋得很深。
7.2 多播委托丢返回值
前面已经说过一次,但值得放在问题清单里再来一遍。很多人以为给带返回值的委托+=多个方法,最终会把所有结果都返回,实际只会拿到最后一个。这里列一个典型对比表:
| 期望 | 实际 |
|---|---|
委托+=三个返回int的方法,调用后拿到三个结果的求和 | +=三个返回int的方法,调用后只返回最后一个方法的返回值 |
| 如果调用链中间某个方法抛异常,希望异常被吞掉继续执行后面 | 异常会中断整个调用链,后面的方法不会执行 |
| 给事件挂多个处理器,希望某个处理器异常不影响其他处理器 | 默认情况下异常会从触发点抛出,中断后续所有处理器 |
如果你确实想要"收集所有结果"或者"隔离各个处理器",正确做法是用GetInvocationList()手动遍历调用列表:
foreach (Func<int, int> action in f.GetInvocationList()) { try { int result = action(5); // 收集 result } catch (Exception ex) { // 记录异常,继续执行其他订阅方 } }7.3 闭包捕获循环变量:经典中的经典
这个坑每年都会坑到一大批人,我必须用完整示例来讲清楚。先说一个修复过的场景,如果你的代码长这样:
List<Action> actions = new(); for (int i = 0; i < 5; i++) { actions.Add(() => Console.WriteLine(i)); } foreach (Action action in actions) { action(); }运行结果会是什么?很多人以为是0, 1, 2, 3, 4,但实际是5, 5, 5, 5, 5。原因就是闭包捕获的是变量i本身,而不是i当时的值。循环结束的时候i已经变成了5,五个 lambda 看到的是同一个变量的最终值。
C# 5 之后,foreach循环变量已经改成了每次迭代创建一个新变量,所以用foreach不会踩这个坑;但for循环里依然会踩。解决办法也很经典:在循环体内建一个局部副本:
for (int i = 0; i < 5; i++) { int copy = i; actions.Add(() => Console.WriteLine(copy)); }这里copy是每次迭代都新建的局部变量,每个 lambda 捕获到的copy对象是独立的,所以输出就会变成预期的0, 1, 2, 3, 4。这个坑在你写异步任务列表、事件订阅时特别容易出现,建议大家在代码审查的时候重点关注循环内创建委托的代码。
7.4 自定义委托类型不匹配
前面提过两个相同签名的自定义委托不能互转,这也是频繁出现在实际工程里的别扭问题。比如你定义了一个delegate void LogHandler(string message),框架里另一个方法只接受Action<string>,你满心欢喜地传进去,结果编译报错。
解决方案有几个:
- 最推荐:直接用
Action<string>取代自定义委托; - 如果确实无法改动签名,可以通过中间层转换,比如
new LogHandler(msg => logAction(msg)); - 设计公共API时优先考虑内置委托类型,把兼容性成本降低到零。
现在看很多开源库和框架的公开API,其实已经几乎见不到自定义委托类型了,要么用Func、Action,要么用事件模型。这也说明了行业已经用脚投票:简单统一的内置委托在绝大多数场景下都够用,自定义委托只适合极少数需要强命名语义的场景。
7.5 性能与分配:委托调用真的慢吗
不少同学关心委托调用的性能。我直接说结论:从委托到普通方法调用的额外开销,在绝大多数业务场景里小到可以忽略。现代. NET 的 JIT 对委托调用有非常激进的优化,尤其是单播委托,很多情况下会被内联,甚至可以优化到和直接调用几乎一样快。真正的性能问题不在于调用本身,而在于闭包可能引起的额外内存分配,以及在高频循环里频繁创建 lambda 带来的垃圾回收压力。
如果在一个每秒执行几百万次的循环里使用委托,建议预先缓存委托实例,避免每次迭代都新建 lambda:
Func<int, int> cached = x => x + 1; for (int i = 0; i < hugeCount; i++) { Use(cached); }但绝大多数业务系统根本到不了这个量级,所以我的建议是:先正确,再优雅,最后才谈性能。不要为了几百纳秒的所谓"性能"把代码写得又臭又硬,收益完全覆盖不了维护成本。
8. 一点个人体会
在带新人走过很多轮委托这个坎之后,我最大的感受是:委托这东西,最忌零散地学。今天背一句"委托是引用"、明天抄一段+=的用法,后天看一篇事件详解,看起来每个点都懂了一点,但合在一起遇到真实业务场景,还是不知道该怎么组合它们。
五步法其实就是在帮你串一条完整的线:底层有"方法地址"这个锚点,语法层面有"类型"这个容器,实例化层面有"包装"这个动作,调用层面有"调用列表"这个机制,设计层面有"行为参数化"这个目的。五条线拧成一股绳,你再看到Func、Action、事件、回调、LINQ表达式,看到的不再是孤立的语法糖,而是同一套思想在不同场景下的表现形式。
最后分享一个我自己很受用的小技巧:当你拿不准一个地方该不该用委托时,试着把自己想象成框架的作者。你正在设计一个库,这个库需要允许使用你库的人"注入一段行为",又不想让他们改动你的核心代码——这个注入的入口,就是委托该出现的地方。这个方法帮我做了无数次设计决策,到现在依然管用。