1. 程式人生 > 程式設計 >面向物件之六大設計原則

面向物件之六大設計原則

七大設計原則 — SOLID

這六大原則是業界在面向物件設計中經過總結精煉得出,在英文表示下各個原則 首字母縮寫就是SOLID。

  • Single Responsibility Principle:單一職責原則
  • Open Closed Principle:開閉原則
  • Liskov Substitution Principle:里氏替換原則
  • Interface Segregation Principle:介面隔離原則
  • Dependence Inversion Principle:依賴倒置原則

另外還有兩個設計原則。

  • Law of Demeter:迪米特法則
  • Composite/Aggregate Reuse Principle:組合/聚合複用原則

單一職責原則(SRP)

概念:

一個類,應該只有一個引起它變化的原因。通俗的講就是一個類應該只負責一個職責,如果這個類需要修改的話,也只是因為這個職責的變化了才引發類的修改。

例子:

public class Car {
    /**
     * 啟動引擎
     */
    public void start() {
        System.out.println("車輛啟動了!");
    }
    /**
     * 熄火
     */
    public void stop() {
        System.out.println("車輛熄火了!");
    }
    /**
     * 加速
     */
    public void speed
() { System.out.println("車輛加速中!"); } /** * 接送乘客 */ public void pickUpPassenger(){ System.out.println("接送乘客中!"); } /** * 去加油站加油 */ public void gasUp(){ System.out.println("去加油站加汽油!"); } } 複製程式碼

在以上的例子當中,如果汽車啟動的引數變化了就需要修改這個Car類,如果接送乘客的規則改變的話也需要修改這個類當中的程式碼。而正確的設計應該將接送客人的方法和去加油站加油的方法提取出來賦予TaxiDriver類中。將程式碼修改帶來影響的範圍限定得越小越好。一旦類包含的職責越來越多的話,類就會變得低內聚高耦合。

public class Car {
    /**
     * 啟動引擎
     */
    public void start() {
        System.out.println("車輛啟動了!");
    }
    /**
     * 熄火
     */
    public void stop() {
        System.out.println("車輛熄火了!");
    }
    /**
     * 加速
     */
    public void speed() {
        System.out.println("車輛加速中!");
    }
}

class TaxiDriver{
    /**
     * 汽車例項
     */
    public Car car;
    /**
     * 接送乘客
     */
    public void pickUpPassenger(){
        System.out.println("接送乘客中!");
    }

    /**
     * 去加油站加油
     */
    public void gasUp(){
        System.out.println("去加油站加汽油!");
    }
}
複製程式碼

在以上這個例子中就將汽車的操作與接送乘客、加油的操作進行了解耦。

優點:

  1. 降低了類的複雜性。
  2. 提高類的可讀性,提高系統的可維護性。
  3. 降低變更引起的風險(降低對其他功能的影響)。

開閉原則(OCP)

概念:

一個實體(類、函式、模組等)應該對外擴充套件開放,對內修改封閉。某實體應該易於擴充套件,在擴充套件某類的功能時應該通過新增新的程式碼來實現而不是修改其內部的程式碼。

例子:

public class Driver {
    public void drive(Car car) {
	    // 做一些駕駛前準備
        if ("BMW".equals(car.getBrand())) {
            System.out.println("駕駛寶馬車!");
        }
        if ("BENZ".equals(car.getBrand())) {
            System.out.println("駕駛賓士車!");
        }
    }
}

class Car {
    /**
     * 汽車品牌
     */
    private String brand;
    
    public String getBrand() {
        return brand;
    }
    public void setBrand(String brand) {
        this.brand = brand;
    }
}
複製程式碼

在上面的例子當中,司機類當前只會駕駛寶馬車和賓士車,那如果我想要讓司機也需要駕駛奧迪車呢?這個時候我們就需要去修改司機的drive方法了。那新增某一個功能卻需要去修改原本的程式碼時,很容易引發bug,而原本的程式碼時經過測試好的,一經修改的話又需要進行重新的測試。而所有引用到此方法的地方也需要進行修改,維護的成本就變得極高。

public class Driver {
    public void drive(Car car) {
	    // 做一些駕駛前準備
        car.start();
    }
}

