C#反射及实际应用

nhb 发布于 24 天前 53 次阅读


反射

反射(Reflection)

反射指程序可以访问、检测和修改它本身状态或行为的一种能力。反射提供了封装程序集,模块和类型的对象。

既然要理解反射,我们首先需要了解程序集、模块、类型和成员,以及它们之间的联系。

  • 程序集(Assembly):程序集是.NET应用程序最小部署、版本控制和安全单元,通常表现为.exe和.dll文件。它是类型的容器,包含一个叫清单(Manifest)的数据结构,也叫数据集元数据,记录程序集包含的许多重要信息。
  • 模块(Module):模块是程序集内部的一个物理文件。模块是包含着中间语言IL指令和元数据的二进制文件。一般一个程序集只包含一个模块。
  • 类型(Type):这是反射最常处理的单位,是对数据的抽象定义。它一般包含类 (Class)、结构体 (Struct)、接口 (Interface)、枚举 (Enum)、委托 (Delegate)。
  • 成员(Member):成员是构成类型的基本元素。种类包括:字段(Field)、属性(Property)、方法(Method)、事件(Event)、构造函数(Constructor)。

他们之间的关系可以比喻成一本书,程序集是整本书,清单是目录,模块是章节,类型是段落,而成员则是段落里的单词。在使用反射的时候,我们实际上通过这些关系来进行操作。

反射的优缺点

优点:

  • 反射提高了程序的灵活性和扩展性。
  • 降低耦合性,提高自适应能力。
  • 允许程序创建和控制任何类的对象,无需提前硬编码目标类。

缺点:

  • 性能损耗: 反射涉及到查找元数据、解析字符串、类型检查等,比直接调用代码慢很多(通常在量级上相差 10-100 倍)。
  • 编译器检查失效: 反射使用字符串(如 "MyMethod")来调用成员。如果你写错了字母,编译器不会报错,程序会在运行时直接崩溃。

反射的根基:类型(Type)

这里的类型指的是Type,它是类的信息类,是反射最基础的使用方式。每当你定义一个类、结构体、接口或枚举时,编译器都会生成对应的元数据,而 Type 对象就是你在运行时访问这些元数据的句柄。

获取类型

获取 Type 类方法有typeof和GetType,前者是运算符而后者则是方法。GetType是object封装的方法。

    int a = 32;
    Console.WriteLine(a.GetType());
    //输出System.Int32
    Console.WriteLine(typeof(int));
    //输出System.Int32

Type也是对象,因此这里获取的都是指向堆上的同一块内存的一个对象。

也可以使用Type的静态方法传入该变量类型具体的类名来获取。

    //如果传入的字符串拼写错误或者没有包括命名空间(找不到),就会返回null
    Console.WriteLine(Type.GetType("System.Int32"));
    //输出System.Int32

获取成员

通过Type可以获取一个类的全部成员,全部成员指的是全部字段、方法、属性等。我们需要使用System.Reflection 的命名空间下的类 MemberInfo 来获取信息。用 Type 中的GetMembers方法来返回一个MemberInfo数组。

获取类信息并打印:

    public class Test {
        public int Id { get; set; }
        public string Name { get; set; }
        private string Secret { get; set; }
    }
    public static void Main(string[] args) {
        Test test = new Test() { Id = 1, Name = "Test" };
        Type type = typeof(Test);
        MemberInfo[] members = type.GetMembers();
        for (int i = 0; i < members.Length; i++) {
            Console.WriteLine(members[i]);
        }
    }

打印如下:

Int32 get_Id()
Void set_Id(Int32)
System.String get_Name()
Void set_Name(System.String)
System.Type GetType()
System.String ToString()
Boolean Equals(System.Object)
Int32 GetHashCode()
Void .ctor()
Int32 Id
System.String Name

以上是通过无参调用GetMembers方法,实际上调用的是GetMembers(Type.DefaultLookup),它会同时获取父类的成员信息,无法获取私有成员的信息。

这里的DefaultLookup是BindingFlags(筛选器) 的默认值,使用BindingFlags就可以拿到需要的成员。

以下是常用的筛选标志:

  • public:查找公共成员。
  • NonPublic查找非公共成员。
  • Instance查找实例成员(非静态)。
  • Static查找静态成员。
  • FlattenHierarchy查找父类中的静态成员。
  • DeclaredOnly只查自己定义的,不查从父类继承来的。

