By Chidume Nnamdi | Oct 9, 2018
物件導向的程式設計型別為軟體開發帶來了新的設計。
這使開發人員能夠在一個類中組合具有相同目的/功能的資料,來實現單獨的一個功能,不必關心整個應用程式如何。
但是,這種物件導向的程式設計還是會讓開發者困惑或者寫出來的程式可維護性不好。
為此,Robert C.Martin指定了五項指導方針。遵循這五項指導方針能讓開發人員輕鬆寫出可讀性和可維護性高的程式
這五個原則被稱為S.O.L.I.D原則(首字母縮寫詞由Michael Feathers派生)。
- S:單一責任原則
- O:開閉原則
- L:裡式替換
- I:介面隔離
- D:依賴反轉
我們在下文會詳細討論它們
筆記:本文的大多數例子可能不適合實際應用或不滿足實際需求。這一切都取決於您自己的設計和用例。這都不重要,關鍵是您要了解明白這五項原則。
單一責任原則
“......你有一份工作” - Loki來到雷神的Skurge:Ragnarok
一個類只實現一個功能
一個類應該只負責一件事。如果一個類負責超過一件事,就會變得耦合。改功能的時候會影響另外一個功能。
- 筆記:該原則不僅適用於類,還適用於軟體元件和微服務。
舉個例子,考慮這個設計:
class Animal {
constructor(name: string){ }
getAnimalName() { }
saveAnimal(a: Animal) { }
}
複製程式碼
這個Animal類違反了SRP(單一責任原則)
怎麼違反了呢?
SRP明確說明了類只能完成一項功能,這裡,我們把兩個功能都加上去了:animal資料管理和animal屬性管理。建構函式和getAnimalName方法管理Animal的屬性,然而,saveAnimal方法管理Animal的資料儲存。
這種設計會給以後的開發維護帶來什麼問題?
如果app的更改會影響資料庫的操作。必須會觸及並重新編譯使用Animal屬性的類以使app的更改生效。
你會發現這樣的系統缺乏彈性,像多米諾骨牌一樣,更改一處會影響其他所有的地方。
讓我們遵循SRP原則,我們建立了另外一個用於資料操作的類:
class Animal {
constructor(name: string){ }
getAnimalName() { }
}
class AnimalDB {
getAnimal(a: Animal) { }
saveAnimal(a: Animal) { }
}
複製程式碼
“我們在設計類時,我們應該把相關的功能放在一起,所以當他們需要發生改變時,他們會因為同樣的原因而改變。如果是因為不同的原因需要改變它們,我們應該嘗試把它們分開。” - Steven Fenton
遵循這些原則讓我們的app變得高內聚。
開閉原則
軟體實體(類,模組,函式)應該是可以擴充套件的,而不是修改。
繼續看我們的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')
return 'roar';
if(a[i].name == 'mouse')
return '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')
return 'roar';
if(a[i].name == 'mouse')
return 'squeak';
if(a[i].name == 'snake')
return 'hiss';
}
}
AnimalSound(animals);
複製程式碼
每當新的動物加入,AnimalSound函式就需要加新的邏輯。這是個很簡單的例子。當你的app變得龐大和複雜時,你會發現每次加新動物的時候就會加一條if語句,隨後你的app和AnimalSound函式都是if語句的身影。
那怎麼修改AnimalSound函式呢?
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++) {
a[i].makeSound();
}
}
AnimalSound(animals);
複製程式碼
現在Animal有個makeSound的私有方法。我們每一個animal繼承了Animal類並且實現了私有方法makeSound。
每個animal例項都會在makeSound中新增自己的實現方式。AnimalSound方法遍歷animal陣列並呼叫其makeSound方法。
現在,如果我們新增了新動物,AnimalSound方法不需要改變。我們需要做的就是新增新動物到動物陣列。
AnimalSound方法現在遵循了開閉原則。
另一個例子:
假設您有一個商店,並且您使用此類給您喜愛的客戶打2折:
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;
}
}
}
複製程式碼
哈哈哈,這樣不就背離開閉原則了麼?如果我們又想加新的折扣,那又是一堆if語句。
為了遵循開閉原則,我們建立了繼承Discount的新類。在這個新類中,我們將會實現新的行為:
class VIPDiscount: Discount {
getDiscount() {
return super.getDiscount() * 2;
}
}
複製程式碼
如果你決定給VIP80%的折扣,就像這樣:
class SuperVIPDiscount: VIPDiscount {
getDiscount() {
return super.getDiscount() * 2;
}
}
複製程式碼
你看,這不就不用改了。
里氏替換
A sub-class must be substitutable for its super-class
這個原則的目的是確定一個子類可以毫無錯誤地佔據其超類的位置。如果程式碼會檢查自己類的型別,它一定違反了這個原則。
繼續Animal例子。
//...
function AnimalLegCount(a: Array<Animal>) {
for(int i = 0; i <= a.length; i++) {
if(typeof a[i] == Lion)
return LionLegCount(a[i]);
if(typeof a[i] == Mouse)
return MouseLegCount(a[i]);
if(typeof a[i] == Snake)
return SnakeLegCount(a[i]);
}
}
AnimalLegCount(animals);
複製程式碼
這已經違反了里氏替換(也違反了OCP原則)。它必須知道每個Animal的型別並且呼叫leg-conunting相關(返回動物腿數)的方法。
如果要加入新的動物,這個方法必須經過修改才能加入。
//...
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)
return LionLegCount(a[i]);
if(typeof a[i] == Mouse)
return MouseLegCount(a[i]);
if(typeof a[i] == Snake)
return SnakeLegCount(a[i]);
if(typeof a[i] == Pigeon)
return PigeonLegCount(a[i]);
}
}
AnimalLegCount(animals);
複製程式碼
來,我們依據里氏替換改造這個方法,我們按照Steve Fenton說的來:
- 如果超類(Animal)有一個接受超類型別(Animal)引數的方法。 它的子類(Pigeon)應該接受超型別(Animal型別)或子類型別(Pigeon型別)作為引數。
- 如果超類返回超類型別(Animal)。 它的子類應該返回一個超型別(Animal型別)或子類型別(Pigeon)。
現在,開始改造:
function AnimalLegCount(a: Array<Animal>) {
for(let i = 0; i <= a.length; i++) {
a[i].LegCount();
}
}
AnimalLegCount(animals);
複製程式碼
AnimalLegCount函式更少關注傳遞的Animal型別,它只呼叫LegCount方法。它就只知道這引數是Animal型別,或者是其子類。
Animal類現在必須實現/定義一個LegCount方法:
class Animal {
//...
LegCount();
}
複製程式碼
然後它的子類就需要實現LegCount方法:
//...
class Lion extends Animal{
//...
LegCount() {
//...
}
}
//...
複製程式碼
當它傳遞給AnimalLegCount方法時,他返回獅子的腿數。
你看,AnimalLegCount不需要知道Animal的型別來返回它的腿數,它只呼叫Animal型別的LegCount方法,Animal類的子類必須實現LegCount函式。
介面隔離原則
制定特定客戶的細粒度介面 不應強迫客戶端依賴它不需要的介面
該原則解決實現大介面的缺點。
讓我們看下下面這段程式碼:
interface Shape {
drawCircle();
drawSquare();
drawRectangle();
}
複製程式碼
這個介面定義了畫正方形、圓形、矩形的方法。圓類、正方形類或者矩形類就必須實現 drawCircle()、 drawSquare()、drawRectangle().
class Circle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Square implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
class Rectangle implements Shape {
drawCircle(){
//...
}
drawSquare(){
//...
}
drawRectangle(){
//...
}
}
複製程式碼
上面的程式碼看著很好笑。矩形類實現了它不需要的方法。其他類也同樣的。
讓我們再加一個介面。
interface Shape {
drawCircle();
drawSquare();
drawRectangle();
drawTriangle();
}
複製程式碼
類必須實現新方法,否則將丟擲錯誤。
我們看到不可能實現可以繪製圓形而不是矩形或正方形或三角形的形狀。 我們可以實現方法來丟擲一個錯誤,表明無法執行操作。
這個Shape介面的設計不符合介面隔離原則。(此處為Rectangle,Circle和Square)不應強制依賴於他們不需要或不使用的方法。
此外,介面隔離原則要求介面應該只執行一個動作(就像單一責任原則一樣)任何額外的行為分組都應該被抽象到另一個介面。
這裡,我們的Shape介面執行應由其他介面獨立處理的動作。
為了使我們的Shape介面符合ISP原則,我們將操作分離到不同的介面:
interface Shape {
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 Shape {
draw(){
//...
}
}
複製程式碼
ICircle介面僅處理圓形繪畫,Shape處理任何形狀的繪圖:),ISquare處理僅正方形的繪製和IRectangle處理矩形繪製。
依賴反轉
依賴應該是抽象而不是concretions 高階模組不應該依賴於低階模組。 兩者都應該取決於抽象。 抽象不應該依賴於細節。 細節應取決於抽象。
在軟體開發有一點,就是我們的app主要由模組組成。當發生這種情況時,我們必須通過使用依賴注入來清除問題。 高階元件取決於低階元件的功能。
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是低階元件。此設計違反依賴反轉第一條:高階模組不應該依賴於低階模組。 兩者都應該取決於抽象。
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類的無需知道它正在使用什麼型別的服務。它都能正常工作。
我們現在可以重新寫我們的XMLHttpService類來實現Connection介面:
class XMLHttpService implements Connection {
const xhr = new XMLHttpRequest();
//...
request(url: string, opts:any) {
xhr.open();
xhr.send();
}
}
複製程式碼
我們可以建立很多各種用途的Http類並且不用擔心出問題。
class NodeHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
class MockHttpService implements Connection {
request(url: string, opts:any) {
//...
}
}
複製程式碼
現在,我們可以看到高階模組和低階模組都依賴於抽象。Http類(高階模組)依賴Connection介面(抽象),Http服務(低階模組)實現Connection介面。
此外,依賴反轉還強制我們不要違反裡式替換:連線型別Node-XML-MockHttpService可替換其父型別Connection。
結論
我們涵蓋了每個軟體開發人員必須遵守的五項原則。 一開始可能難以遵守所有這些原則,但通過長期的堅持,它將成為我們的一部分,並將極大地影響我們的應用程式的維護。
如果您有任何疑問,請隨時在下面發表評論,我很樂意談談!