PHP OO 物件導向原則:開放封閉原則OCP

OO_Principle_OCP

物件導向有五個原則 S.O.L.I.D. :
  • 單一職責原則 SRP (Single Responsibility Principle)
  • 開放封閉原則 OCP (Open Closed Principle)
  • 里氏替換原則 LSP (Liskov Substitution Principle)
  • 介面隔離原則 ISP (Interface Segragation Principle)
  • 依賴反轉原則 DIP (Dependency Inversion Principle)
更多物件導向的理論的學習內容會整理到 PHP OO 的進階教學:

 開放封閉原則 OCP (Open Closed Principle)

一、定義與說明:

軟體中的實體(如:類別、模組、方法),必須能夠擴充延伸但不能做修改。
Software entities like classes, modules and functions should be open for extension but closed for modifications.
寫程式的時候,應該注意有哪些部分可能在開發完成後,會經常因為需求改變導致需要修改程式。(需求改變常常指的是新增功能)
以往在開發應用程式或系統時,擴充新功能通常會更動許多地方(牽一髮而動全身)。然而用最小的幅度來修改已存在的程式碼(甚至不修改)才是最佳的情況
開放封閉原則(Open Closed Principle)是一個可以讓程式在擴充新功能時,不更動原程式碼或者僅以最小幅度修改程式碼的開發方式。

二、遵守單一職責原則的結果:

不管擴增多少新功能,都不必修改原程式碼。
案例:如 Chrome 的擴增套件,可不斷擴增新套件,但不必修改到 Chrome 本身。

三、如何設計單一職責原則的類別?:

在設計上應該要使用抽象層的介面(interface),透過實作介面行為來擴充我們的商業邏輯。
這樣的設計,配合依賴注入(Dependency Injection)讓未來擴充功能時,只需要新增類別並實作介面。最後再將新的擴充內容注入場景裡面,即可達成不修改類別,滿足 OCP 原則。

範例一: 可以顯示百分比%的進度表

此範例我們要設計一個可以顯示下載檔案進度百分比的類別。我們需要兩個主要類別: Progress 進度類別和 File檔案類別。然後想像一下我們要用下面範例來使用這兩個類別:
百分比進度表範例:
function getPercentOfAFileDownloadProgess() {
    $file = new File();
    $file->length = 200;
    $file->sent = 100;

    $progress = new Progress($file);

    return $progress->getAsPercent();
}
在這個範例中,我們希望透過 Progress 類別的 getAsPercent() 方法取得任何大小檔案的百分比。我們使用 File 類別當作 Progress 類別的資料來源。
File 類別:
class File {
    public $length;  // `length` 代表檔案的大小。
    public $sent;    // `sent` 則代表這個檔案已經被下載多少。
}
範例中的 File 類別,是一個相當簡略的檔案物件,只包含了 length 和 sent 兩個屬性。當然在現實中還可能會包含檔案名稱、檔案路徑、相對路徑、檔案類型及權限…等屬性。
Progress 類別
class Progress {

    private $file;

    function __construct(File $file) {
       $this->file = $file;
    }

    function getAsPercent() {
        return $this->file->sent * 100 / $this->file->length;
    }
}
Progress 類別會經由魔術方法 __construct,在建立物件時取得一個 File 類別。
為了讓資料類型更明確,在 __construct 的參數前面使用 TypeHint ,定義該參數的型別為 File 類別。
getAsPercent() 是一個會把 File 的 sent 與 length 計算出百分比的方法。

範例程式違反了原則

雖然以上程式可以正常運行,但卻違反了開放封閉原則..為什麼?

需求變更

每一個應用程式隨著時間的發展可能需要加入一些新的功能。而在我們範例中的應用程式,除了下載檔案,新功能是要能串流播放一首音樂。File的 length 屬性在下載檔案的時候單位是位元組,但是在播放音樂的時候單位為秒。我們如何使用舊有的程式來提供聽音樂的人一個良好的音樂進度表呢?

方案一、利用 PHP 動態語言的優勢

動態語言的優勢就是會在程式碼運行的時候才辨識變數/物件的型態。如果我們從 Progress的建構函示 __construct,把類型提示(typehint)移除的話,我們得進度程式就可以載入音樂類型的檔案:
Progress 進度表類別
class Progress {

