🎓 严老师 · C# / WPF 教学
← 全部课程 Day 3 Phase 1(语言基础 · 委托与事件)

📚 **Day 3 教学 · 2026-08-01(周六)**

7推送消息数
9233字符数
2026-08-01教学日期
<!-- message_id: om_x100b69e736f530a0b03889defb0f189 --> 📚 **Day 3 教学 · 2026-08-01(周六)** 主人早上好。今天进入 Phase 1 的第 3 个知识点。 🎯 今日知识点 **委托与事件(本质 / 内存模型 / 闭包)** 1. 是什么 **委托(Delegate)是 .NET 中类型安全的方法引用。它本质是一个类**——具体是 System.MulticastDelegate 的派生类,编译器会在你写 delegate void Foo(int x); 时自动生成一个继承自 MulticastDelegate 的**密封类**。 每个委托实例有三个关键字段(来自 System.Delegate): - _target:方法所在的对象实例(静态方法时为 null) - _methodPtr:方法本机的入口指针(Native Int) - _invocationList:多播链(null 表示单播,否则是 Delegate[]) **事件(Event)是对委托字段的封装**。C# 中 public event EventHandler Click; 实际上是: - 一个**私有**的委托字段(backing field) - 两个**公共**方法访问器:add_Click 和 remove_Click(编译器生成) 这两个访问器默认会调用 Interlocked.CompareExchange,实现线程安全的 += / -=。 2. 为什么 **为什么委托要设计成"类"而不是函数指针?** - **类型安全**:方法签名(参数 + 返回值)在编译期绑定。 - **面向对象**:可以携带实例状态(_target)、可继承自 System.Object、可被反射。 - **多播链**:单播 → 多播 → 事件 → 观察者模式 → 命令模式 → 整个 WPF/MVVM 的命令系统。 **为什么事件要封装委托字段?** - 暴露委托字段:btn.Click = null 可直接清空所有订阅者。事件只暴露 += / -=,外部无法干涉其他订阅者。 - 默认的 add/remove 访问器保证多线程订阅安全。 3. 怎么用 // 1. 委托声明(编译器生成 MulticastDelegate 派生类) public delegate void NotifyHandler(string message); // 2. 单播委托 NotifyHandler handler = new NotifyHandler(Console.WriteLine); handler("Hello"); // 等价于 handler.Invoke("Hello") // 3. 多播委托(+= 实际是 Delegate.Combine) NotifyHandler h1 = Console.WriteLine; NotifyHandler h2 = msg => Console.WriteLine($"[Echo] {msg}"); NotifyHandler multicast = h1 + h2; // 创建新委托实例,invocationList = {h1, h2} multicast("Hi"); // 两次输出 // 4. 事件(封装委托字段) public class Button { public event NotifyHandler? Click; // 编译器生成 private 字段 + add/remove public void SimulateClick() => Click?.Invoke("Clicked"); } // 5. 闭包(编译器生成 DisplayClass) public Action<int> MakeCounter() { int count = 0; // 被提升到 <>c__DisplayClass 实例字段 return n => count += n; // lambda 捕获 count } **资深级细节**: - **闭包变量提升**:所有被 lambda 捕获的局部变量都会被编译器挪进一个嵌套类 <>c__DisplayClass,并在堆上分配。每次方法调用都会**新建一个** DisplayClass 实例——这是闭包的**隐藏分配成本**。 - **缓存委托实例**:Action<int> cachedAction = n => count += n; 中 cachedAction 是同一个引用。在热路径里,每次 new Action<int>(...) 也是隐藏分配。 --- <!-- message_id: om_x100b69e73679b4a4b3b92dd46c3736e --> 4. 常见误区 ❌ **误区 1**:委托是函数指针。 实际上委托是**对象**(类实例),在堆上分配。多播委托的 _invocationList 也是堆分配。频繁创建委托 = GC 压力。 ❌ **误区 2**:event 和 delegate 字段没区别。 事件只能 += / -=;委托字段可以 = 整体赋值。事件封装了订阅者的多播链,外部无法清空所有订阅者。 ❌ **误区 3**:闭包捕获的是变量的"当前值"。 实际上闭包捕获的是变量的**引用**(DisplayClass 字段)。循环中经典的闭包陷阱: var actions = new List<Action>(); for (int i = 0; i < 3; i++) actions.Add(() => Console.WriteLine(i)); foreach (var a in actions) a(); // 输出: 3 3 3(不是 0 1 2) // 修复:循环变量在每次迭代时拷贝 for (int i = 0; i < 3; i++) { int copy = i; // 每次迭代创建新变量 actions.Add(() => Console.WriteLine(copy)); } ❌ **误区 4**:Lambda 不会产生分配。 - **无捕获 lambda** 在 C# 9+ 可被缓存(static lambda); - **有捕获 lambda** 每次都会创建 Action 实例 + DisplayClass 实例。 这是 WPF 数据绑定的 PropertyChanged 回调里**最常见的隐性内存泄漏源**: // ❌ 危险:每次 PropertyChanged 都新建委托 propertyChanged.PropertyChanged += (s, e) => OnPropertyChanged(nameof(Foo)); // ✅ 推荐:缓存委托实例 private void OnAnyPropertyChanged(object? s, PropertyChangedEventArgs e) => OnPropertyChanged(e.PropertyName); propertyChanged.PropertyChanged += OnAnyPropertyChanged; 5. 关联知识 - **函数指针(C# 9+ delegate*<int, void>)**:托管函数指针,无堆分配,无闭包,但失去类型安全、不能跨程序集。用于热路径(序列化、解析器)。 - **Action<T> / Func<T>**:BCL 预定义的 16 个泛型委托(Action ~ Action<T1..T16>、Func<TResult> ~ Func<T1..T16, TResult>),避免自定义委托类型膨胀。 - **EventHandler<TEventArgs>**:.NET 事件签名标准(object sender, TEventArgs e),事件溯源模式(Event Sourcing)的基础。 - **WPF 命令系统**:ICommand 接口 + RelayCommand / DelegateCommand——本质都是把 Execute(object) / CanExecute(object) 包装成委托。 - **协变 / 逆变**:委托支持参数逆变(Action<object> = Action<string>)和返回值协变(Func<string> = Func<object>)。 --- <!-- message_id: om_x100b69e737d840a0b20fde6e5ed33ad --> 📝 今日 3 道递进面试题 Q1(基础概念) 请简述 C# 中 delegate void Foo(int x); 这段代码在编译后**实际生成了什么**?它与 Action<int> 有何关系? Q2(原理与辨析) MulticastDelegate 是如何实现多播链的?当 Action += Action 时,内部数据结构如何变化?事件(event)与普通的 public Action 字段在封装性上有何**本质区别**? Q3(实战与深度) 在 WPF 的 MVVM 架构中,RelayCommand 通常这样实现: public class RelayCommand : ICommand { private readonly Action<object?> _execute; public RelayCommand(Action<object?> execute) => _execute = execute; public bool CanExecute(object? p) => true; public void Execute(object? p) => _execute(p); public event EventHandler? CanExecuteChanged { add => CommandManager.RequerySuggested += value; remove => CommandManager.RequerySuggested -= value; } } 这段代码有一个**严重的内存泄漏风险**。请分析: 1. 泄漏的根因是什么? 2. 如何检测它?(给出一个具体的诊断方法) 3. 给出至少 2 种解决方案,并分析各自的优劣。 主人先思考这 3 道题。我下面发完整答案解析。 --- <!-- message_id: om_x100b69e737a0e4a8b21cd7ebb17c24b --> 📖 全面复盘讲解 Q1 答案要点 **关键点**: - 编译器生成一个**密封类**(sealed class),继承自 System.MulticastDelegate(后者继承自 System.Delegate)。 - 这个类包含 3 个关键字段:_target(实例引用,静态方法时为 null)、_methodPtr(方法的 native 入口指针)、_invocationList(多播委托链,单播时为 null)。 - 编译器还生成 Invoke 方法(同步调用)、BeginInvoke / EndInvoke(.NET Framework 中)。 - Action<int> 是 BCL 预定义的 delegate void Action<in T>(T obj);,本质与 Foo 完全等价——只是避免了为每个签名自定义委托类型。 **深度解析**: 很多人以为 Action<int> 是"内置优化",其实它和你的 Foo 在 IL 层**完全相同**: // IL 等价: public delegate void Foo(int x); public delegate void Action<in T>(T obj); // 标记 in 是为了逆变 唯一的区别是 Action<int> 由 BCL 提供。使用 Action / Func 是**约定俗成的最佳实践**——因为: 1. 减少自定义类型膨胀(每个 delegate 都生成一个新类)。 2. 跨程序集可读性更强。 3. 协变 / 逆变已经预定义好。 **易错点提醒**: - 不要混淆 System.Delegate 与 System.MulticastDelegate:所有用户委托都继承自**后者**,前者是后者的基类。 - delegate 关键字在 C# 中是**类型声明**(生成新类),不是**变量声明**——这是初学者最常混淆的点。 **扩展思考**: - 如果你需要在 C++/CLI 或非托管代码中调用 C# 方法,应该用 delegate*<int, void>(C# 9+ 函数指针)还是 IntPtr 手动获取函数入口?两种方案在性能和跨平台可移植性上如何取舍? --- <!-- message_id: om_x100b69e7371b5134b0469ca8c911646 --> ─── Q2 答案要点 **关键点**: • **单播**:_invocationList == null,委托直接保存 _target 和 _methodPtr。 • **多播**:Delegate.Combine(a, b) 创建**新委托**,其 _invocationList 是 Delegate[] { a, b }。 • **调用多播委托**:内部循环遍历 _invocationList,依次调用每个委托。**任何一个抛出异常,后续订阅者不会收到通知**——这是著名的"多播委托异常陷阱"。 • **事件 vs 委托字段**: • 委托字段:public Action Click; → 外部可读、可写、可 = 整体赋值、可 null 清空所有订阅者。 • 事件:public event Action Click; → 外部只能 += / -=,**无法**整体赋值或清空;编译器生成的 add/remove 访问器默认用 Interlocked.CompareExchange 保证线程安全。 **深度解析**: // Delegate.Combine 的伪代码实现 public static Delegate Combine(Delegate a, Delegate b) { if (a == null) return b; if (b == null) return a; return a.CombineImpl(b); // MulticastDelegate.CombineImpl → 创建新委托 } CombineImpl 内部会: 1. 把 a 和 b 都展开成扁平数组(如果它们本身是多播)。 2. 创建一个**新的** MulticastDelegate 实例,分配新的 _invocationList。 3. 返回这个新实例。 **关键**:a += b **不会修改 a**,而是创建新委托并赋值给 a!原 a 引用的委托实例不受影响。 **事件 vs 委托字段对比表**: | 维度 | public Action F; | public event Action F; | | ---------- | ---------------- | ----------------------------- | | 外部读取 | ✅ 可读 | ❌ 不可读 | | 外部 += | ✅ | ✅ | | 外部 -= | ✅ | ✅ | | 外部 = 整体赋值 | ✅ | ❌ 编译错误 | | 外部 null 清空 | ✅ | ❌ 编译错误 | | 线程安全 | ❌ 默认非线程安全 | ✅ Interlocked.CompareExchange | **易错点提醒**: • 在类的**外部**,事件只能作为 += / -= 的左操作数——不能 event = Action(...) 整体赋值。 • 但事件触发(Click?.Invoke(...))只能在**声明类内部**进行——这就是封装性的体现。 **扩展思考**: • WPF 的 Button.Click 事件为什么要设计成"只能内部触发"?如果不这样封装,会出现什么安全问题?(提示:从"恶意代码主动触发事件"的角度思考。) --- <!-- message_id: om_x100b69e7344b28a0b12535f58a38eae --> ─── Q3 答案要点 **1. 泄漏根因**: CommandManager.RequerySuggested 是 WPF 的**进程级静态事件**。它的 add / remove 内部维护一个 Delegate.Combine 累积的订阅链(Delegate[]),**强引用**所有订阅者。 每个 RelayCommand 实例把自己 CanExecuteChanged 的 add 访问器订阅到 CommandManager.RequerySuggested 上。这意味着: • CommandManager 的静态字段强引用订阅者委托 • 委托强引用 RelayCommand 实例(_target) • RelayCommand 强引用 _execute(Action<object?>) • Action<object?> 通过闭包捕获,强引用 ViewModel 整条链路:**静态 CommandManager → 委托链 → RelayCommand → Action → ViewModel** → ViewModel 关闭后**永远无法被 GC 回收**,因为 CommandManager 的静态字段在进程生命周期内一直存活。 **关键陷阱**:即便用 WeakReference 包装 ViewModel 也**没用**——WeakReference 不影响订阅链本身的强引用。 **2. 如何检测**: • **方法 A(dump 对比)**:dotnet-dump 或 Visual Studio 内存快照,对比"关闭窗口前"和"关闭窗口 + GC.Collect() 后"的 heap diff。如果 ViewModel 类型实例数量没减少 → 泄漏。 • **方法 B(Finalizer 探针)**:在 ViewModel 写 ~MyViewModel() => Console.WriteLine("Finalized");,正常 GC 后会输出。**没输出 = 泄漏**。 • **方法 C(API 编程检测)**:``` long before = GC.GetTotalMemory(true); // 关闭 View、清理引用 GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); long after = GC.GetTotalMemory(true); Debug.WriteLine($"Leaked: {after - before} bytes"); **3. 至少 2 种解决方案**: **方案 1:手动通知(彻底切断静态事件链)** public class RelayCommand : ICommand { private readonly Action<object?> _execute; public RelayCommand(Action<object?> execute) => _execute = execute; public bool CanExecute(object? p) => true; public void Execute(object? p) => _execute(p); --- <!-- message_id: om_x100b69e7342fe468b29325d2a95000c --> 📝 **今日总结** - **主题**:委托与事件(本质 / 内存模型 / 闭包) - **核心要点**: a. 委托 = MulticastDelegate 派生类,含 _target / _methodPtr / _invocationList 三字段 b. 多播链是**新** Delegate[],+= 不修改原委托 c. 事件 = 委托字段 + add/remove 访问器,封装性 + 线程安全 d. 闭包 = <>c__DisplayClass 嵌套类,捕获变量 → 隐藏堆分配 e. **核心陷阱**:CommandManager.RequerySuggested 静态事件 → ViewModel 内存泄漏 🎯 **等你回复** 主人对哪道题有想法?或者哪个点想再展开讲细点? - 想动手验证的话,最直接的是写一段闭包陷阱的 demo 在 LINQPad 跑一下。 - Q3 的 RelayCommand 泄漏可以用 WeakReference + GC.Collect() 做个对照实验。 我在这里等你反馈。📚

📡 推送信息

教学日期
2026-08-01
所属阶段
Phase 1(语言基础 · 委托与事件)
消息条数
7 条(飞书 DM teacher-yan-bot 推送)
消息 ID
om_x100b69e736f530a0b03889defb0f189om_x100b69e73679b4a4b3b92dd46c3736eom_x100b69e737d840a0b20fde6e5ed33adom_x100b69e737a0e4a8b21cd7ebb17c24bom_x100b69e7371b5134b0469ca8c911646om_x100b69e7344b28a0b12535f58a38eaeom_x100b69e7342fe468b29325d2a95000c
数据来源
feishu_by_date_query_overlay