設計模式六大原則詳解

huansky發表於2020-09-26

引言

對於設計模式,自己很早之前就看了好多本設計模式書籍,其中一些還看了好幾遍,也一直希望自己能在編碼的時候把這些設計模式用上去。可是,在日常的打碼中,用的做多的就是單例,其次是觀察者和建造者模式 ( builder ) 用得比較多,其他的基本很少用到。

用不到的原因是還是不能夠理解設計模式的思想,無法將這些設計模式和編碼遇到的問題聯絡起來,從而用不到設計模式。

其實設計模式的提出都是為了解決一個常見的問題而總結出來的辦法。所以當你思考採用何種設計模式的時候,你應該先問問自己當前問題的是什麼?根據問題去選取合適的設計模式。

等你熟悉了設計模式的以後,你會發現部分設計模式之間存在包含關係,甚至很相像,但是不同的設計模式解決的問題是不一樣的。

當我們在設計一個模組的時候可以從以下幾個角度去考慮:

  • 這個模組與其他模組的關係是什麼樣的?

  • 模組中哪些部分是不變的,哪些部分是在不斷變化的,是如何變化的?

  • 類與類之間的關係是怎麼樣的,為什麼需要依賴,怎麼可以不依賴?

  • 要不要加一個介面?介面的存在是為了解決什麼問題?

當然,本文並不是教你是如何使用設計模式。而是講解設計模式的設計原則。設計模式在被設計出來的時候,也是遵循一些規則的。

設計模式六大原則,具體如下:

  • 單一職責原則(類和方法,介面)

  • 開閉原則 (擴充套件開放,修改關閉)

  • 里氏替換原則(基類和子類之間的關係)

  • 依賴倒置原則(依賴抽象介面,而不是具體物件)

  • 介面隔離原則(介面按照功能細分)

  • 迪米特法則 (類與類之間的親疏關係)

每一個設計原則旁邊都有個括號,是用來解釋,或者描述應用範圍的。下面將詳細介紹每一個原則。

 

單一職責原則的定義(類、方法、介面)

單一職責原則(Single Responsibility Principle,SRP)又稱單一功能原則。這裡的職責是指類變化的原因,單一職責原則規定一個類應該有且僅有一個引起它變化的原因,否則類應該被拆分(There should never be more than one reason for a class to change)。

該原則提出物件不應該承擔太多職責,如果一個物件承擔了太多的職責,至少存在以下兩個缺點:

  1. 一個職責的變化可能會削弱或者抑制這個類實現其他職責的能力;

  2. 當客戶端需要該物件的某一個職責時,不得不將其他不需要的職責全都包含進來,從而造成冗餘程式碼或程式碼的浪費。

單一職責原則的優點

單一職責原則的核心就是控制類的粒度大小、將物件解耦、提高其內聚性。如果遵循單一職責原則將有以下優點。

  • 降低類的複雜度。一個類只負責一項職責,其邏輯肯定要比負責多項職責簡單得多。

  • 提高類的可讀性。複雜性降低,自然其可讀性會提高。

  • 提高系統的可維護性。可讀性提高,那自然更容易維護了。

  • 變更引起的風險降低。變更是必然的,如果單一職責原則遵守得好,當修改一個功能時,可以顯著降低對其他功能的影響。

單一職責原則的實現方法

單一職責原則是最簡單但又最難運用的原則,需要設計人員發現類的不同職責並將其分離,再封裝到不同的類或模組中。而發現類的多重職責需要設計人員具有較強的分析設計能力和相關重構經驗。

示例

public interface UserService {
    
    public void login(String username, String password);
    public void register(String email, String username, String password);
    public void logError(String msg);
    public void sendEmail(String email);
    
}

這段程式碼很顯然存在很大的問題,UserService 既要負責使用者的註冊和登入,還要負責日誌的記錄和郵件的傳送,並且後者的行為明顯區別於前者。

假設我要修改傳送郵件的邏輯就得修改這個類,這時候 qa 還得迴歸登入註冊邏輯,這樣明顯不合理。

因此我們需要進行拆分,根據具體的職能可將其具體拆分如下:

UserService:只負責登入註冊

public interface UserService {

    public void login(String username, String password);
    public void register(String email, String username, String password);

}

LogService :只負責日誌

public interface LogService {

    public void logError(String msg);

}

EmailService: 只負責傳送郵件

public interface EmailService {

    public void sendEmail(String email);

}

