Effective C# 原则19:选择定义和实现接口,而不是继承

JerryXia 发表于 , 阅读 (1,968)

抽象类在类的继承中提供了一个常规的“祖先”。一个接口描述了一个可以被其它类型实现的原子级泛型功能。各有千秋,却也不尽相同。接口是一种合约式设计:一个类型实现了某个接口的类型,就必须实现某些期望的方法。抽象类则是为一个相关类的集合提供常规的抽象方法。这些都是老套的东西了:它是这样的,继承就是说它是某物(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类在添加类型时会检测类型,如果不是一个整数时,会抛出一个异常。基类给你实现了默认的方法,而且给我们提供了机会在我们自己的类中实现详细的行为。

CollectionBase这个基类提供的一些实现可以直接在你的类中使用。你几乎不用写太多的代码,因为你可以利用它提供的公共实现。但IntList的公共API是通过CollectionBase实现接口而来的:IEnumerable,ICollection和IList接口。CollectionBase实现了你可以直接使用的接口。

现在我们来讨论用接口来做参数和返回值。一个接口可以被任意多个不相关的类型实现。比起在基类中编码,实现接口的编码可以在开发人员中提供更强的伸缩性。因为.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( ) );
}

第二个方法的重用性远不及第一个。Array,ArrayList,DataTable,HashTable,ImageList或者很多其它的集合类无法使用第二个方法。让方法的参数使用接口,可以让程序具有通用性,而且更容易重用。

用接口为类定义API函数同样可以取得很好的伸缩性。例如,很多应用程序使用DataSet与你的应用程序进行数据交换。假设这一交流方法是不变的,那这太容易实现了:

public DataSet TheCollection
{
  get { return _dataSetCollection; }
}

然而这让你在将来很容易遇到问题。某些情况下,你可能从使用DataSet改为暴露一个DataTable,或者是使用DataView,甚至是使用你自己定义的对象。任何的改变都会破坏这些代码。当然,你可能会改变参数类型,但这会改变你的类的公共接口。修改一个类的公共接口意味着你要在一个大的系统中修改更多的内容;你须要修改所有访问这个公共接口的地方。

紧接着的第二个问题麻烦问题就是:DataSet类提供了许多方法来修改它所包含的数据。类的用户可能会删除表,修改列,甚至是取代DataSet中的所有对象。几乎可以肯定这不是你想要的。幸运的是,你可以对用户限制类的使用功能。不返回一个DataSet的引用,你就须要返回一个期望用户使用的接口。DataSet支持IListSource接口,它用于数据绑定:

using System.ComponentModel;

public IListSource TheCollection
{
  get { return _dataSetCollection as IListSource; }
}

IListSource让用户通过GetList()方法来访问内容,它同时还有ContainsListCollection属性,因此用户可以修改全部的集合结构。使用IListSource接口,在DataSet里的个别对象可以被访问,但DataSet的所有结构不能被修改。同样,调用者不能使用DataSet的方法来修改可用的行为,从而在数据上移动约束或者添加功能。

当你的类型以类的方式暴露一些属性时,它就暴露了这个类的全部接口。使用接口,你可以选择只暴露一部分你想提供给用户使用的方法和属性。以前在类上实现接口的详细内容,在后来是可以修改的(参见原则23)。

另外,不相关的类型可以实现同样的接口。假设你在创建一个应用程序。用于管理雇员,客户和卖主。他们都不相关,至少不存在继承关系。但他们却共享着某些功能。他们都有名字,而且很有可能要在一些Windows控件中显示他们的名字:

public class Employee
{
  public string Name
  {
    get
    {
      return string.Format( "{0}, {1}", _last, _first );
    }
  }

  // other details elided.
}

public class Customer
{
  public string Name
  {
    get
    {
      return _customerName;
    }
  }

  // other details elided
}

public class Vendor
{
  public string Name
  {
    get
    {
      return _vendorName;
    }
  }
}

Eyployee,Customer和Vendor类不应该共享一个基类。但它们共享一些属性:姓名(正如前面显示的那样),地址,以及联系电话。你应该在一个接口中创建这些属性:

public interface IContactInfo
{
  string Name { get; }
  PhoneNumber PrimaryContact { get; }
  PhoneNumber Fax { get; }
  Address PrimaryAddress { get; }
}

public class Employee : IContactInfo
{
  // implementation deleted.
}

对于不的类型使用一些通用的功能,接口可以简化你的编程任务。Customer,Employee,和Vendor使用一些相同的功能,但这只是因为你把它们放在了接口上。

使用接口同样意味着在一些意外情况下,你可以减少结构类型拆箱的损失。当你把一个结构放到一个箱中时,这个箱可以实现结构上的所有接口。当你用接口指针来访问这个结构时,你不用结构进行拆箱就可以直接访问它。这有一个例子,假设这个结构定义了一个链接和一些说明:

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的对象创建一个有序表,因为URLInfo实现了IComparable接口。URLInfo结构会在添加到链表中时被装箱,但Sort()方法不须要拆箱就可以调用对象的CompareTo()方法。你还须要对参数(other)进行拆箱,但你在调用IComparable.CompareTo()方法时不必对左边的对象进行拆箱。

基类可以用来描述和实现一些具体的相关类型的行为。接口则是描述一些原子级别的功能块,不相关的具体类型都可以实现它。接口以功能块的方法来描述这些对象的行为。如果你明白它们的不同之处,你就可以创建出表达力更强的设计,并且它们面对修改是有很加强的伸缩性的。类的继承可以用来定义一些相关类型。通过实现一些接口来暴露部份功能来访问这些类型。

添加新评论