04-C#.Net-委托和事件-面试题
题 1:委托的本质是什么?请解释委托的底层实现原理。
出题意图
考察候选人对委托的深层理解,是否知道委托不仅仅是语法糖,而是有具体的底层实现。
解答思路
- 先说明委托的表面定义
- 深入到底层实现(类的本质)
- 说明继承关系和关键方法
参考答案
委托的本质是一个类(Class),继承自 System.MulticastDelegate,MulticastDelegate 又继承自 System.Delegate。
当我们定义一个委托时:
public delegate void MyDelegate(int x);
编译器会自动生成一个类,大致等价于:
public sealed class MyDelegate : MulticastDelegate
{
public MyDelegate(object target, IntPtr method);
public void Invoke(int x);
public IAsyncResult BeginInvoke(int x, AsyncCallback callback, object state);
public void EndInvoke(IAsyncResult result);
}
关键点:
- 委托是引用类型,存储在堆上
- 委托实例包含两个重要信息:目标对象(target)和方法指针(method)
Invoke方法用于同步调用BeginInvoke/EndInvoke用于异步调用(.NET Core 已废弃)- 所有委托都是多播委托,可以包含多个方法的调用列表
可以通过 ILSpy 等反编译工具查看委托编译后的真实结构来验证这一点。
题 2:委托和事件有什么区别?为什么要有事件?
出题意图
考察候选人是否理解事件的设计目的,以及事件相对于委托的安全性优势。
解答思路
- 说明两者的关系
- 列举具体区别
- 解释事件存在的必要性
参考答案
关系: 事件是特殊的委托,使用 event 关键字修饰。
主要区别:
| 特性 | 委托 | 事件 |
|---|---|---|
| 外部赋值 | 可以用 = 直接赋值 |
只能用 += 和 -= |
| 外部调用 | 可以在类外部直接调用 | 只能在类内部调用 |
| 子类访问 | 子类可以调用父类的委托 | 子类不能调用父类的事件 |
| 安全性 | 较低,容易被误操作 | 较高,受保护 |
public class Publisher
{
public Action MyDelegate; // 委托
public event Action MyEvent; // 事件
public void Trigger()
{
MyDelegate?.Invoke(); // 内部可以调用
MyEvent?.Invoke(); // 内部可以调用
}
}
Publisher pub = new Publisher();
// 委托的问题
pub.MyDelegate = Handler2; // 直接赋值,覆盖了之前所有订阅
pub.MyDelegate = null; // 清空所有订阅
pub.MyDelegate?.Invoke(); // 外部可以随意触发
// 事件的保护
pub.MyEvent += Handler1; // 只能添加
// pub.MyEvent = null; // 编译错误
// pub.MyEvent?.Invoke(); // 编译错误
为什么要有事件:
- 封装性:防止外部代码随意触发或清空订阅列表
- 安全性:避免误操作导致的 bug
- 语义明确:事件表达"发布-订阅"的意图更清晰
题 3:什么是多播委托?如何安全地移除委托中的方法?
出题意图
考察候选人对多播委托机制的理解,以及实际使用中的注意事项。
解答思路
- 解释多播委托的概念
- 说明移除的机制和规则
- 重点讲解移除的陷阱
参考答案
所有委托都继承自 MulticastDelegate,因此所有委托都是多播委托。多播委托可以通过 += 注册多个方法,形成一个调用链,执行时按注册顺序依次调用。
移除规则:
- 使用
-=操作符移除 - 从后往前逐个匹配,匹配到第一个后移除并停止
- 没有匹配到则不做任何操作
移除的陷阱:
Action action = DoSomething;
// 陷阱1:实例方法必须是同一个实例
Student s1 = new Student();
action += s1.Study;
action -= new Student().Study; // 失败:不是同一个实例
// 陷阱2:Lambda 表达式无法移除
action += () => Console.WriteLine("Hello");
action -= () => Console.WriteLine("Hello"); // 失败:每次生成不同的方法对象
正确的移除方式:
// 保存引用后再移除
Action handler = () => Console.WriteLine("Hello");
action += handler;
action -= handler; // 成功
// 命名方法直接移除
action += NamedMethod;
action -= NamedMethod; // 成功
多播委托的异步执行:
// 错误:多播委托不能直接异步调用
// action.BeginInvoke(null, null); // 运行时异常
// 正确:遍历调用列表,逐个异步执行
foreach (Action handler in action.GetInvocationList())
{
handler.BeginInvoke(null, null);
}
补充: 多播委托有返回值时,只能获取最后一个方法的返回值。
题 4:请解释观察者模式,并说明委托/事件如何实现观察者模式?
出题意图
考察候选人对设计模式的理解,以及委托/事件在实际设计中的应用。
解答思路
- 解释观察者模式的概念和要素
- 说明委托/事件实现方式
- 对比传统面向对象实现方式的优劣
参考答案
观察者模式(发布-订阅模式): 定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都会收到通知并自动更新。
三大要素:
- 发布者(Subject):维护订阅者列表,触发事件
- 订阅者(Observer):订阅事件,响应通知
- 订阅关系:通过
+=建立
委托/事件实现:
public class Cat
{
public event Action CatMiao;
public void Miao()
{
Console.WriteLine("猫:喵~");
CatMiao?.Invoke();
}
}
Cat cat = new Cat();
cat.CatMiao += () => Console.WriteLine("狗:汪汪!");
cat.CatMiao += () => Console.WriteLine("老鼠:快跑!");
cat.Miao();
传统面向对象实现:
public interface IObserver
{
void Update();
}
public class Cat
{
private List<IObserver> observers = new List<IObserver>();
public void Attach(IObserver o) => observers.Add(o);
public void Miao()
{
Console.WriteLine("猫:喵~");
foreach (var o in observers) o.Update();
}
}
两种方式对比:
| 特性 | 传统OOP方式 | 委托/事件方式 |
|---|---|---|
| 代码量 | 多(需要接口和列表管理) | 少(语言内置支持) |
| 类型约束 | 订阅者必须实现接口 | 任何匹配签名的方法 |
| 灵活性 | 较低 | 较高 |
优势: 猫不需要知道谁在监听,添加新订阅者不需要修改猫的代码,职责单一,耦合低。
题 5:Action 和 Func 有什么区别?为什么 .NET 要提供这两个内置委托?
出题意图
考察候选人对框架设计的理解,以及泛型委托的使用场景。
解答思路
- 说明两者的定义和区别
- 解释为什么需要内置委托
参考答案
核心区别:
| 特性 | Action | Func |
|---|---|---|
| 返回值 | 无(void) | 有(最后一个泛型参数) |
| 参数数量 | 0-16 个 | 0-16 个输入 + 1 个输出 |
| 使用场景 | 执行操作、触发事件 | 计算、转换、查询 |
// Action:执行操作,无返回值
Action greet = () => Console.WriteLine("Hello");
Action<string, int> print = (name, age) => Console.WriteLine($"{name}: {age}");
// Func:有返回值,最后一个泛型参数是返回值类型
Func<int> getNumber = () => 42;
Func<int, int, int> add = (a, b) => a + b;
Func<string, bool> isLong = s => s.Length > 10;
为什么需要内置委托:
- 统一类型系统:避免各个库各自定义委托,类型不兼容
- 减少类型数量:委托本质是类,自定义委托过多会增加程序集大小
- 提高互操作性:LINQ、Task 等框架大量使用
Action/Func,统一后可以直接传递
// LINQ 大量使用 Func
numbers.Where(x => x % 2 == 0); // Func<int, bool>
numbers.Select(x => x * x); // Func<int, int>
// Task 使用 Action/Func
Task.Run(() => DoWork()); // Action
Task.Run(() => ComputeResult()); // Func<T>
使用建议: 优先使用 Action/Func,只有当委托有特殊语义时才定义自定义委托(如 EventHandler)。
题 6:委托会导致内存泄漏吗?如何避免?
出题意图
考察候选人对内存管理的理解,以及实际项目中的问题排查能力。
解答思路
- 说明委托导致内存泄漏的原理
- 举例说明典型场景
- 提供解决方案
参考答案
会导致内存泄漏。
原理: 委托/事件会持有订阅者的强引用。如果发布者的生命周期比订阅者长,且订阅者没有取消订阅,订阅者对象将无法被 GC 回收。
典型场景:
public class Publisher
{
public event Action DataChanged;
}
public class Subscriber
{
private byte[] largeData = new byte[1024 * 1024 * 10]; // 10MB
public Subscriber(Publisher publisher)
{
publisher.DataChanged += OnDataChanged; // 订阅,但没有取消
}
private void OnDataChanged() { }
}
// 内存泄漏:publisher 是长生命周期对象
Publisher publisher = new Publisher();
for (int i = 0; i < 100; i++)
{
var sub = new Subscriber(publisher);
// sub 离开作用域,但无法被 GC 回收
// 因为 publisher 的事件持有它的引用
}
// 约 1GB 内存无法释放
解决方案:
- 实现 IDisposable,在 Dispose 中取消订阅
public class Subscriber : IDisposable
{
private Publisher _publisher;
public Subscriber(Publisher publisher)
{
_publisher = publisher;
_publisher.DataChanged += OnDataChanged;
}
public void Dispose()
{
_publisher.DataChanged -= OnDataChanged; // 取消订阅
}
private void OnDataChanged() { }
}
using (var sub = new Subscriber(publisher))
{
// 使用完自动 Dispose,取消订阅
}
- 使用静态方法(不持有实例引用)
publisher.DataChanged += StaticHandler; // 静态方法不持有实例引用
最佳实践:
- 订阅必取消:在对象销毁前必须取消所有事件订阅
- 避免用匿名方法订阅长生命周期事件(无法取消订阅)
- 静态事件尤其危险,订阅后几乎不会被 GC 回收
题 7:委托和接口有什么区别?什么时候用委托,什么时候用接口?
出题意图
考察候选人对语言特性的理解和架构设计能力。
解答思路
- 说明两者的本质区别
- 给出选择建议并举例
参考答案
本质区别:
| 特性 | 委托 | 接口 |
|---|---|---|
| 本质 | 类型安全的函数指针 | 契约/协议 |
| 封装 | 封装单个方法 | 封装一组方法和属性 |
| 多播 | 支持 | 不支持 |
| 状态 | 通常无状态 | 可以有状态 |
选择委托的场景:
- 只需要一个方法(回调、事件通知)
- 方法无状态(简单策略、过滤条件)
- 需要多播(一对多通知)
// 委托:简单回调
public void DownloadAsync(string url, Action<string> onComplete)
{
Task.Run(() => onComplete(Download(url)));
}
// 委托:简单策略(LINQ 风格)
public void Process(Func<int, bool> filter, Action<int> callback)
{
foreach (var item in data.Where(filter)) callback(item);
}
选择接口的场景:
- 需要多个相关方法协同工作
- 需要维护内部状态
- 需要依赖注入
// 接口:复杂行为,多个方法
public interface IPaymentProcessor
{
bool ValidateAccount();
PaymentResult Process(decimal amount);
void Refund(string transactionId);
}
总结: 委托轻量灵活,适合简单场景;接口结构规范,适合复杂场景。两者不是非此即彼,实际项目中经常混合使用。
题 8:什么是委托的闭包?有哪些常见陷阱?
出题意图
考察候选人对闭包的理解和常见陷阱的认知,这是实际开发中容易踩坑的地方。
解答思路
- 解释闭包的概念
- 列举常见陷阱并给出解决方案
参考答案
闭包定义: Lambda 表达式或匿名方法可以访问并捕获其外部作用域的变量,即使外部方法已经返回,被捕获的变量依然存活。
public Func<int> CreateCounter()
{
int count = 0;
return () => ++count; // 捕获外部变量 count
}
var counter = CreateCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3
陷阱1:循环中捕获循环变量(最常见)
// 错误:所有 Lambda 捕获的是同一个变量 i
var actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (var a in actions) a(); // 输出:5 5 5 5 5
// 正确:用局部变量捕获当前值
for (int i = 0; i < 5; i++)
{
int temp = i;
actions.Add(() => Console.WriteLine(temp));
}
foreach (var a in actions) a(); // 输出:0 1 2 3 4
陷阱2:捕获的是引用,不是值
int x = 10;
Action action = () => Console.WriteLine(x);
x = 20;
action(); // 输出:20,不是 10
陷阱3:闭包捕获 this 可能导致内存泄漏
public class MyClass
{
public Action GetAction()
{
return () => Console.WriteLine(this.Value); // 捕获 this
}
}
// 如果 action 被长生命周期对象持有,MyClass 实例无法被 GC 回收
var action = new MyClass().GetAction();
底层原理: 编译器会将闭包中捕获的变量提升到一个编译器生成的类中,Lambda 表达式变成该类的方法,这就是为什么捕获的是引用而不是值。
最佳实践:
- 循环中需要捕获当前值时,用局部变量做一次拷贝
- 注意闭包的生命周期,避免意外延长对象存活时间
- 需要捕获值语义时,显式拷贝到局部变量
回答技巧
- 结构化回答:先说概念,再说原理,最后举例
- 对比说明:通过对比突出特点(委托 vs 事件、委托 vs 接口)
- 结合实际:联系实际项目经验,说明在哪些场景下用过
- 提及注意事项:展示对细节的关注(内存泄漏、闭包陷阱等)
本文来自博客园,作者:龙猫•ᴥ•,转载请注明原文链接:https://www.cnblogs.com/nullcodeworld/p/19738935

浙公网安备 33010602011771号