Effective C# Item19: Prefer Defining and Implementing Interfaces to Inheritance
抽象基类为派生类提供继承的祖先,而接口则描述实现它的类型的行为功能。它们各有功能,二者并不相同。接口是一种约定式的设计:一个类型在实现一个接口时必须实现接口中所定义的所有方法。抽象基类则是提供关联类型的一个普通抽象。简单来说继承代表了“is a”的关系,而接口代表了“behaves like”的关系。它们分别描述了两种不同的结构:抽象基类描述这是个什么对象,而接口描述这个对象的行为是怎样的。
接口描述的是一种机制和约定。我们可以在接口中创建方法,属性,索引和时间。任何实现接口的类型必须完全实现接口中所有的元素。我们必须实现接口中对应的所有方法,提供所有的属性和索引,定义所有的事件。通过接口我们定义并构建了可重用的行为规范。不相关联的类可以实现相同的接口,这使得我们可以减少代码量。另外,相比从基类派生子类来说,实现接口来得更加容易。
在接口中我们不能具体实现类的任何成员,也不能包含任何具体数据。通过接口,我们声明的只是一个必须被实现该接口类型支持的约定。
抽象类可以为其派生类提供具体成员的实现,也可以描述一般的行为。我们可以在抽象类中定义具体的数据成员,方法,属性,事件和索引。这些成员可以是虚拟的、抽象的,也可以是普通的成员。抽象类可以提供接口不能提供的行为的具体实现。
抽象类这种可具体实现的重用方式提供了另一种便利:如果我们在基类中添加了一个方法,所有的派生类也就自动获取了这个新方法。对于接口,如果我们添加一个成员,那么所有实现该接口的类都需要修改,没有实现新成员的类将无法通过编译。
抽象类还是接口?这个问题的关键在于哪一个可以更好的支持我们的结构和模式。接口是固定的,它是功能上的一种约定。抽象基类有扩展性,这些扩展可以反映到派生类中。
当类型需要支持多个接口时,可以通过混合上述两种模式来实现代码复用。一个典型的例子就是System.Collections.CollectionBase。这个类为.Net中的强类型集合提供了抽象,其中实现了IList,ICollection和IEnumerable接口。另外它还提供了一些受保护的方法,我们可以根据不通的用途重写这些方法来实现我们自定义的行为。Ilist接口中包含了Insert()方法,我们可以通过它来向集合中添加新对象。当我们要对添加对象的行为进行处理的时候,应当重写OnInsert()或者OnInsertComplete()方法,而不是实现自己的Insert方法。
这段代码创建了一个整型的数组并通过IList接口为期添加了两个值。通过重写OnInsert()方法,IntList类可以检查输入类型是否正确并对不为整型的输入抛出异常。基类为我们的派生类提供了默认的实现方法和特定行为的钩子。
作为基类的CollectionBase为我们的类型提供了一系列具体的实现。我们不必再单独实现这些操作,而可以使用基类提供的一般实现方法。CollectionBase提供了我们可重用的对接口的一般实现。
接口可以作为参数和返回值来使用。一个接口可以被任意个不相关联的类实现。相比基类来说,接口为开发者提供了更加灵活的开发方式。这一点很重要,因为在.Net环境中只支持类的单继承。
下面两个方法完成的是相同的工作:
第二个方法的可重用性很差。当参数为Array,ArrayList等类型的时候就不能使用。使用接口为方法传递参数可以使方法更加灵活,也易于重用。
使用接口为可以为我们的类型提供更大的灵活性。例如程序中使用DateSet来在应用程序模块之间传递数据。我们可以很简单的写出下面的代码
这样做会为我们的程序留下弱点。当我们一旦需要使用DataSet以外的数据源,例如DataTable,DataView或者我们自定义的类型时,这个方法就不能再使用了。当然我们可以通过改变参数类型的方法来适应这些更改,但是这种修改就意味着修改了类型的公有接口。这种改动可能会带来整个系统的大幅度修改:所有涉及到使用这个方法的地方都需要进行修改。
第二个问题则更加明显:DataSet类提供了一系列用来修改其内部数据的方法。通过这些方法,使用这个类型的用户可能会做一些我们不希望的操作,例如删除DataTable或修改表中的列。幸运的是,我们有限制用户在我们类型中的行为的办法。我们不必返回一个DataSet的引用,而是以接口取而代之。DataSet支持IlistSource接口,它返回可以绑定到数据源的列表。
客户程序通过IListSource接口的GetList()方法可以察看其中的项目。通过ContainsListCollection属性可以获得当前集合的整体结构信息。通过IListSource接口我们可以操作DataSet中的每一个数据项项,但是不能修改DataSet的整体结构。同样,DataSet的那些会改变其约束和行为的方法也就不能被调用了。
当我们的类暴露某个类型的属性时,实质上是暴露了一个对应接口的入口。使用接口我们可以仅暴露出我们希望客户程序使用的方法和属性。
此外,没有关系的类也可以继承同样的接口。假设我们现在正在构建一个用来管理雇员,客户和卖主的应用程序。在类继承关系上,它们三者是没有任何联系的。但是一些普遍的功能是他们有所公有的,比如说它们都有姓名,而且我们要将其姓名显示在应用程序中的控件上。
Employee,Customer和Vendor类并没有一个可以共享的基类。但是它们有一些属性是共有的:姓名,地址和联系电话。我们可以将这些属性集合到一个接口中:
通过这个新的接口,我们为这些没有关联关系的类型构建了一个基本的规则,简化了编写程序的工作:
所有实现了IContactInfo接口的类型的对象都满足这种规则。Customer,Employee和Vendor都使用同样的规则,因为我们是通过同一个接口构建它们的。
有时,通过接口我们也可以减少结构体的拆箱工作。当我们将一个结构体装箱后,箱还支持被装箱结构体支持的所有接口。当我们通过接口来访问结构体中的成员时,我们不必为此而进行拆箱操作。下面的例子中,我们定义一个包含了链接和描述两个字符串成员的结构体:
因为URLInfo实现了IComparable接口,我们可以创建一个有序的List用来保存URLInfo结构的对象。当它们被添加到List中时发生了装箱操作。但是当我们调用Sort()方法进行排序时,不必对等式左右两边的对象都进行拆箱操作再调用CompareTo()方法。当然我们获得other参数时需要进行一次拆箱,但是等式左边是可以通过调用IComparable接口来进行比较的,没有发生拆箱操作。
基类可以为相互关联的类型提供具体的行为描述。接口则是提供一种可以被不相关类型实现的一组功能。它们各有所长。通过合理使用可以让我们的类型在面对改变时有更强的弹性。
译自 Effective C#:50 Specific Ways to Improve Your C# Bill Wagner著
回到目录