Voocii博客
首页博客AI 热榜作品集读书友链工具关于
Voocii© 2026. Built with Next.js & tRPC.
GitHubXEmailRSS


.NET 反射:基本原理和应用

dotnetrick-hayekrick-hayek2024年5月9日

如果你写过 .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 谁、调用谁的什么方法,全部是在程序跑起来之后才决定的。

反射通常用来做这几类事情:

  • 动态创建对象:不知道具体类型,只知道类型名字符串,也能实例化
  • 动态调用方法/读写属性:根据名字反查成员并调用
  • 读取特性(Attribute):比如 ASP.NET Core 的 [HttpGet]、[Required],框架读取这些元数据,再把它们转换成路由或校验行为
  • 实现通用序列化/映射:传统通用序列化库会使用反射遍历属性,并将结果缓存起来;现代 .NET 也可以使用 Source Generator 生成序列化代码
  • 插件化架构:程序启动后扫描某个目录下的 DLL,发现符合某个接口或特性的类型,再动态加载它们

从源代码到反射的元数据流程

二、技术原理:反射到底是"读"了什么

反射能工作,前提是编译出来的程序集里除了可执行的 IL 指令,还带着一份"说明书"——这份说明书就是元数据(Metadata)。

1. 程序集不只是代码,还是一个数据库

一个 .NET 程序集(.dll/.exe)内部大致长这样:

  • IL 指令流:方法体真正的可执行逻辑(中间语言,还没被 JIT 编译成机器码)

  • 元数据表:一组类似数据库表的结构,记录了这个程序集里"有什么"——

    • TypeDef 表:定义了哪些类、结构体、接口,以及它们的基类、实现的接口
    • MethodDef 表:每个方法的名字、参数、返回类型、访问修饰符以及方法体的位置等信息
    • FieldDef / PropertyDef 表:字段和属性的定义
    • CustomAttribute 表:哪个特性挂在哪个类/方法/参数上
    • AssemblyRef / TypeRef 表:引用了哪些外部程序集里的哪些类型

这些表在 Roslyn 编译代码的时候就已经生成好了,跟着 IL 一起被打进 DLL 文件。也就是说,元数据是编译期产物,反射只是运行期去读取它。

2. CLR 加载时把元数据"翻译"成运行时对象

程序运行起来后,CLR 会根据程序集元数据建立可供反射 API 使用的类型和成员信息。具体内部实现会随 .NET 版本变化,使用者通常只需要通过公开的 Type、MethodInfo、PropertyInfo 等类型访问这些信息。

你调用 typeof(User) 或 obj.GetType(),本质上是在获取描述运行时类型的 Type 对象。之后可以通过它查询方法、属性、字段和特性;运行时会缓存部分信息,但不应把所有成员都理解成已经一次性解析完成。

3. Invoke() 是怎么真正调用到方法的

拿到 MethodInfo 之后调用 .Invoke(),背后大致会经历:

  1. 检查你传的参数类型跟方法签名是否匹配
  2. 检查调用者是否有访问权限(比如私有方法要不要开放)
  3. 通过一层动态生成的调用桩(早期是 RuntimeMethodHandle 反射调用引擎,.NET Core 之后大量场景改为用轻量级的 IL Stub / Emit 出的委托)跳转到方法真正的入口地址
  4. 最终落到跟直接调用完全相同的一段 JIT 编译后的机器码上

也就是说,反射最终仍然会执行目标方法,但在此之前需要完成成员查找、参数检查、装箱/拆箱和通用调用准备,有时还会进行异常包装。因此反射调用通常比直接调用慢很多,开销不只是“查找入口”。

直接调用与反射调用路径对比

4. 一个小细节:反射能拿到私有成员吗

通常可以。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 这类托管运行时语言,则把反射当作一等公民正式纳入了标准库。

五、写在最后:什么时候该用、什么时候该忍住

反射很强大,但它是有代价的:

  • 通常比直接调用慢,开销来自成员查找、参数检查、装箱/拆箱和通用调用路径
  • 绕过了编译期类型检查,把本该在编译时发现的错误推迟到了运行时
  • IDE 的重命名重构、静态分析工具,很难追踪到反射里用字符串写死的类型名/方法名

所以业界的实践基本是:框架和基础设施代码可以集中使用反射,业务代码则应根据需求谨慎使用。如果性能是瓶颈,可以缓存 Type、MethodInfo、PropertyInfo,或者使用委托缓存、Expression Tree 编译、Source Generator(源代码生成器)把部分工作提前到编译期。需要注意,表达式树编译和代码生成也有自身的启动成本,应根据实际性能数据选择方案。

评论 (0)

暂无评论,快来抢沙发吧!

目录
  • 一、反射到底是什么
  • 二、技术原理:反射到底是"读"了什么
  • 三、为什么要设计反射这种机制
  • 四、其他语言里有没有类似的东西
  • 五、写在最后:什么时候该用、什么时候该忍住