物件導向的編碼設計原則

糖拌西红柿發表於2024-08-05

之前談DDD時提及過當下很多標榜物件導向的語言,卻是以程序導向來實現的問題。這裡就從編碼設計的角度來順一下物件導向設計的一些思維。其實就像我正在梳理的設計模式一樣,都是些老生常談的東西,可是往往在實踐的時候,這些老生常談的東西會被“反芻”,總會有種常看常新的感覺。

物件導向思想

其實想要進行DDD實踐,不可避免地就要進行OOA和OOD,這裡主要是對OOD的一些設計準則和思想進行梳理。

抽象

物件導向的核心技術就是抽象,相比於程序導向基於資料結構進行步驟式命令開發的思維,物件導向則是以人的思維模式去進行思考,其中,對事物共性、本質的提取就是抽象。

打個比方,人作為現實生活中的一個實體,我們可以很直觀的看到,人都會有性別、年齡、身高、體重等等的一些公共屬性,除此之外,人還會使用語言溝通,會吃飯,會開車等一系列的行為,於是,我們進行總結,人是一種具有性別、年齡、體重……且會說話、睡覺…的物類,而這個總結,幾乎適用於所有的人,於是,人類的概念被概括出來。透過這個過程就會發現,我們的思考過程是,先有了一個模糊的物類,然後在該物類中提取公共的部分進行整和,最後整個模糊的物類就具象化了,整個過程就是歸納、總結。這就是抽象。

對應到程式設計中,OOP需要對我們程式、業務中的一些主體進行特徵的抽取、然後彙總,最後清晰的定義出來,這就是物件導向的第一步,即將實際場景存在或需求中的事物進行泛化,提取公共部分,進行型別的定義。抽象的結果是型別,也就是類。

物件

我們將一個定義抽象出來之後,可以根據這個定義,任意的產生一個具體的例項,這就是程式設計中的Class與具體的new Object,物件是根據抽象出的型別的例項化,我們定義了人類的特徵和行為(即編寫了一個Class),便可以根據這個Class,產出一個具體的個體來(new 出一個物件),就像我們每個人生活在地球這個環境中交流。程式也是一樣,在物件導向的程式世界中,物件才是主角,程式是一個執行態,顯然不是由抽象的類來工作,而是由抽象的類所具象化的一個個具體物件來通訊、交流。

物件導向需要在意的幾個意識:

  • 一切皆是物件:在程式中,任何事務都是物件,可以把物件看作一個奇特的變數,它可以儲存,可以通訊,可以從自身來進行各自操作,你總是可以從要解決的問題身上抽象出概念性的元件,然後在程式中將其表示為一個物件。
  • 程式是物件的集合,它透過傳送訊息來告知彼此需要做什麼:程式就像是個自然環境,一個人,一頭豬,一顆樹,一個斧頭,都是這個環境中的具體物件,物件之間相互的通訊,操作來完成一件事,這便是程式中的一個流程,要請求呼叫一個物件的方法,你需要向該物件傳送訊息。
  • 每個物件都有自己的儲存空間,可容納其他物件:人會有手機,一個人是一個物件,一個手機也是一個物件,而手機可以是人物件中的一部分,或者說,透過封裝現有物件,可製作出新型物件。所以,儘管物件的概念非常簡單,但在程式中卻可達到任意高的複雜程度。
  • 每個物件都擁有其型別:按照通用的說法,任何一個物件,都是某個“類(Class)”的例項,每個物件都必須有其依賴的抽象。
  • 同一類所有物件都能接收相同的訊息:這實際是別有含義的一種說法,大家不久便能理解。由於型別為“圓”(Circle)的一個物件也屬於型別為“形狀”(Shape)的一個物件,所以一個圓完全能接收傳送給"形狀”的訊息。這意味著可讓程式程式碼統一指揮“形狀”,令其自動控制所有符合“形狀”描述的物件,其中自然包括“圓”。這一特性稱為物件的“可替換性”,是OOP最重要的概念之一。

過程思維和物件思維

