一、 委托的核心本质:它不是方法,而是“类型契约”

1. 声明委托(定义契约)

在 C# 中,定义一个委托类型需要严格指定其参数类型和返回类型。委托是一个引用类型,它在底层本质上是一个派生自System.MulticastDelegate 的类(Class)。这就像是定义了一个方法的“形状”或契约:

1
public delegate int MathOperation(int num1, int num2);

当你敲下上面这行代码时,C# 编译器在底层会悄悄帮你生成一个完整的类:

1
2
3
4
5
6
7
8
9
// 编译器的幕后黑手:所有的委托类型都隐式派生自 System.MulticastDelegate
public sealed class MathOperation : System.MulticastDelegate
{
// 构造函数:接收一个对象实例和方法的内存地址
public MathOperation(object @object, IntPtr method);

// 同步调用核心
public virtual int Invoke(int num1, int num2);
}

因为委托本质上是类,所以类能声明在哪里(比如命名空间下、类内部),委托就可以声明在哪里。当把方法赋值给委托时,就相当于声明:“该方法符合此契约要求。”

二、 自定义委托的“黄金四步法”与核心实操

手写一个自定义委托并在代码里跑起来,雷打不动分为四个核心步骤:

  1. 声明委托(定义契约) —— 规定这个委托只能指向什么参数和返回值的方法。
  2. 准备目标方法—— 手写具体的方法,静态方法或实例方法皆可。
  3. 实例化并绑定方法 —— 把具体方法塞给委托实例。
  4. 调用委托 —— 执行委托,间接触发被绑定的具体方法。

