深度探究物件導向:繼承的利與弊 | Java、Kotlin 為例 | 最佳實踐 | 內部類細節

深度探究物件導向:繼承的利與弊 | Java、Kotlin 為例 | 最佳實踐 | 內部類細節

Overview of Content

本文將深入探討物件導向編程中繼承的各種特性及其影響。

我們不僅將討論繼承的優點,還會探討其可能存在的弱點和缺點。透過分析繼承的原則以及使用中的各種考量,我們將幫助讀者更好地理解何時以及如何適當地使用繼承。

此外,我們還將比較繼承與組合的差異,提供更清晰的選擇指南;最後,我們將深入研究 Java 內部類的不同類型,幫助讀者更全面地理解 Java 中的物件導向設計、編譯結果

寫文章分享不易,如有引用參考請詳註出處,如有指導、意見歡迎留言(如果覺得寫得好也請給我一些支持),感謝 😀


物件導向:繼承的特性

繼承是一種 提高程式碼的可用性,並提高系統的可擴充性的有效手段,而組合也達到相同效果(之後會拿「繼承」與「組合」作比較),然而繼承是完全沒有區點的嘛?是否有副作用?… 就是接下來要探討的重點

繼承的弱點

● 繼承雖然可以達成高效的覆用,而我們換到物件導向的想法,物件導向同時也注重於封裝,封裝細節,不讓其類它關注其細節

而繼承則弱點正在於封裝,繼承它打破了封裝的規則,讓子類參與到開發的細節,同時自身也會影響到子類的實現!

這種子類、父類的關係就是「緊耦合」關係,之所以是「緊」的原因在於它們的關係建立在程式語言的語法之上,並且連接在編譯期間!

● 繼承的弱點 1:子類的實現會影響父類


abstract class Shop {

    abstract fun isShopOpen(): Boolean

    fun buy() {
        // 子類的實現會有關細到父類的行為
        if (!isShopOpen()) {
            throw Exception("Shop not open")
        }

        // do something
    }

}

class BookShop: Shop() {

    override fun isShopOpen(): Boolean {
        return false
    }

}

● 繼承的弱點 2:父類的改變會影響子類;最明顯的就是父類別的方法改變、拓展,子類必須被迫實現

繼承的缺點

繼承是一「強制性」擴充:強制其子類必須繼承父類所有的方法、屬性(這也包括了私有),最終可能導致子類必須實現它不需要的方法


abstract class Firmware {

    abstract fun getHardwareVersion(): String

    abstract fun getFirmwareVersion(): String

    abstract fun update(): Boolean

}

class Motor: Firmware() {

    override fun getHardwareVersion(): String {
        return "1.1.0"
    }

    override fun getFirmwareVersion(): String {
        return "2.0.13"
    }

    override fun update(): Boolean {
        println("Update Motor")

        return true
    }

}

class LED: Firmware() {
    override fun getHardwareVersion(): String {
        return "1.0.0"
    }

    override fun getFirmwareVersion(): String {
        return "1.1.1"
    }

    // 非必要的方法!
    override fun update(): Boolean {
        throw UnsupportedOperationException()
    }

}

如果維護者不清晰了解父類,那在拓展時會增加多型的不穩定

由於子類繼承後,可以覆寫(Override)父類的方法,這是多型的特徵,但假 如子類覆寫時曲解了方法的意義,可能導致不安全性的發生


abstract class Shop {

    // 原本的意義是只准反為 Boolean 判斷
    abstract fun isShopOpen(): Boolean

    fun buy() {
        if (!isShopOpen()) {
            throw Exception("Shop not open")
        }

        // do something
    }

}

class BookShop: Shop() {

    // 而子類卻曲解其意義,改為拋出!
    override fun isShopOpen(): Boolean {
        throw Exception("Book shop not open.")
    }

}

繼承的使用原則

最基礎的就是開發文檔要寫清楚,並且說明實做該方法會牽連影響到哪些方法,最終可能導致哪些結果;當然除了文檔之外,我們也可以透過一些規則來實做繼承

