为什么程序已经可以正常工作了,我们还要改变它们呢?答案就是我们可以让它们变得更好。我们常常会改变所使用的工具或者语言,因为新的工具或者语言更富生产力。如果固守旧有的习惯,我们将得不到期望的结果。对于C#这种和我们已经熟悉的语言(如C++或Java)有诸多共通之处的新语言,情况更是如此。人们很容易回到旧的习惯中去。当然,这些旧的习惯绝大多数都很好,C#语言的设计者们也确实希望我们能够利用这些旧习惯下所获取的知识。但是,为了让C#和公共语言运行库(Common Language Runtime,CLR)能够更好地集成在一起,从而为面向组件的软件开发提供更好的支持,这些设计者们不可避免地需要添加或者改变某些元素。本章将讨论那些在C#中应该改变的旧习惯,以及对应的新的推荐做法。
条款1:使用属性代替可访问的数据成员
C#将属性从其他语言中的一种特殊约定提升成为一种第一等(first-class)的语言特性。如果大家还在类型中定义公有的数据成员,或者还在手工添加get和set方法,请赶快停下来。属性在使我们可以将数据成员暴露为公有接口的同时,还为我们提供了在面向对象环境中所期望的封装。在C#中,属性(property)是这样一种语言元素:它们在被访问的时候看起来好像是数据成员,但是它们却是用方法实现的。
有时候,一些类型成员最好的表示形式就是数据,例如一个客户的名字、一个点的x/y坐标,或者上一年的收入。使用属性我们可以创建一种特殊的接口——这种接口在行为上像数据访问,但却仍能获得函数的全部好处。客户代码[1]对属性的访问就像访问公有变量一样。但实际的实现采用的却是方法,这些方法内部定义了属性访问器的行为。
.NET框架假定我们会使用属性来表达公有数据成员。事实上,.NET框架中的数据绑定类只支持属性,而不支持公有数据成员。这些数据绑定类会将对象的属性关联到用户界面控件(Web控件或者Windows Forms控件)上。其数据绑定机制事实上是使用反射来查找一个类型中具有特定名称的属性。例如下面的代码:
textBoxCity.DataBindings.Add("Text",
address, "City");
便是将textBoxCity控件的Text属性和address对象的City属性绑定在一起。(有关数据绑定的细节,参见条款38。)如果City是一个公有数据成员,这样的数据绑定就不能正常工作。.NET框架类库(Framework Class Library)的设计者们之所以不支持这样的做法,是因为将数据成员直接暴露给外界不符合面向对象的设计原则。.NET框架类库这样的设计策略从某种意义上讲也是在推动我们遵循面向对象的设计原则。对于C++和Java编程老手,我想特别指出的是这些数据绑定代码并不会去查找get和set函数。在C#中,我们应该忘掉get_和set_这些旧式的约定,而全面采用属性。
当然,数据绑定所应用的类一般都要和用户界面打交道。但这并不意味着属性只在UI(用户界面)逻辑中有用武之地。对于其他类和结构,我们也需要使用属性。随着时间的推移,新的需求或行为往往会影响原来类型的实现,采用属性比较容易能够应对这些变化。例如,我们可能很快就会发现Customer类型不能有一个空的Name。如果我们使用一个公用属性来实现Name,那么只需要在一个地方做更改即可:
public class Customer
{
private string _name;
public string Name
{
get
{
return _name;
}
set
{
if (( value == null ) ||
( value.Length == 0 ))
throw new ArgumentException( "Name cannot be blank",
"Name" );
_name = value;
}
}
// ……
}
如果使用的是公有数据成员,我们就要寻找并修改所有设置Customer的Name的代码,那将花费大量的时间。
另外,由于属性是采用方法来实现的,因此为它们添加多线程支持就更加容易——直接在get和set方法中提供同步数据访问控制即可:
public string Name
{
get
{
lock( this )
{
return _name;
}
}
set
{
lock( this )
{
_name = value;
}
}
}
既然是采用方法来实现的,那么属性也就具有了方法所具有的全部功能。比如,属性可以实现为虚属性:
public class Customer
{
private string _name;
public virtual string Name
{
get
{
return _name;
}
set
{
_name = value;
}
}
// 忽略其他实现代码。
}
自然,属性也可以实现为抽象属性,或者作为接口定义的一部分:
public interface INameValuePair
{
object Name
{
get;
}
object Value
{
get;
set;
}
}
最后,我们还可以借助属性的特点来创建const和非const版本的接口:
public interface IConstNameValuePair
{
object Name
{
get;
}
object Value
{
get;
}
}
public interface INameValuePair
{
object Value
{
get;
set;
}
}
// 上述接口的应用:
public class Stuff : IConstNameValuePair, INameValuePair
{
private string _name;
private object _value;
#region IConstNameValuePair Members
public object Name
{
get
{
return _name;
}
}
object IConstNameValuePair.Value
{
get
{
return _value;
}
}
#endregion
#region INameValuePair Members
public object Value
{
get
{
return _value;
}
set
{
_value = value;
}
}
#endregion
}
属性在C#中已经成为一项比较完善的、第一等的语言元素。我们可以针对成员函数做的任何事情,对于属性也同样适用。毕竟,属性是对访问/修改内部数据的方法的一种扩展。
我们知道,属性访问器在编译后事实上是两个分离的方法。在C# 2.0中,我们可以为一个属性的get访问器和set访问器指定不同的访问修饰符。这使得我们可以更好地控制属性的可见性。
// 合法的C# 2.0代码:
public class Customer
{
private string _name;
public virtual string Name
{
get
{
return _name;
}
protected set
{
_name = value;
}
}
// 忽略其他实现代码。
}
C#的属性语法扩展自简单的数据字段。如果类型接口需要包含一些索引数据项,则可以使用一种称作索引器(indexer)的类型成员。索引器在C#中又称含参属性(parameterized property)。这种“使用属性来返回一个序列中的数据项”的做法对于很多场合非常有用,下面的代码展示了这一用法:
public int this [ int index ]
{
get
{
return _theValues [ index ] ;
}
set
{
_theValues[ index ] = value;
}
}
// 访问索引器:
int val = MyObject[ i ];
索引器和一般的属性(即支持单个数据项的属性)在C#中有同样的语言支持,它们都用方法实现,我们可以在其内部做任何校验或者计算工作。索引器也可以为虚索引器,或者抽象索引器。它们可以声明在接口中,也可以成为只读索引器或者读—写索引器。以数值作为参数的“一维索引器”还可以参与数据绑定。使用非数值的索引器则可以用来定义map或者dictionary等数据结构:
public Address this [ string name ]
{
get
{
return _theValues[ name ] ;
}
set
{
_theValues[ name ] = value;
}
}
与C#中的多维数组类似,我们也可以创建“多维索引器”——其每一维上的参数类型可以相同,也可以不同。
public int this [ int x, int y ]
{
get
{
return ComputeValue( x, y );
}
}
public int this[ int x, string name ]
{
get
{
return ComputeValue( x, name );
}
}
注意所有的索引器都使用this关键字来声明。我们不能为索引器指定其他的名称。因此,在每个类型中,对于同样的参数列表,我们只能有一个索引器。
属性显然是一个好东西,相较于以前的各种访问方式来讲,它的确是一个进步。但是,有些读者可能会有如下的想法:刚开始先使用数据成员,之后如果需要获得属性的好处时,再考虑将数据成员替换为属性。这种做法听起来似乎有道理,但实际上是错的。让我们来看下面一段代码:
// 使用公有数据成员,不推荐这种做法:
public class Customer
{
public string Name;
// 忽略其他实现代码。
}
这段代码描述了一个Customer类,其内包含一个成员Name。我们可以使用成员访问符来获取/设置其Name的值:
string name = customerOne.Name;
customerOne.Name = "This Company, Inc.";
这段代码非常简洁和直观。有人据此就认为以后如果有需要,再将Customer类的数据成员Name替换为属性就可以了,而使用Customer类型的代码无需做任何改变。这种说法从某种程度上来讲是对的。
属性在被访问的时候和数据成员看起来没有什么差别。这正是C#引入新的属性语法的一个目标。但属性毕竟不是数据,访问属性和访问数据产生的是不同的MSIL。前面那个Customer类型的Name字段在编译后将产生如下MSIL代码:
.field public string Name
而访问该字段的部分编译后的MSIL代码如下:
ldloc.0
ldfld string NameSpace.Customer::Name
stloc.1
向该字段存储数据的部分编译后的MSIL代码如下:
ldloc.0
ldstr "This Company, Inc."
stfld string NameSpace.Customer::Name
大家不必担忧,我们不会整天围绕着IL代码转。为了让大家清楚“在数据成员和属性之间做改变会打破二进制兼容性”,在这里展示一下IL代码还是很重要的。我们再来看下面的Customer类型实现,这次我们采用了属性的方案:
public class Customer
{
private string _name;
public string Name
{
get
{
return _name;
}
set
{
_name = value;
}
}
// 忽略其他实现代码。
}
当我们在C#中访问Name属性时,使用的语法和前面访问字段的语法一模一样。
string name = customerOne.Name;
customerOne.Name = "This Company, Inc.";
但是,C#编译器对于两段相同的C#代码产生的却是完全不同的MSIL代码。我们来看新版Customer类型的Name属性编译后的MSIL:
.property instance string Name()
{
.get instance string NameSpace.Customer::get_Name()
.set instance void NameSpace.Customer::set_Name(string)
} // 属性Customer::Name结束。
.method public hidebysig specialname instance string
get_Name() cil managed
{
// 代码长度 11 (0xb)
.maxstack 1
.locals init ([0] string CS$00000003$00000000)
IL_0000: ldarg.0
IL_0001: ldfld string NameSpace.Customer::_name
IL_0006: stloc.0
IL_0007: br.s IL_0009
IL_0009: ldloc.0
IL_000a: ret
} // 方法Customer::get_Name结束。
.method public hidebysig specialname instance void
set_Name(string 'value') cil managed
{
// 代码长度 8 (0x8)
.maxstack 2
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld string NameSpace.Customer::_name
IL_0007: ret
} // 方法Customer::set_Name结束。
在将属性定义从C#代码转换为MSIL的过程中,有两点需要我们注意:首先,.property指示符定义了属性的类型,以及实现属性get访问器和set访问器的两个函数。这两个函数被标记为hidebysig和specialname。对我们来说,这两个标记意味着它们所修饰的函数不能直接在C#源代码中被调用,也不被认为是类型正式定义的一部分。要访问它们,我们只能通过属性。
当然,大家对于属性定义产生不同的MSIL应该早有预期。更重要的是,对属性所做的get和set访问的客户代码编译出来的MSIL也不同:
// get
ldloc.0
callvirt instance string NameSpace.Customer::get_Name()
stloc.1
// set
ldloc.0
ldstr "This Company, Inc."
callvirt instance void NameSpace.Customer::set_Name(string)
大家看到了,同样是访问客户(Customer)名称(Name)的C#源代码,由于所使用的Name成员不同——属性或者数据成员,编译后产生出的MSIL指令也不同。尽管访问属性和访问数据成员使用的是同样的C#源代码,但是C#编译器却将它们转换为不同的IL代码。
换句话说,虽然属性和数据成员在源代码层次上是兼容的,但是在二进制层次上却不兼容。这意味着如果将一个类型的公有数据成员改为公有属性,那么我们必须重新编译所有使用该公有数据成员的C#代码。本书第4章“创建二进制组件”讨论了二进制组件的相关细节,但是在此之前大家要清楚,将一个数据成员改为属性会破坏二进制兼容性。如果这样的程序集已经被部署,那么升级它们的工作将变得非常麻烦。
看了属性产生的IL代码之后,有读者可能想知道使用属性和使用数据成员在性能上有什么差别。虽然使用属性不会比使用数据成员的代码效率更快,但是它也不见得就会比使用数据成员的代码慢,因为JIT编译器会对某些方法调用(包括属性访问器)进行内联处理。如果JIT编译器对属性访问器进行了内联处理,那么属性和数据成员的效率将没有任何差别。即使属性访问器没有被内联,实际的效率差别相对于函数调用的成本来讲也是可以忽略不计的。只有在很少的一些情况下,这种差别才值得我们注意。
综上所述,只要打算将数据暴露在类型的公有接口或者受保护接口中,我们都应该使用属性来实现。对于具有序列或者字典特征的类型,则应该采用索引器。所有的数据成员都应一律声明为私有。使用属性的好处显而易见:我们可以得到更好的数据绑定支持,我们可以更容易地在将来对其访问方法的实现做任何改变。将变量封装在属性中只不过增加一两分钟代码录入时间。如果刚开始使用数据成员,后来又发现需要使用属性,这时再来修改的成本将是几个小时。今天的一点投入,会为明天节省许多时间。