By Chidume Nnamdi | Oct 9, 2018
面向對象的編程類型爲軟件開發帶來了新的設計。
這使開發人員能夠在一個類中組合具有相同目的/功能的數據,來實現單獨的一個功能,不必關心整個應用程序如何。
但是,這種面向對象的編程還是會讓開發者困惑或者寫出來的程序可維護性不好。
爲此,Robert C.Martin指定了五項指導方針。遵循這五項指導方針能讓開發人員輕鬆寫出可讀性和可維護性高的程序
這五個原則被稱爲S.O.L.I.D原則(首字母縮寫詞由Michael Feathers派生)。
- S:單一責任原則
- O:開閉原則
- L:裏式替換
- I:接口隔離
- D:依賴反轉
我們在下文會詳細討論它們
筆記:本文的大多數例子可能不適合實際應用或不滿足實際需求。這一切都取決於您自己的設計和用例。這都不重要,關鍵是您要了解明白這五項原則。
提示:SOLID原則旨在用於構建模塊化、封裝、可擴展和可組合組件的軟件。Bit是一個幫助你踐行這些原則的強大工具:它可以幫助您在團隊中大規模地在不同項目中輕鬆隔離,共享和管理這些組件.來試試吧。
你也可以在這裏學習更多關於SOLID原則和Bit的東西。
單一責任原則
“......你有一份工作” - 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。
結論
我們涵蓋了每個軟件開發人員必須遵守的五項原則。 一開始可能難以遵守所有這些原則,但通過長期的堅持,它將成爲我們的一部分,並將極大地影響我們的應用程序的維護。
如果您有任何疑問,請隨時在下面發表評論,我很樂意談談!