設計原則之【開放封閉原則】

Gopher大威發表於2022-02-27

設計原則是指導我們程式碼設計的一些經驗總結,也就是“心法”;物件導向就是我們的“武器”;設計模式就是“招式”。

以心法為基礎,以武器運用招式應對複雜的程式設計問題。

表妹今天上班又忘記打卡了

表妹:哥啊,我真的是一點記性都沒有

我:發生什麼事啦?

表妹:今天上班又忘記打卡了,又是白打工的一天,做什麼事都提不起勁來。


你看,傳統的上下班打卡制,這種模式將按時上下班作為考核指標之一,雖然強化了企業的管理,但是卻限制了員工的時間自由,每個員工的情況和工作狀態都不同,強制的上班時間容易導致員工為了應付打卡而打卡,實則工作效率卻不高。

按時上下班其實不是老闆希望達到的目的,老闆希望的是,所有員工的績效達標,最終企業能夠盈利,而上下班打卡制只不過是為了達到這一目標的其中一個方法而已。

明確了將績效作為考核指標。那麼,績效至少達標,這個是不可以修改的,在這個基礎上,員工的上下班時間是可以自由安排的,這樣就可以提高員工的生產效率了。這就是彈性上班制,對業績成效的修改關閉,而對時間制度擴充套件的開放。

你看,這不就是我們軟體開發中的開放-封閉原則嘛。


是說軟體實體(類、模組、函式等)應該可以擴充套件,但是不可以修改。

這是一條最難理解和掌握,但是又最有用的設計原則。

之所以說難理解,是因為,“怎樣的程式碼改動才被定義為擴充套件?怎樣的程式碼改動才被定義為修改?怎樣才算滿足開閉原則?修改程式碼就一定違反了開閉原則嗎?”等問題。

之所以說難掌握,是因為,“如何做到對擴充套件開放,修改封閉?,如何在專案中靈活地應用開閉原則,在保證擴充套件性的同時又不影響程式碼的可讀性?”等問題。

之所以說最有用,是因為,擴充套件性是程式碼質量最重要的衡量標準之一。在23種經典設計模式之中,大部分設計模式都是為了解決程式碼的擴充套件性問題而存在的。

如何理解“對擴充套件開放、修改關閉”?

比如,書店銷售圖書。

圖書有三個屬性:書名、價格和作者。IBook是獲取圖書三個屬性的介面,如下所示:

 1 public interface IBook { 
 2     // 圖書的名稱 
 3     public String getName(); 
 4  5     // 圖書的售價 
 6     public int getPrice(); 
 7  8     // 圖書的作者 
 9     public String getAuthor(); 
10 } 

 

小說類圖書NovelBook是一個具體的實現類,如下所示:

 1 public class NovelBook implements IBook { 
 2     // 圖書的名稱 
 3     private String name; 
 4  5     // 圖書的價格 
 6     private int price; 
 7  8     // 圖書的作者 
 9     private String author; 
10 11     // 通過建構函式傳遞書籍資料 
12     public NovelBook(String _name,int _price,String _author){ 
13         this.name = _name; 
14         this.price = _price; 
15         this.author = _author; 
16     } 
17 18     // 獲得作者是誰 
19     public String getAuthor() { 
20         return this.author; 
21     } 
22 23     // 獲得書名 
24     public String getName() { 
25         return this.name; 
26     } 
27 28     // 獲得圖書的價格 
29     public int getPrice() {
30         return this.price; 
31     } 
32 } 

 

接下來,我們看一下,書店是如何銷售圖書的:

 1 public class BookStore { 
 2     private final static ArrayList<IBook> bookList = new ArrayList<IBook>(); 
 3  4     // 靜態模組初始化,專案中一般是從持久層初始化產生 
 5     static{ 
 6         bookList.add(new NovelBook("天龍八部",3200,"金庸")); 
 7         bookList.add(new NovelBook("巴黎聖母院",5600,"雨果")); 
 8         bookList.add(new NovelBook("悲慘世界",3500,"雨果")); 
 9         bookList.add(new NovelBook("平凡的世界",4300,"路遙")); 
10     } 
11 12     //模擬書店買書 
13     public static void main(String[] args) { 
14         NumberFormat formatter = NumberFormat.getCurrencyInstance(); 
15         formatter.setMaximumFractionDigits(2); 
16         System.out.println("------------書店中的小說類圖書記錄如下:---------------------"); 
17         for(IBook book:bookList){ 
18             System.out.println("書籍名稱:" + book.getName()+"\t書籍作者:" + 
19             book.getAuthor()+  "\t書籍價格:"  +  formatter.format(book.getPrice()/100.0)+"元"); 
20         } 
21     } 
22 } 

