面向對象的編碼設計原則
之前談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中提到數據防腐層設計,相關可以看我的
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 類實現了 Account 和 InterestBearing 接口,因為它有利息收益。而 CheckingAccount 類只實現了 Account 接口,因為它沒有利息收益。這樣,我們就避免了強制要求 CheckingAccount 實現它不需要的 getInterestRate() 方法,從而遵循了接口隔離原則。
LKP-最少知識原則(Least Knowledge Principle)
又叫迪米特法則(Law of Demeter, LoD),所謂最少知識,指的是,只和你的朋友談話。
這個原則用來指導我們在設計系統的時候,應該盡量減少對象之間的交互,對象只和自己的朋友談話,也就是只和自己的朋友交互,從而松散類之間的耦合。通過松散類之間的耦合來降低類之間的相互依賴,這樣在修改系統的某一個部分的時候,就不會影響其他的部分,從而使得系統具有更好的可維護性。
那么究竟哪些對象才能被當作朋友呢?最少知識原則提供了一些指導。
- 當前對象本身。
- 通過方法的參數傳遞進來的對象。
- 當前對象所創建的對象。
- 當前對象的實例變量所引用的對象。
- 方法內所創建或實例化的對象。
總之,最少知識原則要求我們的方法調用必須保持在一定的界限范圍之內,盡量減少對象的依賴關系。
設計原則與設計模式
通過前面的內容,我們大概能有個粗略答案了,即設計原則是抽象,設計模式有點像“對象”。其實設計原則與設計模式也有點這么個意思。
設計原則大多從思想層面給我們指出了面向對象分析設計的正確方向,是我們進行面向對象分析設計時應該盡力遵守的準則。是一種“抽象”。
而設計模式已經是針對某個場景下某些問題的某個解決方案。也就是說這些設計原則是思想上的指導,而設計模式是實現上的手段,因此設計模式也應該遵守這些原則,換句話說,設計模式就是這些設計原則的一些具體體現。是“對象”。
關于設計原則與設計模式的認識和選擇,主要有以下幾點:
- 設計原則本身是從思想層面上進行指導,本身是高度概括和原則性的。只是一個設計上的大體方向,其具體實現并非只有設計模式這一種。理論上來說,可以在相同的原則指導下,做出很多不同的實現來。
- 每一種設計模式并不是單一地體現某一個設計原則。事實上,很多設計模式都是融合了很多個設計原則的思想,并不好特別強調設計模式對某個或者是某些設計原則的體現。而且每個設計模式在應用的時候也會有很多的考量,不同使用場景下,突出體現的設計原則也可能是不一樣的。
- 這些設計原則只是一個建議指導。事實上,在實際開發中,很少做到完全遵守,總是在有意無意地違反一些或者是部分設計原則。設計工作本來就是一個不斷權衡的工作,有句話說得很好:“設計是一種危險的平衡藝術”。設計原則只是一個指導,有些時候,還要綜合考慮業務功能、實現的難度、系統性能、時間與空間等很多方面的問題。?

簡單講過程思維是數據結構加操作;對象思維則是一個整體,既包含數據結構又包含操作,也就是面向對象中的屬性和行為。 在進行面向對象設計和編碼的道路上,眾多知名前輩結合自己的實踐和認知高度抽象概況出了具有指導思想意義的設計原則。這里的每個原則細細品來都是意味深長,但是需要注意的是,就像數據庫范式一樣,它是個指導思想,并不是需要一板一眼遵守的“準則”。
浙公網安備 33010602011771號