設計模式(二) —— 結構型模式(上)

在上一篇文章中,我們學習了 5 種構建型模式。它們主要用於構建對象。讓我們簡單回顧一下:

  • 工廠方法模式:爲每一類對象建立工廠,將對象交由工廠創建,客戶端只和工廠打交道。
  • 抽象工廠模式:爲每一類工廠提取出抽象接口,使得新增工廠、替換工廠變得非常容易。
  • 建造者模式:用於創建構造過程穩定的對象,不同的 Builder 可以定義不同的配置。
  • 單例模式:全局使用同一個對象,分爲餓漢式和懶漢式。懶漢式有雙檢鎖和內部類兩種實現方式。
  • 原型模式:爲一個類定義 clone 方法,使得創建相同的對象更方便。

本篇文章我們將一起學習結構型模式,顧名思義,結構型模式是用來設計程序的結構的。結構型模式就像搭積木,將不同的類結合在一起形成契合的結構。包括以下幾種:

  • 適配器模式
  • 橋接模式
  • 組合模式
  • 裝飾模式
  • 外觀模式
  • 享元模式
  • 代理模式

由於內容較多,本篇我們先講解前三種模式。

一、適配器模式

說到適配器,我們最熟悉的莫過於電源適配器了,也就是手機的充電頭。它就是適配器模式的一個應用。

試想一下,你有一條連接電腦和手機的 USB 數據線,連接電腦的一端從電腦接口處接收 5V 的電壓,連接手機的一端向手機輸出 5V 的電壓,並且他們工作良好。

中國的家用電壓都是 220V,所以 USB 數據線不能直接拿來給手機充電,這時候我們有兩種方案:

  • 單獨製作手機充電器,接收 220V 家用電壓,輸出 5V 電壓。
  • 添加一個適配器,將 220V 家庭電壓轉化爲類似電腦接口的 5V 電壓,再連接數據線給手機充電。

如果你使用過早期的手機,就會知道以前的手機廠商採用的就是第一種方案:早期的手機充電器都是單獨製作的,充電頭和充電線是連在一起的。現在的手機都採用了電源適配器加數據線的方案。這是生活中應用適配器模式的一個進步。

適配器模式:將一個類的接口轉換成客戶希望的另外一個接口,使得原本由於接口不兼容而不能一起工作的那些類能一起工作。

適配的意思是適應、匹配。通俗地講,適配器模式適用於有相關性但不兼容的結構,源接口通過一箇中間件轉換後纔可以適用於目標接口,這個轉換過程就是適配,這個中間件就稱之爲適配器。

家用電源和 USB 數據線有相關性:家用電源輸出電壓,USB 數據線輸入電壓。但兩個接口無法兼容,因爲一個輸出 220V,一個輸入 5V,通過適配器將輸出 220V 轉換成輸出 5V 之後纔可以一起工作。

讓我們用程序來模擬一下這個過程。

首先,家庭電源提供 220V 的電壓:

class HomeBattery {
    int supply() {
        // 家用電源提供一個 220V 的輸出電壓
        return 220;
    }
}

USB 數據線只接收 5V 的充電電壓:

class USBLine {
    void charge(int volt) {
        // 如果電壓不是 5V,拋出異常
        if (volt != 5) throw new IllegalArgumentException("只能接收 5V 電壓");
        // 如果電壓是 5V,正常充電
        System.out.println("正常充電");
    }
}

先來看看適配之前,用戶如果直接用家庭電源給手機充電:

public class User {
    @Test
    public void chargeForPhone() {
        HomeBattery homeBattery = new HomeBattery();
        int homeVolt = homeBattery.supply();
        System.out.println("家庭電源提供的電壓是 " + homeVolt + "V");

        USBLine usbLine = new USBLine();
        usbLine.charge(homeVolt);
    }
}

運行程序,輸出如下:

家庭電源提供的電壓是 220V

java.lang.IllegalArgumentException: 只能接收 5V 電壓

這時,我們加入電源適配器:

