首页 > 代码库 > 使用抽象类和接口的优解

使用抽象类和接口的优解

  • 1. 前言
  • 2. 所谓习惯认知
  • 3. 开门见山的万金油
      • 第1条:抽象类设计注重对象性,接口设计注重服务性
      • 第2条:更近的抽象类,更远的接口
      • 第3条:子类间有关系时考虑用抽象类,没有关系时一定要用接口
      • 第4条:版本迭代中优先考虑使用抽象类而不是接口
  • 4. 设计是个性的妥协
  • 5. 参考资料

1. 前言

笔者相信,每个使用面向对象语言的开发者自编码以来,肯定便琢磨过抽象类(Abstract)和接口(Interface)的区别。可能一些人已经找到了适合自己的方式,另一部分却仍然深陷泥沼,每次在说服自己用interface代替abstract class的时候都要使出全身的力气。本篇文章便是笔者从自身体会出发,提出一些关于抽象类和接口使用的优解。假如能对大家有所帮助,那写作的初衷便已经满足了大半。

正如笔者过去的一篇文章《使用HttpClient的优解》的标题所示,即使是谈论编码和类型设计的正确性,笔者也不会大言不惭地说自己的想法和实践便是最佳的,毕竟在现实生活中一个问题的解决方法可能有很多,所以曾经或以后表述在文章中的任何观点,也都只会是在笔者看来的一些“优解”。不过有个免责声明是,假如在未来出现了一个问题只对应一种解决方法的情况,我自然也会不害臊地说一声“最佳实践”是也。所以人生呐,不正是和下围棋类似吗,都是在寻求所谓的“神之一手”而已。

2. 所谓习惯认知

当我们一谈起如何区别使用抽象类和接口时,在大多时候,我们总从别人的口中得到类似于以下的答案:

  1. 抽象类中的方法可以有自己的默认实现,而接口中是没有的(JAVA8中是有接口的默认方法实现的,但是我觉得并不理想,反而是个十分混淆视听的特性)
  2. 抽象类是单继承实现,而接口可以实现多重继承
  3. 接口更多的时候代表一种契约,其中规定了继承于它的子类必须完成的动作
  4. 抽象类注重“IS-A”关系,接口注重“HAS-A”关系
  5. 当我们关注“一个对象是什么”的时候,我们需要使用抽象类;当我们关注“一个对象可以做什么”的时候,我们需要使用接口类。
  6. ……

在笔者看来,除了第4和第5点有部分指导意义外,其他几句(我们还可以凭感觉和经验扩充很多出来)其实只是动听的“废话”而已。

这么讲还是有些心虚,毕竟后文还是会针对这几点做一些讲解。特别是第4和第5点将几乎始终贯穿于我们的讲解内容中。

实际上,这些答案确实都很正确,只是似乎它离我们真正想要的回答仍然有些距离。笔者记得小时候向不同的老师请教题目时,有些老师会给出一些方向性的指教,有些老师则会很直截了当的解答和延伸,前者便有些像上述观点的表现——热情,礼貌,但一问三不知……呸,说错了——其实是美其名曰“解题思路”。而大多数人呢,真正需要的并不是各种博客或者面向对象设计书籍中的模棱两可的方向,而是隐隐期待所谓“万金油式”的良方。那这样的“万金油”到底有没有呢?如果到目前为止,你仍然对我的文章抱有将信将疑的认可而不是一口否决时,那就继续往下读吧,说不定便能寻觅到这剂良方。但我不能确保我认为的“优解”便一定就是“万金油”,而不是只是一般的“解题思路”。

此之蜜糖,彼之砒霜。

3. 开门见山的万金油

第1条:抽象类设计注重对象性,接口设计注重服务性

其实这条原则只是第4和第5点的详细说明而已。举一个简单的例子,笔者为了保家卫国决定去服兵役,在经过训练后,我成为了一名步兵(Infantry Abstract Class),具体的职位是一名突击士兵(Commando Concret Class)。但是战争来临,突击队员也被赋予了更多的任务,突击士兵临时有了警卫员的职责,所以笔者让Commando继承了IGuard。

在一般人眼里,警卫员Guard也可以是Abstract Class类型,但是在我们的例子中,它是被临时赋予的责任(或者说服务对象),所以设计成接口是比较合适的,否则也是违背了C#只能单一继承的设定。

如果大家去看看.NET的BCL框架,你会发现部分接口是“I”+形容词|动词的形式。比如 IDisposable,IEnumerable,IComparable ,ICompare等,其实这正是服务型的一种体现,这种设计风格的接口指明实现该接口的类型是一种可XX服务,即表示为可释放的,可枚举的,可比较的。

但是也不要以为接口与我们常识中的对象就绝缘了,上述所言的内容只是一个比较明确设计原则而已。我们知道名词形式的接口形式也是很普遍的,常见的集合基类便都是“I”+名词的形式,IList,ICollection,它们在名字上就体现了其作为集合可以提供集合服务。

