「MoreThanJava」Day 5:物件導向進階——繼承詳解

我沒有三顆心臟發表於2020-08-07

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

Part 1. 繼承概述

上一篇文章 中我們簡單介紹了繼承的作用,它允許建立 具有邏輯等級結構的類體系,形成一個繼承樹。

Animal 繼承樹

繼承使您可以基於現有類定義新類。 新類與現有類相似,但是可能具有其他例項變數和方法。這使程式設計更加容易,因為您可以 在現有的類上構建,而不必從頭開始。

繼承是現代軟體取得巨大成功的部分原因。 程式設計師能夠在先前的工作基礎上繼續發展並不斷改進和升級現有軟體。

物件導向之前,寫程式碼的一些問題

如果你有一個類的原始碼,你可以複製程式碼並改變它變成你想要的樣子。在物件導向程式設計之前,就是這樣子做的。但至少有兩個問題:

❶ 很難保持僅僅有條。

假設您已經有了幾十個需要的類,並且需要基於原始類創造新的一些類,再基於新的類創造出更新的類,最終您將獲得數十個原始檔,這些原始檔都是通過其他已更改的原始檔的另外版本。

假設現在在一個原始檔中發現了錯誤,一些基於它的原始檔需要進行修復,但是對於其他原始檔來說,並不需要!沒有細緻的寫程式碼的計劃,您最終會陷入混亂....

❷ 需要學習原始程式碼。

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

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

單繼承

用於作為新類别範本的類稱為 父類 (或超類或基類),基於父類建立的類稱為 子類 (或派生類)

就像上圖中演示的那樣,箭頭從子類指向父類。(在上圖中,雲表示類,而矩形表示物件,這樣的表示的方法來自於 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,與您共同成長!

非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見!

相關文章