class Car {
    /**
     * 汽車品牌
     */
    private String brand;

    public String getBrand() {
        return brand;
    }
    public void setBrand(String brand) {
        this.brand = brand;
    }
    public void start() {
    
    }

}

class BMW extends Car {
    public void start() {
        System.out.println("駕駛寶馬車!");
    }
}

class BENZ extends Car {
    public void start() {
        System.out.println("駕駛賓士車!");
    }
}
複製程式碼

在以上的例子當中,如果需要給司機在新增一種品牌車型的駕駛技能的時候,就不需要去修改原本的drive方法了,而僅需要新增一種品牌車型,並重寫Car類中的start方法即可。

還有一個比較通俗的例子:商品的價錢需要根據會員等級進行打折,如果在獲取價錢的 方法當中直接根據會員等級計算出價錢返回,就違反了開閉原則。而是應該通過關閉對 商品價錢方法的修改,新增一個會員類,通過呼叫商品獲取價錢的方法和會員等級計算 出價錢進行返回。

優點:

  1. 對類的功能擴充套件變得靈活。
  2. 擴充套件變得靈活的話,維護性自然就提高了。

里氏替換原則(LSP)

概念:

任何基類可以出現的地方,子類一定可以出現。 LSP是繼承複用的基石,只有當子類可以替換掉基類,軟體單位的功能不受到影響時,基類才能真正被複用。個人理解里氏替換原則是用來檢驗繼承是否合理的原則。

例子:

public class Bird {
    // 飛行速度
    int velocity;
    // 飛行操作
    public void fly() {
        System.out.println("撲打翅膀!");
        velocity = 20;
    }
    public int getVelocity() {
        return velocity;
    }
    public void setVelocity(int velocity) {
        this.velocity = velocity;
    }
}

/**
 * 鴕鳥類
 */
class Ostrich extends Bird {
    // 由於鴕鳥不會飛 所以方法裡面為空
    public void fly() {
        //I do nothing
    }
}


/**
 * 測試鳥類
 */
class TestBird {
    public void getFlyTime() {
        // 測試普通鳥類 將會得到飛行時間為10分鐘
        Bird bird = new Bird();
        int flyTimeBird = 200 / bird.getVelocity();
        // 測試鴕鳥 將會得到無限的飛行時間 程式將直接報錯
        Bird ostrich = new Ostrich();
        int flyTimeOstrich = 200 / ostrich.getVelocity();
    }
}
複製程式碼

在以上的例子當中,Bird類可以計算出飛行時間,而Ostrich類計算飛行時間就會出錯。這種就是子類無法替換父類的例子。而當你的同事呼叫計算飛行時間方法的時候,並不會去檢視每個子類中的方法實現,所以就會得出莫名其妙的結果。

里氏替換原則的實踐可以歸納為以下四點:

  • 子類必須實現父類的抽象方法,但不得重寫(覆蓋)父類的非抽象(已實現)方法。
  • 子類中可以增加自己特有的方法。
  • 當子類覆蓋或實現父類的方法時,方法的前置條件(即方法的形參)要比父類方法的輸入引數更寬鬆。
  • 當子類的方法實現父類的抽象方法時,方法的後置條件(即方法的返回值)要比父類更嚴格。

優點:

  1. 規範了父類與子類之間安全的繼承,繼承是安全的,程式碼的複用才是安全的。
  2. 繼承也提高了程式碼可維護性,方便修改父類的公共方法和子類的特定方法。

依賴倒置原則(DIP)

概念:

依賴倒置原則(Dependence Inversion Principle)是程式要依賴於抽象介面,不要依賴於
具體實現。簡單的說就是要求對抽象進行程式設計,不要對實現進行程式設計,這樣就降低了客戶與
實現模組間的耦合。
複製程式碼

例子:

public class Driver {
    public void drive(BMW car) {
        car.start();
    }
}
class BMW {
    public void start() {
        System.out.println("駕駛寶馬車!");
    }
}
複製程式碼

