「MoreThanJava」Day 5:面向對象進階——繼承詳解

  • 「MoreThanJava」 宣揚的是 「學習,不止 CODE」,本系列 Java 基礎教程是本身在結合各方面的知識以後,對 Java 基礎的一個總回顧,旨在 「幫助新朋友快速高質量的學習」
  • 固然 不論新老朋友 我相信您均可以 從中獲益。若是以爲 「不錯」 的朋友,歡迎 「關注 + 留言 + 分享」,文末有完整的獲取連接,您的支持是我前進的最大的動力!

Part 1. 繼承概述

上一篇文章 中咱們簡單介紹了繼承的做用,它容許建立 具備邏輯等級結構的類體系,造成一個繼承樹。html

Animal 繼承樹

繼承使您能夠基於現有類定義新類。 新類與現有類類似,可是可能具備其餘實例變量和方法。這使編程更加容易,由於您能夠 在現有的類上構建,而沒必要從頭開始。java

繼承是現代軟件取得巨大成功的部分緣由。 程序員可以在先前的工做基礎上繼續發展並不斷改進和升級現有軟件。git

面向對象以前,寫代碼的一些問題

若是你有一個類的源代碼,你能夠複製代碼並改變它變成你想要的樣子。在面向對象編程以前,就是這樣子作的。但至少有兩個問題:程序員

❶ 很難保持僅僅有條。github

假設您已經有了幾十個須要的類,而且須要基於原始類創造新的一些類,再基於新的類創造出更新的類,最終您將得到數十個源文件,這些源文件都是經過其餘已更改的源文件的另外版本。面試

假設如今在一個源文件中發現了錯誤,一些基於它的源文件須要進行修復,可是對於其餘源文件來講,並不須要!沒有細緻的寫代碼的計劃,您最終會陷入混亂....編程

❷ 須要學習原始代碼。後端

假設您有一個複雜的類,基本上能夠完成所需的工做,可是您須要進行一些小的修改。若是您修改了源代碼,即便是進行了很小的更改,也可能會破壞某些內容。所以,您必須研究原始代碼以確保所作的更改正確,這可能並不容易。設計模式

Java 的自動繼承機制極大地緩解了這兩個問題。app

單繼承

用於做爲新類模板的類稱爲 父類 (或超類或基類),基於父類建立的類稱爲 子類 (或派生類)

就像上圖中演示的那樣,箭頭從子類指向父類。(在上圖中,雲表示類,而矩形表示對象,這樣的表示的方法來自於 Grady Booch 寫的《面向對象的分析和設計》一書。而在官方的 UML-統一建模語言 中,類和對象都用矩形表示,請注意這一點)

在 Java 中,子類僅從一個父類繼承特徵,這被稱爲 單繼承 (與人類不一樣)

有些語言容許"孩子"從多個"父母"那裏繼承,這被稱爲 多繼承。但因爲具備多重繼承,有時很難說出哪一個父母爲孩子貢獻了哪些特徵 (跟人類同樣..)

Java 經過使用單繼承避免了這些問題。(意思 Java 只容許單繼承)

is-a 關係

上圖顯示了一個父類 (Video 視頻類),一個子類 (Movie 電影類)。它們之間的實線表示 "is-a" 的關係:電影是視頻。

注意,繼承是在類之間,而不是在對象之間。 (上圖兩朵雲都表明類)

父類是構造對象時使用的藍圖,子類用於構造看起來像父對象的對象,但具備附加功能的對象。

類之間的關係簡述

簡單地說,類和類之間的關係有三種:is-ahas-ause-a

  • is-a 關係也叫繼承或泛化,好比學生和人的關係、手機和電子產品的關係都屬於繼承關係;
  • has-a 關係一般稱之爲關聯,好比部門和員工的關係、汽車和引擎的關係都屬於關聯關係;關聯關係若是是總體和部分的關聯,那麼咱們稱之爲 聚合關係;若是總體進一步負責了部分的生命週期 (總體和部分是不可分割的,同時同在也同時消亡),那麼這種就是最強的關聯關係,咱們稱之爲 合成 關係。
  • use-a 關係一般稱之爲依賴,好比司機有一個駕駛的行爲 (方法),其中 (的參數) 使用到了汽車,那麼司機和汽車的關係就是依賴關係。

