如果你写过 .NET,也用过一些框架——比如 ASP.NET Core 的依赖注入、AutoMapper、Newtonsoft.Json、EF Core——那你很可能早就在间接使用反射了。这些框架通常会在启动或构建阶段使用反射读取类型、属性和特性信息,再结合缓存、表达式树或 Source Generator 生成更高效的执行逻辑。
那么,究竟什么是反射、底层怎么实现的、为什么要设计这样一个东西?
简单理解:反射(Reflection)是程序在运行时检查和操作程序集、类型及其成员信息的一组能力。
正常情况下,你写代码的时候需要提前知道要调用哪个类、哪个方法:
var user = new User();
user.SayHello();
这里的 User 和 SayHello 在编译期就已经确定了。但反射能做到这件事:
var assembly = Assembly.Load("MyApp");
var type = assembly.GetType("MyApp.User")
?? throw new InvalidOperationException("找不到类型");
var instance = Activator.CreateInstance(type)
?? throw new InvalidOperationException("无法创建实例");
var method = type.GetMethod("SayHello")
?? throw new InvalidOperationException("找不到方法");
method.Invoke(instance, null);
整段代码里,User 这个类名甚至可以是从配置文件、数据库、命令行参数里读出来的字符串——编译的时候不知道要 new 谁、调用谁的什么方法,全部是在程序跑起来之后才决定的。
反射通常用来做这几类事情:
[HttpGet]、[Required],框架读取这些元数据,再把它们转换成路由或校验行为反射能工作,前提是编译出来的程序集里除了可执行的 IL 指令,还带着一份"说明书"——这份说明书就是元数据(Metadata)。
一个 .NET 程序集(.dll/.exe)内部大致长这样:
IL 指令流:方法体真正的可执行逻辑(中间语言,还没被 JIT 编译成机器码)
元数据表:一组类似数据库表的结构,记录了这个程序集里"有什么"——
TypeDef 表:定义了哪些类、结构体、接口,以及它们的基类、实现的接口MethodDef 表:每个方法的名字、参数、返回类型、访问修饰符以及方法体的位置等信息FieldDef / PropertyDef 表:字段和属性的定义CustomAttribute 表:哪个特性挂在哪个类/方法/参数上AssemblyRef / TypeRef 表:引用了哪些外部程序集里的哪些类型这些表在 Roslyn 编译代码的时候就已经生成好了,跟着 IL 一起被打进 DLL 文件。也就是说,元数据是编译期产物,反射只是运行期去读取它。
程序运行起来后,CLR 会根据程序集元数据建立可供反射 API 使用的类型和成员信息。具体内部实现会随 .NET 版本变化,使用者通常只需要通过公开的 Type、MethodInfo、PropertyInfo 等类型访问这些信息。
你调用 typeof(User) 或 obj.GetType(),本质上是在获取描述运行时类型的 Type 对象。之后可以通过它查询方法、属性、字段和特性;运行时会缓存部分信息,但不应把所有成员都理解成已经一次性解析完成。
拿到 MethodInfo 之后调用 .Invoke(),背后大致会经历:
RuntimeMethodHandle 反射调用引擎,.NET Core 之后大量场景改为用轻量级的 IL Stub / Emit 出的委托)跳转到方法真正的入口地址也就是说,反射最终仍然会执行目标方法,但在此之前需要完成成员查找、参数检查、装箱/拆箱和通用调用准备,有时还会进行异常包装。因此反射调用通常比直接调用慢很多,开销不只是“查找入口”。
通常可以。BindingFlags.NonPublic | BindingFlags.Instance 表示查找非公开的实例成员:
var method = type.GetMethod(
"SayHello",
BindingFlags.Instance | BindingFlags.NonPublic);
但能否成功调用还可能受到运行时、裁剪、AOT 和动态代码限制影响。测试代码中依赖反射调用私有方法通常意味着设计需要重新考虑;框架也更常通过构造函数、公开属性或专门扩展点完成注入。
反射访问私有成员很强大,但也会削弱封装边界。
除了方法,还可以读取属性:
var property = type.GetProperty("Name")
?? throw new InvalidOperationException("找不到属性");
var value = property.GetValue(instance);
Console.WriteLine(value);
BindingFlags.Public、BindingFlags.NonPublic 用于筛选可见性,BindingFlags.Instance、BindingFlags.Static 用于筛选实例或静态成员,BindingFlags.DeclaredOnly 则表示只查当前类型声明的成员。
这是个挺值得琢磨的问题——C# 是强类型、编译期检查很严格的语言,为什么要专门开一个"后门"让类型信息在运行期还能被查询和操作?
主要是这几个现实需求逼出来的:
1. 通用库没法提前认识你的类型
写一个通用 JSON 序列化库的人,不可能提前知道全世界的开发者会定义出什么样的类。一种通用办法是:给我一个 object,我在运行时反射出它有哪些属性,再逐个读取并序列化。现代库也可以使用 Source Generator、显式契约或预生成代码减少运行时反射。
2. 特性(Attribute)需要有人来"读"
[Required]、[Route("/api/users")] 这些特性本身只是一段元数据声明,不会自己产生任何行为。是 ASP.NET Core、模型验证框架在运行时用反射把这些特性读出来,再决定要不要做参数校验、要不要注册路由。没有反射,特性只是一段没人理的注释。
3. 依赖注入容器需要动态组装对象图
传统 DI 容器通常会在运行时检查构造函数和服务注册信息,再动态组装对象图。也可以通过手工注册工厂或使用 Source Generator,把一部分工作提前到编译期完成。
4. 插件化和热插拔架构
企业级系统经常需要"不改主程序,只增加一个插件就能加载新功能"。常见做法是使用反射扫描程序集,发现实现指定接口或带有指定特性的类型,再动态实例化。真实的插件系统还需要处理依赖、版本兼容、加载隔离和卸载,现代 .NET 中通常要结合 AssemblyLoadContext 设计。
5. 工具类需求:调试器、序列化、ORM、测试框架
Visual Studio 调试时能看到对象内部的字段值,Entity Framework 能把数据库记录映射成实体实例,xUnit/NUnit 能自动发现带有 [Fact] 的测试方法。这些工具通常会结合反射、运行时诊断 API、表达式树、缓存或代码生成等机制,反射是其中重要的一部分,但不是唯一基础。
说到底,反射解决的是一个根本矛盾:编译期类型检查带来安全性,但也带来了"死板"。反射为框架和工具代码提供了处理未知类型的能力,但代价是部分检查从编译期推迟到了运行期,因此需要更严格的参数校验、异常处理和测试。
反射不是 .NET 独有的,几乎所有主流语言都在某种程度上支持类似能力,只是实现方式和开放程度差别很大。
Java:几乎是 .NET 反射的孪生兄弟
Java 和 .NET 都提供了成熟的运行时反射 API,概念上比较相似:Java 的 Class、Method、Field,对应 .NET 的 Type、MethodInfo、FieldInfo。但两者的访问控制和类加载行为并不完全相同。Spring 和 Jackson 也会结合反射、缓存和代码生成等机制实现依赖注入与 JSON 序列化。
Python:反射能力是"与生俱来"的
Python 是动态语言,类型信息本来就在运行时才确定,所以它不需要一个专门叫"反射"的独立系统——type()、dir()、getattr()、setattr() 这些内建函数本身就是反射能力的一部分,用起来比 C#/Java 自然得多,代价是没有编译期类型检查兜底。
Python 是动态语言,类型操作本来就是运行时能力的一部分,所以它不需要一个专门叫"反射"的独立系统——type()、dir()、getattr()、setattr() 这些内建函数都可以用于运行时检查和操作对象,可以说本身就是反射能力的一部分。用起来比 C#/Java 自然得多,代价是没有完整的编译期类型检查兜底。
Python 也可以通过类型注解、mypy 或 pyright 提供静态分析,但通常不会像 C# 那样强制完成完整的编译期类型检查。
Go:故意设计得很克制
Go 的 reflect 包提供了 reflect.TypeOf、reflect.ValueOf,但 Go 社区对滥用反射的态度相当谨慎——官方文档里甚至专门写了一篇《The Laws of Reflection》告诫大家谨慎使用,因为反射代码往往难读、难维护、且丢失了编译期检查。这跟 Go 语言本身追求"简单直白"的哲学是一致的。
C++:几乎没有真正意义上的反射
C++ 只有比较有限的 RTTI(运行时类型识别,比如 typeid、dynamic_cast),能做类型判断,但通常拿不到"这个类有哪些方法/字段"这样的完整元数据。C++ 更常使用模板、宏、第三方库或代码生成解决类似问题;静态反射相关能力应以具体 C++ 标准版本和编译器支持情况为准。
Rust:同样偏保守
Rust 默认不带运行时反射,标准库里连 Any 都只能做有限的向下转型(downcast)。想要类似 .NET 那种能力,通常得靠过程宏(proc-macro)在编译期生成代码来模拟,比如 serde 序列化库就是这么干的——用编译期代码生成替代运行期反射,换取更好的性能。
JavaScript:动态到"反射"这个词都显得多余
JavaScript 本身就是动态类型,Object.keys()、for...in、Object.getOwnPropertyNames() 等 API 可以枚举对象成员,typeof 和 instanceof 可以判断值的类型类别;ES6 之后还增加了专门的 Reflect 对象。
可以看出:动态语言(Python/JS)天生自带反射能力,静态语言 里越"系统级/贴近硬件"(C++/Rust),越排斥运行时反射,转而用编译期机制(模板/宏)替代;而 Java/.NET 这类托管运行时语言,则把反射当作一等公民正式纳入了标准库。
反射很强大,但它是有代价的:
所以业界的实践基本是:框架和基础设施代码可以集中使用反射,业务代码则应根据需求谨慎使用。如果性能是瓶颈,可以缓存 Type、MethodInfo、PropertyInfo,或者使用委托缓存、Expression Tree 编译、Source Generator(源代码生成器)把部分工作提前到编译期。需要注意,表达式树编译和代码生成也有自身的启动成本,应根据实际性能数据选择方案。
暂无评论,快来抢沙发吧!