在以上的例子中呢,司機類直接依賴於寶馬車進行駕駛,如果有一天我們想讓司機開賓士車呢?只能去修改原方法的引數型別,改為賓士類。而我們一開始就為所有車抽象出一個抽象車類呢?此時就不用擔心去修改原方法了,只需要傳參的時候傳入賓士車即可。

例子:

public class Driver {
    // 此時可以傳BMW車,也可以傳入BENZ車
    // 如果想讓司機換開賓士車的話,不需要更改這部分的程式碼
    public void drive(Car car) {
        car.start();
    }
}

class Car {
    void start() {
    
    }
}

class BMW extends Car{
    public void start() {
        System.out.println("駕駛寶馬車!");
    }
}
class BENZ extends Car{
    public void start() {
        System.out.println("駕駛賓士車!");
    }
}
複製程式碼

這種依賴抽象的例子很多,例如我們在開發的時候定義DAO,在Service當中呼叫這些DAO時是通過宣告介面引用而不是通過宣告實現類引用。假設我們需要將資料庫框架從Hibernate轉到Mybatis的時候,Service可以毫無感知的無縫切換。因為之前Service當中並無與實現類耦合。還有比如我們專案中的日誌工具,記錄日誌依賴的都是規範好的日誌介面,如果需要從log4j遷移到logback也是對程式碼無侵入的。

優點:

  1. 減少類之間的耦合。
  2. 低耦合使得程式碼更容易進行維護和擴充套件。

介面隔離原則(ISP)

概念:

客戶端不應該依賴它不需要的介面;一個類對另一個類的依賴應該建立在最小的介面上。介面隔離原則和單一職責原則很像。單一職責是從物件的職責上面去限定,而介面隔離原則希望每個介面都是最小的介面,介面小的話才使得複用介面變得更加容易。

例子:

/**
 * 交通工具類
 */
public interface Vehicle {
    // 飛行
    void fly();
    // 航行
    void sail();
    // 陸行
    void run();
}

class AirPlane implements Vehicle {
    @Override
    public void fly() {
        System.out.println("飛行");
    }
    @Override
    public void sail() {
    
    }
    @Override
    public void run() {

    }
}
複製程式碼

在以上的例子中,交通工具類集合了所有交通的方式,這種方式不可取,如果飛機實現這個介面,卻也要去實現與飛機不相關的航行和陸行方法。我們應該縮小介面的粒度。

/**
 * 飛行介面
 */
public interface Fly {
    // 飛行
    void fly();
}

/**
 * 航行介面
 */
interface Sail {
    // 航行
    void sail();
}


/**
 * 陸行介面
 */
interface Run {
    // 陸行
    void run();
}

/**
 * 飛行器類
 */
class AirCraft implements Fly {
    @Override
    public void fly() {
        System.out.println("飛行");
    }
}
複製程式碼

優點:

  1. 避免大介面被許多子類實現,造成耦合。降低了耦合,程式碼也變得好維護。
  2. 小介面可以賦予特定的含義,使得程式碼更好理解(例如Comparable介面和Serialization介面一目瞭然)。
  3. 減少沒必要實現的冗餘程式碼。

組合/聚合複用原則(CARP)

概念:

儘量使用組合和聚合,儘量少使用繼承。為什麼呢?繼承不是面向物件的良好特性嗎? 繼承有很多侷限性。首先,繼承屬於一種硬編碼。如果沒有遵守里氏替換原則,父類一旦修改,所有子類都需要進行改變。

例子:

/**
 * 手機類有打電話、遊戲、音樂功能
 */
public class Phone extends GameBoy {
    // 撥打電話
    void call() {
        System.out.println("播放電話");
    }
}

/**
 * 播放音樂類
 */
class Pods {
    void playMusic() {
        System.out.println("播放音樂");
    }
}

/**
 * 遊戲機類,有遊戲和音樂功能
 */
class GameBoy extends Pods{
    void playGame() {
        System.out.println("玩遊戲");
    }
}
複製程式碼

在以上的例子當中,如果Pods類增加了播放磁帶的功能,而手機類並不需要播放磁帶音樂的功能。或者遊戲機類增加了搖桿按鍵功能,而我們的手機同樣不需要這個功能。這種繼承會導致手機類平臺無故的繼承了不需要的方法。或者這時候我們新出了一個智慧手機類SmartPhone類,又想使用之前的所有功能呢?再一次繼承Phone類,使得繼承的層級更深了,各個類耦合得更緊密。還有一點,Gameboy類也不一點需要播放音樂的功能。

