面向對象基本原則(3)- 最少知道原則與開閉原則
五、最少知道原則【迪米特法則】
1. 最少知道原則簡介
最少知識原則(Least KnowledgePrinciple,LKP)也稱爲迪米特法則(Law of Demeter,LoD)。雖然名字不同,但描述的是同一個規則:一個對象應該對其他對象有最少的瞭解。
通俗地講,一個類應該對自己需要耦合或調用的類知道得最少,你(被耦合或調用的類)的內部是如何複雜都和我沒關係,那是你的事情,我就知道你提供的這麼多public方法,我就調用這麼多,其他的我一概不關心。
2. 最少知道原則實現
只與直接關聯的類交流
每個對象都必然會與其他對象有耦合關係,耦合關係的類型有很多,例如組合、聚合、依賴等。
出現在成員變量、方法的輸入輸出參數中的類稱爲直接關聯的類,而出現在方法體內部的類不屬於直接關聯的類。
下面舉例說明如何才能做到只與直接關聯的類交流。
場景:老師想讓班長清點女生的數量
- Bad
/**
* 老師類
* Class Teacher
*/
class Teacher {
/**
* 老師對班長髮布命令,清點女生數量
* @param GroupLeader $groupLeader
*/
public function command(GroupLeader $groupLeader)
{
// 產生一個女生羣體
$girlList = new \ArrayIterator();
// 初始化女生
for($i = 0; $i < 20; $i++){
$girlList->append(new Girl());
}
// 告訴班長開始執行清點任務
$groupLeader->countGirls($girlList);
}
}
/**
* 班長類
* Class GroupLeader
*/
class GroupLeader {
/**
* 清點女生數量
* @param \ArrayIterator $girlList
*/
public function countGirls($girlList)
{
echo "女生數量是:", $girlList->count(), "\n";
}
}
/**
* 女生類
* Class Girl
*/
class Girl {
}
$teacher= new Teacher();
//老師發佈命令
$teacher->command(new GroupLeader()); // 女生數量是:20
上面實例中,Teacher類僅有一個直接關聯的類 -- GroupLeader。而Girl這個類就是出現在commond方法體內,因此不屬於與Teacher類直接關聯的類。
方法是類的一個行爲,類竟然不知道自己的行爲與其他類產生依賴關係,這是不允許的,違反了迪米特法則。
對程序進行簡單的修改,把 對 $girlList 的初始化移出 Teacher 類,同時在 GroupLeader 中增加對 Girl 的注入,避開 Teacher 類對陌生類 Girl 的訪問,降低系統間的耦合,提高系統的健壯性。
下面是改進後的代碼:
- Good
/**
* 老師類
* Class Teacher
*/
class Teacher {
/**
* 老師對班長髮布命令,清點女生數量
* @param GroupLeader $groupLeader
*/
public function command(GroupLeader $groupLeader)
{
// 告訴班長開始執行清點任務
$groupLeader->countGirls();
}
}
/**
* 班長類
* Class GroupLeader
*/
class GroupLeader {
private $_girlList;
/**
* 傳遞全班的女生進來
* GroupLeader constructor.
* @param Girl[]|\ArrayIterator $girlList
*/
public function __construct(\ArrayIterator $girlList)
{
$this->_girlList = $girlList;
}
//清查女生數量
public function countGirls()
{
echo "女生數量是:", $this->_girlList->count(), "\n";
}
}
/**
* 女生類
* Class Girl
*/
class Girl {
}
// 產生一個女生羣體
$girlList = new \ArrayIterator();
// 初始化女生
for($i = 0; $i < 20; $i++){
$girlList->append(new Girl());
}
$teacher= new Teacher();
//老師發佈命令
$teacher->command(new GroupLeader($girlList)); // 女生數量是:20
關聯的類之間也要有距離
迪米特法則要求類“羞澀”一點,儘量不要對外公佈太多的public方法和非靜態的public變量,儘量內斂,多使用private、protected等訪問權限。
一個類公開的public屬性或方法越多,修改時涉及的面也就越大,變更引起的風險擴散也就越大。因此,爲了保持類間的距離,在設計時需要反覆衡量:是否還可以再減少public方法和屬性,是否可以修改爲private、protected等訪問權限,是否可以加上final關鍵字等。
實例場景:實現軟件安裝的過程,其中first方法定義第一步做什麼,second方法定義第二步做什麼,third方法定義第三步做什麼。
- Bad
/**
* 導向類
* Class Wizard
*/
class Wizard {
/**
* 第一步
* @return int
*/
public function first()
{
echo "執行第一步安裝...\n";
// 模擬用戶點是或取消
return rand(0, 1);
}
/**
* 第二步
* @return int
*/
public function second()
{
echo "執行第二步安裝...\n";
// 模擬用戶點是或取消
return rand(0, 1);
}
/**
* 第三步
* @return int
*/
public function third()
{
echo "執行第三步安裝...\n";
// 模擬用戶點是或取消
return rand(0, 1);
}
}
/**
* 安裝軟件類
* Class InstallSoftware
*/
class InstallSoftware {
/**
* 執行安裝軟件操作
* @param Wizard $wizard
*/
public function installWizard(Wizard $wizard)
{
$first = $wizard->first();
//根據first返回的結果,看是否需要執行second
if($first === 1){
$second = $wizard->second();
if($second === 1){
$third = $wizard->third();
if($third === 1){
echo "軟件安裝完成!\n";
}
}
}
}
}
// 實例化軟件安裝類
$invoker = new InstallSoftware();
// 開始安裝軟件
$invoker->installWizard(new Wizard()); // 運行結果和隨機數有關,每次的執行結果都不相同
Wizard類把太多的方法暴露給InstallSoftware類,兩者的朋友關係太親密了,耦合關係變得異常牢固。如果要將Wizard類中的first方法返回值的類型由int改爲boolean,就需要修改InstallSoftware類,從而把修改變更的風險擴散開了。因此,這樣的耦合是極度不合適的。
改進:在Wizard類中增加一個installWizard方法,對安裝過程進行封裝,同時把原有的三個public方法修改爲private方法。
/**
* 導向類
* Class Wizard
*/
class Wizard {
//第一步
private function first()
{
echo "執行第1個方法...\n";
// 模擬用戶點是或取消
return rand(0, 1);
}
//第二步
private function second()
{
echo "執行第2個方法...\n";
// 模擬用戶點是或取消
return rand(0, 1);
}
//第三個方法
private function third()
{
echo "執行第3個方法...\n";
// 模擬用戶點是或取消
return rand(0, 1);
}
public function installWizard(){
$first = $this->first();
//根據first返回的結果,看是否需要執行second
if($first === 1){
$second = $this->second();
if($second === 1){
$third = $this->third();
if($third === 1){
echo "軟件安裝完成!\n";
}
}
}
}
}
/**
* 安裝軟件類
* Class InstallSoftware
*/
class InstallSoftware {
/**
* 執行安裝軟件操作
* @param Wizard $wizard
*/
public function installWizard(Wizard $wizard)
{
$wizard->installWizard();
}
}
// 實例化軟件安裝類
$invoker = new InstallSoftware();
// 開始安裝軟件
$invoker->installWizard(new Wizard()); // 運行結果和隨機數有關,每次的執行結果都不相同
代碼改進後,類間的耦合關係變弱了,結構也清晰了,變更引起的風險也變小了。
3. 最佳實踐
在實際應用中經常會出現這樣一個方法:放在本類中也可以,放在其他類中也沒有錯,那怎麼去衡量呢?
你可以堅持這樣一個原則:如果一個方法放在本類中,既不增加類間關係,也對本類不產生負面影響,那就放置在本類中。
在實際應用中,如果一個類跳轉兩次以上才能訪問到另一個類,就需要想辦法進行重構了。
因爲一個系統的成功不僅僅是一個標準或是原則就能夠決定的,有非常多的外在因素決定,跳轉次數越多,系統越複雜,維護就越困難,所以只要跳轉不超過兩次都是可以忍受的,這需要具體問題具體分析。
迪米特法則要求類間解耦,但解耦是有限度的,除非是計算機的最小單元——二進制的0和1。那纔是完全解耦,在實際的項目中,需要適度地考慮這個原則,別爲了套用原則而做項目。
原則只是供參考,如果違背了這個原則,項目也未必會失敗,這就需要大家在採用原則時反覆度量,不遵循是不對的,嚴格執行就是“過猶不及”。
六、開閉原則
1. 開閉原則簡介
開閉原則的英文名稱是 Open-Close Principle,簡稱OCP。
開閉原則是面向對象設計中最基礎的設計原則,它指導我們如何建立一個穩定、靈活的軟件系統。
開閉原則的英文定義是
Software entities like classes,modules and functions should be open for extension but closed for modifications.
一個軟件實體如類、模塊和函數應該對擴展開放,對修改關閉。 其含義是說一個軟件實體應該通過擴展來實現變化,而不是通過修改已有的代碼來實現變化。
軟件實體包括以下幾個部分:
- 項目或軟件產品中按照一定的邏輯規則劃分的模塊。
- 抽象和類。
- 方法。
一個軟件產品只要在生命期內,都會發生變化,既然變化是一個既定的事實,我們就應該在設計時儘量適應這些變化,以提高項目的穩定性和靈活性,真正實現“擁抱變化”。開閉原則告訴我們應儘量通過擴展軟件實體的行爲來實現變化,而不是通過修改已有的代碼來完成變化,它是爲軟件實體的未來事件而制定的對現行開發設計進行約束的一個原則。
2. 開閉原則的優點
提高複用率
在面向對象的設計中,所有的邏輯都是從原子邏輯組合而來的,而不是在一個類中獨立實現一個業務邏輯。只有這樣代碼纔可以複用,粒度越小,被複用的可能性就越大。
複用可以減少代碼量,避免相同的邏輯分散在多個角落,避免日後的維護人員爲了修改一個微小的缺陷或增加新功能而要在整個項目中到處查找相關的代碼。
那怎麼才能提高複用率呢?縮小邏輯粒度,直到一個邏輯不可再拆分爲止。
提高可維護性
一款軟件投產後,維護人員的工作不僅僅是對數據進行維護,還可能要對程序進行擴展,維護人員最樂意做的事情就是擴展一個類,而不是修改一個類,甭管原有的代碼寫得多麼優秀還是多麼糟糕,讓維護人員讀懂原有的代碼,然後再修改,是一件很痛苦的事情,不要讓他在原有的代碼海洋裏遊弋完畢後再修改,那是對維護人員的一種折磨和摧殘。
面向對象開發的要求
萬物皆對象,我們需要把所有的事物都抽象成對象,然後針對對象進行操作,但是萬物皆運動,有運動就有變化,有變化就要有策略去應對。怎麼快速應對呢?這就需要在設計之初考慮到所有可能變化的因素,然後留下接口,等待“可能”轉變爲“現實”。
2. 變化的三種類型
邏輯變化
只變化一個邏輯,而不涉及其他模塊,比如原有的一個算法是 a*b+c
,現在需要修改爲 a*b*c
,可以通過修改原有類中的方法的方式來完成,前提條件是所有依賴或關聯類都按照相同的邏輯處理。
子模塊變化
一個模塊變化,會對其他的模塊產生影響,特別是一個低層次的模塊變化必然引起高層模塊的變化,因此在通過擴展完成變化時,高層次的模塊修改是必然的,剛剛的書籍打折處理就是類似的處理模塊,該部分的變化甚至會引起界面的變化。
視圖變化
可見視圖是提供給客戶使用的界面,該部分的變化一般會引起連鎖反應。如果僅僅是界面上按鈕、文字的重新排布倒是簡單,最司空見慣的是業務耦合變化,例如一個展示數據的列表,按照原有的需求是6列,突然有一天要增加1列,而且這一列要跨N張表,處理M個邏輯才能展現出來,這樣的變化是比較恐怖的,但還是可以通過擴展來完成變化,這就要看我們原有的設計是否靈活。
3. 開閉原則的使用
抽象約束
抽象是對一組事物的通用描述,沒有具體的實現,也就表示它可以有非常多的可能性,可以跟隨需求的變化而變化。因此,通過接口或抽象類可以約束一組可能變化的行爲,並且能夠實現對擴展開放,其包含三層含義:
第一,通過接口或抽象類約束擴展,對擴展進行邊界限定,不允許出現在接口或抽象類中不存在的public方法;
第二,參數類型、引用對象儘量使用接口或者抽象類,而不是實現類;
第三,抽象層儘量保持穩定,一旦確定即不允許修改。
封裝變化
對變化的封裝包含兩層含義:
第一,將相同的變化封裝到一個接口或抽象類中;
第二,將不同的變化封裝到不同的接口或抽象類中,不應該有兩個不同的變化出現在同一個接口或抽象類中。
封裝變化,也就是受保護的變化(protected variations),找出預計有變化或不穩定的點,我們爲這些變化點創建穩定的接口,準確地講是封裝可能發生的變化,一旦預測到或“第六感”發覺有變化,就可以進行封裝。
4. Show me the code
書店售書場景
代碼使用PHP7.2語法編寫
- 書籍接口
/**
* Interface IBook
* 書籍接口
*/
interface IBook {
/**
* 書籍名稱
* @return mixed
*/
public function getName() : string ;
/**
* 書籍價格
* 這裏把價格定義爲int類型並不是錯誤,
* 在非金融類項目中對貨幣處理時,一般取2位精度,
* 通常的設計方法是在運算過程中擴大100倍,在需要展示時再縮小100倍,減少精度帶來的誤差。
* @return mixed
*/
public function getPrice() : int ;
/**
* 書籍作者
* @return mixed
*/
public function getAuthor() : string ;
}
- 小說類
/**
* 小說類
* Class NovelBook
*/
class NovelBook implements IBook {
/**
* 書籍名稱
* @var string $_name
*/
private $_name;
/**
* 書籍價格
* @var int $_price
*/
private $_price;
/**
* 書籍作者
* @var string $_author
*/
private $_author;
/**
* 通過構造函數傳遞書籍信息
* @param string $name
* @param int $price
* @param string $author
*/
public function __construct(string $name, int $price, string $author)
{
$this->_name = $name;
$this->_price = $price;
$this->_author = $author;
}
/**
* 獲取書籍名稱
* @return string
*/
public function getName() : string
{
return $this->_name;
}
/**
* 獲取書籍價格
* @return int
*/
public function getPrice() : int
{
return $this->_price;
}
/**
* 獲取書籍作者
* @return string
*/
public function getAuthor() : string
{
return $this->_author;
}
}
- 售書場景
// 產生一個書籍列表
$bookList = new ArrayIterator();
// 始化數據
$bookList->append(new NovelBook("天龍八部",3200,"金庸"));
$bookList->append(new NovelBook("巴黎聖母院",5600,"雨果"));
echo "------書店賣出去的書籍記錄如下:--------\n";
foreach($bookList as $book){
$price = $book->getPrice() / 100;
echo <<<TXT
書籍名稱: {$book->getName()}
書籍作者: {$book->getAuthor()}
書籍價格: {$price} 元
---\n
TXT;
}
------書店賣出去的書籍記錄如下:--------
書籍名稱: 天龍八部
書籍作者: 金庸
書籍價格: 32 元
---
書籍名稱: 巴黎聖母院
書籍作者: 雨果
書籍價格: 56 元
---
一段時間之後,書店決定對小說類書籍進行打折促銷:所有40元以上的書籍9折銷售,其他的8折銷售。面對需求的變化,我們有兩種解決方案。
- 修改實現類NovelBook
直接修改NovelBook類中的getPrice()方法實現打折處理。該方法在項目有明確的章程(團隊內約束)或優良的架構設計時,是一個非常優秀的方法,但是該方法還是有缺陷的。例如採購書籍人員也是要看價格的,由於該方法已經實現了打折處理價格,因此採購人員看到的也是打折後的價格,會因信息不對稱而出現決策失誤的情況。
- 通過擴展實現變化
增加一個子類OffNovelBook,覆寫getPrice方法,高層次的模塊通過OffNovelBook類產生新的對象,完成業務變化對系統的最小化開發,修改少,風險也小。
- 打折銷售的小說類
/**
* 打折銷售的小說類
* Class OffNovelBook
*/
class OffNovelBook extends NovelBook {
/**
* 覆寫獲取銷售價格方法
*
* @return int
*/
public function getPrice() : int
{
//原價
$originPrice = parent::getPrice();
if($originPrice > 40){ //原價大於40元,則打9折
$discountPrice = $originPrice * 90 / 100;
}else{
$discountPrice = $originPrice * 80 / 100;
}
return $discountPrice;
}
}
- 打折售書場景
// 產生一個書籍列表
$bookList = new ArrayIterator();
// 始化數據,實際項目中一般是由持久層完成
$bookList->append(new OffNovelBook("天龍八部",3200,"金庸"));
$bookList->append(new OffNovelBook("巴黎聖母院",5600,"雨果"));
echo "------書店賣出去的書籍記錄如下:------\n";
foreach($bookList as $book){
$price = $book->getPrice() / 100;
echo <<<TXT
書籍名稱: {$book->getName()}
書籍作者: {$book->getAuthor()}
書籍價格: {$price} 元
---\n
TXT;
}
------書店賣出去的書籍記錄如下:------
書籍名稱: 天龍八部
書籍作者: 金庸
書籍價格: 28.8 元
---
書籍名稱: 巴黎聖母院
書籍作者: 雨果
書籍價格: 50.4 元
---
又過了一段時間,書店新增加了計算機書籍,它不僅包含書籍名稱、作者、價格等信息,還有一個獨特的屬性:面向的是什麼領域,也就是它的範圍,比如是和編程語言相關的,還是和數據庫相關的,等等。
- 增加一個IComputerBook接口,它繼承自IBook
/**
* 計算機類書籍接口
* Interface IComputerBook
*/
interface IComputerBook extends IBook
{
/**
* 計算機書籍增加一個範圍屬性
* @return string
*/
public function getScope() : string ;
}
- 計算機書籍類
/**
* 計算機書籍類
* Class ComputerBook
*/
class ComputerBook implements IComputerBook
{
/**
* 書籍名稱
* @var string $_name
*/
private $_name;
/**
* 書籍價格
* @var int $_price
*/
private $_price;
/**
* 書籍作者
* @var string $_author
*/
private $_author;
/**
* 書籍範圍
* @var string $_scope
*/
private $_scope;
/**
* 通過構造函數傳遞書籍信息
* ComputerBook constructor.
* @param string $name
* @param int $price
* @param string $author
* @param string $scope
*/
public function __construct(string $name, int $price, string $author, string $scope)
{
$this->_name = $name;
$this->_price = $price;
$this->_author = $author;
$this->_scope = $scope;
}
/**
* 獲取書籍名稱
* @return string
*/
public function getName() : string
{
return $this->_name;
}
/**
* 獲取書籍價格
* @return int
*/
public function getPrice() : int
{
return $this->_price;
}
/**
* 獲取書籍作者
* @return string
*/
public function getAuthor() : string
{
return $this->_author;
}
/**
* 獲取書籍範圍
* @return string
*/
public function getScope() : string
{
return $this->_scope;
}
}
- 增加計算機書籍銷售
//產生一個書籍列表
$bookList = new ArrayIterator();
// 始化數據,實際項目中一般是由持久層完成
$bookList->append(new OffNovelBook("天龍八部",3200,"金庸"));
$bookList->append(new OffNovelBook("巴黎聖母院",5600,"雨果"));
$bookList->append(new ComputerBook("高性能MySQL",4800,"Baron", '數據庫'));
echo "------書店賣出去的書籍記錄如下:------\n";
foreach($bookList as $book) {
$price = $book->getPrice() / 100;
echo <<<TXT
書籍名稱: {$book->getName()}
書籍作者: {$book->getAuthor()}
書籍價格: {$price} 元
---\n
TXT;
}
------書店賣出去的書籍記錄如下:------
書籍名稱: 天龍八部
書籍作者: 金庸
書籍價格: 28.8 元
---
書籍名稱: 巴黎聖母院
書籍作者: 雨果
書籍價格: 50.4 元
---
書籍名稱: 高性能MySQL
書籍作者: Baron
書籍價格: 48 元
---
開閉原則對擴展開放,對修改關閉,並不意味着不做任何修改,低層模塊的變更,必然要有高層模塊進行耦合,否則就是一個孤立無意義的代碼片段。
參考文獻:《設計模式之禪》