C# 委托与事件详解:委托声明、Action/Func、多播、event 与 EventHandler 入门教程

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("张三");
    }
}

关键点:

  1. delegate 关键字声明的是一个委托类型,不是变量。
  2. 能装进 MyDelegate 的方法,参数和返回值必须和声明完全一致。
  3. 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);    // 触发,两个订阅者同时收到
    }
}

八、常见坑

  1. 事件没有任何订阅者时直接 Invoke,抛 NullReferenceException。 委托链为空时事件就是 null。统一用 OnAlarm?.Invoke(...) 空条件调用,有订阅者才触发,这是 C# 6 起的标准写法。

  2. 用 lambda 订阅事件,之后想取消却 -= 不掉。 每次写的 lambda 都是新的委托实例。需要退订时,把处理方法单独写成命名方法,或把委托实例保存在字段里,用同一个引用完成 += 和 -=;只加不摘还会让订阅对象无法被回收,埋下内存泄漏隐患。

  3. 多播链中一个方法抛异常,后面的方法全部不执行。 委托按顺序调用,异常会中断整条链。关键场景可用 GetInvocationList() 取出每个委托单独 try-catch,保证一个失败不影响其他订阅者。

  4. 分不清事件和委托字段的访问差异。 事件在类外部只能 +=、-=,不能 = 覆盖、不能 Invoke;委托字段没有这些限制。需要"对外发布通知"就用 event,内部临时回调才用裸委托。

  5. lambda 闭包捕获的是"变量"而不是"当时的值"。 比如在循环里建委托,所有委托共享同一个变量,执行时读到的是最终值。需要立即固定值,就在循环内部用一个局部变量拷贝一份再捕获。注意 C# 5 起 foreach 的迭代变量已改为每轮独立,老教程里的 foreach 闭包坑在新版本不存在,但 for 循环和其他共享变量场景仍要留意。

九、小结

  1. 委托是装方法的类型 ,签名匹配的方法才能装入;现代开发直接用 Action(无返回值)、Func(有返回值)、Predicate(返回 bool),基本不用自己声明。
  2. += 实现多播、-= 取消,但 lambda 必须保留引用才能摘掉;多播按顺序执行,一个抛异常会中断整条链。
  3. event 是加了访问限制的委托:外部只能订阅/退订,只有发布者内部能触发,这就是发布订阅模式。
  4. 标准事件用 EventHandler<TEventArgs>,触发前用 ?.Invoke 防空引用;留意闭包捕获的是变量不是值。
相关推荐
Dovis(誓平步青云)1 小时前
切换语言后按钮被截断,本地化审计怎样发现长度风险
开发语言·前端·javascript·人工智能·机器学习·华为·harmonyos
小王C语言1 小时前
Qt常用控件:按钮类控件,普通按键、单选、复选
开发语言·qt
街头便衣1 小时前
OpenRouter周榜复算:164万亿token,输出2.55%,DeepSeek账单占1.4%
android·java·开发语言·人工智能·语言模型·开源
IT规划师2 小时前
MQTT实战 - MQTT 5.0新特性实战:共享订阅、Topic Alias、流控(十)
物联网·.net
老王IT2 小时前
PHP语言基础语法介绍
开发语言·php
2501_9445215910 小时前
Ramda 跨端三方库仓颉适配全流程
开发语言·javascript·flutter·php
行思理10 小时前
go语言学习难度及适合学习的人群
开发语言·学习·golang
wangchunyu11412 小时前
JAVA CompletableFuture异步编程实战
java·开发语言·python