設(shè)計模式之禪(一) —— 六大設(shè)計原則

1.1 單一職責原則

單一職責原則:Singel Responsibility Principle,SRP
單一職責原則的定義:應(yīng)該有且僅有一個原因引起類的變更。

書中提到一個例子:對電話的抽象。


電話類圖.png

繼續(xù)細化,向下拆分,向上抽象

電話通話時可以抽象出4個過程:撥號、通話、回應(yīng)、掛機。
在舉著個例子的時候,作者提到大部分人可能都會說這個沒什么問題,動作定義比較清晰。其實如果更深層的了解電話的結(jié)構(gòu),應(yīng)該可以對電話類進行一個更完整的抽象。
比如:電話的通話過程,是否是自始至終都是這四個階段,在發(fā)展過程中會不會增加或者減少。如果以階段為維度來進行抽象,是否會出現(xiàn)經(jīng)常變更的情況。如果換一個維度,如:從更底層職責的角度來進行抽象,會抽象出“協(xié)議管理”和“數(shù)據(jù)傳輸” 兩個角度,無論中間階段發(fā)生什么變化,這兩個職責,是一個電話必須擁有的。
所以我們在對業(yè)務(wù)模型進行抽象定義時,也需要盡量的事無巨細的了解業(yè)務(wù)的模型,然后再做分析。

回歸原則定義

原則定義:應(yīng)該有且僅有一個原因引起類的變更。
上面的接口并不是“只有一個原因引起變化”的。IPhone 不只是只有一個職責,它包含兩個職責:

  • 協(xié)議管理:dial() 和hangup() 方法負責撥號接通和掛機
  • 數(shù)據(jù)傳送:chat() 實現(xiàn)數(shù)據(jù)傳送,把話轉(zhuǎn)換成信號在雙方之間傳遞

這兩個職責都會引起這個接口或者實現(xiàn)類的變化:

日常習慣

比如一個用戶信息接口:


image.png

這個接口有一個“修改用戶信息”的方法。這個方法太過于籠統(tǒng),一個方法承擔了多個職責,這樣的接口雖然對上層來說只提供了一個接口,但是它的職責并不是單一的。這樣做需要在接口文檔上做額外的注釋,說明這個修改接口都可以修改哪些信息,操作參數(shù)什么情況需要傳什么樣的值。在《代碼整潔之道》中建議過:提供好的注釋,不如將代碼寫的別人一看就明白,無需注釋。這里也一樣,一個好的接口的定義,不需要文檔中做長篇大論的調(diào)用說明。與其做一堆說明,不如在定義接口的時候,定義的清晰易懂。


image.png

這樣定義會對上層更友好一點,將修改用戶信息拆解為多個方法,每個方法只負責一件事,別人一看就知道,那個方法改的是什么,這個接口每個方法都能修改什么,清晰完整。

實際開發(fā)

雖然一直說要按照SRP的原則去進行設(shè)計,但畢竟理論是理論,實踐是實踐。在實際開發(fā)過程中,有很多因素導致最終無法達到按照SRP原則的最終效果,比如開會討論業(yè)務(wù)模型中職責的劃分;又比如deadline比較緊急,沒有足夠的時間進行討論和設(shè)計。一個行業(yè)的驅(qū)動最終還是業(yè)務(wù),代碼只是實現(xiàn)業(yè)務(wù)的工具,是輪子。一個功能,最低要求就是先能跑起來,完成功能。只是在一開始實現(xiàn)的時候,盡可能的去往 SRP 上靠,讀者的建議是:

對于單一職責原則, 我的建議是接口一定要做到單一職責, 類的設(shè)計盡量做到只有一個原因引起變化。

1.2 里氏替換原則

里氏替換原則原則是在繼承方面上的一個要求。它是針對繼承的弊端而出現(xiàn)的一個原則。
繼承的優(yōu)點:

  • 共享代碼,提供代碼的重用性
  • 提高代碼的擴展性
  • 提高產(chǎn)品或者項目的開放性

繼承的缺點:

  • 繼承是侵入性的,只要繼承,就必須擁有父類的所有屬性和方法
  • 增強了耦合性。當父類的內(nèi)容修改時,需要考慮子類的修改,可能會造成大段代碼需要重構(gòu)

