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 even­numbered 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#不允许。之所以属性不能引入它自己的泛型类型参数,最主要的原因是概念上说不通。属性本来应该表示可供查询或设置的某个对象特征。一旦引入泛型类型的参数,就意味着有可能改变查询/设置行为。但属性不应该和行为沾边。公开对象的行为-无论是不是泛型-都应该定义方法而非属性。

posted @ 2019-12-08 10:31  FH1004322  阅读(168)  评论(0)    收藏  举报