    private $file;

    function __construct($file) {
        $this->file = $file;
    }

    function getAsPercent() {
        return $this->file->sent * 100 / $this->file->length;
    }
}
現在,我們可以丟任何東西到 Progress裡面。這裡的任何東西是字面上的任何東西!!!
Music 音樂類別
class Music {

    public $length;
    public $sent;

    public $artist;
    public $album;
    public $releaseDate;

    function getAlbumCoverFile() {
        return 'Images/Covers/' . $this->artist . '/' . $this->album . '.png';
    }
}
我們可以用測試 File 類別的方式來測試 Music 類別:
function getPercentOfAMusicStream() {
    $music = new Music();
    $music->length = 200;
    $music->sent = 100;

    $progress = new Progress($music);

    return $progress->getAsPercent();
}
基本上,可以被測量的內容都可被 Progress 類別使用。所以我們應該把程式碼的變數名稱從 Music 改為 measurableContent 可測量的內容。
class Progress {

    private $measurableContent;

    function __construct($measurableContent) {
        $this->measurableContent = $measurableContent;
    }

    function getAsPercent() {
        return $this->measurableContent->sent * 100 / $this->measurableContent->length;
    }
}
方案一中我們把類型提示(Typehint)移除了,雖然程式碼可以運行,但是使用這個方式仍然有一個大問題:
當我們使用 File 類別來當作類型提示(Typehint)時,我們可以很確定 Progress 類別在處理哪些類型的檔案。使用類型提示(Typehint)可以避免有其他不確定的檔案類型進入Progress 類別,在有類型提示(Typehint)的情形下,假如有人傳入了一個不預期的資料類型,則會發生下列的錯誤,告訴你程式預期得到什麼資料類型,以及被丟入了什麼資料類型。:
Argument 1 passed to Progress::__construct()
must be an instance of File,
instance of Music given.
但是沒有類型提示(Typehint)的時候,我們必須確保任何被傳遞進來的參數都有 length 以及 sent 兩個變數,否則程式就會發生錯誤
一般來說,我們沒辦法從不確定的類別存取其屬性跟方法。當我們使用類型提示(Typehint)的時候,就確定可以被傳遞進來的參數之資料類型會跟類型提示(Typehint)是相同的類型。
現在我們移除了類型提示(Typehint),我們可以傳遞任何東西甚至字串給 Progress,並且會造成一個很醜陋的 Error:
function getPercentOfAMusicStream() {
    $progress = new Progress('some string');
    $this->assertEquals(50, $progress->getAsPercent());
}
由上面的測試,我們傳遞了一個字串給 Progress,並且得到以下錯誤訊息:
Trying to get property of non-object.
有沒有使用類型提示(Typehint)會在 Debug 時有很大的差異:
是否使用 TypeHint 發生錯誤時的差異
使用 Type Hint 程式會提示發生錯誤的方法需要什麼類別與屬性。
無使用 Type Hint 程式不會提示發生錯誤的方法需要什麼類別與屬性。開發者必須打開 Progress 類別才能知道它需要什麼
在範例中,當我們不使用 TypeHint 的時候,資料來源的 sent 與 length 屬性必須隨著 Progress 來定義,這是一種隱性契約,只有在相互認識的時候才能讓程式正常運行。隱性契約可能會讓你的程式相當複雜,而且不易搜尋,你可能需要花很多時間才能找到 getAsPercent() 需要的參數是 sentlength
這個方案一只有在其他方案無法建立或者可能造成程式結構嚴重改變的情形下才建議使用。

方案二:使用 策略模式 Strategy Pattern

