每個開發者應該瞭解的SOLID原則
面向物件的程式設計模式為軟體開發帶來了新的設計理念。
這使開發人員能夠在一個類中組合具有相同目的/功能的資料,無論整個應用程式如何,這個類處理特定的事情。
但是,這種面向物件的程式設計還是不能預防寫出令人困惑或不可維護的程式。
因此,Robert C. Martin制定了五項指導方針。 這五個準則/原則使開發人員可以輕鬆建立可讀和可維護的程式。
這五個原則被稱為S.O.L.I.D原則(首字母縮寫詞由Michael Feathers派生)。
- S:Single Responsibility Principle 單一職責原則
- O:Open-Closed Principle 開發-關閉原則
- L:Liskov Substitution Principle 里氏替換原則
- I:Interface Segregation Principle 介面隔離原則
- D:Dependency Inversion Principle 依賴倒置原則
我們接下來會詳細的討論
注意:本文中的大多數示例可能不足以滿足實際需要或不適用於實際應用程式。 這一切都取決於您自己的設計和用例。 最重要的是要了解並知道如何應用/遵循原則 。
Single Responsibility Principle 單一職責原則
“…You had one job” — Loki to Skurge in Thor: Ragnarok A class should have only one job.
一個類應該只負責一件事。 如果一個類有多個職責,那麼它就會變得耦合。修改一項職責會導致要去修改另一項職責。
注意:這項原則不僅適用於類,也適用於元件和微服務
舉個例子,考慮如下的設計:
class Animal { constructor(name: string){ } getAnimalName() { } saveAnimal(a: Animal) { } }
這個Animal
類違反了SPR
(單一職責)。
為什麼?
SRP
宣告類應該有一個責任,在這裡,我們可以提出兩個職責:動物資料庫管理和動物屬性管理。saveAnimal
管理資料庫上的Animal
的儲存時,建構函式和getAnimalName
管理Animal
屬性。
這種設計以後會導致什麼問題?
如果程式以影響資料庫管理功能的方式更改,必須觸及並重新編譯使用Animal屬性的類以抵償新的更改。
這個系統看起來很有彈性,就像多米諾骨牌效應一樣,觸控一張卡就會影響所有其他卡片。
為了使其符合SRP,我們建立了另一個類,它將負責將動物儲存到資料庫:
class Animal { constructor(name: string){ } getAnimalName() { } } class AnimalDB { getAnimal(a: Animal) { } saveAnimal(a: Animal) { } }
我們在設計類的時候,應該將相關的功能放在一起,每當要變化的時候,他們會出於同樣的原因而改變。 如果功能因不同原因而發生變化,我們應該嘗試將功能分開。- 史蒂夫芬頓
通過正確使用這些,我們的應用程式變得高度凝聚力。
Open-Closed Principle 開放關閉原則
軟體實體(類,模組,函式)應該是可以擴充套件的,而不是修改。
我們繼續看我們的Animal
類:
class Animal { constructor(name: string){ } getAnimalName() { } }
我們想要遍歷一個動物的列表,並使它們發出聲音。
//... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse') ]; function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') log('roar'); if(a[i].name == 'mouse') log('squeak'); } } AnimalSound(animals);
AnimalSound
方法不符合開放式原則,因為它不能對新型別的動物進行封閉。
如果我們新增一個新的動物:蛇
//... const animals: Array<Animal> = [ new Animal('lion'), new Animal('mouse'), new Animal('snake') ] //...
我們必須去修改AnimalSound
這個方法:
//... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(a[i].name == 'lion') log('roar'); if(a[i].name == 'mouse') log('squeak'); if(a[i].name == 'snake') log('hiss'); } } AnimalSound(animals);
你看,對於每個新動物,都會向AnimalSound
函式新增一個新邏輯。這是一個非常簡單的例子。當您的應用程式增長並變得複雜時,您將看到每次在應用程式中新增新動物時,在AnimalSound
函式中將反覆重複if
語句。
我們如何使它(AnimalSound
)符合OCP
?
class Animal { makeSound(); //... } class Lion extends Animal { makeSound() { return 'roar'; } } class Squirrel extends Animal { makeSound() { return 'squeak'; } } class Snake extends Animal { makeSound() { return 'hiss'; } } //... function AnimalSound(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { log(a[i].makeSound()); } } AnimalSound(animals);
Animal現在有一個虛擬方法makeSound
。我們讓每個動物擴充套件Animal類並實現虛擬的makeSound
方法。
每個動物都會在makeSound
中新增自己的實現方式。AnimalSound
遍歷動物陣列並呼叫其makeSound
方法。
現在,如果我們新增一個新動物,AnimalSound
不需要改變。我們需要做的就是將新動物新增到動物陣列中。
AnimalSound
現在符合OCP原則。
另一個例子:
假設您有一個商店,您可以使用此類給您喜愛的客戶提供20%的折扣:
class Discount { giveDiscount() { return this.price * 0.2 } }
當您決定為VIP客戶提供雙倍的20%折扣。您可以像這樣修改類:
class Discount { giveDiscount() { if(this.customer == 'fav') { return this.price * 0.2; } if(this.customer == 'vip') { return this.price * 0.4; } } }
不,這不符合OCP原則。 OCP不允許這種寫法。如果我們想給一個不同的客戶新的百分比折扣,你需要新增新邏輯。
為了使其遵循OCP原則,我們將新增一個將擴充套件折扣的新類。在這個新類中,我們將實現其新行為:
class VIPDiscount: Discount { getDiscount() { return super.getDiscount() * 2; } }
如果您決定向超級VIP客戶提供80%的折扣,它應該是這樣的:
class SuperVIPDiscount: VIPDiscount { getDiscount() { return super.getDiscount() * 2; } }
你看到了,拓展而沒有修改。
Liskov Substitution Principle 里氏替換原則
子類必須可替代其超類
這個原則的目的是確定一個子類可以毫無錯誤地佔據其超類的位置。如果程式碼發現自己檢查類的型別,那麼它一定違反了這個原則。
我們使用動物的例子:
//... function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) log(LionLegCount(a[i])); if(typeof a[i] == Mouse) log(MouseLegCount(a[i])); if(typeof a[i] == Snake) log(SnakeLegCount(a[i])); } } AnimalLegCount(animals);
這違反了LSP原則(以及OCP原則)。它必須知道每種Animal型別並呼叫相關的Leg-Count
功能。
隨著動物的每一次新創造,該功能必須修改以接受新動物。
//... class Pigeon extends Animal { } const animals[]: Array<Animal> = [ //..., new Pigeon(); ] function AnimalLegCount(a: Array<Animal>) { for(int i = 0; i <= a.length; i++) { if(typeof a[i] == Lion) log(LionLegCount(a[i])); if(typeof a[i] == Mouse) log(MouseLegCount(a[i])); if(typeof a[i] == Snake) log(SnakeLegCount(a[i])); if(typeof a[i] == Pigeon) log(PigeonLegCount(a[i])); } } AnimalLegCount(animals);
為了使這個功能遵循LSP原則,我們將遵循Steve Fenton假設的LSP要求:
- 如果超類(Animal)有一個方法將一個超類型別(Animal)作為引數,它的子類(Pigeon)必須可以接受一個超類型別(Animal)或者子類型別(Pigeon)作為引數。
- 如果超類返回一個超類型別(Animal)。它的子類應該返回一個超類型別(Animal)或者子類型別(Pigeon)。
現在,我們可以重新實現AnimalLegCount函式:
function AnimalLegCount(a: Array<Animal>) { for(let i = 0; i <= a.length; i++) { a[i].LegCount(); } } AnimalLegCount(animals);
AnimalLegCount函式更少關注Animal傳遞的型別,它只調用LegCount方法。它只知道引數必須是Animal型別,Animal類或其子類。
Animal類現在必須實現/定義LegCount方法:
class Animal { //... LegCount(); }
並且它的子類必須實現LegCount方法:
//... class Lion extends Animal{ //... LegCount() { //... } } //...
當它傳遞給AnimalLegCount函式時,它返回獅子的腿數。
你看,AnimalLegCount不需要知道Animal的型別來返回它的腿數,它只是呼叫Animal型別的LegCount方法,因為通過約定,Animal類的子類必須實現LegCount函式。
Interface Segregation Principle 介面隔離原則
製作客戶特定的細粒度介面
不應強迫客戶端依賴於他們不使用的介面。
該原則處理實現大介面的缺點。
我們來看看下面的IShape介面:
interface IShape { drawCircle(); drawSquare(); drawRectangle(); }
此介面繪製正方形,圓形,矩形。實現IShape介面的類Circle,Square或Rectangle必須定義drawCircle(),drawSquare(),drawRectangle()方法。
class Circle implements IShape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Square implements IShape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } } class Rectangle implements IShape { drawCircle(){ //... } drawSquare(){ //... } drawRectangle(){ //... } }
看看上面的程式碼很有趣。類Rectangle實現了它沒有使用的方法(drawCircle和drawSquare),同樣類Square實現了drawCircle,drawRectangle,類Circle(drawSquare,drawSquare)。
如果我們向IShape介面新增另一個方法,比如drawTriangle(),
interface IShape { drawCircle(); drawSquare(); drawRectangle(); drawTriangle(); }
類必須實現新方法否則將丟擲錯誤。
我們發現,實現一個可以繪製圓形而不是矩形或正方形或三角形的形狀是不可能的。實現方法時丟擲一個錯誤,表明無法執行操作。
ISP對IShape介面的設計不滿意。客戶端(此處為Rectangle,Circle和Square)不應強制依賴於他們不需要或不使用的方法。此外,ISP宣告介面應該只執行一件事情(就像SRP原則一樣)任何額外的行為都應該被抽象到另一個介面。
在這裡,我們的IShape介面執行應由其他介面獨立處理的操作。
為了使我們的IShape介面符合ISP原則,我們將操作分離到不同的介面:
interface IShape { draw(); } interface ICircle { drawCircle(); } interface ISquare { drawSquare(); } interface IRectangle { drawRectangle(); } interface ITriangle { drawTriangle(); } class Circle implements ICircle { drawCircle() { //... } } class Square implements ISquare { drawSquare() { //... } } class Rectangle implements IRectangle { drawRectangle() { //... } } class Triangle implements ITriangle { drawTriangle() { //... } } class CustomShape implements IShape { draw(){ //... } }
The ICircle interface handles only the drawing of circles, IShape handles drawing of any shape :), ISquare handles the drawing of only squares and IRectangle handles drawing of rectangles.
ICircle介面只畫圓形,IShape介面可以畫任何形狀 :),ISquare介面只畫正方形,IRectangle介面只畫矩形。
或者
類(圓形,矩形,正方形,三角形等)可以從IShape介面繼承並實現它們自己的繪製行為。
class Circle implements IShape { draw(){ //... } } class Triangle implements IShape { draw(){ //... } } class Square implements IShape { draw(){ //... } } class Rectangle implements IShape { draw(){ //... } }
然後我們可以使用I -interfaces來建立Shape特徵,如Semi Circle,Right-Angleled Triangle,Equilateral Triangle,Blunt-Edged Rectangle等。
Dependency Inversion Principle 依賴倒置原則
依賴性應該是抽象而非凝聚
A.高階模組不應該依賴於低階模組。 兩者都應該依賴於抽象。
B.抽象不應該依賴於細節。 細節應取決於抽象。
在軟體開發中有一點我們的應用程式將主要由模組組成。 當發生這種情況時,我們必須通過使用依賴注入來清除問題。 高階元件取決於要執行的低階元件。
class XMLHttpService extends XMLHttpRequestService {} class Http { constructor(private xmlhttpService: XMLHttpService) { } get(url: string , options: any) { this.xmlhttpService.request(url,'GET'); } post() { this.xmlhttpService.request(url,'POST'); } //... }
這裡,Http是高階元件,而HttpService是低階元件。 此設計違反DIP A:高階模組不應依賴於低階模組。 它應該取決於它的抽象。
Http類被迫依賴於XMLHttpService類。 如果我們要改變Http連線服務,也許我們想通過Nodejs連線到網際網路,甚至模擬http服務。 我們必須改所有的Http例項程式碼,這違反了OCP原則。
Http類應該更少關注您正在使用的Http服務的型別。 我們建立一個Connection介面:
interface Connection { request(url: string, opts:any); }
Connection
介面有一個請求方法。 有了這個,我們將一個Connection
型別的引數傳遞給我們的Http
類:
class Http { constructor(private httpConnection: Connection) { } get(url: string , options: any) { this.httpConnection.request(url,'GET'); } post() { this.httpConnection.request(url,'POST'); } //... }
所以現在,無論傳遞給Http的Http連線服務的型別如何,它都可以輕鬆連線到網路,而無需知道網路連線的型別。
我們現在可以重新實現我們的XMLHttpService類來實現Connection介面:
class XMLHttpService implements Connection { const xhr = new XMLHttpRequest(); //... request(url: string, opts:any) { xhr.open(); xhr.send(); } }
我們可以建立許多Http Connection型別並將它傳遞給我們的Http類,而不用擔心錯誤。
class NodeHttpService implements Connection { request(url: string, opts:any) { //... } } class MockHttpService implements Connection { request(url: string, opts:any) { //... } }
現在,我們可以看到高階模組和低階模組都依賴於抽象。 Http類(高階模組)依賴於Connection介面(抽象)和Http Service類(低階模組)依賴於Connection介面(抽象)。
此外,DIP將強制我們不要違反里氏替換原則:連線型別Node-XML-MockHttpService可替換其父型別Connection。
結論
這裡涵蓋了每個軟體開發人員必須遵守的五項原則。 一開始可能難以遵守所有這些原則,但通過穩定的實踐和遵守,它將成為我們程式設計的一部分,並將極大地影響我們的應用程式的維護。
注: 本篇文章為譯文,原文連結
閱讀更多精彩的文章,請點個關注,或者收藏我的個人部落格 :)