利用類之間的這些關係,咱們能夠在已有類的基礎上來完成某些操做,也能夠在已有類的基礎上建立新的類,這些都是實現代碼複用的重要手段。複用現有的代碼不只能夠減小開發的工做量,也有利於代碼的管理和維護,這是咱們在平常工做中都會使用到的技術手段。

層級結構

上圖顯示了一個父類和一個子類的 層次結構,以及從每一個類構造的一些對象。這些對象用矩形表示,以表達它們比設計的類更真實。

在層次結構中,每一個類最多有一個父類,但可能有幾個子類。 層次結構頂部的類沒有父級。此類稱爲層次結構的

另外,一個類能夠是另外一個子類的父類,也能夠是父類的子類。就像人類同樣,一我的是某些人類的孩子,也是其餘人類的父母。(但在 Java 中,一個孩子只有一個父母)

Part 2. 繼承的實現

從父類派生子類的語法是使用 extend 關鍵字:

class ChildClass extend ParentClass {
    // 子類的新成員和構造函數....
}

父類的成員 (變量和方法) 經過繼承包含在子類中。其餘成員將在其類定義中添加到子類。

視頻觀影 App 示例

Java 編程是經過建立類層次結構並從中實例化對象來完成的。您能夠擴展本身的類或擴展已經存在的類。Java 開發工具包 (JDK) 爲您提供了豐富的基類集合,您能夠根據須要擴展這些基類。

(若是某些類已經使用 final 修飾,則沒法繼承)

下面演示了一個使用 Video 類做爲基類的視頻觀影 App 的程序設計:

Video 基類:

class Video {

    private String title;   // name of video
    private int length;     // number of minutes

    // constructor
    public Video(String title, int length) {
        this.title = title;
        this.length = length;
    }

    public String toString() {
        return "title=" + title + ", length=" + length;
    }

    public String getTitle() { return title;}
    public void setTitle(String title) { this.title = title;}
    public int getLength() { return length;}
    public void setLength(int length) { this.length = length;}
}

Movie 電影類繼承 Video:

class Movie extends Video {

    private String director;// name of the director
    private String rating;  // num of rating

    // constructor
    public Movie(String title, int length, String director, String rating) {
        super(title, length);
        this.director = director;
        this.rating = rating;
    }

    public String getDirector() { return director; }
    public String getRating() { return rating; }
}

這兩個類均已定義:Video 類可用於構造視頻類型的對象,如今 Movie 類可用於構造電影類型的對象。

Movie 類具備在 Video 中定義的成員變量和公共方法。

使用父類的構造函數

查看上方的示例,在 Movie 類的初始化構造函數中有一條 super(title, length); 的語句,是 "調用父類 Video 中帶有 title、length 參數的構造器" 的簡寫形式。

因爲 Movie 類的構造器不能訪問 Video 類的私有字段,因此必須經過一個構造器來初始化這些私有字段。能夠利用特殊的 super 語法調用這個構造器。

重要說明:super() 必須是子類構造函數中的第一條語句。 (這意味子類構造器老是會先調用父類的構造器) 這件事常常被忽略,致使的結果就是一些神祕的編譯器錯誤消息。

若是子類的構造器沒有顯式地調用父類的構造器,將自動地調用父類的無參構造器。若是父類沒有無參數的構造器,而且在子類的構造器中又沒有顯式地調用父類的其餘構造器,Java 編譯器就會報告一個錯誤。(在咱們的例子中 Video 缺乏無參數的構造函數,故👆上面圖片代碼會報錯)

建立一個無參構造函數

關於構造函數的一些細節:

  1. 您能夠顯式爲類編寫無參數的構造函數。
  2. 若是您沒有爲類編寫任何構造函數,那麼將自動提供無參數構造函數 (稱爲默認構造函數)
  3. 若是爲一個類編寫了一個構造函數,則不會自動提供默認的構造函數。
  4. 所以:若是您爲類編寫了額外的構造函數,那麼,則還必須編寫一個無參數構造函數 (供子類調用)

在示例程序中,類 Video 包含構造函數,所以不會自動提供默認構造函數。 因此,Movie 類 super() 函數建議默認使用的構造函數 (會自動調用無參數構造函數) 會致使語法錯誤。

解決方法是將無參數構造函數顯式放在類中 Video ,以下所示:

class Video {

    private String title;   // name of video
    private int length;     // number of minutes

