設計原則:開閉原則(OCP)

Mr於發表於2021-04-13

1.什麼是開閉原則

開閉原則的英文是Open Closed Principle,縮寫就是OCP。其定義如下:

軟體實體(模組、類、方法等)應該“對擴充套件開放、對修改關閉”。

從定義上看,這個原則主要包含兩部分:

  • 對擴充套件開放:“ 這意味著模組的行為是可以擴充套件的。當應用程式的需求改變時,我們可以對其模組進行擴充套件,使其具有滿足那些需求變更的新行為。換句話說,我們可以改變模組的功能。

  • 對修改關閉:“ 對模組行為進行擴充套件時,不必改動該模組的原始碼或二進位制程式碼。模組的二進位制可執行版本,無論是可連結的庫、DLL或Java的.jar檔案,都無需改動。

通俗解釋就是,新增一個新的功能,應該通過在已有程式碼(模組、類、方法)的基礎上進行擴充套件來實現,而不是修改已有程式碼。

之前的一篇文章《何謂高質量程式碼?》中,我們總結了高質量程式碼的幾個衡量標準。

而開閉原則解決的就是程式碼的擴充套件性問題。如果某段程式碼在應對未來需求變化的時候,能夠做到“對擴充套件開放、對修改關閉”,那就說明這段程式碼的擴充套件性比較好。

2.如何做到對擴充套件開放、對修改關閉

那麼應該怎樣寫出擴充套件性好的程式碼呢?

在思想上我們要具備擴充套件意識、抽象意識、封裝意識。這些意識的培養要比一些具體的方法更為重要,這依賴我們對物件導向的理解、對業務的掌握度,以及長期的經驗積累...... 這要求我們在寫程式碼的時候後,要多花點時間往前多思考一下,未來可能有哪些需求變更,識別出程式碼的易變部分與不易變部分,合理設計程式碼結構,事先留好擴充套件點,以便在未來不需要改動程式碼整體結構、做到最小程式碼改動的情況下,新的程式碼能夠很靈活地插入到擴充套件點上。

在方法上,我們主要可以通過多型、依賴注入、面向介面程式設計等方式來實現程式碼的可擴充套件性。做到“對擴充套件開放、對修改關閉”。我們要將可變部分抽象出來以隔離變化,提供抽象化的不可變介面,給上層系統使用。當具體的實現發生變化的時候,我們只需要基於相同的抽象介面,擴充套件一個新的實現,替換掉老的實現即可,上游系統的程式碼幾乎不需要修改。

比如,我們的專案中通常會用到一些第三方元件,訊息中介軟體,快取中介軟體......訊息中介軟體我們可能一開始使用RabbitMQ,但是可能後來會換成Kafka,快取中介軟體可能會從Memcache換成Redis。這種情況,如果我們的上層應用直接依賴這些中介軟體呼叫程式碼,那麼更換的成本就會更高,這種程式碼就不利於擴充套件。

image-20210203082100894

public class MemcacheClient {

    public boolean set(String key, String value) {
        return false;
    }

    public String get(String key) {
        return null;
    }

    public boolean remove(String key) {
        return false;
    }
}

public class OcpApplication {

    public void test() {

        // 業務程式碼
        //...
        //...

        //寫快取
        MemcacheClient client = new MemcacheClient();
        client.set("testKey", "testValue");
    }
}

如上示例,我們的上層應用OcpApplication直接依賴了MemcacheClient,如果未來有需要把Memcache換成Redis,我們就需要替換掉所有呼叫了MemcacheClient的上層應用方法,這嚴重違背了開閉原則。

在這種情況下,通常我們會把這種中介軟體的呼叫設計成可插拔的。我們提供一個這些中介軟體的抽象介面出來,讓所有上層系統都依賴這組抽象的介面程式設計,並且通過依賴注入的方式來呼叫。當我們要替換新的中介軟體的時候,比如將 Memcache替換成 Redis,就可以可以很方便地拔掉老的Memecache實現,插入新的Redis實現。

image-20210203080434828

/**
 * 快取中介軟體的使用抽象出介面
 */
public interface ICacheClient {

    boolean set(String key, String value);

    String get(String key);

    boolean remove(String key);
}

/**
 * MemcacheClient
 */
public class MemcacheClient implements ICacheClient {

    public boolean set(String key, String value) {
        return false;
    }

    public String get(String key) {
        return null;
    }

    public boolean remove(String key) {
        return false;
    }
}

/**
 * RedisClient
 */
public class RedisClient implements ICacheClient {
    
    @Override
    public boolean set(String key, String value) {
        return false;
    }

    @Override
    public String get(String key) {
        return null;
    }

    @Override
    public boolean remove(String key) {
        return false;
    }
}

public class OcpApplication {

    /**
     * 依賴注入cacheClient
     */
    ICacheClient cacheClient;
    