而抽象类呢?反正笔者是没见过除了名词以外的设计的。

所以当我们要设计一个飞行接口的时候,我们就知道了我们设计成 IFlyable。

第2条:更近的抽象类,更远的接口

在大多数关于设计模式的博客或者书籍中,Shape和Animal总是两个最受欢迎的对象。Shape的子类可以有Rectangle,Square,Circle,Animal的种类就更多了,比如Dog,Duck,Tiger,而对于Dog又有金毛(GoldenRetriever),泰迪(Teddy)等具体实现类。那么我们到底该如何设计这种具有多层层次的模型呢。笔者的建议便如原则所示,更近的抽象类,更远的接口

public interface IBarkable
{
    void Bark();
}

public interface IAnimal
{
    void GrowUp();
}

public abstract class Dog:IAnimal
{
    protected Dog(IBarkable barkBehavior)
    {
        BarkBehavior = barkBehavior;
    }

    protected IBarkable BarkBehavior { get;}

    public virtual void PerformBark()
    {
        BarkBehavior.Bark();
    }

    public virtual void GrowUp()
    {
        //code
    }
}

public class GoldenRetriever : Dog
{
    public GoldenRetriever(IBarkable barkBehavior) : base(barkBehavior)
    {
    }
}

public class Teddy :Dog {
    public Teddy(IBarkable barkBehavior) : base(barkBehavior)
    {
    }
}

以上的代码设计可以很直观地体现这一点,Dog和GoldenRetriever,Teddy更近,即可以认为“IS-A”的关系更强烈,而Animal和Dog以及GoldenRetriever的关系都太远了(Animal和Dog间省略了太多的关系,有兴趣的读者不妨去翻翻生物书),所以Animal被设计为接口,Dog和GoldenRetriever分别设计成抽象类和接口。而IBarkable接口只是一点小小的调剂,做为狗叫的表现服务组合到了我们的Dog类中,毕竟有些狗是不叫的(是否想起了熟悉的鸭子嘎嘎叫设计),我们必须把这种变化从类型中封装出来。

但是,我们知道Dog仍然 “IS-A” Animal,不是吗?

值得多说一句的是,针对本节的代码,我们其实可以抽离出一种很棒的普适设计原则。

IYourService
abstract YourServiceBase : IYourService
YourServiceImpl1 : YourServiceBase
YourServiceImpl2 : YourServiceBase
YourServiceImpl3 : IYourService

在这种设计中,实现类既可以继承实现YourServiceBase(通用方法和属性的默认属性,类似于模板方法),也可以直接继承实现IYourService,这提供了很好的灵活性。

第3条:子类间有关系时考虑用抽象类,没有关系时一定要用接口

第3条其实只是对第2条原则的补充而已,请原谅笔者这种凑字数的不道德行为。

在第2条原则中,我们提到了“关系”这个词语,这对于设计来说是一个关键。比如GoldenRetriever和Teddy都是狗,可能会因为存在种间隔离而不能繁殖,但是它们总可以很和谐地在草地上玩耍(比如说后者总是想趴在前者身上?)。而且因为Dog被定义为抽象类,我们可以让一些通用的方法和属性被具体的Dog类继承,甚至还可以使用模板方法设计模式!!!。反之,我们也可以这么说,当抽象类中没有默认实现时,除了满足语义上的需要,抽象类一文不值(嗯……笔者曾考虑把这句话当作原则之一)。

笔者很想在每个以“甚至”开头的句子末尾处用感叹号,只是担心这样会让自己显得有点老。

另外,在笔者看来,番茄炒蛋中的番茄除了组成这个名字,也是一文不值的。

而我们设计接口的时候是怎么考虑的呢——只是考虑多重继承,服务性还有减少框架设计和迭代时的苦果吗?除此以外,是不是还要考虑下子类间的关系呢。比如一个日志基类,我们可以很自然地将其定义为ILogger接口。这样的常识可以用上述的第1条服务性原则来证实。

public interface ILogger
{
    void Debug(Exception exception, string messageTemplate);

    void Debug<T>(Exception exception, string messageTemplate, T propertyValue);
}

当我们观察它的子类时,我们可能会发现它提供了很多存储实现,比如数据库,txt,xml。如果这个库足够好,那么它提供的存储源也将足够多。那这些存储源之间有关系吗?看着好像有但是完全却不如Dog及其子类那么直观(比如说未来可能推出的日志互导功能),我们可以自然而然地认为它们都是独立存在的。这有些吹毛求疵,但是笔者还是希望大家能感觉到其中的区别。而作为该日志库的使用者,我们好像也丝毫不关心它的实现之间的联系。

第4条:版本迭代中优先考虑使用抽象类而不是接口

不知道这条原则是不是和大多数人心中对于抽象类和接口设计的原则产生了冲突——明明该优先考虑定义接口的吧,毕竟多重继承怎么都不会出错!对于这样的读者,不如先看看这条原则的详细内容再做考虑。