這時候,我們們再去回顧前面提到的優點,就能深深體會了。

這裡只是講了介面,其實對類也一樣,甚至方法也是一樣的。

對於類來說,根據類名,確保裡面提供的方法都是屬於這個類的。

對於方法,不要把不相關的物件例項作為引數傳進來。如果你發現某個方法依賴某個不相關的物件,那麼這個方法的實現可能就存在問題。

比如 android 中圖片下載後顯示到 imageView 中,我提供如下的方法:

loadImage(String url, ImageView view) {

// 下載圖片,展示圖片

}

對於 loadImage 這個方法,引數 url 是ok 的,但是引數 ImageView 卻是不合理的。因為這裡做了兩個操作,下載圖片,展示圖片。應該將這個方法在進行拆分:

// 下載圖片 
loadImage(String url) {

}
// 顯示圖片 displayImage(String url, ImageView view) { // 呼叫 getBitmap (url) 獲取圖片 // 獲取圖片後將其設定到 view 中。 } // 根據 url 獲取圖片, getBitmap(String url) { }

這樣整個邏輯就很清晰。後續需要修改下載邏輯,也不會影響到展示邏輯。當然其實還有個問題是,這兩個方法要不要放在一個類裡面?

 

開閉原則

開閉原則的實現方法:可以通過“抽象約束、封裝變化”來實現開閉原則,即通過介面或者抽象類為軟體實體定義一個相對穩定的抽象層,而將相同的可變因素封裝在相同的具體實現類中。

因為抽象靈活性好,適應性廣,只要抽象的合理,可以基本保持軟體架構的穩定。而軟體中易變的細節可以從抽象派生來的實現類來進行擴充套件,當軟體需要發生變化時,只需要根據需求重新派生一個實現類來擴充套件就可以了。

示例

// 矩形
public class Rectangle {

    public double getWidth() {
        return width;
    }
    
    public double getHeight() {
        return height;
    }

} 

需要計算矩形的面積

// 面積計算器
public class AreaCalculator {

    public double area(Rectangle shape){
        return shape.getWidth() * shape.getHeight();
    }
}

假設這時候,又多了一個圓形類

// 圓形
public class Circular {

    public double getRadius(){
        return radius;
    }
}

同樣也需要計算他的面積,這時候就會變成下面這樣子:

public class AreaCalculator {

    public double area(Object shape){
        if(shape instanceof Rectangle) {
            Rectangle rectangle = (Rectangle) shape;
            return rectangle.getWidth() * rectangle.getHeight();
        } else if (shape instanceof Circular) {
            Circular circular = (Circular) shape;
            return circular.getRadius() * circular.getRadius() * Math.PI;
        } else {
            throw new RuntimeException("There is no such type.");
        }
    }
}

這麼更改完成,完全沒有問題。但是在真實的生產環境中,情況更為複雜,更改涉及的部分較多,那樣就可能導致牽一髮動全身。並且,以前編寫的經過測試的一些功能需要重新測試,甚至導致某些功能不可用。

改進版,把計算面積這個公有邏輯變成一個介面:

public interface Shape {

    public double getArea();

}
 
public class Rectangle implements Shape{

    public double getWidth() {
        return width;
    }

    public double getHeight() {
        return height;
    }

    public double getArea() {
        return getWidth() * getHeight();
    }
    
}

這樣,當需求變更,需要計算圓形面積的時候,我們只需建立一個圓形的類,並實現 Shape 介面即可:

public class Circular implements Shape {

    public double getRadius(){
        return radius;
    }

    public double getArea() {
        return getRadius() * getRadius() * Math.PI;
    }
}

計算三角形面積、四邊形面積... 的時候,我們只需讓它們去實現 Shape 介面即可,無需修改原始碼。

 

里氏替換原則

里氏替換原則主要闡述了有關繼承的一些原則,也就是什麼時候應該使用繼承,什麼時候不應該使用繼承,以及其中蘊含的原理。里氏替換原是繼承複用的基礎,它反映了基類與子類之間的關係,是對開閉原則的補充,是對實現抽象化的具體步驟的規範。

里氏替換原則的作用

里氏替換原則的主要作用如下。

  1. 里氏替換原則是實現開閉原則的重要方式之一。

  2. 它克服了繼承中重寫父類造成的可複用性變差的缺點。

  3. 它是動作正確性的保證。即類的擴充套件不會給已有的系統引入新的錯誤,降低了程式碼出錯的可能性。

  4. 加強程式的健壯性,同時變更時可以做到非常好的相容性,提高程式的維護性、可擴充套件性,降低需求變更時引入的風險。