簡單講過程思維是資料結構加操作;物件思維則是一個整體,既包含資料結構又包含操作,也就是物件導向中的屬性和行為。

物件導向設計原則

在進行物件導向設計和編碼的道路上,眾多知名前輩結合自己的實踐和認知高度抽象概況出了具有指導思想意義的設計原則。這裡的每個原則細細品來都是意味深長,但是需要注意的是,就像資料庫正規化一樣,它是個指導思想,並不是需要一板一眼遵守的“準則”。

SRP-單一職責原則(Single Responsibility Principle)

單一職責的官方定義:

一個類應該只有一個引起它變化的原因

這裡變化的原因就是所說的“職責”,如果一個類有多個引起它變化的原因,那麼也就意味著這個類有多個職責,再進一步說,就是把多個職責耦合在一起了。這會造成職責的相互影響,可能一個職責的變化,會影響到其他職責的實現,甚至引起其他職責隨著變化,這種設計是很脆弱的。
這個原則看起來是最簡單和最好理解的,但是實際上是很難完全做到的,難點在於如何區分“職責”。這是個沒有標準量化的東西,哪些算職責、到底這個職責有多大的粒度、這個職責如何細化等等,例如:

public class FileUtil {
    public void readFile(String filePath) {
        // 讀取檔案的程式碼
    }

    public void writeFile(String filePath, String content) {
        // 寫入檔案的程式碼
    }

    public void encryptFile(String filePath) {
        // 加密檔案的程式碼
    }

    public void decryptFile(String filePath) {
        // 解密檔案的程式碼
    }
}

我們的開發習慣經常會根據一個物件或者概念+操作去定義一個Util,這個Util會作為公共處理程式碼來幫我們處理系統中關於檔案相關的操作。但是嚴格來講,這是違背了單一職責原則的,因為如果將來需要修改檔案的讀取邏輯或加密演算法,可能會影響到其他功能,這就違反了單一職責原則。如果想要嚴格遵守單一職責,應該改為:

// 負責檔案讀取的類
public class FileReader {
    public void readFile(String filePath) {
        // 讀取檔案的程式碼
    }
}

// 負責檔案寫入的類
public class FileWriter {
    public void writeFile(String filePath, String content) {
        // 寫入檔案的程式碼
    }
}

// 負責檔案加密的類
public class FileEncryptor {
    public void encryptFile(String filePath) {
        // 加密檔案的程式碼
    }

    public void decryptFile(String filePath) {
        // 解密檔案的程式碼
    }
}

現在,每個類都只有一個職責:

  • FileReader 類只負責讀取檔案。
  • FileWriter 類只負責寫入檔案。
  • FileEncryptor 類負責檔案的加密和解密。

這樣,每個類的變更原因都只有一個,符合單一職責原則。如果需要修改檔案讀取邏輯,只需要修改FileReader類;如果需要修改加密演算法,只需要修改FileEncryptor類,而不會影響到其他類。但是實際專案中如果真嚴苛到每個操作都細化為一個類,多半會被人罵SB。

因此,在實際開發中,這個原則最容易被違反,因為這個度的把控是很難的。我們能做的就是基於專案實際情況的操作粒度來把控這個“職責”,如果專案中對於檔案的操作,改動和牽扯範圍很廣,那嚴格遵守單一職責會帶來很好的擴充套件性和維護性,但是如果專案十分簡單,基於公共Util且萬年不變,那完全沒有必要進行單一職責改造,單體一個專案一個Util足夠了。

OCP-開閉原則(Open-Closed Principle)

類應該對擴充套件開放,對修改關閉。

