CS61B資料結構及演算法筆記:(五)類別繼承

Evan
6 min readNov 24, 2021

--

除了可以從接口繼承尚未實現的方法以外,也可以透過關鍵字 extends 從父類別繼承所有的成員,包含所有的變數、方法及巢狀類別(除構築函數)。舉例來說,建立一個新的類別 RotatingSLList 擁有將最後一個節點移動到第一個節點的方法 rotateRight,其中一個做法是完全重新寫一個新的類別,包含所需的變數、節點、方法等等,或者是透過 extends 的關鍵字,直接繼承在 SLList 內既有的成員,包含了會使用到的 removeLast 以及 addFirst,再添加新的方法即可。

為了說明繼承的特性,考慮建立另一個特殊的類別 VengefulSLList,其特色為當使用 removeLast 的方法時,不只能刪除最後一個節點,同時記住被刪除的節點。建立的思路為

  1. 在類別內需要有一個列表以紀錄被刪除的節點
  2. 覆寫父類別內會丟棄被刪除的節點的方法,改為將被刪除的節點存至列表
  3. 新增從記錄下來的列表中,印出被刪除節點的方法

然而,如直接按照 SLList 內 removeLast 的方式完成,會發生錯誤。原因是即使從父類別繼承了所有的成員,包括變數及方法,卻會因為父類別內的變數 size 及 sentinel 被宣告成 private 變數的關係(被宣告為 private 的方法或變數代表只能在該類別內存取),無法被子類別存取,導致無法使用繼承下來的方法。此時可以透過關鍵字 super,讓 Java 知道要去父類別內呼叫 removeLast,並存取在父類別內被宣告成是 private 的變數。到目前為止 deletedItems 尚未被實例,因此需要一個構築函數實例 SLList 以存放被刪除的節點。

上述提到雖然構築函數不會被繼承,不過 Java 會預設在子類別的構築函數內,使用父類別其中一個構築函數,預設是沒有參數的構築函數。以 VengefulSLList 為例,Java 會使用 super() 的關鍵字,使用父類別 SLList 內的構築函數 SLList(),實例 sentinel 和 size 這兩個變數,以利使用繼承下來的方法;接著再實例 deletedItems,存放刪除的節點。因此實際上 Java 在執行構築函數 VengefulSLList() 時會執行如下:

public VengefulSLList() {

super();

deletedItems = new SLList();}

super() 雖然可以被省略,不過當需要使用父類別的構築函數需要填入參數例如:super(5) 時,此時就不能省略,否則還是會預設使用沒有參數的構築函數。

到目前為止,繼承讓程式碼變得更簡潔也更容易維護,不過雖然方便,有時卻無法保證每一次從父類別繼承下來的方法,在子類別內都適用,舉例來說,一個父類別 Dog 內的方法 bark() 會呼叫另一個方法 barkMany(int N) 如下:

當子類別繼承父類別,並覆寫 barkMany(int N) 如下:

此時在子類別 SubDog 內並無方法 bark(),因此會從父類別 Dog 繼承該方法,可是當執行繼承下來的內容 barkMany(1)時,會因為覆寫使得重新呼叫方法 bark(),形成一個無限的迴圈。

雖然遇到這個問題可以透過修改父類別來解決,不過在使用繼承時,即使程式發生錯誤,也不希望還要去確認所有繼承的父類別內的每一個方法如何完成。

封裝性(encapsulation)是一個物件導向程式語言的基本精神,舉例來說,當繼承 ArrayList 這個類別時,透過文件可以知道這個類別可以使用甚麼方法,例如:addLast 等等,並不需要了解如何實現這些方法。例如:實現 addLast ,是直接在陣列 index = size 的位置填入新增的項目,或是用一個迴圈找到最後一個空的位置,再填入新增的項目。

由此可知,在使用繼承雖然方便,代價卻是有可能破壞程式的封裝性。

型態確認(Type Check)

在執行一個 Java 程式時,會區分成兩個階段,分別是

  1. 編譯階段(compile time)
    確認程式碼是否可以被執行
  2. 執行階段(run time)
    實際執行程式碼

以下方程式碼為例,逐步介紹 Java 如何進行型態的確認

  1. VengefulSLList<Integer> vsl = new VengefulSLList<>(9);
    等號兩端皆為 VengefulSLList,實例化類別 VengefulSLList 並且以變數 vsl 儲存物件地址。
  2. SLList<Integer> sl = vsl;
    將另一個變數 sl 指向 vsl;此時 Java 會在編譯階段檢查 sl 和 vsl 是否為繼承關係。根據圖一所示,因 VengefulSLList 是一種 SLList,允許將 sl 存放 vsl 的物件地址,在執行階段指向 vsl。
  3. sl.addLast(10);
    因 sl 為 SLList,在編譯階段會檢查 SLLsit 是否擁有 addLast 這個方法;在執行階段因 VengefulSLList 並無實現 addLast,因此使用從 SLList 繼承下來的 addLast。
  4. sl.removeLast();
    因 sl 為 SLList,在編譯階段會檢查 SLLsit 是否擁有 removeLast 這個方法;在執行階段因 VengefulSLList 覆寫了 removeLast,因此使用覆寫過後的方法。
  5. sl.printLostItems();
    因 sl 為 SLList,在編譯階段會檢查 SLLsit 是否擁有 printLostItems 這個方法,此時會發現即使實際上 sl 指向的物件是 VengefulSLList,VengefulSLList 也確實有 printLostItems 這個方法,不過在編譯階段只會考慮靜態型態,從而發現 SLList 並無實現 printLostItems,因此編譯錯誤。
  6. VengefulSLList<Integer> vsl2 = sl;
    同上,在編譯階段只會考慮靜態型態,因 SLList 並不一定是 VengefulSLList,因此,即使實際上 sl 指向的是 VengefulSLList,在編譯階段還是會出現錯誤。

型態轉換(Casting)

Java 提供了一個可選擇的方式解決在編譯階段發生錯誤的問題,利用小括號,即可告訴 Java 在編譯階段,將型態轉換成特定型態,如下所示:

VengefulSLList<Integer> vsl2 = (VengefulSLList) sl;

將靜態類型為 SLList 的 sl 強制轉換為 VengefulSLList,在編譯階段告訴 Java sl 的型態並非 SLList 而是 VengefulSLList,在執行階段成功將 vsl2 指向 sl。

型態確認的用意為確保程式執行的安全性,如今使用型態轉換雖然可以增加彈性,卻也會增加程式錯誤的風險,因此在使用時須多加留意。

--

--