DefaultLookup的值如下:

private const BindingFlags DefaultLookup = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public;

然而直接使用 GetMembers 是一个非常重的操作。它会分配大量内存来创建数组和 MemberInfo对象。因此一般使用分类获取的方式来得到成员。

获取并执行构造函数

构造函数比较特殊,它有单独的方法信息 ConstructorInfo。获取它后可以让我们正常实例化对应的类。所以Type也封装了专门针对构造函数的方法 GetConstructors(以及单数的可过滤方法)。通过下代码,我们可以获取 Test 类的全部构造方法:

Type t = typeof(Test);
ConstructorInfo[] ctors = t.GetConstructors();
for (int i = 0; i < ctors.Length; i++)
{
    Console.WriteLine(ctors[i]);
}

具体结果就是输出三个.ctor方法。

通过传入一个Type数组可以定位获取构造函数,传入的Type数组需要与对应的构造函数有相同数量和相同类型的参数。空数组对应无参构造。

调用时需要使用ConstructorInfo 封装的 Invoke 方法(或者说它的基类封装), Invoke 就需要传入 object 数组来对应实参,返回的是object(实际上是原类,可通过转换来获得原类)。

// 假设构造函数是 public Player(string name, int level)
Type[] paramTypes = { typeof(string), typeof(int) };
ConstructorInfo ctor = t.GetConstructor(paramTypes);

object[] parameters = { "Gemini", 99 };
object instance = ctor.Invoke(parameters);

注意:Invoke 方法一定要严格按照类型顺序和类型数量进行数组构建,否则会直接抛出异常

获取字段和方法

获取字段与方法的写法与上文构造函数部分大同小异,最明显的区别只是使用哪个方法和返回值罢了。二者的主要方法是 GetFields 与 GetMethods ,返回值分别是 FieldInfo 数组与 MethodInfo 数组。

字段:

Type t = typeof(Player);
// 定义搜索范围:实例成员 + 公共 + 非公共
BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
//若字段是私有的则需要配合 BindingFlags
FieldInfo field = t.GetField("health", flags);

//获取全部的字段
FieldInfo[] fields = t.GetFields();

// 读取值 (需要传入具体的对象实例)
int hp = (int)field.GetValue(playerInstance);

// 修改值
field.SetValue(playerInstance, 100);

方法:

如果一个类中有多个同名方法(重载),只靠字符串名字 会导致歧义,所以可以传入指定的参数类型数组Type来精确获取方法。

Type type = typeof(Test);
//假设我现在有两个私有的方法SecretMethod()和SecretMethod(string message)

BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance;
//通过同时传入BindingFlags和Type数组可以精确地指定方法即使是私有的
MethodInfo methodInfo = type.GetMethod(SCERET_NAME, flags, new Type[] { typeof(string) });
//通过 Invoke 方法传入实例与参数数组来调用方法
methodInfo.Invoke(test, new object[] { "Hello, World!" });

Activator

Activator是一个用于快速实例化的静态类,能将Type对象快速实例化为对象。Activator不属于反射的命名空间,但是是反射非常常用的类。主要方法是 CreateInstance 方法,传入类信息可以默认调用无参构造函数来创建对象的实例。一般可以在类信息后传入object的可变长数组来判读调用哪个构造函数。

Type t = typeof(Test);
//默认无参构造
Test test1 = Activator.CreateInstance(t) as Test;
//调用2个int参数的构造函数
Test test2 = Activator.CreateInstance(t, 1, 2) as Test;

若某个类是单例模式,构造函数是私有的,还可以使用Activator来强行实例化。

Type t = typeof(GameManager);
// 第二个参数 true 表示:允许调用非公共(NonPublic)构造函数
object obj = Activator.CreateInstance(t, true);

性能问题:使用Activator的耗时相比于使用ConstructorInfo来说较慢,如果是在游戏中启动加载配置Activator的性能损耗完全可以忽略不计,但是绝对不要再Updata这种函数中使用Activator。

程序集类(Assembly):

程序集类主要用于加载其他程序集,并获得其他程序集的类信息。如果想用非自身程序集的信息,就必须先加载程序集。最常见的程序集就是.dll文件。

加载程序集

加载程序集有Load、LoadFrom、LoadFile三种方法,Load是加载处于同一文件夹下的程序集,传入参数是程序集名称;而后二者则是加载不同文件夹下的程序集,LoadFrom传入包含程序集清单的路径,LoadFile则传入完全限定路径。

