Elytra
Blog2026

C# 的委托与事件:Delegate、Action、Func 与 event 是什么关系?

从 delegate 到 Action/Func,再到 event 的访问限制——并拆开 Func 的源码定义、16 个参数重载,以及泛型类型参数上的 in/out 变型。

教程
GPT 5.6+1
csharpdotnetdelegateeventunity

Unity 脚本里经常能看到:

public event Action<int> AmmoChanged;

同一文件里又可能有 Func<string, bool>、Lambda、自定义 delegate。初看像四套互不相关的语法,读文档时还会撞上 Func<in T, out TResult>——这里的 in / out 修饰的是泛型类型参数,与方法的 ref / out 形参、与「返回值」这个词本身都不是一回事。

这篇文章想回答一串连贯的问题:

  1. 委托、Action、Func、event 到底是什么关系?
  2. Func 在 .NET 源码里怎么定义的?为什么能写到 16 个参数?
  3. 为什么 TResult 总在最后一个类型参数位置?能不能自己定义 MyFunc,把 TResult 放在第一位?

如果你熟悉 C++,可以先把 delegate 想成「类型安全、可绑定多个目标的多播函数指针」。C# 1.0 就有委托与事件;C# 3.0 的 Lambda 让这套写法日常化——这和 C# 版本史 里 LINQ 与函数式混合的脉络是同一条线。

一张地图:由内向外的一条主线

delegate 机制 Action / Func Lambda 实例 event 封装

全文主线按上表由语言核心向外围写:先有 delegate 机制,再在 BCL 里封装成 Action/Func,再创建可调用实例,最后用 event 限制谁能触发。

环节它是什么
Delegate语言里的函数类型
Action / FuncBCL1 预写好的泛型 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,然后 Explode

Action 与 Func:BCL 里的预置类型

每次签名都手写 delegate 很啰嗦。.NET 在 BCL(Base Class Library,基础类库,即 System.* 等标准 API)里预定义了两族泛型委托。31

Action — 返回类型为 void,类型参数列表全部对应形参:

Action                          // () → void
Action<int>                     // (int) → void
Action<int, string>             // (int, string) → void

Func — 多一个结果类型参数 TResult,且有一条 BCL 约定:

Func 的最后一个泛型类型参数固定表示委托的返回类型,记为 TResult;前面的类型参数依次对应形参。

Func<int, int, float> divide = (a, b) => (float)a / b;
// 形参 (int, int),返回 float
Action<T1, T2, ...>           →  (T1, T2, ...) → void
Func<T1, T2, ..., TResult>    →  (T1, T2, ...) → TResult

Action 和 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.onClickSceneManager.sceneLoaded 同理。运行时差异(Mono / IL2CPP / CoreCLR)见 Unity 的 C# 运行时委托与 event 的语义一致

event ≠ 线程安全

event 限制的是访问方式,不自动保证多线程下订阅列表安全。跨线程触发时仍可能需要复制委托快照;Unity 主线程事件通常不用纠结。

深入:Func 在 BCL 里如何定义

本节属于实现剖面,接在 Action/Func 之后读即可:你已经会用 Func<int, string>,这里回答「库里到底声明了什么」。

源码里就是一组 delegate

打开 .NET 源码 Function.csFunc 不是魔法语法,而是一组普通的泛型 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>0Func<string>(无参,返回 string
Func<T, TResult>1Func<int, string>
Func<T1,…,T16, TResult>16极少手写,但 LINQ / 表达式树场景会碰到

Action 同理:ActionAction<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 TT 只出现在形参等输入位置 → 允许逆变赋值
out TResultTResult 只出现在返回类型等输出位置 → 允许协变赋值

记忆: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 Animalf6 = 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 放在末尾的原因:

  1. Action 成对Action<T1,T2> 的类型参数全是形参;Func 仅在末尾追加 TResult
  2. 读类型像读调用Func<int, int, float> 从左到右对应 f(a, b) 的实参,最后是结果类型。
  3. 互操作成本:全生态已围绕此约定建立错误信息与文档,改顺序收益很小。

实战:怎么选类型?

场景推荐
无返回值、形参简单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 类库会少很多「这是哪种魔法」的错觉。

带走三句

  1. 类型delegate / Action / Func — 描述签名;Func 末位类型参数是 TResult
  2. 实例:方法、Lambda、+= — 真正可调用的对象。
  3. 事件event — 限制谁能 Invoke,而不是换一种 delegate。

注释

  1. Microsoft Learn,Base Class Library。BCL 是随 .NET 发布的标准类库(System.* 等),与 CLR(运行时引擎)并列;Action/Func 定义在 System 命名空间。 2

  2. Microsoft Learn,Delegates (C# Programming Guide)。说明 delegate 类型及多播委托的 += / -= 组合调用行为。

  3. Microsoft Learn,Action<T> 委托文档Func<T,TResult> 委托文档Action 为无返回值委托;Func 最后一个类型参数为 TResult

  4. Microsoft Learn,Introduction to events。说明 event 关键字对委托字段的封装,以及发布者/订阅者模型。

  5. .NET Runtime,Function.csFunc 族全部 delegate 声明的源码位置。

  6. Microsoft Learn,Lambda expressions in PLINQ and TPL。说明 .NET 预定义 17 个 Func 与 17 个 Action 重载(0 到 16 个形参类型参数)。

  7. Microsoft Learn,Covariance and Contravariance in Generics。说明类型参数的输入/输出位置及 in/out 变型规则;C# 4.0 语言背景见本站 C# 版本史

  8. Microsoft Learn,Handling and raising events。说明 EventHandler / EventHandler<TEventArgs>EventArgs 派生类在 .NET 事件模式中的用法。

On this page