    // no-argument constructor
    public Video() {
        this.title = "unknown";
        this.length = 0;
    }

    // constructor
    public Video(String title, int length) {
        this.title = title;
        this.length = length;
    }

    ...
}

覆蓋方法

讓咱們來實例化 Movie 對象:

public class Tester {

    public static void main(String[] args) {
        Video video = new Video("視頻1", 90);
        Movie movie = new Movie("悟空傳", 139, "郭子健", "5.9");
        System.out.println(video.toString());
        System.out.println(movie.toString());
    }
}

程序輸出:

title=視頻1, length=90
title=悟空傳, length=139

movie.toString() 是 Movie 類直接繼承自 Video 類,它並無使用 Movie 對象具備的新變量,所以並不會打印導演和評分。

咱們須要給 Movie 類添加新的 toString() 的使用方法:

// 添加到 Movie 類中
public String toString() {
    return "title:" + getTitle() + ", length:" + getLength() + ", director:" + getDirector()
        + ", rating:" + getRating();
}

如今,Movie 擁有了本身的 toString() 方法,該方法使用了繼承自 Video 的變量和本身定義的變量。

即便父類有一個 toString() 方法,子類中新定義的 toString() 也會 覆蓋 父類的版本。當子類方法的 簽名 (就是返回值 + 方法名稱 + 參數列表) 與父類相同時,子類的方法就會 覆蓋 父類的方法。

如今運行程序,Movie 打印出了咱們指望的完整信息:

title=視頻1, length=90
title:悟空傳, length:139, director:郭子健, rating:5.9

有些人認爲 superthis 引用是相似的概念,實際上,這樣比較並不太恰當。這是由於 super 不是一個對象的引用,例如,不能將值 super 賦給另外一個對象變量,它只是一個指示編譯器調用父類方法的特殊關鍵字。

正像前面所看到的那樣,在子類中能夠增長字段、增長方法或覆蓋父類的方法,不過,繼承絕對不會刪除任何字段或方法。

Part 3. 更多細節

protected 關鍵字

若是類中建立的變量或者方法使用 protected 描述,則指明瞭 "就類用戶而言,這是 private 的,但對於任何繼承於此類的導出類或者任何位於同一個 內的類來講,它是能夠訪問的"。下面咱們就上面的例子來演示:

public class Video {
    protected String title;   // name of video
    protected int length;     // number of minutes
    ...
}

public class Movie extends Video {
    ...
    public String toString() {
        return "title:" + title + ", length:" + length + ", director:" + director
            + ", rating:" + rating;
    }
    ...
}

protected 修飾以前,若是子類 Movie 要訪問父類 Video 的 title 私有變量只能經過父類暴露出來的 getTitle() 公共方法,如今則能夠直接使用。

向上轉型

"爲新的類提供方法" 並非繼承技術中最重要的方面,其最重要的方面是用來表現新類和基類之間的關係。這種關係能夠用 "新類是現有類的一種類型" 這句話加以歸納。

因爲繼承能夠確保基類中全部的方法在子類中也一樣有效,因此可以向基類發送的全部信息也一樣能夠向子類發送。例如,若是 Video 類具備一個 play() 方法, 那麼 Movie 類也將一樣具有。這意味着咱們能夠準確地說 Movie 對象也是一種類型的 Video(體現 is-a 關係)

這一律唸的體現用下面的例子來講明:

public class Video {
    ...
    public void play() {}
    public static void start(Video video) {
        // ...
        video.play();
    }
    ...
}

// 測試類
public class Tester {
    public static void main(String[] args) {
        Movie movie = new Movie("悟空傳", 139, "郭子健", "5.9");
        Video.start(movie);
    }
}

在示例中,start() 方法能夠接受 Video 類型的引用,這是在太有趣了!

在測試類中,傳遞給 start() 方法的是一個 Movie 引用。鑑於 Java 是一個對類型檢查十分嚴格的語言,接受某種類型 (上例是 Video 類型) 的方法一樣能夠接受另一種類型 (上例是 Movie 類型) 就會顯得很奇怪!

除非你認識到 Movei 對象也是一種 Video 對象

start() 方法中,程序代碼能夠對 Video 和它全部子類起做用,這種將 Movie 引用轉換爲 Video 引用的動做,咱們稱之爲 向上轉型 (這樣稱呼是由於在繼承樹的畫法上,基類在子類的上方...)