層級限制

● 繼承的層級最好做適當的管控,否則容易造成類別拓展的負擔,並也降低程式的理解性、可讀性… 建議:繼承層次應該保持在不超過 3 層 的架構(不考慮到 Object 類)


// kotlin    

// 第一層
abstract class Food {
    
}

// 第二層
abstract class Fruit: Food {
    
}

// 第三層(最多)
class Apple: Fruit {
    
}

界面 & 繼承 的宣告

如果有界面(interface)的實做 & 繼承(abstract class:我們應該 盡可能的使用界面類作為宣告,而非使用抽象類;

這是因為抽象類所代表的責任、含義會比介面更大(因為抽象類是介於介面、實體類的中間,它往往也承擔了部分的細節做法);概念程式如下…

● 介面 & 繼承的相關程式


// kotlin 範例

interface IEatAction {
    fun eat();
}

abstract class Fruit: Food, IEatAction {

    override fun eat() {
        println("Eat fruit");
    }

}

class Apple: Fruit {

}

● 我們這裡建議使用界面作為宣告,來降低對於實作細節的依賴!


fun main() {
    // 應該使用「界面」作為宣告 (使用這個更好!)
    val apple: IEatAction = Apple();

    // 使用 抽象 作為宣告,會與實體的細節產生更多切合面的接觸點
    val apple2: Fruit = Apple();
}

有規劃的設計父類

盡量在父類完成共有的方法,為子類提供一系列預設的實做(這也提高了程式碼的重用性);而實際上通常並非這麽順利,通常有需要子類實現的方法,如下︰

父類完成方法:某些方法適用於所有的子類(同常是邏輯),那就可以在父類完成該方法


abstract class LoadFile {

    abstract fun load(): File

    fun getContent(): String {
        val file = load()

        val reader = file.reader()

        return reader.use {
            it.readText().run {
                this.ifEmpty {
                    "The file is empty"
                }
            }
        }
    }

}

子類完成方法:某些方法的實做取決於各個子類別的特定屬性、實做細節


class LoadDiskFile: LoadFile() {

    override fun load(): File {
        return File("file://....")
    }

}

class LoadWebFile: LoadFile() {

    override fun load(): File {
        return File(URI("www.google.tw"))
    }

}

● 盡量的管控,不要讓子類去複寫父類已經完成的方法(也就是建議父類已經完成的方法使用 final 宣告)

基於 盡量在父類完成共有的方法 的原則,我們在設計父類別時 可以使用一些技巧來將方法限制在父類別中

A. 使用 private 修飾共同的邏輯方法,不讓子類別去訪問


abstract class MyClass {

    private fun commonLogic() {
        // do nothing
    }

}

B. 使用 final 來描述共同的方法,不讓子類別去覆寫(Override)


abstract class MyClass {

    final fun sayHello() {
        // do nothing
    }

}

C. 父類別不應該在建構函數(Consturctor)呼叫子類別實現的方法,否則可能造成各種狀況的崩潰 (Crash)

下面範例可能會造成的問題是:JVM 在建構時會先建構基類(也就是父類別),這會導致子類別尚未建立完成就被呼叫,即可能造成 Crush


abstract class BaseMessageStore {

    constructor() {
        getMessage()
    }

    abstract fun getMessage(): String

}

class MessageStore: BaseMessageStore() {

    override fun getMessage(): String {
        return "HelloWorld"
    }

}

繼承的考量

由上面我們可以知道繼承是一種有代價(而且不低)的行為,那我們甚麽時候該用繼承呢?

濫用繼承

● 先來看一個繼承的濫用案例


abstract class Clothe {

    abstract fun color(): String

}

class GreenClothe: Clothe() {

    override fun color(): String {
        return "Green"
    }

}

class RedClothe: Clothe() {

    override fun color(): String {
        return "Red"
    }

}

class WhiteClothe: Clothe() {

    override fun color(): String {
        return "White"
    }

}

這個設計 沒有詳加考慮到業務邏輯關係,僅因為讓子類別來定義簡易屬性,而使用繼承;

它除了類別名稱不同之外,屬性、行為接相同,這等同於 濫用繼承,這猶如殺雞用牛刀,完全沒有必要

繼承必須要父類、子類參與邏輯關係,其中也許可有不同的屬性、私有行為


繼承與組合的比較

在軟體的開發階段,會經過幾個時期

早期階段:早期是 創建 階段,從基礎簡單的呈現出符合業務邏輯的,在經歷整體類別到區域類別的分解(抽出重複部份),從 子類到到父類別的抽象過程

後期階段:後期階段基本上是進行 維護,維護已經創建的抽象父類別,如果這時要擴充,就需要進行區域類別的繼承、組合

早期:組合的分解 & 繼承的抽象

組合關係的分解過程,對應繼承關係的抽象過程

接著我們用同一個案例但不同手段(繼承、組合)來完成目的;當我們收到業務之後,需要從 0 建構出一個類,這個過程是 實體到抽象

假設我們有個新需求,要創建兩種類型的 Licence 解析,分別是 JWT、RSA 的 License 驗證

繼承關係的抽象過程(繼承的抽象):會經過兩個過程實做、抽象化

A. 從實做分析:寫出各個實做的細節


class RSALicense {

    fun parserLicenseContent(content: String): Boolean {

        if (content.isNotEmpty()) {
            return false
        }

        return content.startsWith("RSA")
    }

}

class JWTLicense {

    fun parserLicenseContent(content: String): Boolean {

        if (content.isNotEmpty()) {
            return false
        }

        return content.contains("JWT")
    }

}

B. 抽象化:分析實做相關性並抽象化


abstract class LicenseCheck {

    protected abstract fun parserLicenseContent(content: String): Boolean

    fun check(content: String): Boolean {

        if (content.isNotEmpty()) {
            return false
        }

        return parserLicenseFile(content)
    }

}

組合關係的分解過程(組合的分解):將相同之處進行分解,拆分到另一類,並且重新組合;

抽出相同方法,透過界面抽象化:其中相同之處就在於 parserLicenseFile 方法

這裡透過界面(interface)抽象化,讓組合類具有拓展性


interface IParser {
    fun parserLicenseContent(content: String): Boolean
}

class RSAParserLicenseContent constructor(val content: String): IParser {

    override fun parserLicenseContent(content: String): Boolean {
        return content.startsWith("RSA")
    }

}

class JWTParserLicenseContent constructor(val content: String): IParser {

    override fun parserLicenseContent(content: String): Boolean {
        return content.contains("JWT")
    }

}

使用組合方式將相關處理類組合並使用


// 組合 IParser 抽象方法
class LicenseCheckComponent constructor(val parser: IParser) {

    fun check(content: String): Boolean {

        if (content.isNotEmpty()) {
            return false
        }

        return parser.parserLicenseContent(content)
    }

}

後期:組合的組合 & 繼承的擴充

組合關係的「組合過程」,對應繼承關係的「擴充過程」

接著我們用同一個案例但不同手段(繼承、組合)來完成相同的目的;以覆用、拓展為目的,並以維護為目的


// 已達成的類如下

interface ICheck {
    fun check(content: String): Boolean
}

abstract class LicenseCheck: ICheck {

    protected abstract fun parserLicenseContent(content: String): Boolean

    override fun check(content: String): Boolean {

        if (content.isNotEmpty()) {
            return false
        }

        return parserLicenseContent(content)
    }

}

// 使用 open 描述,讓其可被繼承
open class RSALicense: LicenseCheck() {

    override fun parserLicenseContent(content: String): Boolean {
        return content.startsWith("RSA")
    }

}

// 使用 open 描述,讓其可被繼承
open class JWTLicense: LicenseCheck() {

    override fun parserLicenseContent(content: String): Boolean {
        return content.contains("JWT")
    }

}

假設我們在一個已經成熟的專案中,有一個拓展的新需求;而已達成的類如下描述,要透過以下類進行拓展…

繼承關係的擴充過程

透過繼承,拓展出新的功能:覆用父類已完成的方法,並加上自身的業務邏輯


class RSALicence128: RSALicense() {

    override fun parserLicenseContent(content: String): Boolean {
        return super.parserLicenseContent(content) 
            // 加上自身的業務邏輯
            && content.length == 128
    }

}

class JWTLicence256: JWTLicense() {

    override fun parserLicenseContent(content: String): Boolean {
        return super.parserLicenseContent(content) 
            // 加上自身的業務邏輯
            && content.length == 256
    }

}

● 這種繼承關係,會引入與父類別的強關聯關係,也就是子類必須相當了解父類別實現的方法

組合關係的組合過程

透過組合,拓展出新的功能:將需求透過「新建立的類」個別建立,並 完成部份業務需求


class Licence128: LicenseCheck() {

    override fun parserLicenseContent(content: String): Boolean {
        return content.length == 128
    }

}

class Licence256: JWTLicense() {

    override fun parserLicenseContent(content: String): Boolean {
        return content.length == 256
    }

}

組合新建立的類(須拓展的新方法)、已有的類(舊有的功能),來完整達成需求


class RSALicence128_2 constructor(val l128: Licence128, val rsa: RSALicense): ICheck {

    override fun check(content: String): Boolean {
        return l128.check(content) && rsa.check(content)
    }

}

class JWTLicence256_2 constructor(val l285: Licence256, val jwt: JWTLicense): ICheck {

    override fun check(content: String): Boolean {
        return l285.check(content) && jwt.check(content)
    }

}

● 以拓展、組合的方式去建立新的功能,可以保證舊有的類不受到影響,並且也符合類的設計原則 開閉原則 Open Close Principle

繼承 vs. 組合的結論

● 接著,我們說說繼承的「特徵」,謂何說繼承的代價是比較大的?

系統的複查度:多層級繼承會使的系統變得複雜,不易維護

組合相對比起來複雜度低,容易做插拔替換

靜態繼承關係:運行時會被迫接受父類的所有特徵(包括私有方法、成員),並且 無法改變父類(必須一直在父類環境下運行)

組合則不會有這種困擾,允許替換功能的環境

● 在 UML 中的關聯關係、聚合關係可以統一稱為組合,使用組合可以完成跟繼承相同的事情;其中兩個的差異比較如下表

\組合關係繼承關係
封裝(v) 保有每個類的封裝性,獨立性高(x) 破壞封裝性,子類父類都會相互影響
擴充性(v) 針對不同的細節實做不同的類,擴充性高(x) 有擴充性,不過 相對的代價也高、複雜度變高
動態性(v) 支援動態組合,可以透過 setter 替換不同的實做(x) 子類無法改變父類的實做,與父類是強耦合關係
可變性(v) 基於依賴倒置關係,可以將封裝的方法抽象為界面,實做方只需要依賴所需的界面組合就可以(x) 子類強制繼承父類的所有方法、屬性
界面的關聯性(x) 隔離了界面的,不需要手動獲取界面的方法(v) 自動擁有父類的界面
建立物件的代價(x) 必須手動傳入組合類(v) 子類別建立時,同時就建立好父類

Java 內部類的不同

Java 的內部類有分為多種,如果使用得當,可以優雅的規劃出類的責任、關聯範疇;我們可以做以下分類來區別一下不同的內部類

A. 實名內部類

分類關鍵
實體內部類與外部有引用關係,必須先創建外部類才能創建內部類
靜態內部類關鍵字 static,與外部類無明顯關係
區域(方法)內部類這種方法內部類較少使用;它不能用 publicprotectedprivate 描述

B. 變數(匿名)內部類

分類關鍵
實體變數在創建類後添加 {} 即是一個實體變數;自動有外部引用
靜態變數靜態特性的匿名類,沒有外部引用
局部(方法)變數在方法內創建匿名類

● 內部類通常都會在以下需求中被使用

封裝內部(區域)所需資料

● 分開類別後,方便直接存取、呼叫外部類成員(包括 private 成員)

實名內部類

A. 實體內部類

由於與外部類有引用關係,所以內部類創建完後,可以使用外部類的成員

與外部有引用關係,必須先創建外部類才能創建內部類


// java 範例

class OuterClass {

    private int outerValue = -1;

    class InnerClass {
        InnerClass() {
            // 直接使用外部類成員
            outerValue = 1000;
        }
    }

    public static void main(String[] args) {
        OuterClass oc = new OuterClass();
        OuterClass.InnerClass ic = oc.new InnerClass();
    }

}

B. 靜態內部類

與外部「無」直接依賴慣係(不會自動持有外部類的實體引用參考),可以直接創建物件的實例


// java 範例

class OuterClass {

    static class StaticInnerClass {
        StaticInnerClass() {
            // 非法行為!無法通過編譯
//            outerValue = -100;
        }
    }

    public static void main(String[] args) {
        OuterClass.StaticInnerClass osi = new StaticInnerClass();

    }

}

C. 區域(方法)內部類

僅限於方法內可創建(可見範圍只在方法內)


class OuterClass {

    private int outerValue = -1;

    int myMethod() {
        int testVar = 100;
        final int testVar2 = 300;
        class DataClass {
            final int outsideVar = outerValue;
            final int localVar = testVar;
            final int localFinalVar = testVar2;

            int total() {
                return outsideVar + localVar + localFinalVar;
            }
        }

        DataClass dc = new DataClass();
        return dc.total();
    }

}

匿名內部類

最常見的通常是匿名介面類

A. 實體變數:自動有外部引用


class Message {
    String message;
}

class MyClazz {

    int value = 100;

    Message message = new Message() {
        // 匿名類的建構函數
        {
            message = "Hello anonymous class";
            System.out.println("message: " + message + ", value: " + value);
        }
    };

    public static void main(String[] args) {
        new MyClazz();
    }

}

● 匿名類沒有建構函數,但我們可以在匿名類別中 { } 符號中撰寫程式,這段程式可作為建構函數呼喚(由 JVM 自動呼叫該區塊)

B. 靜態變數:沒有外部引用


class MyClazz {

    int value = 100;


    static Message staticMessage = new Message() {
        {
            message = "Hello anonymous class";
            // Error! 無法存取外部成員變數
            // System.out.println("message: " + message + ", value: " + value);
            
            // 必須自己創建外部物件
            MyClazz mc = new MyClazz();
            System.out.println("message: " + message + ", value: " + mc.value);
        }
    };

}

C. 局部(方法)變數


void showMessage() {
    Message message = new Message() {
        {
            message = "Hello anonymous class";
        }
    };

    System.out.println("message: " + message.message + ", value: " + value);
}

內部類:Java 編譯後的文件名

● 經過 javac 編譯過後,JVM 對內類別名的規則如下

內部類規則說明
成員內部類外部名$內部名由於有實體名稱,所以使用外部實體名、內部實體名

public class ClzName {

    static class StaticClz { }

    class InnerClz { }
}

● Enum 也算實名內部類!編譯後的規則也如上

內部類規則說明
區域內部類(函數內的類)外部名$數字內部類名-

public class ClzName {

    void demoFunction() {

        class MethodClz { }

        class HelloClz { }

    }
}
內部類規則說明
匿名內部類外部類$數字由於是匿名類,所以後面沒有類別名稱很正常

class MyClz {

}

public class ClzName {

    MyClz myClz = new MyClz() { };

    void demoFunction() {

        MyClz funcMyClz = new MyClz() { };

    }
}

更多的 Java 語言相關文章

Java 語言深入

● 在這個系列中,我們全方位地探討了 Java 語言的各個核心主題,旨在幫助你徹底掌握這門強大的編程語言。無論你是想深入理解 Java 的基礎類型與變數作用域,還是探索異常處理與運算子的細節,這些文章都將為您提供寶貴的知識

深入 Java 物件導向

● 探索 Java 物件導向的奧妙,掌握介面、抽象類、繼承等重要概念!幫助你針對物件導向設計有更深入的了解!


Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響