1. 从C/C++的“地址”到C#的“类型安全”:为什么我们需要委托?
如果你是从C或C++转战C#的开发者,第一次看到“委托”这个概念,可能会觉得有点故弄玄虚。这不就是函数指针吗?在C语言里,一个int (*funcPtr)(int, int)就能指向一个函数,然后funcPtr(3, 5)就能调用,多直接。C++里更是有std::function、成员函数指针、std::bind等一套更复杂的机制。那C#为什么还要发明一个“委托”出来,它和函数指针到底有什么不同?
这恰恰是理解C#委托价值的关键起点。C/C++的函数指针,本质上是一个内存地址,它指向函数代码段的起始位置。这种机制极其强大,也极其“原始”和“危险”。说它危险,是因为它缺乏类型安全。一个指向int add(int, int)的函数指针,理论上可以被强制转换成指向void dangerous()的函数指针并调用,编译器可能只会给个警告,甚至在某些宽松的设置下连警告都没有,运行时崩溃或产生不可预知的行为是家常便饭。此外,函数指针几乎不携带任何上下文信息(C++的成员函数指针是个特例,它隐式包含了this指针的偏移信息,但使用起来颇为晦涩)。
C#作为一门在.NET CLR(公共语言运行时)上运行的、强调安全性和生产力的托管语言,它的设计哲学之一就是“用类型安全来换取开发效率与运行时稳定性”。委托,就是这一哲学在“可调用对象”领域的体现。你可以把委托理解为一种“类型安全的、面向对象的、多播的函数指针”。
类型安全意味着编译器会在编译期严格检查委托类型与目标方法的签名(返回值类型、参数类型、参数数量及修饰符)是否匹配。不匹配?编译直接报错,把隐患消灭在萌芽状态。面向对象意味着委托是System.Delegate的派生类,它是一个完整的类实例,可以赋值给变量、作为参数传递、作为返回值,甚至存储在集合里。多播是委托超越传统函数指针的一个关键特性,一个委托实例可以封装多个方法,调用一次委托,所有方法按顺序执行。
所以,当你从int (*funcPtr)(int, int)切换到C#的delegate int CalculateDelegate(int a, int b)时,你失去的是一点“底层操控的自由”,换来的是巨大的安全性、可维护性和框架集成度(比如事件模型)。对于绝大多数应用层和业务层开发来说,这是一笔非常划算的买卖。在接下来的内容里,我们会先搭建起委托的基础认知,理解它如何声明、实例化和调用,为后续深入事件、Lambda表达式和异步编程打下坚实的基础。
2. 委托的声明、实例化与调用:从语法到本质
理解了委托的“为什么”,我们来看“怎么做”。委托的使用遵循一个清晰的流程:声明委托类型 -> 创建匹配的方法 -> 实例化委托对象 -> 调用委托。
2.1 声明委托类型:定义“方法的形状”
委托的声明看起来很像一个方法签名,但前面加上了delegate关键字。它定义了一种“契约”:任何符合这个签名的方法,都可以被该委托类型的实例所引用。
// 声明一个委托类型,它表示“接受两个int参数,返回一个int”的所有方法 public delegate int BinaryOperation(int x, int y); // 另一个例子:表示一个无参无返回值的方法 public delegate void LogMessage();这里有几个关键点:
delegate是类型定义关键字:BinaryOperation和LogMessage现在是两种新的类型,就像class定义类、enum定义枚举一样。你可以用它们来声明变量、参数和属性。- 签名即契约:
BinaryOperation委托类型规定,任何它指向的方法,必须接收两个int参数,并返回一个int。参数名(x,y)在委托声明中只起提示作用,实际绑定方法时并不检查参数名是否一致,只检查类型和顺序。 - 委托类型通常定义在类外部或命名空间下:为了使其能在多个类中复用,通常将委托类型声明在命名空间级别,而不是某个类内部。当然,如果某个委托仅用于特定类,也可以声明为类的嵌套类型。
2.2 准备目标方法与委托实例化
有了委托类型,我们需要有符合其签名的方法来让它指向。任何访问级别允许(通常是public或internal)的静态方法或实例方法都可以。
// 一个静态方法 public static int Add(int a, int b) => a + b; // 一个实例方法(属于某个类的对象) public class Calculator { public int Multiply(int a, int b) => a * b; public int Subtract(int a, int b) => a - b; }实例化委托,就是将委托对象与具体的方法关联起来。在C# 2.0之前,必须使用new关键字显式创建。现在,我们更常用的是更简洁的方法组转换语法。
// 方式1:传统new实例化(显式,C# 1.0) BinaryOperation op1 = new BinaryOperation(Add); // 方式2:方法组转换(简洁,推荐,C# 2.0+) BinaryOperation op2 = Add; // 编译器自动推断并创建委托实例 // 绑定实例方法 Calculator calc = new Calculator(); BinaryOperation op3 = calc.Multiply; // 委托会“记住”calc这个对象实例这里有一个非常重要的细节:当委托绑定到一个实例方法(如calc.Multiply)时,委托内部不仅存储了方法Multiply的引用,还存储了方法所属对象calc的引用。这样,在调用委托时,才能正确地在calc这个对象上调用Multiply方法。这是委托“面向对象”特性的一个具体体现,也是它比C语言普通函数指针更强大的地方——它能自然地处理面向对象中的实例方法。
2.3 调用委托:同步与多播
委托实例化后,调用它就和调用普通方法一样,使用()操作符。
int result1 = op1(10, 20); // 调用op1,实际执行Add(10, 20),result1 = 30 int result2 = op2(5, 6); // result2 = 11 int result3 = op3(7, 8); // 在calc对象上调用Multiply(7, 8),result3 = 56委托的另一个强大特性是多播。一个委托实例可以封装多个方法。使用+或+=运算符可以将多个方法添加到委托的调用列表中,使用-或-=可以移除。Delegate.Combine和Delegate.Remove是底层实现这些操作的静态方法。
BinaryOperation multiOp = Add; // 初始只有Add multiOp += calc.Multiply; // 添加Multiply multiOp += calc.Subtract; // 添加Subtract Console.WriteLine(“调用多播委托:”); // 调用多播委托,会按添加顺序依次执行Add, Multiply, Subtract // 但注意:只有最后一个方法的返回值会被返回! int finalResult = multiOp(10, 5); Console.WriteLine($“最终返回值: {finalResult}“); // 输出:最终返回值: 5 (来自Subtract) // 移除一个方法 multiOp -= calc.Multiply; // 现在multiOp只包含Add和Subtract重要提示:对于有返回值(非
void)的多播委托,如上面的BinaryOperation,调用时会顺序执行所有方法,但只有最后一个被执行的方法的返回值会被作为整个委托调用的返回值,之前方法的返回值会被丢弃。这是一个非常容易踩坑的地方。因此,多播委托通常用于返回void的委托类型(即“行动”而非“计算”),这正是C#中“事件”的典型模式。
2.4 委托的空值检查与条件调用
由于委托是引用类型,其变量可能为null。直接调用一个null委托会抛出NullReferenceException。安全的调用方式有两种:
// 方式1:传统的空值检查 if (op1 != null) { op1(10, 20); } // 方式2:Null条件运算符(?.)与Invoke(C# 6.0+ 更简洁) op1?.Invoke(10, 20);Invoke是委托类内部定义的一个方法,它执行委托的调用。op1(10, 20)实际上是C#提供的语法糖,编译器会将其编译为op1.Invoke(10, 20)。使用?.运算符可以安全地在委托不为null时调用Invoke。
3. 委托在实战中的典型应用场景
理解了基础语法,我们来看看委托在真实项目中是如何大显身手的。它绝不仅仅是为了模仿函数指针,而是实现回调、策略模式、模板方法等设计模式的利器。
3.1 回调机制:解耦调用方与被调用方
这是委托最经典的应用。想象一个排序算法,它需要对一个对象数组进行排序,但排序的依据(比如按对象的哪个属性排序)是运行时才能决定的。我们可以让调用者提供一个“比较”方法给排序算法。
// 定义一个比较委托 public delegate int Comparison<T>(T x, T y); // 一个通用的冒泡排序方法(仅示例,非高效实现) public static void BubbleSort<T>(T[] array, Comparison<T> comparison) { if (comparison == null) throw new ArgumentNullException(nameof(comparison)); for (int i = 0; i < array.Length - 1; i++) { for (int j = 0; j < array.Length - 1 - i; j++) { // 使用传入的委托进行比较决策 if (comparison(array[j], array[j + 1]) > 0) { T temp = array[j]; array[j] = array[j + 1]; array[j + 1] = temp; } } } } // 使用:对一组Person按年龄排序 public class Person { public string Name; public int Age; } Person[] people = { new Person { Name = “Alice”, Age = 30 }, new Person { Name = “Bob”, Age = 25 } }; // 传入一个Lambda表达式作为比较逻辑 BubbleSort(people, (p1, p2) => p1.Age.CompareTo(p2.Age));在这个例子中,BubbleSort方法完全不知道Person类型的存在,它只依赖于一个Comparison<T>委托。排序的逻辑核心(比较两个元素)被“外包”给了调用者。这实现了算法框架与具体业务逻辑的完美解耦。.NET框架中的Array.Sort、List.Sort等方法正是大量使用了这种模式。
3.2 实现策略模式:运行时切换算法
策略模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换。委托是天生的策略接口实现者。
public class PaymentProcessor { // 定义支付策略委托 public delegate bool PaymentStrategy(decimal amount, string cardNumber); // 持有当前策略 private PaymentStrategy _currentStrategy; public void SetPaymentStrategy(PaymentStrategy strategy) { _currentStrategy = strategy; } public bool ProcessPayment(decimal amount, string cardNumber) { if (_currentStrategy == null) throw new InvalidOperationException(“支付策略未设置。”); return _currentStrategy(amount, cardNumber); } } // 不同的策略实现 public class PaymentStrategies { public static bool CreditCardPayment(decimal amount, string cardNumber) { Console.WriteLine($“使用信用卡{cardNumber}支付{amount}元。”); // 模拟支付逻辑 return amount > 0; } public static bool PayPalPayment(decimal amount, string cardNumber) { Console.WriteLine($“使用PayPal账户支付{amount}元。”); // 模拟支付逻辑 return true; } } // 客户端代码 var processor = new PaymentProcessor(); processor.SetPaymentStrategy(PaymentStrategies.CreditCardPayment); bool success = processor.ProcessPayment(100.00m, “1234-5678-9012-3456”); // 运行时动态切换策略 processor.SetPaymentStrategy(PaymentStrategies.PayPalPayment); success = processor.ProcessPayment(50.00m, null);通过委托,我们无需定义一堆IPaymentStrategy接口和实现类,代码更加简洁直观。策略的切换变成了简单的委托赋值。
3.3 异步编程的基础(APM模式)
在.NET早期的异步编程模型(Asynchronous Programming Model, APM)中,委托扮演了核心角色,即BeginInvoke和EndInvoke。虽然现在已被更先进的Task和async/await模式取代,但理解其原理仍有价值。
public delegate string LongRunningTaskDelegate(string input); public static string PerformLongTask(string input) { Thread.Sleep(5000); // 模拟耗时操作 return input.ToUpper(); } // APM模式调用 class Program { static void Main() { LongRunningTaskDelegate taskDelegate = PerformLongTask; // 开始异步执行 IAsyncResult asyncResult = taskDelegate.BeginInvoke(“hello”, null, null); Console.WriteLine(“主线程继续执行其他工作...”); // 等待异步操作完成,并获取结果 string result = taskDelegate.EndInvoke(asyncResult); Console.WriteLine($“异步任务结果: {result}“); // 输出: HELLO } }BeginInvoke会在线程池中排队执行委托所指向的方法,并立即返回一个IAsyncResult对象供主线程查询状态或等待。EndInvoke用于获取异步执行的结果(或异常)。尽管现在不推荐直接使用BeginInvoke/EndInvoke(尤其在跨平台.NET Core/.NET 5+中,其对某些委托类型的支持已被移除),但它是理解后续Task封装的基础。
4. 当委托遇见Lambda表达式与匿名方法
C# 2.0引入了匿名方法,C# 3.0引入了Lambda表达式,这两者极大地简化了委托的使用,使得我们无需为了一个简单的逻辑而去专门定义一个命名方法。
4.1 匿名方法(C# 2.0)
匿名方法允许你在需要委托的地方“内联”地定义一个方法。
BinaryOperation op = delegate(int a, int b) { return a * a + b * b; }; // 计算平方和 int sumOfSquares = op(3, 4); // 25匿名方法的语法以delegate关键字开头,后面跟着参数列表和方法体。它解决了为仅使用一次的简单逻辑创建命名方法的繁琐问题。但在C# 3.0引入Lambda表达式后,匿名方法的使用就大大减少了,因为Lambda更简洁。
4.2 Lambda表达式(C# 3.0+):委托的“语法糖”
Lambda表达式是编写匿名函数的更简洁方式。它有两种形式:
表达式Lambda:当函数体是单个表达式时。
BinaryOperation op = (a, b) => a + b; // 参数类型可推断,返回a+b Func<int, int, int> func = (x, y) => x * y; // 使用内置Func委托语句Lambda:当函数体包含多条语句时,需要用
{}包围。Action<string> log = message => { string timestamp = DateTime.Now.ToString(“yyyy-MM-dd HH:mm:ss”); Console.WriteLine($“[{timestamp}] {message}“); }; log(“应用程序启动”);
**Lambda表达式的强大之处在于类型推断**:在绝大多数情况下,编译器能根据上下文推断出参数类型,无需显式声明。这使得代码极其简洁。上面例子中的`(a, b) => a + b`,编译器知道它要被赋值给`BinaryOperation`(需要两个`int`返回一个`int`),因此`a`和`b`被推断为`int`类型。 ### 4.3 闭包:Lambda与匿名方法的“超能力” Lambda和匿名方法不仅仅是语法糖,它们能捕获(或“闭合”)其所在作用域的局部变量,形成**闭包**。这是它们超越普通命名方法的决定性特性。 ```csharp public static Func<int> CreateCounter() { int count = 0; // 局部变量 // 返回一个Lambda,它捕获了变量`count` return () => ++count; } // 使用 Func<int> counter = CreateCounter(); Console.WriteLine(counter()); // 输出 1 Console.WriteLine(counter()); // 输出 2 Console.WriteLine(counter()); // 输出 3在这个例子中,CreateCounter方法执行完毕后,其局部变量count按常理应该被销毁。但由于返回的Lambda表达式捕获了它,CLR会通过生成一个隐藏的辅助类(或结构体)来“提升”这个变量的生命周期,使其与委托实例共存亡。每次调用counter(),实际上是在操作那个被捕获的、生命周期被延长的count变量。
闭包是许多高级编程模式(如工厂模式生成有状态函数、延迟计算、事件订阅中保持上下文)的基石。但它也带来了一个常见的陷阱:在循环中捕获循环变量。
List<Action> actions = new List<Action>(); for (int i = 0; i < 5; i++) { actions.Add(() => Console.WriteLine(i)); } foreach (var action in actions) { action(); // 你以为会输出0,1,2,3,4?错了! } // 实际输出:5,5,5,5,5这是因为i是循环变量,所有Lambda捕获的是同一个变量i,而不是每次循环时i的一个快照。当循环结束后,i的值变成了5,所以所有委托调用时打印的都是5。修复方法是在循环内创建一个局部变量副本:
for (int i = 0; i < 5; i++) { int temp = i; // 创建副本 actions.Add(() => Console.WriteLine(temp)); // 捕获副本 } // 现在输出:0,1,2,3,4理解闭包机制对于正确、高效地使用Lambda至关重要。
5. .NET内置泛型委托:Action与Func
在早期C#中,我们经常需要为不同的方法签名声明各种各样的委托类型,这很繁琐。.NET Framework 3.5引入了System命名空间下的一组泛型委托,其中最常用的就是Action和Func,它们几乎可以覆盖所有常见的委托场景。
5.1 Action委托:表示无返回值的方法
Action系列委托用于封装没有返回值(void)的方法。它有多达16个重载,支持0到16个输入参数。
// 无参数 Action doSomething = () => Console.WriteLine(“Hello!”); doSomething(); // 1个参数 Action<string> logError = (message) => Console.WriteLine($“错误: {message}“); logError(“文件未找到”); // 2个参数 Action<string, int> repeatPrint = (text, times) => { for (int i = 0; i < times; i++) Console.WriteLine(text); }; repeatPrint(“Hi”, 3);5.2 Func委托:表示有返回值的方法
Func系列委托用于封装有返回值的方法。它的最后一个泛型参数总是返回值类型,前面的参数是输入参数类型。同样支持0到16个输入参数(因此最多有17个泛型参数,16个输入+1个输出)。
// 1个输入,1个输出 (Func<T, TResult>) Func<int, string> intToString = (num) => num.ToString(); string s = intToString(42); // “42” // 2个输入,1个输出 (Func<T1, T2, TResult>) Func<int, int, int> add = (a, b) => a + b; int sum = add(10, 20); // 30 // 无输入,1个输出 (Func<TResult>) Func<DateTime> getCurrentTime = () => DateTime.Now; DateTime now = getCurrentTime();5.3 为什么推荐使用Action/Func?
- 标准化与一致性:它们成为了.NET生态系统中表示回调、操作和函数的“标准词汇”。当你看到一个方法接收一个
Action<T>参数时,你立刻知道它需要一个能处理T类型对象的无返回值操作。 - 减少委托类型爆炸:你不再需要为每一个不同的方法签名去声明一个新的委托类型。这使得API更简洁,代码库更干净。
- 与LINQ高度集成:LINQ查询操作符大量使用
Func委托作为参数。例如Where方法接收一个Func<TSource, bool>(即谓词),Select方法接收一个Func<TSource, TResult>(即选择器)。熟练掌握Action/Func是使用LINQ的基础。
那么,什么时候还需要自定义委托类型呢?主要是在需要提高代码可读性和自文档化的时候。EventHandler就是一个经典的例子。虽然它本质上等同于Action<object, EventArgs>,但EventHandler这个名字清晰地表明了它的用途是处理事件。同样,如果你有一个特定的、在领域内反复使用的回调签名,为其定义一个具有描述性名称的委托类型(如Comparison<T>、Converter<TInput, TOutput>)会让代码意图更清晰。
6. 委托的底层机制与性能考量
对于大多数应用,把委托当作一个高级特性使用即可。但如果你在性能敏感的循环或高频调用的路径中使用委托,了解其底层机制和开销是有益的。
6.1 委托的本质:一个特殊的类
当你声明一个delegate时,C#编译器会在后台为你生成一个继承自System.MulticastDelegate的类。MulticastDelegate又继承自System.Delegate。这个生成的类包含几个关键字段:
_target(object类型):对于实例方法,它保存了方法所属对象的引用;对于静态方法,它为null。_methodPtr(IntPtr类型):一个指向方法代码入口点的指针。_invocationList(object类型):一个用于实现多播的数组。当委托只封装一个方法时,它为null;当封装多个方法时,它是一个对象数组,存储了多个委托实例。
当你调用一个委托时,CLR会检查_invocationList。如果为null,则直接调用_methodPtr指向的方法(通过_target确定this)。如果不为null,则遍历数组,依次调用每个委托。
6.2 性能开销
与直接方法调用相比,委托调用确实有额外的开销:
- 间接调用开销:需要通过指针跳转,无法进行某些内联优化。
- 空值检查开销:安全的调用需要检查委托实例是否为
null。 - 多播委托的遍历开销:如果委托是多播的,需要遍历调用列表。
然而,在绝大多数业务场景下,这种开销是微不足道的,完全不需要担心。委托调用通常比虚拟方法调用(virtual method call)还要快一点。只有在极端性能敏感的热点路径(例如在每帧渲染循环中调用数百万次),才需要考虑优化。
6.3 性能优化技巧
如果真的遇到性能瓶颈,可以考虑以下方法:
缓存委托实例:避免在循环内部重复创建相同的委托。将其创建一次,存储在字段或静态变量中重复使用。
// 不好:每次循环都创建新的委托 for (int i = 0; i < 1000000; i++) { list.ForEach(x => Process(x)); // Lambda表达式每次都会(隐式)创建新委托 } // 好:委托实例只创建一次 private static readonly Action<Item> s_processAction = (x) => Process(x); for (int i = 0; i < 1000000; i++) { list.ForEach(s_processAction); }对于单播委托,考虑使用接口或直接调用:如果某个回调路径极其关键,且确定是单播的,可以定义一个简单的接口来替代委托,或者直接硬编码方法调用。但这会牺牲灵活性和代码的优雅性,属于一种权衡。
使用
MethodImplOptions.AggressiveInlining:如果委托指向的是一个很小的静态方法,可以尝试用[MethodImpl(MethodImplOptions.AggressiveInlining)]标记该方法,提示JIT编译器尽可能内联它。但这只是一个提示,编译器不一定遵从。
我的经验是,除非性能分析器(Profiler)明确显示委托调用是瓶颈,否则不要进行过早优化。委托带来的代码清晰度、解耦和可维护性的收益,在99%的情况下都远超其微小的性能开销。
7. 常见陷阱与最佳实践
掌握了委托的基本用法和原理后,了解一些常见的“坑”和最佳实践,能让你在项目中更加得心应手。
7.1 陷阱一:多播委托的返回值处理
如前所述,非void多播委托的返回值只有最后一个有效。这是一个设计上的特性,但很容易被忽略,导致难以察觉的Bug。
最佳实践:除非有特殊设计,否则多播委托应使用返回void的委托类型(如Action系列或自定义的EventHandler)。如果需要收集多个返回值,应显式地遍历委托调用列表,或者使用其他模式(如返回集合)。
7.2 陷阱二:委托与垃圾回收(内存泄漏)
这是一个在长时间运行的应用(如桌面应用、服务)中非常严重的问题。当一个对象订阅了另一个对象的事件(本质上是多播委托)后,只要事件发布者还活着,事件订阅者就无法被垃圾回收,因为发布者的委托调用列表中持有对订阅者对象的引用。
public class EventPublisher { public event EventHandler SomethingHappened; } public class EventSubscriber { public EventSubscriber(EventPublisher publisher) { publisher.SomethingHappened += OnSomethingHappened; } private void OnSomethingHappened(object sender, EventArgs e) { } } // 如果publisher的生命周期很长,而subscriber不再需要,但由于它被publisher的事件引用,它无法被GC回收。解决方案:当订阅者不再需要接收事件时,必须记得取消订阅。
// 在订阅者中保存发布者的引用和事件处理程序 private EventPublisher _publisher; private EventHandler _eventHandler; public void Subscribe(EventPublisher publisher) { _publisher = publisher; _eventHandler = new EventHandler(OnSomethingHappened); _publisher.SomethingHappened += _eventHandler; } public void Unsubscribe() { if (_publisher != null && _eventHandler != null) { _publisher.SomethingHappened -= _eventHandler; _publisher = null; _eventHandler = null; } }对于WeakReference或弱事件模式(如WeakEventManager),它们可以缓解此问题,但通常用于框架设计,普通业务代码中规范地取消订阅是更直接有效的方法。
7.3 陷阱三:在值类型上使用委托(关于装箱)
如果委托绑定到一个结构体(值类型)的实例方法,那么在对该委托进行赋值、传递等操作时,结构体会被装箱。因为委托的_target字段是object类型。
public struct MyStruct { public void Method() { } } MyStruct s = new MyStruct(); Action action = s.Method; // 这里会发生装箱!s被复制并装箱到一个object中。如果这是一个高频操作,可能会产生不必要的堆内存分配,影响性能。对于性能敏感的场景,需要留意这一点。通常的解决方法是避免在热路径上对结构体使用实例方法委托,或者将结构体改为类(引用类型)。
7.4 最佳实践总结
- 命名约定:自定义委托类型名称应以
Delegate结尾(如EventHandler),提高可读性。 - 优先使用
Action和Func:除非有明确的语义化需求,否则使用内置泛型委托。 - 为事件使用特定的委托类型:事件应使用遵循
EventHandler或EventHandler<TEventArgs>模式的委托类型,这几乎是.NET社区的强制约定。 - 总是进行空值检查:在调用委托前,使用
?.Invoke()或显式if检查。 - 注意多播委托的返回值:避免对非
void委托进行多播,除非你明确知道后果并需要此行为。 - 管理好事件订阅的生命周期:及时取消订阅,防止内存泄漏。
- 在Lambda中小心捕获循环变量:使用局部变量副本解决闭包陷阱。
- 性能敏感处缓存委托:避免在循环中重复创建相同的委托实例。
委托是C#现代编程风格的基石之一,从简单的回调到复杂的事件驱动架构、LINQ查询、异步编程,处处都有它的身影。理解其从C/C++函数指针演进而来的历史,掌握其类型安全、面向对象、多播的核心特性,并熟练运用Lambda表达式和内置泛型委托,你将能写出更灵活、更解耦、更富表现力的C#代码。在下一篇中,我们将深入探讨委托最经典的应用——事件,看看C#如何基于委托构建出一套完整、安全的事件发布-订阅模型。