里氏替換原則的實現方法(繼承)

里氏替換原則通俗來講就是:子類可以擴充套件父類的功能,但不能改變父類原有的功能。也就是說:子類繼承父類時,除新增新的方法完成新增功能外,儘量不要重寫父類的方法。

根據上述理解,對里氏替換原則的定義可以總結如下:

  • 子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法

  • 子類中可以增加自己特有的方法

  • 當子類的方法過載父類的方法時,方法的前置條件(即方法的輸入引數)要比父類的方法更寬鬆

  • 當子類的方法實現父類的方法時(重寫/過載或實現抽象方法),方法的後置條件(即方法的的輸出/返回值)要比父類的方法更嚴格或相等

通過重寫父類的方法來完成新的功能寫起來雖然簡單,但是整個繼承體系的可複用性會比較差,特別是運用多型比較頻繁時,程式執行出錯的概率會非常大。

如果程式違背了里氏替換原則,則繼承類的物件在基類出現的地方會出現執行錯誤。

這時其修正方法是:取消原來的繼承關係,重新設計它們之間的關係。

關於里氏替換原則的例子,最有名的是“正方形不是長方形”。當然,生活中也有很多類似的例子,例如,企鵝、鴕鳥和幾維鳥從生物學的角度來劃分,它們屬於鳥類;但從類的繼承關係來看,由於它們不能繼承“鳥”會飛的功能,所以它們不能定義成“鳥”的子類。同樣,由於“氣球魚”不會游泳,所以不能定義成“魚”的子類;“玩具炮”炸不了敵人,所以不能定義成“炮”的子類等。

對於正方形和長方形最好的做法是再新增一個父類,他們同時繼承自這個父類。

 

依賴倒置(抽線細節)

依賴倒置原則是實現開閉原則的重要途徑之一,它降低了客戶與實現模組之間的耦合。

由於在軟體設計中,細節具有多變性,而抽象層則相對穩定,因此以抽象為基礎搭建起來的架構要比以細節為基礎搭建起來的架構要穩定得多。這裡的抽象指的是介面或者抽象類,而細節是指具體的實現類。

使用介面或者抽象類的目的是制定好規範和契約,而不去涉及任何具體的操作,把展現細節的任務交給它們的實現類去完成。

依賴、倒置原則的作用

依賴倒置原則的主要作用如下。

  • 依賴倒置原則可以降低類間的耦合性。

  • 依賴倒置原則可以提高系統的穩定性。

  • 依賴倒置原則可以減少並行開發引起的風險。

  • 依賴倒置原則可以提高程式碼的可讀性和可維護性。

依賴倒置原則的實現方法

依賴倒置原則的目的是通過要面向介面的程式設計來降低類間的耦合性,所以我們在實際程式設計中只要遵循以下4點,就能在專案中滿足這個規則。

  1. 每個類儘量提供介面或抽象類,或者兩者都具備。

  2. 變數的宣告型別儘量是介面或者是抽象類。

  3. 任何類都不應該從具體類派生。

  4. 使用繼承時儘量遵循里氏替換原則。

依賴倒置原則在“顧客購物程式”中的應用。

分析:本程式反映了 “顧客類”與“商店類”的關係。商店類中有 sell() 方法,顧客類通過該方法購物以下程式碼定義了顧客類通過韶關網店 ShaoguanShop 購物

 class Customer {
    public void shopping(ShaoguanShop shop) {
        //購物
        System.out.println(shop.sell());
    }
}

但是,這種設計存在缺點,如果該顧客想從另外一家商店(如婺源網店 WuyuanShop)購物,就要將該顧客的程式碼修改如下:

class Customer {
    public void shopping(WuyuanShop shop) {
        //購物
        System.out.println(shop.sell());
    }
}

顧客每更換一家商店,都要修改一次程式碼,這明顯違背了開閉原則。

存在以上缺點的原因是:顧客類設計時同具體的商店類繫結了,這違背了依賴倒置原則。

解決方法是:定義“婺源網店”和“韶關網店”的共同介面 Shop,顧客類面向該介面程式設計,其程式碼修改如下:

class Customer {
    public void shopping(Shop shop) {
        //購物
        System.out.println(shop.sell());
    }
}

