C# 委托与事件详解:委托声明、Action/Func、多播、event 与 EventHandler 入门教程
导语:普通变量里装的是数据(数字、字符串),而委托(delegate)里装的是方法 ,你可以把它当成一个"方法遥控器":拿着它就能调用背后的方法,不用关心方法具体写在哪个类里。事件(event)则是加了保险的委托,只允许外部"订阅"和"退订",不能随便覆盖和触发。这两个概念是理解回调、按钮点击、WinForms/WPF 消息机制的基础,本文用大白话和可运行代码把它们一次讲清。
一、委托是什么
1.1 用生活类比理解
假设你要找人修水管,你不需要自己会修,只要在"招工平台"上发一个需求:要一个能修水管、带两个工具参数的师傅。任何符合这个要求的师傅都能接单,你只需要打一个电话(Invoke),活就被干了。
委托就是这个"招工需求":
- 它规定了方法必须长什么样(几个参数、什么类型、返回什么)。
- 任何签名匹配的方法都能"装进"委托变量。
- 调用委托,就等于调用它装着的方法。
1.2 委托是一种类型
委托和 class、int 一样是一种类型,只不过它的实例装的是方法。它是引用类型,底层继承自 System.MulticastDelegate。
【插图 1:委托与事件知识地图】
二、委托的声明与基本使用
2.1 老式写法:自己声明委托类型
csharp
// 声明一个委托类型:能装"一个 string 参数、无返回值"的方法
public delegate void MyDelegate(string message);
class Program
{
static void SayHello(string name)
{
Console.WriteLine($"你好,{name}");
}
static void Main()
{
// 把方法装进委托(方法名直接赋值,编译器自动转换)
MyDelegate d = SayHello;
// 两种调用方式完全等价
d.Invoke("张三");
d("张三");
}
}
关键点:
delegate关键字声明的是一个委托类型,不是变量。- 能装进
MyDelegate的方法,参数和返回值必须和声明完全一致。 d.Invoke(...)和d(...)是一回事,后者是简写。
2.2 实例方法也能装
csharp
public class Player
{
public string Name { get; set; }
public void Move(string direction)
{
Console.WriteLine($"{Name} 向{direction}移动");
}
}
Player player = new Player { Name = "玩家1" };
MyDelegate d = player.Move; // 实例方法同样可以,委托会记住是哪个对象的方法
d("东");
【插图 2:委托变量如何"装"方法】
三、现代写法:Action、Func、Predicate
实际开发中几乎不用自己声明委托,系统已经内置了三个通用泛型委托:
3.1 Action:没有返回值
csharp
Action a1 = () => Console.WriteLine("无参数无返回值");
Action<string> a2 = msg => Console.WriteLine(msg);
Action<int, int> a3 = (x, y) => Console.WriteLine(x + y);
a1();
a2("消息");
a3(1, 2);
3.2 Func:有返回值
Func 最后一个泛型参数是返回值类型:
csharp
Func<int, int, int> add = (x, y) => x + y; // 两个 int 参数,返回 int
int result = add(3, 5);
Console.WriteLine(result); // 8
Func<string> getText = () => "一段文本"; // 无参数,返回 string
3.3 Predicate:专门做判断
Predicate<T> 接收一个参数、固定返回 bool,常用于集合筛选:
csharp
Predicate<int> isEven = n => n % 2 == 0;
Console.WriteLine(isEven(4)); // True
List<int> numbers = new List<int> { 1, 2, 3, 4 };
List<int> evens = numbers.FindAll(isEven); // FindAll 接收 Predicate
3.4 三者速查
| 内置委托 | 返回值 | 典型用途 |
|---|---|---|
Action / Action<T...> |
无 | 执行一个动作、回调通知 |
Func<T..., TResult> |
有(最后一个泛型参数) | 需要计算结果的回调、LINQ |
Predicate<T> |
固定 bool | 筛选、条件判断 |
四、多播委托:一个委托挂多个方法
委托支持用 += 挂多个方法,调用时按挂载顺序依次执行,这叫多播:
csharp
Action<string> logger = msg => Console.WriteLine($"日志:{msg}");
logger += msg => Console.WriteLine($"屏幕显示:{msg}");
logger += msg => Console.WriteLine($"写入文件:{msg}");
logger("系统启动"); // 三个方法都会执行
用 -= 可以摘掉方法:
csharp
logger -= msg => Console.WriteLine($"日志:{msg}");
注意:用匿名方法/lambda 直接 -= 是摘不掉的,因为新写的 lambda 和之前那个不是同一个对象。要能取消,必须把委托实例存成变量:
csharp
Action<string> handler = msg => Console.WriteLine(msg);
logger += handler; // 挂
logger -= handler; // 摘,同一个实例才能成功
有返回值的多播委托,只能拿到最后一个方法的返回值;想逐个处理可以用 GetInvocationList()。
五、事件 event:为什么有了委托还要事件
5.1 公开委托字段的两个安全问题
如果把一个委托字段直接 public,外部可以干两件危险的事:
csharp
public class Alarm
{
public Action OnAlarm; // 公开的委托字段
}
Alarm alarm = new Alarm();
alarm.OnAlarm = () => Console.WriteLine("覆盖了"); // 问题1:用 = 把别人挂的方法全冲掉
alarm.OnAlarm.Invoke(); // 问题2:外部可以随便触发,警报想按就按
5.2 event 解决了什么
加上 event 关键字后,规则立刻变了:
csharp
public class Alarm
{
public event Action OnAlarm; // 事件
public void Trigger()
{
OnAlarm?.Invoke(); // 只有类内部能触发
}
}
Alarm alarm = new Alarm();
alarm.OnAlarm += () => Console.WriteLine("订阅警报"); // 外部只能 +=
alarm.OnAlarm -= () => { }; // 只能 -=
// alarm.OnAlarm = ...; // 编译报错:外部不能赋值
// alarm.OnAlarm.Invoke(); // 编译报错:外部不能触发
类比:委托字段是一个谁都能按、谁都能换的喇叭;事件是只有发布者自己能按的警报按钮,外部只能选择"我要不要听"(订阅/退订)。
【插图 3:事件的发布订阅模型】
六、事件的标准写法:EventHandler
.NET 有一套事件命名和参数约定,正式代码推荐用 EventHandler<TEventArgs>:
csharp
// 自定义事件参数,携带要通知的数据
public class TemperatureEventArgs : EventArgs
{
public double CurrentTemp { get; }
public TemperatureEventArgs(double temp)
{
CurrentTemp = temp;
}
}
public class TemperatureSensor
{
// 标准事件:发送者 + 事件参数
public event EventHandler<TemperatureEventArgs> Overheated;
public void Check(double temp)
{
if (temp > 80)
{
// 触发事件:sender 是自己,参数带上温度
Overheated?.Invoke(this, new TemperatureEventArgs(temp));
}
}
}
订阅:
csharp
TemperatureSensor sensor = new TemperatureSensor();
sensor.Overheated += Sensor_Overheated; // 订阅,方法签名自动匹配
sensor.Check(90);
// 事件处理方法:sender 是谁发的,e 是事件数据
static void Sensor_Overheated(object sender, TemperatureEventArgs e)
{
Console.WriteLine($"收到警报:当前温度 {e.CurrentTemp} 度");
}
七、实战:温度报警器(发布订阅完整案例)
下面把"传感器发布事件、显示屏和报警器两个订阅者"写成完整程序:
csharp
class Program
{
static void Main()
{
TemperatureSensor sensor = new TemperatureSensor();
// 订阅者1:显示屏
sensor.Overheated += (s, e) =>
Console.WriteLine($"【显示屏】温度过高:{e.CurrentTemp} 度");
// 订阅者2:声光报警器
sensor.Overheated += (s, e) =>
Console.WriteLine("【报警器】红灯闪烁,蜂鸣器鸣响");
// 模拟温度检测
sensor.Check(70); // 不触发
sensor.Check(95); // 触发,两个订阅者同时收到
}
}
八、常见坑
-
事件没有任何订阅者时直接 Invoke,抛 NullReferenceException。 委托链为空时事件就是 null。统一用
OnAlarm?.Invoke(...)空条件调用,有订阅者才触发,这是 C# 6 起的标准写法。 -
用 lambda 订阅事件,之后想取消却
-=不掉。 每次写的 lambda 都是新的委托实例。需要退订时,把处理方法单独写成命名方法,或把委托实例保存在字段里,用同一个引用完成+=和-=;只加不摘还会让订阅对象无法被回收,埋下内存泄漏隐患。 -
多播链中一个方法抛异常,后面的方法全部不执行。 委托按顺序调用,异常会中断整条链。关键场景可用
GetInvocationList()取出每个委托单独 try-catch,保证一个失败不影响其他订阅者。 -
分不清事件和委托字段的访问差异。 事件在类外部只能
+=、-=,不能=覆盖、不能 Invoke;委托字段没有这些限制。需要"对外发布通知"就用 event,内部临时回调才用裸委托。 -
lambda 闭包捕获的是"变量"而不是"当时的值"。 比如在循环里建委托,所有委托共享同一个变量,执行时读到的是最终值。需要立即固定值,就在循环内部用一个局部变量拷贝一份再捕获。注意 C# 5 起 foreach 的迭代变量已改为每轮独立,老教程里的 foreach 闭包坑在新版本不存在,但 for 循环和其他共享变量场景仍要留意。
九、小结
- 委托是装方法的类型 ,签名匹配的方法才能装入;现代开发直接用
Action(无返回值)、Func(有返回值)、Predicate(返回 bool),基本不用自己声明。 +=实现多播、-=取消,但 lambda 必须保留引用才能摘掉;多播按顺序执行,一个抛异常会中断整条链。- event 是加了访问限制的委托:外部只能订阅/退订,只有发布者内部能触发,这就是发布订阅模式。
- 标准事件用
EventHandler<TEventArgs>,触发前用?.Invoke防空引用;留意闭包捕获的是变量不是值。