注:在BookStore中宣告瞭一個靜態模組,實現了資料的初始化,這部分應該是從持久層產生的,由持久層工具進行管理。

執行結果如下:

------------書店中的小說類圖書記錄如下:--------------------- 
書籍名稱:天龍八部  書籍作者:金庸  書籍價格:¥32.00元 
書籍名稱:巴黎聖母院 書籍作者:雨果  書籍價格:¥56.00元 
書籍名稱:悲慘世界  書籍作者:雨果  書籍價格:¥35.00元 
書籍名稱:平凡的世界 書籍作者:路遙 書籍價格:¥43.00元 

 

但是,最近書店的小說類圖書銷量下滑很嚴重,所以,書店希望通過打折來刺激消費:所有40元及以上的小說類圖書8折銷售,40元以下的按9折銷售。

對於已經投產的專案來說,這就是一個變化,那麼,我們應該怎麼應對呢?

有三種方法可以解決這個問題:

修改介面

在IBook上新增一個getOffPrice()的方法,專門進行打折處理。

首先,IBook作為介面應該是穩定可靠的,不應該經常發生變化,否則介面作為契約的作用就失去了意義。

其次,修改了介面,NovelBook實現類也要做相應的修改,這樣,為了實現這個需求,改動的面積是比較大的。

修改實現類

修改NovelBook實現類中getPrice()的方法,這樣,改動的面積相對比較小了,僅僅侷限在NovelBook實現類中。但是這樣的話,使用者就無法獲得圖書的原價了。

通過擴充套件實現變化

增加一個子類OffNovelBook,複寫getPrice()方法,高層次的模組(也就是static靜態模組區)通過OffNovelBook類產生新的物件。如下所示:

public class OffNovelBook extends NovelBook { 
    public OffNovelBook(String _name,int _price,String _author){ 
        super(_name,_price,_author); 
    } 
​
    // 覆寫銷售價格 
    @Override 
    public int getPrice(){ 
        // 原價 
        int selfPrice = super.getPrice(); 
        int offPrice=0; 
        if(selfPrice < 4000){  // 原價低於40元,則打9折 
            offPrice = selfPrice * 90 /100; 
        }else{ 
            offPrice = selfPrice * 80 /100; 
        } 
        return offPrice; 
    } 
}

你看,僅僅擴充套件一個子類並複寫getPrice()方法,就可以完成新增的業務。接下來看一下BookStore類的修改:

public class BookStore { 
    private final static ArrayList<IBook> bookList = new ArrayList<IBook>(); 
​
    // 靜態模組初始化,專案中一般是從持久層初始化產生 
    static{ 
        // 換成打折的小說
        bookList.add(new OffNovelBook("天龍八部",3200,"金庸")); 
        bookList.add(new OffNovelBook("巴黎聖母院",5600,"雨果")); 
        bookList.add(new OffNovelBook("悲慘世界",3500,"雨果")); 
        bookList.add(new OffNovelBook("平凡的世界",4300,"路遙")); 
    } 
​
    // 模擬書店買書 
    public static void main(String[] args) { 
        NumberFormat formatter = NumberFormat.getCurrencyInstance(); 
        formatter.setMaximumFractionDigits(2); 
        System.out.println("------------書店中的小說類圖書記錄如下:---------------------"); 
        for(IBook book:bookList){ 
            System.out.println("書籍名稱:" + book.getName()+"\t書籍作者:" + 
            book.getAuthor()+  "\t書籍價格:"  +  formatter.format(book.getPrice()/100.0)+"元"); 
        } 
    } 
} 

上面只修改了靜態模組初始化部分,其他部分沒有修改。執行結果如下:

------------書店中的小說類圖書記錄如下:--------------------- 
書籍名稱:天龍八部  書籍作者:金庸  書籍價格:¥25.60元 
書籍名稱:巴黎聖母院 書籍作者:雨果  書籍價格:¥50.40元 
書籍名稱:悲慘世界  書籍作者:雨果  書籍價格:¥28.00元 
書籍名稱:平凡的世界 書籍作者:路遙 書籍價格:¥38.70元 

上面這個例子,通過一處擴充套件,一處修改,實現了打折的新需求。可能有同學就會問:“這不還是修改了程式碼嗎?”

修改程式碼就意味著違反了開閉原則嗎?

BookStore類確實修改了,這部分屬於高層次的模組。在業務規則改變的情況下,高層模組必須有部分改變以適應新業務。新增一個新功能,不可能任何模組、類、方法的程式碼都不“修改”,這個是做不到的。類需要建立、組裝、並且做一些初始化操作,才能構建成可執行的程式,這部分程式碼的修改是在所難免的。

