type
status
date
summary
tags
category
icon

一、概述

设想有这么一种情况,一家公司发布了宿主应用程序后,交给其他公司创建加载项(add-in)来扩展宿主应用程序。那其他公司在对程序类型一无所知的情况下,如何在运行时发现类型的信息、创建类型的实例以及访问类型的成员呢?答案是我们可以通过反射来实现。

二、流程详情

2.1 加载程序集

我们知道,JIT编译器将方法的IL代码编译成本机代码时,会查看代码中引用了哪些类型,然后在运行时,JIT编译器利用程序集的TypeRefAssemblyRef元数据表来确定哪一个程序集定义了所引用的类型。
🔷
Assembly程序集名称 在AssemblyRef元数据表的记录项中,包含了构成程序集强名称的各个部分——包括名称(无扩展名和路径)、版本、语言文化和公钥标记——并把他们连接成一个字符串。然后JIT编译器尝试将与该标识匹配的程序集加载到AppDomain中,例如一个程序集的名称可以为: string name = ”System.Data, version=4.0.0.0, culture=neutral, PublicKeyToken=b77a5c561934e089”
CLR在内部使用System.Reflection.Assembly类的静态Load方法将程序集显式的加载到AppDomain中。在大多数可扩展的应用程序中,Assembly的Load方法是将程序集加载到AppDomain的首选方式
如果Load找到指定的程序集,会返回程序集的Assembly对象的引用。如果Load没有找到指定程序集,会抛出一个System.IO.FileNotFoundException异常。
LoadFrom方式加载
对于制定了路径名的程序集,我们能通过调用Assembly的LoadFrom方法调用(本质上内部还是通过Load查找)
在内部,LoadFrom首先调用System.Reflection.AssemblyName类的静态GetAssemblyName方法。该方法打开指定路径下的文件,找到AssemblyRef元数据表的记录项,提取程序集标识信息(版本、公钥标识等),然后以System.Reflection.AssemblyName的形式返回这些信息。随后,LoadFrom方法在内部调用Load方法,将AssemblyName对象传给它。最后,CLR通过重定向策略,在各个位置查找匹配的程序集。
Load找到匹配的程序集时会加载它,并返回代表已加载程序集的Assembly对象,LoadFom方法将会返回这个对象;如果Load没有找到匹配的程序集,LoadFrom会加载通过实参传递的路径中的程序集。
⚠️
重要提示 一台机器可能同时存在具有相同标识的多个程序集,所以强烈建议每次生成程序集时都更改版本号,确保每个版本都有自己的唯一性标识,确保LoadFrom方法的行为符合预期

2.2 获取程序集中包含的类型

通过反射,我们能够获取程序集中定义了哪些类型,最常用的API是Assembly的ExportedTypes属性。下面例子展示加载一个程序集,并显示其中定义的所有公开导出类型(即定义为Public的类型,它们在程序集外部可见)
上述代码中,System.Type对象代表一个类型的引用,而不是类型定义。众所周知,System.Object在C#中是所有对象的基类,它定义了公共的非虚实例方法GetType。调用这个方法时,CLR会判断指定对象的类型,并返回对该类型的Type对象的引用。由于在一个AppDomain中,每个类型只有一个Type对象,所以可以使用相等和不等操作符判断两个对象是不是相同的类型:

2.3 使用反射获取类型的成员

在上面的步骤中,我们已经知道能够通过Assembly的ExportedTypes属性获取到一个程序集中所有定义为Pulbic的类型,那么如何知道类型中包含了哪些成员,更进一步又如何调用这些成员呢?

2.3.1 发现类型的成员

FCL包含抽象基类System.Reflection.MemberInfo,封装了所有类型成员都通用的一组属性MemeberInfo有许多派生类,每个都封装了与特定类型成员相关的更多属性。如下图所示
notion image
下面代码演示了如何查询类型的成员并显示成员的信息:
代码

