C#基础知识梳理系列十五:反射
反射,一个很有用且有意思的特性。当动态创建某个类型的实例或是调用方法或是访问对象成员时通常会用到它,它是基于程序集及元数据而工作的,所以这一章我们来讨论一下程序集、反射如何工作、如何动态创建类型及对象等相关知识,甚至可以动态创建程序集。
通过本系列的前面章节,我们已经知道,Windows为每个进程分配独立的内存空间地址,各个进程之间不能直接相互访问。Windows对.NET的支持是以宿主和COM的形式实现的,基于.NET平台语言实现的代码文件使用Windows PE的文件格式,CLR其实就是COM,相当于一个虚拟机(当然这个虚拟机可以部署到任意支持它的系统环境中),在安装.NET Framework时,CLR的组件与其他COM一样在Windows系统中享有同等的待遇,当CLR启动初始化时会创建一个应用程序域,应用程序域是一组程序集的逻辑容器,它会随着进程的终止而被卸载销毁,CLR把程序代码所需要的程序集加载到当前(或指定的)应用程序域内。CLR可以以其初始化时创建的应用程序域为基础再创建其他的新应用程序域,两个应用程序域中的代码不能直接访问,当然可以通过“中介”进行数据传送。新的程序域创建完后CLR完全可以卸载它,以同步方式调用AppDomain.Unload方法即可,调用此方法后,CLR会挂起当前进程中的所有线程,接着查找并中止运行在即将卸载的程序域内的线程,然后进行垃圾回收,最后主线程恢复运行。
任何Windows程序都可以寄宿CLR,一台机上可以安装多个版本的CLR。Windows在启动一个托管的程序时会先启动MSCorEE.dll中的一个方法,该方法在内部根据一个托管的可执行文件信息来加载相应版本的CLR,CLR初始完成之后,将程序集加载到应用程序域,最后CLR检查程序集的CLR头信息找到Main方法并执行它。
程序集是所有类型的集合,它还有一个重要的东西就是元数据。JIT就是利用程序集的TypeRef和AssemblyRef等元数据来确定所引用的程序集及类型,这些元数据包括名称、版本、语言文化和公钥标记等,JIT就是根据这些信息来加载一个程序集到应用程序域中。如果要自己加载一个程序集,可以调用类型Assembly的LoadXXX系列方法。
(1) Load重载系列
该方法会按照一定的顺序查找指定目录中的程序集:先去GAC中查找(如果是一个强命名程序集),如果找不到,则去应用程序的基目录、子目录查找。如果都没找到,则抛出异常。如下代码加载程序集MyAssemblyB:
string assemblyName = "MyAssemblyB, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; Assembly assembly = Assembly.Load(assemblyName);
(2) LoadFrom重载系列
加载指定程序集名称或路径的程序集,其在内部调用Load方法,并且还可以指定一个网络路径,如果指定网络路径,则先下载该程序集,再将其加载到程序域,如下代码:
Assembly.LoadFrom("http://solan.cnblogs.com/MyAssembly.dll");
(3) LoadFile重载系列
从任意路径加载一个程序集,并且可以从不同路径加载相同名称的程序集。
在一个项目中,可能程序集之间都有依赖关系,也可以将一个程序集作为资源数据嵌入到一个程序集中,在需要时再加载该程序集,这时通过注册ResolveAssembly事件来加载这个程序集。如下;
AppDomain.CurrentDomain.AssemblyResolve += (sender, arg) => { byte[] buffer = null; using (Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("ConsoleApp.MyAssemblyA.dll")) { buffer = new byte[stream.Length]; stream.Read(buffer, 0, buffer.Length); } return Assembly.Load(buffer); };
以上代码要求必须先将MyAssemblyA.dll文件以资源形式嵌入到ConsoleApp项目中。这样在运行ConsoleApp程序时,如果使用了MyAssemblyA中的类型且未找到MyAssemblyA.dll文件,则会进入上面的事件方法来加载程序集MyAssemblyA。
如果只是想了解一个程序集的元数据分析其类型而不调用类型的成员,为了提高性能,可以调用这些方法:
Assembly.ReflectionOnlyLoadFrom(String assemblyFile) Assembly.ReflectionOnlyLoad(byte[] rawAssembly) Assembly.ReflectionOnlyLoad(String assemblyName)
如果试图调用上面这三个方法加载的程序集中类型的代码,则CLR会抛出异常。
我们知道,在程序集(或模块)内有一个很重要的数据就是元数据,它们描述了类型定义表,字段定义表,方法表等,也就是说所有的类型及成员定义项都会在这里被清楚详细地记录下来。很明显,如果我们拿到了这些“描述信息”,当然就相当于已经明确知道了一个类型及其成员,进而就可以“构造”这个类型,通过反射就可以达到这样的目的。另人高兴的是我们不用分析那些元数据就可以方便地得到程序集内的类型成员,.NET Framework提供了一些与此相关的类定义在命名空间System.Reflection下。
反射提供了封装程序集、模块和类型的对象(Type 类型)。反射机制运行在程序运行时动态发现类型及其成员。
(1)查找程序集内所定义的类型
在将某一程序集加载到应用程序域后,可以通过Assembly的GetExportedTypes方法来获取该程序集所有的公开类型,如下代码:
private void GetTypes() { string assemblyName = "MyAssemblyB, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; Assembly assembly = Assembly.Load(assemblyName); Type[] types = assembly.GetExportedTypes(); foreach (Type t in types) { Console.WriteLine(t.Name); } }
(2)查找类型成员
在命名空间System.Reflection中有一个抽象类型MemberInfo,它封装了与类型成员相关的通用属性,每一个类型成员都有一个对应的从MemberInfo派生而来的类型,并且内置了一些特殊的属性特征,如FieldInfo、MethodBase(ContructorInfo、MethodInfo)、PropertyInfo和EventInfo。可以通过调用类型Type对象的GetMembers方法获取该类型的所有成员或相应成员,如下代码(对上面的GetTypes方法的修改)获取全部成员列表:
Type[] types = assembly.GetExportedTypes(); foreach (Type t in types) { Console.WriteLine(t.Name); MemberInfo[] members = t.GetMembers(); }
Type有一组GetXXX方法是获取对象成员的,以下列出部分方法:
GetConstructor/GetConstructors //获取构造函数 GetEvent/GetEvents //获取事件 GetField/GetFields //获取字段 GetMethod/GetMethods //获取方法 GetProperty/GetProperties //获取属性
并且每个方法都可以接收一个枚举类型BindingFlags的参数指定控制绑定和由反射执行的成员和类型搜索方法的标志。有关BindingFlags 枚举可参考MSDN文档
如下代码获取AudiCar类型的Owner属性和Run()方法:
private void GetTypeMethod() { string assemblyName = "MyAssemblyB, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; Assembly assembly = Assembly.Load(assemblyName); Type t = assembly.GetType("MyAssemblyB.AudiCar"); MethodInfo method = t.GetMethod("Run"); PropertyInfo pro = t.GetProperty("Owner"); }
(3)构造类型实例
在拿到类型及成员信息之后,我们就可以构造类型的实例对象了。FCL提供了几个方法来构造一个类型的实例对象,有关这些方法详细内容,可参考MSDN文档:
Activator.CreateInstance() //重载系列 Activator.CreateInstanceFrom() //重载系列 AppDomain.CurrentDomain.CreateInstance() //重载系列 AppDomain.CurrentDomain.CreateInstanceFrom() //重载系列
如下构造AudiCar类型的实例:
private void TestCreateInstance() { string assemblyName = "MyAssemblyB, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; Assembly assembly = Assembly.Load(assemblyName); Type t = assembly.GetType("MyAssemblyB.AudiCar"); var obj = Activator.CreateInstance(t); Debug.Assert(obj != null); }
看一下调试:
另外,还可以调用类型的构造函数创建实例对象,如下:
obj = t.InvokeMember("AudiCar", BindingFlags.CreateInstance, null, null, null);
如果仅仅得到类型的对象,好像意义并不大,我们更多的是要操作对象,比如访问属性,调用方法等,这一节我们来看一下如何访问成员。
类型Type提供了一个访问目标类型成员的非常靠谱的方法InvokeMember,调用此方法时,它会在类型成员中找到目标成员(这通常指定成员名称,也可以指定搜索筛选条件BindingFlags,如果调用的目标成员是方法,还可以给方法传递参数。),如果找到则调用目标方法,并返回目标访问返回的结果,如果未找到,则抛出异常,如果是在目标方法内部有异常,则InvokeMember会先捕获该异常,包装后再抛出新的异常TargetInvocationException。以下是InvokeMember方法的原型:
public object InvokeMember(string name, BindingFlags invokeAttr, Binder binder, object target, object[] args); public object InvokeMember(string name, BindingFlags invokeAttr, Binder binder, object target, object[] args, CultureInfo culture); public abstract object InvokeMember(string name, BindingFlags invokeAttr, Binder binder, object target, object[] args, ParameterModifier[] modifiers, CultureInfo culture, string[] namedParameters); name 目标方法名称 invokeAttr 查找成员筛选器 binder 规定了匹配成员和实参的规则 target 要调用其成员的对象 args 传递给目标方法的参数
在上一节的最后我们展示了如何调用类型的构造函数来实例化一个对象,下面的代码演示了如何调用对象的方法,其中方法Turn接收一个Direction类型的参数:
string assemblyName = "MyAssemblyB, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; Assembly assembly = Assembly.Load(assemblyName); Type t = assembly.GetType("MyAssemblyB.AudiCar"); var obj = Activator.CreateInstance(t); t.InvokeMember("Turn", BindingFlags.InvokeMethod, null, obj, new object[] { Direction.East });
另外,调用目标对象的方法,还可以以MethodInfo的方式进行,如下:
Type t = assembly.GetType("MyAssemblyB.AudiCar"); var obj = Activator.CreateInstance(t); MethodInfo method = t.GetMethod("Turn"); method.Invoke(obj, new object[] { Direction.Weast });
以下是对属性的读写操作:
Type t = assembly.GetType("MyAssemblyB.AudiCar"); var obj = Activator.CreateInstance(t); //为属性Owner赋值 obj.GetType().GetProperty("Owner").SetValue(obj, "张三", null); //读取属性Owner的值 string name = (string)obj.GetType().GetProperty("Owner").GetValue(obj, null);
对于其他成员(如字段等)的访问,可参考MSDN文档。
反射对泛型的支持
以上的演示都是针对普通类型,其实反射也提供了对泛型的支持,这里只简单演示一下反射对泛型的简单操作。比如我们有如下一个泛型类型定义:
namespace MyAssemblyB { public class MyGeneric<T> { public string GetName<T>(T name) { return "Generic Name:" + name.ToString(); } } }
这个类型很简单,类型MyGeneric内有一个方法,该方法返回带有附加信息” Generic Name:”的名称。先来看一下如何获取指定参数类型为string的泛型类:
private void TestGenericType() { string assemblyName = "MyAssemblyB, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; Assembly assembly = Assembly.Load(assemblyName); Type[] types = assembly.GetExportedTypes(); foreach (Type t in types) { //检测是否泛型(在程序集MyAssemblyB中只定义了一个泛型类型 MyGeneric<T>) if (t.IsGenericType) { //为泛型类型参数指定System.String类型,并创建实例 object obj = Activator.CreateInstance(t.MakeGenericType(new Type[] { typeof(System.String) })); //生成泛型方法 MethodInfo m = obj.GetType().GetMethod("GetName").MakeGenericMethod(new Type[] { typeof(System.String) }); //调用泛型方法 var value = m.Invoke(obj, new object[] { "a" }); Console.WriteLine(value); } } }
调试起来,看一下最终的value值:
反射泛型的时候,要先确定目标类型是泛型,在创建泛型类型实例前,必须调用MakeGenericType方法构造一个真正的泛型,该方法接收一个要指定泛型类型参数的类型数组,同样调用泛型方法前要调用方法MakeGenericMethod构造相应的泛型方法,此方法也接收一个指定泛型类型的类型数组。
前面几节所描述的都是基于已经存在程序集的情况下进行反射,.NET Framework还提供了在内存中动态创建类型的强大功能。我们知道程序集包括模块,模块包括类型,类型包括成员,在动态创建类型的时候也是要遵循这个顺序。动态创建类型是基于元数据的实现方式来实现的,这一部分被定义在命名空间System.Reflection.Emit内,有一系列的XXXBuilder构造器来创建相应的类型对象。我们来看一要动态创建类型,有哪些步骤(这里只是简单演示):
(1) 程序集是老窝,所以要先创建一个程序集:
AssemblyBuilder aBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(new AssemblyName("TempDynamicAssembly"), AssemblyBuilderAccess.Run);
(2) 有了程序集,接下来是模块
ModuleBuilder mBuilder = aBuilder.DefineDynamicModule("NotifyPropertyChangedObject");
(3) 接下来就是创建类型了:
this.tBuilder = mBuilder.DefineType(typeFullName, TypeAttributes.Public | TypeAttributes.BeforeFieldInit);
(4) 现在可以创建类型的成员了,为类型创建一个属性Name。我们知道属性包含字段和对字段的两个访问器,所以应该先创建字段,然后再创建两个访问器方法,这一段是按照IL码的先后顺序来的,如下:
FieldBuilder fieldBuilder = this.tBuilder.DefineField(string.Format("{0}Field", propertyName), propertyType, FieldAttributes.Private); PropertyBuilder propertyBuilder = tBuilder.DefineProperty(propertyName, PropertyAttributes.HasDefault, propertyType, null); MethodAttributes getSetAttr = MethodAttributes.Public | MethodAttributes.SpecialName | MethodAttributes.HideBySig; MethodBuilder getAccessor = tBuilder.DefineMethod(string.Format("get_{0}", propertyName), getSetAttr, propertyType, Type.EmptyTypes); ILGenerator getIL = getAccessor.GetILGenerator(); getIL.Emit(OpCodes.Ldarg_0); getIL.Emit(OpCodes.Ldfld, fieldBuilder); getIL.Emit(OpCodes.Ret); propertyBuilder.SetGetMethod(getAccessor); MethodBuilder setAccessor = tBuilder.DefineMethod(string.Format("set_{0}", propertyName), getSetAttr, null, new Type[] { propertyType }); setAccessor.DefineParameter(1, ParameterAttributes.None, "value"); ILGenerator setIL = setAccessor.GetILGenerator(); setIL.Emit(OpCodes.Nop); setIL.Emit(OpCodes.Ldarg_0); setIL.Emit(OpCodes.Ldarg_1); setIL.Emit(OpCodes.Stfld, fieldBuilder); setIL.Emit(OpCodes.Ldarg_0); setIL.Emit(OpCodes.Ldstr, propertyName); setIL.Emit(OpCodes.Call, this.mBuilder); setIL.Emit(OpCodes.Nop); setIL.Emit(OpCodes.Ret); propertyBuilder.SetSetMethod(setAccessor);
注意,这里面有对事件的操作,可以忽略。
(5) 最后调用类型构造器的CreateType()方法就可以创建该类型了:
tBuilder.CreateType();
该方法返回一个Type类型。
类型创建完成后,我们就可以使用上一节讲的反射相关知识对该类型进行操作了,这里当然是一个简单的类型,如果想创建复杂的类型,比如有方法,事件等成员,那可以发挥你的汇编能力来慢慢折腾吧,也可以体味一下当时汇编程序员们的苦逼!托管下的汇编编码已经很简化了,围绕Emit方法折腾死!如果想研究IL,可以用IL DASM打开托管程序集,慢慢欣赏吧。
在我们的日常开发中,有时用了动态类型还是很方便的,比如当你要创建一个DataGrid的数据源DataTable,但多少列不确定,列的数据类型不确定,列名也不确定的情况下,这时根据要求创建一个动态类型,继而再创建一个该类型的集合就很方便使用了。我封装了一个动态创建类型的类,在本文的结尾提供下载,喜欢的可以拿去。
这里所描述的是动态地在内存创建一个类,关于动态类型dynamic和var,这里就不再瞎掰了,感兴趣的可以去查找相关资料。
反射为我们开发提供了非常便利的编程实践,但使用它也有几点需要注意。
既然是反射,我们在编码时对类型是未知的,如果是已知,就没必要再用反射了, 除非是要做类似分析类型元数据的工具,而我们一般使用反射是要操作其属性字段、调用其方法等,目的是用而不是分析。在编译使用了反射的代码过程中,反射的目标类型是不安全的,很有可能在调用反射出来的类对象时出错,这一点要注意。
反射是基于元数据实现的,所以在使用反射过程中,代码会搜索程序集的元数据,这些元数据是基于字符串的,并且无法预编译,所以这一系列的操作对性能有严重影响。另外,由于我们对目标类型未知,在向方法传递参数时通常是以object数组传递,CLR会逐个检查参数的数据类型,无论是传入还是返回,都有可能进行大量的类型转换,这也损伤了性能。所以对于反射的应用,应该注意。当然,像一些ORM等框架是以牺牲性能来换取方便的开发体验就另当别说了。
最后我们来演示一个简单的支持插件的小项目也可以弥补上面几节中丢失的代码块。总共有三个项目,模拟对车进行跑和转弯测试,如图:
ConsoleApp项目是测试程序
MyAssemblyA 是整个插件系列的接口契约,定义了汽车接口的跑动作Run和转变动作Turn,我们约定所有的其他插件车必须符合这个契约,即实现这个接口。
接口如下:
namespace MyAssemblyA { public interface ICar { void Run(); void Turn(Direction direction); } } namespace MyAssemblyA { public enum Direction { East, Weast, South, North } }
MyAssemblyB 插件程序,任何一个类型的汽车必须实现MyAssemblyA里的接口约定,这里只是演示,当然可以将每一个类型的汽车分配到一个程序集中去。代码如下:
namespace MyAssemblyB { //奥迪 public class AudiCar : ICar { public AudiCar() { } public string Owner { get; set; } public void Run() { Console.WriteLine("AudiCar Run"); } public void Turn(Direction direction) { Console.WriteLine("AudiCar Turn: " + direction.ToString()); } } } namespace MyAssemblyB { // 奔驰 public class BenzCar:ICar { public BenzCar() { } public string Owner { get; set; } public void Run() { Console.WriteLine("BenzCar Run"); } public void Turn(Direction direction) { Console.WriteLine("BenzCar Turn: " + direction.ToString()); } } }
在主程序ConsoleApp中,我们只关心车能跑,能转弯即可,至于它是奥迪还是奔驰,我们不管;至于这车是喷气还是长翅膀驱动,是靠太阳吸引力转弯还是人推着转弯,我们不关心,我们只接受客户发来一个车的类型名(也可以从配置文件中读取),来看一下代码:
public void TestCar(string carType) { //从配置文件中读取程序集信息 string assemblyName = "MyAssemblyB, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; Assembly assembly = Assembly.Load(assemblyName); //也可以从配置文件中读取车的类型 "MyAssemblyB.AudiCar" Type t = assembly.GetType(carType); //构造一辆车 var obj = Activator.CreateInstance(t); ICar car = obj as ICar; //如果这个车符合契约,则进行测试 if (car != null) { car.Run(); car.Turn(Direction.East); } }
这样我们的主测试程序就不再关心什么车型了,只要符合我们测试的契约即可,降低耦合,提高灵活。