class Customer {
    public void shopping(Shop shop) {
        //購物
        System.out.println(shop.sell());
    }
}

這樣,不管顧客類 Customer 訪問什麼商店,或者增加新的商店,都不需要修改原有程式碼了,其類如下圖所示:


顧客購物程式的類圖
程式程式碼如下:

package principle;
public class DIPtest
{
    public static void main(String[] args)
    {
        Customer wang=new Customer();
        System.out.println("顧客購買以下商品:"); 
        wang.shopping(new ShaoguanShop()); 
        wang.shopping(new WuyuanShop());
    }
}
//商店
interface Shop
{
    public String sell(); //
}
//韶關網店
class ShaoguanShop implements Shop
{
    public String sell()
    {
        return "韶關土特產:香菇、木耳……"; 
    } 
}
//婺源網店
class WuyuanShop implements Shop
{
    public String sell()
    {
        return "婺源土特產:綠茶、酒糟魚……"; 
    }
} 
//顧客
class Customer
{
    public void shopping(Shop shop)
    {
        //購物
        System.out.println(shop.sell()); 
    }
} 

程式的執行結果如下:

顧客購買以下商品:
韶關土特產:香菇、木耳……
婺源土特產:綠茶、酒糟魚……

介面隔離原則(介面)

介面隔離原則(Interface Segregation Principle,ISP)要求程式設計師儘量將臃腫龐大的介面拆分成更小的和更具體的介面,讓介面中只包含客戶感興趣的方法。

2002 年羅伯特·C.馬丁給“介面隔離原則”的定義是:客戶端不應該被迫依賴於它不使用的方法(Clients should not be forced to depend on methods they do not use)。該原則還有另外一個定義:一個類對另一個類的依賴應該建立在最小的介面上(The dependency of one class to another one should depend on the smallest possible interface)。

以上兩個定義的含義是:要為各個類建立它們需要的專用介面,而不要試圖去建立一個很龐大的介面供所有依賴它的類去呼叫。

介面隔離原則和單一職責都是為了提高類的內聚性、降低它們之間的耦合性,體現了封裝的思想,但兩者是不同的:

  • 單一職責原則注重的是職責,而介面隔離原則注重的是對介面依賴的隔離。

  • 單一職責原則主要是約束類,它針對的是程式中的實現和細節;介面隔離原則主要約束介面,主要針對抽象和程式整體框架的構建。

介面隔離原則的優點

介面隔離原則是為了約束介面、降低類對介面的依賴性,遵循介面隔離原則有以下 5 個優點。

  1. 將臃腫龐大的介面分解為多個粒度小的介面,可以預防外來變更的擴散,提高系統的靈活性和可維護性。

  2. 介面隔離提高了系統的內聚性,減少了對外互動,降低了系統的耦合性。

  3. 如果介面的粒度大小定義合理,能夠保證系統的穩定性;但是,如果定義過小,則會造成介面數量過多,使設計複雜化;如果定義太大,靈活性降低,無法提供定製服務,給整體專案帶來無法預料的風險。

  4. 使用多個專門的介面還能夠體現物件的層次,因為可以通過介面的繼承,實現對總介面的定義。

  5. 能減少專案工程中的程式碼冗餘。過大的大介面裡面通常放置許多不用的方法,當實現這個介面的時候,被迫設計冗餘的程式碼。

介面隔離原則的實現方法

在具體應用介面隔離原則時,應該根據以下幾個規則來衡量。

  • 介面儘量小,但是要有限度。一個介面只服務於一個子模組或業務邏輯。

  • 為依賴介面的類定製服務。只提供呼叫者需要的方法,遮蔽不需要的方法。

  • 瞭解環境,拒絕盲從。每個專案或產品都有選定的環境因素,環境不同,介面拆分的標準就不同深入瞭解業務邏輯。

  • 提高內聚,減少對外互動。使介面用最少的方法去完成最多的事情。

對於介面隔離,大家還是可以參考單一職責提到的示例:

public interface UserService {
    
    public void login(String username, String password);
    public void register(String email, String username, String password);
    public void logError(String msg);
    public void sendEmail(String email);
    
} 

這時候,應該就能理解拆分的好處了。

 

迪米特法則 (類與類之間的關係)