使用 Strategy Pattern 一個最普遍以及最常使用來遵從 OCP 原則的方法:
Strategy Pattern 引入了介面(interface)的應用。介面在物件導向程式中,是一種特殊的實體(entity),用於定義 client 類別與 server 類別之間的契約或規範。兩種類別必須透過介面定義的契約或規範來確保雙方的行為是否符合預期。在程式中無數個不關聯的 server 類別可以遵從相同的契約來服務同一個 client 類別。
就如上圖所示,File、Music等類別實作同一個介面來服務同一個 Progress 類別。
interface Measurable {
    function getLength();
    function getSent();
}
在一個介面中,我們只可以定義行為。
function getPercentOfAFileDownloadProgess() {
    $file = new File();
    $file->setLength(200);
    $file->setSent(100);

    $progress = new Progress($file);

    $this->assertEquals(50, $progress->getAsPercent());
}
接下來開始進行測試,我們必須使用 setter 來給予 File 屬性值。注意,這些 setter 行為可能會不小心被寫到 Measurable 介面裡面,這是不合理的。因為介面是像是 client class(如 Progress) 與 server class (File 和 Music)之間的契約。Progress 並不需要使用 setter 來設置 sent 與 length 吧!!!
因此非常不建議把 setter 定義在介面裡面。而且如果你把 setter 定義在介面裡面,你將強迫讓所有 server classes 都必須實作 setter。有些 server class 確實需用在自己類別裡面使用 setter 設值,但另一些 server class 則可能是由相依套件等第三方類別來更改值(非透過 setter),所以說要強迫把 setter 放在所有 server 上是有點奇怪。
接下來讓 Music 與 File 實作 Measurable 介面吧:
File
class File implements Measurable {

    private $length;
    private $sent;

    public $filename;
    public $owner;

    function setLength($length) {
        $this->length = $length;
    }

    function getLength() {
        return $this->length;
    }

    function setSent($sent) {
        $this->sent = $sent;
    }

    function getSent() {
        return $this->sent;
    }

    function getRelativePath() {
        return dirname($this->filename);
    }

    function getFullPath() {
        return realpath($this->getRelativePath());
    }

}
Music
class Music {

    private $length;
    private $sent;

    public $artist;
    public $album;
    public $releaseDate;

    function setLength($length) {
        $this->length = $length;
    }

    function getLength() {
        return $this->length;
    }

    function setSent($sent) {
        $this->sent = $sent;
    }

    function getSent() {
        return $this->sent;
    }

    function getAlbumCoverFile() {
        return 'Images/Covers/' . $this->artist . '/' . $this->album . '.png';
    }
}
現在,MusicFile 類別實作了 Measurable 介面,並且針對我們有興趣的屬性(property)做了 setter 與 getter 。接下來看一下 Progress。我們快完成了..
Progress
class Progress {

    private $measurableContent;

    function __construct(Measurable $measurableContent) {
        $this->measurableContent = $measurableContent;
    }

    function getAsPercent() {
        return $this->measurableContent->getSent() * 100 / $this->measurableContent->getLength();
    }

}
Progress 只需要一點點的修改,我們可以在 __construct 方法的參數前面定義一個資料類型為Measurable的類型提示 TypeHint。
這是一個依賴注入(Dependency Injection)的方式,現在,Progress 與 ( File 和 Music )之間有了個顯性契約
Progress 可以確定每次從 __construct 方法傳遞進來的變數都已經實作過Measurable 介面,
File 和 Music 也可以經由實作Measurable介面來確定他們實作的方法可以滿足 Progress 的需求。
這樣的設計,配合Dependency Injection,未來不管在那麼不管需求的內容如何變化,都可以因為改變注入的內容,而不用修改 client 類別,以滿足OCP原則。

結論:

只有經常變更的地方需要使用 OCP 原則。
就像其他原則一樣,OCP 只是一個原則,讓程式變得靈活的代價是需要花費額外的時間與精力將程式引入新的抽象層,還會增加程式的複雜度。所以 OCP 原則只適合被套用於經常變更的地方!
除此之外,還有許多設計模式可以幫助我們在不修改程式碼的情況下擴增新功能。例如 Decorator 修飾模式幫助我們來遵守 OCP 原則。 以及Factory 工廠模式 或者是 Observer 觀察者模式 是用來讓應用程式可以用最小幅度的修改來完成需求變更
最後,如果你有依照 OCP 原則的話,擴增功能會變得很簡單且需修改的程式會很少。只要有部分程式碼曾經被修改過一次,它就很可能又會被再次修改,當這種可能性成真的時候,記得 OCP 可以幫你節省很多時間跟精力。
資料參考:

留言

這個網誌中的熱門文章

Git Commit Message 這樣寫會更好,替專案引入規範與範例

Gitlab 合併請求 Merge Request 是什麼?

PHP OO 物件導向基礎教學