利用定制特性,可宣告式为自己的代码构造添加注解来实现特殊功能。定制特性允许为几乎每一个元数据表记录项定义和应用信息。这种可扩展的元数据信息能在运行时查询,从而动态改变代码的执行方式。使用各种.NET技术(windows窗体、wpf和wcf等),会发现它们都利用了定制特性,目的是方便开发者在代码中表达它们的意图。
使用定制特性
我们都知道能将public、private,static这样的特性应用于类型和成员。我们都同意应用特性具有很大的作用。但是,如果能定义自己的特性,会不会更有用?例如,能不能定义一个类型,指出该类型通过序列化来进行远程处理?能不能将特性应用于方法,指出执行该方法需要授予特定安全权限。
为类型和方法创建和应用用户自定义的特性能带来极大便利。当然,编译器必须理解这些特性,才能在最终的元数据中生成特性信息,由于编译器厂商一般不会发布其编译器产品的源代码,所以Microsoft采取另一种机制提供对用户自定义特性的支持。这个机制称为定制特性。面向clr的所有编译器都识别定制特性,并能在最终的元数据中生成特性信息。
关于自定义attribute,首先应该知道:它们只是将一些附加信息与某个目标元素关联起来的方式。编译器会在托管模块的元数据中生成(签入)这些额外信息。大多数特性对编译器来说没有意义:编译器只是机械地检查源代码中的特性,并生产对应的元数据。
.NET类库定义了几百个定制特特性,可将它们应用于自己源代码中的各种元素。比如讲Flags特性应用于枚举类型,枚举类型就成了位标志(bit flag)集合。
CLR允许将attribute应用于可在文件的元数据中表示的几乎任何东西。不过,最常应用attribute的还是以下定义表中的记录项:TypeDef(类、结构、枚举、接口和委托),MethodDef(含构造器)、ParamDef(方法参数)、FiledDef(字段)、PropertyDef(属性)、EventDef(事件),AssemblyDef(程序集)和MouduleDef(模块)。
具体的说,在C#中只允许将attribute应用于对以下任何一个目标元素进行定义的源代码:程序集、模块、类型(类、结构、枚举、接口、委托)、字段、方法(含构造器)、方法参数、方法返回值、属性、事件和泛型类型参数。
应用一个attribute时,C#允许用一个前缀明确指定attribute要应用于的目标元素。以下代码展示了所有可能的前缀。在许多情况下,即使省略前缀,编译器一样能判断一个attribute要应用于的目标元素。但在另一些情况下,必须指定前缀向编译器清楚表达我们的意图。
[assembly: MyAttr(1)] // 应用于程序集 [module: MyAttr(2)] // 应用于模块 [type: MyAttr(3)] // 应用于类型 internal sealed class SomeType<[typevar: MyAttr(4)] T> { // 应用于泛型类型变量 [field: MyAttr(5)] // 应用于字段 public Int32 SomeField = 0; [return: MyAttr(6)] // 应用于返回值 [method: MyAttr(7)] // 应用于方法 public Int32 SomeMethod( [param: MyAttr(8)] // 应用于方法参数 Int32 SomeParam) { return SomeParam; } [property: MyAttr(9)] // 应用于属性 public String SomeProp { [method: MyAttr(10)] // 应用于get访问器方法 get { return null; } } [event: MyAttr(11)] // 应用于事件 [field: MyAttr(12)] // 应用于编译器生成的字段 [method: MyAttr(13)] // 应用于编译器生成的add&&remove方法 public event EventHandler SomeEvent; } [AttributeUsage(AttributeTargets.All)] public class MyAttr : Attribute { public MyAttr(Int32 x) { } }
前面介绍了如何应用定制特性,接下来看看定制特性到底是什么?
定制特性实际是一个类型的实例。为了符合"公共语言规范"(CLS)的要求,attribute类必须直接或间接地从公共抽象类System.Attribute派生。C#只允许使用符合CLS规范的attribute。attribute类是可以在任何命名空间中定义的。
如前所述,特性是类的一个实例。类必须有一个公共构造器,这样才能创建它的实例。所以,将一个attribute应用于一个目标元素时,语法类似于调用类的某个实例构造器。除此之外,语言可能支持一些特殊的语法,允许你设置于attribute类关联的公共字段或属性。比如,我们将DllImport这个attribute应用于GetVersionEx方法:
[DllImport("kernel32",CharSet = CharSet.Auto, SetLastError = true)]
这一行代码语法表面上很奇怪,因为调用构造器时永远不会出现这样的语法。查阅DllImportAttbute类的文档,会发现它只接受一个String类型的参数。在这个例子中,"Kernel32"这个String类型的参数已经传给它了。构造器的参数称为"定位参数",而且是强制性的。也就是说,应用attribute时,必须指定参数。
那么,另外两个"参数"是什么?这种特殊的语法允许在DllImportAttbute对象构造好之后,设置对象的任何公共字段和属性。在这个例子中,当DllImportAttbute对象构造好,而且将"Kernel32"传给构造器之后,对象的公共实例字段CharSet和SetListError被分别设置为CharSet.Auto和true。用于设置字段或属性的"参数"被称为"命名参数"。这种参数是可选的,因为在应用attribute的一个实例时,不一定要指定命名参数。
另外,还可以将多个attribute应用于一个目标元素。将多个attribute应用一个目标元素时,attribute的顺序是无关紧要的。在C#中,可将每个attribute都封闭到一对方括号中,也可以在一对方括号中封闭多个以逗号分隔的attribute。
定义自己的特性类
现在我们已经知道attribute是从System.Attribute派生的一个类的实例,并知道如何应用一个attribute。接着我们来研究下如何定制attribute。假定你是Microsoft的一位员工,并负责为枚举类型添加位标志(biit flag)支持。为此,我们,要做的第一件事情是定义一个FlagAttribute类:
namespace System { public class FlagsAttribute : System.Attribute { public FlagsAttribute(){ } } }
注意,FlagsAttribute类是从Attribute继承的。所以,才使FlagsAttribute类成为符合CLS要求的一个attribute。除此之外,注意类名有一个Attribute后缀;这是为了保持于标准的相容性,但并不是必须的。最后,所有的非抽象attribute都至少要包括一个公共构造器。
提示:应将特性想象成逻辑状态容器。也就是说,虽然特性类型是一个类,但这个类应该很简单。应该只提供一个公共构造器来接受attribute的强制性状态消息,而且这个类可以提供公共字段和属性,以接受attribute的可选状态信息。这个类不应提供任何公共方法、事件或其他成员。
现在的情况是FlagsAttribute类的实例能应用于任何目标元素。但事实上,这个特性应该只能应用于枚举类型,应用于属性或方法是没有意义的。为了告诉编译器这个特性的合法应用范围,需要向特性应用System.AttributeUsageAttribute类的实例。下面是新代码
namespace System { [AttributeUsage(AttributeTargets.Enum,Inherited=false)] public class FlagsAttribute : System.Attribute { public FlagsAttribute(){ } } }
AttributeUsage的属性Inherited指出特性应用于基类时,是否同时应用于派生类和重写的方法。
AttributeUsage还有一个附加的公共属性AllowMultiple,他控制是否能将特性多次应用于同一目标。大多数特性多次应用于同一目标是没有意义的,但少数几个特性确实有必要多次应用于同一个目标。FCL特性类ConditionalAttribute允许将它的多个实例应用于同一个目标元素。
注意:定义自己的特性类时,如果忘记向自己的类应用AttributeUsage特性,编译器和clr将假定该特性能应用于所有目标元素,向每个目标元素都只能应用一次,而且可继承。
特性构造器和字段/属性数据类型
定义一个特性类时,可定义构造器来获取参数。开发人员在应用特性类的实例时,必须指定这些参数。还可在类中定义非静态公共字段是属性,使开发人员能够为attribute类的实例选择恰当的设置。
定义attribute类的实例构造器、字段和属性时,数据类型只能限制在一个小的子集内。具体的说,合法的数据类型只有:Boolean,Char,Byte,Sbyte,Int16,UInt16,Int32,Uint32,Int64,Uint64,Single,Double,String,Type,Object或枚举类型。除此之外,还可使用上述任意类型的一维0基数组。然而,要尽量避免使用数组,因为对于attribute类来说,如果它的构造器要获取一个数组作为参数,就会失去与CLS的相容性。
应用一个attribute时,必须传递一个编译时常量表达式,它与attribute类定义的类型相匹配。在attribute类定义一个Type参数,Type字段或者Type属性的任何地方,都必须使用C#的typeof操作符。在attribute类定义一个Object参数、Object字段或Object属性的任何地方,都可以传递一个Int32、String或者其他任何常量表达式(包括null)。如果常量表达式代表一个值类型,那么在运行时构造一个attribute的实例时,会对值类型进行装箱。以下是一个示例attribute及用法:
public enum Color { Red } [AttributeUsage(AttributeTargets.All)] internal sealed class SomeAttribute : Attribute { public SomeAttribute(String name, Object o, Type[] types) { // 'name' 引用了一个String类型 // 'o' 引用了一个合法类型(如有必要,就进行装箱) // 'types' 引用一个一维0基Type数组 } } [Some("Jeff", Color.Red, new Type[] { typeof(Math), typeof(Console) })] internal sealed class SomeType { }
逻辑上,当编译器检测到一个目标元素应用了一个attribute时,编译器会调用attribute类的构造器,向它传递任何指定的参数,从而构造attribute类的一个实例。然后,编译器会采用增强型构造器语法所指定的值,对任何公共字段和属性进行初始化。在构造并初始化好定制attribute类的对象之后,编译器将它的状态序列化到目标元素的元数据表记录项中。
提示:所谓"attribute",就是一个类的实例,它被序列化成驻留在元数据中的字节流。运行时,可以对元数据中包含的字节进行反序列化,从而构造类的一个实例。实际发生的事情是:编译器在元数据中生成创建attribute类的一个实例所需的信息。每个构造器参数都是1字节的类型id,后跟具体值。对构造器的参数进行序列化时,编译器先写入字段、属性名称,再跟上1字节的类型id,最后是具体的值。如果是数组,则会先保存数组元素的个数,再跟上每个单独的元素。
检测定制特性
枚举类型和为标志那一章,我们描述了如何将flafs特性应用于枚举类型,从而改变system.enum的tostring和format方法的行为。方法的行为之所以改变,是因为它们会在运行时检查自己操作的枚举类型是否管理了flags特性元数据。以后我们会在反射章节完整探讨这种技术。
假定你是Microsoft的员工,负责实现Enum的ToString方法,你会像下面这样实现它:
public override string ToString() { //枚举类型是否应用了FlagsAttribute类型的实例 if(this.GetType().IsDefined(typeof(FlagsAttribute),false)) { //如果是,就执行代码,将值视为一个位标志枚举类型 } else { //如果如果㔻,就执行代码,将值视为一个普通枚举类型 } }
上述代码调用Type的IsDefined方法,要求系统查看枚举类型的元数据,检查是否关联了FlagsAttribute类的一个实例。如果IsDefined返回true,表面FlagsAttribute的一个实例已于枚举类型关联,ToString方法会认为值包含了一个位标志集合。如果IsDefined放回false,ToString方法会认为值是一个普通的枚举类型。
因此,在定义定制特性时,也必须实现一些代码来检查某些目标上是否存在该attribute类的实例,然后执行一些逻辑分支代码。这样定制特性才能真正发挥作用。
FCL提供了多种方式检查一个attribute的存在。如果通过system.type对象来检测特性,可以像前面展示的那样使用IsDefined方法。但有时需要检测除了类型之外的其他目标(比如程序集、模块或方法)上的特性。为简化讨论,让我们聚焦于system.Reflection.CustomAttributeExtensions类定义的扩展方法。这个类定义了三个静态方法来获取与一个目标关联的attribute:IsDefined,GetCustomAttribute和GetCustomAttributes。每个方法都有几个重载版本。
如果只想知道一个attribute是否应用于一个目标,那么应该调用IsDefined,因为它的效率比另外两个方法高的多。我们知道,将一个attribute应用于一个目标时,可以为attribute的构造器指定参数,并可以选择设置字段或属性。使用IsDefined不会构造一个attribute对象,不会调用它的构造器,也不会设置它的字段和属性。
每次使用GetCustomAttribute和GetCustomAttributes方法时,都会构造指定特性对象的新实例,并根据源代码中指定的值来设置每个实例的字段和属性。这个两个方法返回的都是对完全构造好的特性类实例的引用。
调用上述任何一个方法时,它们内部必须扫描托管模块的元数据,执行字符串比较来定位指定的attribute类。显然,这些操作会耗费一定的时间。假如对性能要求高,可以考虑缓存这些方法调用的返回结果。而不是反复调用来请求相同的信息。
System.Reflection 命名空间定义了几个类允许你检查一个模块的元数据的内容。这些类包括Assmbly,Module,ParameterInfo,MemberInfo,Type,MethodInfo,ConstructorInfo,FiledInfo,EeventInfo,PropertyInfo及其各自的*Builder类。所以这些方法还提供了IsDefined和GetCustomAttributes方法。只有System.Attribute提供了非常方便的GetCustomAttribute方法。
反射类提供的那个版本的GetCustomAttributes方法返回的是有Object实例构成的一个数据(Object[]),而不是由Attribute实例构成的一个数组(Attribute[])。这是由于反射类能返回不相容与cls规范的特性类对象。
注意:将一个类传给IsDefined,GetCustomAttribute或GetCustomAttributes方法时,这些方法会检测是否应用了指定的attribute类或它的派生类,如果代码要搜索一个具体的attribute类,应该针对返回值执行一个额外的检查,确保这些方法返回的正是想搜索的类。还可以考虑将自己的attribute类定义成sealed,减少可能存在的混淆,并避免这个检查。
一下实例代码列出了一个类型中定义的所有方法,并显示应用于每个方法的特性。
[assembly: CLSCompliant(true)] namespace AttributeStudy { [Serializable] [DefaultMember("Main")] [DebuggerDisplay("Richter", Name = "Jeff", Target = typeof(Program))] public sealed class Program { [Conditional("Debug")] [Conditional("Release")] public void DoSomething() { } public Program() { } [assembly: CLSCompliant(true)] [STAThread] public static void Main() { // 显示应用于这个类型的attribute集 ShowAttributes(typeof(Program)); // 获取与类型关联的方法集 MemberInfo[] members = typeof(Program).FindMembers( MemberTypes.Constructor | MemberTypes.Method, BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public | BindingFlags.Static, Type.FilterName, "*"); foreach (MemberInfo member in members) { // 显示应用于这个成员的attribute集 ShowAttributes(member); } Console.Read(); } private static void ShowAttributes(MemberInfo attributeTarget) { Attribute[] attributes = Attribute.GetCustomAttributes(attributeTarget); Console.WriteLine("Attributes applied to {0}: {1}", attributeTarget.Name, (attributes.Length == 0 ? "None" : String.Empty)); foreach (Attribute attribute in attributes) { // 显示已应用的每个attribute的类型 Console.WriteLine(" {0}", attribute.GetType().ToString()); if (attribute is DefaultMemberAttribute) Console.WriteLine(" MemberName={0}", ((DefaultMemberAttribute)attribute).MemberName); if (attribute is ConditionalAttribute) Console.WriteLine(" ConditionString={0}", ((ConditionalAttribute)attribute).ConditionString); if (attribute is CLSCompliantAttribute) Console.WriteLine(" IsCompliant={0}", ((CLSCompliantAttribute)attribute).IsCompliant); DebuggerDisplayAttribute dda = attribute as DebuggerDisplayAttribute; if (dda != null) { Console.WriteLine(" Value={0}, Name={1}, Target={2}", dda.Value, dda.Name, dda.Target); } } Console.WriteLine(); } } }
输出结果为:
Attributes applied to Program: System.SerializableAttribute System.Diagnostics.DebuggerDisplayAttribute Value=Richter, Name=Jeff, Target=ConsoleTest.Program System.Reflection.DefaultMemberAttribute MemberName=Main Attributes applied to DoSomething: System.Diagnostics.ConditionalAttribute ConditionString=Debug System.Diagnostics.ConditionalAttribute ConditionString=Release Attributes applied to Main: System.STAThreadAttribute Attributes applied to .ctor: None
两个特性实例的相互匹配
除了判断是否向目标应用了一个特性的实例,可能还需要检查attribute的字段来确定它们的值。为此,一个方法就是老老实实地写代码来检查attribute类的字段的值。System.Attribute重写了Object的Equals方法。这个方法内部会比较两个对象的类型。如果 不一致,Equals会返回false。如果类型一致,Equals会利用反射来比较两个attribute对象中的字段值(为每个字段调用Equals)。如果所有字段都匹配,就返回true;否则返回false。但我们可以在自己attribute类中重写Equals来移除反射的使用,从而提升性能。
System.Attribute还公开了虚方法Match,可重写它来提供更丰富的语义。Match的默认实现只是调用的Equals方法并返回它的结果。下面演示了如何重写Equals和Match,后者在一个特性代表另一个特性的子集的前提下返回true。另外,还演示了如何使用Match。
[Flags] internal enum Accounts { Savings = 0x0001, Checking = 0x0002, Brokerage = 0x0004 } [AttributeUsage(AttributeTargets.Class)] internal sealed class AccountsAttribute : Attribute { private Accounts m_accounts; public AccountsAttribute(Accounts accounts) { m_accounts = accounts; } public override Boolean Match(Object obj) { // 如果基类实现了Match,而且基类 // 不是Attribute,就取消对下面这行的注释 //if (!base.Match(obj)) //{ // return false; //} if (obj == null) { return false; } if (this.GetType() != obj.GetType()) { return false; } AccountsAttribute other = (AccountsAttribute)obj; // 比较字段,判断它们是否有相同的值 if ((other.m_accounts & m_accounts) != m_accounts) { return false; } return true; // 对象匹配 } public override Boolean Equals(Object obj) { //如果基类实现了Equals,而且基类不能Object //就取消对下面的注释 //if (!base.Equals(obj)) //{ // return false; //} if (obj == null) { return false; } if (this.GetType() != obj.GetType()) { return false; } AccountsAttribute other = (AccountsAttribute)obj; // 比较字段,判断它们是否有相同的值 if (other.m_accounts != m_accounts) { return false; } return true; // Objects are equal } // 还需要重写GetHashCode,因为我们重写了Equals public override Int32 GetHashCode() { return (Int32)m_accounts; } } [Accounts(Accounts.Savings)] internal sealed class ChildAccount { } [Accounts(Accounts.Savings | Accounts.Checking | Accounts.Brokerage)] internal sealed class AdultAccount { } public class Program { public static void Main() { CanWriteCheck(new ChildAccount()); CanWriteCheck(new AdultAccount()); // 只是为了演示在一个没有应用AccountsAttribute的方法也能正确工作 CanWriteCheck(new Program()); Console.Read(); } private static void CanWriteCheck(Object obj) { // 构造attribute类型的一个实例,并把它初始化我们要 // 显示查找的内容 Attribute checking = new AccountsAttribute(Accounts.Checking); // 构造应用于类型的attribute实例 Attribute validAccounts = Attribute.GetCustomAttribute( obj.GetType(), typeof(AccountsAttribute), false); // 如果attribute应用于类型,而且attribute指定了 // "Checking" 账户, 表面该类可以开支票 if ((validAccounts != null) && checking.Match(validAccounts)) { Console.WriteLine("{0} types can write checks.", obj.GetType()); } else { Console.WriteLine("{0} types can NOT write checks.", obj.GetType()); } } }
输出结果:
ConsoleTest.ChildAccount types can NOT write checks.
ConsoleTest.AdultAccount types can write checks.
ConsoleTest.Program types can NOT write checks.
检测定制特性时不创建从ATTRIBUTE派生的对象
本节将讨论如何利用另一种技术来检测应用于一个元数据记录项的attribute。在某些安全性要求严格的场合,这个技术能保证不会执行从Attribute派生的类中的代码。毕竟,调用Attribute的GetCustomAttribute或者GetCustomAttributes方法时,这些方法会在内部调用attribute类的构造器,而且可能调用属性的set方法。除此之外,首次访问一个类型会造成CLR调用类型的类型构造器(如果有的化话)。在构造器、set构造器方法以及类型构造器中,可能包含每次查找一个attribute时都要执行的代码,这样一来,就相当于允许未知的代码在APPDomain中运行,可以说是一个安全隐患。
使用System.Reflection.CustomAttributeData类,可以在查找attribute时同时禁止执行attribute类中的代码。这个类定义了一个静态方法GetCustomAttributes来获取与一个目标关联的attribute。该方法有4个重载版本:一个接受一个Assembly,一个接受一个Module,一个接受一个ParameterInfo,还有一个接受一个Memberinfo。这个类是在System.Reflection命名空间定义的。通常,是先用Assembly的静态方法ReflectionOnlyLoad加载一个程序集,在用CustomAttributeData类分析这个程序集的元数据中的attribute。简单的说,ReflectionOnlyLoad以一种特方式加载程序集,期间会禁止CLR执行程序集中的任何代码,包括类型构造器。
CustomAttributeData的GetCustomAttributes方法相当于一个工厂方法。也就是说,调用它会返回IList<CustomAttributeData>类型的对象,其中包括了一个有CustomAttributeData构成的一个集合。在集合中,应用于指定目标的每个定制attribute都有一个对象的元素。针对每个CustomAttribute对象都可以查询一些只读属性,判断attribute对象是如何构造和初始化的。具体的说,Customctor属性指出构造器方法"要"如何调用。ComstructorArguments属性以一个IList<CustomAttributeTypedArgument>实例的形式返回"要"传给这个构造器的实参。NamedArguments属性以一个IList<CustomAttributeNamedArgument>实例的形式,返回"要"设置的字段或属性。注意,这里之所以说"要",是因为不会实际地调用构造器和set访问器方法。通过禁止执行attribute类的任何方法,我们获得了增强的安全性。
条件特性类
定义、应用和反射特性能带来许多便利,所以开发人员越来越频繁地使用这些技术。特性简化了对代码的注释,还能实现丰富的功能。近来,开发人员越来越喜欢在设计和调试期间利用特性来辅助开发。
应用了System.Diagnostics.ConditionalAttribute的特性称为条件特性类。下面是一个例子:
//#define TEST #define VERIFY namespace ConsoleTest { using System; using System.Diagnostics; [Conditional("TEST")] [Conditional("VERIFY")] public sealed class CondAttribute : Attribute { } [Cond] public class Program { public static void Main() { Console.WriteLine("CondAttribute is {0} applied to Program type.", Attribute.IsDefined(typeof (Program), typeof (CondAttribute)) ? "" : "not"); Console.Read(); } } }
编译器如果发现向目标元素应用了CondAttribute的一个实例,那么当含有目标元素的代码编译时,只有定义TEST或VERIFY符号的前提下,编译器才会在元数据中生成特性信息。虽然如此,attribute类的定义元数据和实现存在于程序集中。