迪米特法則(Law of Demeter,LoD)又叫作最少知識原則(Least Knowledge Principle,LKP),產生於 1987 年美國東北大學(Northeastern University)的一個名為迪米特(Demeter)的研究專案,由伊恩·荷蘭(Ian Holland)提出,被 UML 創始者之一的布奇(Booch)普及,後來又因為在經典著作《程式設計師修煉之道》(The Pragmatic Programmer)提及而廣為人知。

迪米特法則的定義是:只與你的直接朋友交談,不跟“陌生人”說話(Talk only to your immediate friends and not to strangers)。其含義是:如果兩個軟體實體無須直接通訊,那麼就不應當發生直接的相互呼叫,可以通過第三方轉發該呼叫。其目的是降低類之間的耦合度,提高模組的相對獨立性。

迪米特法則中的“朋友”是指:當前物件本身、當前物件的成員物件、當前物件所建立的物件、當前物件的方法引數等,這些物件同當前物件存在關聯、聚合或組合關係,可以直接訪問這些物件的方法。

迪米特法則的優點

迪米特法則要求限制軟體實體之間通訊的寬度和深度,正確使用迪米特法則將有以下兩個優點。

  1. 降低了類之間的耦合度,提高了模組的相對獨立性。

  2. 由於親合度降低,從而提高了類的可複用率和系統的擴充套件性。

但是,過度使用迪米特法則會使系統產生大量的中介類,從而增加系統的複雜性,使模組之間的通訊效率降低。所以,在釆用迪米特法則時需要反覆權衡,確保高內聚和低耦合的同時,保證系統的結構清晰。

迪米特法則的實現方法

從迪米特法則的定義和特點可知,它強調以下兩點:

  1. 從依賴者的角度來說,只依賴應該依賴的物件。

  2. 從被依賴者的角度說,只暴露應該暴露的方法。

所以,在運用迪米特法則時要注意以下 6 點。

  1. 在類的劃分上,應該建立弱耦合的類。類與類之間的耦合越弱,就越有利於實現可複用的目標。

  2. 在類的結構設計上,儘量降低類成員的訪問許可權。

  3. 在類的設計上,優先考慮將一個類設定成不變類。

  4. 在對其他類的引用上,將引用其他物件的次數降到最低。

  5. 不暴露類的屬性成員,而應該提供相應的訪問器(set 和 get 方法)。

  6. 謹慎使用序列化(Serializable)功能

明星與經紀人的關係例項。

分析:明星由於全身心投入藝術,所以許多日常事務由經紀人負責處理,如與粉絲的見面會,與媒體公司的業務洽淡等。這裡的經紀人是明星的朋友,而粉絲和媒體公司是陌生人,所以適合使用迪米特法則,其類圖如下圖所示。


明星與經紀人的關係圖
程式碼如下: 

package principle;
public class LoDtest
{
    public static void main(String[] args)
    {
        Agent agent=new Agent();
        agent.setStar(new Star("林心如"));
        agent.setFans(new Fans("粉絲韓丞"));
        agent.setCompany(new Company("中國傳媒有限公司"));
        agent.meeting();
        agent.business();
    }
}
//經紀人
class Agent
{
    private Star myStar;
    private Fans myFans;
    private Company myCompany;
    public void setStar(Star myStar)
    {
        this.myStar=myStar;
    }
    public void setFans(Fans myFans)
    {
        this.myFans=myFans;
    }
    public void setCompany(Company myCompany)
    {
        this.myCompany=myCompany;
    }
    public void meeting()
    {
        System.out.println(myFans.getName()+"與明星"+myStar.getName()+"見面了。");
    }
    public void business()
    {
        System.out.println(myCompany.getName()+"與明星"+myStar.getName()+"洽淡業務。");
    }
}
//明星
class Star
{
    private String name;
    Star(String name)
    {
        this.name=name;
    }
    public String getName()
    {
        return name;
    }
}
//粉絲
class Fans
{
    private String name;
    Fans(String name)
    {
        this.name=name;
    }
    public String getName()
    {
        return name;
    }
}
//媒體公司
class Company
{
    private String name;
    Company(String name)
    {
        this.name=name;
    }
    public String getName()
    {
        return name;
    }
}

程式的執行結果如下:

粉絲韓丞與明星林心如見面了。
中國傳媒有限公司與明星林心如洽淡業務。

 到此,設計模式的六大原則就講完了。

設計模式文章

橋接模式

中介者模式

代理模式

抽象工廠模式詳解 —— head first 設計模式

裝飾者模式

介面卡模式

策略模式

觀察者模式

建造者模式 (Builder)

相關文章