class Adapter {
    int convert(int homeVolt) {
        // 適配過程:使用電阻、電容等器件將其降低爲輸出 5V
        int chargeVolt = homeVolt - 215;
        return chargeVolt;
    }
}

然後,用戶再使用適配器將家庭電源提供的電壓轉換爲充電電壓:

public class User {
    @Test
    public void chargeForPhone() {
        HomeBattery homeBattery = new HomeBattery();
        int homeVolt = homeBattery.supply();
        System.out.println("家庭電源提供的電壓是 " + homeVolt + "V");

        Adapter adapter = new Adapter();
        int chargeVolt = adapter.convert(homeVolt);
        System.out.println("使用適配器將家庭電壓轉換成了 " + chargeVolt + "V");

        USBLine usbLine = new USBLine();
        usbLine.charge(chargeVolt);
    }
}

運行程序,輸出如下:

家庭電源提供的電壓是 220V
使用適配器將家庭電壓轉換成了 5V
正常充電

這就是適配器模式。在我們日常的開發中經常會使用到各種各樣的 Adapter,都屬於適配器模式的應用。

但適配器模式並不推薦多用。因爲未雨綢繆好過亡羊補牢,如果事先能預防接口不同的問題,不匹配問題就不會發生,只有遇到源接口無法改變時,才應該考慮使用適配器。比如現代的電源插口中很多已經增加了專門的充電接口,讓我們不需要再使用適配器轉換接口,這又是社會的一個進步。

二、橋接模式

考慮這樣一個需求:繪製矩形、圓形、三角形這三種圖案。按照面向對象的理念,我們至少需要三個具體類,對應三種不同的圖形。

抽象接口 IShape:

public interface IShape {
    void draw();
}

三個具體形狀類:

class Rectangle implements IShape {
    @Override
    public void draw() {
        System.out.println("繪製矩形");
    }
}

class Round implements IShape {
    @Override
    public void draw() {
        System.out.println("繪製圓形");
    }
}

class Triangle implements IShape {
    @Override
    public void draw() {
        System.out.println("繪製三角形");
    }
}

接下來我們有了新的需求,每種形狀都需要有四種不同的顏色:紅、藍、黃、綠。

這時我們很容易想到兩種設計方案:

  • 爲了複用形狀類,將每種形狀定義爲父類,每種不同顏色的圖形繼承自其形狀父類。此時一共有 12 個類。
  • 爲了複用顏色類,將每種顏色定義爲父類,每種不同顏色的圖形繼承自其顏色父類。此時一共有 12 個類。

乍一看沒什麼問題,我們使用了面向對象的繼承特性,複用了父類的代碼並擴展了新的功能。

但仔細想一想,如果以後要增加一種顏色,比如黑色,那麼我們就需要增加三個類;如果再要增加一種形狀,我們又需要增加五個類,對應 5 種顏色。

更不用說遇到增加 20 個形狀,20 種顏色的需求,不同的排列組合將會使工作量變得無比的龐大。看來我們不得不重新思考設計方案。

形狀和顏色,都是圖形的兩個屬性。他們兩者的關係是平等的,所以不屬於繼承關係。更好的的實現方式是:將形狀和顏色分離,根據需要對形狀和顏色進行組合,這就是橋接模式的思想。

橋接模式:將抽象部分與它的實現部分分離,使它們都可以獨立地變化。它是一種對象結構型模式,又稱爲柄體模式或接口模式。

官方定義非常精準、簡練,但卻有點不易理解。通俗地說,如果一個對象有兩種或者多種分類方式,並且兩種分類方式都容易變化,比如本例中的形狀和顏色。這時使用繼承很容易造成子類越來越多,所以更好的做法是把這種分類方式分離出來,讓他們獨立變化,使用時將不同的分類進行組合即可。

說到這裏,不得不提一個設計原則:合成 / 聚合複用原則。雖然它沒有被劃分到六大設計原則中,但它在面向對象的設計中也非常的重要。

