C# 的委托与事件:Delegate、Action、Func 与 event 是什么关系?
从 delegate 到 Action/Func,再到 event 的访问限制——并拆开 Func 的源码定义、16 个参数重载,以及泛型类型参数上的 in/out 变型。
Unity 脚本里经常能看到:
public event Action<int> AmmoChanged;同一文件里又可能有 Func<string, bool>、Lambda、自定义 delegate。初看像四套互不相关的语法,读文档时还会撞上 Func<in T, out TResult>——这里的 in / out 修饰的是泛型类型参数,与方法的 ref / out 形参、与「返回值」这个词本身都不是一回事。
这篇文章想回答一串连贯的问题:
- 委托、Action、Func、event 到底是什么关系?
Func在 .NET 源码里怎么定义的?为什么能写到 16 个参数?- 为什么
TResult总在最后一个类型参数位置?能不能自己定义MyFunc,把TResult放在第一位?
如果你熟悉 C++,可以先把 delegate 想成「类型安全、可绑定多个目标的多播函数指针」。C# 1.0 就有委托与事件;C# 3.0 的 Lambda 让这套写法日常化——这和 C# 版本史 里 LINQ 与函数式混合的脉络是同一条线。
一张地图:由内向外的一条主线
全文主线按上表由语言核心向外围写:先有 delegate 机制,再在 BCL 里封装成 Action/Func,再创建可调用实例,最后用 event 限制谁能触发。
| 环节 | 它是什么 |
|---|---|
| Delegate | 语言里的函数类型 |
| Action / Func | BCL1 预写好的泛型 delegate |
| Lambda / 方法引用 | 创建委托实例的写法 |
| event | 套在委托字段外的访问壳:外部只能 += / -= |
别和「深入 BCL 实现」混成一条层轴
后文有一节 深入:Func 在 BCL 里如何定义,是沿实现方向往下看——打开 Function.cs,看 Func 无非是一组 delegate 声明。它不属于上面主线的外围环节。
若把主线理解成「越往后越外围」,delegate 最核心、event 最外层,逻辑是自洽的。若在同一套编号里插入「拆源码」,会像「突然又钻回内核」——正是因为把两条阅读路径叠进了同一编号。下文已拆开:主线只走由内向外;实现细节单独成节。
delegate:语言机制
平时直接调用:
void Shoot(int damage) => Console.WriteLine($"Damage: {damage}");
Shoot(10);委托先声明一种签名类型,再把具体方法赋进去:
delegate void AttackHandler(int damage);
AttackHandler attack = Shoot;
attack(10);AttackHandler 表示:任何「接收 int、返回 void」的方法都能塞进来。而且 delegate 原生多播——+= / -= 可以挂多个目标,一次 Invoke 依次调用。2
void Shoot(int damage) { }
void Explode(int damage) { }
AttackHandler attack = Shoot;
attack += Explode;
attack(10); // Shoot,然后 ExplodeAction 与 Func:BCL 里的预置类型
每次签名都手写 delegate 很啰嗦。.NET 在 BCL(Base Class Library,基础类库,即 System.* 等标准 API)里预定义了两族泛型委托。31
Action — 返回类型为 void,类型参数列表全部对应形参:
Action // () → void
Action<int> // (int) → void
Action<int, string> // (int, string) → voidFunc — 多一个结果类型参数 TResult,且有一条 BCL 约定:
Func的最后一个泛型类型参数固定表示委托的返回类型,记为TResult;前面的类型参数依次对应形参。
Func<int, int, float> divide = (a, b) => (float)a / b;
// 形参 (int, int),返回 floatAction<T1, T2, ...> → (T1, T2, ...) → void
Func<T1, T2, ..., TResult> → (T1, T2, ...) → TResultAction 和 Func 不是与 delegate 并列的新机制——它们自己就是 delegate 声明。 差别只在 BCL 已经帮你写好了名字和 arity(类型参数个数)。
Predicate 呢?
Predicate<T> 就是 (T) → bool 的专用 delegate,常见于 List<T>.FindAll。今天完全可以写成 Func<T, bool>,只是老 API 保留了单独类型名。
Lambda:创建委托实例
Lambda 不改变类型系统,只是给委托提供实现:
Func<int, int, int> add = (a, b) => a + b;Unity 里:
button.onClick += () => Debug.Log("Clicked");编译器生成 delegate 实例(或在 LINQ 场景生成表达式树)。事件订阅里几乎总是前者。
event:给委托字段加访问封装
问题从哪来
如果公开普通字段:
public Action OnDeath;外部能 +=,也能 = Foo 覆盖所有人,甚至 OnDeath?.Invoke() 自己触发——发布者边界被破坏。4
event 做了什么
public class Player
{
public event Action OnDeath;
public void Die() => OnDeath?.Invoke();
}外部只能 += / -=;只有 Player 能 Invoke。这就是「某事发生了,感兴趣的人来订阅」的 发布-订阅 模型:
public class Player
{
public event Action<int> HealthChanged;
public event Action Died;
private int health;
public void TakeDamage(int damage)
{
health -= damage;
HealthChanged?.Invoke(health);
if (health <= 0) Died?.Invoke();
}
}
player.HealthChanged += UpdateHealthBar;
player.Died += ShowGameOver;
player.Died += PlayDeathSound;Player 不必认识 UI、音效、掉落系统——它只负责在正确的时机 Invoke。Unity 的 Button.onClick、SceneManager.sceneLoaded 同理。运行时差异(Mono / IL2CPP / CoreCLR)见 Unity 的 C# 运行时,委托与 event 的语义一致。
event ≠ 线程安全
event 限制的是访问方式,不自动保证多线程下订阅列表安全。跨线程触发时仍可能需要复制委托快照;Unity 主线程事件通常不用纠结。
深入:Func 在 BCL 里如何定义
本节属于实现剖面,接在 Action/Func 之后读即可:你已经会用
Func<int, string>,这里回答「库里到底声明了什么」。
源码里就是一组 delegate
打开 .NET 源码 Function.cs,Func 不是魔法语法,而是一组普通的泛型 delegate 声明。5
namespace System
{
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T, out TResult>(T arg);
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);
// ……一直写到 16 个形参类型参数 ……
public delegate TResult Func<
in T1, in T2, /* … */ in T16,
out TResult
>( /* T1 arg1 … T16 arg16 */ );
}因此 Func<int, string> 对应其中两个泛型类型参数的一版:T = int(形参类型),TResult = string(委托的返回类型):
public delegate TResult Func<in T, out TResult>(T arg);你写的:
Func<int, string> f = n => n.ToString();等价于:持有一个「int 形参 → string 返回类型」的 System.Func<,> 实例。
为什么能「支持那么多参数」?
不是因为 C# 有可变参数泛型(没有),而是因为 BCL 手工写了 17 个重载:
| 类型 | 形参类型参数个数 | 示例 |
|---|---|---|
Func<TResult> | 0 | Func<string>(无参,返回 string) |
Func<T, TResult> | 1 | Func<int, string> |
| … | … | … |
Func<T1,…,T16, TResult> | 16 | 极少手写,但 LINQ / 表达式树场景会碰到 |
Action 同理:Action 到 Action<T1,…,T16>,共 17 个。6
16 是框架作者的实用主义上限。真需要 17 个以上形参,通常应改传对象或 struct,而不是继续堆类型参数。
Func 不建模 ref / out 形参
BCL 的 Func / Action 只描述按值传递的形参列表。方法本身可以有 ref / out 形参,但那种签名要自定义 delegate,不能指望 Func 族直接表达。
这与下文 in / out 泛型变型修饰符 无关——后者修饰的是类型参数,不是形参传递方式。
泛型类型参数上的 in / out(协变与逆变)
源码里写成 Func<in T, out TResult>。这里的 in / out 修饰泛型类型参数,与方法的 ref / out 形参无关。7
要点只有一句:in / out 不影响委托怎么调用,只影响不同泛型实例之间能否赋值。
| 写法 | 含义 |
|---|---|
in T | T 只出现在形参等输入位置 → 允许逆变赋值 |
out TResult | TResult 只出现在返回类型等输出位置 → 允许协变赋值 |
记忆:in = 消费(Consumer),out = 生产(Producer)。
没有 in / out:泛型不变
public delegate TResult MyFunc<T, TResult>(T arg);
MyFunc<Animal, Cat> f1 = animal => new Cat();
MyFunc<Cat, Animal> f2 = f1; // ❌ 无 in/out,不能转换委托照常 Invoke,只是 MyFunc<Animal, Cat> 与 MyFunc<Cat, Animal> 被视为完全不同的类型。
Func 的变型规则
BCL 声明:
public delegate TResult Func<in T, out TResult>(T arg);Cat : Animal 时:
// 形参逆变:实现能收更宽的类型,可赋给要求更窄形参的槽位
Func<Animal, int> f1 = a => a.GetHashCode();
Func<Cat, int> f2 = f1; // ✅
// TResult 协变:实现返回更具体的类型,可赋给要求更宽返回类型的槽位
Func<int, Cat> f3 = n => new Cat();
Func<int, Animal> f4 = f3; // ✅
// 两个方向同时成立
Func<Animal, Cat> f5 = animal => new Cat();
Func<Cat, Animal> f6 = f5; // ✅用 is-a 理解 f6 = f5
Cat is an Animal。f6 = f5 合法,是因为 f6 的合法调用都可以交给 f5:
Cat cat = new Cat();
Animal ret = f6(cat);
// 等价于 ret = f5(cat);
// 实参:Cat → Animal 形参 → is-a ✅
// 返回:Cat → Animal 变量 → is-a ✅反例(编译失败):
Func<Cat, Cat> narrowIn = c => new Cat();
Func<Animal, Cat> wideIn = narrowIn; // ❌ 形参:窄不能替换宽
Func<int, Animal> wideOut = n => new Animal();
Func<int, Cat> narrowOut = wideOut; // ❌ 返回:宽不能替换窄口诀:形参类型可以更宽,返回类型可以更窄。
in / out 写反了:声明非法
public delegate TResult BadFunc<out T, in TResult>(T arg); // ❌out T 不能出现在形参位置,in TResult 不能作为返回类型——编译器直接拒绝。
能不能定义 MyFunc,把 TResult 放在第一位?
能。 TResult 写在最后是 BCL 的类型参数命名约定,不是语言限制:
public delegate TResult MyFunc<out TResult, in T>(T arg);
MyFunc<string, int> f = n => n.ToString();
// TResult=string,形参类型 T=int与 BCL 对比:
Func<int, string> g = n => n.ToString();
// 形参 int,TResult string两者描述同一签名 (int) → string,只是泛型类型参数的书写顺序不同。你无法修改 System.Func——LINQ、TPL 等生态都认「最后一个类型参数是 TResult」。自定义 MyFunc 只适合团队内部 DSL。
Microsoft 把 TResult 放在末尾的原因:
- 与
Action成对:Action<T1,T2>的类型参数全是形参;Func仅在末尾追加TResult。 - 读类型像读调用:
Func<int, int, float>从左到右对应f(a, b)的实参,最后是结果类型。 - 互操作成本:全生态已围绕此约定建立错误信息与文档,改顺序收益很小。
实战:怎么选类型?
| 场景 | 推荐 |
|---|---|
| 无返回值、形参简单 | Action / event Action |
| 有返回值、形参简单 | Func<…, TResult> |
| 形参多、业务语义强 | 自定义 delegate + 有意义的名字 |
| 对接 WinForms / 传统 .NET 事件 | EventHandler / EventHandler<TEventArgs> 8 |
public event Action<int> HealthChanged;
public delegate void DamageHandler(Player p, int damage, DamageType type);
public event DamageHandler Damaged;
public event EventHandler<DamageEventArgs> DamagedClassic;读代码时的人话翻译
public event Action<int> AmmoChanged;→ 公开事件;可订阅/取消;触发时带 int;只有声明类能 Invoke。
Func<string, bool> filter = path => File.Exists(path);→ filter 是「string 形参 → bool 返回类型」的委托实例;Lambda 是实现。
Func<object, string> f = obj => obj.ToString();
Func<string, string> g = f;→ 形参类型参数 T 的逆变:Func<object, …> 可赋给 Func<string, …>。
Delegate、Action、Func、Lambda、event 不是五套机制,而是同一套「函数作为一等公民」在不同环节的名字:类型(delegate / Action / Func)→ 实例(Lambda)→ 访问规则(event)。搞清这条链之后,Unity 脚本和 .NET 类库会少很多「这是哪种魔法」的错觉。
带走三句
- 类型:
delegate/Action/Func— 描述签名;Func末位类型参数是TResult。 - 实例:方法、Lambda、
+=— 真正可调用的对象。 - 事件:
event— 限制谁能Invoke,而不是换一种 delegate。
注释
-
Microsoft Learn,Base Class Library。BCL 是随 .NET 发布的标准类库(
System.*等),与 CLR(运行时引擎)并列;Action/Func 定义在System命名空间。 ↩ ↩2 -
Microsoft Learn,Delegates (C# Programming Guide)。说明 delegate 类型及多播委托的
+=/-=组合调用行为。 ↩ -
Microsoft Learn,
Action<T>委托文档 与Func<T,TResult>委托文档。Action为无返回值委托;Func最后一个类型参数为TResult。 ↩ -
Microsoft Learn,Introduction to events。说明
event关键字对委托字段的封装,以及发布者/订阅者模型。 ↩ -
.NET Runtime,
Function.cs。Func族全部 delegate 声明的源码位置。 ↩ -
Microsoft Learn,Lambda expressions in PLINQ and TPL。说明 .NET 预定义 17 个
Func与 17 个Action重载(0 到 16 个形参类型参数)。 ↩ -
Microsoft Learn,Covariance and Contravariance in Generics。说明类型参数的输入/输出位置及
in/out变型规则;C# 4.0 语言背景见本站 C# 版本史。 ↩ -
Microsoft Learn,Handling and raising events。说明
EventHandler/EventHandler<TEventArgs>与EventArgs派生类在 .NET 事件模式中的用法。 ↩