定義

Liskov Substitution Principle, LSP

所有引用父類的地方必須能透明的使用其子類的對象。

通俗的講,只要父類出現(xiàn)的地方子類就可以出現(xiàn),而且替換為子類也不會產(chǎn)生任何錯誤或者異常,使用者可能根本不需要知道是父類還是子類。但是反過來就不行,有子類的地方,無法用父類進行替換。

LSP 對良好的繼承定義了一個規(guī)范,這個規(guī)范包含四層含義:

1. 子類必須完全實現(xiàn)父類的方法

書中的例子是“士兵和槍”的例子。當定義“ToyGun”時,由于 ToyGun 無法殺人,程序無法正常運行。原因是 ToyGun 無法完整的實現(xiàn) shoot 功能。

在具體應(yīng)用場景中就要考慮下面這個問題了: 子類是否能夠完整地實現(xiàn)父類的業(yè)務(wù), 否則就會出現(xiàn)像上面的拿槍殺敵人時卻發(fā)現(xiàn)是把玩具槍的笑話

如果子類不能完整地實現(xiàn)父類的方法, 或者父類的某些方法在子類中已經(jīng)發(fā)
生“畸變”, 則建議斷開父子繼承關(guān)系, 采用依賴、 聚集、 組合等關(guān)系代替承。

注意:在類中調(diào)用其他類時務(wù)必要使用父類或接口, 如果不能使用父類或接口, 則說明類的設(shè)計已經(jīng)違背了LSP原則。

2. 子類可以有自己的個性

書中給出的例子是“狙擊手使用狙擊槍殺人”,表達的意思是:如果實例類型為子類,則子類無法強轉(zhuǎn)成父類類型進行調(diào)用。

3. 覆蓋或?qū)崿F(xiàn)父類的方法是輸入?yún)?shù)可以被放大

書中舉出了一個例子,這個例子會導致“子類在沒有覆寫父類的方法的前提下,子類方法被執(zhí)行了”。
這個例子中子類對于方法的定義就有問題:

public class Father {
    public Collection doSomething(Map map) {
        System.out.println("父類被執(zhí)行...");
        return map.values();
    }
}

public class Son extends Father {
    //縮小輸入?yún)?shù)范圍
    public Collection doSomething(HashMap map) {
        System.out.println("子類被執(zhí)行...");
        return map.values();
    }
}

public class Client {
    public static void invoker() {
        //有父類的地方就有子類
        Father f = new Father();
        HashMap map = new HashMap();
        f.doSomething(map);
    }

    public static void main(String[] args) {
        invoker();
    }
}

子類的doSomething方法,并不是覆蓋,而是對從父類繼承過來的doSomething方法的重載。在 Client 執(zhí)行時,如果使用子類去替換,實際執(zhí)行的將會是子類的 doSomething 方法。從而導致了“子類在沒有覆寫父類的方法的前提下,子類方法被執(zhí)行了”。
注意,這里是有一個前提:“子類在沒有覆寫父類的方法的前提下

如果是子類在復寫了父類的方法下,使用子類去替換父類,調(diào)用的實際是子類的方法,這樣是ok的。但是上面卻是沒有復寫到父類的方法。沒有復寫,而且輸入?yún)?shù)的范圍比父類的方法大,就會出現(xiàn)問題。

正確的例子是:

public class Father {
    public Collection doSomething(HashMap map) {
        System.out.println("父類被執(zhí)行...");
        return map.values();
    }
}

public class Son extends Father {
    //縮小輸入?yún)?shù)范圍
    public Collection doSomething(Map map) {
        System.out.println("子類被執(zhí)行...");
        return map.values();
    }
}

public class Client {
    public static void invoker() {
        //有父類的地方就有子類
        Father f = new Father();
        HashMap map = new HashMap();
        f.doSomething(map);
    }

    public static void main(String[] args) {
        invoker();
    }
}

這樣執(zhí)行,子類也沒有復寫到父類的方法,但是在Client中用子類去替換父類,實際執(zhí)行的還是父類方法。
最根本的原因,就是 子類在定義同名方法時,輸入?yún)?shù)的范圍比父類更大。