合成 / 聚合複用原則:優先使用合成 / 聚合,而不是類繼承。

繼承雖然是面向對象的三大特性之一,但繼承會導致子類與父類有非常緊密的依賴關係,它會限制子類的靈活性和子類的複用性。而使用合成 / 聚合,也就是使用接口實現的方式,就不存在依賴問題,一個類可以實現多個接口,可以很方便地拓展功能。

讓我們一起來看一下本例使用橋接模式的程序實現:

新建接口類 IColor,僅包含一個獲取顏色的方法:

public interface IColor {
    String getColor();
}

每種顏色都實現此接口:

public class Red implements IColor {
    @Override
    public String getColor() {
        return "紅";
    }
}

public class Blue implements IColor {
    @Override
    public String getColor() {
        return "藍";
    }
}

public class Yellow implements IColor {
    @Override
    public String getColor() {
        return "黃";
    }
}

public class Green implements IColor {
    @Override
    public String getColor() {
        return "綠";
    }
}

在每個形狀類中,橋接 IColor 接口:

class Rectangle implements IShape {

    private IColor color;

    void setColor(IColor color) {
        this.color = color;
    }

    @Override
    public void draw() {
        System.out.println("繪製" + color.getColor() + "矩形");
    }
}

class Round implements IShape {

    private IColor color;

    void setColor(IColor color) {
        this.color = color;
    }

    @Override
    public void draw() {
        System.out.println("繪製" + color.getColor() + "圓形");
    }
}

class Triangle implements IShape {

    private IColor color;

    void setColor(IColor color) {
        this.color = color;
    }

    @Override
    public void draw() {
        System.out.println("繪製" + color.getColor() + "三角形");
    }
}

測試函數:

@Test
public void drawTest() {
    Rectangle rectangle = new Rectangle();
    rectangle.setColor(new Red());
    rectangle.draw();
    
    Round round = new Round();
    round.setColor(new Blue());
    round.draw();
    
    Triangle triangle = new Triangle();
    triangle.setColor(new Yellow());
    triangle.draw();
}

運行程序,輸出如下:

繪製紅矩形
繪製藍圓形
繪製黃三角形

這時我們再來回顧一下官方定義:將抽象部分與它的實現部分分離,使它們都可以獨立地變化。抽象部分指的是父類,對應本例中的形狀類,實現部分指的是不同子類的區別之處。將子類的區別方式 —— 也就是本例中的顏色 —— 分離成接口,通過組合的方式橋接顏色和形狀,這就是橋接模式,它主要用於兩個或多個同等級的接口

三、組合模式

上文說到,橋接模式用於將同等級的接口互相組合,那麼組合模式和橋接模式有什麼共同點嗎?

事實上組合模式和橋接模式的組合完全不一樣。組合模式用於整體與部分的結構,當整體與部分有相似的結構,在操作時可以被一致對待時,就可以使用組合模式。例如:

  • 文件夾和子文件夾的關係:文件夾中可以存放文件,也可以新建文件夾,子文件夾也一樣。
  • 總公司子公司的關係:總公司可以設立部門,也可以設立分公司,子公司也一樣。
  • 樹枝和分樹枝的關係:樹枝可以長出葉子,也可以長出樹枝,分樹枝也一樣。

在這些關係中,雖然整體包含了部分,但無論整體或部分,都具有一致的行爲。

組合模式:又叫部分整體模式,是用於把一組相似的對象當作一個單一的對象。組合模式依據樹形結構來組合對象,用來表示部分以及整體層次。這種類型的設計模式屬於結構型模式,它創建了對象組的樹形結構。

考慮這樣一個實際應用:設計一個公司的人員分佈結構,結構如下圖所示。

我們注意到人員結構中有兩種結構,一是管理者,如老闆,PM,CFO,CTO,二是職員。其中有的管理者不僅僅要管理職員,還會管理其他的管理者。這就是一個典型的整體與部分的結構。

3.1.不使用組合模式的設計方案

要描述這樣的結構,我們很容易想到以下設計方案:

新建管理者類:

public class Manager {
    // 職位
    private String position;
    // 工作內容
    private String job;
    // 管理的管理者
    private List<Manager> managers = new ArrayList<>();
    // 管理的職員
    private List<Employee> employees = new ArrayList<>();

    public Manager(String position, String job) {
        this.position = position;
        this.job = job;
    }
    
    public void addManager(Manager manager) {
        managers.add(manager);
    }

    public void removeManager(Manager manager) {
        managers.remove(manager);
    }

    public void addEmployee(Employee employee) {
        employees.add(employee);
    }

    public void removeEmployee(Employee employee) {
        employees.remove(employee);
    }

    // 做自己的本職工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }
    
    // 檢查下屬
    public void check() {
        work();
        for (Employee employee : employees) {
            employee.work();
        }
        for (Manager manager : managers) {
            manager.check();
        }
    }
}

新建職員類:

public class Employee {
    // 職位
    private String position;
    // 工作內容
    private String job;

    public Employee(String position, String job) {
        this.position = position;
        this.job = job;
    }

    // 做自己的本職工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }
}

客戶端建立人員結構關係:

public class Client {
    
    @Test
    public void test() {
        Manager boss = new Manager("老闆", "唱怒放的生命");
        Employee HR = new Employee("人力資源", "聊微信");
        Manager PM = new Manager("產品經理", "不知道幹啥");
        Manager CFO = new Manager("財務主管", "看劇");
        Manager CTO = new Manager("技術主管", "划水");
        Employee UI = new Employee("設計師", "畫畫");
        Employee operator = new Employee("運營人員", "兼職客服");
        Employee webProgrammer = new Employee("程序員", "學習設計模式");
        Employee backgroundProgrammer = new Employee("後臺程序員", "CRUD");
        Employee accountant = new Employee("會計", "背九九乘法表");
        Employee clerk = new Employee("文員", "給老闆遞麥克風");
        boss.addEmployee(HR);
        boss.addManager(PM);
        boss.addManager(CFO);
        PM.addEmployee(UI);
        PM.addManager(CTO);
        PM.addEmployee(operator);
        CTO.addEmployee(webProgrammer);
        CTO.addEmployee(backgroundProgrammer);
        CFO.addEmployee(accountant);
        CFO.addEmployee(clerk);

        boss.check();
    }
}

運行測試方法,輸出如下(爲方便查看,筆者添加了縮進):

我是老闆,我正在唱怒放的生命
	我是人力資源,我正在聊微信
	我是產品經理,我正在不知道幹啥
		我是設計師,我正在畫畫
		我是運營人員,我正在兼職客服
		我是技術主管,我正在划水
			我是程序員,我正在學習設計模式
			我是後臺程序員,我正在CRUD
	我是財務主管,我正在看劇
		我是會計,我正在背九九乘法表
		我是文員,我正在給老闆遞麥克風

這樣我們就設計出了公司的結構,但是這樣的設計有兩個弊端:

  • name 字段,job 字段,work 方法重複了。
  • 管理者對其管理的管理者和職員需要區別對待。

關於第一個弊端,雖然這裏爲了講解,只有兩個字段和一個方法重複,實際工作中這樣的整體部分結構會有相當多的重複。比如此例中還可能有工號、年齡等字段,領取工資、上下班打卡、開各種無聊的會等方法。

大量的重複顯然是很醜陋的代碼,分析一下可以發現, Manager 類只比 Employee 類多一個管理人員的列表字段,多幾個增加 / 移除人員的方法,其他的字段和方法全都是一樣的。

有讀者應該會想到:我們可以將重複的字段和方法提取到一個工具類中,讓 Employee 和 Manager 都去調用此工具類,就可以消除重複了。

這樣固然可行,但屬於 Employee 和 Manager 類自己的東西卻要通過其他類調用,並不利於程序的高內聚。

關於第二個弊端,此方案無法解決,此方案中 Employee 和 Manager 類完全是兩個不同的對象,兩者的相似性被忽略了。

