沒錯,這就是物件導向程式設計(設計模式)需要遵循的 6 個基本原則

liuqing_hu發表於2018-08-23

本文首發於 沒錯,這就是物件導向程式設計(設計模式)需要遵循的 6 個基本原則,轉載請註明出處。

在討論物件導向程式設計和模式(具體一點來說,設計模式)的時候,我們需要一些標準來對設計的好還進行判斷,或者說應該遵循怎樣的原則和指導方針。

現在,我們就來了解下這些原則:

  • 單一職責原則(S)
  • 開閉原則(O)
  • 里氏替換原則(L)
  • 介面隔離原則(I)
  • 依賴倒置原則(D)
  • 合成複用原則
  • 及迪米特法則(最少知道原則)

本文將涵蓋 SOLID + 合成複用原則的講解及示例,迪米特法則以擴充套件閱讀形式給出。

單一職責原則(Single Responsibility Principle[SRP]) ★★★★

一個類只負責一個功能領域中的相應職責(只做一類事情)。或者說一個類僅能有一個引起它變化的原因。

來看看一個功能過重的類示例:

/**
 * CustomerDataChart 客戶圖片處理
 */
class CustomerDataChart
{
    /**
    * 獲取資料庫連線
    */
    public function getConnection()
    {
    }

    /**
    * 查詢所有客戶資訊
    */
    public function findCustomers()
    {
    }

    /**
    * 建立圖表
    */
    public function createChart()
    {
    }

    /**
    * 顯示圖表
    */
    public function displayChart()
    {
    }
}

我們發現 CustomerDataChart 類完成多個職能:

  • 建立資料庫連線
  • 查詢客戶
  • 建立和顯示圖表

此時,其它類若需要使用資料庫連線,無法複用 CustomerDataChart;或者想要查詢客戶也無法實現複用。另外,修改資料庫連線或修改圖表顯示方式都需要修改 CustomerDataChart 類。這個問題挺嚴重的,無論修改什麼功能都需要多這個類進行編碼。

所以,我們採用 單一職責原則 對類進行重構,如下:

/**
 * DB 類負責完成資料庫連線操作
 */
class DB
{
    public function getConnection()
    {
    }
}

/**
 * Customer 類用於從資料庫中查詢客戶記錄
 */
class Customer
{
    private $db;

    public function __construct(DB $db)
    {
        $this->db = $db;
    }

    public function findCustomers()
    {
    }
}

class CustomerDataChart
{
    private $customer;

    public function __construct(Customer $customer)
    {
        $this->customer = $customer;
    }

    /**
    * 建立圖表
    */
    public function createChart()
    {
    }

    /**
    * 顯示圖表
    */
    public function displayChart()
    {
    }
}

重構完成後:

  • DB 類僅處理資料庫連線的問題,挺提供 getConnection() 方法獲取資料庫連線;
  • Customer 類完成操作 Customers 資料表的任務,這其中包括 CRUD 的方法;
  • CustomerDataChart 實現建立和顯示圖表。

各司其職,配合默契,完美!

開閉原則(Open-Closed Principle[OCP]) ★★★★★

開閉原則最重要 的物件導向設計原則,是可複用設計的基石。

「開閉原則」:對擴充套件開放、對修改關閉,即儘量在不修改原有程式碼的基礎上進行擴充套件。要想系統滿足「開閉原則」,需要對系統進行 抽象

通過 介面抽象類 將系統進行抽象化設計,然後通過實現類對系統進行擴充套件。當有新需求需要修改系統行為,簡單的通過增加新的實現類,就能實現擴充套件業務,達到在不修改已有程式碼的基礎上擴充套件系統功能這一目標。

示例,系統提供多種圖表展現形式,如柱狀圖、餅狀圖,下面是不符合開閉原則的實現:

<?php

/**
 * 顯示圖表
 */
class ChartDisplay
{
    private $chart;

    /**
     * @param string $type 圖示實現型別
     */
    public function __construct(string $type)
    {
        switch ($type) {
            case 'pie':
                $this->chart = new PieChart();
                break;

            case 'bar':
                $this->chart = new BarChart();
                break;

            default:
                $this->chart = new BarChart();
        }

        return $this;
    }

    /**
     * 顯示圖示 
     */
    public function display()
    {
        $this->chart->render();
    }
}

/**
 * 餅圖
 */
class PieChart
{
    public function render()
    {
        echo 'Pie chart.';
    }
}

/**
 * 柱狀圖
 */
class BarChart
{
    public function render()
    {
        echo 'Bar chart.';
    }
}

