反射(reflection) 让代码在运行时取得程序集、类型和成员的元数据,并按需创建对象、读写成员或调用方法。
只按名称查找会撞上重载、继承和可见性边界;动态调用还会把目标方法的异常包装成 TargetInvocationException。
用明确的 Type、签名和 BindingFlags 缩小查找范围,检查空结果与目标类型,并在裁剪或 Native AOT 发布流程中验证反射路径。
是什么,为什么存在
反射是 .NET 在运行时公开程序元数据的一组 API。程序集保存类型定义,类型再公开构造函数、方法、属性、字段、事件和特性等记录。普通 C# 调用由编译器绑定到成员,反射则把「查找哪个成员」推迟到程序运行时。
反射的入口通常是 System.Type。typeof(Order) 取得编译期已知类型,value.GetType() 取得对象的实际运行时类型,assembly.GetType(name) 则按名称在指定程序集中查找类型。三种方式表达的已知信息不同,不应为了「更动态」而把稳定的 typeof 改成字符串。
当框架只能在运行时知道参与类型时,反射有实际用途。测试运行器发现带标记的方法,依赖注入容器选择构造函数,序列化器读取属性,插件宿主识别实现接口的类型。共同点是框架代码不能在编译时直接写出所有业务类型。
反射也会把编译期错误推迟成运行时错误。拼错名称可能得到 null,重载不明确可能抛出 AmbiguousMatchException,实参与签名不匹配会在调用边界失败。能用接口、泛型或普通委托直接表达的固定调用,通常不需要反射。
元数据可见不等于业务上允许调用。若用户输入可以任意选择类型或成员,程序就把内部 API 变成了一种动态命令语言。公开边界应把外部名称映射到小型白名单,而不是直接传给 Type.GetMethod() 或 Activator.CreateInstance()。
工作原理
元数据对象形成导航图
Assembly 表示已加载的程序集,Type 表示一种类型声明。Type.GetMembers() 返回抽象的 成员信息(member information) ,具体结果再表现为 MethodInfo、PropertyInfo、FieldInfo、EventInfo 或 ConstructorInfo。这些对象描述成员,也提供部分动态操作入口。
下面的图同时包含导航关系和主要类型层级。Type 本身也派生自 MemberInfo,因为嵌套类型可以作为另一个类型的成员出现;图中只画出日常查找最常用的路径。
反射对象不是成员值。PropertyInfo 描述属性的类型与访问器;调用 GetValue() 时才读取某个目标对象。MethodInfo 描述签名;调用 Invoke() 时才把目标对象和实参数组交给运行时。
Type 来自不同的已知条件
typeof(T) 不需要对象实例,也能表示接口、开放泛型或数组类型。GetType() 只能在非空对象上调用,但会返回派生对象的实际类型。泛型代码中的 typeof(T) 是类型参数当前构造出来的类型,不一定等于变量所指对象的运行时类型。
字符串查找还必须解决程序集身份。Type.GetType("Namespace.Widget") 并不会搜索进程中每个程序集;对其他程序集中的类型,通常需要程序集限定名或先选定 Assembly 再调用 GetType()。找不到时应处理 null,或在契约要求失败时使用带 throwOnError 的重载。
查找由名称、签名和标志共同决定
不带参数的 GetMethods()、GetProperties() 等方法主要返回公开成员。需要非公开、静态或仅当前类型声明的成员时,应显式组合 绑定标志(binding flags) 。使用筛选重载时,通常至少要从 Instance/Static 和 Public/NonPublic 两组各选一个。
| 意图 | 常用标志或参数 | 仍需确认 |
|---|---|---|
| 公开实例成员 | `Public | Instance` |
| 当前类型自己声明的成员 | 加 DeclaredOnly | 是否遗漏需要的基类成员 |
| 公开与非公开实例成员 | `Public | NonPublic |
| 一个方法重载 | 名称加形参 Type[] | 泛型、ref 与可空契约 |
成员名称不能标识一个重载。GetMethod("Quote") 在同名重载存在时可能失败;给出 [typeof(decimal), typeof(string)] 才把查找限定到具体形参类型。返回 MethodInfo 后仍应检查静态或实例目标、返回类型和应用自己的允许规则。
调用分成解析和执行
反射调用先解析元数据,再执行目标成员。MethodInfo.Invoke(target, arguments) 对实例方法要求兼容的 target,对静态方法使用 null。参数放在 object?[] 中,因此值类型会装箱,编译器也不会替你完成普通调用点上的可空分析。
若目标方法自身抛出异常,Invoke() 会抛出 TargetInvocationException,原始异常位于 InnerException。参数数量、参数类型或目标对象错误则可能在目标方法开始前失败,不属于目标异常。日志与重试策略必须区分这两个阶段。
特性读取可能创建对象
特性(attribute) 记录在元数据中。GetCustomAttribute<T>() 等 API 返回特性对象,意味着运行时会按记录调用特性构造函数并设置命名实参。特性构造函数因此不应执行网络访问、文件写入或其他隐藏工作。
只需要检查特性类型与构造实参时,CustomAttributeData 可以读取元数据表示而不实例化特性。它适合扫描工具和诊断器,但返回的是 CustomAttributeTypedArgument 等描述对象,不是已经执行过构造函数的业务对象。
示例
下面三个示例依次检查成员、选择并调用重载,再按特性发现受约束的实现。当前环境没有 .NET SDK 或 C# 编译器,因此代码块按仓库约定标明未执行,输出块不伪造结果。
检查当前类型的公开实例成员
第一个程序把查找限制为 Order 自己声明的公开实例成员。它显式排序结果,只是为了让诊断输出稳定;业务逻辑不应依赖反射 API 的返回顺序。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Linq;
using System.Reflection;
Type orderType = typeof(Order);
BindingFlags flags = BindingFlags.Public
| BindingFlags.Instance
| BindingFlags.DeclaredOnly;
foreach (MemberInfo member in orderType.GetMembers(flags)
.OrderBy(member => member.MemberType)
.ThenBy(member => member.Name))
{
Console.WriteLine($"{member.MemberType}: {member.Name}");
}
Order order = new ExpressOrder { Id = 42, Total = 19.95m };
Console.WriteLine($"declared: {orderType.Name}");
Console.WriteLine($"runtime: {order.GetType().Name}");
public class Order
{
public int Id { get; init; }
public decimal Total { get; init; }
private void Recalculate() { }
}
public sealed class ExpressOrder : Order { }# not executed here: the .NET SDK and C# compilers are unavailableDeclaredOnly 排除了继承成员,Public | Instance 又排除了 Recalculate()。属性的公开访问器也是方法,所以 GetMembers() 的结果不只包含两条 Property 记录。需要纯属性列表时,直接调用 GetProperties(flags) 比先取全部成员再按类型筛选更准确。
变量的静态类型是 Order,但 order.GetType() 返回 ExpressOrder。插件、代理和 ORM 实体中经常出现这项差异。开始反射前,应先写清契约针对声明类型还是实际运行时类型。
精确选择重载并保留原始异常
第二个程序用形参类型数组选择双参数重载。它分别处理正常结果和目标方法抛出的异常,不把业务失败误报成反射基础设施失败。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Reflection;
Type calculatorType = typeof(ShippingCalculator);
MethodInfo quote = calculatorType.GetMethod(
nameof(ShippingCalculator.Quote),
[typeof(decimal), typeof(string)])
?? throw new MissingMethodException(calculatorType.FullName, "Quote");
var calculator = new ShippingCalculator();
object? result = quote.Invoke(calculator, [12.5m, "EU"]);
Console.WriteLine($"quote: {result}");
try
{
quote.Invoke(calculator, [-1m, "EU"]);
}
catch (TargetInvocationException error)
when (error.InnerException is ArgumentOutOfRangeException inner)
{
Console.WriteLine($"inner: {inner.GetType().Name}");
}
public sealed class ShippingCalculator
{
public decimal Quote(decimal weight) => Quote(weight, "local");
public decimal Quote(decimal weight, string zone)
{
ArgumentOutOfRangeException.ThrowIfNegative(weight);
return zone == "EU" ? weight * 2m : weight;
}
}# not executed here: the .NET SDK and C# compilers are unavailablenameof 防止方法改名后留下无提示的字符串,但它不能选择重载。真正消除歧义的是形参类型列表。若方法包含 ref 或 out 参数,对应元素必须使用 typeof(T).MakeByRefType(),调用后还要从实参数组读取更新后的值。
捕获筛选器只处理内部异常为 ArgumentOutOfRangeException 的包装异常。其他 TargetInvocationException 会继续传播,参数绑定阶段抛出的异常也不会进入这个分支。生产代码还应保留原始堆栈与操作上下文,而不是只打印消息。
用接口与特性约束类型发现
最后一个程序只实例化同时满足接口、具体类型和标记特性三项条件的类型。反射负责发现候选者,普通接口负责后续调用,因此动态边界保持得很窄。
// # not executed here: the .NET SDK and C# compilers are unavailable
using System;
using System.Linq;
using System.Reflection;
var commands = Assembly.GetExecutingAssembly()
.GetTypes()
.Where(type => typeof(ICommand).IsAssignableFrom(type))
.Where(type => !type.IsAbstract && !type.ContainsGenericParameters)
.Select(type => new
{
Type = type,
Marker = type.GetCustomAttribute<CommandAttribute>()
})
.Where(candidate => candidate.Marker is not null)
.OrderBy(candidate => candidate.Marker!.Name);
foreach (var candidate in commands)
{
if (Activator.CreateInstance(candidate.Type) is ICommand command)
Console.WriteLine($"{candidate.Marker!.Name}: {command.Execute()}");
}
public interface ICommand
{
string Execute();
}
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CommandAttribute(string name) : Attribute
{
public string Name { get; } = name;
}
[Command("status")]
public sealed class StatusCommand : ICommand
{
public string Execute() => "ready";
}# not executed here: the .NET SDK and C# compilers are unavailable接口检查阻止无关类型进入激活路径,特性则提供稳定的外部名称。Activator.CreateInstance(Type) 仍要求这里的候选类型具有可用的无参构造函数;真实插件协议应在启动时验证这一约束,并对重复命令名给出确定错误。
扫描当前小程序集适合示例,但大型应用不应每次请求都重复扫描。通常在有明确生命周期的启动阶段构建不可变注册表。若类型来自外部程序集,还要单独设计加载上下文、依赖解析、卸载和信任边界。
陷阱
只按方法名查找
修复方法: 同时指定形参类型、泛型形态和所需标志,并用 ?? throw 在解析阶段产生含类型与签名的错误。把解析放到启动验证中,避免第一次真实请求才发现配置错误。
漏掉一组 BindingFlags
修复方法: 用一句话写出查找意图,再分别选择 Public/NonPublic、Instance/Static 和可选的 DeclaredOnly。用至少一个继承成员和一个私有成员测试边界。
依赖枚举顺序
修复方法: 按公开规则选择唯一候选者,例如标记特性或精确签名;零个和多个候选者都应明确失败。排序只能稳定展示,不能代替业务选择规则。
把 CanWrite 当作公开 setter
修复方法: 若协议只允许公开写入,检查 property.SetMethod?.IsPublic == true。再按字段白名单、空值和转换规则校验输入;需要构造期不变量时,优先调用明确构造函数或工厂。
丢失目标异常
修复方法: 先区分解析、绑定和目标执行阶段。目标异常按应用策略保留或重新抛出原始异常,基础设施错误则携带成员签名与目标类型单独报告。
把外部字符串直接变成调用
修复方法: 把外部标识映射到应用定义的命令表,每项绑定一个已审核委托或受约束类型。程序集来源、接口实现和构造方式都要在进入注册表前验证;隔离插件还需要进程或平台级安全边界。
忽略裁剪和 Native AOT
修复方法: 在真实发布配置中启用分析器并处理警告。能静态表达成员需求时使用 DynamicallyAccessedMembers 等注解;模式过于动态时缩小反射边界、声明限制,或改用源生成器和显式注册。
查找、调用与部署边界
Type 身份不只是一段名称
类型的 FullName 不包含完整程序集身份。两个程序集可以声明同名类型,插件也可能在不同的 AssemblyLoadContext 中加载相同程序集的不同实例。日志可显示名称帮助人阅读,但注册表和缓存应使用实际 Type 或明确的程序集身份,而不是只用短名称。
IsAssignableFrom() 按运行时类型身份和继承/接口关系判断兼容性。名称相同不保证可赋值。插件发生看似不可能的强制转换错误时,应同时检查契约程序集由哪个加载上下文载入,而不是继续比较字符串。
继承会改变候选集合
公开实例方法通常会沿继承层级出现在查找结果中,DeclaredOnly 则把结果限制到当前 Type。基类私有成员不会像普通继承成员那样成为派生类的成员。要检查每一层自己声明的私有成员,需要逐层访问 BaseType 并明确执行查询。
FlattenHierarchy 的名字很容易误导。它影响继承层级上的部分静态成员,不表示「取回所有基类私有内容」,也不是实例成员的通用递归开关。查找规则应围绕具体 API 与目标成员种类写测试。
接口还有独立边界。检查一个类型是否实现接口可用 interfaceType.IsAssignableFrom(candidate);若要处理开放泛型接口,则需要检查候选接口的 IsGenericType 和 GetGenericTypeDefinition()。只比较接口名称会丢失泛型实参与程序集身份。
属性、字段和方法不是同一种成员
属性是访问器方法组成的元数据抽象,不等于幕后字段。自动属性可能生成字段,但字段名称属于编译器实现约定,不应成为序列化或映射协议。需要属性时用 GetProperties(),需要字段时用 GetFields(),不要从 GetMembers() 的混合结果猜测。
索引器也是属性,但 GetIndexParameters() 非空,调用 GetValue() 或 SetValue() 时需要索引实参。只处理普通属性的映射器应明确排除索引器。写入前还应分别检查 getter、setter 的存在与可见性,而不是只看 CanRead 和 CanWrite。
方法签名还包含泛型参数数量、ref/out 形态和调用约定。typeof(int) 与 typeof(int).MakeByRefType() 不是同一个形参类型。处理泛型方法定义时,先验证 IsGenericMethodDefinition 和约束,再用 MakeGenericMethod() 构造封闭方法。
反射绑定不会复刻所有源码规则
普通 C# 调用由编译器执行重载解析、泛型推断和可空分析。反射从运行时类型与对象数组工作,没有源码调用点的全部信息。不要假定 Invoke() 会按你的业务协议解析字符串、自动选择最合适的重载或执行领域转换。
若动态入口接收文本,应在反射外定义解析层。它按允许的目标类型处理枚举、可空值、日期、区域设置和空文本,再把类型正确的对象交给成员。这样,输入错误与反射错误有不同且稳定的报告方式。
可选参数也需要明确策略。反射调用可以在特定绑定路径中使用 Type.Missing 请求默认值,但这不等于省略数组元素;API 演进后,默认值还可能与调用方预期不同。面向外部输入的命令协议最好显式定义每个参数及其版本。
调用会跨越异常边界
TargetInvocationException 表示目标构造函数或方法开始执行后抛出了异常。它不是业务异常的新语义,只是反射调用边界的包装。需要按原始异常类型处理时,应查看 InnerException,并避免用 throw inner; 重置堆栈。
反射前置阶段也有自己的失败:成员不存在、匹配歧义、目标对象类型错误、参数数量错误、参数类型不兼容或成员不可调用。把全部异常捕获成一个「reflection failed」会损失可操作信息。诊断事件至少应记录阶段、稳定成员签名和目标类型,但不要记录敏感实参。
ref 与 out 参数通过实参数组传入。调用完成后,运行时把更新值写回数组对应位置。若封装器丢弃数组或复用错误的数组实例,普通调用可见的输出参数会消失,这类问题需要专门测试。
读取特性有两种成本模型
GetCustomAttributes() 返回实例,特性构造函数和命名属性赋值会执行。构造函数抛错时,扫描过程可能在业务代码尚未启动前失败。特性设计应保持数据化,消费者则应决定实例化失败是跳过候选者还是终止启动。
CustomAttributeData.GetCustomAttributes(member) 读取构造函数、位置实参与命名实参的描述,不创建特性实例。扫描器只需要路由名或版本号时,可以先用这条路径。不过,它仍要校验实参形状,不能把元数据中的值默认视为可信配置。
特性继承也不是统一规则。查询 API 的 inherit 参数、特性的 AttributeUsage.Inherited,以及目标是类型还是被重写成员,都会影响结果。注册协议应明确只接受直接声明还是也接受继承标记,并为重复标记定义冲突规则。
缓存要保留完整选择条件
反射查找可放在启动阶段或其他低频路径,重复调用的热路径可以缓存已经验证的 MemberInfo 或委托。是否值得缓存必须由真实工作负载测量;没有基准时,不应声称某个固定倍数。创建强类型委托还能让后续调用脱离 object?[],但会增加签名适配代码。
缓存键必须包含决定结果的全部条件,通常至少有 Type、成员种类、名称、签名和相关标志。只用方法名会把不同重载合并,只用 FullName 会忽略程序集或加载上下文。面向可卸载插件时,静态缓存持有 Type 或 MemberInfo 还可能阻止加载上下文卸载。
缓存也需要所有权和上限。若外部输入能制造无限类型名或签名组合,无界并发字典会把一次解析优化变成内存增长入口。优先缓存经过白名单验证的小型候选集,并在插件卸载或配置更新时替换整个注册表。
裁剪分析需要看见成员需求
裁剪器根据静态可见的使用关系移除不需要的代码。typeof(Handler).GetMethods() 这类可分析模式和从任意字符串取得类型再扫描全部成员,给分析器的信息完全不同。警告说明静态分析不能证明所需成员会保留,不能只因为开发构建能运行就忽略。
DynamicallyAccessedMembers 可以把「这个 Type 值必须保留哪些成员」传播到参数、返回值或字段。注解必须匹配真实反射操作;写得过宽会保留过多代码,写得过窄仍可能在发布产物中失败。DynamicDependency 与 RequiresUnreferencedCode 解决的是不同形状的问题,也不应被当作通用消警工具。
无法静态描述的大型反射协议适合重新设计。显式注册把类型选择变成普通引用,源生成器可以在编译期生成映射或序列化代码。保留反射时,应把它集中在少数入口,并用目标 RID 的裁剪或 Native AOT 产物运行集成测试。
反射不是隔离机制
访问非公开成员会绕过类型作者希望调用方遵守的普通 API 边界,也容易随内部重构而损坏。它不应成为跨组件协议。测试工具偶尔需要这种访问时,应把依赖集中并接受升级成本,生产集成则优先要求公开契约。
允许加载并实例化第三方程序集意味着执行该程序集中的代码。接口和特性筛选只说明形状,不证明代码安全。需要运行不可信插件时,应使用操作系统进程、权限、资源限制和通信协议形成隔离,不能依赖 AssemblyLoadContext 或反射白名单充当沙箱。
延伸阅读
5个问题 · 2 道输出预测题 · 1 道找错题