所以我們有更好的設計方案,那就是組合模式!

3.2.使用組合模式的設計方案

組合模式最主要的功能就是讓用戶可以一致對待整體和部分結構,將兩者都作爲一個相同的組件,所以我們先新建一個抽象的組件類:

public abstract class Component {
    // 職位
    private String position;
    // 工作內容
    private String job;

    public Component(String position, String job) {
        this.position = position;
        this.job = job;
    }

    // 做自己的本職工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }

    abstract void addComponent(Component component);

    abstract void removeComponent(Component component);

    abstract void check();
}

管理者繼承自此抽象類:

public class Manager extends Component {
    // 管理的組件
    private List<Component> components = new ArrayList<>();

    public Manager(String position, String job) {
        super(position, job);
    }

    @Override
    public void addComponent(Component component) {
        components.add(component);
    }

    @Override
    void removeComponent(Component component) {
        components.remove(component);
    }

    // 檢查下屬
    @Override
    public void check() {
        work();
        for (Component component : components) {
            component.check();
        }
    }
}

職員同樣繼承自此抽象類:

public class Employee extends Component {

    public Employee(String position, String job) {
        super(position, job);
    }

    @Override
    void addComponent(Component component) {
        System.out.println("職員沒有管理權限");
    }

    @Override
    void removeComponent(Component component) {
        System.out.println("職員沒有管理權限");
    }

    @Override
    void check() {
        work();
    }
}

修改客戶端如下:

public class Client {

    @Test
    public void test(){
        Component boss = new Manager("老闆", "唱怒放的生命");
        Component HR = new Employee("人力資源", "聊微信");
        Component PM = new Manager("產品經理", "不知道幹啥");
        Component CFO = new Manager("財務主管", "看劇");
        Component CTO = new Manager("技術主管", "划水");
        Component UI = new Employee("設計師", "畫畫");
        Component operator = new Employee("運營人員", "兼職客服");
        Component webProgrammer = new Employee("程序員", "學習設計模式");
        Component backgroundProgrammer = new Employee("後臺程序員", "CRUD");
        Component accountant = new Employee("會計", "背九九乘法表");
        Component clerk = new Employee("文員", "給老闆遞麥克風");
        boss.addComponent(HR);
        boss.addComponent(PM);
        boss.addComponent(CFO);
        PM.addComponent(UI);
        PM.addComponent(CTO);
        PM.addComponent(operator);
        CTO.addComponent(webProgrammer);
        CTO.addComponent(backgroundProgrammer);
        CFO.addComponent(accountant);
        CFO.addComponent(clerk);

        boss.check();
    }
}

運行測試方法,輸出結果與之前的結果一模一樣。

可以看到,使用組合模式後,我們解決了之前的兩個弊端。一是將共有的字段與方法移到了父類中,消除了重複,並且在客戶端中,可以一致對待 Manager 和 Employee 類:

  • Manager 類和 Employee 類統一聲明爲 Component 對象
  • 統一調用 Component 對象的 addComponent 方法添加子對象即可。

3.3.組合模式中的安全方式與透明方式

讀者可能已經注意到了,Employee 類雖然繼承了父類的 addComponent 和 removeComponent 方法,但是僅僅提供了一個空實現,因爲 Employee 類是不支持添加和移除組件的。這樣是否違背了接口隔離原則呢?

接口隔離原則:客戶端不應依賴它不需要的接口。如果一個接口在實現時,部分方法由於冗餘被客戶端空實現,則應該將接口拆分,讓實現類只需依賴自己需要的接口方法。

答案是肯定的,這樣確實違背了接口隔離原則。這種方式在組合模式中被稱作透明方式.

透明方式:在 Component 中聲明所有管理子對象的方法,包括 add 、remove 等,這樣繼承自 Component 的子類都具備了 add、remove 方法。對於外界來說葉節點和枝節點是透明的,它們具備完全一致的接口。

這種方式有它的優點:讓 Manager 類和 Employee 類具備完全一致的行爲接口,調用者可以一致對待它們。