開閉原則要求的是,類的行為是可以擴充套件的,而且是在不修改已有程式碼的情況下進行擴充套件,也不必改動已有的原始碼或者二進位制程式碼。
這看起來好像是矛盾的,但這是指實際的編碼過程中,畢竟這是一個指導思想,站在指導思想的角度上來看,也未必矛盾;實現開閉原則的關鍵就在於合理地抽象、分離出變化與不變化的部分,為變化的部分預留下可擴充套件的方式,比如,鉤子方法或是動態組合物件等。
這個原則看起來也很簡單。但事實上,一個系統要全部做到遵守開閉原則,幾乎是不可能的,也沒這個必要。適度的抽象可以提高系統的靈活性,使其可擴充套件、可維護,但是過度地抽象,會大大增加系統的複雜程度。應該在需要改變的地方應用開閉原則就可以了,而不用到處使用,從而陷入過度設計。

LSP-里氏替換原則(Liskov Substitution Principle)

子類物件應該能夠替換掉它們的父類物件,而不影響程式的行為。

簡單來講就是子類可以替換掉父類在程式中的位置而不影響程式的使用,這是一種基於物件導向的多型的使用。它可以避免在多型的使用過程中出現某些隱蔽的錯誤。

public abstract class Account {
    private String accountNumber;
    private double balance;
    public Account(String accountNumber, double balance) {
        this.accountNumber = accountNumber;
        this.balance = balance;
    }
    public String getAccountNumber() {
        return accountNumber;
    }
    public double getBalance() {
        return balance;
    }
    public void deposit(double amount) {
        balance += amount;
        System.out.println("Deposited: " + amount + ", New Balance: " + balance);
    }
    public abstract void withdraw(double amount);
}

//賬戶的派生類
public class CheckingAccount extends Account {
    private double overdraftLimit;
    public CheckingAccount(String accountNumber, double balance, double overdraftLimit) {
        super(accountNumber, balance);
        this.overdraftLimit = overdraftLimit;
    }
    @Override
    public void withdraw(double amount) {
        if (amount <= balance + overdraftLimit) {
            balance -= amount;
            System.out.println("Withdrew: " + amount + ", New Balance: " + balance);
        } else {
            System.out.println("Insufficient funds for withdrawal: " + amount);
        }
    }
    public double getOverdraftLimit() {
        return overdraftLimit;
    }
}
//里氏替換使用場景
public class Bank {
    private List<Account> accounts = new ArrayList<>();

    public void addAccount(Account account) {
        accounts.add(account);
    }

    public void processTransactions() {
        for (Account account : accounts) {
            account.withdraw(100); // 假設每個賬戶都嘗試取出100元
            account.deposit(50);  // 假設每個賬戶都存入50元
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Bank bank = new Bank();
        bank.addAccount(new Account("123456", 1000));
        bank.addAccount(new CheckingAccount("789012", 500, 300));

        bank.processTransactions();
    }
}

這個符合里氏替換原則的樣例的關鍵點是,無論是普通的 Account 物件還是 CheckingAccount 物件,都可以被 Account 型別的變數處理,而不需要任何特殊邏輯來區分它們。這就是里氏替換原則的體現:CheckingAccount 物件可以無縫替換 Account 物件,而不會破壞 Bank 類的行為。

事實上,當一個類繼承了另外一個類,那麼子類就擁有了父類中可以繼承下來的屬性和操作。理論上來說,此時使用子型別去替換掉父型別,應該不會引起原來使用父型別的程式出現錯誤。
但是,在某些情況下是會出現問題的。比如,如果子型別覆蓋了父型別的某些方法,或者是子型別修改了父型別某些屬性的值,那麼原來使用父型別的程式就可能會出現錯誤,因為在執行期間,從表面上看,它呼叫的是父型別的方法,需要的是父型別方法實現的功能,但是實際執行呼叫的卻是子型別覆蓋實現的方法,而該方法和父型別的方法並不一樣,於是導致錯誤的產生。
從另外一個角度來說,里氏替換原則是實現開閉的主要原則之一。開閉原則要求對擴充套件開放,擴充套件的一個實現手段就是使用繼承:而里氏替換原則是保證子型別能夠正確替換父型別,只有能正確替換,才能實現擴充套件,否則擴充套件了也會出現錯誤

DIP-依賴倒置原則(Dependence Inversion Principle)

高層模組不應依賴於低層模組,兩者都應該依賴於抽象;抽象不應依賴於細節,細節應依賴於抽象

所謂依賴倒置原則,指的是,要依賴於抽象,不要依賴於具體類。要做到依賴倒置典型的應該做到:

