☰
C#委托全解析:delegate、Action、Func与Lambda的区别与实战
2026/10/14 6:51:52 网站建设 项目流程

C# 的委托大概是我见过“语法糖密度”最高的语言特性之一了。不少新人学委托时都有这样的困惑:delegate、Action、Func、匿名方法、Lambda,这些名词张牙舞爪地摆在面前,看起来都是“把一个方法传来传去”,但写法却五花八门,不知道到底该学哪个、该用哪个。

我最早接触 C# 委托那会儿,总觉得它就是“函数指针”的换皮版本,无非是让方法能作为参数传来传去。后来项目写多了,才意识到委托真正的价值不在于“把函数塞进变量”,而在于它把调用方式从编译期解放了出来——你的代码可以决定在什么时候、让谁、去做什么。围绕这个能力,C# 在过去二十多年里沉淀出了四种典型写法:delegate自定义委托、Func/Action内置泛型委托、匿名方法、Lambda 表达式。这篇就把这四种实现彻底拆开,讲清楚它们各自是什么、为什么要存在、以及实战中怎么选。

这篇内容适合对委托有基本印象、但想系统搞明白它们之间区别的开发者,也适合面试前想快速梳理一遍委托知识的同学。我会用同一个日志组件场景贯穿全文,四种写法轮番上阵。你看完可以直接把这套思路搬回项目里用。

1. 委托到底是什么,为什么绕不开它

1.1 委托的本质:把“行为”当数据传递

委托的底层原理其实很朴素:它定义了一种“方法的签名契约”,然后允许你把符合这个签名的方法当作对象一样保存、传递、调用。C# 里的委托是引用类型,编译器在背后会把一个delegate声明编译成一个密封类,这个类继承自MulticastDelegate,自带Invoke、BeginInvoke、GetInvocationList等方法。换句话说,你写下的每一行委托语法,最终都会被编译器翻译成一次普通的对象创建和方法调用。

我习惯用一个生活化的类比去理解委托:你订外卖,提前把“送到几号楼哪个房间”告诉商家,商家按这个描述履约。委托本质就是这种“履约描述”——你给某段逻辑一个签名,把符合签名的具体方法交给它,它负责在合适时机帮你调用。

委托解决了什么问题?最主要的是解耦。调用方不需要关心被调用的方法具体是谁、做了什么,只需要关心“这个签名能不能匹配得上”。比如一个日志组件,它不需要关心日志写到控制台还是写到文件,只关心“你给我的方法能不能接收一条字符串消息”。

1.2 委托、事件与接口的分工

委托和事件是很多新人的盲区。简单说,事件是委托的“受约束版本”:类内部可以随便调用委托,但event暴露出去后,外部只能通过+=和-=订阅/退订,不能直接赋值覆盖,也不能在类外部触发调用。这在设计公共 API 时能避免很多“订阅被误替换”的隐患。

那委托和接口又有什么区别?接口约束的是“对象实现了什么”,委托约束的是“方法长什么样”。如果你的设计目标是描述一类对象的能力,选用接口;如果只是想声明一个可传入的算法、回调或者处理器,委托明显更轻量。比如排序时传一个Comparison<T>委托,而不是定义一个ISorter接口再实现一个类。

1.3 四种实现是一条语法演化链

把四种委托实现放在一起看,其实就是一条 C# 语言自身的演进路线:delegate是 C# 1.0 的原初设计,讲究类型安全,但代码很啰嗦;C# 2.0 引入了匿名方法,允许不提前定义命名方法,直接把逻辑写到调用的地方;C# 3.0 顺势推出了 Lambda 表达式,语法更简洁,写法更接近数学意义上的“函数”;而Func和Action是 .NET 3.5 时期跟随 LINQ 一起出现的内置泛型委托,让开发者可以在绝大多数场景下不再需要自定义委托类型。

理解了这条链路,再看现在大量 C# 代码里Action<T>+ Lambda 满天飞的现象,就不会觉得是“一种风格”,而是一种合理的进化结果。