    public OcpApplication(ICacheClient cacheClient){
        this.cacheClient=cacheClient;
    }

    public void test() {

        // 業務程式碼
        //...
        //...

        //寫快取
        cacheClient.set("testKey", "testValue");
    }
}

3.如何靈活運用開閉原則

開閉原則看似簡單,但我卻認為是SOLID 中最難掌握的一條原則。其難點就在於如何在真正的專案中去靈活運動開閉原則。而且OCP同樣存在著一些陷阱,怎麼才算滿足或違反開閉原則,修改程式碼就一定意味著違反開閉原則嗎,擴充套件點設計的越多越好嗎......

3.1 靈活設計擴充套件點

對於業務系統,要想識別出盡可能多的擴充套件點,就要求你對業務有足夠的瞭解,能夠預見一些未來可能的變化。

對於偏技術的系統,比如,框架、元件、類庫等,就需要充分了解它的使用場景?以及今後想要擴充套件點功能?使用者未來會有哪些更多的訴求......

但即便我們對業務、對系統有足夠的瞭解,也不可能識別出所有的擴充套件點,即便可以,併為這些地方都預留擴充套件點,也是沒有必要的。同樣有一條原則叫KISS原則,那就是儘量保持簡單,不要進行過度設計,實際上很多人都會陷入這樣一個誤區,我們常常為了一些很可能不存在的擴充套件而絞盡腦汁!

最合理的做法就是,對於一些比較確定的、短期內可能就會擴充套件,或者需求改動對程式碼結構影響比較大的情況,或者實現成本不高的擴充套件點,可以事先做些擴充套件性設計。但對於一些不確定未來是否要支援的需求,或者實現起來比較複雜的擴充套件點,我們可以等到有需求驅動的時候,再通過重構程式碼的方式來支援擴充套件的需求。

3.2 修改程式碼一定違反開閉原則嗎

開閉原則中對於修改是封閉的並非是一個絕對的概念。

1.修復缺陷所做的改動

缺陷在軟體中很常見,是不可能完全消除的。當缺陷出現時,就需要我們修復現有的程式碼。軟體修復明顯傾向於實用主義而不是堅持開放封閉原則。

2.客戶端無法感知到的改動

如果一個類的改動會引起另一個類的改動,那麼這兩個類就是緊密耦合的。相反,如果一個類的修改總是獨立的,並不會引起其他類的改動,那麼這些類就是鬆散耦合的。我們要記住,任何情況下,鬆散耦合都比緊密耦合要好。如果我們對現有程式碼的修改不會影響客戶端程式碼,那麼也就談不上違背開放封閉原則。

3.修改還是擴充套件?

從開閉原則定義中,我們可以看出,開閉原則可以應用在不同粒度的程式碼中,可以是模組,也可以類,還可以是方法(及其屬性)。同樣一個程式碼改動,在粗程式碼粒度下,可以被認定為“修改”,但在細程式碼粒度下,又可以被認定為“擴充套件”。

比如,在類這個層面新增屬性和方法相當於修改類,這個程式碼改動可以被認定為“修改”;但這個改動並沒有修改已有的屬性和方法,在方法(及其屬性)這一層面,它又可以被認定為“擴充套件”。

實際上,當糾結於某個程式碼改動是“修改”還是“擴充套件”的時候,我們就已經背離了設計原則的初衷,開閉原則的本質目的就是為了讓我們的程式碼更具有擴充套件性,更容易維護,如果我們可以很容易的完成修改,又不會影響到既有的程式碼與單測,就可以認為這是一個合理的改動。

3.3 擴充套件性與可讀性的平衡

在有些情況下,程式碼的擴充套件性會跟可讀性相沖突。為了更好地支援擴充套件性,我們對程式碼進行了重構,重構之後的程式碼要比之前的程式碼複雜很多,理解起來也更加有難度。實際上很多時候,我們都要結合具體的場景在擴充套件性和可讀性之間做權衡。在某些場景下,擴充套件性很重要,我們就可以適當地犧牲一些可讀性;而在另一些場景下,可讀性更加重要,那我們就適當地犧牲一些擴充套件性。

小結

絕大多數情況下,我們的系統都不是一錘子買賣,通常隨著需求的迭代,我們需要不斷地對其進行維護與擴充套件。而開閉原則的思想可以很好的解決擴充套件性的問題,因此理解並掌握開閉原則至關重要,但這需要我們充分的理解物件導向的思想,合理的利用封裝、多型等方法以及長期大量的積累!

系列文章

設計原則:單一職責(SRP)

設計原則:開閉原則(OCP)

設計原則:裡式替換原則(LSP)

設計原則:介面隔離原則(ISP)

設計原則:依賴倒置原則(DIP)

何謂高質量程式碼?

理解RESTful API

關注下方公眾號,回覆“程式碼的藝術”,可免費獲取重構、設計模式、程式碼整潔之道等提升程式碼質量等相關學習資料

相關文章