  • 高層模組不應該依賴於底層模組,二者都應該依賴於抽象。
  • 抽象不應該依賴於具體實現,具體實現應該依賴於抽象

很多人覺得,層次化呼叫的時候,應該是高層呼叫“底層所擁有的介面”,這是一種典型的誤解。事實上,一般高層模組包含對業務功能的處理和業務策略選擇,應該被重用,是高層模組去影響底層的具體實現。
因此,這個底層的介面應該是由高層提出的,然後由底層實現的。也就是說底層的介面的所有權在高層模組,因此是一種所有權的倒置。
比較經典的案例應該是COLA中提到資料防腐層設計,相關可以看我的COLA框架那一篇 。

ISP-介面隔離原則(Interface Segregation Principle)

不應該強迫客戶依賴於它們不使用的方法。一個類不應該實現它不需要的介面。

這個原則用來處理那些比較“龐大”的介面,這種介面通常會有較多的操作宣告,涉及到很多的職責。客戶在使用這樣的介面的時候,通常會有很多他不需要的方法,這些方法對於客戶來講,就是一種介面汙染,相當於強迫使用者在一大堆“垃圾方法”中去尋找他需要的方法。其實有一點“介面的單一職責”的意思。
因此,這樣的介面應該被分離,應該按照不同的客戶需要來分離成為針對客戶的介面。這樣的介面中,只包含客戶需要的操作宣告,這樣既方便了客戶的使用,也可以避免因誤用介面而導致的錯誤。
分離介面的方式,除了直接進行程式碼分離之外,還可以使用委託來分離介面,在能夠支援多重繼承的語言中,還可以採用多重繼承的方式進行分離。

透過一個正反案例體會一下,假設我們有一個銀行系統,其中包括兩種型別的賬戶:儲蓄賬戶(SavingsAccount)和支票賬戶(CheckingAccount)。儲蓄賬戶提供存款和獲取利息的功能,而支票賬戶提供存款、取款和透支的功能。

反例:

interface BankAccount {
    void deposit(double amount);
    void withdraw(double amount);
    double getInterestRate();
}

class SavingsAccount implements BankAccount {
    private double balance;
    public SavingsAccount(double initialDeposit) {
        this.balance = initialDeposit;
    }
    @Override
    public void deposit(double amount) {
        balance += amount;
    }
    @Override
    public void withdraw(double amount) {
        // 儲蓄賬戶不允許透支
        if (amount <= balance) {
            balance -= amount;
        } else {
            throw new IllegalArgumentException("Insufficient funds");
        }
    }
    @Override
    public double getInterestRate() {
        return 0.03; // 假設利息率為3%
    }
}

class CheckingAccount implements BankAccount {
    private double balance;
    private double overdraftLimit;
    public CheckingAccount(double initialDeposit, double overdraftLimit) {
        this.balance = initialDeposit;
        this.overdraftLimit = overdraftLimit;
    }
    @Override
    public void deposit(double amount) {
        balance += amount;
    }
    @Override
    public void withdraw(double amount) {
        if (amount <= balance + overdraftLimit) {
            balance -= amount;
        } else {
            throw new IllegalArgumentException("Insufficient funds for overdraft");
        }
    }
    @Override
    public double getInterestRate() {
        // 支票賬戶通常沒有利息
        return 0.0;
    }
}

這裡BankAccount 介面強制要求所有賬戶實現 getInterestRate() 方法,這違反了ISP,因為不是所有型別的賬戶都有利息。如果想要符合ISP,應該講使用者公共操作分為兩個介面,進一步保證介面功能的單一性。

public interface Account {
    void deposit(double amount);
    void withdraw(double amount);
}

public interface InterestBearing {
    double getInterestRate();
}

public class SavingsAccount implements Account, InterestBearing {
    private double balance;

    public SavingsAccount(double initialDeposit) {
        this.balance = initialDeposit;
    }