4. 覆蓋或?qū)崿F(xiàn)父類的方法是輸出結(jié)果可以被縮小

書中分了兩種情況:

  • 子類覆蓋:返回值范圍要小于等于父類的方法
  • 方法重載:這個無所謂,因為不會調(diào)用到該方法
    這個也比較好理解,目的是:
    讓上層在調(diào)用目標方法后,在使用方法的返回值時不會出現(xiàn)不存在方法的現(xiàn)象。如果返回值是父類,而實際返回值類型是子類,這樣沒什么問題;如果反過來,就可能會出現(xiàn)問題,上層在調(diào)用返回值中的方法,有可能是子類獨有的方法,而返回值類型是父類,會出現(xiàn)調(diào)用失敗的現(xiàn)象。

總結(jié):

遵守了這四個規(guī)范,也就相當于遵守了 LSP 原則

1.3 依賴倒置原則

依賴倒置原則的表現(xiàn):

  • 模塊間的依賴通過抽象發(fā)生,實現(xiàn)類之間不發(fā)生直接的依賴關(guān)系,其依賴關(guān)系是通過接口或抽象類產(chǎn)生的;
  • 接口或抽象類不依賴于實現(xiàn)類;
  • 實現(xiàn)類依賴接口或抽象類。

書中用“司機開車”的例子來說明。

image.png
public class Benz {
    //汽車肯定會跑
    public void run() {
        System.out.println("奔馳汽車開始運行...");
    }
}
public class Client {
    public static void main (String[] args)  {
        Driver zhangSan = new Driver();
        Benz benz = new Benz();
        //張三開奔馳車
        zhangSan.drive(benz);
    }
}

Driver 和 Benz 類都是實現(xiàn)類,Driver 強依賴 Benz 類。
如果將來需要司機去開 BMW,程序?qū)o法完成。此處進行結(jié)構(gòu)優(yōu)化,對實現(xiàn)類進行抽象,解除強依賴關(guān)系。


image.png
public interface IDriver {
    //是司機就應(yīng)該會駕駛汽車
    public void drive(ICar car);
}

public class Driver implements IDriver{
    //司機的主要職責就是駕駛汽車
    public void drive(ICar car){
        car.run();
    }
}

public interface ICar {
    //是汽車就應(yīng)該能跑
    public void run();
}

public class Benz implements ICar{
    //汽車肯定會跑
    public void run(){
        System.out.println("奔馳汽車開始運行...");
    }
}

public class BMW implements ICar{
    //寶馬車當然也可以開動了
    public void run(){
        System.out.println("寶馬汽車開始運行...");
    }
}

public class Client {
    public static void main (String[] args)  {
        IDriver zhangSan = new Driver();
        ICar benz = new Benz();
        //張三開奔馳車
        zhangSan.drive(benz);

        //IDriver zhangSan = new Driver();
        //ICar bmw = new BMW();
        //張三開奔馳車
        //zhangSan.drive(bmw);
    }
}

總結(jié)一下依賴倒置的好處:

  • 在新增加低層模塊時, 只修改了業(yè)務(wù)場景類, 也就是高層模塊, 對其他低層模塊如Driver類不需要做任何修改, 業(yè)務(wù)就可以運行, 把“變更”引起的風險擴散降到最低
  • 兩個類之間有依賴關(guān)系, 只要制定出兩者之間的接口( 或抽象類) 就可以獨立開發(fā)了, 而且項目之間的單元測試也可以獨立地運行

最佳實踐

我們怎么在項目中使用這個規(guī)則呢:

  • 每個類盡量都有接口或抽象類, 或者抽象類和接口兩者都具備
  • 變量的表面類型盡量是接口或者是抽象類
  • 任何類都不應(yīng)該從具體類派生
  • 盡量不要覆寫基類的方法
  • 結(jié)合里氏替換原則使用

1.4 接口隔離原則

定義

接口:

  • 類接口
  • 實例接口

隔離:

  • 客戶端不應(yīng)該依賴它不需要的接口
  • 類之間的依賴關(guān)系應(yīng)該建立在最小的接口之上

接口隔離原則概括為一句話:

建立單一接口,接口盡量細化,同時接口中方法盡量少。

例子

