Composite

December 29, 2021

假設你今天要開發一個動畫編輯程式,動畫由圖片組成,數張圖片組合為動畫清單,動畫清單也可以由其他已完成的動畫清單組成,你可能這麼設計:

import java.util.*;

class Image {
    ...
}

class Playlist {
    private List<Image> images = new ArrayList<>();
    void add(Image Image) {
    	images.add(Image);
    }
    
    void add(Playlist playlist) {
    	for(Image Image : playlist.images) {
    		add(Image);
    	}
    }
}

確實地,這樣是解決了需求,不過,如果有個動畫清單是由數個動畫清單組成,現在想從中取出或者移除某個動畫清單,這種方式就行不通了,因為被加入的動畫清單,其中的圖片會被取出,最後你就不知哪個圖片來自哪個動畫清單了。

另一方面,如果今天想在清單中加入影片,Playlist 增加個 add(Video video) 嗎?明天又想加入什麼其他素材呢?再加個 add(XXX xxx)

零件/整體

仔細想想,無論是圖片、動畫清單、影片等,都是素材,那就這麼設計吧!

import java.util.*;

interface Material {
}

class Image implements Material {
    ...
}

class Playlist implements Material {
    private List<Material> materials = new ArrayList<>();
    public void add(Material material) {
    	materials.add(material);
    }
}

這麼一來,不管 Image 想加入 Playlist

var main = new Playlist("主清單");
main.add(new Image("Duke 左揮手"));
main.add(new Image("Duke 右揮手"));

或是 Playlist 想加入 Playlist

var walking = new Playlist("走路清單");
walking.add(new Image("Duke 走左腳"));
walking.add(new Image("Duke 走右腳"));
main.add(walking);

或者又多個 Image

main.add(new Image("片尾"));

甚至增加個 Video 都不成問題:

class Video implements Material {
    ...
}

main.add(new Video("幕後花絮"));

如果想要移除或取得素材呢?就以上的範例來說,因為建立 PlaylistImageVideo 時有指定名稱,或許可以定義 remove(String name)get(String name) 方法,其中走訪 materials,根據名稱來刪除或取得,當然,若要更有效率一些,或許 materials 可以改用 Map

在這樣將零件/整體一視同仁看待的概念,Gof 稱為 Composite 模式,當物件組合時具有遞迴性,例如這邊的播放軟體範例,或者是測試框架(測試案例與套件)、視窗程式(容器與元件)、需要用樹狀表示的資料結構等,就可以用 Composite 模式作為一個思考的方向。

一視同仁?

不過,你可能會說,哪裡一視同仁了?Playlistadd 方法,然而其他類別沒有啊?嗯…這是看你從哪個角度來看,如果從客戶端操作 Playlist,就可以新增 PlaylistImageVideo 來看,是一視同仁沒錯。

不過,通常會採用 Composite 模式來組合的物件,還會定義一些共同行為,以方才的範例來說,或許這個行為是播放:

import java.util.*;

interface Material {
    void play(); // 定義共同的播放行為
}

class Image implements Material {
    ...
    public void play() {
        ...實作播放
    }
}

class Playlist implements Material {
    private List<Material> materials = new ArrayList<>();
    public void add(Material material) {
    	materials.add(material);
    }

    public void play() {
        for(Material material : materials) {
            material.play();
        }
    }
}

這麼一來,就方才的 all 清單來說,要全部播放的話只要 all.play() 就可以了,就客戶端角度來看,一視同仁的行為是指播放,因為不用管 all 裡頭到底管理的是哪個物件。

一開始我特意不加入 play 的定義,是因為 Gof 在談 Composite 時,是歸類在結構,而結構主要是從物件與物件間組裝的角度來觀察、討論。

就以上的播放軟體來說,Playlist 才具有 add 這類組裝物件的方法,這意謂著它才是具有管理物件的職責角色,視你構築的軟體特性而定,有時你會想要加以區別,例如,若你設計一個視窗程式框架,Window 會是個容器,其中可以裝 ButtonTextField 等,然而 Button 你不想要它是個容器,單純就當個被按的角色,Button 就是沒有管理用的方法,就不會被誤用來當成容器。

有些樹狀結構,每個節點可能就會包含管理物件的行為,任何節點都可以是子樹的根節點,而任何節點也能成為葉節點,例如,也許你想設計另一種視窗程式框架,功能更豐富,就算是 Button,也可以當個容器,例如,裡頭還可以承載 Image,變成一個圖片按鈕之類,那麼你可能會想讓 Button 也實作管理物件的職責:

interface Component {
    void add(Component component);
    void remove(Component component);
    void paint(g: Graphics);
}

class Container implements Component {
    ...實作 add 等方法
}

class Button extends Container {
    ...實作 Button 需要的行為
}

這時從客戶端的角度來看,一視同仁的行為就包含了管理物件的 addremove,以及繪製元件時的 paint 方法了。

其實也不用太在意從誰的角度看有沒有一視同仁,重點是要搞清楚任務是什麼?為什麼一開始的實作方式行不通?是因為滿足不了什麼需求?重構的方向是什麼?後來採用的方式是不是能達成任務?

確實地,有時我們會透過書或文件來認識模式,以便擴展、吸收一些前人的經驗;然而,透過這種方式認識模式時,如果作者沒談到方才的這幾個問題,也務必要自行思考、自問這些問題!