使用抽象類和接口的優解


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. 印象中的書籍若干


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM