設計模式:面向物件的設計原則上(SRP、OCP、LSP)

語言: CN / TW / HK

在面向物件的世界裡,可以分為:面向物件的基礎知識、面向物件的設計原則和設計模式,如果用武俠小說來做比喻,基礎知識就是需要練習的基本功、設計原則就是內功心法、設計模式則是各種各樣的具體招式,所以說熟練掌握了設計原則,就能以不變應萬變。

面向物件的設計原則,我們最熟悉的就是 SOLID 原則,SOLID 原則是五個常用原則的首字母縮寫,當然除了 SOLID 原則,還有一些其他的原則,所以後面就分為 SOLID 原則和其他原則兩大塊來介紹。

SOLID 原則指的是常用的五個設計原則:

  • 單一職責原則(SRP)

  • 開放封閉原則(OCP)

  • 里氏替換原則(LSP)

  • 介面隔離原則(ISP)

  • 依賴倒置原則(DIP)

我們平時寫程式碼會根據實際的業務情況建立類和方法,然後在方法中進行邏輯的編寫,SOLID 原則就是告訴我們應該怎麼合理地組織類和方法。最終使我們開發的程式能夠滿足:

  • 可擴充套件

  • 可複用

  • 可閱讀

這五個原則 Robert C. Martin  在《敏捷軟體開發:原則、模式與實踐》和《架構整潔之道》中都有完整地闡述,恰好,這兩本書我都有。

單一職責原則(SRP)

在面試時當問起單一職責原則時,很多同學都會回答,一個類或方法只做一件事,好像是對的,但也不全對。Robert C. Martin  在《敏捷軟體開發:原則、模式與實踐》給出的定義是「一個類應該只有一個發生變化的原因」,而到了 《架構整潔之道》定義變成了「任何一個軟體模組應該只對某一類行為者負責」。

現在就有三種定義了:

  • 只做一件事:是從內容的維度考慮,而不是變化的維度,一件事的這個事可大可小,如果是一個複雜的系統,也會產生出超級類。準確地說,這個不算是單一職責原則;

  • 只有一個發生變化的原因:軟體是在不斷迭代的,不可能不發生變化,常常一個類在頻繁地進行修改,原因就是不止一個變化的原因,所以讓類只有一個發生變化的原因,可以讓類更加內聚,但極端情況下,我們進行細粒度化地拆解,每個類可能只有一個方法了,這也不是想要的結果;

  • 只對某一類行為者負責:該定義除了變化,更是考慮了變化的來源,變化的來源就是平時提需求的人,這些人有著不同的職責和角色,按照這個維度,將不同的角色的人關注的內容劃分到不同的地方,類的劃分會更加合理。

舉個例子:低程式碼平臺中的表單模型,有下面一些場景:

  • 前臺表單開啟時的渲染;

  • 前臺表單資料的收集和儲存;

  • 後端表單佈局的設定;

  • 後端表單屬性的設定;

  • 後端表單中控制元件屬性的設定;

  • 後端表單拖入控制元件後根據資料模型的對接。

如果按照只做一件事的定義,這些場景都可以放在一個類中,因為都是跟表單相關的一件事,隨著功能的進化,表單相關的功能會越來越多,這個類也就會越來越龐大。

如果按照只有一個發生變化的原因的定義,上面列舉的場景會拆分成獨立的類,也有可能顆粒度更細,就容易變成過度設計了,導致複雜度變高。

最後一種,按照變化來源的維度,表單可以分為普通使用者的前臺使用和管理員進行表單模型設定兩種角色。按這兩種角色進行拆分,如果想要讓表單的佈局設定變得更易用,需要調整程式碼,就不會影響到前臺使用者的相關功能。

單一職責既指導我們怎麼進行程式碼的封裝,將什麼內容的程式碼放到一起,又告訴我們需要識別程式碼變化的來源,怎樣將揉在一起的程式碼進行合理地分解。

開放封閉原則(OCP)

只要我們的產品在進行迭代,就存在程式碼的新增和修改。只要存在程式碼的修改,就會帶來風險,OCP 原則讓他們儘量保持穩定的部分的不變,如果需要新增新的功能就使用擴充套件的方式進行實現。該原則的定義是:軟體實體(類、模組、函式)應該對擴充套件開放,對修改封閉。

在日常開發中,經常會有這樣的情況:

  • 一個很小的改動,預估半天就能完成,開發做著做著說時間不夠,關聯的地方太多了,最終兩三天才能完成;

  • 一個很小的改動,開發很快就調整完了,在驗證時發現其他很多不相干的地方出現各種問題。

究其原因,就是程式碼耦合性高,一個很小的程式碼改動會產生連鎖反應,擴充套件性差,OCP 原則就是解決擴充套件性問題的。

舉個例子:在低程式碼產品的列表模型有兩個關鍵點,資料來源和展現模式,起初,資料來源就是資料庫中的表,展示模式就是普通的表格,慢慢地列表模型會不斷地豐富:

  • 資料來源:表、檢視、儲存過程、API 介面等;

  • 展現模式:表格、樹、日曆、時間軸等。

如果程式碼都寫到一起,當出現這些新增需求的時候,就需要修改原來的程式碼:

  • 新增很多的 if 判斷;

  • 在方法中新增新的引數用來進行一些場景的判斷;

  • 為了不影響上層的呼叫,方法的引數設定成了可空,很容易導致後續開發人員在呼叫時的誤用。

使用 OCP 原則來看上面的例子,定義好資料輸出的格式和介面抽象,就不用關心背後的源是什麼,有任何的新的型別的新增,只需要擴充套件一個新的類進行相關邏輯的實現即可。

像我們熟悉的 VS Code 編輯器,只要符合介面標準,就能夠開發出各種各樣的外掛,這就是典型的面向擴充套件性的設計,符合 OCP 原則。

如果是單一職責原則的主要邏輯是封裝,那開放封閉原則的主要邏輯則是抽象(繼承)和多型。

里氏替換原則(LSP)

我們只要談及面向介面程式設計,就會涉及到繼承,繼承中的子類不是隨便怎麼寫都可以,而是要遵循一定的原則,這就是里氏替換原則發揮作用的地方。

1988 年,Barbara Liskov 在描述如何定義子型別時寫了這樣一段話:

這裡需要的是一種可替換性:如果對於每個型別是 S 的物件 o1 都存在一個型別為 T 的物件 o2 ,能使操作 T 型別的程式 P 在用 o2 替換 o1 時行為保持不變,我們就可以將 S 稱為 T 的子型別。

簡單的定義就是:子型別必須能夠替換掉他們的基型別。

下面拿書中的正方形和長方形的例子,可以很好的說明如果違反 LSP 後果會很嚴重。

按照我們的常識,正方形是一種特殊的長方形,所以正方形的類繼承長方形的類就理所當然了:

public class Rectangle
{
protected int _height;
protected int _width;

public virtual void SetHeight(int height)
{
this._height = height;
}
public virtual void SetWidth(int width)
{
this._width = width;
}
public int Area()
{
return _height * _width;
}
}

public class Square:Rectangle
{
private void SetSide(int side)
{
this._height = side;
this._width = side;
}

public override void SetHeight(int height)
{
SetSide(height);
}
public override void SetWidth(int width)
{
SetSide(width);
}
}

按照里氏替換的原則,子類要能夠替換父類,所以應該要能夠支援下面這種呼叫:

Rectangle rectangle = new Square();
rectangle.SetHeight(5);
rectangle.SetWidth(4);
int area = rectangle.Area();
if (area != 20)
{
throw new Exception("長和寬相乘和麵積不相等");
}
Console.WriteLine(area);
Console.ReadLine();

上面的程式碼,當 new 後面用子類 Square 替換了 Rectangle 後,area 的值就不是 20 了,所以是違反里氏替換原則的。雖然我們直覺上感覺正方形是一種特殊的長方形,但從程式碼邏輯的角度來看,正方形和長方形並不是 IS-A 的關係,而  IS-A 的關係是繼承時需要遵循的規則 。

IS-A 是指當 A 是 B 的子類,就需要滿足 A 是一個 B,判斷 A 是不是一個 B 可以根據所表現出來的行為,例如將鳥作為一個抽象,裡面只有一個行為吃,那麼貓、狗、魚都可以作為其子類,如果定義的行為只有飛,那麼鴕鳥也不能作為其子類。所以說只有行為相同,才是符合 IS-A 關係,也就不會違反 LSP 原則。

LSP 原則用來指導繼承關係中子類該如何設計,子類的設計要保證在替換父類的時候,不改變原有程式的邏輯以及不破壞原有程式的正確性。

由於篇幅的原因,下一篇再介紹介面隔離原則(ISP)和依賴倒置原則(DIP)。希望本文對您有所幫助。