書中給出的例子是“美女類”的例子


image.png

接口定義了一個美女:

public interface IPettyGirl {
    //要有姣好的面孔
    public void goodLooking();
    //要有好身材
    public void niceFigure();
    //要有氣質(zhì)
    public void greatTemperament();
}

接口存在的問題是:審美會隨著時間的改變而改變。

接口IPettyGirl的設(shè)計是有缺陷的, 過于龐大了, 容納了一些可變的因素。而我們卻把這些特質(zhì)都封裝了起來, 放到了一個接口中, 封裝過度了。

把原IPettyGirl接口拆分為兩個接口, 一種是外形美的美女IGoodBodyGirl, 這類美女的特點就是臉蛋和身材極棒, 超一流, 但是沒有審美素質(zhì), 比如隨地吐痰, 文化程度比較低; 另外一種是氣質(zhì)美的美女IGreatTemperamentGirl, 談吐和修養(yǎng)都非常高。把一個比較臃腫的接口拆分成了兩個專門的接口, 靈活性提高了, 可維護性也增加了, 不管以后是要外形美的美女還是氣質(zhì)美的美女都可以輕松地通過PettyGirl定義。


修改后的星探尋找美女類圖
/** 外表型美女 **/
public interface IGoodBodyGirl {
    //要有姣好的面孔
    public void goodLooking();
    //要有好身材
    public void niceFigure();
}

/** 氣質(zhì)型美女 **/
public interface IGreatTemperamentGirl {
    //要有氣質(zhì)
    public void greatTemperament();
}

/** 最標準的美女,擁有所有優(yōu)點 **/
public class PettyGirl implements IGoodBodyGirl,IGreatTemperamentGirl {

    private String name;
    //美女都有名字
    public PettyGirl(String _name){
        this.name=_name;
    }

    //臉蛋漂亮
    public void goodLooking() {
        System.out.println(this.name + "---臉蛋很漂亮!");
    }

    //氣質(zhì)要好
    public void greatTemperament () {
        System.out.println(this.name + "---氣質(zhì)非常好!");
    }
    //身材要好
    public void niceFigure () {
        System.out.println(this.name + "---身材非常棒!");
    }
}

讓客戶端去依賴兩個專用的接口,比去依賴一個綜合的接口要更加靈活。

接口隔離原則的目的

接口隔離原則是對接口進行規(guī)范的約束

接口要盡量的小

這一點上面的例子已經(jīng)體現(xiàn)了,如果一個接口已經(jīng)存在了臃腫現(xiàn)象,它會影響一個正常的代碼結(jié)構(gòu),一些不需要實現(xiàn)的方法強制去實現(xiàn)。要對接口進行細化。

接口要高內(nèi)聚

高內(nèi)聚 就是提高接口、 類、 模塊的處理能力, 減少對外的交互。
具體到接口隔離原則就是, 要求在接口中盡量少公布public方法, 接口是對外的承諾, 承諾越少對系統(tǒng)的開發(fā)越有利, 變更的風險也就越少, 同時也有利于降低成本

定制服務(wù)

只提供訪問者需要的方法

接口設(shè)計是有限度的:

  • 對接口的拆分也需要有度,根據(jù)接口隔離原則拆分接口時,首先必須滿足單一職責原則
  • 接口的設(shè)計粒度越小, 系統(tǒng)越靈活, 這是不爭的事實。 但是, 靈活的同時也帶來了結(jié)構(gòu)的復雜化, 開發(fā)難度增加, 可維護性降低。

如何衡量原子接口或原子類的劃分:

  • 一個接口只服務(wù)于一個子模塊或業(yè)務(wù)邏輯
  • 通過業(yè)務(wù)邏輯壓縮接口中的public方法, 接口時常去回顧, 盡量讓接口達到“滿身筋骨肉”, 而不是“肥嘟嘟”的一大堆方法
  • 已經(jīng)被污染了的接口, 盡量去修改, 若變更的風險較大, 則采用適配器模式進行轉(zhuǎn)化處理
  • 了解環(huán)境, 拒絕盲從。 每個項目或產(chǎn)品都有特定的環(huán)境因素,深入了解業(yè)務(wù)邏輯

與單一職責原則的區(qū)別