首先让我们来想想接口设计时的主要缺点吧——

接口没有内部的默认实现,所以模板方法这一强大但容易让人迷糊的设计模式便不能使用了。除此以外呢,如果想让API初步迭代,那么它的灵活性不如抽象类。(框架设计中)一旦对外发布了一个接口,它的成员就永远固定了,给接口添加任何东西都会破坏那些实现了该接口的已有类型。而对于抽象类就没有这样的苦恼,只要添加的方法不是抽象的就可以。

让我们举一个由官方改造而来的小例子说明一下,当我们在自己的开源框架第一版实现了一个抽象类FileReader(它可以读取不同File的内容),我们可以对其实现XMLReader,JsonReader等,但是很可惜,在第一版中没有提供对未完成的I/O读取操作设置超时时限的支持,比如ReadTimeout属性。于是我们痛定思痛,在第二版中加上了相关内容。

public abstract class FileReader
{
    public virtual int ReadTimeout
    {
        get => throw new InvalidOperationException();
        set => throw new InvalidOperationException();
    }
}

public class XMLReader : FileReader
{
    public override int ReadTimeout{ 
        get { 
            ……
        } 
        set {
            ……
        } 
    }
}

那如果我们一开始把FileReader设计成接口IFileReader呢?如果我们需要逐步演化基于API的接口,唯一方法就是添加有额外成员的接口。比如我们需要在第二版中针对超时设置增加一个ITimeOutFileReader接口,然后让XMLReader继承这个接口。

public interface ITimeoutEnabledFileReader : IFileReader
{
    int ReadTimeout { get; set; }
}

public class XMLReader : ITimeoutEnabledFileReader
{
    public int ReadTimeout { 
        get { 
            ……
        } 
        set {
            ……
        } 
    }
}

目前为止,一切都很正常,但是对于那些使用及返回IFileReader的已有API,就有了问题。比如有一个A类。

为每个接口提供至少一个使用该接口的API也是一个必要的设计准则

public class A
{
    public A(IFileReader fileReader)
    {
        ……
    }

    public IFileReader BaseFileReader {
        get {…… }
    }
}

那么怎么让A支持ITimeoutEnabledFileReader接口?其实还是有几种方法的,只是每种的成本都比较高,比如在使用BaseFileReader的时候进行动态类型转换成ITimeoutFileReader;也可以添加一个名为TimeoutEnabledA的新类型,但这无疑增加了框架的复杂性,而且也将造成雪崩反应——那些使用A类型的API,是不是需要新的版本来操作新的TimeoutEnabledA呢。

而当FileReader为抽象类时,为第二版框架添加超时限制才成为了可能。

A a=new A(new XMLReader());
a.BaseFileReader.ReadTimeout=100;

综上所述,在框架设计迭代时我们需要优先考虑抽象类(如果可能要迭代的话),而不是接口。除了多重继承,接口能做的事情,抽象类也完全可以代劳,甚至能因为通用方法和属性实现而做得更好。即便在语义上,接口代表的是一种契约关系,但是设计良好的抽象类难道不能承担契约的责任吗?

不过仍然需要注意的是,这个例子是以提供外部服务的框架开发来作为例子,而在面向业务开发的过程中是不是也是如此?答案也是一样,正如第1条原则中举的例子,我们的聚合中心是Infantry和Commando,而在不断的迭代过程中,接口不断以服务的形式加入我们的抽象对象中,过程也仍然是运行良好。

多用组合,少用继承其实能很大程度上避开迭代时的坑。

我们在领域驱动开发中常常会接触到面向接口编程的概念,但是此接口却非彼接口,而是超类。抽象类是超类,接口也是超类,所以千万不要以为面向接口编程就是面向语言中的Interface编程

4. 设计是个性的妥协

不像其他的妖艳贱货,本文章在最后不提供使用总结,请诸位仔细阅读以上文章并提出批判

其实已经没有更多的话值得留在这一节了,但是笔者还想再发几句牢骚。如本文开头所讲,类型设计是个永恒的老问题,即便在前几十年中网络中已经有关于这方面的大量著作文章,在以后的日子里,对类型设计的讨论也依然会不绝如缕。在笔者看来,类型设计是个个人色彩很强的词汇,但却由于它属于面向对象这种大的框架里,个性便永远也要与那些固有的正确原则做妥协,这是无奈的地方,却又是精彩的地方。笔者资历尚浅,在本篇文章中阐述的所有观点其实也只是大规则下的个人体会而已,所以如果有异议或者其他好的想法,希望不吝赐教。

最后——唯有写与思,方解其中味。

5. 参考资料

  1. Interface vs Abstract Class (general OO)
  2. When to use an interface instead of an abstract class and vice versa?
  3. Interfaces and Abstract Classes
  4. Framework 设计准则
  5. 印象中的书籍若干

使用抽象类和接口的优解