← 全部文章 C# 语言

C# 委托与事件

2023 年 11 月 29 日 · 2 分钟读完

目录
  1. 委托(Delegate)
  2. 基本用法
  3. 多播委托
  4. 处理每一个返回值
  5. 事件(Event)
  6. 基本用法
  7. 防止重复订阅
  8. 区别
  9. 延伸

刚学 C# 时,委托和事件经常傻傻分不清。它们功能相似,用途却不一样,分开讲完再比较。

委托(Delegate)

委托是一种类型,它的值是对一个或多个方法的引用,类似 C 的函数指针。有了它,方法就能当参数传递、当变量存起来。

基本用法

三步:声明委托类型、定义委托变量、绑定签名一致的方法。

delegate bool Compare(int a, int b);   // 1. 声明类型:两个 int 进,bool 出

static Compare w;                      // 2. 定义变量

static bool Greater(int a, int b) => a > b;

static void Main(string[] args)
{
    w += Greater;                      // 3. 绑定
    Console.WriteLine(w(1, 2));        // 调用 w 就是调用 Greater,输出 False
}

无参数、无返回值的委托写法完全一样,只是签名换成 delegate void Write();。

多播委托

一个委托变量可以用 += 绑定多个方法,调用时按绑定顺序依次执行。带返回值时有个坑:

static bool Greater(int a, int b) => a > b; // 返回 false
static bool Less(int a, int b) => a < b;    // 返回 true

w += Greater;
w += Less;
Console.WriteLine(w(1, 2));                 // True

两个方法都执行了,但只拿到最后一个方法的返回值,前面的结果都丢了。

处理每一个返回值

想要所有结果,就用 GetInvocationList() 自己一个个调:

static bool AllTrue(int a, int b)
{
    bool ret = true;
    if (w != null)
    {
        foreach (Compare item in w.GetInvocationList())
        {
            ret &= item(a, b); // 有一个 false 就是 false
        }
    }
    return ret;
}

Console.WriteLine(AllTrue(1, 2)); // False

事件(Event)

事件是对委托的封装,用来实现观察者模式(发布-订阅)。它对外只开放 += 和 -=:

  • 外部只能订阅、取消订阅;
  • 外部不能直接调用,也不能 = null 把别人的订阅清掉;
  • 只有声明事件的类自己能触发。

基本用法

public delegate bool Compare(int a, int b);

public class Sensor
{
    public event Compare WriteEvent;

    public void Trigger(int a, int b)
    {
        WriteEvent?.Invoke(a, b); // 没人订阅时为 null,所以用 ?.
    }
}

static bool Print(int a, int b)
{
    Console.WriteLine(a + b);
    return a > b;
}

var t = new Sensor();
t.WriteEvent += Print;
t.WriteEvent += Print;   // 同一个方法订阅了两次
t.Trigger(1, 2);         // 打印两次 3
// t.WriteEvent(1, 2);   // 编译错误:类外部不能触发事件

事件不会判断重复订阅,订阅几次就执行几次。

防止重复订阅

自己实现 add、remove 访问器:

public class Sensor
{
    private Compare _handlers;

    public event Compare WriteEvent
    {
        add
        {
            if (_handlers != null && _handlers.GetInvocationList().Contains(value))
                return;
            _handlers += value;
        }
        remove
        {
            _handlers -= value;
        }
    }

    public void Trigger(int a, int b)
    {
        // 自定义访问器以后,事件名不能再直接调用,要调用背后的委托字段
        _handlers?.Invoke(a, b);
    }
}

区别

  • 委托是类型,可以把方法当参数传递、存储,拿到委托变量的人都能直接调用它。
  • 事件是委托的封装(类的成员),提供更安全的订阅机制,只有持有类能触发。

延伸

  • 大多数时候不用自己声明委托类型:Action 系列表示无返回值(Action<int, int>),Func 系列最后一个类型参数是返回值(Func<int, int, bool> 就等于上面的 Compare),Predicate<T> 返回 bool。配合 lambda:Func<int, int, bool> w = (a, b) => a > b;。
  • 事件的标准写法:public event EventHandler<DataEventArgs> DataReceived;,参数是 (object sender, DataEventArgs e),DataEventArgs 继承 EventArgs。WinForm 的所有控件事件都是这个形状,团队里统一用它最省心。
  • 忘记 -= 会内存泄漏:订阅者的生命周期短(比如一个弹出窗体)、发布者的生命周期长(比如全局的 PLC 通讯对象)时,窗体关闭后仍被事件引用,回收不掉,事件还会继续往已关闭的窗体里调。关闭时一定取消订阅,见 什么对象会被 .NET 垃圾回收。
  • lambda 订阅取消不掉:e += (s, a) => Foo(); 之后再写一遍同样的 lambda 去 -= 是无效的,因为是两个不同的委托实例。需要取消的订阅,要先把 lambda 存到变量里。
  • 事件在哪个线程触发,处理方法就在哪个线程执行:通讯线程里触发的事件,处理方法里直接改界面控件会报跨线程异常,要用 Invoke,见 C# 扩展方法 里的 InvokeIfRequired。

# C# # 委托 # 事件

原文首发于 blog.csdn.net