我們要做的是,儘量讓修改操作更集中、更少、更上層,儘量讓最核心、最複雜的那部分邏輯程式碼滿足開閉原則。

如何做到“對擴充套件開放、修改關閉”?

實際上,開閉原則講的就是程式碼的擴充套件性問題,是判斷一段程式碼是否易擴充套件的“金標準”。

在講具體的方法論之前,我們先來看一些更加偏向頂層的指導思想。為了儘量寫出擴充套件性好的程式碼,我們要時刻具備擴充套件意識、抽象意識、封裝意識。這些“潛意識”可能比任何開發技巧都重要。

擴充套件意識:在寫程式碼的時候,我們要多花點時間往前多思考一下,這段程式碼未來可能有哪些需求變更、如何設計程式碼結構,事先留好擴充套件點,以便在未來需求變更的時候,不需要改動程式碼整體結構、做到最小程式碼改動的情況下,新的程式碼能夠靈活地插入到擴充套件點上,做到“對擴充套件開放、對修改關閉”。

抽象意識:提供抽象化的不可變介面,給上層系統使用。當具體的實現發生變化的時候,我們只需要基於相同的抽象介面,擴充套件一個新的實現,替換老的實現即可,上游系統的程式碼幾乎不需要修改。

封裝意識:在識別出程式碼可變部分和不可變部分之後,我們要將可變部分封裝起來,隔離變化。

在眾多的設計原則、思想、模式中,最常用來提高程式碼擴充套件性的方法有:多型、依賴注入、基於介面而非實現程式設計,以及大部分的設計模式(比如:裝飾、策略、模板、責任鏈、狀態等)。

設計模式這一塊,我們另外再分享。今天重點學習一下,如何利用多型、依賴注入、基於介面而非實現程式設計,來實現“對擴充套件開放、對修改關閉”。

假如,我們現在要開發一個通過Kafka來傳送非同步訊息。對於這樣一個功能的開發,我們要學會將其抽象成一組跟具體訊息佇列(Kafka)無關的非同步訊息介面。所有上層系統都依賴這組抽象的介面程式設計,並且通過依賴注入的方式來呼叫。當我們要替換新的訊息佇列的時候,比如將Kafka替換成RocketMQ,可以很方便地拔掉老的訊息佇列實現,插入新的訊息佇列實現。

// 這一部分體現了抽象意識
public interface MessageQueue { //...}
public class KafkaMessageQueue implements MessageQueue { //...}
public class RocketMQMessageQueue implements MessageQueue { //...}
public interface MessageFromatter { //...}
public class JsonMessageFromatter implements MessageFromatter { //...}
public class ProtoBufMessageFromatter implements MessageFromatter { //...}
public class Demo {
    private MessageQueue msgQueue;        // 基於介面而非實現程式設計
    public Demo(MessageQueue msgQueue) {  // 依賴注入
        this.msgQueue = msgQueue;
    }
    
    // msgFormatter:多型、依賴注入
    public void sendNotification(Notification notification, MessageFormatter msg) {
        //..
    }
}

當然,開閉原則也不是免費的,有時候,程式碼的擴充套件性會跟可讀性衝突。這個時候,我們就需要在兩者之間做一個權衡。總之,沒有一個放之四海而皆準的參考標準,全憑實際的應用場景來決定。

如何預留擴充套件點?

前面我們提到,寫出支援“對擴充套件開放、對修改關閉”的程式碼的關鍵是預留擴充套件點,那麼問題是,應該如何才能識別出所有可能的擴充套件點呢?

如果開發業務導向的系統,比如電商系統、物流系統、金融系統等,要想識別儘可能多的擴充套件點,就需要對業務本身有足夠多的瞭解。

如果開發通用、偏底層的框架、類庫、元件等,就需要了解它們會被如何使用,日後可能會新增什麼功能。

“唯一不變的就是變化本身”,儘管我們對業務系統、框架功能有足夠多的瞭解,也不能識別出所有的擴充套件點。即便我們能夠識別出所有的擴充套件點,為這些地方做預留擴充套件點的設計,成本都是很大的,這就叫做“過度設計”

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

好啦,設計原則是否應用得當,應該根據具體的業務場景,具體分析。

總結

對擴充套件開放,是為了應付變化(需求);

對修改封閉,是為了保證已有程式碼的穩定性;

最終結果是為了讓系統更有彈性

參考

《大話設計模式》

極客時間專欄《設計模式之美》

https://blog.csdn.net/sinat_20645961/article/details/48239347

相關文章