改善C#程序的50种方法(转)
,为什么程序已经可以正常工作了,我们还要改变它们呢?答案就是我们可以让它们变得更好。我们常常会改变所使用的工具或者语言,因为新的工具或者语言更富生产力。如果固守旧有的习惯,我们将得不到期望的结果。对于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编译器对属性访问器进行了内联处理,那么属性和数据成员的效率将没有任何差别。即使属性访问器没有被内联,实际的效率差别相对于函数调用的成本来讲也是可以忽略不计的。只有在很少的一些情况下,这种差别才值得我们注意。
综上所述,只要打算将数据暴露在类型的公有接口或者受保护接口中,我们都应该使用属性来实现。对于具有序列或者字典特征的类型,则应该采用索引器。所有的数据成员都应一律声明为私有。使用属性的好处显而易见:我们可以得到更好的数据绑定支持,我们可以更容易地在将来对其访问方法的实现做任何改变。将变量封装在属性中只不过增加一两分钟代码录入时间。如果刚开始使用数据成员,后来又发现需要使用属性,这时再来修改的成本将是几个小时。今天的一点投入,会为明天节省许多时间。
条款2:运行时常量(readonly)优于编译时常量(const)
C#语言有两种不同的常量机制:一种为编译时 (compile-time)常量,一种为运行时(runtime)常量。两种常量有着非常迥异的行为,使用不正确会导致程序的性能下降或者出现错误。这 两种代价,哪一个都没有人愿意承担,但是如果必须承担一个,那么“慢、但是能够正确运行的”程序总比“快、但是可能出错的”程序要好。因此,我们说运行时常量优于编译时常量。编译时常量比运行时常量稍微快一点,但却缺乏灵活性。只有在性能非常关键,并且其值永远不会改变的情况下,我们才应该使用编译时常量。
在C#中,我们使用readonly关键字来声明运行时常量,用const关键字来声明编译时常量。
// 编译时常量:
public const int _Millennium = 2000;
// 运行时常量:
public static readonly int _ThisYear = 2004;
编译时常量与运行时常量行为的不同处在于它们的访问方式。编译时常量在编译后的结果代码中会被替换为该常量的值,例如下面的代码:
if ( myDateTime.Year == _Millennium )
其编译后的IL和下面的代码编译后的IL一样:
if ( myDateTime.Year == 2000 )
运行时常量的值则在运行时被计算。对于使用运行时常量的代码,其编译后的IL将维持对readonly变量(而非它的值)的引用。
这种差别会为我们使用两种常量类型带来一些限制。编译时常量只可以用于基元类型(包括内建的整数类型和浮点类型)、枚举类型或字符串类型。因为只有这些类型才允许我们在初始化器中指定有意义的常量值[2]。在使用这些常量的代码编译后得到的IL代码中,常量将直接被替换为它们的字面值(literal)。例如,下面的代码就不会通过编译。事实上,C#不允许我们使用new操作符来初始化一个编译时常量,即使被初始化的常量类型为一个值类型。
// 下面的代码不会通过编译,但是换成readonly就可以:
private const DateTime _classCreation = new DateTime( 2000, 1, 1, 0, 0, 0 );
编译时常量仅限于数值和字符串。只读(read- only)字段之所以也被称作一种常量,是因为它们的构造器一旦被执行,我们将不能对它们的值做任何修改。与编译时常量不同的地方在于,只读字段的赋值操 作发生在运行时,因此它们具有更多的灵活性。比如,只读字段的类型就没有任何限制。对于只读字段,我们只能在构造器或者初始化器中为它们赋值。在上面的代 码中,我们可以声明readonly的DateTime结构变量,但是却不能声明const的DateTime结构变量。
我们可以声明readonly的实例常量,从而为一个类型的每个实例存储不同的值。但是const修饰的编译时常量默认就被定义为静态常量。
我们知道,运行时常量和编译时常量最重要的区别就在于运行时常量值的辨析发生在运行时,而编译时常量值的辨析发生编译时。换言之,使用运行时常量编译后的IL代码引用的是readonly变量,而非它的值;而使用编译时常量编译后的IL代码将直接引用它的值——就像我们直接在代码中使用常量值一样。即使我们使用的是数值常量并跨程序集引用,情况也是一样:如果在程序集A中引用程序集B中的常量,那么编译后程序集A中出现的那个常量将被它的值所替换。这种差别对于代码的维护性而言有着相当的影响。
编译时常量与运行时常量被辨析的方式影响着运行时的兼容性。假设我们在一个名为Infrastructure 的程序集中分别定义了一个const字段和一个readonly字段:
public class UsefulValues
{
public static readonly int StartValue = 5;
public const int EndValue = 10;
}
在另外一个程序集Application中,我们又引用着这些值:
for ( int i = UsefulValues.StartValue; i < UsefulValues.EndValue; i++ )
Console.WriteLine( "value is {0}", i );
如果我们运行上面的代码,将得到以下输出:
Value is 5
Value is 6
...
Value is 9
假设随着时间的推移,我们又发布了一个新版的Infrastructure程序集:
public class UsefulValues
{
public static readonly int StartValue = 105;
public const int EndValue = 120;
}
我们将新版的Infrastructure程序集分发出去,但并没有重新编译Application程序集。我们期望得到如下的输出:
Value is 105
Value is 106
...
Value is 119
但实际上,我们却没有得到任何输出。因为现在那个循环语句将使用105作为它的起始值,使用10作为它的结束 条件。其根本原因在于C#编译器在第一次编译Application程序集时,将其中的EndValue替换成了它对应的常量值10。而对于 StartValue来说,由于它被声明为readonly,所以它的辨析发生在运行时。因此,Application程序集在没有被重新编译的情况下, 仍然可以使用新的StartValue值。为了改变所有使用readonly常量的客户代码的行为,简单地安装一个新版的Infrastructure 程序集就足够了。“更改一个编译时常量的值”应该被视作对类型接口的更改,其后果是我们必须重新编译所有引用该常量的代码。“更改一个公有的运行时常量的值”应该被视作对类型实现的更改,它与其客户代码在二进制层次上是兼容的。大家看看上述代码中的循环编译后的MSIL,就会对这里所谈的更加清楚了:
IL_0000: ldsfld int32 Chapter1.UsefulValues::StartValue
IL_0005: stloc.0
IL_0006: br.s IL_001c
IL_0008: ldstr "value is {0}"
IL_000d: ldloc.0
IL_000e: box [mscorlib]System.Int32
IL_0013: call void [mscorlib]System.Console::WriteLine
(string,object)
IL_0018: ldloc.0
IL_0019: ldc.i4.1
IL_001a: add
IL_001b: stloc.0
IL_001c: ldloc.0
IL_001d: ldc.i4.s 10
IL_001f: blt.s IL_0008
大家可以在这段MSIL代码的顶端看到StartValue的确是被动态加载的,而在其末尾可以看到结束条件被硬编码(hard-code)为10。
不过,有时候有些值确实可以在编译时确定,这时候就应该使用编译时常量。例如,考虑在对象的序列化形式(有关对象序列化,可参见条款25)中使用一组常量来区分不同版本的对象。其中,标记特殊版本号的持久化数据应该采用编译时常量,因为它们的值永远不会改变。但是标记当前版本号的数据应该采用运行时常量,因为它的值会随着每个不同的版本而改动。
private const int VERSION_1_0 = 0x0100;
private const int VERSION_1_1 = 0x0101;
private const int VERSION_1_2 = 0x0102;
// 主发行版本:
private const int VERSION_2_0 = 0x0200;
// 标记当前版本:
private static readonly int CURRENT_VERSION = VERSION_2_0;
我们使用运行时版本[3]来将当前的版本号存储在每一个序列化文件中:
// 从持久层数据源读取对象,将存储的版本号与编译时常量相比对:
protected MyType( SerializationInfo info, StreamingContext cntxt )
{
int storedVersion = info.GetInt32( "VERSION" );
switch ( storedVersion )
{
case VERSION_2_0:
readVersion2( info, cntxt );
break;
case VERSION_1_1:
readVersion1Dot1( info, cntxt );
break;
// 忽略其他细节。
}
}
// 写入当前版本号:
[SecurityPermissionAttribute( SecurityAction.Demand, SerializationFormatter =true ) ]
void ISerializable.GetObjectData( SerializationInfo inf, StreamingContext cxt )
{
// 使用运行时常量来标记当前版本号:
inf.AddValue( "VERSION", CURRENT_VERSION );
// 写入其他元素……
}
使用const较之于使用readonly的唯一好处就是性能:使用已知常量值的代码效率要比访问readonly值的代码效率稍好一点。但是这其中的效 率提升是非常小的,大家应该和其所失去的灵活性进行一番权衡比较。在打算放弃灵活性之前,一定要对两者的性能差别做一个评测。
综上所述,只有当某些情况要求变量的值必须在编译时可用,才应该考虑使用const,例如:特性(attribute)类的参数,枚举定义,以及某些不随组件版本变化而改变的值。否则,对于其他任何情况,都应该优先选择readonly常量,从而获得其所具有的灵活性。
条款3:操作符is或as优于强制转型
C#是一门强类型语言。一般情况下,我们最好避免将一个类型强制转换为其他类型。但是,有时候运行时类型检查是无法避免的。相信大家都写过很多以System.Object类型为参数的函数,因为. NET框架预先为我们定义了这些函数的签名。在这些函数内部,我们经常要把那些参数向下转型为其他类型,或者是类,或者是接口。对于这种转型,我们通常有两种选择:使用as操作符,或者使用传统C风格的强制转型。另外还有一种比较保险的做法:先使用is来做一个转换测试,然后再使用as操作符或者强制转 型。
正确的选择应该是尽可能地使用as操作符,因为它比强制转型要安全,而且在运行时层面也有比较好的效率。需要注意的是,as和is操作符都不执行任何用户自定义的转换。只有当运行时类型与目标转换类型匹配时,它们才会转换成功。它们永远不会在转换过程中构造新的对象。
我们来看一个例子。假如需要将一个任意的对象转换为一个MyType的实例。我们可能会像下面这样来做:
object o = Factory.GetObject( );
// 第一个版本:
MyType t = o as MyType;
if ( t != null )
{
// 处理t, t现在的类型为MyType。
} else
{
// 报告转型失败。
}
或者,也可以像下面这样来做:
object o = Factory.GetObject( );
// 第二个版本:
try {
MyType t;
t = ( MyType ) o;
if ( t != null )
{
// 处理t, t现在的类型为MyType。
} else
{
// 报告空引用失败。
}
} catch
{
// 报告转型失败。
}
相信大家都同意第一个版本的转型代码更简单,也更 容易阅读。其中没有添加额外的try/catch语句,因此也就避免了其带来的负担。注意,第二个版本中除了要捕捉异常外,还要对null的情况进行检 查,因为如果o本来就是null,那么强制转型可以将它转换成任何引用类型。但如果是as操作符,且被转换对象为null,那么执行结果将返回null。 因此,如果使用强制转型,我们既要检查其是否为null,还要捕捉异常。如果使用as操作符,我们只需要检查返回的引用是否为null就可以了。
cast 和as操作符之间最大的区别就在于如何处理用户自定义的转换。操作符as和is都只检查被转换对象的运行时类型,并不执行其他的操作。如果被转换对象的运行时类型既不是所转换的目标类型,也不是其派生类型,那么转型将告失败。但是,强制转型则会使用转换操作符来执行转型操作,这包括任何内建的数值转换。例如,将一个long类型强制转换为一个short类型将会导致部分信息丢失。
在我们使用用户自定义的转换时,也会有同样的问题,来看下面的代码:
public class SecondType
{
private MyType _value;
// 忽略其他细节。
// 转换操作符。
// 将SecondType 转换为MyType,参见条款29。[4]
public static implicit operator MyType( SecondType t )
{
return t._value;
}
}
假设下面第一行代码中的Factory.GetObject()返回的是一个SecondType对象:
object o = Factory.GetObject( );
// o 为一个SecondType:
MyType t = o as MyType; // 转型失败,o的类型不是MyType。
if ( t != null )
{
// 处理t, t现在的类型为MyType。
} else
{
// 报告转型失败。
}
// 第二个版本:
try {
MyType t1;
t1 = ( MyType ) o; // 转型失败,o的类型不是MyType。
if ( t1 != null )
{
// 处理t1, t1现在的类型为MyType。
} else
{
// 报告空引用失败。
}
} catch
{
// 报告转型失败。
}
两个版本的转型操作都失败了。大家应该还记得我前面说过强制转型会执行用户自定义的转换,有读者据此认为强制转型的那个版本会成功。这么想本身没有错误,只是编译器在产生代码时依据的是对象o的编译时类 型。编译器对于o的运行时类型一无所知——编译器只知道o的类型是System.Object。因此编译器只会检查是否存在将System.Object 转换为MyType的用户自定义转换。它会到System.Object类型和MyType类型的定义中去做这样的检查。由于没有找到任何用户自定义转 换,编译器将产生代码来检查o的运行时类型,并将其和MyType进行比对。由于o的运行时类型为SecondType,因此转型将告失败。编译器不会检查在o的运行时类型SecondType和MyType之间是否存在用户自定义的转换。
当然,如果将上述代码做如下修改,转换就会成功执行:
object o = Factory.GetObject( );
// 第三个版本:
SecondType st = o as SecondType;
try {
MyType t;
t = ( MyType ) st;
if ( t != null )
{
// 处理t, t现在的类型为MyType。
} else
{
// 报告空引用失败。
}
} catch
{ // 报告转型失败。
}
在正式的开发中,我们绝不能写如此丑陋的代码,但它却向我们揭示了问题的所在。虽然大家永远都不可能像上面那样写代码,但可以使用一个以System.Object类型为参数的函数,让该函数在内部执行正确的转换。
object o = Factory.GetObject( );
DoStuffWithObject( o );
private void DoStuffWithObject( object o2 )
{
try {
MyType t;
t = ( MyType ) o2; // 转型失败,o的类型不是MyType
if ( t != null )
{
// 处理t, t现在的类型为MyType。
} else
{
// 报告空引用失败。
}
} catch
{
// 报告转型失败。
}
}
记住,用户自定义的转换操作符只作用于对象的编译时类型,而非运行时类型上。至于o2的运行时类型和MyType之间是否存在转换,并不重要。事实上,编译器对此并不了解,也不关心。对于下面的语句,如果st的声明类型不同,会有不同的行为:
t = ( MyType ) st;
但对于下面的语句,不管st的声明类型是什么,都会产生同样的结果[5]。因此,我们说as操作符要优于强制转型——它的转型结果相对比较一致。
但如果as操作符两边的类型没有继承关系,即使存在用户自定义转换操作符,也会产生编译时错误。例如,下面的语句:
t = st as MyType;
我们已经知道在转型的时候应该尽可能地使用as操作符。下面我们来谈谈一些不能使用as操作符的情况。首先,as操作符不能应用于值类型。例如,下面的代码编译的时候就会报错:
object o = Factory.GetValue( );
int i = o as int; // 不能通过编译。
这是因为int是一个值类型,所以不可以为null。如果o不是一个整数,那这个i里面还能存放什么呢?存入的任何值都必须是有效的整数,所以as不能和值类型一起使用。那就只能使用强制转型了:
object o = Factory.GetValue( );
int i = 0;
try {
i = ( int ) o;
} catch
{
i = 0;
}
但是,我们也并非只能这样。我们还可以使用is语句来避免其中对异常的检查或者强制转型:
object o = Factory.GetValue( );
int i = 0;
if ( o is int )
i = ( int ) o;
如果o是某个其他可以转换为int的类型,例如double,那么is操作符将返回false。如果o的值为null,is操作符也将返回false。
只有当我们不能使用as操作符来进行类型转换时,才应该使用is操作符。否则,使用is将会带来代码的冗余:
// 正确, 但是冗余:
object o = Factory.GetObject( );
MyType t = null;
|
if ( o is MyType )
t = o as MyType;
上面的代码和下面的代码事实上是一样的:
// 正确, 但是冗余:
object o = Factory.GetObject( );
MyType t = null;
if ( ( o as MyType ) != null )
t = o as MyType;
这种做法显然既不高效,也显得冗余。如果我们打算使用as来做转型,那么再使用is检查就没有必要了。直接将as操作符的运算结果和null进行比对就可以了,这样比较简单。
既然我们已经明白了is操作符、as操作符和强制转型之间的差别,那么大家猜猜看foreach循环语句中使用的是哪个操作符来执行类型转换呢?
public void UseCollection( IEnumerable theCollection )
{
foreach ( MyType t in theCollection )
t.DoStuff( );
}
答案是强制转型。事实上,下面的代码和上面foreach语句编译后的结果是一样的:
public void UseCollection( IEnumerable theCollection )
{
IEnumerator it = theCollection.GetEnumerator( );
while ( it.MoveNext( ) )
{
MyType t = ( MyType ) it.Current;
t.DoStuff( );
}
}
之所以使用强制转型,是因为foreach语句需要同时支持值类型和引用类型。无论转换的目标类型是什么,foreach语句都可以展现相同的行为。但是,由于使用的是强制转型,foreach语句可能产生BadCastException异常[6]。
由于IEnumerator.Current返回 的是System.Object,而Object中又没有定义任何的转换操作符,因此转换操作符就不必考虑了。如果集合中是一组SecondType对象,那么运用在UseCollection()函数中将会出现转型失败,因为foreach语句使用的是强制转型,而强制转型并不关心集合元素的运行时类 型。它只检查在System.Object类(由IEnumerator.Current返回的类型)和循环变量的声明类型MyType之间是否存在转 换。
最后,有时候我们可能想知道一个对象的确切类型, 而并不关心它是否可以转换为另一种类型。如果一个类型继承自另一个类型,那么is操作符将返回true。使用System.Object的GetType ()方法,可以得到一个对象的运行时类型。利用该方法可以对类型进行比is或as更为严格的测试,因为我们可以拿它所返回的对象的类型和一个具体的类型做对比。
再来看下面的函数:
public void UseCollection( IEnumerable theCollection )
{
foreach ( MyType t in theCollection )
t.DoStuff( );
}
如果创建了一个继承自MyType的类NewType,那便可以将一组NewType对象集合应用在UseCollection函数中。
public class NewType : MyType
{
// 忽略实现细节。
}
如果我们打算编写一个函数来处理所有与 MyType类型兼容的实例对象,那么UseCollection函数所展示的做法就挺好。但如果打算编写的函数只处理运行时类型为MyType的对象, 那就应该使用GetType()方法来对类型做精确的测试。我们可以将这种测试放在foreach循环中。运行时类型测试最常用的地方就是相等判断(参见 条款9)。对于绝大多数其他的情况,as和is操作符提供的.isinst比较[7]在语义上都是正确的。
好的面向对象实践一般都告诫我们要避免转型,但有时候我们别无选择。不能避免转型时,我们应该尽可能地使用C#语言中提供的as和is操作符来更清晰地表 达意图。不同的转型方式有不同的规则,is和as操作符绝大多数情况下都能满足我们的要求,只有当被测试的对象是正确的类型时,它们才会成功。一般情况下不要使用强制转型,因为它可能会带来意想不到的负面效应,而且成功或者失败往往在我们的预料之外。
条款4:使用Conditional特性代替#if条件编译
#if/#endif 条件编译常用来由同一份源代码生成不同的结果文件,最常见的有debug版和release版。但是,这些工具在具体应用中并不是非常得心应手,因为它们太容易被滥用了,使用它们创建的代码通常都比较难理解,且难以调试。C#语言的设计者们对这种问题的解决方案是创建更好的工具,以达到为不同环境创建不同 机器码的目的。C#为此添加了一个Conditional特性,该特性可以标示出某种环境设置下某个方法是否应该被调用。使用这种方式来描述条件编译要比 #if/#endif更加清晰。由于编译器理解Conditional特性,所以它可以在Conditional特性被应用时对代码做更好的验证。 Conditional特性应用在方法这一层次上,因此它要求我们将条件代码以方法为单位来表达。当需要创建条件代码块时,我们应该使用 Conditional特性来代替传统的#if/#endif。
大多数程序老手都使用过条件编译来检查对象的前置条件和后置条件。例如,编写一个私有方法来检查所有类与对象的不变式(invariant)[8],然后将这样的方法进行条件编译,从而让其只出现在debug版本的程序中。
private void CheckState( )
{
// 老式的做法:
#if DEBUG
Trace.WriteLine( "Entering CheckState for Person" );
// 获取正在被调用函数的名称:
string methodName = new StackTrace( ).GetFrame( 1 ).GetMethod( ).Name;
Debug.Assert( _lastName != null, methodName,"Last Name cannot be null" );
Debug.Assert( _lastName.Length > 0, methodName, "Last Name cannot be blank" );
Debug.Assert( _firstName != null, methodName, "First Name cannot be null" );
Debug.Assert( _firstName.Length > 0, methodName, "First Name cannot be blank" );
Trace.WriteLine( "Exiting CheckState for Person" );
#endif
}
条件编译#if和#endif使得最终 release版本中的CheckState()成为一个空方法,但它在release版和debug版中都将得到调用。虽然在release版中, CheckState()什么也不做,但是我们必须为方法的加载、JIT编译和调用付出成本。
就正确性而言,这种做法一般没什么问题,但有时候还是可能会在release版本中导致一些诡异的bug。下面的代码展示了使用#if和#endif条件编译时可能常犯的错误:
public void Func( )
{
string msg = null;
#if DEBUG
msg = GetDiagnostics( );
#endif
Console.WriteLine( msg );
}
上面的代码在debug版本中运行得很好,但是放 到release版本中就会输出一个空行。输出一个空行本身没有什么,但这毕竟不是我们本来的意图。我们自己搞糟的事情,编译器也帮不上什么忙,因为我们 把属于程序主逻辑的代码和条件编译代码混在一起了。在源代码中随意地使用#if和#endif将使我们很难诊断不同版本间的行为差别。
C#为此 提出了一种更好的选择:Conditional特性。使用Conditional特性,我们可以将一些函数隔离出来,使得它们只有在定义了某些环境变量或者设置了某个值之后才能发挥作用。Conditional特性最常用的地方就是将代码改编为调试语句。.NET框架已经为此提供了相关的功能支持。下面的 代码展示了Conditional特性的工作原理,以及适用场合。
构建Person对象时,我们一般会添加如下的方法来验证对象的不变式:
private void CheckState( )
{
// 获取正在被调用函数的名称:
string methodName = new StackTrace( ).GetFrame( 1 ).GetMethod( ).Name;
Trace.WriteLine( "Entering CheckState for Person:" );
Trace.Write( "\tcalled by " );
Trace.WriteLine( methodName );
Debug.Assert( _lastName != null, methodName, "Last Name cannot be null" );
Debug.Assert( _lastName.Length > 0, methodName, "Last Name cannot be blank" );
Debug.Assert( _firstName != null, methodName, "First Name cannot be null" );
Debug.Assert( _firstName.Length > 0, methodName, "First Name cannot be blank" );
Trace.WriteLine( "Exiting CheckState for Person" );
}
有些读者可能对上面代码中的一些库函数还不够熟悉,我 们来简单介绍一下。StackTrace类使用反射(reflection,参见条款43)来获取当前正被调用的方法名。其代价相当高,但它可以极大地简 化我们的工作,例如帮助我们获取有关程序流程的信息。在上面的代码中,使用它,我们便可以得到正被调用的方法名称为CheckState。其余的方法在另 外两个类中,分别为System.Diagnostics.Debug和System.Diagnostics.Trace。Debug.Assert方 法用于测试某个条件,如果该条件错误,程序将被终止,其他参数定义的消息也将被打印出来。Trace.WriteLine方法则会把诊断信息打印到调试控 制台上。因此,如果有Person对象状态无效,CheckState方法将会显示信息,并终止程序。我们可以将其作为前置条件和后置条件,在所有的公有 方法和受保护方法中调用它。
public string LastName
{
get
{
CheckState( );
return _lastName;
}
set
{
CheckState( );
_lastName = value;
CheckState( );
}
}
当首次试图将LastName属性设置为空字符串或者null时,CheckState将引发一个断言错误。这样我们就会修正set访问器以检查传递给LastName的参数。这正是我们想要的功能。
但在每个公有函数中都做这样的额外检查显然比较浪费时间,我们可能只希望其出现在调试版本中。这就需要Conditional特性了:
[ Conditional( "DEBUG" ) ]
private void CheckState( )
{
// 代码保持不变。
}
应用了Conditional特性之后,C#编译器只有在检测到DEBUG环境变量时,才会产生对CheckState方法的调用。Conditional特性不会影响CheckState()方法的编 译,它只会影响对该方法的调用。如果定义有DEBUG符号,上面的LastName属性将变为如下的代码:
public string LastName
{
get
{
CheckState( );
return _lastName;
}
set
{
CheckState( );
_lastName = value;
CheckState( );
}
}
否则,将得到如下代码:
public string LastName
{
get
{
return _lastName;
}
set
{
_lastName = value;
}
}
无论是否定义有DEBUG符号, CheckState()方法的方法体都维持不变,它都会被C#编译器处理,并生成到结果程序集中。这个例子其实也向大家展示了C#编译器的编译过程与 JIT编译过程之间的区别。这种做法看起来也会带来一点效率损失,但是其中耗费的成本仅仅是磁盘空间。如果没有被调用,CheckState()方法并不会加载到内存中并进行JIT编译。将CheckState()方法生成到程序集中产生的影响是非常微不足道的。这种策略耗费很小的性能,换来的却是灵活 性。如果感兴趣的话,大家可以查看.NET框架类库中的Debug类来对此获得更深的理解。在每个安装有.NET框架的机器上,System.dll程序 集中都包含有Debug类中所有方法的代码。当调用这些方法的代码被编译时,系统环境变量将决定这些方法是否被调用。
我们创建的方法也可以依赖于多个环境变量。当我们应用多个Conditional特性时,它们之间的组合关系将为“或(OR)”。例如,下面的CheckState方法被调用的条件为定义有DEBUG或者TRACE环境变量:
[ Conditional( "DEBUG" ), Conditional( "TRACE" ) ]
private void CheckState( )
要创建一个使用“与(AND)”关系的构造,我们需要自己在源代码中定义预处理符号:
#if ( VAR1 && VAR2 )
#define BOTH
#endif
是的,要创建一个依赖于多个环境变量的条件程序,我们不得不回到使用#if的老式做法中去。不过所有#if都只不过是创建新的符号而已,我们应该避免将可执行代码放在其中。
Conditional特性只可以应用在整个方法上。另外需要注意的是,任何一个使用Conditional特性的方法只能返回void类型。
我们不能在一个方法内的代码块上应用Conditional特性,也不可以在有返回值的方法上应用 Conditional特性。为了应用Conditional特性,我们需要将具有条件性的行为单独放到一个方法中。虽然我们仍然需要注意那些 Conditional方法可能给对象状态带来的负面效应,但Conditional特性的隔离策略总归要比#if/#endif好得多。使用#if和 #endif代码块,我们很有可能会错误地删除一些重要的方法调用或者赋值语句。
上面的例子使用了DEBUG或者TRACE这样的预定义符号,但我们也可以将其扩展到我们自己定义的符号上。Conditional特性可以被任何方式定义的符号所控制,例如编译器命令行,操作系统shell的环境变量,或者源代码pragma。
综上所述,使用Conditional特性比使用 #if/#endif产生的IL代码更有效率。同时,将其限制在函数层次上可以清晰地将条件性的代码分离出来,从而使我们的代码具有更好的结构。另外, C#编译器也为此提供了很好的支持,从而帮助我们避免以前使用#if或#endif时常犯的错误。
条款5:总是提供ToString()方法
System.Object.ToString ()恐怕是.NET中最常用的方法了。应该为我们的类的所有客户代码提供一个合理的版本,否则这些代码就只能使用我们的类的一些属性来自己定制可读的表示 了。类型的字符串表示非常有用,可以在很多地方向用户显示对象的有关信息,例如在Windows Forms上、Web Forms上、控制台输出窗口中,以及调试环境中。为此,我们创建的每一个类型都应该重写Object类的ToString()方法。如果创建的是更复杂 的类型,则应该实现Iformattable.ToString()方法。如果我们没有重写该方法,或者写得不够好,那么使用它们的客户代码就要自己想办法修补了。
System.Object 默认提供的ToString()方法会返回类型的名称。这样的信息一般没有什么用处,像"Rect"、"Point"、"Size"这样的字符串大多都不 是我们希望显示给用户的。但如果我们不重写Object的ToString()方法,用户看到的就将是这些。我们只需要写一次,但是客户将享用无数次。一点点付出,就可以让很多人(包括我们自己)受益。
让我们来看看重写System.Object.ToString()这个最简单的需求。该方法主要的功能就是为类型提供一个最常用的文本表示。例如,考虑下面这个具有三个字段的Customer类:
public class Customer
{
private string _name;
private decimal _revenue;
private string _contactPhone;
}
如果不提供重写的版本,Customer将继承 Object类的ToString()方法,也就是返回一个"Customer"字符串。这个字符串实在没有什么用处。即使ToString()方法只应 用于调试的目的,它也应该输出一个更有意义的字符串。我们重写的时候应该尽量考虑客户所希望的表示。就Customer类来说,返回_name是一个不错 的选择:
public override string ToString()
{
return _name;
}
即使大家不遵循本条款中的其他建议,也要遵循这里所展示的实践。它可以节省很多人的时间。在我们为Customer类重写了ToString()方法之后,该类的对象将可以更容易地添加到Windows Forms控件、Web Forms控件或者控制台上。.NET FCL在将对象显示到各个控件(如Combo Box、List Box、Text Box等)上时,使用的就是Object.ToString()的重写版本。如果我们在Windows Forms或者Web Forms上创建了一个Customer对象的列表,其文本显示将为Customer的名称(_name)。 System.Console.WriteLine()方法、System.String.Format()方法等内部也都调用到了ToString() 方法。只要当.NET FCL需要获取Customer的字符串表示时,我们的Customer类型都将以其名称(_name)来响应。仅仅提供一个具有三行代码的方法,就可以 处理所有这些基本的需求。
虽然简单的ToString()方法很多时候已经可以满足我们的需求,但有时候,我们还需要功能更强的方法。上述Customer类型有三个字段:_name、 _revenue和_contactPhone,而我们仅使用了_name一个字段。我们可以通过实现IFormattable接口来解决这个问题。 IFormattable接口包含了一个重载的ToString()方法,它允许我们为类型指定某种格式信息。当我们需要为类型创建不同形式的字符串输出 时,这个接口非常有用。Customer类型就是一个例子。比如,有些用户可能希望创建一个报表,在其中以表格的形式包含客户的名称和上一年的收入。 IFormattable.ToString()方法允许用户为我们的类型指定某种格式的字符串输出。其签名如下:
string System.IFormattable.ToString( string format, IFormatProvider formatProvider )
我们可以使用格式字符串来为我们的类型指定自己的格式。比如,使用特定的字符来表示某种格式信息。在Customer类型的例子中,我们可以使用n来表示name,使用r来表示revenue,使用p来 表示phone。另外,还可以指定这些字符的组合形式。下面的代码展示了一种可能的做法:
#region IFormattable Members
// 所支持的格式:
// 用n 表示name。
// 用r 表示revenue。
// 用p 表示contact phone。
// 同时支持组合格式: nr、np、npr等。
// "G" 表示通用格式。
string System.IFormattable.ToString( string format, IFormatProvider formatProvider )
{
if ( formatProvider != null )
{
ICustomFormatter fmt = formatProvider.GetFormat(this.GetType( ) ) as ICustomFormatter;
if ( fmt != null )
return fmt.Format( format, this, formatProvider );
}
switch ( format )
{
case "r":
return _revenue.ToString( );
case "p":
return _contactPhone;
case "nr":
return string.Format( "{0,20}, {1,10:C}", _name, _revenue );
case "np":
return string.Format( "{0,20}, {1,15}", _name, _contactPhone );
case "pr":
return string.Format( "{0,15}, {1,10:C}", _contactPhone, _revenue );
case "pn":
return string.Format( "{0,15}, {1,20}", _contactPhone, _name );
case "rn":
return string.Format( "{0,10:C}, {1,20}", _revenue, _name );
case "rp":
return string.Format( "{0,10:C}, {1,20}", _revenue, _contactPhone );
case "nrp":
return string.Format( "{0,20}, {1,10:C}, {2,15}", _name, _revenue, _contactPhone );
case "npr":
return string.Format( "{0,20}, {1,15}, {2,10:C}", _name, _contactPhone, _revenue );
case "pnr":
return string.Format( "{0,15}, {1,20}, {2,10:C}", _contactPhone, _name, _revenue );
case "prn":
return string.Format( "{0,15}, {1,10:C}, {2,15}", _contactPhone, _revenue, _name );
case "rpn":
return string.Format( "{0,10:C}, {1,15}, {2,20}", _revenue, _contactPhone, _name );
case "rnp":
return string.Format( "{0,10:C}, {1,20}, {2,15}", _revenue, _name, _contactPhone );
case "n":
case "G":
default:
return _name;
}
}
#endregion
添加该函数使得Customer类型的客户可以定制Customer类型的表示:
IFormattable c1 = new Customer();
Console.WriteLine( "Customer record: {0}", c1.ToString( "nrp", null ) );
IFormattable.ToString ()的实现一般来说是依类型而异的,但有些工作是每一个类型中我们都需要处理的。首先,我们必须支持表示“通用格式”的"G"。其次,我们必须支持两种形式的“空格式”,即""和null。这三种格式返回的字符串必须与Object.ToString()的重写版本返回的字符串相同。.NET FCL对每一个实现了IFormattable接口的类型,会调用IFormattable.ToString(),而非Object.ToString ()。.NET FCL通常会用一个null的格式字符串来调用IFormattable.ToString(),只是在一小部分场合会使用"G"来表示通用格式。如果我们的类型支持IFormattable接口,但又不支持这些标准格式,那么我们就打破了FCL中的自动字符串转换规则。
IFormattable.ToString ()方法的第二个参数为一个实现了IFormatProvider接口的对象。该对象允许客户程序提供一些我们不能预料的格式化选项。如果看前面 IFormattable.ToString()的实现,总会有一些我们期望、但实际上却没有提供的格式化选项。如果我们希望提供的输出容易为人所读懂, 这种情况便不可避免。不管我们支持多少种格式化选项,用户总有一天会期望某种我们无法预料的格式。这就是上面的代码示例中最开始的几行所做的工作:寻找实现了IFormatProvider接口的对象,然后将格式化任务交给其中的ICustomFormatter来完成。
下面,将我们的视角从类的作者转到类的使用者上来。假设我们期望的某种格式没有获得支持,例如某些customer的name字符数要大于20,这时候我们希望提供字符数为50的name。这就是 IFormatProvider接口的用武之地了。我们需要创建两个类:一个实现IFormatProvider接口,另一个实现 ICustomFormatter接口——该类用于创建自定义的输出格式。IFormatProvider接口中定义有一个方法:GetFormat (),该方法会返回一个实现了ICustomFormatter接口的对象。ICustomFormatter接口中包含了实际执行格式化的方法。下面的 代码实现了提供字符数为50的name输出:
// IFormatProvider示例:
public class CustomerFormatProvider : IFormatProvider
{
#region IFormatProvider Members
// IFormatProvider 仅包含一个方法。
// 该方法返回一个使用指定接口格式的对象。
// 一般情况下,只有ICustomFormatter被实现。
public object GetFormat( Type formatType )
{
if ( formatType == typeof( ICustomFormatter ))
return new CustomFormatter ( );
return null;
}
#endregion
}
// 一个嵌套类,为Customer类提供定制格式。
public class CustomFormatter: ICustomFormatter
{
#region ICustomFormatter Members
public string Format( string format, object arg, IFormatProvider formatProvider )
{
Customer c = arg as Customer;
if ( c == null )
return arg.ToString( );
return string.Format( "{0,50}, {1,15}, {2,10:C}", c.Name, c.ContactPhone, c.Revenue );
}
#endregion
}
上面的GetFormat()方法创建了一个实现了ICustomFormatter接口的对象。ICustomFormatter.Format()方法则按指定的方式执行实际的格式化输出工作,将对象转换为一个字符串格式。我们可以为ICustomFormatter.Format()方法定义format参数,以便指定多种格式化选项。参数 formatProvider则是用于调用GetFormat()方法的一个IFormatProvider对象。
要指定我们自己定制的格式,需要显式调用string.Format()方法,并传递一个IFormatProvider对象:
Class Customer : CustomFormatter
{
…
}
Customer c1 = new Customer();
Console.WriteLine(c1.Format("",c1,new CustomFormatter());
不管一个类是否实现了IFormattable接 口,我们都可以为其创建IformatProvider和ICustomFormatter的实现类。因此即使一个类的原作者没有提供合理的 ToString()行为,我们仍然可以为其提供格式化支持。当然,作为一个类的外部访问者,我们只能通过访问其中的公有属性和数据成员来构造字符串。虽然编写两个类(分别实现IFormatProvider和ICustomFormatter)需要很多工作,且其目的仅仅是为了得到一个字符串。但是,一 旦使用了这种方式来实现我们自己定义的字符串输出,它们将在.NET框架的各个地方得到支持。
现在,再让我们回到类作者这一角色上来。重写 Object.ToString()是为类提供字符串表示的最简单方式。每当我们创建一个类型时,都要提供该方法。它应该是我们的类型最明显、最常用的一 种表示。只有在一些比较少的情况下,当我们期望为类型提供更复杂的输出格式时,才应该实现IFormattable接口。它为“类型的用户定制类型的字符 串输出”提供了一种标准的方式。如果我们没有做这些工作,用户就要自己来实现自定义格式化器。那样的做法需要更多的代码,因为用户处于类外,无法访问对象 的内部状态。
人们总有获取类型信息的需求,而字符串对于人来说是最容易理解的。我们应该积极地去做这件事,而重写所有类型中的ToString()方法可能是所有做法中用最简单的。
条款6:明辨值类型和引用类型的使用场合
值类型还是引用类型?结构还是类?如何正确地使用它们?这里不是C++,在那里,所有的类型都被我们定义为值类型,然后我们可以选择创建它们的引用形式。这也不是Java,在那里,所有的类型都是引用类型[9]。 在C#中,我们必须在设计类型的时候就决定类型实例的行为。这种决定非常重要。我们必须清楚这种决定的后果,因为后期的更改会导致许多代码在不经意间出现错误。在创建类型的时候选择struct或class关键字可能很简单,但如果之后要更改,所有使用我们类型的客户程序都要随之做很多更改。
说class优于struct或者struct优于class可能把问题简单化了。正确的选择依赖于我们期望将来的客户程序如何使用我们的类型。值类型不支持多态,比较适合存储供应用程序操作的数据。引用类型支持多态,应该用于定义应用程序的行为。在设计类型时,我们应该考虑类型的责任,根据期望的责任,我们才能判断创建何种类型。简而言之,结构用于存储数据,类用于定义行为。
由于C++和Java中一些常见的问题,.NET 和C#才引入了值类型和引用类型的区别。在C++中,所有的参数和返回值都使用传值的方式来传递。传值的方式效率很高,但会带来一个问题:不完整复制(又叫对象切割)。如果在本该需要基类对象的地方,传递了一个派生类的对象,那么派生类的对象将只有属于基类的那一部分被复制。因此我们将失去关于这个派生类的信息。所调用的虚函数也将为基类的版本。
Java对这个问题的解决方式是从某种程度上摈弃值类型。所有Java中用户自定义的类型都是引用类型。所有的参数和返回值都以传引用的方式传递[10]。这种策略获得的好处是一致性,但在性能上却有一定的损失。而实际上有些类型没有必要支持多态。Java程序员因此要为每一个变量付出堆内存分配和垃圾收集的代价[11]。 另外,对每个变量进行“解引用(dereference)”也要花费一些额外的时间。归根结底还是因为所有的变量都是引用类型。在C#中,我们使用 struct或者class关键字来声明一个类型为值类型还是引用类型。值类型主要用于较小的轻量级类型,而引用类型则主要用于构建整个类层次 (class hierarchy)。本节我们将展示一个类型的不同使用方法,从而帮助大家理解值类型和引用类型之间的区别。
我们先从下面的代码开始,这里的类型用做一个方法的返回值:
private MyData _myData;
public MyData Foo()
{
return _myData;
}
// 调用Foo()方法:
MyData v = Foo();
TotalSum += v.Value;
如果MyData是一个值类型,返回值将被复制到v的存储空间上,其中v处于栈上。但是,如果MyData是一个引用类型,我们实际上就将一个内部变量的引用暴露给了外界。这就打破了类型封装的原则(参见条款23)。
如果将上面的代码做如下改动:
private MyData _myData;
public MyData Foo()
{
return _myData.Clone( ) as MyData;
}
// 调用Foo()方法:
MyData v = Foo();
TotalSum += v.Value;
现在,v是_myData的一个副本。作为引用类型,_myData和v都位于堆上。这虽然避免了将类型的内部数据暴露给外界,但却在堆上创建了额外的对象。如果v是一个局部变量,它很快将变成垃圾,而且Clone方法还强制要求我们做运行时类型检查。总的来说,这样的做法是不够高效的。
因此,通过公有方法和属性暴露给外界的数据类型应该为值类型。当然,这也并不是说每一个公有成员返回的类型都应该是值类型。上面的代码实际上对于MyData有一种假设,那就是它的责任是用来存储数据的。
但是,考虑下面的代码:
private MyType _myType;
public IMyInterface Foo()
{
return _myType as IMyInterface;
}
// 调用Foo()方法:
IMyInterface iMe = Foo();
iMe.DoWork( );
虽然在上面的代码中,_myType仍然从Foo 方法返回。但这次不是访问返回值内部的数据,而是通过一个定义好的接口来调用一个方法。这次访问MyType对象的目的不是其中的数据内容,而是它的行为 ——上面的代码使用了IMyInterface来表达行为,其实现则可以使用多种不同的类型。本例中,我们使用的是引用类型,而非值类型。MyType的责任在于它的行为,而非它的数据成员。
这段简单的代码向大家展示了值类型和引用类型之间的区别:值类型用于存储数据,引用类型用于定义行为。让我们再进一步看看这些类型如何在内存中存储,以及与存储模型相关的性能考虑。考虑下面的类:
public class C
{
private MyType _a = new MyType( );
private MyType _b = new MyType( );
// 略去其余的实现。
}
C var = new C();
上面的代码总共创建了多少对象?分别为多大?这要看情况而定。如果MyType是一个值类型,则只需要一次内存分配,大小为MyType类型大小的两倍[12]。 但如果MyType是一个引用类型,将有3次内存分配:一次用于C对象,大小为8个字节(假设指针为32位);两次用于C对象中所包含的MyType对象的分配。结果不同的原因在于值类型在对象中采用内联(inline)的方式存储,而引用类型则不是。每个引用类型的变量中存储的只是一个引用,在存储空间上也需要额外的分配。
为了帮助大家理解这一点,考虑下面的代码:
MyType [] var = new MyType[ 100 ];
如果MyType是一个值类型,则只需要一次分配,大小为MyType对象大小的100倍。但如果MyType是一个引用类型,刚开始需要一次分配,分配后数组的各元素值为null。如果再初始化数组中的每个元素,我们总共将需要执行101次分配——101次分配要比1次分配耗费更多的时间。分配许多引用类型对象将在堆空间上造成很多碎片,从而降低系统的速度。如果我们创建的类型主要用于存储数据,值类型无疑是最佳的选择。
将类型设计为值类型还是引用类型是一个非常重要的决定。如果刚开始没有确定好,之后再将值类型改变为引用类型将带来很多层面的影响。考虑下面的类型:
public struct Employee
{
private string _name;
private int _ID;
private decimal _salary;
// 省略了各个属性。
public void Pay( BankAccount b )
{
b.Balance += _salary;
}
}
上面的例子相当简单,只包含了一个方法用于向Employee支付薪水。系统刚开始运行得很好,但随着时间的进展,我们可能需要不同种类的Employee:销售人员可以获取代理佣金,经理则可以获取红利奖金。因此,我们需要将Employee更改为一个类:
public class Employee
{
private string _name;
private int _ID;
private decimal _salary;
// 省略了各个属性。
public virtual void Pay( BankAccount b )
{
b.Balance += _salary;
}
}
这将会破坏许多目前正在使用原来Employee结构的代码。原来的“按值返回”将变为“按引用返回”。原来的“传值参数”将变为“传引用参数”。比如,下面一小段代码的行为就将发生很大的变化:
Employee e1 = Employees.Find( "CEO" ); //关键,如果是值类型就复制一个副本,如果是引用类型则取对象
e1.Salary += Bonus; // 添加一次奖金。
e1.Pay( CEOBankAccount );
如果Employee为一个结构,这段代码的含义将为“一次性的奖金发放”。但现在将Employee更改为一个类,这段代码表达的将是“对薪水的永久性提升”。本来应用“按值复制”的地方,现在被替换成了“按引用复制”。编译器会很愉快地帮助我们做这样的改变,CEO可能也很愉快。不过,CFO恐怕就要报告bug了。如果将值类型改变为引用类型会导致程序行为的改变,我们便不能这么做。
出现上述问题的原因在于Employee类型不再遵从值类型的设计原则了。除了存储Employee的数据外,我们还给它添加了责任——在本例中,就是向Employee支付薪水。责任是类的范畴。类可以很容易定义普通责任的多态实现。结构不能这么做,它们应该仅限于存储数据。
.NET 文档推荐我们将类型的大小作为选择值类型还是引用类型的决定性因素。在现实中,一个更值得我们考虑的因素应该为类型的使用场合。如果具有比较简单的结构, 或者是作为数据的载体,那就比较适合设计为值类型。值类型在内存管理方面也具有更好的效率:较少的堆内存碎片,较少的内存垃圾,以及较少的间接访问。更重要的是,值类型从方法或者属性中返回时使用的将是复制的方式——这避免了将内部结构的引用暴露给外界的危险。但值类型为此付出的是某些特性的缺失。值类型对常用的面向对象技术有着非常有限的支持。我们不能创建值类型的继承层次。我们应该将所有的值类型当作密封(sealed)类型。我们可以让值类型实现某些接口,但那需要装箱操作,条款17展示了其所导致的性能问题。我们应该将值类型当作数据的存储容器,而非OO(面向对象)环境中的对象。
一般而言,我们创建的引用类型总是要比值类型多。如果以下问题的答案都为Yes,那我们就应该创建值类型。大家可以将这些问题应用在前面的Employee例子上:
1. 该类型的主要职责是否用于数据存储?
2. 该类型的公有接口是否完全由一些数据成员存取属性定义?
3. 是否确信该类型永远不可能有子类?
4. 是否确信该类型永远不可能具有多态行为?
综上所述,我们应该将用于底层数据存储的类型设计为值类型,将用于定义应用程序行为的类型设计为引用类型。这样一来,我们既获得了从类对象内复制数据的安全性,也获得了基于栈的和内联方式的存储模型所带来的内存优势,同时还能利用标准的面向对象技术来创建应用程序的逻辑。如果对类型将来的应用情况不确定,那就应该使用引用类型。
条款7:将值类型尽可能实现为具有常量性和原子性的类型
具有常量性的类型很简单,它们自创建后便保持不变。如果在构造的时候就验证了参数的有效性,我们就可以确保从此之后它都处于有效的状态。因为我们不可能再更改其内部状态。通过禁止在构建对象之后更改对象状态,我们实际上可以省却许多必要的错误检查。具有常量性的类型同时也是线程安全的:多个reader可以访问同样的内容。因为如果内部状态不可能改变,那么不同线程也就没有机会获得同一数据的不同值。具有常量性的类型也可以安全地暴露给外界,因为调用者不可能改变对象的内部状态。具有常量性的类型在基于散列(hash)的集合中也表现得很好,因为由Object.GetHashCode()方法返回的值必须是一个不变量(参见条款10),而具有常量性的类型显然可以保证这一点。
然而,并非所有类型都可以为常量类型。如果那样的话,我们将需要克隆对象来改变程序的状态。这也就是为什么本条款同时针对具有常量性和原子性的值类型。我们应该将我们的类型分解为各种可以自然形成单个实体的结构。比如,Address类型就是这样的例子。一个Address对象是由多个相关字段组成的单一实体。其中一个字段的更改可能意味着需要更改其他字段。而Customer类型就不具有原子性。一个Customer类型可能包含许多信息:地址(address)、名称(name)以及一个或者多个电话号码(phone number)。这些独立信息中的任何一个都可能更改。一个Customer对象可能要更改它的电话号码,但并不需要更改地址;也可能更改它的地址,而仍然保留同样的电话号码;也可能更改它的名称,但保留同样的电话号码和地址。因此,Customer对象并不具有原子性。但它由各个不同的原子类型组成:一个地址、一个名称或者一组电话号码/类型对[13]。具有原子性的类型都是单一的实体:我们通常会直接替换一个原子类型的整个内容。但有时候也有例外,比如更改构成它的几个字段。
下面是一个典型的可变类型Address的实现:
// 可变结构Address。
public struct Address
{
private string _line1;
private string _line2;
private string _city;
private string _state;
private int _zipCode;
// 依赖系统产生的默认构造器。
public string Line1
{
get { return _line1; }
set { _line1 = value; }
}
public string Line2
{
get { return _line2; }
set { _line2 = value; }
}
public string City
{
get { return _city; }
set { _city= value; }
}
public string State
{
get { return _state; }
set
{
ValidateState(value);
_state = value;
}
}
public int ZipCode
{
get { return _zipCode; }
set
{
ValidateZip( value );
_zipCode = value;
}
}
// 忽略其他细节。
}
// 应用示例:
Address a1 = new Address( );
a1.Line1 = "111 S. Main";
a1.City = "Anytown";
a1.State = "IL";
a1.ZipCode = 61111 ;
// 更改:
a1.City = "Ann Arbor"; // ZipCode、State 现在无效。
a1.ZipCode = 48103; // State 现在仍然无效。
a1.State = "MI"; // 现在整个对象正常。
内部状态的改变意味着有可能违反对象的不变式 (invariant)——至少是临时性地违反。在我们将City字段更改之后,a1就处于无效的状态了。City更改后便不再与State或者 ZipCode匹配。上面的代码看起来好像没什么问题,但是假设这段代码是一个多线程程序的一部分,那么任何在City更改过程中的上下文切换都可能导致 另一个线程看到不一致的数据视图。
即使我们并不是在编写多线程应用程序,上面的代码仍然存在问题。假设ZipCode的值无效,因此抛出了一个异常。这时候我们实际上仅做了一部分改变,对象将处于一个无效的状态。为了修复这个问题,我们需要在Address结构中添加相当多的内部校验代码。这无疑将增加代码的体积和复杂性。为了完全实现异常安全,我们还需要在所有改变多个字段的代码块处放上防御性的代码。线程安全也要求我们在每一个属性访问器(get和set)上添加线程同步检查。总而言之,这将是一个相当可观的工作——而且我们还要考虑随着时间的推移,功能的增加,以及代码可能的扩展。
相反,让我们将Address结构实现为常量类型。首先,要将所有的实例字段都更改为只读字段:
public struct Address
{
private readonly string _line1;
private readonly string _line2;
private readonly string _city;
private readonly string _state;
private readonly int _zipCode;
// 忽略其他细节。
}
同时要删除每个属性的所有set访问器:
public struct Address
{
// ...
public string Line1
{
get { return _line1; }
}
public string Line2
{
get { return _line2; }
}
public string City
{
get { return _city; }
}
public string State
{
get { return _state; }
}
public int ZipCode
{
get { return _zipCode; }
}
}
现在我们得到了一个常量类型。为了让其可用,我们还需要添加必要的构造器来彻底初始化Address结构。目前看来,Address结构只需要一个构造器来为其每一个字段赋值。复制构造器就不必要了, 因为C#默认的赋值操作符已经足够高效了。记住,默认的构造器仍然是有效的。使用默认构造器创建的Address对象中所有的字符串将为null,而 zipCode将为0:
public struct Address
{
private readonly string _line1;
private readonly string _line2;
private readonly string _city;
private readonly string _state;
private readonly int _zipCode;
public Address( string line1, string line2, string city, string state, int zipCode)
{
_line1 = line1;
_line2 = line2;
_city = city;
_state = state;
_zipCode = zipCode;
ValidateState( state );
ValidateZip( zipCode );
}
// 忽略其他细节。
}
要改变常量类型,我们需要创建一个新对象,而非在现有的实例上做修改:
// 创建一个Address:
Address a1 = new Address( "111 S. Main", "", "Anytown", "IL", 61111 );
// 使用重新初始化的方式来改变对象:
a1 = new Address( a1.Line1, a1.Line2, "Ann Arbor", "MI", 48103 );
现在a1只可能处于以下两个状态中的一个:原来位于Anytown的位置,或者位于Ann Arbor的新位置。我们将不可能再像前面的例子中那样把一个现有的Address对象更改为任何无效的临时状态。那些无效的中间态只可能存在于 Address构造器的执行过程中,不可能出现在构造器之外。只要一个Address对象被构造好后,它的值将保持恒定不变。新版的Address也是异常安全的:a1或者为原来的值,或者为新构造的值。如果有异常在新的Address对象的构造过程中被抛出,那么a1将保持原来的值。
对于常量类型,我们还要确保没有任何漏洞会导致其内部状态被更改。由于值类型不支持派生类型,因此我们不必担心派生类型会更改其字段。但我们需要注意常量类型中的可变引用类型字段。当我们为这样的类型实现构造器时,需要对其中的可变类型进行防御性的复制。下面的例子假设Phone为一个具有常量性的值类型,因为我们只关心值类型的常量性:
// 下面的类型为状态的改变留下了漏洞。
public struct PhoneList
{
private readonly Phone[] _phones;
public PhoneList( Phone[] ph )
{
_phones = ph;
}
public IEnumerator Phones
{
get
{
return _phones.GetEnumerator();
}
}
}
Phone[] phones = new Phone[10];
// 初始化phones
PhoneList pl = new PhoneList( phones );
// 改变phones数组:
// 同时也改变了常量类型的内部状态。
phones[5] = Phone.GeneratePhoneNumber( );
我们知道,数组是一个引用类型。这意味着 PhoneList结构内部引用的数组和外部的phones数组引用着同一块内存空间。这样开发人员就有可能通过修改phones来修改常量结构 PhoneList。为了避免这种可能性,我们需要对数组做一个防御性的复制。上面的例子展示的是一个可变集合类型可能存在的漏洞。如果Phone为一个可变的引用类型,那么将更具危害性。在这种情况下,即使集合类型可以避免更改,集合中的值仍然可能会被更改。这时候,我们就需要对这样的类型在所有构造器中做防御性的复制了——事实上只要常量类型中存在任何可变的引用类型,我们都要这么做:
// 常量类型: 构造时对可变的引用类型进行复制。
public struct PhoneList
{
private readonly Phone[] _phones;
public PhoneList( Phone[] ph )
{
_phones = new Phone[ ph.Length ];
// 因为Phone是一个值类型,所以可以直接复制值。
ph.CopyTo( _phones, 0 );
}
public IEnumerator Phones
{
get
{
return _phones.GetEnumerator();
}
}
}
Phone[] phones = new Phone[10];
// 初始化phones
PhoneList pl = new PhoneList( phones );
// 改变phones数组:
// 不会改变pl中的副本。
phones[5] = Phone.GeneratePhoneNumber( );
当要返回一个可变的引用类型时,我们也要遵循同样的规则。例如,如果我们要添加一个属性来从PhoneList结构中获取整个数组,那么其中的访问器也要创建一个防御性的复制。更多细节可参见条款23。
初始化常量类型通常有三种策略,选择哪一种策略依赖于一个类型的复杂度。定义一组合适的构造器通常是最简单的策略。例如,上述的Address结构就是通过定义一个构造器来负责初始化工作。
我们也可以创建一个工厂方法(factory method)来进行初始化工作。这种方式对于创建一些常用的值比较方便。.NET框架中的Color类型就采用了这种策略来初始化系统颜色。例如,静态方法Color.FromKnownColor()和Color.FromName()可以根据一个指定的系统颜色名,来返回一个对应的颜色值。
最后,对于需要多个步骤操作才能完整构造出一个常量类型的情况,我们可以通过创建一个可变的辅助类来解决。.NET中的String类就采用了这种策略,其辅助类为 System.Text.StringBuilder。我们可以使用StringBuilder类通过多步操作来创建一个String对象。在执行完所有必要的操作后,我们便可以通过StringBuilder类来获取期望的String对象。
具有常量性的类型使得我们的代码更加易于编写和维护。我们不应该盲目地为类型中的每一个属性都创建get和set访问器。对于目的是存储数据的类型来说,我们应该尽可能地将它们实现为具有常量性和原子性的值类型。在这些类型的基础上,我们可以很容易地构建更复杂的结构。
条款8:确保0为值类型的有效状态
.NET系统的默认初始化机制会将所有的对象设置为0[14]。对于值类型来讲,我们无法阻止其他程序员将其所有的成员都初始化为0[15]。因此,我们应该将0作为值类型的默认值。
枚举类型就是一种典型的情况。我们创建的枚举类型决不应该将0视为无效状态。我们知道,所有的枚举类型都继承自System.ValueType。默认的枚举值从0开始,但是我们可以更改这种默认行为。
public enum Planet
{
// 显式赋值。
// 否则将默认从0开始。
Mercury = 1,
Venus = 2,
Earth = 3,
Mars = 4,
Jupiter = 5,
Saturn = 6,
Neptune = 7,
Uranus = 8,
Pluto = 9
}
Planet sphere = new Planet();
这里的sphere将为0,显然是一个无效的状态。 这样,那些要求“枚举值必须位于预定义集合中”的代码(通常都是这样的情况)就不能正常工作了。因此,当我们创建自己的枚举值时,要确保0为有效的状态。 如果我们使用位模式来定义枚举值,那么应该将0定义为“不包括所有其他属性的情况”。
根据目前的情况来看,我们应该强制用户显式初始化枚举值:
Planet sphere = Planet.Mars;
但这将使得这样的枚举类型很难作为值类型的成员:
public struct ObservationData
{
Planet _whichPlanet; // 看的是什么呢?
Double _magnitude; // 感觉亮度。
}
创建ObservationData对象将得到一个无效的Planet字段:
ObservationData d = new ObservationData();
新创建的ObservationData对象的_magnitude将为0,这是合理的。但_whichPlanet却是无效的。我们需要让0成为有效的状态。如果可能的话,我们最好将0作为默认的值。Planet枚举类型没有一个明显的默认值。当用户没有给出明确的选择时,我们随便设定一个Planet值是没有意义的。如果碰到这种情况,我们可以将0作为一个未初始化值明确表示出来,这样可方便后续再对其更新:
public enum Planet
{
None = 0,
Mercury = 1,
Venus = 2,
Earth = 3,
Mars = 4,
Jupiter = 5,
Saturn = 6,
Neptune = 7,
Uranus = 8,
Pluto = 9
}
Planet sphere = new Planet();
现在sphere将包含一个None值。将这个未初始化的默认值添加到Planet枚举中,会给ObservationData结构带来一些影响。新创建的ObservationData对象将包含一个值为0的_magnitude和一个值为None的_whichPlanet。这时候,我们应该添加一个显式的构造器,来支持用户显式初始化类型所有的字段:
public struct ObservationData
{
Planet _whichPlanet; // 看的是什么呢?
Double _magnitude; // 感觉亮度。
ObservationData( Planet target, Double mag )
{
_whichPlanet = target;
_magnitude = mag;
}
}
但是,要记住ObservationData仍然有一个默认构造器。用户仍可以使用默认的构造器来创建“让系统初始化”的变量,我们无法禁止用户这么做。
在讨论其他值类型之前,我们需要再谈一下枚举类型作为位标记(flag)来应用时的一些特殊规则。使用Flags特性的枚举类型应该总是将None值设为0:
[Flags]
public enum Styles
{
None = 0,
Flat = 1,
Sunken = 2,
Raised = 4,
}
许多开发人员都在位标记枚举值上使用“按位AND”(bitwise AND)操作符。如果遇到0值,就会出现严重的问题。如果Flat值为0,那么下面的测试将永远为false:
if ( ( flag & Styles.Flat ) != 0 ) // 如果Flat == 0,将永远为false。
DoFlatThings( );
如果使用Flags,我们要确保0为有效状态,且其意义为“不包括所有其他标记的情况”。
如果值类型中包含有引用类型,会出现另一种常见的初始化问题。包含字符串就是一种常见的情况:
public struct LogMessage
{
private int _ErrLevel;
private string _msg;
}
LogMessage MyMessage = new LogMessage( );
MyMessage 对象的_msg字段将为一个空引用。我们没有办法强制做其他的初始化,但是我们可以使用属性来将该问题限定在类型内部。我们可以创建一个属性来将_msg 值暴露给类型的所有客户,并在属性内部添加逻辑,使其返回一个“内容为空的字符串”,而非一个空引用:
public struct LogMessage
{
private int _ErrLevel;
private string _msg;
public string Message
{
get
{
return (_msg != null ) ? _msg : string.Empty;
}
set
{
_msg = value;
}
}
}
我们应该在类型内部使用这样的属性。这样做可以将空引用检查集中在一个地方。当从我们的程序集中被调用时,Message的访问器方法几乎肯定会被内联。我们在获得高效代码的同时,也将错误降到了最低。
综上所述,系统会将值类型的所有实例初始化为0。我们没有办法阻止用户创建“字段全部为0”的值类型实例。如果可能的话,我们应该将“字段全部为0”作为类型的默认值。作为一种特殊情况,被用做位标记的枚举类型,应该确保0的意义为“不包括所有其他标记的情况”。
条款9:理解几个相等判断之间的关系
当我们创建自己的类型时(无论是类还是结构),可以为类型定义“相等判断”的含义。C#提供了四种不同的函数来判断两个对象是否“相等”:
public static bool ReferenceEquals( object left, object right );
public static bool Equals( object left, object right );
public virtual bool Equals( object right);
public static bool operator==( MyClass left, MyClass right );
但是“我们可以这么做”并不意味着“我们应该这么做”。对于前两个静态函数,我们永远都不应该去重新定义。我们通常需要创建自己的Equals()实例方法,来为类型定义“相等语义”。偶尔需要重写operator==[16],主要是考虑值类型的性能。另外,这四个函数之间也存在一定的关系。当我们改变其中一个时,有可能影响其他几个的行为。是的,用四个函数来做“相等判断”是过于复杂了。但是不要担心,我们可以简化这个问题。
就像C#中许多复杂的元素一样,这里也考虑到了同样的事实——C#允许我们创建两种类型:值类型和引用类型。如果两个引用类型的变量指向同一个对象,它们将被认为是“引用相等”。如果两个值类型的变量类型相同,而且包含同样的内容,它们被认为是“值相等”。这便是“相等判断”需要那么多方法的原因。
让我们首先从两个“永远都不必重新定义”的静态函数开始。如果两个变量指向同一个对象——也就是它们拥有同样的对象标识(object identity),那么Object.ReferenceEquals()方法会返回true。不管比较的是引用类型还是值类型,该方法都判断的是“引用相等”,而非“值相等”。这就意味着如果我们使用ReferenceEquals()来比较两个值类型,其结果永远返回false。即使我们将一个值类型和自身进行比较,ReferenceEquals()的返回值仍是false。导致这种结果的原因在于装箱(有关装箱的详细讨论,参见条款16)。
int i = 5;
int j = 5;
if ( Object.ReferenceEquals( i, j ))
Console.WriteLine( "Never happens." );
else
Console.WriteLine( "Always happens." );
if ( Object.ReferenceEquals( i, i ))
Console.WriteLine( "Never happens." );
else
Console.WriteLine( "Always happens." );
我们永远都不应该去重新定义Object.ReferenceEquals()方法,因为它已经把它应该做的工作——判断两个不同变量的对象标识(object identity)是否相等——做得很好了。
我们永远都不应该去重新定义的第二个静态函数是 Object.Equals()。当我们不知道两个变量的运行时类型(runtime type)时,可以使用该方法来判断两个变量是否相等。注意,System.Object是C#中所有类型的最终基类。因此,任何时候我们比较的两个变量都是System.Object的实例。值类型变量和引用类型变量都是如此。那么该方法是如何判断两个变量是否相等的呢?因为该方法并不知道它们的类型, 而“相等判断”又是依赖类型的。答案很简单:该方法会将判断的责任交给其中一个类型来做。事实上,静态Object.Equals()方法的实现如下:
public static bool Equals( object left, object right )
{
// 检查是否引用相等。
if (left == right ) //System.object类型的==实现的是引用相等
return true;
// 两者同时为null引用的情况在上面已经处理。
if ((left == null) || (right == null))
return false;
return left.Equals (right);
}
上面代码中引入的两个方法我们还没有讨论: operator==()和Equals()实例方法。下面我们会详细讨论这两个方法,但是静态Equals()方法的讨论还没有结束。目前来讲,我希望大家能够理解在静态Equals()方法的内部,实际上是通过调用left参数的实例Equals()方法来实现的。
和ReferenceEquals ()方法一样,我们永远都不要去重新定义静态的Object.Equals()方法,因为它也已经将它应该做的工作——当不知道两个对象的运行时类型时, 判断它们是否相等——做得很好了。由于静态的Equals()方法会将判断的工作交给left参数的实例Equals()方法来做,因此它会使用left 参数的类型所定义的规则来进行相等判断。
现在,大家已经理解了为什么我们永远都不需要重新定义静态ReferenceEquals()和Equals()方法。下面我们来讨论那些可以重写的方法。 但是在这之前,让我们先来简要谈谈相等关系的数学属性。对于“相等判断”,我们需要确保我们的定义和实现与其他程序员的期望一致。这意味着我们需要牢记相等的数学属性:自反(reflexive)、对称(symmetric)和可传递(transitive)。自反属性意味着任何对象都和其自身相等。不管是什么类型,a==a都应该返回true。对称属性意味着相等判断时的顺序是无关紧要的:也就是说如果a==b返回true,那么b==a也返回 true。如果a==b返回false,那么b==a也返回false。最后一个属性可传递性含义如下:如果a==b并且b==c都返回true,那么a ==c也应该返回true。
下面我们来看Object.Equals()实例函数,谈谈我们应该何时以及如何重写它。当Equals()方法的默认行为与我们的类型要求不一致时,我们就要重写它。Object.Equals()实例方法默认判断的是“引用相等”,其行为和Object.ReferenceEquals()完全一致。但是值类型例外。
System.ValueType重写了Object.Equals()方法。记住,ValueType是所有值类型(我们使用struct关键 字创建的类型)的基类型。如果两个值类型变量的类型相同,并且内容一致,这两个变量才被认为相等。ValueType为Equals()方法实现了这种行为。但是,ValueType为Equals()方法提供的重写实现效率并不高。由于ValueType.Equals()[17]是所有值类型的基类,为了提供正确的行为,它必须能够在不知道对象运行时类型的情况下,比较其派生类型中的所有成员变量。在C#中,这意味着要使用反射。如本书条款44所述,反射有许多缺点,特别是当性能是我们的目标时更是如此。“相等判断”是一个在程序中被频繁调用的基础性构造,因此性能是一个值得我们考虑的目标。几乎在所有的情况下,我们都应该为自己的值类型提供一个更快的Equals()重写版本。我们对值类型Equals()实例方法的推荐也相当简单:无论何时创建一个值类型,我们都要重写ValueType.Equals()方法。
对于引用类型,只有当我们希望更改其预定义的语义时,才应该重写Equals()实例方法。.NET框架类库中的许多类都使用“值语义”而非“引用语义”来做相等判断。如果两个string对象包含相同的内容,它们将被认为相等。两个DataRowView对象则在它们都引用同样的DataRow时,才被认为相等。如果我们的类型遵循“值语义”(比较内容),而非“引用语义”(比较对象标识),我们就应该重写Object.Equals()实例方法。
前面我们已经知道了应该在何时重写 Object.Equals()实例方法,下面我们就必须理解如何来实现它。值类型的相等关系中有许多隐含的装箱操作,本书条款17对此有讨论。对于引用类型,我们的实例方法需要遵循预定义行为,避免向用户返回奇怪的结果。下面是一种标准的实现模式:
public class Foo
{
public override bool Equals( object right )
{
// 检查是否为null:
// 在C#方法中,this指针永远都不可能为null。
if (right == null) //空判断
return false;
if (object.ReferenceEquals( this, right )) //引用判断
return true;
// 下面将对此进行讨论。
if (this.GetType() != right.GetType()) //类型判断
return false;
// 比较两个实例的内容:
return CompareFooMembers(this, right as Foo ); //内容判断
}
}
首先,Equals()绝对不应该抛出异常——那没有什么意义。两个变量要么相等,要么不相等,不存在其他失败的情况。对于所有失败的条件,我们都应该返回false,例如空引用,或者错误的参数类型。 现在,让我们仔细浏览一下上面的方法,以理解为什么某些检查是必要的,而某些检测则是可以省去的。
第一个检查判断右边的对象是否为null。对于this 引用,则不需要检查。在C#中,this指针永远都不可能为null。如果是通过null引用来调用任何实例方法,那么CLR在调用进入方法之前就会抛出一个异常。
第二个检查会判断两个对象引用是否为同一个对象,即比较对象标识。这是一个非常高效的测试,如果对象引用相等,则对象内容一定相等。
第三个检查判断的是两个对象的类型是否一致。这种使用GetType()方法进行的精确比较是非常重要的。首先,注意它没有假设this指针的类型为Foo,相反是通过调用this.GetType() 来获取其类型的,因为实际的类型可能继承自Foo。其次,代码检查的是所比较对象的精确类型。仅仅确保将right参数转换为当前类型是不够的。那样的话会导致两个非常诡异的bug。下面的例子演示了这种问题:
public class B
{
public override bool Equals( object right )
{
// 检查是否为null:
if (right == null)
return false;
// 检查是否引用相等:
if (object.ReferenceEquals( this, right ))
return true;
// 这里存在问题,下面将会讨论。
B rightAsB = right as B;
if (rightAsB == null)
return false;
return CompareBMembers( this, rightAsB );
}
}
public class D : B
{
// 忽略其他细节。
public override bool Equals( object right )
{
// 检查是否为null:
if (right == null)
return false;
if (object.ReferenceEquals( this, right ))
return true;
// 这里存在问题。
D rightAsD = right as D;
if (rightAsD == null)
return false;
if (base.Equals( rightAsD ) == false)
return false;
return CompareDMembers( this, rightAsD );
}
}
//测试:
B baseObject = new B();
D derivedObject = new D();
// 比较1。
if (baseObject.Equals(derivedObject))
Console.WriteLine( "Equals" );
else
Console.WriteLine( "Not Equal" );
// 比较2。
if (derivedObject.Equals(baseObject))
Console.WriteLine( "Equals" );
else
Console.WriteLine( "Not Equal" );
不管怎么样,上面的代码应该要么打印两次 Equals,要么打印两次Not Equal。但是由于某些错误,上面的代码输出并非如此。其中第二个比较永远返回false,因为基类B的对象不可能被转换为D。但是第一个比较却可能返回true。因为派生类D的对象可以被隐式地转换为类型B。如果右边参数中属于B类型的那一部分成员,正好与左边对象中B类型的成员相等,那么 B.Equals()将认为两个对象相等,即时这时候两个对象的类型不同。这实际上已经破坏了Equals的对称性。这种构造之所以能够破坏Equals 的对称性,是由于在继承层次中发生了自动转型。
在下面的代码中,D对象会被显式转型为一个B对象:
baseObject.Equals( derived )
如果baseObject.Equals()判定它们的字段匹配,那么它会认为两个对象相等。另一方面,在下面的代码中,B对象却不能转化成一个D对象:
derivedObject.Equals( base )
由于B对象不能被转化为D对象,因此derivedObject.Equals()方法将总是返回false。如果我们不检查对象的精确类型,便会很容易陷入这种情况:即比较的顺序会影响比较的结果。
当我们重写Equals()方法时,还有一种实践需要遵循:即如果基类的Equals()方法不是由System.Object或 System.ValueType提供的话,我们也应该调用基类的Equals()方法。前面的代码就是一个例子。D类中的Equals()方法调用了基类B中定义的Equals()方法,但B类中的Equals()方法却没有调用baseObject.Equals()方法[18],因为那会导致调用System.Object中定义的Equals()方法,而该方法只有在两个参数指向同一个对象时,才会返回true。但这并不是我们想要的结果,否则我们自己就不用写了。
综上所述,Equals()实例方法的重写规则如下:对于所有的值类型,我们都应该重写其Equals()方法;对于引用类型,当System.Object提供的“引用语义”不能满足我们的需要时,我们才应该去重写Equals()方法。在实现我们自己的Equals()方法时,应该遵循上面介绍的标准模式。 另外,重写Equals()方法的同时,也要重写GetHashCode()方法(相关细节,参见条款10)。
三种“相等判断”已经讨论完了,下面我们来讨论最后一种“相等判断”:operator==()。只要我们创建的是值类型,都需要重定义operator==()[19]。 其理由和重写ValueType.Equals()实例函数的理由完全一样。因为系统默认提供的版本是通过使用反射来比较两个值类型实例的内容,其效率要远低于我们自己编写的效率,所以我们需要自己来实现该操作符。另外,在比较值类型时,可以参考条款17中的推荐以避免装箱操作。
注意,并没有说在任何重写Equals()实例方法的时候,都应该提供operator==()。我的意思是当我们创建值类型时,才应该这么做。创建引用类型时,应该很少需要重写operator==()。.NET框架中的类期望所有引用类型上应用的operator==()都遵循“引用语义”。
C#为我们提供了4种“相等判断”的方式,但是我们只需要考虑为其中两种提供自己的定义。我们永远都不应该重写Object.ReferenceEquals()静态方法和Object.Equals()静态方法[20], 因为它们已经提供了正确的判断,且该判断与具体的运行时类型无关。对于值类型,我们应该总是重写Object.Equals()实例方法和 operator==(),从而为它们提供效率较好的“相等判断”。对于引用类型,当我们认为相等的含义并非是对象标识相同时,才需要重写 Object.Equals()实例方法。很简单,不是么?
条款10:理解GetHashCode()方法的缺陷
本条款讨论函数GetHashCode()的缺陷,这种情况在全书中是唯一的。幸运的是,GetHashCode()函数只应用在一个地方:为一个基于散列 (hash)的集合定义键的散列值,典型的集合为Hashtable或Dictionary容器。因为基类的GetHashCode()实现有很多问题。 对引用类型来讲,它可以正常工作,但是效率很低。对于值类型来讲,基类中的实现通常是不正确的。更为糟糕的是,我们编写的GetHashCode()不可能既有效率又正确。没有哪个函数能比GetHashCode()产生更多的讨论和混淆。下面的内容可以帮助大家理清这些混淆。
如果我们定义的类型在容器中不会被当作键来使用, 那就没什么问题。例如,表示窗口控件、Web页面控件或者数据库连接的类型就不太可能被当作键在集合中使用。在这些情况下,我们无需做任何事情。所有的引用类型都有一个正确的散列码,尽管效率非常低下。值类型应该具有常量性(参见条款7),这时候默认的实现也可以正常工作,虽然效率也不高。在我们创建的大多数类型中,最好的做法就是完全避免自己实现GetHashCode()。
如果有一天,我们创建的类型要被当作散列表 (hash table)中的键使用,我们就需要自己实现GetHashCode()。基于散列的容器使用散列码来优化查询。每一个对象都会产生一个称做散列码的整数值。基于散列码的值,对象会被装进一些“散列桶(bucket)”中。要搜索一个对象,我们会请求它的键,并在其对应的“散列桶”中搜索。在.NET中, 每一个对象都有一个散列码,其值由System.Object.GetHashCode()决定。任何GetHashCode()的重载[21]版本都必须遵循以下三条规则:
1. 如果两个对象相等(由operator==定义),它们必须产生相同的散列码。否则,这样的散列码不能用来查找容器中的对象[22]。
2. 对于任何一个对象A,A.GetHashCode()必须是一个实例不变式(invariant)。即不管在A上调用什么方法,A.GetHashCode()都必须总是返回相同的值。这可以确保放在“散列桶”中的对象总是位于正确的“散列桶”中。
3. 对于所有的输入,散列函数应该在所有整数中产生一个随机的分布。这样,我们才能从一个散列容器上获得效率的提升。
要编写一个正确且有效率的散列函数,我们需要对类型有充分的知识才能确保遵循第3条规则。System.Object和System.ValueType中定义的 GetHashCode()没有这方面的优势,因为它们必须在对具体类型几乎一无所知的情况下,提供最合适的默认行为。 Object.GetHashCode()使用System.Object中的一个内部字段来产生散列值。系统创建的每一个对象在创建时都会有一个唯一的对象键(一个整数值)。这些键从1开始,每创建一个新的对象(任何类型),便会随之增长。对象标识字段会在System.Object构造器中设置,并且之后不能被更改。对于一个给定的对象,Object.GetHashCode()会返回该值作为散列码。
现在让我们对照上述三条规则,逐一检查Object.GetHashCode()方法。在没有重写operator==的情况下,如果两个对象相等[23], 那么Object.GetHashCode()会返回同样的散列值。System.Object的operator==()判断的是对象标识。 GetHashCode()会返回内部的对象标识字段。这样做是可行的。但是,如果我们实现了自己的operator==,那就也必须实现 GetHashCode(),以确保遵循第一条规则。有关相等判断,参见条款9。
Object.GetHashCode()默认会满足第二条规则:在对象创建之后,其散列码永远不会改变。
第三条规则:对于所有的输入,散列函数应该在所有整数中产生一个随机的分布,Object.GetHashCode()不能被满足该规则。除非我们创建了数量非常多的对象,否则一个数值序列在所有整数中不会是一个随机的分布。Object.GetHashCode()返回的散列码会集中在整数范围的低端。
这意味着Object.GetHashCode ()的实现是正确的,但是效率不够好。如果我们基于自己实现的一个引用类型创建了一个散列表,System.Object的默认行为可以使我们的散列表正常工作,但是会比较慢。当我们创建的引用类型要用于散列键时,我们应该重写其GetHashCode()方法,使其产生的散列值在所有整数范围内有一个良好的分布。
在谈论如何实现我们自己的GetHashCode ()方法之前,我们先来对照上述三条规则,看看ValueType.GetHashCode()方法。System.ValueType重写了 GetHashCode()方法,为所有值类型提供了一种默认的实现。默认的实现会返回类型中定义的第一个字段的散列码。看下面的例子:
public struct MyStruct
{
private string _msg;
private int _id;
private DateTime _epoch;
}
MyStruct对象返回的散列码实际等同于_msg字段的散列码。下面的代码将总是返回true:
MyStruct s = new MyStruct();
return s.GetHashCode() == s._msg.GetHashCode();
第一条规则为:两个相等(由operator== ()定义)的对象也必须有相同的散列码。在绝大多数情况下,我们的值类型会满足该规则。但是我们也可能会破坏该规则,就像我们在引用类型中所做的那样。默认情况下,ValueType.operator==()会比较结构中的第一个字段和其他每个字段。这将可以满足第一条规则。如果我们在自己的结构中定义的operator==使用了第一个字段,那么也可以满足第一条规则。但是,如果结构的第一个字段没有参与类型的“相等判断”,那就违背了第一条规则,从而会破坏GetHashCode()方法[24]。
第二条规则: 要求散列码必须是一个实例不变式。只有当结构中的第一个字段是一个常量字段时,该规则才会被满足。如果结构的第一个字段发生变化,那么结构的散列码也会随之改变。这就打破了该规则。是的,只要结构中的第一个字段在对象的生存期中发生了改变,那么GetHashCode()方法将遭到破坏。这是“尽可能实现具有常量性的值类型”的又一个理由(参 见条款7)。
第三条规则依赖于第一个字段的类型,以及其使用情况。如果第一个字段会在所有的整数中产生一个随机的分布,同时第一个字段也会随机分布于结构的所有实例中,那么GetHashCode()将会为结构产生 一个比较均匀的分布。但是,如果第一个字段经常都是相同的值,那就将违反第三条规则。下面对前面的代码做了一个很小的更改:
public struct MyStruct
{
private DateTime _epoch;
private string _msg;
private int _id;
}
如果_epoch字段被设置为当前的日期(不包括时间),那么在一个特定日期中创建的MyStruct对象将拥有相同的散列码。这就会破坏散列码的均匀分布。
|
我们来总结一下Object.GetHashCode()方法的默认行为:对于所有的引用类型,该方法会正常工作,虽然它并不必然会产生一个高效的分布。 (如果我们重写了Object.operator ==(),将会破坏GetHashCode()方法。)只有在结构类型的第一个字段是只读的情况下,ValueType类型的GetHashCode() 才会正常工作。只有当第一个字段包含的值分布于“其输入的一个有意义的子集上”时,ValueType.GetHashCode()才会产生一个比较高效的散列码。
如果我们要创建一个更好的散列码,就需要为类型添加一些约束。我们再来对照上述三条规则,看看如何创建一个能够良好工作的GetHashCode()实现。
首先,如果两个对象相等(由operator== ()定义),它们必须返回相同的散列码。任何用来产生散列码的属性或者数据值,都必须参与到类型的相等判断中。很明显,这意味着会有相同的属性同时用于相等判断和散列码计算中。但并非所有参与相等判断的属性,都会用来做散列码计算。System.ValueType的默认行为就是这样。但是,这又通常意味着会违反第三条规则。同样的数据元素应该同时参与到两种计算中。
第二条规则是GetHashCode()的返回值必须是一个实例不变式。假设我们定义了如下的引用类型:
public class Customer
{
private string _name;
private decimal _revenue;
public Customer( string name )
{
_name = name;
}
public string Name
{
get { return _name; }
set { _name = value; }
}
public override int GetHashCode()
{
return _name.GetHashCode();
}
}
再假设我们执行了如下的代码:
Customer c1 = new Customer( "Acme Products" );
myHashMap.Add( c1, orders );
// Name错了:
c1.Name = "Acme Software"; // c1.Name的更改, 导致散列码也会更改, 将会在集合中失去c1这个Customer
在上面的代码中,c1会在myHashMap中的某个地方丢失。当我们将c1放入myHashMap中时,散列码会根据字符串"Acme Products"来产生。但在我们将Name更改为"Acme Software"之后,散列码也会更改。新的散列码会根据"Acme Software"来产生。这时候c1仍然存储在由"Acme Products"定义的“散列桶”中,虽然实际上它应该存储在由"Acme Software"定义的“散列桶”中。我们将会在集合中失去c1这个Customer。丢失的原因在于散列码不再是一个对象不变式。因为在存储对象之后,我们更改了它的“散列桶”。
只有在Customer是一个引用类型时,上述问题才会出现。值类型的行为有所不同,但是仍然会引起问题。如果Customer是一个值类型,将有一个c1的副本被存储在myHashMap中。最后一行 对Name的改变将不会对存储在myHashMap中的副本产生任何效果。由于装箱和拆箱都会导致复制,因此“想在一个值类型对象被添加到一个集合中之后,再改变其成员”几乎是不可能的。
解决第二条规则的唯一方式就是让散列码函数根据对象中的常量属性(一个或多个)返回一个值。System.Object使用对象标识来遵循该规则,因为对象标识不可能改变。 System.ValueType希望我们类型中的第一个字段不会改变。如果不将类型实现为常量类型,我们将无法很好地满足该规则。当我们定义的值类型要用作一个散列集合中的键时,它必须是一个常量类型。如果不遵循这种推荐,将类型作为键的用户将有可能破坏散列表。下面的代码对Customer类进行了修正,让我们能够在改变它的同时维持其Name属性的常量性:
|
public class Customer
{
private readonly string _name;
private decimal _revenue;
public Customer( string name ) : this ( name, 0 )
{
}
public Customer( string name, decimal revenue )
{
_name = name;
_revenue = revenue;
}
public string Name
{
get { return _name; }
}
// 改变Name,返回一个新对象:
public Customer ChangeName( string newName )
{
return new Customer( newName, _revenue );
}
public override int GetHashCode()
{
return _name.GetHashCode();
}
}
将Name实现为常量属性后,我们改变Customer.Name属性的方式必须做如下的变动:
Customer c1 = new Customer( "Acme Products" );
myHashMap.Add( c1,orders );
// Name错了:
Customer c2 = c1.ChangeName( "Acme Software" );
Order o = myHashMap[ c1 ] as Order;
myHashMap.Remove( c1 );
myHashMap.Add( c2, o );
我们必须删除原来的Customer,改变其名字,然后再将新的Customer对象添加到散列表中。这看起来要比第一个版本麻烦,但是它可以正常工作。前一个版本会导致程序员编写不正确的代码。通过将“用于散列码计算的属性”强制实现为常量属性,我们可以确保正确的行为。类型的用户将不可能在这个问题上犯错。是的,这个版本需要的编码更多。我们强制让开发人员编写更多的代码,因为这是编写正确代码的唯一方式。我们要确保任何用于散列码计算的数据成员都不会改变。
第三条规则要求GetHashCode()对于所有的输入,应该在所有整数中产生一个随机的分布。满足这条规则依赖于所创建的具体类型。如果有这样的万能公式存在,那么System.Object就会实现它了,可惜事实上没有。一个常用且成功的算法是对一个类型中的所有字段调用GetHashCode()返回的值进行XOR(异或)。如果我们的类型中含有可变字段,那么应该在计算时排除它们。
GetHashCode()有非常特殊的需求:相等的对象必须产生相同的散列码;散列码必须是对象不变式;必须产生一个均匀的分布以获得比较好的效率。只有具有常量性的类型,这三个需求才可能全部被满足。对于其他类型,我们要依赖默认的行为,但是要清楚理解其缺陷。
条款11:优先采用foreach循环语句
C#的foreach语句不仅仅只是do... while或者for循环语句的一个变体。它会为我们的集合产生最好的遍历代码。实际上,foreach语句的定义和.NET框架中的集合接口密切相关。 对于一些特殊的集合类型,C#编译器会产生具有最佳效率的代码。遍历集合时,我们应该使用foreach语句,而非其他的循环构造。例如,对于下面三种循 环:
|
int [] foo = new int[100];
// 循环1:
foreach ( int i in foo)
Console.WriteLine( i.ToString( ));
// 循环2:
for ( int index = 0; index < foo.Length; index++ )
Console.WriteLine( foo[index].ToString( ));
// 循环3:
int len = foo.Length;
for ( int index = 0; index < len; index++ )
Console.WriteLine( foo[index].ToString( ));
对于当前和将来的C#编译器(版本1.1及其以上 版本),第1个循环产生的代码最优,而且需要键入的字符也最少,因此程序员的开发效率也比较高。(不过在C# 1.0编译器下,第1个循环产生的代码效率较慢,第2个循环产生的代码效率最好。)大多数C和C++程序员认为效率最高的第3循环,反而是最坏的选择。通过将Length变量放到循环之外,我们实际上阻碍了JIT编译器移除循环中的范围检查。
C#代码运行在一个安全、托管的环境中。每一个内存位置都会被检查,包括数组索引。事实上,第3个循环所产生的代码和如下的代码等效:
// 循环3, 和编译器产生的代码等效:
int len = foo.Length;
for ( int index = 0; index < len; index++ )
{
if ( index < foo.Length )
Console.WriteLine( foo[index].ToString( ));
else
throw new IndexOutOfRangeException( );
}
JIT和C#编译器并不“喜欢”我们用这种方式来帮助它们。将Length属性放到循环之外只会让JIT编译器做更多的工作,产生的代码也更慢。CLR会确保我们写的代码不会滥用变量拥有的内存。CLR 会在访问每一个特定数组元素之前,产生一个数组界限(并非上面的len变量)测试。如果我们像上面那样写代码,每一个数组界限测试会被执行两次。
在循环的每一次迭代中,我们都要对数组索引做两次检查。第1个循环和第2个循环更快的理由在于C#编译器和JIT编译器可以确保循环中的数组界限是安全的。只要循环变量不是数组的Length属性,每一次迭代时都会执行数组界限检查。
对于1.0版本的C#编译器,在数组上使用 foreach语句产生的代码比较慢的原因在于装箱操作(有关装箱的详细讨论,参见条款17)。在.NET中,数组是类型安全的。1.1版本之后的C#编译器会为数组与其他集合产生不同的IL。在1.0版本的编译器产生的代码中,在数组上使用foreach语句实际上是通过IEnumerator接口来遍历数组,而这会导致装箱与拆箱操作:
IEnumerator it = foo.GetEnumerator( );
while( it.MoveNext( ))
{
int i = (int) it.Current; // 这里将出现装箱和拆箱。
Console.WriteLine( i.ToString( ) );
}
相反,对于1.1版本之后的C#编译器,在数组上使用foreach语句将产生类似如下的构造:
for ( int index = 0; index < foo.Length; index++ )
Console.WriteLine( foo[index].ToString( ));
由于foreach语句总会产生最佳的代码,所以我们不必刻意去记忆哪种构造会产生最高效的循环构造——foreach和编译器会为我们做这些工作。
如果效率还不能说服大家,那么来看看语言互操作的情况。总有一些人(其中的大多数人都有使用其他一些编程语言的经验)坚定地认为数组的起始索引变量应该从1(而非0)开始。不管我们怎么费力地说服他们,都无法改变他们的这个习惯。.NET开发组在这个问题上已经 尽力了。我们可以在C#语言中用如下的初始化方式,来获得一个起始索引不为0的数组:
|
// 创建一个一维数组,范围为 [ 1 .. 5 ]。
Array test = Array.CreateInstance( typeof( int ),new int[ ]{ 5 }, new int[ ]{ 1 });
很多人面对这样的代码可能会退缩,转而使用起始索引为0的数组。但是总有一些人对此比较顽固。不管你怎么努力,这些人都会坚持从1开始索引数组。幸运地是,在这个问题上我们可以使用foreach语句来蒙混编译器:
foreach( int j in test )
Console.WriteLine ( j );
这里的foreach语句知道如何获得数组的上下界,因此就不必烦劳我们——而且其效率和我们手写的for循环一样快,不管其他人采用的数组下界是多少,我们使用这种做法都可以正常工作。
另外,foreach语句还可以为我们带来其他好处。其中的循环变量是只读的——也就是说我们不能替换foreach语句中的集合对象。而且还存在一个显式强制转型。如果集合中保存的对象类型不正确,迭代语句将抛出一个异常。
对于多维数组,foreach语句也有类似的好处。假设我们要创建一个棋盘,我们可能会编写如下的两段代码:
private Square[,] _theBoard = new Square[ 8, 8 ];
// 另外地方的代码:
for ( int i = 0; i < _theBoard.GetLength( 0 ); i++ )
for( int j = 0; j < _theBoard.GetLength( 1 ); j++ )
_theBoard[ i, j ].PaintSquare( );
使用foreach语句,我们可以将上面的遍历代码做如下的简化:
foreach( Square sq in _theBoard )
sq.PaintSquare( );
不管数组的维数是多少,foreach语句都会产生正确的遍历代码。如果我们之后又要做一个3D棋盘,上面的foreach循环仍然会正常工作。而其他手写的循环代码就需要更改了:
for ( int i = 0; i < _theBoard.GetLength( 0 ); i++ )
for( int j = 0; j < _theBoard.GetLength( 1 ); j++ )
for( int k = 0; k < _theBoard.GetLength( 2 ); k++ )
_theBoard[ i, j, k ].PaintSquare( );
事实上,对于在每一维上拥有不同下界的多维数组来讲,foreach循环也会正常工作。这里我就不再编写这样的示例代码了。如果有人使用那样的集合,我们要知道foreach语句也能处理它。
如果我们刚开始使用的是数组,后来又需要转向其他数据结构,foreach语句允许我们不用更改绝大多数代码,从而保持代码的灵活性。假设我们刚开始有如下一个简单的数组:
int [] foo = new int[100];
但过了一段时间后,我们发现该数组无法方便地处理我们需要的某种功能。这时候,我们选择将数组更改为ArrayList:
// 设置初始大小:
ArrayList foo = new ArrayList( 100 );
这样更改之后,任何手写的for循环代码都将遭到破坏:
int sum = 0;
// 下面的代码将不能编译:ArrayList 使用Count,而非Length
for ( int index = 0; index < foo.Length; index++ )
sum += foo[ index ]; //代码将不能编译:foo[ index ] 是一个object,而非int。
而使用foreach语句,它会编译为不同的代码,自动将每一个操作数强制转换为正确的类型。我们在代码上无需做任何更改。事实上,使用foreach语句不仅可以更改为标准集合类型——任何集合类型都可以使用foreach。
如果我们支持.NET环境为集合所定义的规则,用户便可以使用foreach来遍历我们的类型成员。要让foreach语句将一个类看做集合类型,该类必须拥有一些属性。总共有3种方式可以使一个类成为集合类:类型具备一个公有的GetEnumerator()方法;类型显式实现了IEnumerable接口;类型实现了IEnumerator接口[25]。
|
最后,foreach语句还会为我们在资源管理方面带来额外的好处。IEnumerable接口只包含一个方法:GetEnumerator()。在一个支持IEnumerable接口的类型上使用foreach语句会产生类似如下的代码(会有一些优化):
IEnumerator it = foo.GetEnumerator( ) as IEnumerator;
using ( IDisposable disp = it as IDisposable )
{
while ( it.MoveNext( ))
{
int elem = ( int ) it.Current;
sum += elem;
}
}
如果编译器可以确定类型对IDisposable接口的实现情况,那么它就会自动优化finally块中的语句[26]。
综上所述,foreach是一个非常有用的语句。 它会使用最高效的构造为“数组的上下界索引”、“多维数组遍历”和“操作数转型”产生正确的代码,并且产生的是最具效率的循环结构。它是遍历集合的最佳方式。使用它,我们编写的代码将比较“经久耐用”,而且在刚开始编写的时候也比较简单。使用foreach为我们带来的开发效率提升可能很少,但是随着时间的推移,它的效益会不断增长。
C#语言引入了许多新的语法来表达程序设计。我们所选择的技巧,实际上是向维护、扩展和使用我们软件的开发人员表达了我们的设计意图。所有的C#类型都生存于. NET环境中。.NET环境对于所有类型的能力也都有某种假设。如果我们违反了这些假设,那么类型不能正常工作的可能性就会大大增加。
本章的条款并不是要对软件设计技巧进行概要介绍 ——这方面的著作已经不少。相反,本章主要探讨如何更好地利用不同的C#语言特性,来表达我们的软件设计意图。C#语言的设计者们添加了许多语言特性,来让我们更清晰地表达现代软件设计中的各种惯用法(idiom)。某些语言特性之间的差别非常小,我们通常有许多选择。选择多刚开始看起来似乎是好事情,但是当我们发现需要扩展现有的程序时,区别就开始显现了。我们首先要确保很好地理解本章中的各个条款,然后在应用它们的时候,要对软件未来可能的扩展有一个清醒的认识。
某些语法的改变使我们拥有了新的词汇来表述日常的惯用法。属性、索引器、事件和委托都是这样例子,还有类与接口的区别:类定义类型,接口声明行为。基类声明类型,同时定义一组相关类型所共有的行为。其他 一些设计惯用法也由于垃圾收集器的引入而有所改变。而且,由于绝大多数变量都是引用类型,因此也会为我们的设计惯用法带来一些变化。
本章的推荐条款将帮助大家选择最自然的构造来表达自己的软件设计,从而使创建的软件更易于维护、扩展和使用。
条款19:定义并实现接口优于继承类型
抽象基类为类层次(class hierarchy)提供了一个共用的祖先类(ancestor)。接口则描述了一组可以由某个类型实现的紧凑的功能。每一个都有自己的用武之地,但用处各不相同。接口是一种按合同设计(design by contract)的方式:一个实现了某个接口的类型,必须提供接口中约定的方法实现。抽象基类则为一组相关的类型提供了一个共用的抽象。下面的表述虽然是陈词滥调,但是很有用:继承意味着“is a”,接口意味着“behaves like”。这些表述之所以至今仍有生命力,是因为它们很好地描述了两种构造之间的差别:基类描述了对象是什么;接口描述了对象的行为方式。
接口描述了一组功能,或者说一个合同。我们可以在接口中为任何构造创建占位符(placeholder):方法、属性、索引器和事件。任何实现了接口的类型都必须为接口中定义的所有元素提供具体的实现, 即必须实现所有的方法,提供所有的属性访问器和索引器,并定义接口中定义的所有事件。我们应该识别可重用的行为,并将它们提取出来定义在接口中。我们可以将接口用做函数的参数,并返回值。由于不相关的类型可以共同实现一个接口,因此我们将有更多机会重用代码。而且,实现一个接口对于开发人员来说,要比继承一个我们创建的类型更加容易。
我们不能在接口中提供任何成员的实现。接口不能包含实现,也不能包含任何具体的数据成员。接口是在声明一种合同:所有实现了接口的类型都要负责履行其中的约定。
除 了描述共同的行为外,抽象基类还可以为派生类型提供一些具体的实现。在抽象类中,我们可以指定数据成员、具体的方法、虚方法的实现、属性、事件和索引器。 基类可以实现一些具体的方法,因此可以为子类提供一些通用的可重用代码。任何元素都可以为虚拟成员、抽象成员或者非虚成员。抽象基类可以为任何具体的行为提供一个实现,而接口则不能。
这种实现重用还提供了另一种好处:如果向基类中添加一个方法,所有派生类都将自动隐含这个方法。从这个角度来看,基类为我们提供了一种随时间推移可以有效扩展多个类型功能的方式。通过向基类中添加并实现某种功能,所有的派生类都将立即拥有该功能。而向接口中添加一个成员,则会破坏所有实现了该接口的类。它们不会包含新的方法,并且不会再通过编译。每一个具体的类型都必须更新自己,来实现新的成员。
在抽象基类和接口之间做选择,实际上是一个如何随着时间的推移更好地支持抽象的问题。接口的特点是比较稳定:我们将一组功能封装在一个接口中,作为其他类型的实现合同。基类则可以随着时间的推移进行扩展。这些扩展将成为每个派生类的一部分。
上述两种模型可以混合使用,从而允许类型在支持多个接口的同时,可以重用实现代码。一个典型的例子是System.Collections.CollectionBase。该类提供了一个基类,使用它可以避免.NET集合类中缺乏类型安全的问题。同时,它也实现了几个我们需要的接口:IList、ICollection和IEnumerable。另外, 它还提供了一些受保护的方法,我们可以重写它们来定制一些自己需要的行为。IList接口包含的Insert()方法会将一个新的对象添加到集合中。不用提供我们自己的Insert()实现,我们就可以通过重写CollectionBase类的OnInsert()或者OnInsertCcomplete ()虚方法来处理一些事件。
public class IntList : System.Collections.CollectionBase
{
protected override void OnInsert( int index, object value )
{
try
{
int newValue = System.Convert.ToInt32( value );
Console.WriteLine( "Inserting {0} at position {1}", index.ToString(), value.ToString());
Console.WriteLine( "List Contains {0} items", this.List.Count.ToString());
}
catch( FormatException e )
{
throw new ArgumentException("Argument Type not an integer", "value", e );
}
}
protected override void OnInsertComplete( int index, object value )
{
Console.WriteLine( "Inserted {0} at position {1}", index.ToString( ), value.ToString( ));
Console.WriteLine( "List Contains {0} items", this.List.Count.ToString( ) );
}
}
public class MainProgram
{
public static void Main()
{
IntList l = new IntList();
IList il = l as IList;
il.Insert( 0,3 );
il.Insert( 0, "This is bad" );
}
}
上述代码创建了一个整数数组链表,并使用 IList接口指针往集合中添加两个不同的值。通过重写OnInsert()方法,IntList类可以测试插入值的类型,如果其类型不是整数,它就会抛出一个异常。基类为我们提供了默认的实现,并设置了一些挂钩(hook)供我们定制派生类的行为。
CollectionBase 基类为我们提供了一个可用的实现。我们基本上不需要编写很多代码,因为可以使用基类中提供的通用实现。但是IntList的公有API来自于 CollectionBase实现的接口:IList、ICollection和IEnumerable。CollectionBase为我们提供了这些接口的通用实现。
下面谈谈将接口用做参数和返回值的情况。一个接口可以被任意数量的无关类型实现。针对接口的编码方式(coding to interface)为其他开发人员提供了比针对基类型的编码方式(coding to base class type)更大的灵活性。这很重要,因为.NET环境将类型继承层次限定为单继承。
下面两个方法执行的是同样的任务:
public void PrintCollection( IEnumerable collection )
{
foreach( object o in collection )
Console.WriteLine( "Collection contains {0}", o.ToString( ) );
}
public void PrintCollection( CollectionBase collection )
{
foreach( object o in collection )
Console.WriteLine( "Collection contains {0}", o.ToString( ) );
}
第2个方法的可重用性比较差,它不能和Arrays、ArrayLists、DataTables、Hashtables、ImageLists或其他很多集合类一起使用。将接口作为方法的参数类型不仅适应面广,而且易于重用。
使用接口为一个类定义API还会为我们提供更大的灵活性。例如,许多应用程序都使用DataSet在应用程序的组件之间传递数据。这样,就很容易将代码像如下一样写死:
public DataSet TheCollection
{
get { return _dataSetCollection; }
}
这会使我们很容易在将来遇到问题。比如,在未来的某个时候,我们可能不希望向外界提供DataSet,转而提供DataTable或者DataView,甚至是创建自定义的对象。所有这些改变都会破坏现有的代码。当然,我们可以改变参数类型,但是那会改变类型的公有接口。改变一个类的公有接口,会导致我们对庞大的系统做很多改变。该公有属性被访问的所有地方,都需要进行改变。
第2个问题更为直接和棘手:DataSet类提供有许多方法可以改变其中包含的数据。这样,类型用户便可能删除其中的表,修改其中的列,甚至替换其中的每一个对象。那肯定不会是我们想要的结果。幸运的是,我们可以通过返回期望给用户使用的接口(而非返回整个DataSet对象引用),来限制类型用户的能力。DataSet支持IListSource接口,可作数据绑定之用:
using System.ComponentModel;
public IListSource TheCollection
{
get { return _dataSetCollection as IListSource; }
}
IListSource 接口允许用户通过GetList()方法来查看其中的数据。它还有一个ContainsListCollection属性允许用户判断集合的整体结构。使用IListSource接口,可以访问DataSet中的单个条目,但是其整体结构不能被改变。另外,调用者也不能通过删除约束或者添加功能,来使用 DataSet上的方法改变其中数据上可用的行为。
当使用类将属性提供给外界时,它实际上会把整个类的接口暴露给外界。通过使用接口,我们可以选择只提供那些期望给用户使用的方法和属性。用来实现接口的类属于实现细节,它会随着时间的推移而改变(参见条款23)。
此外,不相关的类型可以实现同样的接口。假设我们编写了一个应用程序来管理员工、客户和厂商。至少在类层次中,它们之间没有关联。但是,它们共享着某种相同的功能。它们都有名称,我们可能会在一些Windows控件中显示这些名称。
public class Employee
{
public string Name
{
get
{
return string.Format( "{0}, {1}", _last, _first );
}
}
// 忽略其他细节。
}
public class Customer
{
public string Name
{
get
{
return _customerName;
}
}
// 忽略其他细节。
}
public class Vendor
{
public string Name
{
get
{
return _vendorName;
}
}
}
Employee、Customer和Vendor三个类不应该共享一个基类。但是它们共享着一些属性:名称(如上面的代码所展示)、地址和联系电话。我们可以将这些属性放在一个接口中:
public interface IContactInfo
{
string Name { get; }
PhoneNumber PrimaryContact { get; }
PhoneNumber Fax { get; }
Address PrimaryAddress { get; }
}
public class Employee : IContactInfo
{
// 忽略实现。
}
这个新的接口可以简化我们的编程任务,因为它允许我们创建相同的函数来操作不相关的类型:
public void PrintMailingLabel( IContactInfo ic )
{
// 忽略实现。
}
上面的函数可以应用于所有实现了IContactInfo接口的类型。Employee、Customer和Vendor类型都可以作为上述函数的参数,因为它们都实现了该接口。
有时候,使用接口还可以帮助我们避免结构类型的拆箱 (unbox)代价。当我们将结构实例放入一个装箱对象时,该装箱对象实际上支持结构支持的所有接口。当通过接口指针来访问该结构时,我们不必拆箱即可访问到内部的数据。下面的例子展示了一个结构,其中定义了一个链接和一个描述:
public struct URLInfo : IComparable
{
public string URL;
private string description;
public int CompareTo( object o ) //首先调用这个方法
{
if (o is URLInfo)
{
URLInfo other = ( URLInfo ) o;
return CompareTo( other );
}
else
throw new ArgumentException("Compared object is not URLInfo" );
}
public int CompareTo( URLInfo other )
{
return URL.CompareTo( other.URL );
}
}
URLInfo u = new URLInfo();
u.URL = "123";
URLInfo u1 = new URLInfo();
u1.URL = "1234";
IComparable t = u;
int i = t.CompareTo(u1); //对于t对象不需要拆箱,u1对象需要拆箱
由于URLInfo实现了IComparable 接口,因此我们可以创建一个URLInfo对象的排序链表。将URLInfo结构添加到链表中时,它会被装箱。但是Sort()方法不需要对排序过程中需要比较的两个对象进行拆箱,即可调用CompareTo()方法。当然,我们仍然需要对其中作为参数的那个对象(other)进行拆箱,但是对于调用 IComparable.CompareTo()方法时左边的那个对象,则不需要拆箱。
综上所述,基类描述并实现了一组相关类型间共用的行为。接口则描述了一组比较紧凑的功能,供其他不相关的具体类型来实现。二者都有自己的用武之地。类定义了我们要创建的类型。接口以功能分组的形式描述了 那些类型的行为。如果理解好二者之间的差别,我们便可以创建更富表现力、更能应对变化的设计。应该使用类层次来定义相关的类型,然后让它们实现不同的接口,以便通过接口向外界提供功能。
条款20:明辨接口实现和虚方法重写
乍一看,实现接口和重写虚方法好像一样。我们都是在一个类型中为另一个类型中声明的成员提供定义。这种初始印象非常具有欺骗性。实际上,实现接口和重写虚方法之间的差别很大。接口中声明的成员并非虚方法——至少,默认情况下不是虚方法。
派生类不能重写基类中实现的接口成员。接口可以被显式实现(explicitly implemented),这会使它们被排除在一个类的公有成员之外。接口成员与虚方法的概念不同,用法也不同。
但是我们也可以用一种让派生类可以重写实现的方式来实现接口。这时候,只要为派生类创建一些挂钩(hook)就可以了。
为了演示二者之间的差别,我们先来看一个简单的接口和一个类对它的实现。
interface IMsg
{
void Message();
}
public class MyClass : IMsg
{
public void Message()
{
Console.WriteLine( "MyClass" );
}
}
Message()方法是MyClass公有接口的一部分。Message()也可以通过IMsg来访问,因为IMsg是MyClass类型的一部分。现在如果添加一个派生类,情况就会变得稍微有些复杂:
public class MyDerivedClass : MyClass
{
public new void Message()
{
Console.WriteLine( "MyDerivedClass" );
}
}
注意,上面的Message()方法的定义中必须添加new关键字(参见条款29)。MyClass.Message()并不是一个虚方法。派生类不能为其提供一个重写的版本。MyDerived类实际上创建了一个新的Message()方法。
这个新的Message()方法和MyClass.Message()方法并不构成重写关系,它仅仅是把MyClass中的Message()方法给隐藏了。另外,MyClass.Message()仍然可以通过IMsg引用来访问:
MyDerivedClass d = new MyDerivedClass( );
d.Message( ); // 打印"MyDerivedClass"。
IMsg m = d as IMsg; //调用直接实现接口的那个类的方法
m.Message( ); // 打印"MyClass"。
接口方法并不是虚方法。当我们实现一个接口时,我们是在类型中声明一个特定接口合同的具体实现。
但是,我们通常要创建接口,然后在基类中实现它们,并在派生类中更改它们的实现。是的,我们可以这么做,有两种做法供我们选择。如果不能访问基类,我们可以在派生类中重新实现接口:
public class MyDerivedClass : MyClass, IMsg
{
public new void Message()
{
Console.WriteLine( "MyDerivedClass" );
}
}
添加IMsg接口会改变派生类的行为,所以现在IMsg.Message()会使用派生类中提供的实现:
MyDerivedClass d = new MyDerivedClass( );
d.Message( ); // 打印"MyDerivedClass"。
IMsg m = d as IMsg;
m.Message( ); // 打印"MyDerivedClass"
在MyDerivedClass.Message()方法上,我们仍然需要使用new关键字。这可能会让大家感觉存在某种问题(参见条款29)。基类中对IMsg.Message()的实现仍然可以通过一个基类对象的引用来获得:
MyDerivedClass d = new MyDerivedClass( );
d.Message( ); // 打印"MyDerivedClass"。
IMsg m = d as IMsg;
m.Message( ); // 打印"MyDerivedClass"
MyClass b = d;
b.Message( ); // 打印"MyClass"
修正这个问题的唯一办法就是修改基类——将接口方法声明为虚方法:
public class MyClass : IMsg
{
public virtual void Message()
{
Console.WriteLine( "MyClass" );
}
}
public class MyDerivedClass : MyClass
{
public override void Message()
{
Console.WriteLine( "MyDerivedClass" );
}
}
现在MyDerivedClass——以及所有继承自MyClass的类——都可以声明它们自己的Message()方法。每次被调用的也都是重写的版本,不管是通过MyDerivedClass引用,还是通过IMsg引用,或者通过MyClass引用。
如果不喜欢这种不够纯粹的虚函数,可以对MyClass的定义做一个小的改动:
public abstract class MyClass: IMsg
{
public abstract void Message();
}
是的,我们可以在实现一个接口的同时,不实现接口中的方法。通过将接口中的方法声明为抽象方法,我们实际上是在要求所有派生类都必须提供该接口的实现。IMsg接口是MyClass声明的一部分,但是定义该方法的工作被延迟到了各个派生类中。
显式接口实现(explicit interface implementation)使我们可以在实现一个接口的同时,将其成员从类型的公有接口中隐藏掉。它为实现接口和重写虚方法之间的关系带来了一些其他的变数。当有一个更合适的版本可用时,我们可以使用显式接口实现来限制客户代码直接调用接口方法(采用抽象类继承接口)。条款26中的IComparable设计惯用法向我们详细地展示了这一点。
实现接口拥有的选择要比创建和重写虚函数多。我们可以为类层次创建密封(sealed)的实现、虚实现或者抽象的合同。我们也可以决定派生类如何以及何时修改“基类中实现的接口成员的默认行为”。接口方法不是虚方法,而是一个单独的合同。
条款21:使用委托表达回调
我:“孩子,去院子里铲草,我要读会儿书。”
Scott:“爸爸,我已经把院子打扫干净了。”
Scott:“爸爸,我为割草机充上气了。”
Scott:“爸爸,割草机不能启动。”
我:“我来启动它。”
Scott:“爸爸,我搞好了。”
上面一段很短的交流演示了回调 (callback)的概念。我给自己的小孩一个任务,他在完成任务期间不断地打断我。在等待他完成任务的每一部分的过程中,我不需要停下我自己的工作。 当他有一个重要(或者不重要)的状态需要报告,或者需要我的协助时,他可以周期性地打断我。回调用于为服务器和客户机之间提供异步的反馈。这中间可能会牵扯到多线程,或者需要为同步更新提供一个入口点。在C#中,回调使用委托来表达。
委托为我们提供了类型安全的回调定义。虽然大多数常见的委托应用都和事件有关,但那并不是C#委托应用场合的全部。当类之间有通信的需要,并且我们期望一种比接口更为松耦合的机制时,委托便是最合适的选择。委托允许我们在运行时配置目标,并可通知多个客户对象。委托对象中包含一个方法引用,该方法可以是静态方法,也可以是实例方法。使用委托,我们可以和一个或多个在运行时配置的客户对象进行通信。
可以将所有包含单个函数调用的委托对象组合为多播委托(multicast delegate)。但在这种构造中有两点需要我们注意:第一,如果有委托调用出现异常,那么这种构造将不能保证安全;第二,整个调用的返回值将为最后一个函数调用的返回值。
在一个多播委托调用中,每一个目标会被顺次调用。委托对象本身不会捕捉任何异常。因此,任何目标抛出的异常都会结束委托链的调用。
返回值也有类似的问题。我们定义的委托可以有具体的返回类型(非void)。
例如,我们可能会编写一个回调来检查用户是否要异常结束:
public delegate bool ContinueProcessing();
public void LengthyOperation( ContinueProcessing pred )
{
foreach( ComplicatedClass cl in _container )
{
cl.DoLengthyOperation();
// 检查用户是否要异常结束:
if (false == pred())
return;
}
}
作为单个委托,上面的代码工作得很好,但是如果作为多播委托使用,就会出现问题:
ContinueProcessing cp = new ContinueProcessing ( CheckWithUser );
cp += new ContinueProcessing( CheckWithSystem );
c.LengthyOperation( cp );
多播委托返回的值是委托链上最后一个函数调用的返回值。所有其他的返回值都会被忽略。例如,上面代码中CheckWithUser()的返回值就会被忽略。
我们可以自己调用委托链上的每个委托目标,从而避免上述两个问题。所创建的每一个委托都包含一个委托链表。要检查委托链并调用每一个目标,我们需要自己遍历调用链表:
public delegate bool ContinueProcessing();
public void LengthyOperation( ContinueProcessing pred )
{
bool bContinue = true;
foreach( ComplicatedClass cl in _container )
{
cl.DoLengthyOperation();
foreach( ContinueProcessing pr in pred.GetInvocationList( )) //委托链表
bContinue = bContinue & pr();
if (false == bContinue)
return;
}
}
在上面的代码中,我们要求每一个委托调用都为true时遍历才能继续。
委托为我们提供了一种在运行时进行回调的最好方式,这种方式对客户类只有非常简单的要求。我们可以在运行时配置委托目标。另外,委托也支持多个客户目标。在.NET中,客户回调应该使用委托来实现。
条款22:使用事件定义外发接口
事件为类型定义了外发接口(outgoing interface)。C#的事件建立在委托之上,委托为事件处理器(event handler)提供了类型安全的函数签名。由于绝大多数使用委托的例子都是事件,一些开发人员便开始认为事件和委托是同一种东西。在条款21中,我向大家展示了“可以使用委托,但却没有定义事件”的例子。当我们的类型必须和多个客户通信,并为它们提供系统中的行为通知时,我们才应该触发事件。
考虑一个简单的例子。我们正在创建一个日志类,来派发应用程序中的所有消息。它会从应用程序中的消息源接受所有消息,然后将它们派发给感兴趣的侦听者。这些侦听者可能会被连接到控制台、数据库、系统日志 或者其他一些机制上。我们可以像下面这样定义这个类,当有消息到达时便触发一个事件:
public class LoggerEventArgs : EventArgs
{
public readonly string Message;
public readonly int Priority;
public LoggerEventArgs ( int p, string m )
{
Priority = p;
Message = m;
}
}
// 定义事件处理器的签名:
public delegate void AddMessageEventHandler( object sender, LoggerEventArgs msg );
public class Logger
{
private static Logger _theOnly = null;
private Logger( )
{
}
static Logger( )
{
_theOnly = new Logger( );
}
public Logger Singleton
{
get
{
return _theOnly;
}
}
// 定义事件:
public event AddMessageEventHandler Log;
// 添加一个消息,并做日志记录。
public void AddMsg ( int priority, string msg )
{
// 下面将讨论这个惯常做法。
AddMessageEventHandler l = Log;
if ( l != null )
l ( null, new LoggerEventArgs( priority, msg ) );
}
}
AddMsg ()方法展示了触发事件的正确方法。其中,引用Log事件处理器的临时变量是一个重要的安全措施,它可以应对多线程环境中的竞争条件。如果没有这个引用的副本,客户代码可能会在if判断语句和事件处理器执行之间删除事件处理器。通过对引用的副本,这种事情就不会发生了。
LoggerEventArgs 类中定义了事件的优先级和消息内容。AddMessageEventHandler委托则为事件处理器定义了签名。在Logger类内部,事件字段Log 定义了事件处理器。编译器看到这个公有的事件字段定义后,会为我们创建对应的Add和Remove操作符。编译器产生的代码就好像我们编写了如下的代码一 样:
public class Logger
{
private AddMessageEventHandler _Log;
public event AddMessageEventHandler Log
{
add
{
_Log = _Log + value;
}
remove
{
_Log = _Log - value;
}
}
public void AddMsg (int priority, string msg)
{
AddMessageEventHandler l = _Log;
if (l != null)
l (null, new LoggerEventArgs (priority, msg));
}
}
}
C#编译器会为我们定义的事件创建add和 remove访问器。我认为公有的事件声明更简练,更易于阅读和维护,也更正确。当我们在自己的类中创建事件时,应该声明公有事件,然后让编译器为我们创建add和remove属性。只有在需要添加额外的规则时,我们才应该自己编写这些处理代码。
事件并不需要知道潜在的侦听者。下面的类会自动将所有消息发送给标准错误控制台:
class ConsoleLogger
{
static ConsoleLogger()
{
Logger.Log += new AddMessageEventHandler( Logger_Log );
}
private static void Logger_Log( object sender, LoggerEventArgs msg )
{
Console.Error.WriteLine( "{0}:\t{1}", msg.Priority.ToString(), msg.Message );
}
}
下面的类则可以将消息输出到系统事件日志中:
class EventLogger
{
private static string eventSource;
private static System. Diagnostics. EventLog logDest;
static EventLogger()
{
logger.Log +=new AddMessageEventHandler( Event_Log );
}
public static string EventSource
{
get
{
return eventSource;
}
set
{
eventSource = value;
if ( ! EventLog.SourceExists( eventSource ) )
EventLog.CreateEventSource( eventSource, "ApplicationEventLogger" );
if ( logDest != null )
logDest.Dispose( );
logDest = new EventLog( );
logDest.Source = eventSource;
}
}
private static void Event_Log( object sender, LoggerEventArgs msg )
{
if ( logDest != null )
logDest.WriteEntry( msg.Message, EventLogEntryType.Information, msg.Priority );
}
}
事件发生时,它会通知所有感兴趣的客户对象。因此,Logger类不需要预先知道哪些对象对日志事件感兴趣。
Logger 类只包含了一个事件。但也有一些类(绝大多数是Windows控件)包含的事件数量非常多。这时候,为每个事件都定义一个字段的做法是不可接受的。在某些情况下,只有很少的事件会在程序中真正发挥作用。当遇到这种情况时,我们可以改变设计,根据运行时的需要动态创建事件对象。
.NET框架内核在Windows控件子系统中包含有这方面的做法示例。为了演示其中的做法,这里会向Logger类添加一些子系统。我们可以为每个子系统创建一个事件。客户则会登记和它们的子系统相关的事件。
扩展后的Logger类包含一个 System.ComponentModel.EventHandlerList容器,该容器中存储着所有的事件对象,这些事件将会针对具体的子系统来被触发。更新后的AddMsg()方法现在接受一个字符串参数,用于指定产生日志消息的子系统。如果子系统有侦听者,那么事件就会被触发。另外,如果一个事件侦听者登记了所有的消息,它的事件也会被触发:
public class Logger
{
private static System.ComponentModel.EventHandlerList
Handlers = new System.ComponentModel.EventHandlerList();
static public void AddLogger(string system, AddMessageEventHandler ev )
{
Handlers[ system ] = ev;
}
static public void RemoveLogger( string system )
{
Handlers[ system ] = null;
}
static public void AddMsg ( string system, int priority, string msg )
{
if (system != null && system.Length > 0)
{
AddMessageEventHandler l = Handlers[ system ] as AddMessageEventHandler;
LoggerEventArgs args = new LoggerEventArgs(priority, msg );
if ( l != null )
l ( null, args );
// 空字符串意味着接受所有消息:
l = Handlers[ "" ] as AddMessageEventHandler;
if ( l != null )
l( null, args );
}
}
}
class ConsoleLogger //事件的监听者、具体处理者
{
static ConsoleLogger()
{
Logger. AddLogger(“Consoler”, new AddMessageEventHandler( Logger_Log ));
}
private static void Logger_Log( object sender, LoggerEventArgs msg )
{
Console.Error.WriteLine( "{0}:\t{1}", msg.Priority.ToString(), msg.Message );
}
}
class EventLogger //事件的监听者、具体处理者
{
private static string eventSource;
private static System. Diagnostics. EventLog logDest;
static EventLogger()
{
logger.AddLoger(“EventLogger”,new AddMessageEventHandler( Event_Log ));
}
public static string EventSource
{
get
{
return eventSource;
}
set
{
eventSource = value;
if ( ! EventLog.SourceExists( eventSource ) )
EventLog.CreateEventSource( eventSource, "MyLogType" );
//第一个参数对应左边目录树节点中的'来源'名称,第二个参数表示控制面板中的'事件查看器'左边的目录树节点
if ( logDest != null )
logDest.Dispose( );
logDest = new EventLog( );
logDest.Source = eventSource;
}
}
private static void Event_Log( object sender, LoggerEventArgs msg )
{
if ( logDest != null )
logDest.WriteEntry( msg.Message, EventLogEntryType.Information, msg.Priority );
}
}
private void button1_Click(object sender, EventArgs e)
{
ConsoleLogger l = new ConsoleLogger(); //注册事件
Logger.AddMsg("Console", 1, "Console Output"); //触发事件
//下面一行代码说明事件也不必非要有订阅者才能正常工作
EventLogger.EventSource = "RogerLog";//对应事件查看器左边目录树节点中的'来源'名称
Logger.AddMsg("EventLogger", 1, "Event Output"); //触发事件
}
上面的代码会在EventHandlerList 集合中存储各个事件处理器。当客户代码关联到一个特定的子系统上时(只要一使用这个定义的对象,如EventLogger ),新的事件对象就会被创建, 事件就会被注册。对同一个子系统的后续请求会获取相同的事件对象。如果我们的类在其接口中包含有大量的事件,我们就应该考虑使用这样的事件处理器集合。当客户代码真正关联有事件处理器时(如调用EventLogger.EventSource时候,会注册事件成员,即创建事件成员),我们才会创建事件成员。在.NET框架内部,System.Windows.Forms.Control类使用了一种更复杂的实现,来隐藏所有事件字段操作的复杂性。每一个事件字段在内部会通过访问一个对象集合,来添加和删除特定的处理器。大家可以在C#语言规范(参见条款49)中找到有关这种常见做法的更多信息。
综上所述,我们使用事件来定义类型中的外发接口, 任意数量的客户对象都可以将自己的处理器登记到事件上,然后处理它们。这些客户对象不需要在编译时存在。事件也不必非要有订阅者才能正常工作。在C#中使用事件可以对发送者和可能的通知接受者进行解耦。发送者可以完全独立于接收者进行开发。事件是一种广播类型行为信息的标准方式。
条款23:避免返回内部类对象的引用
大家可能认为只读属性就只能读取,调用者不可能更改属性值。可惜的是,并非所有情况都如此。如果我们创建的属性返回了一个引用类型,那么调用者就可以访问该对象的公有成员,包括那些修改属性状态的成员。例如:
public class MyBusinessObject
{
// 只读属性提供了对私有数据成员的访问:
private DataSet _ds;
public DataSet Data
{
get
{
return _ds;
}
}
}
// 访问DataSet:
MyBusinessObject bizObj = new MyBusinessObject();
DataSet ds = bizObj.Data;
// 并非我们期望的行为,但是这么做是允许的:
ds.Tables.Clear( ); // 删除所有数据表。
这里,任何外部的客户代码都可以修改MyBusinessObject类型内部的DataSet。我们可以创建属性来隐藏内部的数据结构,也可以创建方法来让客户代码仅通过它们操作数据,这样我们的类就可以管理对内部状态的任何改变。但是一个只读属性却将这样的类封装打开了一个缺口。由于是只读属性,而不是一个读/ 写属性,因此会出现我们考虑不到的问题。
欢迎大家来到奇妙的基于“引用”的类型系统中来!在这样的系统中,任何返回引用类型的成员,都会返回该对象的一个句柄(handle)。这个句柄使得调用者可以到达对象内部的数据结构,无需通过对象就可以改变其中包含的引用。
显然,我们希望避免这种行为。我们可以选择为类创建接口,然后让用户通过接口使用对象。我们不希望用户在我们不知道的情况下,访问或者改变对象的内部状态。共有4种不同的策略可以防止类型的内部数据结构遭受无意的改变:值类型、常量类型、接口和包装器(wrapper)。
第1种选择值类型,当客户代码通过属性来访问值类型成员时,实际返回的是值类型的副本。对该副本的任何更改都不会影响对象的内部状态。客户代码可以根据自己的需要更改该副本,以达到它们的目的这不会影响内部状态。
第2种选择常量类型,如System.String,也是安全的。我们可以在类型中安全地返回string或者其他常量类型,客户代码不可能对它们做任何更改,因此可以确保类型内部状态的安全。
第3种选择是通过定义接口,将客户对内部数据成员的访问限制在一个子集中(参见条款19)。当我们创建类时,可以创建一组接口来支持类型功能的子集。通过使用接口向外界提供类型的功能,我们可以将内部数据遭受无意更改的可能性最小化。客户代码可以通过我们提供的接口(不包括类型的全部功能)访问内部对象。例如,使用IListSource接口向外提供 DataSet的功能就是这种策略的一个应用。某些“诡计多端”的程序员可能会通过猜测实现接口的对象类型,然后使用强制转型来破坏这种策略。但是这样的做法肯定会造成一些bug。
最后一种策略:包装器(wrapper)对象,在System.DataSet类中也有应用。DataViewManager类为我们提供了访问DataSet的方式,但它却阻止我们调用那些DataSet类上的变动性方法:
public class MyBusinessObject
{
// 只读属性提供了对私有数据成员的访问:
private DataSet _ds;
public DataView this[ string tableName ]
{
get
{
return _ds.DefaultViewManager.CreateDataView( _ds.Tables[ tableName ] );
}
}
}
// 访问dataset:
DataView list = bizObj[ "customers" ];
foreach ( DataRowView r in list )
Console.WriteLine( r[ "name" ] );
DefaultViewManager通过创建一些DataView来访问DataSet中的各个数据表。这样,用户就无法更改DataSet中的表了。我们可以对每个DataView进行配置以支持对单个数据元素的更改。但是客户代码无法更改其中的表或者数据列。因为读/写是被默认支持的,所以客户代码仍然可以添加、修改或删除单个的数据条目。
在探讨如何创建一个完全只读的数据视图之前,先来看看当允许外部客户代码更改数据时,我们有什么样的办法可以响应这种更改?这很重要,因为我们可能经常需要将一个DataView导出到UI控件上,以支持用户编辑数据(参见条款38)。大家肯定都用过Windows Forms的数据绑定功能,它可以帮助用户编辑对象的私有数据。DataSet中的DataTable类触发的事件使其可以很容易地实现Observer (观察者)模式:我们的类可以响应客户代码对其所做的任何改变。当DataSet中的DataTable的任何列或者行发生改变时,都会触发相关的事件。 在将一个编辑动作提交给DataTable之前,会有ColumnChanging和RowChanging事件被触发。在提交改变之后,会有 ColumnChanged和RowChanged事件被触发。
当希望将内部数据元素暴露给外界,供外部客户代码更改时,也可以利用这种技巧,但我们需要对这些更改进行校验和响应。我们的类可以订阅那些由内部数据结构产生的事件。然后让事件处理器通过更新其他内部状态,来对更改进行校验和响应。
回到原来的问题上,我们希望允许客户代码查看数据,但却不希望它们做任何更改。当我们的数据存储在一个DataSet中时,我们可以通过创建一个不允许更改的DataView,来确保这一点。 DataView类中包含的属性允许我们指定是否支持在特定表上的添加、删除、更改,甚至排序操作。我们可以创建一个索引器来根据所请求的使用索引器的表,返回一个定制的DataView:
public class MyBusinessObject
{
// 只读属性提供了对私有数据成员的访问:
private DataSet _ds;
public IList this[ string tableName ]
{
get
{
DataView view = _ds.DefaultViewManager.CreateDataView( _ds.Tables[ tableName ] );
view.AllowNew = false;
view.AllowDelete = false;
view.AllowEdit = false;
return view;
}
}
}
// 访问DataSet:
IList dv = bizOjb[ "customers" ];
foreach ( DataRowView r in dv )
Console.WriteLine( r[ "name" ] );
在上面的类中,我们使用IList接口来返回特定数据表的视图。我们可以在任何集合上使用IList接口,它并不局限于DataSet。我们不应该简单地返回 DataView对象,因为用户可以很容易地再次启用它的编辑、增加和删除能力。通过定制返回的视图,我们则可以避免对链表中的对象的更改。返回IList接口事实上禁止了用户改变DataView对象的操作权限。
综上所述,将引用类型通过公有接口暴露给外界,将使得类型的用户不用通过我们定义的方法和属性,就能够更改对象的内部结构。这违反了我们通常的直觉,会导致常见的错误。如果我们导出的是引用而非值,那就需要改变类型的接口。如果只是简单地返回内部数据,那么我们实际上就给外界赋予了访问内部成员的权限。客户代码可以调用成员中任何可用的方法。通过使用接口或者包装器对象向外界提供内部的私有数据,我们可以限制外界对它们的访问能力。当希望客户代码更改内部数据元素时,我们应该实现Observer(观察 者)模式,以使对象可以对更改进行校验或响应。
条款24:声明式编程优于命令式编程
和命令式编程相比,声明式编程可能是一种更简单、 更精炼的描述软件程序行为的方式。声明式(declarative)编程意味着使用声明、而非指令的方式来定义程序的行为。和许多其他程序语言一样,C#中绝大多数编程都是命令式(imperative)编程:通过编写方法来定义程序的行为。通过使用特性(attribute),我们也可以在C#中实现声明式编程。我们可以将特性应用在类、属性、数据成员或者方法上,.NET运行时则会为我们添加适当的行为。声明式编程更易于实现、阅读和维护。
让我们从一个大家已经使用过的典型示例开始。当编写第1个ASP.NET Web服务时,向导程序会产生如下的代码:
[WebMethod]
public string HelloWorld()
{
return "Hello World";
}
VS.NET Web服务向导程序会为HelloWorld()方法添加一个[WebMethod]特性。这会将HelloWorld()方法声明为一个Web方法。由于对该特性的使用,ASP.NET运行时会为我们创建一些相关的代码。首先,它会为我们创建Web服务描述语言(Web Service Description Language,简称WSDL)文档,其中包含一个对“调用HelloWorld()方法的SOAP文档”的描述。其次,ASP.NET运行时还会添加对HelloWorld()方法SOAP请求的路由支持,并且会动态创建HTML页面来支持我们在IE中测试新的Web服务。这些都有赖于对 WebMethod特性的应用。该特性声明了我们的意图,ASP.NET运行时则确保对这种意图给予适当的支持。使用这样的特性可以节省许多开发时间,也可以避免许多错误。
这并没有什么神奇的。ASP.NET运行时在后台使用反射来确定类中的哪些方法为Web方法。在找到Web方法之后,ASP.NET运行时会添加所有必要的框架代码,从而将我们编写的函数转换为Web方法。
[WebMethod] 特性只是.NET类库中定义的许多特性中的一个,这些特性可以帮助我们更快捷地创建正确的应用程序。例如,有的特性可以帮助我们创建支持序列化的类型(参见条款25)。有的特性可以控制条件编译(参见条款4)。通过使用特性所支持的声明式编程,我们可以更快地创建代码,并降低犯错的几率。我们应该使用. NET框架中的特性来声明我们的意图,而不是自己编写代码。这样的做法花费的时间较少,也更容易,且编译器不会犯错。
如果预定义特性不能满足我们的需要,我们可以通过定义自己的特性并使用反射来创建声明式的编程构造。作为示例,我们可以创建一个特性及相关的代码,从而允许用户创建定义有默认排序顺序的类型。下面的代码示例演示了如何通过添加特性来定义对Customer集合排序的规则。
[DefaultSort( "Name" )]
public class Customer
{
string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
public decimal CurrentBalance
{
get { return _balance; }
}
public decimal AccountValue
{
get
{
return calculateValueOfAccount();
}
}
}
DefaultSort 特性为Customer类定义了默认的排序属性:Name。其隐含意思是任何Customer的集合都要使用Customer的Name进行排序。 DefaultSort特性并不是.NET框架的一部分。若要实现它,我们需要自己创建DefaultSortAttribute类:
[AttributeUsage( AttributeTargets.Class | AttributeTargets.Struct )]
public class DefaultSortAttribute : System.Attribute
{
private string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
public DefaultSortAttribute( string name )
{
_name = name;
}
}
随后编写的代码必须根据DefaultSoft特性来对Customer集合进行排序。首先要使用反射找到正确的属性,然后比较两个不同对象上该属性的值。好在,我们只需要编写一次这样的代码就可以了。
接下来,我们需要创建一个实现了IComparer的类(有关IComparer,条款26有详细讨论)。IComparer有一个CompareTo()方法,用于比较给定类型的两个对象,从而允许目标类(即实现了IComparable接口的类型)定义排序顺序。GenericComparer类的构造器会根据被比较的类型,查找默认的排序属性描述符。Compare()方法则使用默认的排序属性来比较任意类型的两个对象。
internal class GenericComparer : IComparer
{
private readonly PropertyDescriptor _sortProp; // 有关默认属性的信息:
private readonly bool _reverse = false; // 升序或者降序。
public GenericComparer( Type t ) : this( t, false ) // 类型构造。
{
}
public GenericComparer( Type t, bool reverse ) // 类型构造,以及排序方向。
{
_reverse = reverse;
// 获取类型上的默认排序特性:
object [] a = t.GetCustomAttributes(typeof(DefaultSortAttribute), false );
if ( a.Length > 0 )
{
DefaultSortAttribute sortName = a[ 0 ] as DefaultSortAttribute;
string name = sortName.Name; // 查找特性,以及排序属性的名称:
// 获取属性的PropertyDescriptor:
// 初始化排序属性:
PropertyDescriptorCollection props = TypeDescriptor.GetProperties( t );
if ( props.Count > 0 )
{
foreach ( PropertyDescriptor p in props )
{
if ( p.Name == name ) // 找到了默认排序属性:
{
_sortProp = p;
break;
}
}
}
}
}
// Compare 方法。
int IComparer.Compare( object left, object right )
{
// null 比任何对象都小:
if (( left == null ) && ( right == null ))
return 0;
if ( left == null )
return -1;
if ( right == null )
return 1;
if ( _sortProp == null )
return 0;
// 获取每个对象的排序属性:
IComparable lField = _sortProp.GetValue( left ) as IComparable;
IComparable rField = _sortProp.GetValue( right ) as IComparable;
int rVal = 0;
if ( lField == null )
if ( rField == null )
return 0;
else
return -1;
rVal = lField.CompareTo( rField );
return ( _reverse ) ? -rVal : rVal;
}
}
GenericComparer类会根据DefaultSort特性中声明的属性,对Customer集合进行排序:
CustomerList.Sort( new GenericComparer(typeof( Customer )));
GenericComparer 的实现代码使用了一些高级技巧,比如反射(参见条款43)。但是我们只需要编写一次就可以了。自此之后,我们需要做的就是在类上添加 DefaultSort特性,然后便可以使用GenericComparer对这些对象的集合进行排序了。如果我们更改了DefaultSort特性上的参数,也就更改了类的行为。我们不需要在代码中更改任何算法。
当一个简单的声明便可以表明我们的意图时,采用这种声明式的做法能够有效地避免重复性代码的编写。再来看一下GenericComparer类。我们可以为创建的每一个类型编写不同版本(也更为简单)的排序算法。使用声明式编程的好处在于,我们可以编写一个通用的类,然后使用一个简单的声明为每个类型创建不同的行为。这里的关键在于行为的改变是基于一个声明,而不是基于任何算法的改变。GenericComparer类适用于任何应用了DefaultSort特性的类型。如果在应用程序中只需要一两次排序功能,那么编写一些简单的函数就可以了。但是,如果我们的程序中有许多不同的类型都需要相同的行为,那么从长远来看,通用的算法加声明式的解决方案将会为我们节省大量的时间和精力。例如,我们永远也不用编写由WebMethod特性产生的所有代码。我们应该利用此技术来为我们的算法服务。条款42也讨论 了一个例子:如何使用特性来构建附加的命令处理器。其他的一些例子包括从定义附加包(add-on package)到构建动态的网页UI。
综上所述,声明式编程是一个强大的工具。当可以使用特性来声明我们的意图时,实际上也就避免了在多个类似的手工编写(hand-coded)的算法中,犯逻辑错误的可能。声明式编程会创建更为可读和清晰的代码。这意味着更少的错误。如果可以使用.NET框架中定义的特性,那么我们就应该积极地使用。如果不能,则可以考虑选择创建我们自己的特性类,然后在将来使用它创建相同的行为。
条款25:尽可能将类型实现为可序列化的类型
持久化(persistence)是类型的一个核心特性。这种特性往往是在我们忽略支持它们的时候,才会被注意到。如果我们的类型没有正确地支持序列化(serialization),那么其他开发人员在使用我们的类型作为成员或者基类的时候将有许多工作要做。他们必须自己实现这样的标准特性。如果不访问类型的私有实现细节,要想为类型正确实现序列化几乎是不可能的。如果我们自己不支持序列化,让类的用户来添加序列化支持将会很困难,甚至根本做不到。
因此,只要有实际意义,我们都应该尽可能地为我们的类型添加序列化支持。只要类型表示的不是UI控件、窗口或者表单,支持序列化都是有意义的。我们不能因为序列化支持需要额外的工作而选择不支持它们。. NET的序列化支持非常简单,我们没有任何理由不支持它们。许多情况下,只要添加一个Serializable特性就足够了,例如:
[Serializable]
public class MyType
{
private string _label;
private int _value;
}
之所以可以像上面那样做,是因为MyType的所有成员都是可序列化的:string和int都支持序列化。为什么我们要尽可能地为类型添加序列化支持呢?下面的代码可以帮助我们明白其中的缘由:
[Serializable]
public class MyType
{
private string _label;
private int _value;
private OtherClass _object;
}
字段_object的类型为 OtherClass,只有在OtherClass类型支持序列化的前提下,应用于MyType上的Serializable特性才能正常工作。如果 OtherClass不支持序列化,那么我们将在序列化MyType时得到一个运行时错误。为了支持序列化MyType,我们必须自己在MyType中编写代码,对OtherClass对象进行序列化。如果不了解OtherClass类型中定义的内部情况,要想实现这一点是不可能的。
.NET序列化会将对象中的所有成员变量保存到一个输出流中。而且,它还支持任意的对象图:即使对象中有循环引用,Serialize()和Deserialize()方法也会正确地对每个对象仅做一次存储和恢复。当一些交织成网状的对象被反序列化时,.NET序列化框架会正确地重建对象间的引用关系。最后需要指出的是,Serializable特性同时支持二进制序列化和SOAP序列化。本条款中的所有技巧也都支持这两种序列化格式。但要记住,只有当对象图中的所有类型都支持序列化时,这里谈的机制才会正常工作。这也是我们强调所有的类型都要支持序列化的原因。只要漏掉了一个类,就很难对整个对象图进行序列化。过不了多久,每个人都要再次自己编写序列化代码。
添加Serializable特性是一种最简单的支持对象序列化的技巧。但是最简单的方案并不总是正确的。有时候,我们可能并不打算序列化对象中的所有成员:有些成员可能只是为了缓存一个耗时较长的操作的结果。其他一些成员可能会持有一些只有活动内存中(in-memory)的操作才需要的运行时资源。我们也可以使用特性管理这些需求。在数据成员上添加 [NonSerialized]特性,可以告诉序列化框架不要将这些成员作为对象的状态来存储:
[Serializable]
public class MyType
{
private string _label;
[NonSerialized]
private int _cachedValue;
private OtherClass _object;
}
NonSerialized 成员会为类的设计者增加一些额外的负担。在反序列化的过程中,.NET框架的序列化API不会初始化那些NonSerialized成员。因为类型的构造器不会被调用,所以成员的初始化器也就不会被执行。当使用Serializable特性时,那些NonSerialized成员只会得到系统设定的默认值:0或者null。如果这种默认的0初始化机制不合适,我们就需要实现IDeserializationCallback接口来初始化它们。 IDeserializationCallback仅包含一个方法OnDeserialization()。在整个对象图被反序列化之后,.NET框架会调用该方法。我们应该使用该方法来初始化对象中包含的NonSerialized成员。由于整个对象图已经被读入内存中,因此在对象或者它的任何 Serializable成员上调用函数都是安全的。不幸的是,也有不安全的地方。在整个对象图被读入内存中之后,.NET框架会在对象图中每一个支持 IDeserializationCallback接口的对象上调用OnDeserialization()方法。在处理 OnDeserialization()方法的过程中,对象图中的任何其他对象都可能访问我们对象上的公有成员。如果它们先被调用,我们对象中的 NonSerialized成员就为0或者null。由于.NET框架并不保证调用的顺序,因此我们必须确保我们的所有公有方法都能处理 “NonSerialized成员未被初始化”的情况。
现在大家已经理解了为什么要尽可能地为所有类型添加序列化支持:NonSerialized类型在被用于其他需要序列化的类型中时,会导致更多的工作。大家也掌握了如何使用特性来支持最简单的序列化方法,包括如何初始化NonSerialized成员。
另外, 序列化数据也需要支持不同版本的程序。为类型添加序列化支持意味着我们在未来可能需要读取以前版本的序列化数据。当发现对象图中增加或者删除了某些字段, 使用Serializable特性所产生的代码将会抛出异常。如果我们准备支持多个版本,并且需要对序列化过程做更多的控制,则应该使用 ISerializable接口。该接口定义了一些挂钩(hook)函数,允许我们对类型的序列化过程进行定制。ISerializable接口使用的方法和存储(storage)与默认初始化机制使用的方法和存储相同。这意味着我们刚开始创建类的时候,可以使用Serializable特性,如果有必要提供扩展时,再为ISerializable接口添加支持。
作为示例,下面我们考虑一下在MyType的第2个版本添加了一个字段之后,如何为其提供序列化支持。简单地添加一个新字段所产生的格式显然与之前磁盘上存储的版本不一致。
[Serializable]
public class MyType
{
private string _label;
[NonSerialized]
private int _value;
private OtherClass _object;
// 下面的字段在版本2中加入。
// 当发现版本1.0的文件中没有该字段时,运行时会抛出异常。
private int _value2;
}
我们可以通过添加ISerializable接口来解决这样的问题。ISerializable接口定义了一个方法,但是我们实现该接口的时候必须提供两个。ISerializable接口定义的 GetObjectData()方法用于往流中写入数据。另外,我们还必须提供一个序列化构造器来根据流中的数据初始化对象:
private MyType( SerializationInfo info, StreamingContext cntxt );
下面类中的序列化构造器展示了如何在由Serializable特性所产生的默认实现的基础上,一致地处理类型的前后两个版本。
using System.Runtime.Serialization;
using System.Security.Permissions;
[Serializable]
public sealed class MyType : ISerializable
{
private string _label;
[NonSerialized]
private int _value;
private OtherClass _object;
private const int DEFAULT_VALUE = 5;
private int _value2; //序列化后的新版本加的字段
// 忽略其他公有构造器。
// 仅由序列化框架使用的私有构造器。
private MyType( SerializationInfo info, StreamingContext cntxt )
{
_label = info.GetString( "_label" );
_object = ( OtherClass )info.GetValue( "_object", typeof( OtherClass ));
try
{
_value2 = info.GetInt32( "_value2" );
}
catch ( SerializationException e )
{
// 发现是版本1。
_value2 = DEFAULT_VALUE;
}
}
[SecurityPermissionAttribute(SecurityAction.Demand, SerializationFormatter =true)]
void ISerializable.GetObjectData (SerializationInfo inf, StreamingContext cxt)
{
inf.AddValue( "_label", _label );
inf.AddValue( "_object", _object );
inf.AddValue( "_value2", _value2 );
}
}
序列化流将每一个数据项存储为一个键/值 (key/value)对。由特性产生的代码会使用变量的名字作为变量值的键。添加ISerializable接口时,键名与变量的顺序必须匹配。变量的顺序即在类中的声明顺序。(顺便提一句,这就意味着重新调整类中变量的顺序或者重新命名变量,将会打破先前序列化创建的文件的兼容性。)
另外,上面的代码还要求 SerializationFormatter异常安全许可。如果没有被正确地保护,GetObjectData()可能会为我们的类引入安全漏洞。恶意代码可能会创建一个StreamingContext,然后使用GetObjectData()获取对象的值,再将更改后的版本序列化到另一个 SerializationInfo中,从而实现对象状态的更改。这就使得恶意的开发人员可以访问对象的内部状态,然后在流中更改,最后再将更改结果传回对象。要求SerializationFormatter许可则堵住了这个潜在的漏洞。这确保了只有受信任的代码才能访问该函数,获取对象的内部状态。 (有关代码安全,参见条款47。)
实现 ISerializable接口也会带来一项弊端。大家可能已经发现上面的MyType被实现为sealed类型。这使得MyType只能成为一个叶子类。在基类中实现ISerializable接口将使得所有派生类的序列化变得更为复杂。这意味着每一个派生类都必须为反序列化创建受保护的构造器。另外,为了支持非密封(nonsealed)的类,我们需要在GetObjectData()方法中创建挂钩(hook)函数,以备派生类向流中添加它们自己的数据。编译器不会对这些可能的错误做任何检查。如果缺乏正确的构造器,在从流中读取一个派生类对象时,运行时将会抛出异常。如果 GetObjectData()方法中缺乏挂钩函数,对象中属于派生类的那部分数据成员将永远不会被保存到文件中,也没有错误被抛出。我本想做如下的推荐:“在叶子类中实现Serializable”。没有这样说是因为这并不可行。因为如果要派生类支持序列化,那么基类首先要支持序列化。要将 MyType更改为一个支持序列化的基类,我们必须将序列化构造器更改为protected,并创建一个虚方法供派生类重写,以存储它们的数据。
using System.Runtime.Serialization;
using System.Security.Permissions;
[Serializable]
public class MyType : ISerializable
{
private string _label;
[NonSerialized]
private int _value;
private OtherClass _object;
private const int DEFAULT_VALUE = 5;
private int _value2;
// 忽略其他公有构造器。
public MyType() //序列化时需要
{
}
// 仅由序列化框架使用的私有构造器。
protected MyType( SerializationInfo info, StreamingContext cntxt )
{
_label = info.GetString( "_label" );
_object = ( OtherClass )info.GetValue( "_object", typeof ( OtherClass ));
try
{
_value2 = info.GetInt32( "_value2" );
}
catch ( SerializationException e )
{
// 发现是版本1。
_value2 = DEFAULT_VALUE;
}
}
[ SecurityPermissionAttribute( SecurityAction.Demand, SerializationFormatter =true ) ]
void ISerializable.GetObjectData( SerializationInfo inf, StreamingContext cxt )
{
inf.AddValue( "_label", _label );
inf.AddValue( "_object", _object );
inf.AddValue( "_value2", _value2 );
WriteObjectData( inf, cxt );
}
// 在派生类中重写以序列化派生类中的数据:
protected virtual void WriteObjectData(SerializationInfo inf,StreamingContext cxt )
{
}
public string Label
{
get { return _label ;}
set { _label = value;}
}
public OtherClass Obj
{
get { return _object;}
set { _object = value;}
}
public int Value2
{
get { return _value2;}
set { _value2 = value;}
}
}
派生类则需要提供自己的序列化构造器,并重写WriteObjectData()方法:
[Serializable]
public class DerivedType : MyType
{
private int _DerivedVal;
public DerivedType () //序列化时需要
{
}
private DerivedType ( SerializationInfo info, StreamingContext cntxt ) : base( info, cntxt )
{
_DerivedVal = info.GetInt32( "_DerivedVal" );
}
protected override void WriteObjectData( SerializationInfo inf,StreamingContext cxt )
{
inf.AddValue( "_DerivedVal", _DerivedVal ); //先序列化基类,再序列化子类
}
public int DerivedVal
{
get { return _DerivedVal ;}
set {_DerivedVal = value;}
}
}
[Serializable]
public class OtherClass
{
string _idcardNo;
public OtherClass()
{}
public string IDcard
{
get { return _idcardNo; }
set { _idcardNo = value; }
}
}
以下是执行序列化的代码
private void button1_Click(object sender, EventArgs e) //序列化基类
{
DerivedType myObj = new DerivedType ();
myObj.Label = "lc";
myObj.Value2 = 100;
OtherClass other = new OtherClass();
other.IDcard = “430181”;
myObj.Obj = other;
XmlSerializer mySerializer = new XmlSerializer(typeof(DerivedType));
StreamWriter myWriter = new StreamWriter(@"c:\MySerilFileName.xml");
mySerializer.Serialize(myWriter, myObj);
myWriter.Close();
}
从序列化流中写入和读取数据的顺序必须一致。上面是选择首先读取/写入基类中的数据,因为我认为这种做法更简单。如果我们的读取/写入代码没有使用相同的顺序来序列化整个类层次,那么序列化代码就不会正常工作。
.NET框架为对象序列化提供了一个简单、标准的算法。如果我们的类型需要持久化,那就应该遵循标准的实现。如果我们不为类型添加序列化支持,那么其他使用我们类型的类也不能支持序列化。我们所做的工作应该尽可能地使类的使用者更加方便。如果可以,应该使用默认的方式来支持序列化;如果默认的Serializable特性不行,则应该实现 ISerializable接口。
条款26:使用IComparable和IComparer接口实现排序关系
有时候,我们的类型需要排序关系来支持它们在集合中的排序和搜索。.NET框架定义了两个接口来描述类型的排序关系:IComparable和IComparer。IComparable接口定义了类型的自然排序方式。IComparer则为类型提供了另外的排序方式。我们可以为类型实现各种关系操作符(<、>、<=、>=)来提供特定于类型的比较操作,从而避免接口实现所带来的运行时开销。本条款讨论如何通过两个接口IComparable和IComparer,来为类型实现排序关系,从而支持使用.NET框架对我们的类型进行排序,并帮助其他用户通过这些操作获取最佳的性能。
IComparable接口仅包含一个方法:CompareTo()。该方法保持了C语言库函数strcmp的传统:返回值小于0表示当前对象小于被比较的对象,返回值等于0表示两个对象相等,返回值大于0表示当前对象大于被比较的对象。
IComparable接口接受的参数类型为System.Object,因此我们需要对CompareTo()函数的参数进行运行时类型校验。每一次执行比较时,我们都要重新解析参数的类型:
public struct Customer : IComparable
{
private readonly string _name;
public Customer( string name )
{
_name = name;
}
#region IComparable Members
public int CompareTo( object right )
{
if ( ! ( right is Customer ) )
throw new ArgumentException( "Argument not a customer", "right" );
Customer rightCustomer = ( Customer )right;
return _name.CompareTo( rightCustomer._name );
}
#endregion
}
像上面这样使用IComparable接口来实现比较操作有许多缺点。首先,我们必须检查参数的运行时类型。一些不正确的代码可以合法地将任何类型传递给CompareTo()方法。另外,即使正确的参数,如果是像上面的值类型,也要被执行装箱和拆箱来进行实际的比较。这对于每一次比较来说又是一个成本。对一个集合进行排序需要使用 IComparable. CompareTo()方法进行平均数为N×log(n)次的比较。每一次都将导致三次装箱操作和拆箱操作。对于一个拥有1000个Point的数组来说,平均将有超过20000次装箱与拆箱操作,其中N×log(n)大约为7000,每次比较有3次装箱与拆箱操作。我们必须寻找更好的替代方案。我们当然不能更改IComparable.CompareTo()的定义。
但这并非意味着我们必须强制用户承担这种弱类型实现的性能代价。我们可以创建自己的CompareTo()方法实现,使其接受的参数类型为Customer:
public struct Customer : IComparable
{
private string _name;
public Customer( string name )
{
_name = name;
}
#region IComparable Members
// IComparable.CompareTo()
// 该方法在类型上不够安全。
// 必须检查参数right的运行时类型。
int IComparable.CompareTo( object right )
{
if ( ! ( right is Customer ) )
throw new ArgumentException( "Argument not a customer", "right" );
Customer rightCustomer = ( Customer )right;
return CompareTo( rightCustomer );
}
// 类型安全的CompareTo。
// 其中right是一个Customer或者派生自Customer的类。
public int CompareTo( Customer right ) //类型安全的显示接口出现
{
return _name.CompareTo( right._name );
}
#endregion
}
IComparable.CompareTo ()现在是一个显式接口实现(explicit interface implementa- tion);它只可以通过IComparable接口来调用。从此,Customer结构的用户访问的将是类型安全的CompareTo( Customer right ),而类型不安全的IComparable.CompareTo ( object right )则无法访问。这样,如下有明显错误的代码就不会再通过编译:
Customer c1;
Employee e1;
if ( c1.CompareTo( e1 ) > 0 )
Console.WriteLine( "Customer one is greater" );
这段代码不能通过编译是因为所传递的参数e1与公有方法Customer.CompareTo ( Customer right )要求的参数类型不匹配。IComparable.CompareTo( object right )方法使用Customer是无法访问的,我们只可以通过将对象显式转型为IComparable接口来访问IComparable中的方法:
Customer c1;
Employee e1;
if ( ( c1 as IComparable ).CompareTo( e1 ) > 0 )
Console.WriteLine( "Customer one is greater" );
实现IComparable接口时,我们应该使用显式接口实现,同时为接口中的方法提供一个公有的强类型重载版本。强类型的重载版本在提高代码性能的同时,能减少误用CompareTo()方法的可能。
对于.NET框架中的Sort函数,我们看不到这种做法所获得的好处,因为它仍旧是通过接口指针来访问CompareTo()方法(参见条款19)。但是如果代码已知两个参与比较的对象类型,那么上述做法就会获得比较好的性能。
下面我们对Customer结构做一个小的更改。C#语言允许我们重载标准的关系操作符,在为Customer重载这些操作符时,我们也应该使用类型安全的CompareTo()方法:
public struct Customer : IComparable
{
private string _name;
public Customer( string name )
{
_name = name;
}
#region IComparable Members
// IComparable.CompareTo()
// 该方法在类型上不够安全。
// 必须检查参数right的运行时类型。
int IComparable.CompareTo( object right )
{
if ( ! ( right is Customer ) )
throw new ArgumentException( "Argument not a customer", "right");
Customer rightCustomer = ( Customer )right;
return CompareTo( rightCustomer );
}
// 类型安全的CompareTo()。
// 其中right是一个Customer或者派生自Customer的类。
public int CompareTo( Customer right )
{
return _name.CompareTo( right._name );
}
// 关系操作符。
public static bool operator < ( Customer left, Customer right )
{
return left.CompareTo( right ) < 0;
}
public static bool operator <=( Customer left, Customer right )
{
return left.CompareTo( right ) <= 0;
}
public static bool operator >( Customer left, Customer right )
{
return left.CompareTo( right ) > 0;
}
public static bool operator >=( Customer left, Customer right )
{
return left.CompareTo( right ) >= 0;
}
#endregion
}
上面的代码描述了Customer根据name决定的标准排序关系。之后,我们需要创建一个根据revenue(收入)对所有客户进行排序的报表。我们仍然需要Customer中已经定义的标准比较功能——即根据name进行排序。这时,我们可以通过创建一个实现了IComparer接口的类,来满足其他的排序要求。
IComparer接口为类型实现多种排序支持提供了一种标准的方式。.NET FCL中所有支持IComparable的方法也都提供了另外的重载版本来支持IComparer排序。因为是Customer结构的作者,所以我们可以在Customer结构中创建一个新的私有嵌套类(RevenueComparer),然后通过Customer结构中的静态属性提供给外界:
public struct Customer : IComparable
{
private string _name;
private double _revenue;
// 包含在前面示例代码的部分省略。
private static RevenueComparer _revComp = null;
// 返回一个实现了IComparer的对象。
// 使用“缓式评估(lazy evaluation)”只创建一个对象。
public static IComparer RevenueCompare
{
get
{
if ( _revComp == null )
_revComp = new RevenueComparer();
return _revComp;
}
}
// 下面的类根据收入来比较Customer。
// 因为总是通过接口指针来使用,所以仅提供接口方法的实现。
private class RevenueComparer : IComparer
{
#region IComparer Members
int IComparer.Compare( object left, object right )
{
if ( ! ( left is Customer ) )
throw new ArgumentException("Argument is not a Customer", "left");
if (! ( right is Customer) )
throw new ArgumentException("Argument is not a Customer", "right");
Customer leftCustomer = ( Customer ) left;
Customer rightCustomer = ( Customer ) right;
return leftCustomer._revenue.CompareTo( rightCustomer._revenue);
}
#endregion
}
}
在最后一个版本的Customer结构中,我们内嵌了一个类RevenueComparer。这个新版的Customer,除了允许我们按照name对Customer集合进行排序之外 (Customer的自然排序),还通过实现Icomparer接口的RevenueComparer类为我们提供了另一种排序方式,即根据 revenue对Customer进行排序。如果不能访问Customer类的源代码,我们仍然可以提供IComparer接口,来使用公有属性对 Customer进行排序。只有在访问不到类的源代码时,我们才应该这么做——比如对于.NET框架中的类,如果需要一种不同的排序方式,我们就要这么做。
本条款没有提及Equals()或者==操作符 (参见条款9)。排序关系和相等比较是不同的操作。我们不需要实现相等比较来实现排序关系。实际上,引用类型通常是基于对象的内容来实现排序关系,并根据对象标识来实现相等比较。即使Equals()返回false,CompareTo()也可能返回0。这是绝对合法的。相等比较和排序关系并不必然相同。
综上所述,IComparable和IComparer为类型实现排序关系提供了两种标准的机制。IComparable接口应该用于为类型实现最自然的排序关系。
条款27:避免ICloneable接口
ICloneable 听起来是个好主意:可以为那些支持复制的类型实现ICloneable接口。如果不想支持复制,那就不要实现它。但是我们的类型并非活在真空中。让一个类型支持ICloneable接口会影响它的派生类。一旦类型支持ICloneable接口,那么它所有的派生类也都必须支持它。而且,其所有成员类型也都要支持ICloneable接口,或者有其他创建复制的机制。最后,当我们设计的类型包含交织成网状的对象时,支持深复制将变得很困难。 ICloneable接口在其官方的定义里很巧妙地绕过了这个问题,其定义如下:ICloneable接口或者支持深复制(deep copy),或者支持浅复制(shallow copy)。浅复制指的是新对象包含所有成员变量的副本,如果成员变量为引用类型,那么新对象将和原对象引用同样的对象。深复制指的也是新对象包含所有成员变量的副本,但是所有引用类型的成员变量将被递归地克隆。对于C#的内建类型,例如整数,深复制和浅复制产生的是同样的结果。那么我们的类型应该支持哪一个?这要根据具体类型而定。但是在同一个对象中混合浅复制和深复制会导致许多不一致的问题。当涉足ICloneable接口时,这样的问题很难逃脱。大多数情况下,避免ICloneable接口反倒会获得一个比较简单的类——对类的客户来讲比较容易使用,对创建者来讲也比较容易实现。
任何只包含内建类型成员的值类型都不需要支持 ICloneable接口;一个简单的赋值语句对struct的值所做的复制要比Clone()来得高效得多。Clone()必须对返回值进行装箱,才能转换为一个System.Object引用。调用者则必须进行强制转型才能获取真正的值。值类型默认的复制支持对我们来说已经足够了。我们没有必要再编写Clone()函数来重复这项工作。
如果值类型中包含引用类型呢?最明显的例子是包含字符串:
public struct ErrorMessage
{
private int errCode;
private int details;
private string msg;
// 忽略细节。
}
字符串是一个特殊的例子,因为string是一个具有常量性的类。如果我们对ErrorMessage对象进行赋值,两个ErrorMessage对象都将引用同一个字符串。但这并不会导致任何问题,而这放到一个普通的引用类型就会出现问题。通过任何一个对象更改msg变量,都会创建一个新的string对象(参见条款7)。
更一般的情况——创建一个包含任意引用类型变量的 struct——就比较复杂了。不过这种情况相当少见。C#语言为struct提供的内建赋值操作创建的是一个浅复制——即两个struct引用的是同一个引用类型对象。要创建一个深复制,我们需要克隆其内包含的引用类型,而且需要确知其Clone()方法支持深复制。无论哪种情况,我们都没有必要为值类型添加ICloneable接口支持——赋值操作符可以创建任何值类型的新副本。
综上所述,对值类型来讲,提供 ICloneable接口的理由不够充分。下面我们来看引用类型。引用类型要通过支持ICloneable接口来表明自身支持浅复制或者深复制。但是在为一个类添加ICloneable接口支持时,我们要审慎行事,因为那样做会强制要求该类的所有派生类也都必须支持ICloneable接口。考虑下面两个 类:
class BaseType : ICloneable
{
private string _label = "class name";
private int [] _values = new int [ 10 ];
public object Clone()
{
BaseType rVal = new BaseType( );
rVal._label = _label;
for( int i = 0; i < _values.Length; i++ )
rVal._values[ i ] = _values[ i ];
return rVal;
}
}
class Derived : BaseType
{
private double [] _dValues = new double[ 10 ];
static void Main( string[] args )
{
Derived d = new Derived();
Derived d2 = d.Clone() as Derived;
if ( d2 == null )
Console.WriteLine( "null" );
}
}
如果运行上面的程序,我们将发现d2的值为 null。Derived类从BaseType类中继承了ICloneable.Clone()方法,但是继承来的实现对Derived类型来讲却是不正确的,因为它仅仅克隆了基类。BaseType.Clone()创建了一个BaseType对象,而非一个Derived对象。这就是测试程序中d2返回 null的原因——它不是一个Derived对象。但是,即使我们能够克服这个问题,BaseType.Clone()也不能对Derived中定义的 _dValues数组进行正确的复制。当我们的类型实现了ICloneable接口,就会强制要求其所有派生类也实现ICloneable接口。实际上, 这时候我们应该提供一个挂钩函数(hook function)来允许所有派生类使用我们的实现(参见条款21)。为了支持克隆,派生类只可以添加那些支持ICloneable接口的值类型或引用类型成员变量。这对所有的派生类来说是一个非常严格的限制。因此我们说,为基类添加ICloneable接口支持通常会为其派生类带来一些负担,所以我们应该避免在非密封(nonsealed)类中实现ICloneable接口。
如果整个类层次必须实现ICloneable接口,我们可以创建一个抽象的Clone()方法,并强制要求所有的派生类实现它。
这时候,我们需要定义一种方式,使派生类可以创建基类成员的副本。这可以通过定义一个protected的复制构造器来实现:
class BaseType
{
private string _label;
private int [] _values;
protected BaseType( )
{
_label = "class name";
_values = new int [ 10 ];
}
// 供派生类用来做clone。
protected BaseType( BaseType right )
{
_label = right._label;
_values = right._values.Clone( ) as int[ ] ;
}
}
sealed class Derived : BaseType, ICloneable
{
private double [] _dValues = new double[ 10 ];
public Derived ( )
{
_dValues = new double [ 10 ];
}
// 使用基类的“复制构造器”构造一个副本。
private Derived ( Derived right ) : base ( right )
{
_dValues = right._dValues.Clone( ) as double[ ];
}
static void Main( string[] args )
{
Derived d = new Derived();
Derived d2 = d.Clone() as Derived;
if ( d2 == null )
Console.WriteLine( "null" );
}
public object Clone()
{
Derived rVal = new Derived( this );
return rVal;
}
}
在上面的代码中,我们的基类BaseType没有实现ICloneable接口,但它提供了一个受保护的复制构造器,以使派生类可以复制其内的成员。如果有必要,“叶子类”——即那些密封类——可以实现 ICloneable接口。我们的基类没有强制要求所有的派生类实现ICloneable接口,但它为所有希望实现ICloneable接口的派生类提供了必要的方法支持。
ICloneable 接口有其价值所在,但那都是特例,而非普遍的规则。对于值类型来讲,我们永远都不需要支持ICloneable接口,使用默认的赋值操作就可以了。我们应该为那些确实需要复制操作的“叶子类”提供ICloneable接口支持。对于那些子类可能需要支持ICloneable接口的基类,我们应该为其创建一 个受保护的复制构造器。除此之外,我们应该避免支持ICloneable接口。
条款28:避免强制转换操作符
转换操作符为类之间引入了一层“替换性(substitutability)”。“替换”意味着一个类的实例可以被替换为另一个类的实例。这对我们来说可以是一种好处:一个派生类的对象可以被当做一个基类对象来使用。
例如在经典的Shape类层次中,我们可以创建一 个Shape(形状)基类,并派生出许多子类:Rectangle(长方形)、Ellipse(椭圆)、Circle(圆)等。在任何需要Shape的地方,我们都可以使用一个Circle子类来替换。替换得以实现是因为多态发挥的作用,因为Circle是一个更为具体的Shape类型。当我们创建一个类时,某些转换会自动奏效。例如,任何对象都可以被当作System.Object实例来使用,因为System.Object是整个.NET类层次中的根类。类似地,任何类的对象都可以被隐式地当作它所实现的一个接口或者它的基类来使用。另外,C#语言还支持许多数值转换。
在为我们的类型定义了转换操作符之后,我们实际上是在告诉编译器这些类型可以被当作目标类型来使用。这样的替换经常会导致一些很诡异的bug,因为我们的类型可能并不是目标类型的完美替换品。例如,更改目标类型状态的结果可能并不会反应到我们的类型上。更糟糕的是,如果我们的转换操作符返回一个临时对象,更改的效应将仅限于临时对象,而且随后就会被丢弃变成垃圾对象。最后,转换操作符的调用规则是基于对象的编译时类型,而非运行时类型。为此,类型的使用者可能要执行多次强制转型来调用转换操作符,这样的做法会导致难以维护的代码。
如果希望将一个类型转换为另一个类型,我们应该使用构造器。这种做法更清楚地反映了创建新对象的行为。而转换操作符会为代码引入很难发现的问题。假设我们获得了一个如图3-1所示的类库的代码。其中 Circle类和Ellipse类都派生自Shape类。我们决定保留这个类层次不动,因为虽然Circle和Ellipse有相关性,但是我们并不希望在类层次中出现非抽象的叶子类;并且当试图让Circle类派生自Ellipse类时,会遇到一些实现方面的问题。然而,我们总还是会认为每一个 Circle都可以是一个Ellipse。而且,某些Ellipse也可以被当作Circle来使用。
图3-1 图形类层次
这会导致我们添加两个转换操作符。由于每一个 Circle都是一个Ellipse,因此我们需要添加隐式转换操作符将一个Circle转换为一个Ellipse。当一个类型需要被转换成另一个类型才能正常工作时,隐式转换操作符就会被调用。与此相反,当我们在源代码中使用强制转型操作符时,显式转换操作符便会被调用。看下面的代码:
public class Circle : Shape
{
private PointF _center;
private float _radius;
public Circle() : this ( PointF.Empty, 0 )
{
}
public Circle( PointF c, float r )
{
_center = c;
_radius = r;
}
public override void Draw()
{
//……
}
static public implicit operator Ellipse( Circle c ) //通过构造器隐式转换为另外一个对象
{
return new Ellipse( c._center, c._center, c._radius, c._radius );
}
}
有了隐式转换操作符之后,我们就可以在任何需要Ellipse的地方使用Circle对象。而且,这样的转换会自动发生:
public double ComputeArea( Ellipse e )
{
// 返回椭圆的面积。
}
// 调用:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
ComputeArea( c );
上面的例子展示了所谓的“替换”:在本来要求Ellipse的地方使用了Circle。替换后,ComputeArea()函数工作得很好,这很幸运。但是,对于如下的函数:
public void Flatten( Ellipse e )
{
e.R1 /= 2;
e.R2 *= 2;
}
// 使用Circle来调用:
Circle c = new Circle( new PointF ( 3.0f, 0 ), 5.0f );
Flatten( c );
就有问题了。Flatten()方法接受一个Ellipse作为参数。编译器必须将Circle转换为Ellipse。这便是我们上面创建的隐式转换操作符的工作。
隐式转换操作符调用后会创建一个临时的 Ellipse对象,然后传递给Flatten()函数作为参数。这个临时的Ellipse对象会被Flatten()函数修改,然后就变成垃圾对象。 Flatten()函数只是在临时的Ellipse对象上显现了一些副作用。结果是真正的Circle对象c什么都没有发生。
将隐式转换操作符更改为显式转换操作符只会强制用户添加一个转型动作:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
Flatten( ( Ellipse ) c );
原来的问题并没有解决。强制用户添加转型动作会导致同样的问题——仍然会创建临时对象,将临时对象变平(flatten),然后将其丢弃,而Circle对象c根本没有得到任何更改。然而,如果我们通过创建一个构造器来将Circle转换为Ellipse,那么下面代码的行为就很清晰了:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
Flatten ( new Ellipse( c ));
绝大多数程序员看到上面两行代码,立刻就会明白Flattern()中对Ellipse的任何更改都会丢失。这样自然就会对Ellipse对象保持一个追踪:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
// 处理Circle。
// ……
// 转换为一个Ellipse。
Ellipse e = new Ellipse( c ); //可以访问Ellipse类的内部变量, 会给类的封装性带来严重的漏洞
Flatten( e );
变量e中保存着变平的Ellipse。通过使用构造器来代替转换操作符,我们不但没有丢失任何功能,反而使得新对象的创建工作变得更加清晰。(对于那些C++老手来说,需要注意C#不会将构造器用做隐式或者显式的转换。只有在显式使用new操作符时,C#才会创建新对象,没有任何例外。因此C#的构造器上不需要使用explicit关键字。)
在转换操作符中返回对象内部的字段,虽然不再出现上述行为,但是它们会带来其他问题——会给类的封装性带来严重的漏洞。因为如果将我们的类型强制转换为其他类型,类的客户程序就可以访问类的内部变量。不管有什么理由,都应该竭力避免这种做法,参见条款23。
综上所述,转换操作符所获得的“替换性”会为代码带来一些问题。提供转换操作符的意思是在向类的用户表明,在本该使用这个类的地方用户可以用其他的类来代替。当访问被替换的对象时,和客户程序打交道的实际上是一些临时对象或者内部字段。这些临时对象被修改之后,结果就会被丢弃。这样诡异的bug是很难发现的,因为进行类型转换的代码是由编译器产生的。因此我们应该避免使用转换操作符。
条款29:只有当新版基类导致问题时才考虑使用new修饰符
我们一般在类成员上使用new修饰符,来重新定义继承自基类的非虚成员。我们可以这么做并不意味着我们就应该这么做。重新定义非虚方法会导致含混不清的行为。例如,对于下面的代码,绝大多数开发人员都会不假思索地认为它们的行为是一样的(假设两个类有继承关系):
object c = MakeObject( );
// 通过MyClass引用调用:
MyClass cl = c as MyClass;
cl.MagicMethod( );
// 通过MyOtherClass引用调用:
MyOtherClass cl2 = c as MyOtherClass;
cl2.MagicMethod( );
如果使用了new修饰符,情况就不是这样了:
public class MyClass
{
public void MagicMethod( )
{
// 忽略细节。
}
}
public class MyOtherClass : MyClass
{
// 重新定义MagicMethod。
public new void MagicMethod( )
{
// 忽略细节。
}
}
这种做法会导致许多含混不清的地方。如果在同样的对象上调用同样的函数,我们期望同样的代码被执行。但事实是,如果我们更改了用来调用函数的引用,函数调用的行为也将有所不同。这种不一致的行为看上去很荒唐。一个MyOtherClass对象,由于对它的引用不同,而有不同的行为。修饰符new并不会将一个非虚方法变为一个虚方法。相反,它会在类中添加一个不同的方法。
非虚方法为静态绑定。任何引用MyClass.MagicMethod()的源代码调用的都将是该方法。系统不会在运行时寻找派生类中定义的其他版本。另一方面,虚函数使用的是动态绑定。系统会根据对象的运行时类型来选择调用正确的函数。
避免使用new修饰符来重定义非虚函数,并非意味着我们要将基类中所有的函数都定义为虚函数。当程序库的设计者将一个函数定义为虚函数时,实际上是为类型订立了一项合同:即表明任何派生类都可以更改虚函数的实现。事实上,虚函数集合定义了派生类中所有可能改变的行为。“默认为虚”的设计表明派生类可以更改类的所有行为。这意味着我们没有仔细思考派生类到底会更改哪些部分的行为。我们不应该这么做。相反,我们应该花费时间仔细考虑应该将哪些方法和属性声明为多态成员。我们应该仅将它们声明为虚成员。不要认为这种做法是对类的用户的限制。相反,应该将这种做法当作是在为定制类型行为提供一些入口点。
仅有一种情况我们需要使用new修饰符,那就是在我们使用新版的基类后,其增添的方法名和子类中现在已经被使用的方法名冲突。因为已经有代码在依赖子类中现有的方法名了,比如可能有其他程序集在使用这样的方法。例如,我们通过继承另外一个程序库中定义的BaseWidget,定义了新的MyWidget类:
public class MyWidget : BaseWidget
{
public void DoWidgetThings( )
{
// 忽略细节。
}
}
假设我们完成了MyWidget之后,已经有客户在使用它。然后我们发现BaseWidget公司又发布了一个新版的BaseWidget。由于对其中的新功能抱有热切的期待,我们立即购买了它,并试图生成新版的MyWidget。可是,生成的时候失败了,原因在于BaseWidget添加了自己的DoWidgetThings方法。
public class BaseWidget
{
public void DoWidgetThings()
{
// 忽略细节。
}
}
这是一个问题,我们的基类悄无声息地在其内引入了一个和子类同名的方法。有两种修正该问题的方法。首先,我们可以更改DoWidgetThings方法的名字:
public class MyWidget : BaseWidget
{
public void DoMyWidgetThings( )
{
// 忽略细节。
}
}
或者,我们可以使用new修饰符:
public class MyWidget : BaseWidget
{
public new void DoWidgetThings( )
{
// 忽略细节。
}
}
如果能访问到MyWidget类的所有客户程序代码,我们应该选择更改方法的名字,因为这在长期来讲比较方便。但是,如果我们的MyWidget类发行遍布全世界,那将迫使所有客户做繁多的更改。这就是 new修饰符的用武之地了。我们的客户可以继续使用DoWidgetThings()方法而无需做任何更改。他们也不会调用 BaseWidget.DoWidget- Things(),因为这样的调用在客户代码中不可能存在。修饰符new正是应用于这样的场合:新版的基类增添的成员与子类中先前已经声明的成员发生了冲突。
当然,随着时间的推移,我们的用户可能也会试图去使用BaseWidget.DoWidget- Things()方法。这时候我们又回到了原来的问题上:两个方法看起来相同,但实际上不同。因此,我们应该考虑new修饰符所带来的长期不良影响。有时候,短期内更改方法名所导致的不方便可能仍然是值得的。
综上所述,使用new修饰符必须小心。如果不分青红皂白地使用,便会在对象上出现含混不清的方法调用。只有在“新版的基类增添的成员与子类中已存在的成员发生了冲突”这样特殊的情况下,我们才应考虑使用 new修饰符。即使在这种情况下,在使用它之前我们也要慎重考虑。除此之外,我们不应该再在任何其他情况下使用new修饰符。
[1]. 即使用属性的代码。——译者注
[2]. 即声明的同时初始化。——译者注
[3]. 译者认为这里的“运行时版本”应为“运行时常量”。——译者注
[4]. 有关转换操作符,参见条款28。——译者注
[5]. 前提是st的实际类型,即运行时类型要一致。——译者注
[6]. 应该为InvalidCastException。——译者注
[7]. .isinst是as和is操作符编译为IL代码后,执行类型比较的关键指令。——译者注
[8]. 这里的不变式是指为了确保类或者对象的本质不会被破坏,而采用一套对其自身状态进行校验的条件。——译者注
[9].这种说法不够准确,Java中预定义的一些数值类型和字符类型仍然为值类型,只是用户自定义的类型只能为引用类型。——译者注
[10].严格来讲这种方式是在“以传值的方式”来传递引用类型,和传引用还有所区别。——译者注
[11].同样,这里准确的说法应该是自定义类型的变量,因为Java中预定义的值类型变量不会分配在堆上,也没有垃圾收集负担;下同。——译者注
[12].严格来讲,除了两倍大小的MyType外,引用类型C的实例对象还需要8个字节的存储空间用于存储方法标指针和SyncBlockIndex;下同。——译者注
[13]. 这里的类型指电话的类型,比如家庭电话或办公电话等。——译者注
[14].二进制意义上的0,即如果为引用类型,就是地址为0。——译者注
[15].因为无论我们是否提供构造器,每一个值类型都必然有一个默认的构造器,该默认构造器会将其内所有成员初始化为0。——译者注
[16]. 这里似为作者笔误,对于operator==应该是重载而非重写,即overload而非override;下同。——译者注
[17]. 这里似为笔误,应为ValueType。——译者注
[18]. 这里为作者笔误,应该为base.Equals方法。——译者注
[19].这里的说法并不准确,值类型默认情况下并没有定义operator==()操作符,因此应该说“考虑定义”而非“重定义”;下同。——译者注
[20].这里似为作者笔误,静态方法只可能隐藏,不可能重写,因此准确的说法应该是“自己提供”而非“重写”。
——译者注
[21].这里并非重载而是重写,即override而非overload,GetHashCode()是一个虚方法。——译者注
[22].这里的说法不够准确,这里“对象相等”的含义应该由Object.Equals虚方法定义,而非operator==定义;下同。——译者注
[23].即引用相等。——译者注
[24].所有本章中谈到的ValueType类型的operator==都是不准确的,应该为ValueType.Equals方法。因为ValueType默认并没有提供operator==。——译者注
[25]. 这里的说法并不正确,实现IEnumerator接口并不能使一个类成为一个集合类。另外IEnumerable接口中包含的就是 GetEnumerator方法,但我们也可以不实现IEnumerable接口,而只提供一个GetEnumerator方法来支持foreach语 句。因此作者在这里将提供GetEnumerator方法与实现IEnumerable接口作为两种不同的方式来归纳。——译者注
[26].这里的using语句会转化成一段包含try/finally的资源管理代码,具体可以参见本书条款15。——译者注