4. 委托、事件与Lambda

委托是.NET类型系统中一个独特的存在:它既像C语言中的函数指针,又具备面向对象的类型安全与多播能力。理解委托、事件与Lambda之间的关系,是区分初级与中高级.NET开发者的重要分水岭。本章从委托类型出发,逐步深入到事件机制、闭包捕获以及Expression与Func的本质差异,并梳理面试中高频出现的陷阱。

一、委托类型:把方法当作参数传递

委托本质上是一个继承自System.MulticastDelegate的类,它定义了方法的签名。声明一个委托类型时,编译器会生成一个包含Invoke、BeginInvoke、EndInvoke方法的密封类。例如:

public delegate int Calculator(int a, int b);
Calculator calc = Add;
int result = calc(3, 5); // 8
int Add(int x, int y) => x + y;

这里calc是一个委托实例,它持有一个方法引用。委托实例可以指向静态方法、实例方法,也可以指向另一个委托。面试中常问:委托和直接调用方法有什么区别?核心在于委托将“调用什么”与“何时调用”解耦,为回调、策略模式、事件机制提供了语言级支持。

二、多播委托与调用链

多播委托通过+=和-=组合多个方法,内部维护一个调用列表。调用多播委托时,所有方法按添加顺序依次执行。需要注意两个关键点:

  • 如果委托有返回值,多播调用只返回最后一个方法的返回值,前面的返回值被丢弃。
  • 如果链中某个方法抛出异常,后续方法不会执行,异常直接向上传播。
Action actions = () => Console.WriteLine("A");
actions += () => Console.WriteLine("B");
actions += () => Console.WriteLine("C");
actions(); // 依次输出 A、B、C

面试陷阱:-=移除的是调用列表中最后一个匹配的方法,而不是所有匹配项。如果同一个方法被添加两次,-=只移除一次。此外,多播委托的调用列表是不可变的,每次+=都会创建一个新的委托实例。

三、事件与观察者模式

事件是委托的封装,它限制了外部代码只能通过+=和-=订阅或取消订阅,而不能直接赋值或调用。事件是观察者模式在C#中的标准实现。发布者定义事件,订阅者注册处理方法,发布者触发事件时通知所有订阅者。

public class OrderService
{
    public event EventHandler<OrderEventArgs> OrderCreated;
    public void CreateOrder(Order order)
    {
        // 业务逻辑
        OnOrderCreated(new OrderEventArgs(order));
    }
    protected virtual void OnOrderCreated(OrderEventArgs e)
    {
        OrderCreated?.Invoke(this, e);
    }
}

这里OrderCreated?.Invoke使用了空条件运算符,避免没有订阅者时抛出NullReferenceException。面试中常问:为什么事件不能像委托字段一样直接调用?因为事件对外只暴露订阅和取消订阅,防止外部代码清空订阅列表或随意触发事件,保证了封装性。

观察者模式的实际应用场景包括:UI事件、消息总线、领域事件。在领域驱动设计中,领域事件通常通过事件机制实现,聚合根发布事件,应用层订阅并处理。需要注意内存泄漏问题:如果订阅者生命周期短于发布者,且未取消订阅,发布者会持有订阅者的引用,导致订阅者无法被GC回收。解决方案是实现IDisposable在Dispose中取消订阅,或使用弱事件模式。

四、闭包与变量捕获

Lambda表达式和匿名方法可以捕获外部变量,形成闭包。编译器会生成一个闭包类,将捕获的变量提升为类的字段。关键陷阱在于捕获的是变量本身,而不是变量的值。

var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions) action();
// 输出 3、3、3,而不是 0、1、2

原因是i被提升为闭包类的字段,所有Lambda共享同一个i。循环结束后i的值为3。如果希望输出0、1、2,需要在循环体内创建局部变量:

for (int i = 0; i < 3; i++)
{
    int local = i;
    actions.Add(() => Console.WriteLine(local));
}

另一个常见陷阱是在foreach循环中捕获迭代变量。C# 5.0之前,foreach的迭代变量是共享的,C# 5.0之后每次迭代都会创建新的变量,因此foreach中捕获是安全的。但for循环中仍然需要手动创建局部变量。

闭包还会延长捕获变量的生命周期。即使外部方法已经返回,闭包仍然持有变量的引用,导致变量无法被回收。在性能敏感的场景中,应避免不必要的闭包捕获。

五、Expression与Func的区别

Func和Action是委托类型,表示可执行的方法引用。而Expression<T>是表达式树,它将Lambda表达式表示为数据结构,而不是可执行代码。表达式树可以被分析、修改、翻译成其他形式,例如SQL语句。

Func<int, bool> func = x => x > 5;
Expression<Func<int, bool>> expr = x => x > 5;

// func可以直接调用
bool result = func(10); // true

// expr不能直接调用,需要编译
var compiled = expr.Compile();
bool result2 = compiled(10); // true

在Entity Framework中,Where方法接受Expression<Func<T, bool>>,因为EF需要将Lambda表达式翻译成SQL。如果传入Func<T, bool>,EF无法翻译,只能将整个表加载到内存中再过滤,造成性能问题。面试中常问:为什么EF的Where要用Expression而不是Func?答案就是表达式树可以被解析和翻译,而委托只是编译后的IL代码,无法反向解析。

表达式树的局限性:不能包含语句体(如{ return x > 5; }),不能包含赋值操作,不能包含await。如果需要这些功能,只能使用委托。

六、常见面试陷阱汇总

陷阱现象正确做法
多播委托返回值只返回最后一个方法的结果避免在多播委托中使用返回值,或手动遍历调用列表
事件内存泄漏订阅者无法被GC回收实现IDisposable取消订阅,或使用弱事件模式
闭包捕获for变量所有Lambda共享同一个变量在循环体内创建局部变量
Expression与Func混用EF查询全表加载确认方法签名接受Expression,避免传入Func
事件直接调用外部代码清空订阅列表使用event关键字封装,提供OnXXX方法触发

掌握这些知识点后,建议在实际项目中刻意练习:用事件重构回调逻辑,用表达式树构建动态查询,用闭包实现延迟计算。只有真正写过、调试过,才能在面试中从容应对。