$pie = new ChartDisplay('pie');
$pie->display(); //Pie chart.

$bar = new ChartDisplay('bar');
$bar->display(); //Bar chart.

在這裡我們的 ChartDisplay 每增加一種圖表顯示,都需要在建構函式中對程式碼進行修改。所以,違反了 開閉原則。我們可以通過宣告一個 Chart 抽象類(或介面),再將介面傳入 ChartDisplay 建構函式,實現面向介面程式設計。


/**
* 圖表介面
*/
interface ChartInterface
{
    /**
    * 繪製圖表
    */
    public function render();
}

class PieChart implements ChartInterface
{
    public function render()
    {
        echo 'Pie chart.';
    }
}

class BarChart implements ChartInterface
{
    public function render()
    {
        echo 'Bar chart.';
    }
}

/**
 * 顯示圖表
 */
class ChartDisplay
{
    private $chart;

    /**
     * @param ChartInterface $chart
     */
    public function __construct(ChartInterface $chart)
    {
        $this->chart = $chart;
    }

    /**
     * 顯示圖示 
     */
    public function display()
    {
        $this->chart->render();
    }
}

$config = ['PieChart', 'BarChart'];

foreach ($config as $key => $chart) {
    $display = new ChartDisplay(new $chart());
    $display->display();
}

修改後的 ChartDisplay 通過接收 ChartInterface 介面作為建構函式引數,實現了圖表顯示不依賴於具體的實現類即 面向介面程式設計。在不修改原始碼的情況下,隨時增加一個 LineChart 線狀圖表顯示。具體圖表實現可以從配置檔案中讀取。

里氏替換原則(Liskov Substitution Principle[LSP]) ★★★★★

里氏代換原則:在軟體中將一個基類物件替換成它的子類物件,程式將不會產生任何錯誤和異常,反過來則不成立。如果一個軟體實體使用的是一個子類物件的話,那麼它不一定能夠使用基類物件。

示例,我們的系統使用者型別分為:普通使用者(CommonCustomer)和 VIP 使用者(VipCustomer),當使用者收到留言時需要給使用者傳送郵件通知。原系統設計如下:

<?php

/**
 * 傳送郵件
 */
class EmailSender
{
    /**
     * 傳送郵件給普通使用者
     *
     * @param CommonCustomer $customer
     * @return void
     */
    public function sendToCommonCustomer(CommonCustomer $customer)
    {
        printf("Send email to %s[%s]", $customer->getName(), $customer->getEmail());
    }

    /**
     * 傳送郵件給 VIP 使用者
     * 
     * @param VipCustomer $vip
     * @return void
     */
    public function sendToVipCustomer(VipCustomer $vip)
    {
        printf("Send email to %s[%s]", $vip->getName(), $vip->getEmail());
    }    
}

/**
 * 普通使用者
 */
class CommonCustomer
{
    private $name;
    private $email;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getEmail()
    {
        return $this->email;
    }
}

/**
 * Vip 使用者
 */
class VipCustomer
{
    private $name;
    private $email;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getEmail()
    {
        return $this->email;
    }
}

$customer = new CommonCustomer("liugongzi", "liuqing_hu@126.com");
$vip = new VipCustomer("vip", "liuqing_hu@126.com");

$sender = new EmailSender();
$sender->sendToCommonCustomer($customer);// Send email to liugongzi[liuqing_hu@126.com]
$sender->sendToVipCustomer($vip);// Send email to vip[liuqing_hu@126.com]

這裡,為了演示說明我們通過在 EmailSender 類中的 send* 方法中使用型別提示功能,對接收引數進行限制。所以如果有多個使用者型別可能就需要實現多個 send 方法才行。

依據 里氏替換原則 我們知道,能夠接收父類的地方 一定 能夠接收子類作為引數。所以我們僅需定義 send 方法來接收父類即可實現不同型別使用者的郵件傳送功能:

<?php

/**
 * 傳送郵件
 */
class EmailSender
{
    /**
     * 傳送郵件給普通使用者
     *
     * @param CommonCustomer $customer
     * @return void
     */
    public function send(Customer $customer)
    {
        printf("Send email to %s[%s]", $customer->getName(), $customer->getEmail());
    }
}

/**
 * 使用者抽象類
 */
abstract class Customer
{
    private $name;
    private $email;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
    }

    public function getName()
    {
        return $this->name;
    }

    public function getEmail()
    {
        return $this->email;
    }

}

/**
 * 普通使用者
 */
class CommonCustomer extends Customer
{
}

/**
 * Vip 使用者
 */
