🎓 严老师 · C# / WPF 教学
← 全部课程 Day 6 Phase 1(语言基础 · 反射与元数据)

📚 **严老师 Day 6:反射与元数据(CLR Metadata + Reflection AP…

5推送消息数
13925字符数
2026-08-05教学日期
<!-- message_id: om_x100b681399465d00b04cee02b867897 --> 📚 **严老师 Day 6:反射与元数据(CLR Metadata + Reflection API + 现代替代方案)** 早上好,主人。今天我们直入 .NET 运行时底层——**反射**和它依赖的 **CLR 元数据**。这是 Phase 1 的第 6 个知识点,也是 async/await、LINQ 之后,资深 C# 必须掌握的"运行时类型系统"核心。 1. 今日知识点:反射与元数据 是什么 **CLR 元数据(Metadata)** 是描述程序集自身的二进制数据,由编译器在生成 PE 文件时与 IL 代码一起写入。位于 PE 文件的特定段,结构为多张表(TypeDef / MethodDef / FieldDef / Param / Property / Event / AssemblyRef / TypeRef 等 **40+ 张**),表与表之间通过 **RID(Record ID)或 token** 互相引用。 **反射(Reflection)** = 读取这些元数据表的运行时 API(System.Reflection / System.Reflection.Metadata)。换句话说: - **元数据 = 数据**(描述程序集"是什么") - **反射 = API**(查询并操作这些数据) CLR 头(CIL 头 / CLI Header)描述了元数据表的位置和布局,运行时通过它把类型信息装入方法表(MethodTable)。 💡 一个常见误解:"元数据就是反射"——错。元数据是 PE 文件里的二进制表,反射是遍历这些表并构造托管对象的 API(Type / MethodInfo / PropertyInfo)。二者是"数据"和"访问器"的关系。 为什么 没有反射,很多企业级框架根本不存在: - **依赖注入容器**:Microsoft.Extensions.DependencyInjection 扫描 services.AddSingleton<TInterface, TImpl>() 时,通过反射创建实例 - **ORM**:EF Core 的 DbSet<T>.Find(primaryKey) 反射获取主键列 - **AOP 切面**:Castle.DynamicProxy / DispatchProxy 生成代理类时反射获取方法签名 - **序列化器**:System.Text.Json 反射读取 [JsonPropertyName] 和构造函数 - **MVVM**:CommunityToolkit.Mvvm 源生成器分析 [ObservableProperty] 字段生成 INotifyPropertyChanged 代码 可以说,**反射是 .NET 框架生态的"动态地基"**——所有"魔法"都建立在它之上。 怎么用(一):基础反射 // 1. Type 三种获取方式 Type t1 = typeof(string); // 编译期已知类型 Type t2 = "hello".GetType(); // 实例获取 Type t3 = Type.GetType("System.String"); // 程序集限定名字符串查找 // 2. 反射调用方法(注意装箱性能开销) MethodInfo method = typeof(Math).GetMethod("Abs", new[] { typeof(int) }); int result = (int)method.Invoke(null, new object[] { -42 }); // 装箱 -42 到 object[] // 3. 反射读写属性 PropertyInfo prop = typeof(Person).GetProperty("Name"); string name = (string)prop.GetValue(person); prop.SetValue(person, "Alice"); // 4. 反射创建实例 object instance = Activator.CreateInstance(typeof(Person)); 这是最基础的反反射——但**性能差、GC 压力大**。下面看怎么优化。 继续听下条,我会讲性能优化、常见误区、关联知识。 --- <!-- message_id: om_x100b681396ef98a0b3e889eb0c3053b --> 📚 **怎么用(二):从 MethodInfo.Invoke 到 Delegate.CreateDelegate 的性能飞跃** 资深 C# 必修课——反射调用的 3 种性能层级: // ❌ 第一层(慢):MethodInfo.Invoke // 每次调用都装箱所有值类型 + 数组分配 + 反射虚调用 MethodInfo method = typeof(Person).GetMethod("Greet"); string r1 = (string)method.Invoke(person, new object[] { "Alice" }); // 性能基准:~1000-2000 ns / 次 // ✅ 第二层(快):Delegate.CreateDelegate // 一次性创建强类型委托,缓存后接近原生调用 MethodInfo method = typeof(Person).GetMethod("Greet"); Func<Person, string, string> greet = (Func<Person, string, string>) Delegate.CreateDelegate(typeof(Func<Person, string, string>), person, method); string r2 = greet(person, "Alice"); // 性能基准:~15 ns / 次(首次 ~5-10 μs 创建委托) // ✅ 第三层(最快):Expression.Compile // 生成 IL 动态方法,JIT 完全内联展开 ParameterExpression target = Expression.Parameter(typeof(Person), "p"); ParameterExpression nameArg = Expression.Parameter(typeof(string), "n"); MethodCallExpression call = Expression.Call(target, method, nameArg); Func<Person, string, string> greetExpr = Expression.Lambda<Func<Person, string, string>>(call, target, nameArg).Compile(); string r3 = greetExpr(person, "Alice"); // 性能基准:~20 ns / 次(首次 ~50-100 μs 编译) **IL 层本质区别**: MethodInfo.Invoke 编译为: callvirt instance object [System.Reflection]System.Reflection.MethodBase::Invoke(object, object[]) 背后要做 5 件事:① 装箱所有值类型参数 ② 反射检查方法签名 ③ 通过 RuntimeMethodHandle 找方法入口 ④ 反汇编 object[] 还原栈帧 ⑤ 调用 + 装箱返回值。 而 Delegate.CreateDelegate 内部用 Reflection.Emit 或 Expression.Compile **生成新的动态方法**,签名匹配强类型委托,调用时等价于静态方法调用——JIT 能完全内联展开,参数从栈寄存器直接传入(**无装箱**)。 怎么用(三):.NET 7+ 源生成 + AOT 注解 传统反射的最大问题:**AOT 剪裁器无法追踪反射可达性**。比如 Activator.CreateInstance(typeof(T))——剪裁器看不到 T 被使用,会把整个类型删掉,运行时 MissingMethodException。 .NET 7 引入 [DynamicallyAccessedMembers] 解决: public JsonSerializerOptions AddContext<TContext>( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TContext context) where TContext : class { // Roslyn 编译期读懂这个注解,写入 PE 元数据 // IL Trimmer 读到注解 → 保留 TContext 的 PublicMethods // ... } 本质是**把"运行时反射需求"前移到编译期**,让 AOT 工具链能正确处理。 怎么用(四):只读元数据的轻量方案 —— MetadataLoadContext 如果只想扫描程序集类型而不执行代码,用 MetadataLoadContext(不会把程序集加载到主 ALC,避免强引用泄漏): using System.Reflection.PortableExecutable; using System.Reflection.Metadata; using FileStream fs = File.OpenRead("Plugin.dll"); using PEReader peReader = new(fs); MetadataReader mr = peReader.GetMetadataReader(); foreach (TypeDefinitionHandle typeDefHandle in mr.TypeDefinitions) { TypeDefinition typeDef = mr.GetTypeDefinition(typeDefHandle); Console.WriteLine(mr.GetString(typeDef.Name)); } 这是插件系统、扫描器、依赖分析工具的标准做法。 常见误区(5 个) 1. **MethodInfo.Invoke 装箱值类型**——参数数组 object[] 强制装箱 int/enum/struct,GC 压力大 2. **不缓存 MemberInfo**——GetProperties() / GetMethods() 每次遍历元数据表,慢且 GC 多 3. **Assembly.LoadFrom 强引用泄漏**——被加载的程序集永远不会被卸载(直到进程退出),应优先用 AssemblyLoadContext + WeakReference 4. **反射破坏 AOT 剪裁**——Native AOT 不会为反射用到的类型生成元数据,需 [DynamicallyAccessedMembers] 或源生成 5. **BindingFlags 漏写**——GetMethod("Foo") 只查 public 实例方法,internal / static / protected 会返回 null,运行时才暴露 关联知识 - **依赖属性(DP)** 的 Register 内部用 DependencyPropertyDescriptor 元数据表 - **MVVM 源生成**:CommunityToolkit.Mvvm.SourceGenerators 在**编译期**生成 INotifyPropertyChanged 代码——零反射 - **AOT 编译**:.NET 8 PublishAot=true 后反射代码需 [RequiresDynamicCode] / [RequiresUnreferencedCode] 标注 - **表达式树**(昨天内容):Expression.Compile 内部其实**也走反射 + IL 生成**,但只编译一次,缓存后接近原生 下条发 3 道递进面试题。 --- <!-- message_id: om_x100b681396a944a4b303974e5c1a22e --> 📚 **3 道递进面试题(从记忆到应用)** Q1(基础概念) **请简述 CLR 元数据(Metadata)的本质与作用。它与 .NET 程序集的可执行代码(IL)是什么关系?为什么说元数据是"自描述"的?** Q2(原理与辨析) **MethodInfo.Invoke(obj, args) 与 Delegate.CreateDelegate(method, obj)(args) 在 IL 层和性能上有什么本质区别?为什么后者能达到接近原生调用的性能?.NET 7+ 引入的 [DynamicallyAccessedMembers] 注解又解决了反射的什么根本问题?** Q3(实战与深度) WPF 企业级应用经常需要一个**通用属性映射器**——给定 Source / Target 对象和 Dictionary<string, string> 字段映射规则(如 "UserName" -> "Name"),把源对象的属性值拷贝到目标对象的对应属性上。 **请设计一个满足以下需求的实现**: 1. 高性能(>10 万次/秒) 2. 类型安全 3. 支持嵌套属性(如 "Address.Street" -> "HomeStreet") 4. 兼容 AOT 剪裁(trim-friendly) 5. **至少给出 3 种备选方案的性能/适用场景对比**,并说明最终选择哪个 下条发全面复盘讲解(Q1 + Q2)。 --- <!-- message_id: om_x100b6813963574a4b2bbedb5842443a --> 📚 **全面复盘(一):Q1 + Q2 详解** ─── Q1 参考答案 **答案要点**: 1. **CLR 元数据 = 程序集自带的二进制表集合**(TypeDef / MethodDef / FieldDef / Param 等 40+ 张) 2. **本质**:描述程序集内类型、字段、方法、属性、事件、引用等所有**声明性信息** 3. **与 IL 的关系**:元数据**与 IL 一起**写入 PE 文件的 .text 段;IL 中每条指令操作数(如 callvirt 0A000003)的 token 解析**依赖**元数据表 4. **自描述**:PE 文件不再需要额外的 .h / .idl 头文件,所有类型信息都在文件内部——IDE 智能提示、运行时类型发现、跨语言互操作全都依赖它 **深度解析**: 为什么是"自描述"?对比 C++:编译器生成的 .dll 只有指令,**没有类型信息**;想查 MyClass::Method(int, double) 的签名,要么看头文件,要么用 dumpbin /headers。 而 .NET 的 .dll 完全自包含: • ildasm MyLib.dll 能反编译出**完整**的 C# 代码 • 跨程序集引用(AssemblyRef 表)也记录在元数据中 • 泛型、接口、attribute、事件都在元数据里有专属表 运行时机制: • JIT 编译方法时,先从元数据表查出方法签名、参数类型、局部变量类型 • 类型转换 isinst / castclass 通过元数据查继承链 • 反射 API(Type.GetMethods())本质就是**遍历元数据表并构造托管对象** **易错点提醒**: • ❌ "元数据就是反射"——元数据是数据,反射是 API • ❌ "IL 调用通过名字"——IL 用 **token**(4 字节,3 字节表索引 + 1 字节表标识) • ❌ "元数据可以删除"——元数据是 PE 文件结构一部分,无法剥离(除非用 ILLink 实验性剥离) ─── Q2 参考答案 **Invoke vs CreateDelegate 本质区别**: | 维度 | MethodInfo.Invoke | Delegate.CreateDelegate | | ------ | --------------------------------- | ----------------------- | | 调用机制 | 反射虚调用(RuntimeMethodHandle.Invoke) | 直接生成强类型委托 | | 参数处理 | object[] 装箱(值类型 100% 装箱) | 强类型参数栈传递(零装箱) | | 每次开销 | ~1000-2000 ns | ~10-50 ns(接近原生) | | 首次开销 | 低 | 高(生成委托) | | AOT 友好 | ❌ 需保留大量元数据 | ⚠️ 仍需保留被调用方法元数据 | **为什么 CreateDelegate 快**: • 生成强类型 Func<T1, T2, ...> 委托对象,调用时直接走委托 → 方法指针,**不经过反射层** • 委托的 Invoke() 在 JIT 编译期内联展开,参数从栈寄存器直接传入(无装箱) **[DynamicallyAccessedMembers] 解决的根本问题**: • **AOT 剪裁器无法通过反射追踪可达性** • Activator.CreateInstance(typeof(T))——剪裁器看不到 T 被使用,会把 T 整个删除,导致运行时 MissingMethodException • [DynamicallyAccessedMembers] 告诉剪裁器:"我要在运行时反射用到这个类型的某些成员,请保留它们" • 本质是**把"运行时反射需求"前移到编译期**,让 AOT 工具链能正确处理 **深度解析**: Delegate.CreateDelegate 内部用 Reflection.Emit 或 Expression.Compile 生成**新的动态方法**,签名匹配强类型委托。调用时等价于一次静态方法调用,JIT 能完全内联。 **性能基准**(典型 64-bit .NET 8,循环 100 万次): • 原生调用:~5 ns • Delegate.CreateDelegate 缓存后:~15 ns • Expression.Compile 缓存后:~20 ns • MethodInfo.Invoke:~1000 ns [DynamicallyAccessedMembers] 是 **Roslyn + IL 链接器的协作**: 1. Roslyn 编译时读懂注解,记录到 PE 元数据 2. IL 链接器(IL Trimmer)读 PE 元数据,发现 T 的某些成员被"动态访问",保留这些成员 **易错点提醒**: • ❌ "CreateDelegate 总是更快"——首次创建慢(~1-10 μs),**必须缓存** • ❌ "DynamicallyAccessedMembers 是性能优化"——它是**正确性**保证,不是性能优化(性能反而可能下降,因为剪裁掉的代码更多) • ❌ "元数据可以动态生成"——Reflection.Emit 可以动态生成**类型和方法**,但仍依赖 CLR 元数据机制 下条发 Q3 实战详解(3 种方案 + 代码 + 对比 + 易错点)。 --- <!-- message_id: om_x100b681397040ca0b3289eb5cdbd734 --> 📚 **全面复盘(二):Q3 实战与深度(通用属性映射器)** ─── 方案 1:纯反射版(Baseline) public static class Mapper { private static readonly ConcurrentDictionary<(Type, Type), Action<object, object>> _cache = new(); public static void Map<TSource, TTarget>(TSource src, TTarget tgt) { var key = (typeof(TSource), typeof(TTarget)); if (!_cache.TryGetValue(key, out var mapper)) { mapper = BuildMapper<TSource, TTarget>(); _cache[key] = mapper; } mapper(src, tgt); } private static Action<object, object> BuildMapper<TSource, TTarget>() { var srcParam = Expression.Parameter(typeof(TSource), "src"); var tgtParam = Expression.Parameter(typeof(TTarget), "tgt"); var assignments = new List<Expression>(); foreach (var srcProp in typeof(TSource).GetProperties()) { var tgtProp = typeof(TTarget).GetProperty(srcProp.Name); if (tgtProp != null && tgtProp.CanWrite) { var srcAccess = Expression.Property(srcParam, srcProp); var tgtAccess = Expression.Property(tgtParam, tgtProp); assignments.Add(Expression.Assign(tgtAccess, srcAccess)); } } var block = Expression.Block(assignments); return Expression.Lambda<Action<object, object>>(block, srcParam, tgtParam).Compile(); } } **性能**:~50 万次/秒(首次编译慢) **问题**:字段映射硬编码(同名匹配),不支持 Dictionary<string, string> 规则,不支持嵌套属性。 ─── 方案 2:表达式树 + 规则驱动(推荐企业级) // 嵌套属性处理:split "Address.Street" → ["Address", "Street"] Expression BuildAccess(ParameterExpression param, string path) { Expression current = param; foreach (var segment in path.Split('.')) { var prop = current.Type.GetProperty(segment); if (prop == null) throw new ArgumentException($"Property {segment} not found on {current.Type.Name}"); current = Expression.Property(current, prop); } return current; } // 主调用 public static void Map<TSource, TTarget>(TSource src, TTarget tgt, Dictionary<string, string> rules) { var srcParam = Expression.Parameter(typeof(TSource), "src"); var tgtParam = Expression.Parameter(typeof(TTarget), "tgt"); var assignments = new List<Expression>(); foreach (var (srcPath, tgtName) in rules) { var srcAccess = BuildAccess(srcParam, srcPath); // 递归构造嵌套 var tgtProp = typeof(TTarget).GetProperty(tgtName); if (tgtProp != null && tgtProp.CanWrite) { var tgtAccess = Expression.Property(tgtParam, tgtProp); assignments.Add(Expression.Assign(tgtAccess, srcAccess)); } } var block = Expression.Block(assignments); var lambda = Expression.Lambda<Action<TSource, TTarget>>(block, srcParam, tgtParam).Compile(); // 缓存 + 调用(生产环境加锁或 Memoization) lambda(src, tgt); } **性能**:~30-50 万次/秒 **AOT 兼容**:⚠️ 中等——GetProperty 仍需保留元数据,需 [DynamicallyAccessedMembers] 注解 ─── 方案 3:源生成(trim-friendly 终极方案) [Mapper(typeof(User), typeof(UserDto))] public partial class UserMapper { // 源生成器在编译期扫描 [Mapper] 特性 // 生成强类型 Map 方法(零反射、零表达式树) public static partial UserDto MapToDto(User source, Dictionary<string, string> rules); } 源生成器分析 User / UserDto 的所有属性,编译期生成最优代码。 **性能**:~1000 万次/秒(接近手写) **AOT 兼容**:✅ 完全兼容 ─── 3 种方案对比 | 维度 | 方案 1:纯反射 | 方案 2:表达式树 + 规则 | 方案 3:源生成 | | ------ | ------------------- | --------------------------------- | ---------- | | 性能 | ~50 万次/秒 | ~30-50 万次/秒 | ~1000 万次/秒 | | 首次开销 | 中(反射 GetProperties) | 高(Expression.Compile) | 零(编译期) | | AOT 兼容 | ❌ 需大量元数据 | ⚠️ 需 [DynamicallyAccessedMembers] | ✅ 完全 | | 类型安全 | ⚠️ 运行期检查 | ⚠️ 运行期检查 | ✅ 编译期 | | 嵌套属性 | ❌ 不支持 | ✅ 支持 | ✅ 支持 | | 动态规则 | ❌ 编译期固化 | ✅ 运行时规则 | ❌ 需重新生成 | | 适用场景 | 一次性脚本、内部工具 | 企业级动态配置 | 性能敏感的固定映射 | **最终选择**: • **快速原型 / 简单映射** → 方案 1(AutoMapper 早期风格) • **WPF / 企业级 / 需要动态规则** → **方案 2(推荐,配 AOT 注解)** • **极致性能 / AOT 强制** → 方案 3(类似 Mapperly、CommunityToolkit.Mvvm) 💡 业界现状:AutoMapper 已逐渐式微,Mapster 和 Mapperly 是当前主流。Mapperly 就是源生成方案的代表——性能比 AutoMapper 快 5-10 倍,零反射。 ─── Q3 易错点提醒 1. ❌ **每次 Map 都重新编译**——必须缓存 lambda / 委托到 ConcurrentDictionary 2. ❌ **表达式树未考虑线程安全**——Expression.Compile() 本身线程安全,但编译结果共享 OK 3. ❌ **嵌套属性忘了 null 检查**——Address 可能为 null,应在生成代码时插入空检查 (Address != null ? Address.Street : null) 4. ❌ **AOT 编译时用 typeof(T) 但 T 被剪掉**——需 [DynamicallyAccessedMembers] 或显式 Type.GetType("FullName") 5. ❌ **值类型装箱**——如果 TSource 是 struct,反射路径会装箱 src,破坏性能。方案 3 源生成可避免 ─── 4. 扩展思考(引导问题) 5. **如果你要为社区写一个开源的"高性能 Mapper"库,你会选择哪个方案?调研 AutoMapper、Mapster、Mapperly 的实现差异和性能基准** 6. **AOT 时代,反射会消亡吗?源生成 + 编译期元数据能完全替代吗?思考 Reflection.Emit 和 Source Generators 的边界** 7. **如果让你重新设计 CLR 元数据表,会怎么优化?当前 40+ 张表是否合理?思考性能(解析速度)和元数据大小的权衡** ─── 主人,今日教学完成 ✅。请回答上面 3 道面试题(可以只回答 Q1 + Q2,或全答)。我会根据你的反馈: • 评估掌握度(完全 / 部分 / 卡住) • 更新 MEMORY-csharp-wpf.md • 决定 Day 7 是推进 Phase 1 第 7 个知识点(**Span / Memory**),还是降维拆解本主题的卡点 期待你的回复 📚

📡 推送信息

教学日期
2026-08-05
所属阶段
Phase 1(语言基础 · 反射与元数据)
消息条数
5 条(飞书 DM teacher-yan-bot 推送)
消息 ID
om_x100b681399465d00b04cee02b867897om_x100b681396ef98a0b3e889eb0c3053bom_x100b681396a944a4b303974e5c1a22eom_x100b6813963574a4b2bbedb5842443aom_x100b681397040ca0b3289eb5cdbd734
数据来源
feishu_via_memory_mid