Object 類

全部的類均具備父類,除了 Object 類。Java 類層次結構的最頂部就是 Object 類。

若是類沒有顯式地指明繼承哪個父類,那麼它會自動地繼承自 Object 類。若是一個子類繼承了一個父類,那麼父類要麼繼承它的父類,要麼自動繼承 Object最終,全部的類都將 Object 做爲祖先。

這意味着 Java 中的全部類都具備一些共同的特徵。這些特徵在被定義在 Object 中:

Object 類擁有的方法

(其中 finalize() 方法在 Java 9 以後棄用了,緣由是由於它自己存在一些問題,可能致使性能問題:死鎖、掛起和其餘問題...)

(想看源碼能夠打一個 Object,而後按住 Ctrl 不放,而後點擊 Object 就能夠進入 JDK 源碼查看了,源碼有十分規範的註釋和結構,你有時甚至會發現一些有趣的東西...)

Java 之父 Gosling 設計的 Object 類,是對萬事萬物的抽象,是在哲學方向上進行的延伸思考,高度歸納了事物的天然行爲和社會行爲。咱們都知道哲學的三大經典問題:我是誰?我從哪裏來?我到哪裏去?在 Object 類中,這些問題均可以獲得隱約的解答:

  1. 我是誰? getClass() 說明本質上是誰,而 toString() 是當前的名片;
  2. 我從哪裏來? Object() 構造方法是生產對象的基本方式,clone() 是繁殖對象的另外一種方式;
  3. 我到哪裏去? finalize() 是在對象銷燬時觸發的方法;(Java 9 以後已移除)

另外,Object 還映射了社會科學領域的一些問題:

  1. 世界是否因你而不一樣? hashCode()equals() 就是判斷與其餘元素是否相同的一組方法;
  2. 與他人如何協調? wait()notify() 就是對象間通訊與協做的一組方法;

理解方法調用

準確地理解如何在對象上應用方法調用很是重要。下面假設咱們要調用 x.f(args)x 是聲明爲 C 的一個對象。下面是調用過程的詳細描述:

  1. 編譯器查看對象的聲明類型和方法名。須要注意的是:有可能存在多個名字爲 f 但參數類型不同的方法。例如,可能存在 f(int)f(String)。編譯器將會一一列舉 C 類中全部名爲 f 的方法和其父類中全部名爲 f 並且能夠訪問的方法 (父類中的私有方法不可訪問)至此,編譯器一直到全部可能被調用的候選方法。
  2. 接下來,編譯器要肯定方法調用中提供的參數類型。若是在全部名爲 f 的方法中存在一個與所提供參數類型徹底匹配的方法,就選擇這個方法。這個過程稱爲 重載解析 (overloading resolution)。例如,對於調用 x.f("Hello"),編譯期將會挑選 f(String),而不是 f(int)。因爲容許類型轉換 (例如,int 能夠轉換成 double),因此狀況可能會變得很複雜。若是編譯器沒有找到與參數類型匹配的方法,或者發現通過類型轉換後有多個方法與之匹配,編譯器就會報錯。至此,編譯器已經知道須要調用的方法的名字和參數類型。
  3. 若是是 private 方法、static 方法、final 方法 (有關 final 修飾符會在下面講到) 或者構造器,那麼編譯器將能夠明確地知道應該調用哪一個方法。這稱爲 靜態綁定 (static binding)。與此對應的是,若是要調用的方法依賴於隱式參數的實際類型,那麼必須在運行時 動態綁定。在咱們的實例中,編譯器會利用動態綁定生成一個調用 f(String) 的指令。
  4. 程序運行而且採用動態綁定調用方法時,虛擬機必須調用與 x 所引用對象的實際類型對應的那個方法。假設 x 的實際類型是 D,它是 C 類的子類。若是 D 類定義了方法 f(String),就會調用這個方法;不然,將在 D 類的父類中尋找 f(String),以此類推。

每次調用方法都要完成這樣的搜索,時間開銷至關大。所以,虛擬機預先爲每一個類計算了一個 方法表 (method table), 其中列出了全部方法的簽名和要調用的實際方法 (存着各個方法的實際入口地址)。這樣一來,在真正調用方法的時候,虛擬機僅查找這個表就好了。(如下是 Video-父類 和 Movie-子類 的方法表結構演示圖)