在下面的例子,我們使用組合/聚合的方式來實現我們的手機類。

public class Phone {
    private Pods pods;
    private GameBoy gameBoy;
    // 撥打電話
    void call() {
        System.out.println("播放電話");
    }
    // 播放音樂
    void playMusic() {
        pods.playMusic();
    }
    // 玩遊戲
    void playGame() {
        gameBoy.playGame();
    }
}

/**
 * 播放音樂類
 */
class Pods {
    void playMusic() {
        System.out.println("播放音樂");
    }
}

/**
 * 遊戲機類,有遊戲和音樂功能
 */
class GameBoy {
    void playGame() {
        System.out.println("玩遊戲");
    }
}
複製程式碼

此時我們通過組合的方式,去除掉了之前的繼承耦合。

優點:

  1. 修改各個複用到的類變得容易,不用擔心會影響到其子類。
  2. 可以動態的替換各個複用類。
  3. 各個複用類各司其職,在其他地方一樣也可以使用,提高了程式碼複用。

迪米特法則(LOD)

概念:

又叫作最少知識原則(Least Knowledge Principle 簡寫LKP),就是說一個物件應當對其他物件有儘可能少的瞭解,不和陌生人說話。也就是說類應該儘可能地少的瞭解其他物件的細節。如果物件A知道物件B的所有細節,那麼物件A就可能會去使用到這些細節。如果你修改了其中物件B中的細節,就會不經意影響到A。

例子:

/**
 * 煮湯類
 * 一共有四個步驟
 */
public class CookSoup {
    void addWater() {
        System.out.println("加水");
    }
    void addFood() {
        System.out.println("加食物");
    }
    void addSalt() {
        System.out.println("加鹽");
    }
    void heat() {
        System.out.println("加熱");
    }
}

/**
 * 這個例子當中使用者知道煮湯的步驟
 */
class TestCookSoup {
    public void testCook() {
        CookSoup cookSoup = new CookSoup();
        cookSoup.addWater();
        cookSoup.addFood();
        cookSoup.addSalt();
        cookSoup.heat();
        System.out.println("得到一碗湯");
    }
}
複製程式碼

再以上的這個例子中,使用者必須知道煮湯的順序,如果不知道的話,就會把湯煮壞。又或者煮湯類修改了一條規則,addFood()加食物方法前一定要將食物切好。那麼所以引用這段程式碼的地方都需要進行增加切食物的動作。對其他物件瞭解得越多,或是瞭解越多物件都會導致物件之間的強烈耦合,一旦耦合的話,修改一處程式碼就會造成其他耦合物件也需要跟著更改。

/**
 * 煮湯類
 * 一共有四個步驟
 */
public class CookSoup {
    void addWater() {
        System.out.println("加水");
    }
    void addFood() {
        System.out.println("加食物");
    }
    void addSalt() {
        System.out.println("加鹽");
    }
    void heat() {
        System.out.println("加熱");
    }
    /**
     * 封裝煮湯步驟
     */
    void cook() {
        addWater();
        addFood();
        addSalt();
        heat();
    }
}


/**
 * 這個例子當中使用者並不知道煮湯的具體步驟
 */
class TestCookSoup {
    public void testCook() {
        CookSoup cookSoup = new CookSoup();
        cookSoup.cook();
        System.out.println("得到一碗湯");
    }
}
複製程式碼

在這個例子中,呼叫者不需要知道煮湯的步驟,直接呼叫cook()方法即可,就不會再煮出一鍋難吃的湯。而如果廚師想在煮湯操作中間新增一些特殊操作的話,也不會影響到呼叫煮湯的程式碼。因為呼叫者並不知道真正的煮湯方法。

優點:

  1. 降低了類之間的耦合。
  2. 提高了可維護性。
  3. 減少了程式碼的可維護性。

個人理解,如有錯誤歡迎大家指正。

轉自我的個人部落格-vc2x.com