但它的缺點也顯而易見:Employee 類並不支持管理子對象,不僅違背了接口隔離原則,而且客戶端可以用 Employee 類調用 addComponent 和 removeComponent 方法,導致程序出錯,所以這種方式是不安全的。

那麼我們可不可以將 addComponent 和 removeComponent 方法移到 Manager 子類中去單獨實現,讓 Employee 不再實現這兩個方法呢?我們來嘗試一下。

將抽象類修改爲:

public abstract class Component {
    // 職位
    private String position;
    // 工作內容
    private String job;

    public Component(String position, String job) {
        this.position = position;
        this.job = job;
    }

    // 做自己的本職工作
    public void work() {
        System.out.println("我是" + position + ",我正在" + job);
    }

    abstract void check();
}

可以看到,我們在父類中去掉了 addComponent 和 removeComponent 這兩個抽象方法。

Manager 類修改爲:

public class Manager extends Component {
    // 管理的組件
    private List<Component> components = new ArrayList<>();

    public Manager(String position, String job) {
        super(position, job);
    }

    public void addComponent(Component component) {
        components.add(component);
    }

    void removeComponent(Component component) {
        components.remove(component);
    }

    // 檢查下屬
    @Override
    public void check() {
        work();
        for (Component component : components) {
            component.check();
        }
    }
}

Manager 類單獨實現了 addComponent 和 removeComponent 這兩個方法,去掉了 @Override 註解。

Employee 類修改爲:

public class Employee extends Component {

    public Employee(String position, String job) {
        super(position, job);
    }

    @Override
    void check() {
        work();
    }
}

客戶端建立人員結構關係:

public class Client {

    @Test
    public void test(){
        Manager boss = new Manager("老闆", "唱怒放的生命");
        Employee HR = new Employee("人力資源", "聊微信");
        Manager PM = new Manager("產品經理", "不知道幹啥");
        Manager CFO = new Manager("財務主管", "看劇");
        Manager CTO = new Manager("技術主管", "划水");
        Employee UI = new Employee("設計師", "畫畫");
        Employee operator = new Employee("運營人員", "兼職客服");
        Employee webProgrammer = new Employee("程序員", "學習設計模式");
        Employee backgroundProgrammer = new Employee("後臺程序員", "CRUD");
        Employee accountant = new Employee("會計", "背九九乘法表");
        Employee clerk = new Employee("文員", "給老闆遞麥克風");
        boss.addComponent(HR);
        boss.addComponent(PM);
        boss.addComponent(CFO);
        PM.addComponent(UI);
        PM.addComponent(CTO);
        PM.addComponent(operator);
        CTO.addComponent(webProgrammer);
        CTO.addComponent(backgroundProgrammer);
        CFO.addComponent(accountant);
        CFO.addComponent(clerk);

        boss.check();
    }
}

運行程序,輸出結果與之前一模一樣。

這種方式在組合模式中稱之爲安全方式。

安全方式:在 Component 中不聲明 add 和 remove 等管理子對象的方法,這樣葉節點就無需實現它,只需在枝節點中實現管理子對象的方法即可。

安全方式遵循了接口隔離原則,但由於不夠透明,Manager 和 Employee 類不具有相同的接口,在客戶端中,我們無法將 Manager 和 Employee 統一聲明爲 Component 類了,必須要區別對待,帶來了使用上的不方便。

安全方式和透明方式各有好處,在使用組合模式時,需要根據實際情況決定。但大多數使用組合模式的場景都是採用的透明方式,雖然它有點不安全,但是客戶端無需做任何判斷來區分是葉子結點還是枝節點,用起來是真香。

總結

到這裏我們就把結構型模式的前三種介紹完了,讓我們總結一下:

  • 適配器模式:用於有相關性但不兼容的接口
  • 橋接模式:用於同等級的接口互相組合
  • 組合模式:用於整體與部分的結構

剩餘四種結構型模式我們將在下篇中學習。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章