例如咱們調用上述例子 Movie 類的 play() 方法。

public void play() {};

因爲 play() 方法沒有參數,所以沒必要擔憂 重載解析 的問題。又不是 private/ static/ final 方法,因此將採用 動態綁定 的方式。

在運行時,調用 object.play() 的解析過程爲:

  1. 首先,虛擬機獲取 object 的實際類型的方法表。這多是 Video、Movie 的方法表,也多是 Video 類的其餘子類的方法表;
  2. 接下來,虛擬機查找定義了 play() 簽名的類。此時,虛擬機已經知道應該調用哪一個方法了;(這裏若是 object 實際類型爲 Movie 則調用 Movie.play(),爲 Video 則調用 Video.play(),若是沒找到才往父類去找..)
  3. 最後,虛擬機調用這個方法。

動態綁定有一個很是重要的特性:無須對現有的代碼進行修改就能夠對程序進行擴展。

假設如今新增一個類 ShortVideo,而且變量 object 有可能引用這個類的對象,咱們不須要對包含調用 object.play() 的代碼從新進行編譯。若是 object 剛好引用一個 ShortVideo 類的對象,就會自動地調用 object.play() 方法。

警告:在覆蓋一個方法時,子類的方法 不能低於 父類方法的 可見性 (public > protected > private)。特別是,若是父類方法是 public,子類方法必須也要聲明爲 public

final 關鍵字

有時候,咱們可能但願組織人們利用某個類定義子類。不容許擴展 (被繼承) 的類被稱爲 final 類。若是在定義類的時候使用了 final 修飾符就代表這個類是 final 類了:

public final class ShortVideo extends Video { ... }

類中的某個特定方法也能夠被聲明爲 final。若是這樣作,子類就不能覆蓋這個方法 (final 類中的全部方法自動地稱爲 final 方法)。例如:

public class Video {
    ...
    public final void Stop() { ... }
    ...
}

若是一個 字段 被聲明爲了 final 類型,那麼對於 final 字段來講,構造對象以後就不容許改變它們的值了。不過,若是將一個類聲明爲 final,只有其中的方法自動地稱爲 final,而不包括字段,這一點須要注意。

將方法或類聲明爲 final 的主要緣由是:確保它們不會在子類中改變語義。

JDK 中的例子

  • Calendar(JDK 實現的日曆類) 中的 getTimesetTime 方法都聲明爲了 final,這就代表 Calendar 類的設計者負責實現 Data 類與日曆狀態之間的轉換,而不容許子類來添亂。
  • 一樣的,String 類也是 final(甚至面試中也常常出現),這意味着不容許任何人定義 String 的子類,換而言之,若是有一個 String 引用,它引用的必定是一個 String 對象,而不多是其餘類的對象。

內聯

在早起的 Java 中,有些程序員爲了不動態綁定帶來的系統開銷而使用 final 關鍵字。若是一個方法沒有被覆蓋而且很短,編譯器就可以對它進行優化處理,這個過程爲 內聯 (inlining)

例如,內聯調用 e.getName() 會被替換爲訪問字段 e.name

這是一項頗有意義的改進,CPU 在處理當前指令時,分支會擾亂預取指令的策略,因此,CPU 不喜歡分支。然而,若是 getName 在另一個類中 被覆蓋,那麼編譯器就沒法知道覆蓋的代碼將會作什麼操做,所以也就不能對它進行內聯處理了。

幸運的是,虛擬機中的 即時編譯器 (JIT) 比傳統編譯器的處理能力強得多。這種編譯器能夠準確地知道類之間的繼承關係,並可以檢測出是否有類確實覆蓋了給定的方法。

若是方法很短、被頻繁調用並且確實沒有被覆蓋,那麼即時編譯器就會將這個方法進行內聯處理。若是虛擬機加載了另一個子類,而這個子類覆蓋了一個內聯方法,那麼優化器將取消對這個方法的內聯。這個過程很慢,不過不多會發生這種狀況。

抽象類

在類的自下而上的繼承層次結構中,位於上層的類更具備通常性,也更加抽象。從某種角度看,祖先類更具備通常性,人們一般只是將它做爲派生其餘類的基類,而不是用來構造你想使用的特定的實例。

考慮一個 Person 類的繼承結構:

每一個人都有一些屬性,如名字。學生與員工都有名字。