2. 第一种实现:delegate 自定义委托,最正统的写法

2.1 定义、实例化与调用

自定义委托是最原始的形态。声明一个委托类型,本质上是在声明“我要定义一种方法的形状”。看一个最典型的声明:

public delegate void LogHandler(string message);

LogHandler就是一个委托类型,它规定:凡是匹配这个类型的对象,必须是一个接收string参数、返回值为void的方法。普通方法可以匹配它,Lambda 可以匹配它,匿名方法也可以匹配它。接下来定义两个普通方法,方便后面做对比:

public static class Logger { public static void WriteToConsole(string message) { Console.WriteLine($"[控制台] {message}"); } public static void WriteToFile(string message) { // 实际项目中这里是写文件逻辑,这里用控制台代替 Console.WriteLine($"[文件] {message}"); } }

把方法“塞”进委托变量有三种等价写法:

// 方式一:构造函数实例化 LogHandler log = new LogHandler(Logger.WriteToConsole); // 方式二:方法组直接转换(C# 2.0 开始支持) LogHandler log2 = Logger.WriteToFile; // 方式三:赋值之后再调用 log2 += Logger.WriteToConsole; log2("订单已支付");

第二次赋值用+=,是因为委托默认是多播的。多播委托内部维护了一个调用列表,+=是把新方法追加到列表尾部,-=则是从列表移除。调用时按追加顺序依次执行,这一点在后面排查大坑时会再次提到。

2.2 泛型委托与协变逆变

自定义委托也可以带泛型参数。比如我需要一种能接收任意类型、返回任意类型的方法形状,定义成delegate T Transformer<T>(T input)就很合适。微软官方文档里那个非常经典的Transformer例子,说的就是这种泛型委托的用法。

自定义委托还支持协变和逆变,这两个概念听起来吓人,其实要点就两条:

  • 返回值类型可以变得更具体(协变),我声明的委托返回object,实际可以绑定一个返回string的方法;
  • 参数类型可以变得更抽象(逆变),我声明的委托接收string参数,实际可以绑定一个接收object参数的方法。

这在处理继承关系时很管用。比如你有一个只处理object的方法,而你的委托声明只接收string,逆变规则允许直接完成方法组转换,编译器不会报错。

2.3 自定义 delegate 的使用场景

既然现在有Func和Action,那自定义 delegate 是不是就该被抛弃了?我的看法是:恰恰相反,某些地方必须用它。

最典型的场景是定义“有领域语义”的委托。Func<string, bool>这种签名在业务代码里几乎什么都能解释:它可能是“字符串是否合法”,也可能是“用户是否有权限”,单看签名完全读不出业务含义。但如果你定义delegate bool ValidateUser(string userName),别人阅读代码时就能从委托类型名称里直接读出意图。框架 API 设计、领域事件定义,这些场景我都更倾向于使用自定义 delegate。

另外,给委托附加 Attribute、写 XML 注释这些需求也只有在自定义委托类型上才能做。用Func和Action表达通用回调是短平快,表达特定业务契约则是自定义 delegate 更胜一筹。

3. 第二种实现:Func 与 Action,让委托变简单

3.1 对应关系与基本用法

Func和Action是 .NET Framework 3.5 开始内置的泛型委托。区分方法特别简单:有返回值用Func,无返回值用Action。Func的最后一个泛型参数代表返回值类型,前面的泛型参数代表入参类型。

// 接收两个 int 参数,返回一个 int Func<int, int, int> add = (a, b) => a + b; // 接收一个 string 参数,无返回值 Action<string> print = Console.WriteLine; // 无参数、无返回值 Action notify = () => Console.WriteLine("通知已发送");

看到这段代码,你应该已经发现了一个关键事实:LogHandler自定义委托和Action<string>本质上表示的是同一种方法签名。Action<string>可以直接接收Logger.WriteToConsole这个普通方法,也可以接 Lambda。这也是为什么很多现代 C# 代码里,你几乎看不到自定义委托声明——直接用Action和Func就能覆盖绝大多数回调需求。

3.2 用 Func / Action 重构一个日志组件

回到日志组件场景。如果我想让日志的“输出目标”可配置,没必要自己声明LogHandler,直接用Action<string>就行:

public class LogManager { private readonly Action<string> _writer; public LogManager(Action<string> writer) { _writer = writer; } public void Info(string message) { _writer($"[INFO] {DateTime.Now:HH:mm:ss} {message}"); } }

调用方想往控制台写就传控制台方法,想存文件就传文件方法:

var consoleLogger = new LogManager(Logger.WriteToConsole); var fileLogger = new LogManager(Logger.WriteToFile); consoleLogger.Info("订单状态更新"); fileLogger.Info("订单状态更新");

以前用delegate自定义类型时必须先声明public delegate void LogHandler(string message);再传给构造函数,现在一个Action<string>全部搞定,类型信息依然安全,代码量少了三分之一。这正是Func/Action存在的意义:对于通用的、不带领域特殊语义的委托签名,不需要开发者重复造轮子。

3.3 内置委托的几个使用细节

我补充两个使用细节。第一,Func和Action都支持最多 16 个入参,业务上完全够用;真遇到 16 个以上参数的设计,先反思一下是不是该传一个对象了。第二,.NET 里其实还内置了一批带语义名称的委托,比如Predicate<T>(返回bool的检查逻辑)、Comparison<T>(比较两个对象返回int)、Converter<TInput, TOutput>(类型转换)。它们本质上是自定义委托的官方“预定义版本”,语义明确,能用的时候就别用裸的Func<T, bool>。

还要注意面向大型项目的选型问题。如果在一个团队里,人人都在用Func<string, bool>表达“校验逻辑”,代码里到处都是雷同签名,可读性会急剧下降。这种情况下我更建议定义语义化委托,把参数命名和返回含义固化下来。Func和Action适合的是“链路上的就地处理”,而不是长期沉淀的公共契约。

4. 第三种实现:匿名方法,C# 2.0 时代的过渡产物

4.1 基本形态

匿名方法在语法上使用delegate关键字,但它不定义一个委托类型,而是直接创建一个委托实例。看例子:

Action<string> log = delegate (string message) { Console.WriteLine($"[匿名] {message}"); };

如果你不关心参数,还能把参数列表整个省略:

Action tick = delegate { Console.WriteLine("定时任务执行了"); };

这里要注意:省略参数列表时,编译器要求该匿名方法不能使用任何参数,这是语法规则,否则就会报编译错误。匿名方法同样支持返回值:

Func<int, int, int> add = delegate (int a, int b) { return a + b; };

4.2 匿名方法还有什么存在价值

说实话,自从 C# 3.0 有了 Lambda,匿名方法在绝大多数场景下已经被 Lambda 替代了。delegate (string message) { ... }和message => { ... }功能几乎完全重合,后者写起来还更短。但我还是见过几个场景坚持用匿名方法:

第一个场景是真的“不需要参数”。Lambda 表达式的参数列表不能凭空省略,你得写_ =>或() =>;而匿名方法直接写delegate俩字就完事了,视觉上更干净。

第二个场景是刻意区分“方法定义”和“表达式计算”。个别团队认为delegate关键字能让人一眼看出这里创建的是一个“可执行代码块”,而 Lambda 表达式长得太像数学表达式了,容易让人忽略它是一个会被延迟执行的方法。这个理由不算硬,但在跨团队协作时它确实能降低认知负担。

学习匿名方法最大的价值是理解 C# 语法演化史。它和 Lambda 使用的底层机制、闭包捕获规则几乎一样,读老项目时看到delegate开头的代码你不至于蒙圈,也更容易理解“语法糖”到底是怎么炼成的。

4.3 匿名方法与委托类型的推断

匿名方法必须显式转换成委托类型才能使用,编译器不会自动把delegate { ... }推断为某个类型。所以你不能写var log = delegate { ... };,必须写Action log = delegate { ... };。这一点和 Lambda 不同——C# 10 之后 Lambda 在某些情况下可以被推断出自然类型,支持var,但匿名方法一直没有这个待遇。

判断是否使用匿名方法,我的标准只有一条:这段代码有没有可能被 Lambda 表达得更清楚?如果只是“不想用参数”,匿名方法可以接受;如果是正常逻辑处理,我直接用 Lambda。

5. 第四种实现:Lambda 表达式,现代 C# 的默认选择

5.1 表达式 Lambda、语句 Lambda 与 async Lambda

Lambda 是匿名方法的语法升级版,用=>替代了delegate关键字,写法更直奔主题。它的三种常见形态各有适用场景。

表达式 Lambda,整个方法体只有一个表达式,编译器会直接返回表达式结果:

Func<int, int, int> add = (a, b) => a + b;

语句 Lambda,方法体是一对花括号,里面可以有赋值、判断、循环、多处返回:

Func<int, int, int> compare = (a, b) => { if (a > b) return 1; if (a < b) return -1; return 0; };

还有 async Lambda,方法体里包含异步操作:

Func<Task<int>> fetchData = async () => { await Task.Delay(100); return 42; };

Lambda 的另一个优势是类型推断能力更强。方法组转换成委托时需要目标类型,Lambda 表达式则可以在很多场合直接让编译器推断出“该委托类型是什么”,不用写在变量声明右侧,代码当然就简洁了。

5.2 闭包捕获与变量生命周期

Lambda 的一个超级重要特性是:它可以捕获外层局部变量,并且在 Lambda 被调用时读取这些变量的最新值,而不是定义时的快照。这个机制叫闭包。结合刚才的外卖例子,你下单时刻点了一份“加辣”,如果这个要求是在烹饪时才读取的话,那就是闭包——任何时候调用都拿到最新状态。

var name = "测试订单"; Action greet = () => Console.WriteLine($"你好,{name}"); name = "正式订单"; greet(); // 输出:你好,正式订单

闭包很强大,但也引出了一连串经典的坑,比如 for 循环捕获问题,我放到后面的“大坑与排查”部分详细展开。这里先记住一个底层事实:编译器会为包含闭包的 Lambda 生成一个隐藏的“显示类”(名字一般形如<>c__DisplayClass0_0),被捕获的变量被提升为这个类的字段,所以 Lambda 才能始终访问到最新值。这个类名在调试堆栈里经常出现,看到它就知道这里有闭包捕获。

5.3 新版本 C# 对 Lambda 的持续增强

Lambda 这些年一直在进化。C# 10 给了 Lambda“自然类型”,编译器能够从 Lambda 表达式本身推断出它能转换成的委托类型,于是有了这样的写法:

var parse = (string s) => int.Parse(s);

不过var推断出的其实是一个底层生成的委托类型,如果你需要明确把它当成Func<string, int>用,建议还是显式声明,避免类型行为与预期不一致。

C# 12 进一步支持了 Lambda 的默认参数和ref/out/params参数。举个例子:

var add = (int a, int b = 1) => a + b; Console.WriteLine(add(5)); // 6

这些新特性让 Lambda 的功能边界越来越广,也让它越来越像一个小巧的原生函数定义。但我的建议是:新特性只有在真正提升可读性时才去用它,别为了秀语法把代码写得只有你能看懂。

6. 四种委托实现横向对比与选型建议

6.1 一行代码看四种写法的等价性

用一个完全相同的行为来对比四种写法,你会瞬间看清它们的异同。下面四段代码都实现同一个逻辑:接收字符串消息,控制台输出,无返回值。

// 1. 自定义 delegate + 命名方法 public delegate void LogHandler(string message); LogHandler log1 = Logger.WriteToConsole; log1("第一条"); // 2. Action 内置泛型委托 + 命名方法 Action<string> log2 = Logger.WriteToConsole; log2("第二条"); // 3. 匿名方法 Action<string> log3 = delegate (string msg) { Console.WriteLine(msg); }; log3("第三条"); // 4. Lambda 表达式 Action<string> log4 = msg => Console.WriteLine(msg); log4("第四条");

四种方式,底层最终都会编译成委托对象,运行时行为几乎一致。区别在于声明阶段的心智负担和代码可读性。这个等价性对照表可以帮你理解为什么说 Lambda 是“语法糖”,而 delegate 是“本质”。

6.2 选型建议:按场景,不按资历

我把自己的选型习惯整理成一个简单的决策思路,各位可以直接当成 checklist:

场景推荐写法原因
通用回调、局部处理、LINQ 链式调用Func/Action+ Lambda代码短、可读性好、无需额外类型声明
领域语义强、公共 API 签名自定义 delegate委托类型名能表达业务含义
不关心参数、只是执行一段逻辑匿名方法delegate { }省去 Lambda 的参数占位符
事件订阅与发布自定义 delegate +event受约束的委托行为更安全
通用语义的断言/比较/转换Predicate<T>、Comparison<T>等内置委托语义明确,避免重复定义

需要特别强调一点:选型标准不是“代码越短越好”。在维护一个大型项目时,代码的意图清晰度远比行数重要。Func<string, bool>六个字符确实比delegate bool Validate(string)短得多,但如果这个委托在项目里被反复使用,后者的命名价值就很明显了。

6.3 为什么说 Func 和 Action 不能完全替代自定义委托

有人会问:既然Func和Action能覆盖所有委托签名,那自定义 delegate 是不是应该被淘汰?答案是否定的。第一,自定义 delegate 支持在类型上附加语义信息,这是Func做不到的。第二,Func和Action只是代表了“某种签名”,而自定义 delegate 可以承载领域概念。第三,在代码审查时,一个叫OrderProcessedHandler的委托和一个Action<int, string>的签名相比,前者只需要看名字就能猜出用途,后者还得结合上下文分析。

框架设计里也有一个考量的点:如果你的 API 暴露给其他团队使用,一个自定义委托类型能让对方的 IntelliSense 提示更友好,方法注释、参数提示都能挂在委托类型上。而Func和Action的提示只能让调用者看到参数类型,语义全部丢失。

7. 委托实战:5 个极易踩的深坑与排查实录

7.1 for 循环里的闭包陷阱

这是我见过出现频率最高的坑。用for循环创建一组 Lambda,期望每个 Lambda 保存当时的循环变量,结果运行时全变成同一个值:

var actions = new List<Action>(); for (var i = 0; i < 3; i++) { actions.Add(() => Console.Write(i + " ")); } foreach (var action in actions) action(); // 实际输出:3 3 3 // 期望输出:0 1 2

原因就是闭包捕获的是变量i本身,而不是i当时的值。循环三次后i已经变成 3,三个 Lambda 读到的自然都是 3。解决办法是在循环体内复制一个局部变量:

for (var i = 0; i < 3; i++) { var current = i; actions.Add(() => Console.Write(current + " ")); }

尤其要注意版本的差异:foreach的迭代变量在 C# 5 之后每次循环都会生成新变量,所以foreach闭包陷阱在新版本运行时已经不存在了;但for循环没有这个待遇,必须手动拷贝。排查闭包问题,第一步永远是确认捕获的是变量还是值。

7.2 多播委托中途异常导致后续调用中断

多播委托的调用列表是按顺序执行的,但它不是“容错队列”。如果第 2 个方法抛了异常,第 3、第 4 个方法都不会执行。这在事件系统中尤其危险:一个订阅方出错,可能让后面所有订阅方都不知道事件发生了。

Action handlers = () => Console.WriteLine("处理器 1"); handlers += () => throw new InvalidOperationException("处理器 2 挂了"); handlers += () => Console.WriteLine("处理器 3"); // 永远不会执行

想做到“一个挂了不影响其他”,需要手动遍历调用列表并逐个捕获异常:

foreach (var handler in handlers.GetInvocationList()) { try { handler.DynamicInvoke(); } catch (Exception ex) { Console.WriteLine($"处理器异常:{ex.Message}"); } }

这个技巧在事件总线、消息分发这类骨架代码里非常关键。普通业务里你用+=挂三五个处理器问题不大,但如果是公共组件,强烈建议在调用层做好隔离。

7.3 事件订阅后不取消导致内存泄漏

事件是委托的封装形式,但它有一个隐蔽的内存隐患:订阅者对象会被事件源对象强引用。一个长生命周期的服务订阅了一个短生命周期对象的静态事件,而这个对象没有在生命周期结束时退订,垃圾回收就永远认为它还被引用着,内存泄漏就发生了。

service.ProgressChanged += OnProgressChanged; // 长生命周期服务的事件

排查思路是:在短生命周期对象的析构逻辑里对称地取消订阅:

service.ProgressChanged -= OnProgressChanged;

在 WPF/WinForms 里,长时间运行页面挂后台事件没退订,内存蹲点会非常头疼。委托虽小,生命周期管理一点都不能马虎。

7.4 async void Lambda 的异常捕获问题

Lambda 用在事件处理时有个很隐蔽的坑。如果事件委托类型的签名是void Handler(object sender, EventArgs e),你把一个asyncLambda 传进去,编译器会把它转换成async void方法。这时方法体内的异常不会通过正常的调用链冒泡到调用方,而是直接被扔进同步上下文,异常捕获的 try-catch 完全失效:

button_Click += async (sender, args) => { await Task.Delay(100); throw new InvalidOperationException("这个异常很难捕获"); };

如果你的 Lambda 本身需要返回Task<int>这类异步结果,就用Func<Task<int>>;如果是事件处理器,至少要了解async void的异常行为,在里面适当加入统一异常处理兜底。对公共回调接口,能设计成Func<Task>就设计成Func<Task>,不要轻易用async void。

7.5 滥用 public delegate 字段而非 event

一个常见的设计失误是把委托字段直接设为public:

public LogHandler Log; // 不好的示范

这个字段可以被外部直接赋值覆盖,之前订阅的方法全被清掉:

logger.Log = Logger.WriteToConsole; // 外部直接替换,之前订阅没了

改用事件声明后,外部只能+=/-=:

public event LogHandler Log;

事件更安全,还顺带获得了编译器生成的线程安全添加/移除逻辑。委托字段能随便写,但事件不能;对外暴露订阅能力时,优先用事件。

8. 最后分享一点我自己摸索出的经验

四种委托实现,表面是四种语法,实际上是 C# 语言做减法的过程:从自定义 delegate 的类型安全、到Func/Action的免定义、再到匿名方法的内联化、最后是 Lambda 的极简表达。每次演化的目的都一个:让“把行为传给别人”这个动作更顺手,同时不丢类型安全。理解这个演进逻辑,比死记四种写法重要得多。

我个人在实际写代码时的习惯是:局部逻辑一律 Lambda,参数列表超过两个就停下来想想是否要定义一个语义化的委托;对外 API 一律优先用自定义 delegate 或内置语义委托;事件一律用event关键字包装。坚持这三条,团队的代码既不会失去现代 C# 的简洁,也不会沦为到处是裸Func的“匿名地狱”。

如果你在学完这四种写法之后还想继续深入,可以去看一下表达式树Expression<TDelegate>。它的语法和 Lambda 几乎一模一样,但行为完全不同——一个是直接生成可执行代码,一个是生成描述“代码长什么样”的数据结构。理解这一点以后,你对 C# 里“语法糖”这个概念的认知,又会上升一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询