基于AppDomain,可发现其中加载的所有程序集。基于程序集,可发现构成它的所有模块。基于程序集或模块,可发现它定义的所有类型。基于类型,可发现它的嵌套类型、字段、构造器、方法、属性和事件。下图总结了用于遍历反射对象模型的各种类型:
notion image

2.3.2 调用类型的成员

当我们知道了类型定义的成员后便可以调用他们。下表展示了调用一种成员需要的方法
成员类型
调用(invoke)成员的方法
FieldInfo
调用GetValue获取字段的值 调用SetValue设置字段的值
ConstructorInfo
调用Invoke构造类型的实例并调用构造器
MethodInfo
调用Invoke来调用类型的方法
PropertyInfo
调用GetValue来调用的属性的get访问器方法 调用SetValue来调用属性的set访问器方法
EventInfo
调用AddEventHandler来调用事件的add访问器方法 调用RemoveEventHandler来调用事件的remove访问器方法
以下示例应用程序演示了用反射来访问类型成员的各种方式。总共提供了三个不同的方法,他们利用反射来访问SomeType的成员。三个方法用不同的方式做相同的事情
  • BindToMemberThenInvokeTheMember:演示了如何绑定到成员并调用它
  • BindToMemberCreateDelegateToMemberThenInvokeTheMember:演示了如何绑定到一个对象或成员,然后创建一个委托来引用该对象或成员,如果需要在相同的对象上多次调用相同的成员,这个技术的性能比上一个要好
  • UseDynamicToBindAndInvokeTheMember:利用了C#的dynamic基元类型简化成员访问方法。在相同类型的不同对象上调用相同成员时,这个技术还能提供不错的性能,因为针对每个类型,绑定都只会发生一次
代码
运行上面代码会得到以下输出

2.4 构建类型的实例

最后,我们来讨论加载了目标程序集,然后通过Type方法获取到了Type派生对象的引用后,如何构造该类型的实例。FCL提供了以下几个方法
System.Activator的CreateInstance方法
Activator类提供了静态CreateInstance方法的几个重载版本。调用方法时既可传递一个Type对象引用,也可传递标识了类型的String,该方法执行成功后会返回对新对象的引用。
System.Activator的CreateInstanceFrom方法
Activator类还提供了一组静态CreateInstanceFrom方法。它们与CreateInstance的行为相似,只是必须通过字符串参数来指定类型及其程序集。该方法返回的是一个ObjectHandle对象的引用,必须调用ObjectHandleUnwrap方法进行具体化。
System.AppDomain的方法
AppDomain类型提供了4个用于构造类型实例的方法,分别是CreateInstanceCreateInstanceAndUnwrapCreateInstanceFromCreateInstanceFromAndUnwrap。这些方法的行为和Activator类的方法相似,区别在于他们都是实例化方法,允许指定在哪个AppDomain中构造对象。
System.Reflection.ConstructorInfo的Invoke实例方法
使用一个Type对象引用,可以绑定到一个特定的构造器,并获取对构造器的ConstructorInfo对象的引用。然后,可利用ConstructorInfo对象引用来调用它的Invoke方法。类型总是在调用AppDomain中创建,返回的是对新对象的引用
利用这些方法,可为除数组(System.Array派生类型)和委托(System.MulticastDelegate派生类型)之外的所有类型创建对象。如果想要创建数组类型的对象,需要调用Array的静态CreateInstance方法。创建委托则要调用MethodInfo的静态CreateDelegate方法。

2.5 性能优化:使用绑定句柄减少内存消耗