如今,假設須要增長一個 getDescription() 的方法,它返回對一我的簡短的描述,學生類能夠返回:一個計算機在讀的學生,員工能夠返回 一個在阿里就任的後端工程師 之類的。這在 Student 和 Employee 類中實現很容易,可是在 Person 類中應該提供什麼內容呢? 除了姓名,Person 類對這我的一無所知。

有一個更好的方法,就是使用 abstract 關鍵字,把該方法定義爲一個 抽象方法,這意味着你並不須要實現這個方法,只須要定義出來就行了:(如下代碼爲 Person 類中的抽象定義)

public abstract String getDescription() {}

爲了提升程序的清晰度,包含一個或多個抽象方法的類自己必須被聲明爲抽象的:

public abstract class Person {
    ...
    public abstract String getDescription() {}
    ...
}

《阿里Java開發規範》強制規定抽象類命名 使用 AbstractBase 開頭,這裏只是作演示因此就簡單用 Person 代替啦~

抽象方法充當着佔位方法的角色,它們在子類中被繼承並實現。

擴展抽象類能夠由兩種選擇。一種是在子類中保留抽象類中的部分或全部抽象方法仍未實現,這樣就必須將子類標記爲抽象類 (由於還有抽象方法);另外一種作法就是實現所有方法,這樣一來,子類就不是抽象的了。

(即便不包含抽象方法,也能夠將類聲明爲抽象類)

抽象類不能實例化,也就是說,若是將一個類聲明爲 abstract,就不能建立這個類的實例,例如:new Person(); 就是錯誤的,但能夠建立具體子類的對象:Person p = new Student(args);,這裏的 p 是一個抽象類型 Person 的變量,它引用了一個非抽象子類 Student 的實例。

Part 4. 爲何不推薦使用繼承?

先別急着奇怪和憤懣,剛學習完繼承以後,就告訴說不推薦使用,這是 有緣由的!

在面向對象編程中,有一條很是經典的設計原則:組合優於繼承。使用繼承有什麼問題?組合相比繼承有哪些優點?如何判斷該用組合仍是繼承?下面咱們就圍繞這三個問題,來詳細講解一下。

如下內容大部分引用自:https://time.geekbang.org/column/article/169593

使用繼承有什麼問題?

上面說到,繼承是面向對象的四大特性之一,用來表示類之間的 is-a 關係,能夠解決代碼複用的問題。雖然繼承有諸多做用,但繼承層次過深、過複雜,也會影響到代碼的可維護性。咱們經過一個例子來講明一下。

假設咱們要設計一個關於鳥的類,咱們將 「鳥類」 這樣一個抽象的事物概念,定義爲一個抽象類 AbstractBird。全部更細分的鳥,好比麻雀、鴿子、烏鴉等,都繼承這個抽象類。

咱們知道,大部分鳥都會飛,那咱們可不能夠在 AbstractBird 抽象類中,定義一個 fly() 方法呢?答案是否認的。儘管大部分鳥都會飛,但也有特例,好比鴕鳥就不會飛。鴕鳥繼承具備 fly() 方法的父類,那鴕鳥就具備「飛」這樣的行爲,這顯然不符合咱們對現實世界中事物的認識。固然,你可能會說,我在鴕鳥這個子類中重寫 (override) fly() 方法,讓它拋出 UnSupportedMethodException 異常不就能夠了嗎?具體的代碼實現以下所示:

