CLR via C#, 4th -- 【设计类型】 -- 第10章属 性
属性,它允许源代码用简化语法来调用方法。
CLR支持两种属性:无参属性,平时说的属性就是指它; 有参属性,它在不同的编程语言中有不同的称呼。例如,C将有参属性称为索引器。
10.1 无参属性
许多类型都定义了能被获取或更改的状态信息。这种状态信息一般作为类型的字段成员实现。
public sealed class Employee { public String Name; // The employee's name public Int32 Age; // The employee's age }
面向对象设计和编程的重要原则之一就是数据封装,意味着类型的字段永远不应该公开,否则很容易因为不恰当使用字段而破坏对象的状态。(-5岁)
Employee e = new Employee(); e.Age = 5; // How could someone be –5 years old?
还有其他原因促使我们封装对类型中的数据字段的访问。
其一,你可能希望访问字段来执行一些副作用、缓存某些值或者推迟创建一些内部对象。
其二,你可能希望以线程安全的方式访问字段。
其三,字段可能是一个逻辑字段,它的值不由内存中的字节表示,而是通过某个算法来计算获得。
基于上述原因,强烈建议将所有字段都设为private。要允许用户或类型获取或设置状态信息,就公开一个针对该用途的方法。封装了字段访问的方法通常称为访问器(accessor)方法。访问器方法可选择对数据的合理性进行检查,确保对象的状态永远不被破坏。
public sealed class Employee { private String m_Name; // Field is now private private Int32 m_Age; // Field is now private public String GetName() { return(m_Name); } public void SetName(String value) { m_Name = value; } public Int32 GetAge() { return(m_Age); } public void SetAge(Int32 value) { if (value < 0) throw new ArgumentOutOfRangeException("value", value.ToString(), "The value must be greater than or equal to 0"); m_Age = value; } }
但是,像这样进行数据封装有两个缺点。
首先,因为不得不实现额外的方法,所以必须写更多的代码;
其次,类型的用户必须调用方法,而不能直接引用字段名。
编程语言和CLR还是提供了一个称为属性(property)的机制。它缓解了第一个缺点所造成的影响,同时完全消除了第二个缺点。
public sealed class Employee { private String m_Name; private Int32 m_Age; public String Name { get { return(m_Name); } set { m_Name = value; } // The 'value' keyword always identifies the new value. } public Int32 Age { get { return(m_Age); } set { if (value < 0) // The 'value' keyword always identifies the new value. throw new ArgumentOutOfRangeException("value", value.ToString(), "The value must be greater than or equal to 0"); m_Age = value; } } }
e.Name = "Jeffrey Richter"; // "Sets" the employee name String EmployeeName = e.Name; // "Gets" the employee's name e.Age = 48; // "Sets" the employee's age e.Age = 5; // Throws ArgumentOutOfRangeException Int32 EmployeeAge = e.Age; // "Gets" the employee's age
可将属性想象成智能字段,即背后有额外逻辑的字段。
每个属性都有名称和类型(类型不能是void)。属性不能重载,即不能定义名称相同、类型不同的两个属性。定义属性时通常同时指定get和set两个方法。但可省略set方法来定义只读属性,或省略get方法来定义只写属性。
经常利用属性的get和set方法操纵类型中定义的私有字段。私有字段通常称为支持字段(backing field)。但get和set方法并非一定要访问支持字段。例如,System.Threading.Thread类型提供了Priority属性来直接和操作系统通信。Thread对象内部没有一个关于线程优先级的字段。没有支持字段的另一种典型的属性是在运行时计算的只读属性。例如,以0结束的一个数组的长度或者已知高度和宽度的一个矩形的面积。
定义属性时,取决于属性的定义,编译器在最后的托管程序集中生成以下两项或三项。
- 代表属性get访问器的方法。仅在属性定义了get访问器方法时生成。
- 代表属性set访问器的方法。仅在属性定义了set访问器方法时生成。
- 托管程序集元数据中的属性定义。这一项必然生成。
public sealed class Employee { private String m_Name; private Int32 m_Age; public String get_Name(){ return m_Name; } public void set_Name(String value) { m_Name = value; // The argument 'value' always identifies the new value. } public Int32 get_Age() { return m_Age; } public void set_Age(Int32 value) { if (value < 0) // The 'value' always identifies the new value. throw new ArgumentOutOfRangeException("value", value.ToString(), "The value must be greater than or equal to 0"); m_Age = value; } }
10.1.1 自动实现的属性
如果只是为了封装一个支持字段而创建属性,C#还提供了一种更简洁的语法,称为自动实现的属性(Automatically Implemented Property,后文简称为AIP)
声明属性而不提供get/set方法的实现,C#会自动为你声明一个私有字段。在本例中,字段的类型是String,也就是属性的类型。另外,编译器会自动实现get Name和set Name方法,分别返回和设置字段中的值。
和直接声明名为Name的public String字段相比,AIP的优势在哪里?
事实上,两者存在一处重要的区别。使用AIP,意味着你已经创建了一个属性。访问该属性的任何代码实际都会调用get和set方法。如果以后决定自己实现get和/或set方法,而不是接受编译器的默认实现,访问属性的任何代码都不必重新编译。然而,如果将Name声明为字段,以后又想把它更改为属性,那么访问字段的所有代码都必须重新编译才能访问属性方法。
AIP的劣势
- 我个人不喜欢编译器的AIP功能,所以一般会避免使用它。理由是:字段声明语法可能包含初始化部分,所以要在一行代码中声明并初始化字段。但没有简单的语法初始化AIP。所以,必须在每个构造器方法中显式初始化每个AIP
- 运行时序列化引擎将字段名持久存储到序列化的流中。AIP的支持字段名称由编译器决定,每次重新编译代码都可能更改这个名称。因此,任何类型只要含有一个AIP,就没办法对该类型的实例进行反序列化。在任何想要序列化或反序列化的类型中,都不要使用AIP功能。
- 调试时不能在AIP的get或set方法上添加断点,所以不好检测应用程序在什么时候获取或设置这个属性。相反,手动实现的属性可设置断点,查错更方便。
10.1.2 合理定义属性
理由是属性看起来和字段相似,但本质是方法。
- 属性可以只读或只写,而字段访问总是可读和可写的(一个例外是readonly字段仅在构造器中可写)。如果定义属性,最好同时为它提供get和set访问器方法。
- 属性方法可能抛出异常;字段访问永远不会。
- 属性不能作为out或ref参数传给方法,而字段可以。
- 属性方法可能花较长时间执行,字段访问则总是立即完成。许多人使用属性是为了线程同步,这就可能造成线程永远终止。所以,要线程同步就不要使用属性,而要使用方法。
- 连续多次调用,属性方法每次都可能返回不同的值,字段则每次都返回相同的值。System.DaterTime类的只读属性Now返回当前日期和时间。每次查询这个属性都返回不同的值。
- 属性方法可能造成明显的副作用(副作用(side effect)是指,访问属性时,除了单纯设置或获取属性,还会造成对象状态的改变。如果存在多个副作用,程序的行为就要依赖于历史;或者说要依赖于求值顺序。),字段访问则永远不会。
- 属性方法可能需要额外的内存,或者返回的引用并非指向对象状态一部分,造成对返回对象的修改作用不到原始对象身上。而查询字段返回的引用总是指向原始对象状态的一部分。使用会返回一个拷贝的属性很容易引起混淆,文档也经常没有专门说明。
10.1.3对象和集合初始化器
经常要构造一个对象并设置对象的一些公共属性(或字段)。为了简化这个常见的编程模式,C#语言支持一种特殊的对象初始化语法。
Employee e = new Employee() { Name = "Jeff", Age = 45 };
如果属性的类型实现了IEnumerable或IEnumerable<D>接口,属性就被认为是集合,而集合的初始化是一种相加(additive)操作,而非替换(replacement)操作。
public sealed class Classroom { private List<String> m_students = new List<String>(); public List<String> Students { get { return m_students; } } public Classroom() {} } public static void M() { Classroom classroom = new Classroom { Students = { "Jeff", "Kristin", "Aidan", "Grant" } }; // Show the 4 students in the classroom foreach (var student in classroom.Students) Console.WriteLine(student); }
10.1.4 匿名类型
利用C#的匿名类型功能,可以用很简洁的语法来自动声明不可变(immutable)的元组类型。元组(元组(tuple)一词来源于对顺序的抽象:single,double,triple,quadruple,quintuple,n-tuple.)类型是含有一组属性的类型,这些属性通常以某种方式相互关联。
// Define a type, construct an instance of it, & initialize its properties var o1 = new { Name = "Jeff", Year = 1964 }; // Display the properties on the console: Console.WriteLine("Name={0}, Year={1}", o1.Name, o1.Year);// Displays: Name=Jeff, Year=1964
编译器会推断每个表达式的类型,创建推断类型的私有字段,为每个字段创建公共只读属性,并创建一个构造器来接受所有这些表达式。在构造器的代码中,会用传给它的表达式的求值结果来初始化私有只读字段。除此之外,编译器还会重写Object的Equals,GetHashCode和ToString方法,并生成所有这些方法中的代码。
[CompilerGenerated] internal sealed class <>f__AnonymousType0<...>: Object { private readonly t1 f1; public t1 p1 { get { return f1; } } ... private readonly tn fn; public tn pn { get { return fn; } } public <>f__AnonymousType0<...>(t1 a1, ..., tn an) { f1 = a1; ...; fn = an; // Set all fields } public override Boolean Equals(Object value) { // Return false if any fields don't match; else true } public override Int32 GetHashCode() { // Returns a hash code generated from each fields' hash code } public override String ToString() { // Return comma separated set of property name = value pairs } }
编译器会生成Equals和GetHashCode方法,因此匿名类型的实例能放到哈希表集合中。属性是只读的,而非可读可写,目的是防止对象的哈希码发生改变。如果对象在哈希表中作为键使用,更改它的哈希码会造成再也找不到它。编译器会生成Tostring方法来帮助进行调试。
编译器支持用另外两种语法声明匿名类型中的属性,能根据变量推断属性名和类型.
String Name = "Grant"; DateTime dt = DateTime.Now; // Anonymous type with two properties // 1. String Name property set to Grant // 2. Int32 Year property set to the year inside the dt var o2 = new { Name, dt.Year };
如果编译器看到你在源代码中定义了多个匿名类型,而且这些类型具有相同的结构,那么它只会创建一个匿名类型定义,但创建该类型的多个实例。所谓“相同的结构”,是指在这些匿名类型中,每个属性都有相同的类型和名称,而且这些属性的指定顺序相同。
另外,由于这种类型的同一性,所以可以创建一个隐式类型的数组(详情参见16.1节“初始化数组元素”),在其中包含一组匿名类型的对象。
匿名类型的实例不能泄露到方法外部。方法原型不能接受匿名类型的参数,因为无法指定匿名类型。类似地,方法也不能返回对匿名类型的引用。虽然可以将匿名类型的实例视为一个Object(所有匿名类型都从Object派生),但没办法将Object类型的变量转型回匿名类型,因为不知道在匿名类型在编译时的名称。
10.1.5 System.Tuple类型
在System命名空间,Microsoft定义了几个泛型Tuple类型,它们全部从Object派生,区别只在于元数(泛型参数的个数)。
和匿名类型相似,Tuple创建好之后就不可变了(所有属性都只读)。
编译器只能在调用泛型方法时推断泛型类型,调用构造器时不能。因此,System命名空间还包含了一个非泛型静态Tuple类,其中包含一组静态Create方法,能根据实参推断泛型类型。这个类扮演了创建Tuple对象的一个工厂的角色,它存在的唯一意义便是简化你的代码。
// Returns minimum in Item1 & maximum in Item2 private static Tuple<Int32, Int32> MinMax(Int32 a, Int32 b) { return Tuple.Create(Math.Min(a, b), Math.Max(a, b)); // Simpler syntax }
要创建多于8个元素的Tuple,可为Rest参数传递另一个Tuple,如下所示:
var t = Tuple.Create(0, 1, 2, 3, 4, 5, 6, Tuple.Create(7, 8)); Console.WriteLine("{0}, {1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}", t.Item1, t.Item2, t.Item3, t.Item4, t.Item5, t.Item6, t.Item7, t.Rest.Item1.Item1, t.Rest.Item1.Item2);
注意 除了匿名类型和Tuple类型,还可研究一下System.Dynamic.ExpandoObject类(在System.Core.dll程序集中定义),这个类和C#的dynamic类型(参见5.5节"dynamic基元类型”)配合使用,就可用另一种方式将一系列属性(键/值对)组合到一起。虽然实现不了编译时的类型安全性,但语法看起来不错(虽然得不到“智能感知”支持),而且还可以在C#和Python这样的动态语言之间传递ExpandoObject对象。
dynamic e = new System.Dynamic.ExpandoObject(); e.x = 6; // Add an Int32 'x' property whose value is 6 e.y = "Jeff"; // Add a String 'y' property whose value is "Jeff" e.z = null; // Add an Object 'z' property whose value is null // See all the properties and their values: foreach (var v in (IDictionary<String, Object>)e) Console.WriteLine("Key={0}, V={1}", v.Key, v.Value); // Remove the 'x' property and its value var d = (IDictionary<String, Object>)e; d.Remove("x");
10.2 有参属性
C#使用数组风格的语法来公开有参属性(索引器),换句话说,可将索引器看成是C#开发人员对[ ]操作符的重载。
using System; public sealed class BitArray { // Private array of bytes that hold the bits private Byte[] m_byteArray; private Int32 m_numBits; // Constructor that allocates the byte array and sets all bits to 0 public BitArray(Int32 numBits) { // Validate arguments first. if (numBits <= 0) throw new ArgumentOutOfRangeException("numBits must be > 0"); // Save the number of bits. m_numBits = numBits; // Allocate the bytes for the bit array. m_byteArray = new Byte[(numBits + 7) / 8]; } // This is the indexer (parameterful property). public Boolean this[Int32 bitPos] { // This is the indexer's get accessor method. get { // Validate arguments first if ((bitPos < 0) || (bitPos >= m_numBits)) throw new ArgumentOutOfRangeException("bitPos"); // Return the state of the indexed bit. return (m_byteArray[bitPos / 8] & (1 << (bitPos % 8))) != 0; } // This is the indexer's set accessor method. set { if ((bitPos < 0) || (bitPos >= m_numBits)) throw new ArgumentOutOfRangeException("bitPos", bitPos.ToString()); if (value) { // Turn the indexed bit on. m_byteArray[bitPos / 8] = (Byte) (m_byteArray[bitPos / 8] | (1 << (bitPos % 8))); } else { // Turn the indexed bit off. m_byteArray[bitPos / 8] = (Byte) (m_byteArray[bitPos / 8] & ~(1 << (bitPos % 8))); } } } }
// Allocate a BitArray that can hold 14 bits. BitArray ba = new BitArray(14); // Turn all the evennumbered bits on by calling the set accessor. for (Int32 x = 0; x < 14; x++) { ba[x] = (x % 2 == 0); } // Show the state of all the bits by calling the get accessor. for (Int32 x = 0; x < 14; x++) { Console.WriteLine("Bit " + x + " is " + (ba[x] ? "On" : "Off")); }
和无参属性的set访问器方法相似,索引器的set访问器方法同样包含了一个隐藏参数,在C#中称为value,该参数代表想赋给“被索引元素”的新值。
CLR本身不区分无参属性和有参属性。对CLR来说,每个属性都只是类型中定义的一对方法和一些元数据。如前所述,不同的编程语言要求用不同语法来创建和使用有参属性。将this[...]为表达索引器的语法,这纯粹是C#团队自己的选择。也正是因为这个选择,所以C#只允许在对象的实例上定义索引器。C#不支持定义静态索引器属性,虽然CLR是支持静态有参属性的。
由于CLR以相同的方式对待有参和无参属性,所以编译器会在托管程序集中生成以下两项或三项。
- 代表有参属性get访问器的方法。仅在属性定义了get访问器方法时生成。
- 代表有参属性set访问器的方法。仅在属性定义了set访问器方法时生成。
- 托管程序集元数据中的属性定义。这一项必然生成。没有专门的有参属性元数据定义表,因为对于CLR来说,有参属性不过就是属性。
编译器在索引器名称之前附加get_或者set_前缀,从而自动生成这些方法的名称。由于C#的索引器语法不允许开发人员指定索引器名称,所以C#编译器团队不得不为访问器方法选择一个默认名称;他们最后选择了Item。因此,编译器生成的方法名就是get_Item和set_Item
查看文档时,留意类型是否提供了名为Item的属性,从而判断该类型是否提供了索引器。例如,System.Collections.Generic.List类型提供了名为Item的公共实例属性,它就是List的索引器。
用C#编程永远看不到Item这个名称,所以一般无需关心编译器选择的是什么名称。但如果为类型设计的索引器要由其他语言的代码访问,就可能需要更改索引器的get和set访问器方法所用的默认名称Item,C#允许向索引器应用定制特性System.Runtime.CompilerServices.IndexerNameAttribute来重命名这些方法,
using System; using System.Runtime.CompilerServices; public sealed class BitArray { [IndexerName("Bit")] public Boolean this[Int32 bitPos] { // At least one accessor method is defined here } }
C#允许一个类型定义多个案引器,只要索引器的参数集不同。在其他语言中,IndexerName特性允许定义多个相同签名的索引器,因为索引器各自可以有不同的名称。C#不允许这样做,是因为它的语法不是通过名称来引用索引器,编译器不知道你引用的是哪个索引器。
System.String类型是改变了索引器名称的一个例子。String的索引器的名称是Chars,而不是Item,这个只读属性允许从字符串中获得一个单独的字符。对于不用[]操作符语法来访问这个属性的编程语言,Chars是更有意义的名称。
10.3调用属性访问器方法时的性能
对于简单的get和set访问器方法,JIT编译器会将代码内联(inline,或者说嵌入)。这样一来,使用属性(而不是使用字段)就没有性能上的损失。内联是指将方法(目前说的是访问器方法)的代码直接编译到调用它的方法中。这就避免了在运行时发出调用所产生的开销,代价是编译好的方法变得更大。由于属性访问器方法包含的代码一般很少,所以对内联会使生成的本机代码变得更小,而且执行得更快。
10.4 属性访问器的可访问性
有时希望为get访问器方法指定一种可访问性,为set访问器方法指定另一种可访问性。最常见的情形是提供公共get访问器和受保护set访问器。
public class SomeType { private String m_name; public String Name { get { return m_name; } protected set {m_name = value; } } }
定义属性时,如果两个访问器方法需要不同的可访问性,C#要求必须为属性本身指定限制最小的可访问性。然后,两个访问器只能选择一个来使用限制较大的。
10.5 泛型属性访问器方法
既然属性本质上是方法,而C#和CLR允许泛型方法,所以有时可能想在定义属性时引入它自己的泛型类型参数(而非使用包容类型的泛型类型参数),但C#不允许。之所以属性不能引入它自己的泛型类型参数,最主要的原因是概念上说不通。属性本来应该表示可供查询或设置的某个对象特征。一旦引入泛型类型的参数,就意味着有可能改变查询/设置行为。但属性不应该和行为沾边。公开对象的行为-无论是不是泛型-都应该定义方法而非属性。
浙公网安备 33010602011771号