1. 基础演练:同步调用(绑定静态与实例方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
using System;

namespace DelegateDemo
{
// 【第一步:声明委托】
// 限制:绑定的方法必须返回 int,且接收两个 int 参数
public delegate int MathOperation(int num1, int num2);

// 【第二步:准备目标方法】
public class Calculator
{
// 静态方法(加法)
public static int Add(int a, int b) => a + b;

// 实例方法(乘法,必须实例化对象才能调用)
public int Multiply(int a, int b) => a * b;
}

class Program
{
static void Main(string[] args)
{
// 【第三步:实例化与方法绑定】

// 写法 1:传统 new 关键字传参(静态方法直接用 类名.方法名)
MathOperation op1 = new MathOperation(Calculator.Add);

// 写法 2:隐式类型化简写(实例方法必须用 实例对象.方法名)
Calculator calcInstance = new Calculator();
MathOperation op2 = calcInstance.Multiply;

// 写法 3:绑定 Lambda 表达式(最常用的简写)
MathOperation op3 = (int x, int y) => x - y;


// 【第四步:调用委托】

// 方式 A:使用显式 Invoke 同步调用
int result1 = op1.Invoke(10, 5);
Console.WriteLine($"op1(加法)结果: {result1}"); // 输出: 15

// 方式 B:像普通方法一样直接简写调用
int result2 = op2(10, 5);
Console.WriteLine($"op2(乘法)结果: {result2}"); // 输出: 50

int result3 = op3(10, 5);
Console.WriteLine($"op3(减法)结果: {result3}"); // 输出: 5
}
}
}

三、 为什么要用委托?从“游戏按钮”看解耦的精髓

如果界面上有许多不同的按钮,点击按钮 1 要“开始游戏”,点击按钮 2 要“好友分享”。普通的死板写法是为每个按钮写一个专用的类,导致代码极度臃肿。

通过委托,我们可以实现完美的方法解耦,让按钮的类结构复用,而点击后的回调行为完全由外部决定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Button
{
// 1. 声明一个点击消息响应的方法签名(委托)
public delegate void onClickDelegate();

// 2. 声明一个具体的委托变量,初始为 null
public onClickDelegate? onClick = null;

public void Click()
{
Console.WriteLine("按钮被点击了");
// 3. 触发回调
if (onClick != null) onClick();
}
}

internal class Program
{
static void Main(string[] args)
{
// 开始游戏按钮
Button gameStartButton = new Button();
gameStartButton.onClick = OnGameStart; // 绑定回调
gameStartButton.Click();

// 好友分享按钮
Button friendButton = new Button();
friendButton.onClick = OnFriend; // 绑定另一个回调
friendButton.Click();
}

static public void OnGameStart() => Console.WriteLine("游戏开始!");
static public void OnFriend() => Console.WriteLine("分享给好友。。");
}

四、 多播委托(Multicast)的数组链条与致命暗坑

由于定义的委托类型都继承自 MulticastDelegate它们天然都是多播委托。委托多播允许在一个委托中注册多个方法,内部维护了一个方法执行链,通过 +=-= 操作符进行管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Zhang
{
public delegate void BuySomethingDelegate();
public void Buywater() => Console.WriteLine("买水!");
public void BuyKFC() => Console.WriteLine("买肯德基");
public void BuyHotDog() => Console.WriteLine("买热狗");
}

// 实际多播演练
Zhang z = new Zhang();
Zhang.BuySomethingDelegate bsd = z.Buywater;
bsd += z.BuyHotDog; // 挂载热狗
bsd += z.BuyKFC; // 挂载肯德基
bsd -= z.BuyHotDog; // 突然不吃了,退掉热狗

bsd(); // 执行多播,依次执行买水和买肯德基

多播委托的致命应用坑点:

  1. 调用顺序:方法按照它们被添加的顺序依次调用。
  2. 异常处理(崩断):如果链条中某个方法抛出了异常,整个执行链会直接中断,后续方法不会被调用。
  3. 返回值被吞:如果委托有返回值,默认直接调用多播委托,只有最后一个方法的返回值会被保留并返回。
    • 规范解法:如果需要拿到每一个回调方法的执行返回值,必须使用 GetInvocationList() 获取子委托数组进行循环遍历调用:
      1
      2
      3
      4
      5
      6
      7
      Delegate[] delegates = cal.GetInvocationList();
      for (int i = 0; i < delegates.Length; i++)
      {
      CalculateDelegate c = (CalculateDelegate)delegates[i];
      int result = c(1, 3); // 逐个执行并稳稳拿到每一个返回值
      Console.WriteLine(result);
      }

五、 全面拥抱三大内置通用泛型委托

在现代 C# 开发中,为了不让代码里充斥着密密麻麻的自定义 public delegate 声明,官方内置了三个通用的泛型委托,能够直接覆盖 99% 的声明需求:

  • Action\<…\>:无返回值的方法类型,支持 0 到 16 个泛型输入参数。
  • Func\<…\>:带返回值的方法类型,支持 0 到 16 个输入参数,最后一个泛型参数固定代表返回值的类型。
  • Predicate:专门用于条件判断的泛型委托,输入一个指定对象,固定返回 bool。常用于集合筛选(如 List.Find、Array.Exists)。

六、 事件(Event):套在委托外面的“安全保护壳”

1. 为什么有了委托,还要发明事件?

在前面的“游戏按钮”案例里,因为 onClick 是个普通的委托变量,外部代码可以直接写 gameStartButton.onClick = null;。这一行赋值会直接把别人之前用 += 绑定的所有回调一刀切全部清空。甚至外部可以直接调用 gameStartButton.onClick(); 去强行触发点击。

这破坏了面向对象的封装和安全原则。为此,C# 提供了 event 关键字。事件本质上是一种特殊的多播委托,它基于发布-订阅模型,是只允许在定义类的内部触发的安全封装壳。

2. 标准的规范事件模型(EventHandler)

在企业级或上位机底层框架开发中,触发事件通常要遵循标准的 .NET 规范,使用内置的 EventHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 1. 自定义事件参数(传递额外数据)
public class PriceChangedEventArgs : EventArgs
{
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
=> (OldPrice, NewPrice) = (oldPrice, newPrice);
}

// 2. 发布者类
public class Stock
{
private decimal _price;

// 使用内置的泛型 EventHandler 声明事件
// 核心安全:外部对事件【只能进行 += 和 -=】,禁止直接 `=` 覆盖,禁止外部直接调用!
public event EventHandler<PriceChangedEventArgs>? PriceChanged;

public decimal Price
{
get => _price;
set
{
if (_price != value)
{
decimal oldPrice = _price;
_price = value;
// 3. 价格变化时,调用安全触发方法
OnPriceChanged(oldPrice, _price);
}
}
}

// 遵循 .NET 规范,触发逻辑封装为 Protected Virtual 的 OnXXX 方法
protected virtual void OnPriceChanged(decimal oldPrice, decimal newPrice)
{
// 职业级安全调用:用 ?.Invoke,触发者传 this
PriceChanged?.Invoke(this, new PriceChangedEventArgs(oldPrice, newPrice));
}
}

3. 事件与委托的终极区别(面试高频)

特性 委托(Delegate) 事件(Event)
基础概念 自自定义引用类型,代表方法指针契约。 特殊的多播委托,基于发布-订阅机制。
外部赋值 (=) 自由允许,但极易覆盖掉其他绑定的方法。 严厉禁止!外部只能使用+=和-=。
外部触发 (Invoke) 自由允许,只要持有该委托变量就能随时调用。 严厉禁止!只有定义该事件的类内部才能触发。
设计目的 用于底层通用方法指针封装或灵活的回调传递。 提供安全的观察者模式,防范外部误操作。

七、 个人复盘心得

  1. 要防范事件引起的“内存泄漏”
    在实际开发中,只要执行了 Publisher.PriceChanged += Subscriber.PrintOut;,发布者内部就会持有一个订阅者的长引用。如果订阅者生命周期结束了(例如窗体关闭),但如果没有显式调用-= 注销事件,垃圾回收器(GC)就永远无法回收它,导致内存积压。切记:有加必有减,对象销毁前一定要主动解绑事件!
  2. 签名契约必须严丝合缝
    无论是自定义委托还是内置委托,参数个数、类型和返回值必须与目标方法百分之百匹配,任何细微的不一致都会引发编译错误。
  3. 调用前必须进行可空校验
    如果一个委托变量或者事件没有被任何外部方法订阅,它的值就是 null。此时直接调用会产生 NullReferenceException 导致系统崩溃。在日常编写代码时,必须强制养成使用?.Invoke() 安全调用的肌肉记忆