public class AbstractBird {
  //...省略其餘屬性和方法...
  public void fly() { //... }
}

public class Ostrich extends AbstractBird { //鴕鳥
  //...省略其餘屬性和方法...
  public void fly() {
    throw new UnSupportedMethodException("I can't fly.'");
  }
}

這種設計思路雖然能夠解決問題,但不夠優美。由於除了鴕鳥以外,不會飛的鳥還有不少,好比企鵝。對於這些不會飛的鳥來講,咱們都須要重寫 fly() 方法,拋出異常。

這樣的設計,一方面,徒增了編碼的工做量;另外一方面,也違背了咱們以後要講的最小知識原則 (Least Knowledge Principle,也叫最少知識原則或者迪米特法則),暴露不應暴露的接口給外部,增長了類使用過程當中被誤用的機率。

你可能又會說,那咱們再經過 AbstractBird 類派生出兩個更加細分的抽象類:會飛的鳥類 AbstractFlyableBird 和不會飛的鳥類 AbstractUnFlyableBird,讓麻雀、烏鴉這些會飛的鳥都繼承 AbstractFlyableBird,讓鴕鳥、企鵝這些不會飛的鳥,都繼承 AbstractUnFlyableBird 類,不就能夠了嗎?具體的繼承關係以下圖所示:

從圖中咱們能夠看出,繼承關係變成了三層。不過,總體上來說,目前的繼承關係還比較簡單,層次比較淺,也算是一種能夠接受的設計思路。咱們再繼續加點難度。在剛剛這個場景中,咱們只關注「鳥會不會飛」,但若是咱們關注更多的問題,例如 「鳥會不會叫」、」鳥會不會下單「 等... 那這個時候,咱們又該如何設計類之間的繼承關係呢?

總之,繼承最大的問題就在於:繼承層次過深、繼承關係過於複雜會影響到代碼的可讀性和可維護性。這也是爲何咱們不推薦使用繼承。那剛剛例子中繼承存在的問題,咱們又該如何來解決呢?

組合相比繼承有哪些優點?

實際上,咱們能夠利用組合 (composition)、接口、委託 (delegation) 三個技術手段,一起來解決剛剛繼承存在的問題。

咱們前面講到接口的時候說過,接口表示具備某種行爲特性。針對「會飛」這樣一個行爲特性,咱們能夠定義一個 Flyable 接口 (至關於定義某一種行爲,下方會有代碼說明),只讓會飛的鳥去實現這個接口。對於會叫、會下蛋這些行爲特性,咱們能夠相似地定義 Tweetable 接口、EggLayable 接口。咱們將這個設計思路翻譯成 Java 代碼的話,就是下面這個樣子:

public interface Flyable {
  void fly();
}
public interface Tweetable {
  void tweet();
}
public interface EggLayable {
  void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鴕鳥
  //... 省略其餘屬性和方法...
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}
public class Sparrow impelents Flayable, Tweetable, EggLayable {//麻雀
  //... 省略其餘屬性和方法...
  @Override
  public void fly() { //... }
  @Override
  public void tweet() { //... }
  @Override
  public void layEgg() { //... }
}

不過,咱們知道,接口只聲明方法,不定義實現。也就是說,每一個會下蛋的鳥都要實現一遍 layEgg() 方法,而且實現邏輯是同樣的,這就會致使代碼重複的問題。那這個問題又該如何解決呢?

咱們能夠針對三個接口再定義三個實現類,它們分別是:實現了 fly() 方法的 FlyAbility 類、實現了 tweet() 方法的 TweetAbility 類、實現了 layEgg() 方法的 EggLayAbility 類。而後,經過 組合和委託 技術來消除代碼重複。具體的代碼實現以下所示:

public interface Flyable {
  void fly();
}
public class FlyAbility implements Flyable {
  @Override
  public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

public class Ostrich implements Tweetable, EggLayable {//鴕鳥
  private TweetAbility tweetAbility = new TweetAbility(); //組合
  private EggLayAbility eggLayAbility = new EggLayAbility(); //組合
  //... 省略其餘屬性和方法...
  @Override
  public void tweet() {
    tweetAbility.tweet(); // 委託
  }
  @Override
  public void layEgg() {
    eggLayAbility.layEgg(); // 委託
  }
}

固然啦,也可使用 JDK 1.8 以後支持的接口默認方法:

public interface Flyable {
    default void fly() {
        // fly的 的默認實現
    }
}

咱們知道繼承主要有三個做用:表示 is-a 關係,支持多態特性,代碼複用。而這三個做用均可以經過其餘技術手段來達成。好比:

  • is-a 關係,咱們能夠經過組合和接口的 has-a 關係來替代;
  • 多態特性咱們能夠利用接口來實現;
  • 代碼複用咱們能夠經過組合和委託來實現;

因此,從理論上講,經過組合、接口、委託三個技術手段,咱們徹底能夠替換掉繼承,在項目中不用或者少用繼承關係,特別是一些複雜的繼承關係。

如何判斷該用組合仍是繼承?

儘管咱們鼓勵多用組合少用繼承,但組合也並非完美的,繼承也並不是一無可取。從上面的例子來看,繼承改寫成組合意味着要作更細粒度的類的拆分。這也就意味着,咱們要定義更多的類和接口。類和接口的增多也就或多或少地增長代碼的複雜程度和維護成本。因此,在實際的項目開發中,咱們仍是要根據具體的狀況,來具體選擇該用繼承仍是組合。

若是類之間的繼承結構穩定 (不會輕易改變),繼承層次比較淺 *(好比,最多有兩層繼承關係),繼承關係不復雜,咱們就能夠大膽地使用繼承。反之,系統越不穩定,繼承層次很深,繼承關係複雜,咱們就儘可能使用組合來替代繼承。

除此以外,還有一些 設計模式 會固定使用繼承或者組合。好比,裝飾者模式(decorator pattern)、策略模式(strategy pattern)、組合模式(composite pattern)等都使用了 組合關係,而 模板模式(template pattern)使用了 繼承關係

前面咱們講到繼承能夠實現代碼複用。利用繼承特性,咱們把相同的屬性和方法,抽取出來,定義到父類中。子類複用父類中的屬性和方法,達到代碼複用的目的。可是,有的時候,從業務含義上,A 類和 B 類並不必定具備繼承關係。好比,Crawler 類和 PageAnalyzer 類,它們都用到了 URL 拼接和分割的功能,但並不具備繼承關係 (既不是父子關係,也不是兄弟關係)僅僅爲了代碼複用,生硬地抽象出一個父類出來,會影響到代碼的可讀性。若是不熟悉背後設計思路的同事,發現 Crawler 類和 PageAnalyzer 類繼承同一個父類,而父類中定義的卻只是 URL 相關的操做,會以爲這個代碼寫得莫名其妙,理解不了。這個時候,使用組合就更加合理、更加靈活。具體的代碼實現以下所示:

public class Url {
  //...省略屬性和方法
}

public class Crawler {
  private Url url; // 組合
  public Crawler() {
    this.url = new Url();
  }
  //...
}

public class PageAnalyzer {
  private Url url; // 組合
  public PageAnalyzer() {
    this.url = new Url();
  }
  //..
}

還有一些特殊的場景要求咱們必須使用繼承。若是你不能改變一個函數的入參類型,而入參又非接口,爲了支持多態,只能採用繼承來實現。好比下面這樣一段代碼,其中 FeignClient 是一個外部類,咱們沒有權限去修改這部分代碼,可是咱們但願能重寫這個類在運行時執行的 encode() 函數。這個時候,咱們只能採用繼承來實現了。

public class FeignClient { // Feign Client框架代碼
  //...省略其餘代碼...
  public void encode(String url) { //... }
}

public void demofunction(FeignClient feignClient) {
  //...
  feignClient.encode(url);
  //...
}

public class CustomizedFeignClient extends FeignClient {
  @Override
  public void encode(String url) { //...重寫encode的實現...}
}

// 調用
FeignClient client = new CustomizedFeignClient();
demofunction(client);

儘管有些人說,要杜絕繼承,100% 用組合代替繼承,可是個人觀點沒那麼極端!之因此 「多用組合少用繼承」 這個口號喊得這麼響,只是由於,長期以來,咱們過分使用繼承。仍是那句話,組合並不完美,繼承也不是一無可取。只要咱們控制好它們的反作用、發揮它們各自的優點,在不一樣的場合下,恰當地選擇使用繼承仍是組合,這纔是咱們所追求的境界。

要點回顧

  1. 繼承概述 / 單繼承 / is-a 關係 / 類之間的關係 / 層級結構;
  2. 繼承的實現 / 覆蓋方法 / protedcted / 向上轉型;
  3. Object 類 / 方法調用 / final / 內聯 / 爲何不推薦使用繼承;

練習

暫無;

參考資料

  1. 《Java 核心技術 卷 I》
  2. 《Java 編程思想》
  3. 《碼出高效 Java 開發手冊》
  4. 設計模式之美 - 爲什麼說要多用組合少用繼承?如何決定該用組合仍是繼承? - https://time.geekbang.org/column/article/169593
  5. Introduction to Computer Science using Java - http://programmedlessons.org/Java9/index.html#part02
  • 本文已收錄至個人 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
  • 我的公衆號 :wmyskxz,我的獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

很是感謝各位人才能 看到這裏,若是以爲本篇文章寫得不錯,以爲 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創做不易,各位的支持和承認,就是我創做的最大動力,咱們下篇文章見!

相關文章
相關標籤/搜索