ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C#委托实战:Action与Func的原理、用法与避坑指南

C#委托实战:Action与Func的原理、用法与避坑指南 说句实在话我刚开始学 C# 委托那会儿对Action和Func有点看不上眼总觉得这俩就是偷懒用的语法糖。直到后来做一个设备数据采集模块接口换来换去、回调嵌套一层又一层才发现内置委托几乎撑起了整个项目的可扩展性。Action和Func不是什么高深概念但用得好不好直接决定代码是“写死”还是“可以插拔”。这篇文章我把这两个类型从原理到实战讲透包括我在生产环境里踩过的闭包、异步和生命周期相关的坑适合刚接触 C# 委托的新人也适合那些天天写业务但没时间深究底层机制的开发者。1. 从自定义 delegate 说起Action 和 Func 到底替代了什么要说清楚Action和Func绕不开委托。很多人学委托时被绕晕是因为先接触了一堆自定义委托声明的语法。其实委托的本质就一句话方法本身也是可以当作参数传递的数据。回调、策略、事件底层全是这个机制。1.1 委托的本质方法引用也是一等公民在 .NET 里委托类型代表的是“一组特定签名的方法引用”。你声明一个委托类型就是在定义“允许哪些方法被装进来”。早年写代码每需要一个签名就得声明一次public delegate void ProgressCallback(int percent); public delegate decimal DiscountCalculator(decimal price, int level);定义完类型还要写一个匹配的方法再把它传来传去。如果一个系统里回调很多光 delegate 声明就能写满一屏。后来 .NET 框架为了省去这种重复劳动把最常用的“有返回值”和“无返回值”两种委托形态直接内置到框架里这就是Func和Action。Action代表“没有返回值”的委托参数可以有也可以没有Func代表“有返回值”的委托最后一个泛型参数永远表示返回类型。框架还通过泛型重载把它们延伸到了 16 个参数。绝大多数业务场景根本不需要自己声明 delegate直接声明一个Func或Action字段就够了。1.2 自定义委托和内置委托的核心差异从类型层面看ActionT、FuncT,TResult和自定义委托都是MulticastDelegate的派生类型编译后的本质没有区别。差异主要体现在三个地方声明成本内置委托随用随取不用提前定义类型自定义委托要先写一行“约定”。可读性FuncOrder,int,decimal只告诉你“两个参数返回 decimal”但OrderAmountCalculator这种名字能直接表达业务语义。重载与泛型内置委托支持泛型变体和协变逆变自定义委托也可以做但默认写法更繁琐。那自定义委托是不是就没用了也不是。如果某个回调在系统里反复出现比如OnDataReceived(int deviceId, byte[] payload)给它起个名字比到处写Actionint,byte[]要清晰很多。我的习惯是一次性或少量使用的回调用内置委托语义很强且复用地点的回调自定义一个类型两者搭配使用。1.3 一张表看明白 Action 和 Func 的签名规则新手最容易搞混的就是“泛型参数到底写几个”。这里我整理一张表照着抄就行声明参数返回值Action无voidActionT1 个voidActionT1,T22 个voidFuncTResult0 个TResultFuncT,TResult1 个类型 TTResultFuncT1,T2,TResult2 个TResult规律一句话有返回类型的就是Func最后一个泛型参数是返回类型没有返回类型的就是Action泛型参数全是方法参数。看几个实际例子Action sayHello () Console.WriteLine(Hello); Actionstring print msg Console.WriteLine(msg); Funcint, int doubleIt x x * 2; Funcint, int, string add (a, b) $结果是 {a b};我见过不少初学者试图用Actionint接收一个“要返回 int 的方法”编译直接报错。这就是对“返回值靠泛型参数表达”这个规则不熟。另外注意Func至少有一个返回类型参数不能写成Func而Action可以不带泛型参数。别小看这个区别写泛型约束的时候经常用得上。2. 动手实践Action 和 Func 的基础用法与调用细节理论讲完直接上代码。这个部分我从“怎么赋值、怎么调用”开始再讲一点进阶的链式调用和与 LINQ 的配合。这些都是日常最常用的场景。2.1 声明、赋值和调用的常见姿势在 C# 里委托实例的赋值有几种等价写法。第一种是直接把一个现有方法传进去叫“方法组转换”static void LogToConsole(string msg) Console.WriteLine($[LOG] {msg}); Actionstring logAction LogToConsole; logAction.Invoke(设备启动);第二种是 Lambda 表达式这是 C# 3.0 之后最主流的写法Actionstring logAction msg Console.WriteLine($[LOG] {msg}); logAction(设备启动);第三种是匿名方法在老代码里偶尔能见到现在基本被 Lambda 取代Funcint, int square delegate (int x) { return x * x; };调用也有讲究。logAction(设备启动)和logAction.Invoke(设备启动)功能完全一样但当你处理可能为 null 的回调时建议用 null 条件操作符logAction?.Invoke(msg);这行代码的意思是如果委托不为空才调用避免NullReferenceException。在多线程环境下先判断再调用有个竞态问题判断的瞬间和真正调用的瞬间委托可能被别的线程置空。而?.Invoke在底层会先拷贝一份委托引用再调用安全性更好。这个细节我也在后面的避坑章节里细说。2.2 Func 的返回值处理与链式调用Func比Action多一个“返回值能继续参与逻辑”的能力这让它可以实现一些很漂亮的链式设计。比如柯里化把一个多参数方法拆成多个单参数方法Funcint, Funcint, int adder x y x y; Funcint, int add2 adder(2); Console.WriteLine(add2(3)); // 输出 5 Console.WriteLine(add2(10)); // 输出 12看起来有点炫技但“返回一个 Func”这个模式在策略注册和中间件设计里很实用。再比如一个简单的缓存包装输入 key 返回一个“真正计算结果”的延迟函数public Funcstring, Funcint CreateCalculator() { var cache new Dictionarystring, int(); return key () { if (cache.TryGetValue(key, out var value)) return value; var result Compute(key); cache[key] result; return result; }; }这种嵌套写法第一次看容易懵拆开看就很简单外层CreateCalculator返回一个函数这个函数接收字符串 key又返回另一个函数真正调用最内层函数时才触发计算。在实现“延迟初始化”“按需加载”这类需求时Func的返回值能力是Action替代不了的。2.3 和 LINQ 的配合哪里都有它的影子很多人其实早就在用Func只是没意识到。LINQ 的Where、Select、Any等方法参数本质就是Func。var nums new Listint { 1, 2, 3, 4, 5 }; // Where 接收 FuncTSource, bool var evens nums.Where(n n % 2 0); // Select 接收 FuncTSource, TResult var squares nums.Select(n n * n); // Any 接收 FuncTSource, bool var hasBig nums.Any(n n 3);你写的那个n n % 2 0编译器推断出来就是一个Funcint,bool。理解这一点以后你自己写一个“只能接收筛选条件”的方法也不难public IEnumerableT FilterT(IEnumerableT source, FuncT, bool predicate) { foreach (var item in source) if (predicate(item)) yield return item; }这里有个很重要的点传入的是条件逻辑函数而不是结果值bool。好处是调用方可以完全自定义规则方法本身不用知道筛选细节。这跟把bool作为参数传进去是完全不同的设计思路后者调用方只能二选一后者却有无穷多种组合。我见过不少刚从其他语言转过来的开发者习惯写一个CanPass(item)接口再加一堆实现类。在 C# 里很多场景完全没必要上接口一个FuncT,bool参数就够了。过度设计接口往往是没吃透委托的体现。3. 项目实战用 Action/Func 重构可复用模块前面说的是语法层面这一章来点真刀真枪的。我从真实项目里抽出三个模式分别对应“策略选择”“解耦通知”“通用重试”。这三个模式基本覆盖了Action和Func在生产环境的 80% 用途。3.1 用 Func 加字典做策略注册替代一长串 if-else业务系统里最常见的就是各种“按类型走不同逻辑”。最原始的做法是switch或if-else每加一种类型就要改一次方法方法越来越长。用字典加Func可以把这个过程变成“注册制”var discountStrategies new Dictionarystring, Funcdecimal, decimal { [normal] price price, [vip] price price * 0.8m, [svip] price Math.Min(price * 0.7m, price - 200m), }; public decimal ComputePrice(string customerType, decimal originalPrice) { if (discountStrategies.TryGetValue(customerType, out var strategy)) return strategy(originalPrice); throw new NotSupportedException($未支持的客户类型: {customerType}); }这样做的好处很明显第一新增策略只需要往字典里加一行第二策略逻辑内聚在注册处而不是散落在各个分支里第三将来策略如果要从数据库读取只需要把字典替换成动态配置。我实际项目里甚至把整个字典做到了构造函数注入这样外部模块可以往里面注册自定义策略等于做成了一个微型的插件机制。3.2 用 Action 做回调解耦让通信模块不依赖业务逻辑做上位机或者网络通信模块时最头疼的是“数据收到以后怎么处理”。如果模块内部直接写解析和存储逻辑那模块和业务就绑死了。用Action做回调模块只需要对外暴露一个“设置处理函数”的入口public class DataReceiver { private Actionstring _onDataReceived; private Actionstring _onError; public void SetHandlers(Actionstring onData, Actionstring onError) { _onDataReceived onData; _onError onError; } public void Start() { while (true) { try { var raw ReceiveFromDevice(); _onDataReceived?.Invoke(raw); } catch (Exception ex) { _onError?.Invoke(ex.Message); } } } }调用方可以把任何自己关心的逻辑传进来var receiver new DataReceiver(); receiver.SetHandlers( data _db.Save(data), error _logger.Error(error) );这样做的好处是模块本身对业务无感知。它不需要知道“收到数据是存数据库还是刷新界面”它只负责收数据、派发数据。如果当初我用接口来做这个解耦就得定义一个IDataHandler接口再写实现类用Action的话一个 Lambda 就搞定代码量少还直观。3.3 用 Func 写通用重试逻辑把异常处理收拢到一处开发中经常遇到“网络闪断、服务临时不可用”的情况。如果每次调用外部服务都写一遍try-catch加循环代码会非常重复。这时候利用FuncT写一个通用重试器是最高效的方案public static T RetryT(FuncT action, int maxRetries, int delayMs) { Exception last null; for (int i 0; i maxRetries; i) { try { return action(); } catch (Exception ex) { last ex; if (i maxRetries - 1) Thread.Sleep(delayMs); } } throw last; }使用方式极其简单var data Retry(() client.GetData(device-01), 3, 500);如果操作没有返回值也可以写一个Action版本的重试或者干脆把Action包装成Funcboolpublic static bool Retry(Action action, int maxRetries, int delayMs) { return Retry(() { action(); return true; }, maxRetries, delayMs); }这个模式的精髓在于逻辑变的是“要执行什么”不变的是“失败了要重试”。把不变的逻辑提取出来把变化的逻辑作为Func参数传入这就是委托在架构层面最大的价值。4. 生产环境里我在 Action/Func 上踩过的坑知识点讲完了说点踩坑经历。这些坑每一个都让我花过不少时间排查写出来也是希望大家少走弯路。4.1 闭包捕获循环变量for 循环的经典陷阱有一次我在批量注册任务发现所有任务执行的都是最后一轮的数据。排查半天原因是for循环里闭包捕获的是同一个变量而不是当前的值。看一下这个例子var actions new ListAction(); for (int i 0; i 3; i) { actions.Add(() Console.WriteLine(i)); } foreach (var action in actions) action();运行结果不是 0、1、2而是 3、3、3。原因在于编译器把i提升到了闭包类的一个字段上所有 Lambda 共享的是同一个引用循环结束以后这个字段的值是 3。解决方案是每轮循环复制一份局部变量for (int i 0; i 3; i) { int current i; actions.Add(() Console.WriteLine(current)); }值得一提的细节在 C# 5 之后foreach的循环变量在每次迭代时都会创建新的“捕获变量”所以你在foreach里直接写 Lambda 一般不会遇到这个问题。但for循环的i始终是同一个局部变量该踩的坑一个都不会少。如果你在用Task.Run、Task.Delay注册异步回调更要留意这个捕获语义。4.2 async lambda 赋给 Action 会变成 async void异常根本抓不住这是我在写一个文件上传重试器时踩到的。当时图省事写了一段类似这样的代码public void Execute(Action action) { ... } Execute(async () { await Task.Delay(100); throw new InvalidOperationException(boom); });这段代码看起来很正常但问题是Execute接收的是Action而 Lambda 体里有await编译器会把这个 Lambda 编译成async void方法。async void的异常不会传播给调用方而是直接“发射”到当前线程的同步上下文如果没有全局异常处理它甚至可能导致进程崩溃。正确做法是让回调类型用FuncTaskpublic async Task Execute(FuncTask action) { await action(); } await Execute(async () { await Task.Delay(100); throw new InvalidOperationException(boom); });这样action被编译为async Task方法外层await就能正常捕获异常。这里想提醒各位只要看到“向 Action 传 async Lambda”这个写法就要立刻停下来想一下。即便某些场景必须用Action至少也要在异步方法内部自己try-catch把异常吞掉或记录而不是指望外层帮忙处理。4.3 多播委托返回值的坑只会拿到最后一个方法的返回值委托支持多播也就是能把多个方法挂到同一个委托上。普通事件里这是好事但如果你用Func做多播返回值就麻烦了Funcint getValue () 1; getValue () 2; getValue () 3; Console.WriteLine(getValue()); // 输出 3结果只会输出最后一个绑定方法的结果前面两个方法虽然执行了但它们的返回值被丢弃了。如果你期望拿到所有结果必须手动遍历foreach (Funcint handler in getValue.GetInvocationList()) { Console.WriteLine(handler()); }这个坑容易出现在事件扩展场景。比如你在给某个类添加“多个外部模块都要处理同一数据”的功能时如果用FuncData,bool做多播以为每个模块都能反馈处理结果很可能会被“永远只看到最后一个模块的结果”给骗了。正确做法要么用GetInvocationList显式遍历要么改成IEnumerableFuncData,bool列表来存储。4.4 委托字段的生命周期忘记注销就是内存泄漏委托持有的是方法引用而方法引用又强引用目标对象。如果一个长期存活的对象持有一个委托字段而这个委托指向了一个短生命周期对象的方法那这个短生命周期对象就永远无法被垃圾回收。最常见的场景是静态事件订阅实例方法public static event EventHandler GlobalEvent; // 某个重量级对象 A GlobalEvent A.Handle; GlobalEvent B.Handle;当 A 和 B 不再需要使用时如果没人执行GlobalEvent - A.HandleA 和 B 就一直挂在全局事件里。这在桌面应用和服务端长驻进程里都是很隐蔽的内存泄漏源。我在实际项目里总结了两条纪律谁订阅谁注销。尽可能在同一个代码块里写订阅和取消订阅的逻辑。短生命周期对象不要订阅静态事件或用静态委托字段。如果不得不订可以使用弱事件模式或者至少提供Dispose方法做解除。针对多线程环境还有个细节取委托到调用委托之间可能被置空所以最稳妥的方式是先用局部变量拷贝再判空调用var handler _onDataReceived; handler?.Invoke(data);这比直接判断_onDataReceived ! null再调用更安全因为局部变量一旦取出来它的引用不会再被改变。5. 进一步挖掘泛型推断、异步版本与委托设计建议基础用法和坑都说完了最后再往前走一步。很多人会用Action和Func做简单回调但到真正设计一个库或者一个复杂模块时还是会犹豫该用具体委托还是自定义 delegate参数太多怎么样异步回调怎么设计这章我给出一些可落地的建议。5.1 方法组转换与 Lambda 的微妙差异把现成方法传给Func叫方法组转换用 Lambda 表达式写一段新逻辑两者表面上差不多但有个细节值得注意Funcint, int abs1 Math.Abs; Funcint, int abs2 x Math.Abs(x);从行为上看abs1和abs2一样从生成代码上看某些情况下方法组会被编译器缓存为同一个委托实例而 Lambda 每次执行到赋值语句时可能会生成新的委托实例具体取决于是否捕获变量。这一点在写热路径代码时会影响性能。如果你的方法组和 Lambda 出现在一个每秒执行几十万次的热循环里建议把委托声明成静态字段缓存起来private static readonly Funcint, int AbsFunc x Math.Abs(x);另外注意当方法有多个重载时方法组转换可能出现歧义。比如Math.Round有一堆重载直接写Funcdouble, double round Math.Round;会编译报错必须显式转换或加 LambdaFuncdouble, double round x Math.Round(x);这个例子虽小但很多人在写自定义控件时遇到过“方法组转换重载导致二义性”的报错知道原因后就容易解决了。5.2 参数太多怎么办封装成对象比硬塞泛型参数好Action和Func最多支持 16 个泛型参数但这不意味着你真的应该传 16 个参数。参数越多调用方越难读懂Funcint,int,string,bool,decimal,Result这种签名谁看了都头大。我在做接口设计时的建议是超过 3 个参数就封装成一个类型。C# 9 以后的record很适合干这个public record ProcessContext(string DeviceId, int Channel, string Protocol, TimeSpan Timeout); FuncProcessContext, ProcessResult processHandler ctx { // 使用 ctx.DeviceId / ctx.Channel ... return new ProcessResult(true, ok); };这样做的额外好处是新增参数时不需要修改所有调用方的 Lambda 签名只修改record定义就行。委托签名保持稳定是架构稳定的重要因素。5.3 异步场景下的委托设计默认用 Func 少用 async void如果你设计的 API 需要支持异步回调委托类型优先选择FuncTask或FuncT, Task而不是Action。原因上面已经讲过Action无法 await异步方法一旦赋给它就是async void异常行为和调度行为都不可控。具体设计可以参考这个模式public class JobRunner { public FuncJobContext, Task OnJobStarting { get; set; } public FuncJobContext, Task OnJobCompleted { get; set; } public async Task RunAsync(JobContext ctx) { if (OnJobStarting ! null) await OnJobStarting(ctx); // 执行任务... if (OnJobCompleted ! null) await OnJobCompleted(ctx); } }如果某些回调体本身不需要异步赋值方可以直接返回Task.CompletedTaskrunner.OnJobCompleted ctx { _logger.Info(job completed); return Task.CompletedTask; };这套设计的弹性在于同步和异步逻辑都能进同一个回调管道而调用方始终能正确 await 异常。我现在的项目里凡是跨层调用、模块间通信、插件接口一律优先考虑FuncTask风格的回调类型。5.4 我习惯的委托设计规范最后总结一下我写代码时的个人规范不算标准答案但至少经过了几个项目的验证优先内置委托能用Action、Func就不用自定义 delegate减少类型数量。关注语义边界同一个回调在系统中出现 3 次以上并且调用点需要体现业务含义时定义一个有名字的委托或接口。回调参数尽量少超过 3 个考虑封装对象避免阅读障碍。异步回调统一用FuncTask从 API 层杜绝async void问题。委托字段统一用?.Invoke避免判空加调用之间的竞态。注销和订阅成对出现尤其在静态事件、长生命周期对象场景必须防内存泄漏。这些规范不一定适合所有团队但能保证你的代码里Action和Func用得又快又稳。最后分享一个小技巧如果你经常写重试逻辑可以把这个模式封装成一个扩展方法放在公共库里支持Action和FuncTask两种重载这样整个团队都能直接受益。我第一次把重试器改成异步版本的时候才发现FuncTask比Action多出来的不仅仅是一个返回值而是一整套可控的异步异常流。这种体会只有自己把代码跑崩溃过才最深刻。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进