    @Override
    public void deposit(double amount) {
        balance += amount;
    }

    @Override
    public void withdraw(double amount) {
        if (amount <= balance) {
            balance -= amount;
        } else {
            throw new IllegalArgumentException("Insufficient funds");
        }
    }

    @Override
    public double getInterestRate() {
        return 0.03; // 假設利息率為3%
    }
}

public class CheckingAccount implements Account {
    private double balance;
    private double overdraftLimit;

    public CheckingAccount(double initialDeposit, double overdraftLimit) {
        this.balance = initialDeposit;
        this.overdraftLimit = overdraftLimit;
    }

    @Override
    public void deposit(double amount) {
        balance += amount;
    }

    @Override
    public void withdraw(double amount) {
        if (amount <= balance + overdraftLimit) {
            balance -= amount;
        } else {
            throw new IllegalArgumentException("Insufficient funds for overdraft");
        }
    }
}

Account 介面包含所有賬戶共有的操作,而 InterestBearing 介面僅包含與利息相關的操作。SavingsAccount 類實現了 AccountInterestBearing 介面,因為它有利息收益。而 CheckingAccount 類只實現了 Account 介面,因為它沒有利息收益。這樣,我們就避免了強制要求 CheckingAccount 實現它不需要的 getInterestRate() 方法,從而遵循了介面隔離原則。

LKP-最少知識原則(Least Knowledge Principle)

又叫迪米特法則(Law of Demeter, LoD),所謂最少知識,指的是,只和你的朋友談話。
這個原則用來指導我們在設計系統的時候,應該儘量減少物件之間的互動,物件只和自己的朋友談話,也就是隻和自己的朋友互動,從而鬆散類之間的耦合。透過鬆散類之間的耦合來降低類之間的相互依賴,這樣在修改系統的某一個部分的時候,就不會影響其他的部分,從而使得系統具有更好的可維護性。
那麼究竟哪些物件才能被當作朋友呢?最少知識原則提供了一些指導。

  • 當前物件本身。
  • 透過方法的引數傳遞進來的物件。
  • 當前物件所建立的物件。
  • 當前物件的例項變數所引用的物件。
  • 方法內所建立或例項化的物件。

總之,最少知識原則要求我們的方法呼叫必須保持在一定的界限範圍之內,儘量減少物件的依賴關係。

設計原則與設計模式

透過前面的內容,我們大概能有個粗略答案了,即設計原則是抽象,設計模式有點像“物件”。其實設計原則與設計模式也有點這麼個意思。

設計原則大多從思想層面給我們指出了物件導向分析設計的正確方向,是我們進行物件導向分析設計時應該盡力遵守的準則。是一種“抽象”。

而設計模式已經是針對某個場景下某些問題的某個解決方案。也就是說這些設計原則是思想上的指導,而設計模式是實現上的手段,因此設計模式也應該遵守這些原則,換句話說,設計模式就是這些設計原則的一些具體體現。是“物件”。

關於設計原則與設計模式的認識和選擇,主要有以下幾點:

    • 設計原則本身是從思想層面上進行指導,本身是高度概括和原則性的。只是一個設計上的大體方向,其具體實現並非只有設計模式這一種。理論上來說,可以在相同的原則指導下,做出很多不同的實現來。
    • 每一種設計模式並不是單一地體現某一個設計原則。事實上,很多設計模式都是融合了很多個設計原則的思想,並不好特別強調設計模式對某個或者是某些設計原則的體現。而且每個設計模式在應用的時候也會有很多的考量,不同使用場景下,突出體現的設計原則也可能是不一樣的。
    • 這些設計原則只是一個建議指導。事實上,在實際開發中,很少做到完全遵守,總是在有意無意地違反一些或者是部分設計原則。設計工作本來就是一個不斷權衡的工作,有句話說得很好:“設計是一種危險的平衡藝術”。設計原則只是一個指導,有些時候,還要綜合考慮業務功能、實現的難度、系統效能、時間與空間等很多方面的問題。

相關文章