接口隔離原則與單一職責的審視角度是不相同的, 單一職責要求的是類和接口職責單一, 注重的是職責, 這是業(yè)務(wù)邏輯上的劃分, 而接口隔離原則要求接口的方法盡量少。

1.5 迪米特法則

定義

原則

對外公開的范圍

一個類公開的public屬性或方法越多, 修改時涉及的面也就越大, 變更引起的風險擴散也就越大,因此, 在設(shè)計時需要反復衡量:

  • 是否還可以再減少 public方法和屬性
  • 是否可以修改為private、 package-private(包類型, 在類、 方法、 變量前不加訪問權(quán)限, 則默認為包類型) 、 protected等訪問權(quán)限
  • 是否可以加上final關(guān)鍵字等

成員的歸屬

如果一個方法或者屬性放在本類中, 既不增加類間關(guān)系, 也對本類不產(chǎn)生負面影響, 那就放置在本類中

最佳實踐

迪米特法則的核心觀念就是類間解耦, 弱耦合,既做到讓結(jié)構(gòu)清晰, 又做到高內(nèi)聚低耦合。

1.6 開閉原則

定義

對擴展開放, 對修改關(guān)閉, 其含義是說一個軟件實體應(yīng)該通過擴展來實現(xiàn)變化, 而不是通過修改已有的代碼來實現(xiàn)變化

例子

書中用 “書店買書” 的例子來進行說明


image.png
/* 書籍接口 */
public interface IBook {
      //書籍有名稱
      public String getName();
      //書籍有售價
      public int getPrice();
      //書籍有作者
      public String getAuthor();
}

/* 小說類 */
public class NovelBook implements IBook {
    //書籍名稱
    private String name;
    //書籍的價格
    private int price;
    //書籍的作者
    private String author;
    //通過構(gòu)造函數(shù)傳遞書籍數(shù)據(jù)
    public NovelBook(String _name,int _price,String _author){
        this.name = _name;
        this.price = _price;
        this.author = _author;
    }
    //作者是誰
    public String getAuthor() {
        return this.author;
    }
    //書籍叫什么名字
    public String getName() {
        return this.name;
    }
    //獲得書籍的價格
    public int getPrice() {
        return this.price;
    }
}

/* 模擬業(yè)務(wù)流程類 */
public class BookStore {
    private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
    //static靜態(tài)模塊初始化數(shù)據(jù), 實際項目中一般是由持久層完成
    static{
        bookList.add(new NovelBook("天龍八部",3200,"金庸"));
        bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
        bookList.add(new NovelBook("悲慘世界",3500,"雨果"));
        bookList.add(new NovelBook("金瓶梅",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)+"元");
        }
    }
}

此時需求增加,需要對打折的書籍的價格進行特殊調(diào)整。

  • 打折行為只會出現(xiàn)在打折書籍中,并不存在于所有書籍。所以不能改動 IBook 接口;
  • 例如采購書籍人員也是要看價格的, 由于該方法已經(jīng)實現(xiàn)了打折處理價格, 因此采購人員看到的也是打折后的價格, 會因信息不對稱而出現(xiàn)決策失誤的情況。 因此, 該方案也不是一個最優(yōu)的方案。(說來慚愧,書上的這一段我沒咋明白作者想表達的意思...)

此時需要構(gòu)造一個新的類作為 NovelBook 的子類


image.png
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;
    }
}

/* 業(yè)務(wù)流程類 */
public class BookStore {
    private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
    //static靜態(tài)模塊初始化數(shù)據(jù), 實際項目中一般是由持久層完成
    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書籍作者: "
        }
    }
}

在定義了新的子類之后,輸入的圖書列表對象可能存在正常的 NovelBook,也會有 OffNovelBook,無論存在什么,業(yè)務(wù)主流程還是無需改動的。關(guān)鍵點在于

在 BookStore 類中,也可以將 bookList 看做是一種外界輸入,參數(shù)的類型為接口類型,main 方法中也是使用的是接口類型對象進行操作。

開閉原則的意義

  • 主業(yè)務(wù)流程不會改動的太頻繁
  • 單測用例不需要頻繁改動
  • 提高復用性
  • 提高可維護性
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容