许多应用程序都绑定了一组类型(Type 对象)或类型成员(MemeberInfo 派生对象),并将这些对象保存在某种形式的集合中。以后,应用程序搜索这个集合,查找特定对象,然后调用(invoke)这个对象。这个机制很好,只是有个小问题:Type 和 MemberInfo 派生对象需要大量内存。所以,如果应用程序容纳了太多这样的对象,但只是偶尔调用,应用程序消耗的内存就会急剧增加
CLR 内部用更精简的方式表示这种信息。CLR 之所以为应用程序创建这些对象,只是为了方便开发人员。CLR 不需要这些大对象就能运行。如果需要保存/缓存大量 Type 和 MemberInfo 派生对象,开发人员可以使用运行时句柄(runtime handle)代替对象以减小工作集(占用的内存)
FCL 定义了三个运行时句柄(全部都在 System 命名空间中),包括 RuntimeTypeHandleRuntimeFieldHandle 和 RuntimeMethodHandle。三个类型都是值类型,都只包含一个字段,也就是一个 IntPtr;这使类型的实例显得相当精简(相当省内存)。IntPtr 字段是一个句柄,引用了 AppDomain 的 Loader 堆中的一个类型、字段或方法。因此,现在需要以一种简单、高效的方式将重量级的 Type 或 MemberInfo 对象转换为轻量级的运行时句柄实例,反之亦然。幸好,使用以下转换方法和属性可轻松达到目的。
  • 要将 Type 对象转换为一个 RuntimeTypeHandle,调用 Type 的静态 GetTypeHandle 方法并传递那个对象 Type 引用。
  • 要将一个 RuntimeTypeHandle 转换为 Type 对象,调用 Type 的静态方法 GetTypeFromHandle,并传递那个 RuntimeTypeHandle
  • 要将 FieldInfo 对象转换为一个 RuntimeFieldHandle,查询 FieldInfo 的实例只读属性 FieldHandle
  • 要将一个 RuntimeFieldHandle 转换为 FieldInfo 对象,调用 FieldInfo 的静态方法 GetFieldFromHandle
  • 要将 MethodInfo 对象转换为一个 RuntimeMethodHandle,查询 MethodInfo 的实例只读属性 MethodHandle
  • 要将一个 RuntimeMethodHandle 转换为一个 MethodInfo 对象,调用 MethodInfo 的静态方法GetMethodFromHandle
以下示例程序获取许多 MethodInfo 对象,把它们转换为 RuntimeMethodHandle 实例,并演示了转换前后的工作集的差异:
编译并运行以上程序,可以得到以下输出:

四、反射的性能

反射是相当强大的机制,它能够允许开发人员在运行时发现并使用编译时还不了解的类型及其成员。但是,它有以下两个缺点:
  • 反射造成编译时无法保证类型的安全性。由于反射严重依赖字符串,所以会丧失编译时的类型安全性。例如,执行Type.GetType(”int”);要求通过反射在程序集中查找名为”int”的类型,代码会通过编译,但在运行时返回null,因为CLR只知道”System.Int32”
  • 反射速度慢。使用反射时,类型及其成员的名称在编译时未知,需要用字符串名称标识每个类型及其成员,然后在运行时发现它们。也就是说,反射机制会不停地执行字符串搜索。通常,字符串搜索执行的是不区分大小写的比较,这会进一步影响速度。
基于上述原因,最好避免利用反射来访问字段或调用方法/属性。应该利用以下两种技术之一开发应用程序来动态发现和构造类型实例。
  • 让类型从编译时已知的基类派生。在运行时构造派生类的实例,将对它的引用放到基类型的变量中(利用转型),再调用基类型定义的虚方法
  • 让类型实现编译时已知的接口。在运行时构造类型的实例,将对它的引用放到接口类型的变量中,再调用接口定义的方法

五、总结

事实上,由于反射并不具备良好的性能和编译时不能保证类型的安全性,我们只有在极少数的场景下才需要使用反射实现功能。下面是常见的两种场景
  1. 如果类库需要理解类型的定义才能提供丰富的功能,就适合使用反射。例如Microsoft Visual Studio设计器在Web窗体或Windows窗体上放置控件时,能利用反射向开发人员展示需要显示的属性
  1. 在运行时,当应用程序需要从特定程序集中加载特定类型以执行特定任务时,也要使用反射
构建可扩展应用程序时,接口是中心。可用基类代替接口,但接口通常是首选的,因为它允许加载项开发人员选择他们自己的基类

六、推荐链接

 
《Unity3D主程手记》笔记:3D模型合并Unity UGUI模块 Graphic类和UI重建(Rebuild)
  • Twikoo
  • Cusdis