class VipCustomer extends Customer
{
}

$customer = new CommonCustomer("liugongzi", "liuqing_hu@126.com");
$vip = new VipCustomer("vip", "liuqing_hu@126.com");

$sender = new EmailSender();
$sender->send($customer);// Send email to liugongzi[liuqing_hu@126.com]
$sender->send($vip);// Send email to vip[liuqing_hu@126.com]

修改後的 send 方法接收 Customer 抽象類作為引數,到實際執行時傳入具體實現類就可以輕鬆擴充套件需求,再多客戶型別也不用擔心了。

依賴倒置原則(Dependence Inversion Principle[DIP]) ★★★★★

依賴倒轉原則:抽象不應該依賴於細節,細節應當依賴於抽象。換言之,要針對介面程式設計,而不是針對實現程式設計。

在里氏替換原則中我們在未進行優化的程式碼中將 CommonCustomer 類例項作為 sendToCommonCustomer 的引數,來實現傳送使用者郵件的業務邏輯,這裡就違反了「依賴倒置原則」。

如果想在模組中實現符合依賴倒置原則的設計,要將依賴的元件抽象成更高層的抽象類(介面)如前面的 Customer 類,然後通過採用 依賴注入(Dependency Injection) 的方式將具體實現注入到模組中。另外,就是要確保該原則的正確應用,實現類應當僅實現在抽象類或介面中宣告的方法,否則可能造成無法呼叫到實現類新增方法的問題。

這裡提到「依賴注入」設計模式,簡單來說就是將系統的依賴有硬編碼方式,轉換成通過採用 設值注入(setter)建構函式注入介面注入 這三種方式設定到被依賴的系統中,感興趣的朋友可以閱讀我寫的 深入淺出依賴注入 一文。

舉例,我們的使用者在登入完成後需要通過快取服務來快取使用者資料:

<?php

class MemcachedCache
{
    public function set($key, $value)
    {
        printf ("%s for key %s has cached.", $key, json_encode($value));
    }
}

class User
{
    private $cache;

    /**
     * User 依賴於 MemcachedCache 服務(或者說元件)
     */
    public function __construct()
    {
        $this->cache = new MemcachedCache();
    }

    public function login()
    {
        $user = ['id' => 1, 'name' => 'liugongzi'];
        $this->cache->set('dp:uid:' . $user['id'], $user);
    }
}

$user = new User();
$user->login(); // dp:uid:1 for key {"id":1,"name":"liugongzi"} has cached.

這裡,我們的快取依賴於 MemcachedCache 快取服務。然而由於業務的需要,我們需要快取服務有 Memacached 遷移到 Redis 服務。當然,現有程式碼中我們就無法在不修改 User 類的建構函式的情況下輕鬆完成快取服務的遷移工作。

那麼,我們可以通過使用 依賴注入 的方式,來實現依賴倒置原則:

<?php

class Cache
{
    public function set($key, $value)
    {
        printf ("%s for key %s has cached.", $key, json_encode($value));
    }
}

class RedisCache extends Cache
{
}

class MemcachedCache extends Cache
{
}

class User
{
    private $cache;

    /**
     * 建構函式注入
     */
    public function __construct(Cache $cache)
    {
        $this->cache = $cache;
    }

    /**
     * 設值注入
     */
    public function setCache(Cache $cache)
    {
        $this->cache = $cache;
    }

    public function login()
    {
        $user = ['id' => 1, 'name' => 'liugongzi'];
        $this->cache->set('dp:uid:' . $user['id'], $user);
    }
}

// use MemcachedCache
$user =  new User(new MemcachedCache());
$user->login(); // dp:uid:1 for key {"id":1,"name":"liugongzi"} has cached.

// use RedisCache
$user->setCache(new RedisCache());
$user->login(); // dp:uid:1 for key {"id":1,"name":"liugongzi"} has cached.

完美!

介面隔離原則(Interface Segregation Principle[ISP]) ★★

介面隔離原則:使用多個專門的介面,而不使用單一的總介面,即客戶端不應該依賴那些它不需要的介面。

簡單來說就是不要讓一個介面來做太多的事情。比如我們定義了一個 VipDataDisplay 介面來完成如下功能:

  • 通過 readUsers 方法讀取使用者資料;
  • 可以使用 transformToXml 方法將使用者記錄轉存為 XML 檔案;
  • 通過 createChart 和 displayChart 方法完成建立圖表及顯示;
  • 還可以通過 createReport 和 displayReport 建立文字報表及現實。