通过GetTypes获取程序集的全部类。

    Assembly assembly = Assembly.LoadFrom(dllPath);
    Type[] types = assembly.GetTypes();

使用程序集

那么我们如果想获取指定类,只需要通过 GetType 方法,传入类名就行了(前提是知道类名)。

    //加载程序集
    Assembly assembly = Assembly.LoadFrom(dllPath);
    //获取这个程序集的Test类型
    Type t = assembly.GetType("Test");        

反射与特性的结合

反射与特性结合后才是完全体。

方法

MemberInfo类提供了 IsDefined 、 GetCustomAttribute和 GetCustomAttributes几种方法。

  • IsDefined bool类型 ,只想知道“有没有”挂这个特性,不关心特性里的具体数据。性能最高。
  • GetCustomAttribute Attribute类型, 获取单个特性实例,用来读取其中的属性(如描述文字、数值等)。
  • GetCustomAttributes Attribute[]类型, 获取目标成员上挂载的所有特性。

假设我们定义了一个自定义特性:

[AttributeUsage(AttributeTargets.All)]
public class DeBugInfoAttribute : Attribute {
    public string Message { get; }
    public DeBugInfoAttribute(string msg) => Message = msg;
}

检查类上是否有特性:

Type t = typeof(TestClass);

// 方式 1:快速判断
if (t.IsDefined(typeof(DeBugInfoAttribute), false)) { 
    // 参数 false 表示不搜索父类继承的特性
}

// 方式 2:获取并读取内容
var attr = t.GetCustomAttribute<DeBugInfoAttribute>();
if (attr != null) {
    Debug.Log($"类特性信息: {attr.Message}");
}

获取字段或者属性上的特性:

FieldInfo field = t.GetField("health", BindingFlags.Instance | BindingFlags.NonPublic);
var rangeAttr = field.GetCustomAttribute<RangeAttribute>(); // Unity自带的Range
//使用反射获取的特性来检测数值是否越界
if (rangeAttr != null) {
    Debug.Log($"该字段限制范围在: {rangeAttr.min} 到 {rangeAttr.max}");
}

性能优化

反射获取特性是一个 非常耗时 的操作,因为它涉及:

  1. 在元数据表中搜索特定的特性定义。
  2. 实例化该特性对象(调用特性的构造函数)。

反射在泛型方面的使用

在了解反射对应泛型的用处之前,我们先理解Type对象的两种形态:

  • 泛型类型定义 (Open Generic Type): 类似于 List<T>。它是一张“空表单”,编译器知道它是泛型,但不知道 T 具体是什么。
  • 已构造的泛型类型 (Closed Generic Type): 类似于 List<int>。它是一张“填好的表单”,T 已被明确指定。

动态创建泛型实例

通过调用MakeGenericType来动态地创建泛型实例:

// 1. 获取泛型定义
Type genericDefinition = typeof(List<>);

// 2. 获取 T 的类型 (假设运行时才确定是 int)
Type itemType = typeof(int);

// 3. 构造出特定的类型 List<int>
Type constructedType = genericDefinition.MakeGenericType(itemType);

// 4. 实例化
object myList = Activator.CreateInstance(constructedType);

调用泛型方法

使用MakeGenericMethod以动态调用泛型方法:

public class DataManager {
    public void Save<T>(T data) {
        Console.WriteLine($"数据已保存: {data}");
    }
}

// 反射调用步骤:
DataManager manager = new DataManager();
MethodInfo method = typeof(DataManager).GetMethod("Save");

// 使用 MakeGenericMethod 填充泛型参数 T
MethodInfo genericMethod = method.MakeGenericMethod(typeof(string));

// 调用
genericMethod.Invoke(manager, new object[] { "Hello Reflection!" });

逆向获取泛型信息

有时候你拿到一个 object,只知道它是某个 List,你想知道它里面装的是什么:

public void InspectGeneric(object obj) {
    Type t = obj.GetType();

    if (t.IsGenericType) {
        // 获取所有泛型参数 (比如 Dictionary<K,V> 会返回两个)
        Type[] args = t.GetGenericArguments();
        foreach (var arg in args) {
            Console.WriteLine($"泛型参数类型: {arg.Name}");
        }
    }
}
最后更新于 2026-04-03