反射(reflection)让程序在运行时检查类型:一个类有哪些属性,它们的值是什么,有哪些方法,附加了哪些特性。特性(attribute)则是另一半:写在方括号里的声明式标记,例如 [Obsolete] 或 [JsonPropertyName("id")],它们本身没有任何作用,直到编译器或某段代码用反射读取它们。序列化器、ORM、校验库、测试框架和 ASP.NET 路由都是这样工作的。
大多数应用代码很少需要直接使用反射,但了解它的原理,能解释框架的很多行为。
Type 对象:typeof 与 GetType
一切都从一个 System.Type 开始。获取它有两种方式:
输出:
Employee
Manager
Employee
False
True
True
System.Int32
False
Name
需要记住的区别是:typeof(X) 指定的是你写代码时就知道的类型,而 obj.GetType() 是询问一个对象它到底是什么。比较 GetType() == typeof(Employee) 是精确匹配,对派生类会失败,这通常不是你想要的;is 和 IsAssignableFrom 会考虑继承关系。nameof 看起来很像,但它根本不是反射:编译器会把它替换成一个字符串常量。
对 null 引用调用 GetType() 会抛出 NullReferenceException,因为没有对象可以询问。对装箱后的可空值类型调用时,它返回的是底层类型:((int?)5).GetType() 是 System.Int32。
读取和设置属性
GetProperties() 以 PropertyInfo 对象的形式列出类型的公共属性,每个 PropertyInfo 都可以读写某个实例上的值:
输出:
Name String = Mug
Price Decimal = 8.50
Stock Int32 = 12
7.90
True
SUP-77
这个例子说明了三件事:
GetValue返回object,所以值类型会以装箱的形式返回,使用前需要强制转换。- 用一个不存在的名称调用
GetProperty会返回null,接下来对它的调用就会抛出NullReferenceException。使用前请先检查。 BindingFlags.NonPublic | BindingFlags.Instance可以访问私有成员。在工具和测试中这样做是合理的,但它绕过了封装,而且类重构之后会悄无声息地出错。
这个循环基本上就是 CSV 导出器或对象转 JSON 的序列化器的工作方式:遍历属性,读取每个值,再格式化输出。
按名称调用方法和创建对象
GetMethod 查找方法,Invoke 用一个实参数组调用它。Activator.CreateInstance 根据一个 Type 创建对象,插件系统和依赖注入容器就是这样构造运行时才确定的类型的:
输出:
60.00
Decimal WithTax(1 parameters)
String Describe(0 parameters)
True
DeclaredOnly 把列表限制为类本身声明的成员;不加它的话,GetMethods 还会返回从 object 继承来的 ToString、Equals、GetHashCode 和 GetType。Type.GetType("Name") 需要带命名空间的完整名称,对于其他程序集中的类型,还需要加上程序集名称("MyApp.Plugins.Csv, MyApp.Plugins")。
如果被调用的方法抛出异常,Invoke 会把它包装在 TargetInvocationException 中;原始异常在它的 InnerException 里。
特性:供编译器和框架读取的标记
特性写在它所描述的东西前面的方括号里。框架定义了很多特性;下面几个会改变编译器的行为:
public class OrderService
{
[Obsolete("Use PlaceOrderAsync instead.")]
public void PlaceOrder(Order order) { }
// Every call site: warning CS0618: 'OrderService.PlaceOrder(Order)' is obsolete: 'Use PlaceOrderAsync instead.'
// [Obsolete("...", true)] makes it error CS0619 instead.
[Conditional("DEBUG")]
public void Trace(string message) => Console.WriteLine(message);
// Calls to Trace are removed entirely from builds without the DEBUG symbol.
}
[Flags] enum Channels { None = 0, Email = 1, Sms = 2 } // changes how ToString formats combinations
[Serializable] class Snapshot { } // marks a type for legacy binary serialization
还有一些特性由库在运行时读取:System.Text.Json 读取 [JsonPropertyName] 和 [JsonIgnore],ASP.NET Core 模型校验和 Entity Framework 读取 [Required] 和 [MaxLength],ASP.NET 路由读取 [HttpGet("orders/{id}")],测试运行器读取 [Fact] 和 [Test]。特性本身什么都不做;起作用的是查找它的那段代码。
ObsoleteAttribute 这个名称在应用时可以简写为 [Obsolete]:按照惯例,每个特性类的名称都以 Attribute 结尾,而 C# 允许你省略这个后缀。
声明和读取自定义特性
自定义特性就是一个派生自 Attribute 的类。[AttributeUsage] 说明它可以应用在哪些地方。构造函数的参数成为位置实参,可写的公共属性成为命名实参:
输出:
Username must be at most 20 characters
Email is required
Keep the city code short
0
这就是 ASP.NET Core 模型校验借助 System.ComponentModel.DataAnnotations 所做事情的一个微缩版。特性的实参必须是编译期常量(数字、字符串、typeof(...)、枚举值,或由它们组成的数组),因为它们存储在程序集的元数据中。GetCustomAttribute<T>() 是 System.Reflection 中的一个扩展方法;如果你只需要知道某个特性是否存在,还可以用 IsDefined(typeof(T))。
反射的代价
反射用速度和安全性换取了灵活性:
- 速度。 按名称查找成员并通过
Invoke或GetValue调用,比直接调用慢得多,而且会对值类型装箱。需要重复使用时,只查找一次PropertyInfo或MethodInfo并保存下来,或者用Delegate.CreateDelegate或MethodInfo.CreateDelegate把它转换成委托再调用。 - 安全性。 拼错的名称或改变了的签名照样能编译通过,却会在运行时失败。只要可以,就用
nameof(Product.Price)代替字符串"Price",这样重命名时编译器就能发现问题。 - 裁剪与 AOT。 经过裁剪的应用和 Native AOT 应用会移除看起来没有被用到的成员,而反射会让这种分析看不到实际的使用。现代的库(
System.Text.Json、GeneratedRegex、日志)正在转向源代码生成器,它们在编译时完成同样的工作。
把反射用在程序中确实无法事先知道类型的部分:插件、通用工具、序列化器、测试辅助代码。当类型已知时,普通代码、泛型或接口更快,而且有编译器检查。
常见错误
- 用
GetType() == typeof(Base)判断是否属于某个基类。 对派生类型会失败。请使用is或IsAssignableFrom。 - 不检查
null。 没有匹配项时,GetProperty、GetMethod和Type.GetType会返回null。 - 在热点循环中使用反射而不做缓存。 缓存
MemberInfo,或者编译出一个委托。 - 从
Invoke捕获了错误的异常。 真正的异常是TargetInvocationException的InnerException。 - 用魔法字符串表示成员名称。 请使用
nameof。
常见问题
C# 中的反射是什么?
反射是程序在运行时检查类型的能力:列出一个类的属性和方法,按名称读取和设置值,调用方法,创建实例,以及读取特性。它位于 System.Reflection 中,从一个 Type 对象开始。序列化器、ORM、依赖注入容器和测试框架都建立在反射之上。
C# 中 typeof 和 GetType 有什么区别?
typeof(Customer) 在编译时根据类型名解析,不需要对象。obj.GetType() 在运行时对实例调用,返回对象的实际类型,它可能比变量声明的类型更具体:对于 Animal a = new Dog();,a.GetType() 是 Dog。对 null 引用调用 GetType() 会抛出 NullReferenceException。
C# 中如何按名称获取属性值?
obj.GetType().GetProperty("Price") 返回一个 PropertyInfo(如果没有这个公共属性则返回 null),.GetValue(obj) 会以 object 的形式读取它的值,.SetValue(obj, value) 则写入值。如果在循环中这样做,请缓存 PropertyInfo,因为查找才是开销最大的部分。
C# 中如何创建自定义特性?
声明一个派生自 System.Attribute 的类,名称以 Attribute 结尾,并用 [AttributeUsage] 标明它可以用在哪里:[AttributeUsage(AttributeTargets.Property)] class MaxLengthAttribute : Attribute { public int Length { get; } public MaxLengthAttribute(int length) { Length = length; } }。用 [MaxLength(50)] 来应用它,用 property.GetCustomAttribute<MaxLengthAttribute>() 来读取它。
C# 中的 Obsolete 特性有什么作用?
在成员上加 [Obsolete("Use PlaceOrderAsync instead")],编译器会在每个调用处发出警告 CS0618,并附上你的消息。[Obsolete("...", true)] 会把警告变成错误 CS0619。库就是用这种方式淘汰旧 API,而不会让调用方一夜之间无法编译。
C# 中的反射慢吗?
和直接调用相比,是的:按名称查找成员,再通过 MethodInfo.Invoke 或 PropertyInfo.GetValue 调用,通常要慢几十到几百倍,而且会对值类型装箱。在启动、配置和偶尔使用的场景中这没有问题。对于热点路径,请缓存 MemberInfo,只构建一次委托,或者改用泛型或源代码生成器。