条款19:定义并实现接口优于继承类型 - 《Effective C#中文版:改善C#程序的...

来源:百度文库 编辑:神马文学网 时间:2024/04/28 05:11:12
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
{
private 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实现了IComparable接口,因此我们可以创建一个URLInfo对象的排序链表。将URLInfo结构添加到链表中时,它会被装箱。但是Sort()方法不需要对排序过程中需要比较的两个对象进行拆箱,即可调用CompareTo()方法。当然,我们仍然需要对其中作为参数的那个对象(other)进行拆箱,但是对于调用IComparable.CompareTo()方法时左边的那个对象,则不需要拆箱。
综上所述,基类描述并实现了一组相关类型间共用的行为。接口则描述了一组比较紧凑的功能,供其他不相关的具体类型来实现。二者都有自己的用武之地。类定义了我们要创建的类型。接口以功能分组的形式描述了那些类型的行为。如果理解好二者之间的差别,我们便可以创建更富表现力、更能应对变化的设计。应该使用类层次来定义相关的类型,然后让它们实现不同的接口,以便通过接口向外界提供功能。