[5分鐘]菜鳥修研之設計模式:六大設計原則
- 2020 年 4 月 4 日
- 筆記
[5分鐘]菜鳥修研之設計模式:六大設計原則
筆者作為一個菜鳥,會嘗試以簡單的程式碼和容易理解的語句去解釋這幾種原則的特性和應用場景。
這六種原則分別為單一職責原則、介面隔離原則、里氏替換原則、迪米特法則、依賴倒置原則、開閉原則。
單一職責原則
單一職責原則(SRP:Single responsibility principle),規定一個類中應該只有一個原因引起類的變化。
單一職責原則的核心就是解耦和增強內聚性。
問題:
// 假設此類是資料庫上下文 public class DatabaseContext { } public class Test { private readonly DatabaseContext _context; public Test(DatabaseContext context) { _context = context; } // 用戶登錄 public void UserLogin() { } // 用戶註銷 public void UserLogout() { } // 新增一個用戶 public void AddUser() { } // 修改一個用戶的資訊 public void UpdateUser() { } // 刪除一個用戶 public void DeleteUser() { } }
Test 負責 職責 P1(用戶登錄和退出)和 P2(用戶帳號管理) 兩個職責,當由於職責 P1 的需求發生變化而需要修改類時, 有可能會導致正常職責 P2 的功能發生故障。
上面的程式碼中,兩個職責被耦合起來,擔任了多種功能。
一個類中應該只有一個原因引起類的變化,也就要求一個類只應該負責一個功能,類中地程式碼是緊密聯繫的。
上面的示例程式碼非常簡單,我們可以很自然地將一個個類分為兩個部分。
// 假設此類是資料庫上下文 public class DatabaseContext { } public class Test1 { private readonly DatabaseContext _context; public Test1(DatabaseContext context) { _context = context; } // 用戶登錄 public void UserLogin() { } // 用戶註銷 public void UserLogout() { } } public class Test2 { private readonly DatabaseContext _context; public Test2(DatabaseContext context) { _context = context; } // 新增一個用戶 public void AddUser() { } // 修改一個用戶的資訊 public void UpdateUser() { } // 刪除一個用戶 public void DeleteUser() { } }
因此,單一職責原則的解決方法,是將不同職責封裝到不同的類或模組中。
介面隔離原則
介面隔離原則(ISP:Interface Segregation Principle) 要求對介面進行細分,類的繼承建立在最小的粒度上,確保客戶端繼承的介面中,每一個方法都是它需要的。
筆者查閱了國外一些資料,大多將介面隔離原則定義為:
「Clients should not be forced to depend upon interfaces that they do not use.」
意思是不應強迫客戶依賴於它不使用的方法。
對於此原則的解釋,這篇文章講的非常透徹:
https://stackify.com/interface-segregation-principle/
這就要求我們拆分臃腫的介面成為更小的和更具體的介面,使得介面負責的功能更加單一。
目的:通過將軟體分為多個獨立的部分來減少所需更改的副作用和頻率。
筆者想到從兩方面論述:
其一,在描述多種動物時,我們可能會將不同種類的動物分類。但是這還不夠,例如在鳥類中,我們印象中鳥的特徵是鳥會飛,但是企鵝不會飛~。
那麼還要對物種的特徵進行細分,例如血液是什麼顏色的、有沒有脊椎等。
其二,我們可以通過下面程式碼表達:
// 與登錄有關 public interface IUserLogin { // 登錄 void Login(); // 註銷 void Logout(); } // 與用戶帳號有關 public interface IUserInfo { // 新增一個用戶 void AddUser(); // 修改一個用戶的資訊 void UpdateUser(); // 刪除一個用戶 void DeleteUser(); }
上面的兩個介面,各種實現不同的功能,彼此沒有交叉,完美。
接下來我們看看兩個繼承了 IUserLogin 介面的程式碼
// 對用戶登錄註銷進行管理,資源準備和釋放 public class Test1 : IUserLogin { public void Login(){} public void Logout(){} } public class Test2 : IUserLogin { public void Login() { // 獲取用戶未讀消息 } public void Logout() { } }
對於 Test1 ,根據登錄和註銷兩個狀態,進行不同操作。
但是,對於 Test2,它只需要登錄這個狀態,其它情況不關它事。那麼 Logout() 對他來說,完全沒有用,這就是介面污染。
上面的程式碼就違法了介面隔離原則。
但是,介面隔離原則有個缺點,就是容易過多地將細分介面。一個項目中,出現成千上萬個介面,將是維護地災難。
因此介面隔離原則要靈活使用,就 Test2 來說,多繼承一個方法無傷大礙,不用就是了。ASP.NET Core 中就存在很多這樣的實現。
public void Function() { throw new NotImplementedException(); }
《設計模式之禪》第四章中,作者對介面隔離原則總結了四個要求:
介面盡量小:不出現臃腫(Fat)的介面。
介面要高內聚:提高介面、類、模組的處理能力。
訂製服務:小粒度的介面可以組成大介面,靈活訂製新的功能。
介面的設計有限度:難以有固定的標準去衡量介面的粒度是否合理。
另外還有關於單一職責原則和介面隔離原則的關係和對比。
單一職責原則是從服務提供者的角度去看,提供一個高內聚的、單一職責的功能;
介面隔離原則是從使用者角度去看,也是實現高內聚和低耦合。
介面隔離原則的粒度可能更小,通過多個介面靈活組成一個符合單一職責原則的類。
我們也看到了,單一職責原則更多是圍繞類來討論;介面隔離原則是對介面來討論,即對抽象進行討論。
開閉原則
開閉原則(Open/Closed Principle)規定 :
「軟體中的對象(類,模組,函數等等)應該對於擴展是開放的,但是對於修改是封閉的」
–《Object-Oriented Software Construction》作者 Bertrand Meyer
開閉原則意味著一個實體是允許在不改變它的源程式碼的前提下變更它的行為。類的改動是通過增加程式碼實現,而不是修改源程式碼。
開閉原則 有 梅耶開閉原則、多態開閉原則。
-
梅耶開閉原則
程式碼一旦完成,一個類的實現只應該因錯誤而修改,新的或者改變的特性應該通過新建不同的類實現。
特點:繼承,子類繼承父類,擁有其所有的方法,並且拓展。
-
多態開閉原則
此原則使用介面而不是父類來允許不同的實現,您可以在不更改它們的程式碼的情況下輕鬆替換它們。
現在大多數情況下,開閉原則指的是多態開閉原則。
多態開閉原則筆者在查閱資料是,發現這個介面指的不是 Interface ,指的是抽象方法、虛方法。
問:面向對象的三大特性是什麼?答:封裝、繼承、多態。
對,多態開閉原則就是指這個多態。不過,原則要求不應對方法進行重載(重寫)、隱藏。
這是一個示例:
// 實現登錄註銷 public class UserLogin { public void Login() { } public void Logout() { } public virtual void A() {/* 做了一些事*/} public virtual void B() {/* 也做了一些事*/ } } public class UserLogin1 : UserLogin { public void Login(string userName) { } // 應不應該對父類的方法進行重載? public override void A() { } // √ public override void B() { } // √ public new void Logout() { } // 也許行? }
多態開閉原則的好處是,引入了抽象,使得兩個類松耦合,而且可以使得在不修改程式碼的前提下,使用子類替換父類(里氏替換原則)。
有時,會看到這樣的題目:介面和抽象類的區別?
筆者隱約記得有過一條這樣的解釋:介面是為了實現共同的標準;抽象是為了程式碼的復用。
當然,介面和抽象,都可以實現里氏替換。
通過開閉原則,我們可以了解到多態,也了解介面和抽象的應用場景。
還有一個問題是,開閉原則要求是要修改或添加功能時,通過子類來實現,而不是修改原有程式碼。那麼是否可以和應該對父類的程式碼進行重載和隱藏?
而開閉原則的核心是構造抽象,從而通過子類派生來實現拓展。貌似沒有說到這方面。
筆者覺得不太應該。。。
先結合下面的里氏替換原則,我們再討論這個問題?
里氏替換原則
里氏替換原則(LSP:Liskov Substitution Principle)要求:凡是父類出現的地方,子類都可以出現。
這就要求了子類必須與父類具有相同的行為。只有當子類能夠替換任何父類的實例時,才會符合里氏替換原則。
里氏替換原則的約束:
- 子類必須實現父類的抽象方法,但不能重寫父類中已實現的方法。
- 子類中可以增加方法拓展功能。
- 當子類覆蓋或實現(虛擬方法/抽象方法)父類的方法時,方法的輸入參數限制更加寬鬆並且返回值要比父類方法更加嚴格。
所以,我們看到開閉原則中的示例,子類應不應該重載父類的方法?應不應該使用 new 關鍵字隱藏父類的方法?為了確保子類繼承後,還具有跟父類一致的特性,不建議這樣做呢,親。
實現了開閉原則,自然可以使用里氏替換原則。
依賴倒置原則
依賴倒置原則(Dependence Inversion Principle)要求程式要依賴於抽象介面,不要依賴於具體實現。
我們可以從程式碼中,慢慢演進和推導理論。
// 實現登錄註銷 public class UserLogin { public void Login(){} public void Logout(){} } public class Test1 : UserLogin { } public class Test2 { private readonly UserLogin userLogin = new UserLogin(); } public class Test3 { private readonly UserLogin _userLogin; public Test3(UserLogin userLogin) { _userLogin = userLogin; } }
上面程式碼中,Test1、Test2、Test3 都依賴 UserLogin 。先不說上面程式碼有什麼毛病,根據依賴倒置原則,應該是這樣編寫程式碼的
// 與登錄有關 public interface IUserLogin { void Login(); // 登錄 void Logout(); // 註銷 } // 實現登錄註銷 public class UserLogin1 : IUserLogin { public void Login(){} public void Logout(){} } // 實現登錄註銷 public class UserLogin2 : IUserLogin { public void Login(){} public void Logout(){} } public class Test4 { private readonly IUserLogin _userLogin; public Test4(IUserLogin userLogin) { _userLogin = userLogin; } }
依賴倒置原則,在於引入一種抽象,這種抽象將高級模組和底層模組彼此分離。高層模組和底層模組松耦合,底層模組的變動不需要高層模組也要變動。
依賴導致原則有兩個思想:
-
高層模組不應該依賴於底層模組,兩者都應該依賴於抽象。
-
抽象不應該依賴細節,細節應該依賴於抽象。
因為依賴於抽象,底層模組可以任意替換一個實現了抽象的模組。
里氏替換原則是要求子類父類行為一致,子類可以替換父類。
依賴倒置原則,每個方法的行為是可以完全不一樣的。
迪米特法則
迪米特法則(Law of Demeter)要求兩個類之間儘可能保持最小的聯繫。
例如 對象A 不應該直接調用 對象B,而是應該通過 中間對象C 來保持通訊。
請參考 https://en.wikipedia.org/wiki/Law_of_Demeter
優勢:松耦合,較少了依賴。
缺點:要編寫許多包裝程式碼,增加複雜讀,模組之間的通訊效率變低。
筆者找了很多資料,發現都是 java 的。。。
最終發現有個是以 C# 講述的文章 https://www.cnblogs.com/zh7791/p/7922960.html
一般來說,較少會提到迪米特原則,程式碼符合依賴倒置原則和里氏替換原則等,也就算是符合迪米特法則了。