Note
- 类的元数据包含该类的成员和特性
- 程序的元数据可以理解为程序的结构信息
- 反射(reflection)用来查看元数据
- C#中通过Type类来反射
- 特性(attribute)用来给类型添加元数据
PS:理解有待加强
反射和特性
元数据和反射
大多数程序都要处理数据,包括读、写、操作和显示数据。(图形也是一种数据的形式。)然而,对于某些程序来说,它们操作的数据不是数字、文本或图形,而是程序和程序类型本身的信息。
- 有关程序及其类型的数据被称为元数据(metadata),它们保存在程序的程序集中
- 程序在运行时,可以查看其他程序集或其本身的元数据。一个运行的程序査看本身的元数据或其他程序的元数据的行为叫做反射(reflection)
对象浏览器是显式元数据的程序的一个示例。它可以读取程序集,然后显示所包含的类型以及类型的所有特性和成员。
本章将介绍程序如何使用Type类来反射数据,以及程序员如何使用特性来给类型添加元数据。
要使用反射,我们必须使用System.Reflection命名空间。
Type 类
之前已经介绍了如何声明和使用C#中的类型。包括预定义类型(int、long和string等)、BCL中的类型(Console、IEnumerable等)以及用户自定义类型(MyClass、Mydel等)。每一种类型都有自己的成员和特性。
BCL声明了一个叫做Type的抽象类,它被设计用来包含类型的特性。使用这个类的对象能让我们获取程序使用的类型的信息。
由于Type是抽象类,因此它不能有实例。而是在运行时,CLR创建从Type(RuntimeType)派生的类的实例,Type包含了类型信息。当我们要访问这些实例时,CLR不会返回派生类的引用而是Type基类的引用。但是,为了简单起见,在本章剩余的篇幅中,我会把引用所指向的对象称为Type类型的对象(虽然从技术角度来说是一个BCL内部的派生类型的对象)。
需要了解的有关Type的重要事项如下:
- 对于程序中用到的每一个类型,CLR都会创建一个包含这个类型信息的Type类型的对象
- 程序中用到的每一个类型都会关联到独立的Type类的对象
- 不管创建的类型有多少个实例,只有一个Type对象会关联到所有这些实例
下图显示了一个运行的程序,它有两个MyClass对象和一个OtherClass对象。注意,尽管有两个MyClass的实例,只会有一个Type对象来表示它。
我们可以从Type对象中获取需要了解的有关类型的几乎所有信息。下表列出了类中更有用的成员。
获取Type对象
本节学习使用GetType方法和typeof运算符来获取Type对象。object类型包含了一个叫做GetType的方法,它返回对实例的Type对象的引用。由于每一个类型最终都是从object继承的,所以我们可以在任何类型对象上使用GetType方法来获取它的Type对象,如下所示:
Type t = myInstance.GetType();
下面的代码演示了如何声明一个基类以及从它派生的子类。Main方法创建了每一个类的实例并且把这些引用放在了一个叫做bca的数组中以方便使用。在外层的foreach循环中,代码得到了Type对象并且输出类的名字,然后获取类的字段并输出。下图演示了内存中的对象。
using System; using System.Reflection; class BaseClass { public int BaseField=0; } class DerivedClass:BaseClass { public int DerivedField=0; } class Program { static void Main() { var bc=new BaseClass(); var dc=new DerivedClass(); BaseClass[] bca=new BaseClass[]{bc,dc}; foreach(var v in bca) { Type t=v.GetType(); Console.WriteLine("Object type : {0}",t.Name); FieldInfo[] fi=t.GetFields(); foreach(var f in fi) { Console.WriteLine(" Field : {0}",f.Name); } Console.WriteLine(); } } }
我们还可以使用typeof运算符来获取Type对象。只需要提供类型名作为操作数,它就会返回Type对象的引用,如下所示:
Type t = typeof(DerivedClass); ↑ ↑ 运算符 希望的Type对象的类型
下面的代码给出了一个使用typeof运算符的简单示例:
using System; using System.Reflection; namespace SimpleReflection { class BaseClass { public int MyFieldBase; } class DerivedClass:BaseClass { public int MyFieldDerived; } class Program { static void Main() { Type tbc=typeof(DerivedClass); Console.WriteLine("Result is {0}.",tbc.Name); Console.WriteLine("It has the following fields:"); FieldInfo[] fi=tbc.GetFields(); foreach(var f in fi) { Console.WriteLine(" {0}",f.Name); } } } }
什么是特性
特性(attribute)是一种允许我们向程序的程序集增加元数据的语言结构。它是用于保存程序结构信息的某种特殊类型的类。
- 将应用了特性的程序结构(program construct)叫做目标(target)
- 设计用来获取和使用元数据的程序(比如对象浏览器)叫做特性的消费者(consumer)
- .NET预定了很多特性,我们也可以声明自定义特性
下图是使用特性中相关组件的概览,并且也演示了如下有关特性的要点。
- 我们在源代码中将特性应用于程序结构
- 编译器获取源代码并且从特性产生元数据,然后把元数据放到程序集中
- 消费者程序可以获取特性的元数据以及程序中其他组件的元数据。注意,编译器同时生产和消费特性
根据惯例,特性名使用Pascal命名法并且以Attribute后缀结尾。当为目标应用特性时,我们可以不使用后缀。例如,对于SerializableAttribute和MyAttributeAttribute这两个特性,我们在把它们应用到结构时可以使用Serializable和MyAttribute短名称。
应用特性
我们先不讲解如何创建特性,而是看看如何使用已定义的特性。这样,你会对它们的使用情况有个大致了解。
特性的目的是告诉编译器把程序结构的某组元数据嵌入程序集。我们可以通过把特性应用到结构来实现。
- 在结构前放置特性片段来应用特性
- 特性片段被方括号包围,其中是特性名和特性的参数列表
例如,下面的代码演示了两个类的开始部分。最初的几行代码演示了把一个叫做Serializable的特性应用到MyClass。注意,Serializable没有参数列表。第二个类的声明有一个叫做MyAttribute的特性,它有一个带有两个string参数的参数列表。
[Serializable] public class MyClass { … } [MyAttribute("Simple class","Version 3.57")] public class MyOtherClass { … }
有关特性需要了解的重要事项如下:
- 大多数特性只针对直接跟随在一个或多个特性片段后的结构
- 应用了特性的结构称为被特件装饰(decorated或adorned,两者都应用得很普遍)
预定义的保留的特性
在学习如何定义自己的特性之前,本小节会先介绍几个.NET预定义特性。
Obsolete(废弃)特性
一个程序可能在其生命周期中经历多次发布,而且很可能延续多年。在程序生命周期的后半部分,程序员经常需要编写类似功能的新方法替换老方法。出于多种原因,你可能不想再使用那些调用过时的旧方法的老代码,而只想用新编写的代码调用新方法。
如果出现这种情况,你肯定希望稍后操作代码的团队成员或程序员也只使用新代码。要警告他们不要使用旧方法,可以使用Obsolete特性将程序结构标注为过期的,并且在代码编译时显式有用的警告消息。以下代码给出了一个使用的示例:
class Program { //应用特性 [Obsolete("User method SuperPrintOut")] static void PrintOut(string str) { Console.WriteLine(str); } static void Main(string[] args) { PrintOut("Start of Main"); } }
注意,即使PrintOut被标注为过期,Main方法还是调用了它。代码编译也运行得很好并且产生了如下的输出:
不过,在编译的过程中,编译器产生了下面的CS0618警告消息来通知我们正在使用一个过期的结构:
另外一个Obsolete特性的重载接受了bool类型的第二个参数。这个参数指定目标是否应该被标记为错误而不仅仅是瞥告。以下代码指定了它需要被标记为错误:
标记为错误 ↓ [Obsolete("User method SuperPrintOut",true)] static void PrintOut(string str) { … }
Conditional特性
Note
Conditional特性类似于C语言的条件编译
Conditional特性允许我们包括或排斥特定方法的所有调用。为方法声明应用Conditional特性并把编译符作为参数来使用。
- 如果定义了编译符号,那么编译器会包含所有调用这个方法的代码,这和普通方法没有什么区别
- 如果没有定义编译符号,那么编译器会忽略代码中这个方法的所有调用
定义方法的CIL代码本身总是会包含在程序集中。只是调用代码会被插入或忽略。
例如,在如下的代码中,把Conditional特性应用到对一个叫做TraceMessage的方法的声明上。特性只有一个参数,在这里是字符串DoTrace。
- 当编译器编译这段代码时,它会检査是否有一个编译符号被定义为DoTrace
- 如果DoTrace被定义,编译器就会像往常一样包含所有对TraceMessage方法的调用
- 如果没有DoTrace这样的编译符号被定义,编译器就不会输出任何对TraceMessage的调用
[Conditional("DoTrace")] static void TraceMessage(string str) { Console.WriteLine(str); }
Conditional特性的示例
以下代码演示了一个使用Conditional特性的完整示例。
- Main方法包含了两个对TraceMessage方法的调用
- TraceMessage方法的声明被用Conditional特性装饰,它带有DoTrace编译符号作为参数。因此,如果DoTrace被定义,那么编译器就会包舍所有对TraceMessage的调用代码
- 由于代码的第一行定义了叫做DoTrace的编译符,编译器会包含两个对TraceMessage的调用
#define DoTrace using System; using System.Diagnostics; namespace AttributeConditional { class Program { [Conditional("DoTrace")] static void TraceMessage(string str) { Console.WriteLine(str); } static void Main() { TraceMessage("Start of Main"); Console.WriteLine("Doing work in Main."); TraceMessage("End of Main"); } } }
如果注释掉第一行来取消DoTrace的定义,编译器就不再会插人两次对TraceMessage的调用代码。这次,如果我们运行程序,就会产生如下输出:
调用者信息特性
调用者信息特性可以访问文件路径、代码行数、调用成员的名称等源代码信息。
- 这三个特性名称为
CallerFilePath
、CallerLineNumber
和CallerMemberName
- 这些特性只能用于方法中的可选参数
下面的代码声明了一个名为MyTrace的方法,它在三个可选参数上使用了这三个调用者信息特性。如果调用方法时显式指定了这些参数,则会使用真正的参数值。但在下面所示的Main方法中调用时,没有显式提供这些值,因此系统将会提供源代码的文件路径、调用该方法的代码行数和调用该方法的成员名称。
using System; using System.Runtime.CompilerServices; public static class Program { public static void MyTrace(string message, [CallerFilePath] string fileName="", [CallerLineNumber] int lineNumber=0, [CallerMemberName] string callingMember="") { Console.WriteLine("File: {0}",fileName); Console.WriteLine("Line: {0}",lineNumber); Console.WriteLine("Called From: {0}",callingMember); Console.WriteLine("Message: {0}",message); } public static void Main() { MyTrace("Simple message"); } }
DebuggerStepThrough 特性
我们在单步调试代码时,常常希望调试器不要进入某些方法。我们只想执行该方法,然后继续调试下一行。DebuggerStepThrough特性告诉调试器在执行目标代码时不要进入该方法调试。
在我自己的代码中,这是最常使用的特性。有些方法很小并且毫无疑问是正确的,在调试时对其反复单步调试只能徒增烦恼。但使用该特性时要十分小心,因为你并不想排除那些可能含有bug的代码。
关于DebuggerStepThrough要注意以下两点:
- 该特性位于System.Diagnostics命名空间
- 该特性可用于类、结构、构造函数、方法或访问器
下面这段随手编造的代码在一个访问器和一个方法上使用了该特性。你会发现,调试器调试这段代码时不会进入IncrementFields方法或X属性的set访问器。
using System; using System.Diagnostics; class Program { int _x=1; int X { get{return _x;} [DebuggerStepThrough] set { _x=_x*2; _x+=value; } } public int Y{get;set;} public static void Main() { var p=new Program(); p.IncrementFields(); p.X=5; Console.WriteLine("X = {0}, Y = {1}",p.X,p.Y); } [DebuggerStepThrough] void IncrementFields() { X++; Y++; } }
其他预定义特性
.NET框架预定义了很多编译器和CLR能理解和解释的特性,下表列出了一些。在表中使用了不带Attribute后缀的短名称。例如,CLSCompliant的全名是CLSCompliantAttribute。
有关应用特性的更多内容
至此,我们演示了特性的简单使用,都是为方法应用单个特性。这部分内容将会讲述其他特性的使用方式。
多个特性
我们可以为单个结构应用多个特性。
- 多个特性可以使用下面列出的任何一种格式:
- 独立的特性片段相互叠在一起
- 单个特性片段,特性之间使用逗号分隔
- 我们可以以任何次序列出特性
例如,下面的两个代码片段显示了应用多个特性的两种方式。两个片段的代码是等价的。
[Serializable ] //多层结构 [MyAttribute("Simple class", "Version 3.57")] [MyAttribute("Simple class", "Version 3.57"),Serializable] //逗号分隔
其他类型的目标
除了类,我们还可以将特性应用到诸如字段和属性等其他程序结构。以下的声明显示了字段上的特性以及方法上的多个特性:
[MyAttribute("Holds a value", "Version 3.2")] //字段上的特性 public int MyField; [Obsolete] //方法上的特性 [MyAttribute("Prints out a message.", "Version 3.6")] public void Printout() { … }
我们还可以显式地标注特性,从而将它应用到特殊的目标结构。要使用显式目标,在特性片段的开始处放置目标类型,后面跟冒号。例如,如下的代码用特性装饰方法,并且还把特性应用到返回值上。
显式目标说明符 ↓ [method: MyAttribute("Prints out a message.", "Version 3.6")] [return: MyAttribute("This value represents …", "Version 2.3")] public long ReturnSetting() { … }
如下表所列,C#语言定义了10个标准的特性目标。大多数目标名可以自明(self-explanatory),而type覆盖了类、结构、委托、枚举和接口。 typevar目标名称指定使用泛型结构的类型参数。
全局特性
我们还可以通过使用assembly和module目标名称来使用显式目标说明符把特性设置在程序集或模块级別。(程序集和模块在第21章中解释过。)一些有关程序集级别的特性的要点如下:
- 程序级级别的特性必须放置在任何命名空间之外,并且通常放置在AssemblyInfo.cs文件中
- AssemblyInfo.cs文件通常包含有关公司、产品以及版权信息的元数据
如下的代码行摘自AssemblyInfo.cs文件:
[assembly: AssemblyTitle("SuperWidget")] [assembly: AssemblyDescription("Implements the SuperWidget product.")] [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("McArthur Widgets, Inc.")] [assembly: AssemblyProduct("Super Widget Deluxe")] [assembly: AssemblyCopyright("Copyright © McArthur Widgets 2012")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")]
自定义特性
你或许已经注意到了,应用特性的语法和之前见过的其他语法很不相同。你可能会觉得特性是和结构完全不同的类型,其实不是,特性只是某个特殊类型的类。
有关特性类的一些要点如下。
- 用户自定义的特性类叫做自定义特性
- 所有特性类都派生自System.Attribute
声明自定义特性
总体来说,声明一个特性类和声明其他类一样。然而,有一些事项值得注意,如下所示。
- 要声明一个自定义特性,需要做如下工作
- 声明一个派生自System.Attribute的类
- 给它起一个以后缀Attribute结尾的名字
- 安全起见,通常建议你声明一个sealed的特性类(sealed密封类,不能被继承)
例如,下面的代码显示了MyAttributeAttribute特性的声明的开始部分:
特性名 基类 ↓ ↓ public sealed class MyAttributeAttribute : System.Attribute { … }
由于特性持有目标的信息,所有特性类的公共成员只能是:
- 字段
- 属性
- 构造函数
使用特性的构造函数
特性和其他类一样,都有构造函数。每一个特性至少必须有一个公共构造函数。
- 和其他类一样,如果你不声明构造函数,编译器会为我们产生一个隐式、公共且无参的构造函数
- 特性的构造函数和其他构造函数一样,可以被重载
- 声明构造函数时必须使用类全名,包括后缀。我们只可以在应用特性时使用短名称
例如,如果有如下的构造函数(名字没有包含后缀),编译器会产生一个错误消息:
public MyAttributeAttribute(string desc,string ver) { Description=desc; VersionNumber=ver; }
指定构造函数
当我们为目标应用特性时,其实是在指定应该使用哪个构造函数来创建特性的实例。列在特性应用中的参数其实就是构造函数的参数。
例如,在下面的代码中,MyAttribute被应用到一个字段和一个方法上。对于字段,声明指定了使用单个字符串的构造函数。对于方法,声明指定了使用两个字符串的构造函数。
[MyAttribute("Holds a value")] //使用一个字符串的构造函数 public int MyField; [MyAttribute("version 1.3", "Sal Martin")] //使用两个字符串的构造函数 public void MyMethod() { … }
其他有关特性构造函数的要点如下。
- 在应用特性时,构造函数的实参必须是在编译期能确定值的常量表达式
- 如果应用的特性构造函数没有参数,可以省略圆括号。例如,如下代码的两个类都使用MyAttr特性的无参构造函数。两种形式的意义是相同的
[MyAttr] class SomeClass … [MyAttr()] class OtherClass …
使用构造函数
和其他类一样,我们不能显式调用构造函数。特性的实例创建后,只有特性的消费者访问特性时才能调用构造函数。这一点与其他类的实例很不相同,这些实例都创建在使用对象创建表达式的位置。应用一个特性是一条声明语句,它不会决定什么时候构造特性类的对象。
下图比较了普通类构造函数的使用和特性的构造函数的使用。
- 命令语句的实际意义是:"在这里创建新的类"
- 声明语句的意义是:"这个特性和这个目标相关联,如果需要构造特性,使用这个构造函数"
构造函数中的位置参数和命名参数
和普通类的方法与构造方法相似,特性的构造方法同样可以使用位置参数和命名参数。如下代码显示了使用一个位置参数和两个命名参数来应用一个特性:
位置参数 命名参数 命名参数 ↓ ↓ ↓ [MyAttribute("An excellent class",Reviewer="Amy McArthur",Ver="0.7.15.33")]
下面的代码演示了特性类的声明以及为MyClass类应用特性。注意,构造函数的声明只列出了一个形参,但我们可通过命名参数给构造函数3个实参。两个命名参数设置了字段Ver和Reviewer的值。
public sealed class MyAttributeAttribute : System.Attribute { public string Description; public string Ver; public string Reviewer; public MyAttributeAttribute(string desc) //一个形参 { Description = desc; } } //三个实参 [MyAttribute("An excellent class”, Reviewer="Amy McArthur", Ver="7.15.33")] class MyClass { … }
构造函教需要的任何位置参数都必须放在命名参数之前。
限制特性的使用
我们已经看到了可以为类应用特性。而特性本身就是类,有一个很重要的预定义特性可以用来应用到自定义特性上,那就是AttributeUsage特性。我们可以使用它来限制特性使用在某个目标类型上。
例如,如果我们希望自定义特性MyAttribute只能应用到方法上,那么可以以如下形式使用AttributeUsage:
只针对方法 ↓ [AttributeUsage( AttributeTarget.Method )] public sealed class MyAttributeAttribute : System.Attribute { … }
AttributeUsage有三个重要的公共属性,如下表所示。表中显示了属性名和属性的含义。对于后两个属性,还显示了它们的默认值。
AttributeUsage的构造函数
AttributeUsage的构造函数接受单个位置参数,该参数指定了特性允许的目标类型。它用这个参数来设置ValidOn属件,可接受目标类型是AttributeTarget枚举的成员。AttributeTarget枚举的完整成员列表如下表所示。
我们可以通过使用按位或运算符来组合使用类型。例如,在下面的代码中,被装饰的特性只能应用到方法和构造函数上。
目标 ↓ [AttributeUsage( AttributeTarget.Method| AttributeTarget.Constructor )] public sealed class MyAttributeAttribute : System.Attribute
当我们为特性声明应用AttributeUsage时,构造函数至少需要一个参数,参数包含的目标类型会保存在ValidOn中。我们还可以通过使用命名参数有选择性地设置Inherited和AllowMultiple属性。如果我们不设置,它们会保持如表24-4所示的默认值。
作为示例,下面一段代码指定了MyAttribute的如下方面。
- MyAttribute能且只能应用到类上
- MyAttribute不会被应用它的派生类所继承
- 不能有MyAttribute的多个实例应用到同一个目标上
[AttributeUsage( AttributeTarget.Class, //必需的,位置参数 Inherited = false, //可选的,命名参数 AllowMultiple = false )] //可选的,命名参数 public sealed class MyAttributeAttribute : System.Attribute { … }
自定义特性的最佳实践
强烈推荐编写自定义特性时参考如下实践。
- 特性类应该表示目标结构的一些状态
- 如果特性需要某些字段,可以通过包含具有位置参数的构造函数来收集数据,可选字段可以采用命名参数按需初始化
- 除了属性之外,不要实现公共方法或其他函数成员
- 为了更安全,把特性类声明为sealed
- 在特性声明中使用AttributeUsage来显式指定特性目标组
如下代码演示了这些准则:
[AttributeUsage( AttributeTargets.Class )] public sealed class ReviewCommentAttribute : System.Attribute { public string Description {get;set;} public string VersionNumber {get;set;} public string ReviewerID {get;set;} public ReviewCommentAttribute(string desc, string ver) { Description = desc; VersionNumber = ver; } }
访问特性
在本章开始处,我们已经看到了可以使用Type对象来获取类型信息。对于访问自定义特性来说,我们也可以这么做。Type的两个方法(IsDefined和GetCustomAttributes)在这里非常有用。
使用IsDefined方法
我们可以使用Type对象的IsDefined方法来检测某个特性是否应用到了某个类上。
例如,以下的代码声明了一个有特性的类MyClass,并且作为自己特性的消费者在程序中访问声明和被应用的特性。代码的开始处是MyAttribute特性和应用特性的MyClass类的声明。这段代码做了下面的事情。
- 首先,Main创建了类的一个对象。然后通过使用从object基类继承的GetType方法获取了Type对象的一个引用
- 有了Type对象的引用,就可以调用IsDefined方法来判断ReviewComment特性是否应用到了这个类
- 第一个参数接受需要检査的特性的Type对象
- 第二个参数是bool类型的,它指示是否搜索MyClass的继承树来查找这个特性
[AttributeUsage(AttributeTargets.Class)] public sealed class ReviewCommentAttribute:System.Attribute {…} [ReviewComment("Check it out","2.4")] class MyClass{} class Program { static void Main() { var mc=new MyClass(); Type t=mc.GetType(); bool isDefined= t.IsDefined(typeof(ReviewCommentAttribute),false); if(isDefined) Console.WriteLine("ReviewComment is applied to type {0}",t.Name); } }
使用GetCustomAttributes方法
GetCustomAttributes方法返回应用到结构的特性的数组。
- 实际返冋的对象是object的数组,因此我们必须将它强制转换为相应的特性类型
- 布尔参数指定是否搜索继承树来査找特性
object[] AttArr = t.GetCustomAttributes(false);
- 调用GetCustomAttributes方法后,每一个与目标相关联的特性的实例就会被创建
下面的代码使用了前面的示例中相同的特性和类声明。但是,在这种情况下,它不检测特性是否应用到了类,而是获取应用到类的特性的数组,然后遍历它们,输出它们的成员的值。
using System; [AttributeUsage(AttributeTargets.Class)] public sealed class MyAttributeAttribute:System.Attribute { public string Description {get;set;} public string VersionNumber{get;set;} public string ReviewerID {get;set;} public MyAttributeAttribute(string desc,string ver) { Description=desc; VersionNumber=ver; } } [MyAttribute("Check it out","2.4")] class MyClass { } class Program { static void Main() { Type t=typeof(MyClass); object[] AttArr=t.GetCustomAttributes(false); foreach(Attribute a in AttArr) { var attr=a as MyAttributeAttribute; if(null!=attr) { Console.WriteLine("Description :{0}",attr.Description); Console.WriteLine("Version Number :{0}",attr.VersionNumber); Console.WriteLine("Reviewer ID :{0}",attr.ReviewerID); } } } }