04-C#.Net-委托和事件-面试题

题 1:委托的本质是什么?请解释委托的底层实现原理。

出题意图
考察候选人对委托的深层理解,是否知道委托不仅仅是语法糖,而是有具体的底层实现。

解答思路

  1. 先说明委托的表面定义
  2. 深入到底层实现(类的本质)
  3. 说明继承关系和关键方法

参考答案

委托的本质是一个类(Class),继承自 System.MulticastDelegateMulticastDelegate 又继承自 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:委托和事件有什么区别?为什么要有事件?

出题意图
考察候选人是否理解事件的设计目的,以及事件相对于委托的安全性优势。

解答思路

  1. 说明两者的关系
  2. 列举具体区别
  3. 解释事件存在的必要性

参考答案

关系: 事件是特殊的委托,使用 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();         // 编译错误

为什么要有事件:

  1. 封装性:防止外部代码随意触发或清空订阅列表
  2. 安全性:避免误操作导致的 bug
  3. 语义明确:事件表达"发布-订阅"的意图更清晰

题 3:什么是多播委托?如何安全地移除委托中的方法?

出题意图
考察候选人对多播委托机制的理解,以及实际使用中的注意事项。

解答思路

  1. 解释多播委托的概念
  2. 说明移除的机制和规则
  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:请解释观察者模式,并说明委托/事件如何实现观察者模式?

出题意图
考察候选人对设计模式的理解,以及委托/事件在实际设计中的应用。

解答思路

  1. 解释观察者模式的概念和要素
  2. 说明委托/事件实现方式
  3. 对比传统面向对象实现方式的优劣

参考答案

观察者模式(发布-订阅模式): 定义对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都会收到通知并自动更新。

三大要素:

  1. 发布者(Subject):维护订阅者列表,触发事件
  2. 订阅者(Observer):订阅事件,响应通知
  3. 订阅关系:通过 += 建立

委托/事件实现:

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 要提供这两个内置委托?

出题意图
考察候选人对框架设计的理解,以及泛型委托的使用场景。

解答思路

  1. 说明两者的定义和区别
  2. 解释为什么需要内置委托

参考答案

核心区别:

特性 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;

为什么需要内置委托:

  1. 统一类型系统:避免各个库各自定义委托,类型不兼容
  2. 减少类型数量:委托本质是类,自定义委托过多会增加程序集大小
  3. 提高互操作性: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:委托会导致内存泄漏吗?如何避免?

出题意图
考察候选人对内存管理的理解,以及实际项目中的问题排查能力。

解答思路

  1. 说明委托导致内存泄漏的原理
  2. 举例说明典型场景
  3. 提供解决方案

参考答案

会导致内存泄漏。

原理: 委托/事件会持有订阅者的强引用。如果发布者的生命周期比订阅者长,且订阅者没有取消订阅,订阅者对象将无法被 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 内存无法释放

解决方案:

  1. 实现 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,取消订阅
}
  1. 使用静态方法(不持有实例引用)
publisher.DataChanged += StaticHandler;  // 静态方法不持有实例引用

最佳实践:

  • 订阅必取消:在对象销毁前必须取消所有事件订阅
  • 避免用匿名方法订阅长生命周期事件(无法取消订阅)
  • 静态事件尤其危险,订阅后几乎不会被 GC 回收

题 7:委托和接口有什么区别?什么时候用委托,什么时候用接口?

出题意图
考察候选人对语言特性的理解和架构设计能力。

解答思路

  1. 说明两者的本质区别
  2. 给出选择建议并举例

参考答案

本质区别:

特性 委托 接口
本质 类型安全的函数指针 契约/协议
封装 封装单个方法 封装一组方法和属性
多播 支持 不支持
状态 通常无状态 可以有状态

选择委托的场景:

  • 只需要一个方法(回调、事件通知)
  • 方法无状态(简单策略、过滤条件)
  • 需要多播(一对多通知)
// 委托:简单回调
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:什么是委托的闭包?有哪些常见陷阱?

出题意图
考察候选人对闭包的理解和常见陷阱的认知,这是实际开发中容易踩坑的地方。

解答思路

  1. 解释闭包的概念
  2. 列举常见陷阱并给出解决方案

参考答案

闭包定义: 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 表达式变成该类的方法,这就是为什么捕获的是引用而不是值。

最佳实践:

  • 循环中需要捕获当前值时,用局部变量做一次拷贝
  • 注意闭包的生命周期,避免意外延长对象存活时间
  • 需要捕获值语义时,显式拷贝到局部变量

回答技巧

  1. 结构化回答:先说概念,再说原理,最后举例
  2. 对比说明:通过对比突出特点(委托 vs 事件、委托 vs 接口)
  3. 结合实际:联系实际项目经验,说明在哪些场景下用过
  4. 提及注意事项:展示对细节的关注(内存泄漏、闭包陷阱等)
posted @ 2026-03-19 14:55  龙猫•ᴥ•  阅读(8)  评论(0)    收藏  举报