abstract class VipDataDisplay
{
    public function readUsers()
    {
        echo 'Read all users.';
    }

    public function transformToXml()
    {
        echo 'save user to xml file.';
    }

    public function createChart()
    {
        echo 'create user chart.';
    }

    public function displayChart()
    {
        echo 'display user chart.';
    }

    public function createReport()
    {
        echo 'create user report.';
    }

    public function displayReport()
    {
        echo 'display user report.';

    }
}

class CommonCustomerDataDisplay extends VipDataDisplay
{

}

現在我們的普通使用者 CommonCustomerDataDisplay 不需要 Vip 使用者這麼複雜的展現形式,僅需要進行圖表顯示即可,但是如果繼承 VipDataDisplay 類就意味著繼承抽象類中所有方法。

現在我們將 VipDataDisplay 抽象類進行拆分,封裝進不同的介面中:

interface ReaderHandler
{
    public function readUsers();
}

interface XmlTransformer
{
    public function transformToXml();
}

interface ChartHandler
{
    public function createChart();

    public function displayChart();
}

interface ReportHandler
{
    public function createReport();

    public function displayReport();
}

class CommonCustomerDataDisplay implements ReaderHandler, ChartHandler
{
    public function readUsers()
    {
        echo 'Read all users.';
    }

    public function createReport()
    {
        echo 'create user report.';
    }

    public function displayReport()
    {
        echo 'display user report.';

    }
}

重構完成後,僅需在實現類中實現介面中的方法即可。

合成複用原則(Composite Reuse Principle[CRP]) ★★★★

合成複用原則:儘量使用物件組合,而不是繼承來達到複用的目的。

合成複用原則就是在一個新的物件裡通過關聯關係(包括組合和聚合)來使用一些已有的物件,使之成為新物件的一部分;新物件通過委派呼叫已有物件的方法達到複用功能的目的。簡言之:複用時要儘量使用組合/聚合關係(關聯關係) ,少用繼承。

何時使用繼承,何時使用組合(或聚合)?

當兩個類之間的關係屬於 IS-A 關係時,如 dog is animal,使用 繼承;而如果兩個類之間屬於 HAS-A 關係,如 engineer has a computer,則優先選擇組合(或聚合)設計。

示例,我們的系統有用日誌(Logger)功能,然後我們實現了向控制檯輸入日誌(SimpleLogger)和向檔案寫入日誌(FileLogger)兩種實現:

<?php

abstract class Logger
{
    abstract public function write($log);
}

class SimpleLogger extends Logger
{
    public function write($log)
    {
        print((string) $log);
    }
}

class FileLogger extends Logger
{
    public function write($log)
    {
        file_put_contents('logger.log', (string) $log);
    }
}

$log = "This is a log.";

$sl = new SimpleLogger();
$sl->write($log);// This is a log.

$fl = new FileLogger();
$fl->write($log);

看起來很好,我們的簡單日誌和檔案日誌能夠按照我們預定的結果輸出和寫入檔案。很快,我們的日誌需求有了寫增強,現在我們需要將日誌同時向控制檯和檔案中寫入。有幾種解決方案吧:

  • 重新定義一個子類去同時寫入控制檯和檔案,但這似乎沒有用上我們已經定義好的兩個實現類:SimpleLogger 和 FileLogger;
  • 去繼承其中的一個類,然後實現另外一個類的方法。比如繼承 SimpleLogger,然後實現寫入檔案日誌的方法;嗯,沒辦法 PHP 是單繼承的語言;
  • 使用組合模式,將 SimpleLogger 和 FileLogger 聚合起來使用。

我們直接看最後一種解決方案吧,前兩種實在是有點。

class AggregateLogger
{
    /**
     * 日誌物件池
     */
    private $loggers = [];

    public function addLogger(Logger $logger)
    {
        $hash = spl_object_hash($logger);
        $this->loggers[$hash] = $logger;
    }

    public function write($log)
    {
        array_map(function ($logger) use ($log) {
            $logger->write($log);
        }, $this->loggers);
    }
}

$log = "This is a log.";

$aggregate = new AggregateLogger();

$aggregate->addLogger(new SimpleLogger());// 加入簡單日誌 SimpleLogger
$aggregate->addLogger(new FileLogger());// 鍵入檔案日誌 FileLogger

$aggregate->write($log);

可以看出,採用聚合的方式我們可以非常輕鬆的實現複用。

迪米特法則

設計模式六大原則(5):迪米特法則

感謝 Liuwei-Sunny 大神在「軟體架構、設計模式、重構、UML 和 OOAD」領域的分享,才有此文。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章