Composite Pattern 合成模式 [Design Pattern in Java]

Posted by Kubeguts on 2020-08-06

將有階層關係的類別狀態給扁平化,給使用者看到的是一致性的關係

若沒有合成模式會怎樣?

假如現在我們來到一間自助吧,有一個菜單類別,但這個菜單類別裡面還會有甜點這個子菜單

大概長下面這樣

於是我們大概會這樣實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

class BuffetMenu {
String name;
ArrayList<String> items;
DessertMenu dessertMenu;

BuffetMenu() {
this.name = "自助餐菜單";
this.items = new ArrayList<>();
this.dessertMenu = new DessertMenu();
// 初始化項目
this.init();
}

private void init() {
this.items.add("牛肉麵");
this.items.add("海鮮飯");
this.items.add("牛排");
}

public void setMenuItem(String item) {
this.items.add(item);
}

public void showMenuItem() {
for(String item: items) {
System.out.println(this.name + ":" + item);
}
}

public void print() {
System.out.println("開始介紹" + this.name);
showMenuItem();
}
}



class DessertMenu {
String name;
ArrayList<String> items;
ChocolateMenu chocolateMenu;

DessertMenu() {
this.name = "甜點菜單";
this.items = new ArrayList<>();
this.init();
}

private void init() {
this.items.add("雞蛋餅乾");
this.items.add("巧克力");
this.items.add("棉花糖");
}

public void setMenuItem(String item) {
this.items.add(item);
}

public void showMenuItem() {
for(String item: items) {
System.out.println(this.name + ":" + item);
}
}

public void print() {
System.out.println("開始介紹" + this.name);
showMenuItem();
}
}

可以看到DessertMenu類別包含在BuffetMenu類別了

我們現在有位服務生提供介紹菜單內容,定義如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Waitress {
BuffetMenu buffetMenu;

Waitress(BuffetMenu buffetMenu) {
this.buffetMenu = buffetMenu;
}

public void introduceMenu() {
// 介紹菜單
this.buffetMenu.print();
this.buffetMenu.showMenuItem();
this.buffetMenu.dessertMenu.print();
this.buffetMenu.dessertMenu.showMenuItem();
}
}

然後我們請服務生開始介紹菜單

1
2
3
4
5
6
7
8
9

public class main {
public static void main(String[] args) {
BuffetMenu buffetMenu = new BuffetMenu();
Waitress waitress = new Waitress(buffetMenu);

waitress.introduceMenu();
}
}
1
2
3
4
5
6
7
8
開始介紹自助餐菜單
自助餐菜單:牛肉麵
自助餐菜單:海鮮飯
自助餐菜單:牛排
開始介紹甜點菜單
甜點菜單:雞蛋餅乾
甜點菜單:棉花糖
甜點菜單:巧克力

但假如現在變成這樣呢:甜點內的巧克力又有不同類型的巧克力,那我們又要再修改上面的程式碼,將Chocolate類別加入到Dessert類別內

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

class DessertMenu {
String name;
ArrayList<String> items;
ChocolateMenu chocolateMenu; // 多了巧克力菜單

DessertMenu() {
this.chocolateMenu = new ChocolateMenu();
this.name = "甜點菜單";
this.items = new ArrayList<>();
this.init();
}

private void init() {
this.items.add("雞蛋餅乾");
this.items.add("棉花糖");
}

public void setMenuItem(String item) {
this.items.add(item);
}

public void showMenuItem() {
for(String item: items) {
System.out.println(this.name + ":" + item);
}
}

public void print() {
System.out.println("開始介紹" + this.name);
}
}

// 多了巧克力菜單
class ChocolateMenu {
String name;
ArrayList<String> items;

ChocolateMenu() {
this.name = "巧克力菜單";
items = new ArrayList<>();
this.init();
}

private void init() {
this.items.add("白巧克力");
this.items.add("黑巧克力");
this.items.add("酒釀巧克力");
}

public void setMenuItem(String item) {
this.items.add(item);
}

public void showMenuItem() {
for(String item: items) {
System.out.println(this.name + ":" + item);
}
}

public void print() {
System.out.println("開始介紹" + this.name);
}
}

這時要修改服務生的腦袋,使其知道巧克力菜單的內容。。。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

class Waitress {
BuffetMenu buffetMenu;

Waitress(BuffetMenu buffetMenu) {
this.buffetMenu = buffetMenu;
}

public void introduceMenu() {
// 介紹菜單
this.buffetMenu.print();
this.buffetMenu.dessertMenu.print();
this.buffetMenu.dessertMenu.chocolateMenu.print();
}
}

但這時候就會發現,每次若新增一個子菜單時,呼叫者就需要直接對其操作,才能夠印出該子菜單的項目列表

一但子菜單內又有子菜單,那整個程式碼的維護的代價就會非常的高

1
2
3
可以看到子菜單內若又有子菜單,要印出該名稱的話,那這行程式碼會越來越長。。。

this.buffetMenu.dessertMenu.chocolateMenu.print();

維護修改的代價

舉個例子,我們想要叫服務生說明,哪些菜/甜點是素食的,這時會改code改到想哭

解決服務生不用笨拙地存取菜單與其子菜單們:合成模式登場

合成模式主要幫我們建立一個類別關係,建立起父類別與子類別的階層關係,並讓客戶端以一致的方式存取樹狀結構內的各個類別

先講結論,以上的菜單們,套用合成模式後,服務生不用透過以下方式存取子菜單並介紹出來了,而是直接可以用一致的方式將所有菜單與子菜單都呼叫出來

1
2
3
4
5
6
7
8
9
10
11
public void introduceMenu() {
// 介紹菜單 <-- 可以不用一一在呼叫菜單與子菜單和子菜單的子菜單。。。
public void introduceMenu() {
// 介紹菜單
this.buffetMenu.print();
this.buffetMenu.showMenuItem();
this.buffetMenu.dessertMenu.print();
this.buffetMenu.dessertMenu.showMenuItem();
this.buffetMenu.dessertMenu.chocolateMenu.print();
this.buffetMenu.dessertMenu.chocolateMenu.showMenuItem();
}

這時我們先來看 合成模式 的類別關係圖

  • Component類別:定義了其所有底下葉節點與合成類別的結構
    • 所有的Leaf節點與Composite節點都會繼承(實作)該Component類別
    • Component是老大哥,老大哥會的下面的也會學到
  • Leaf類別:會實作Component類別,但由於Leaf節點沒有子類別,所以只會有operation()的方法供呼叫
  • Composite類別:合成類別,將以下兩點做合成
    • 所要執行的功能operation()
    • 管理子類別的方法 add() remove() getChild()

對應Composite類別關係圖到我們上述所畫的菜單關係圖,就會長得如下:

把上面所提到的這張圖作轉換

就會變成這樣子 (省略Chocolate類別的菜單項目)

定義MenuComponent抽象類別

定義MenuComponent, 提供菜單基本的方法

這邊主要不是定義成Interface, 而是抽象類別的主要原因是因為,我們會給 子菜單去繼承菜單所具有的方法

但又因會給菜單項目(MenuItem)繼承(如 牛肉麵, 海鮮飯, 牛排),
但菜單項目不需要像是 add(), remove()等方法,所以會在該方法內先拋出 UnsupportedOperationException()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
abstract class MenuComponent {
void add(MenuComponent menuComponent) {
throw new UnsupportedOperationException();
}

void remove(MenuComponent menuComponent) {
throw new UnsupportedOperationException();
}

MenuComponent getChild(int i) {
throw new UnsupportedOperationException();
}

String getName() {
throw new UnsupportedOperationException();
}

String getDescription() {
throw new UnsupportedOperationException();
}

double getPrice() {
throw new UnsupportedOperationException();
}

boolean isVegetarian() {
throw new UnsupportedOperationException();
}

void print() {
throw new UnsupportedOperationException();
}
}

可以看到我們定義了以下方法,並等待真正的菜單類別來實作其細節

  • add(): 可新增 實作MenuComponent的物件
  • remove(): 移除 實作MenuComponent的物件
  • getChild(): 取得Menu內的菜單項目/子菜單
  • getName(): 取得菜單名稱
  • getPrice: 取得菜單項目價格
  • isVegetarian(): 確認是否為素食
  • print(): 印出自己的所有菜單項目/子菜單內容
1
2
3
4
5
還記得最上面的若要請服務生確認菜色是否是素食,但要到處修改的慘痛的經驗嗎?

這時候若在共通的介面,定義好一個isVegetarian(), 請菜色自行提供是否是素食,是的話回傳true, 不是就回傳false, 如此一來服務生就可以在呼叫print()時,可以直接判斷這道項目是否是素食囉

關於print()的實現細節後面會介紹

開始實作菜單元件:定義自助式菜單

接著開始實作自助餐的完整菜單吧!

BuffetMenu.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

class BuffetMenu extends MenuComponent {
// 宣告要放置菜單項目與子菜單的ArrayList
ArrayList<MenuComponent> menuItems = new ArrayList<>();
// 菜單名稱
String name;

BuffetMenu() {
this.name = "自助式菜單";
}

@Override
void add(MenuComponent menuComponent) {
this.menuItems.add(menuComponent);
}

@Override
void remove(MenuComponent menuComponent) {
this.menuItems.remove(menuComponent);
}

@Override
MenuComponent getChild(int i) {
return this.menuItems.get(i);
}

@Override
String getName() {
return this.name;
}

@Override
String getDescription() {
return "自助式菜單提供了主菜們(牛肉麵, 海鮮飯, 牛排), 以及甜點菜單, 請盡情享用!";
}

// 不用實作getPrice 因為菜單沒有價格

// 不用實作isVegetarian 因為菜單沒有價格

// 如果是菜單的話, print使用iterator, 將ArrayList型態的menus中的menuComponent一一取出來, 並呼叫
@Override
void print() {
System.out.println(this.getName());
System.out.println(this.getDescription());

Iterator iterator = menuItems.iterator();

while(iterator.hasNext()) {
// 取出來的可能是菜單項目, 也可能是子菜單
MenuComponent menuComponent = (MenuComponent)iterator.next();
menuComponent.print();
}
}
}

可以看到在BuffetMenu中,已經除了 getPrice()isVegetarian()這兩項菜單不用去實作,其他都有定義邏輯

這邊要注意的是 print() 我們使用 iterator, 將菜單內的MenuComponent一一取出來,並且在呼叫其print(), 依序對自助式菜單內的菜單項目做介紹這樣

定義菜單項目

接著我們定義一個牛肉麵類別 BeefNoodles, 實作 MenuComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

class BeefNoodles extends MenuComponent {
// 菜單名稱
String name;

@Override
String getName() {
return this.name;
}

@Override
String getDescription() {
return "好吃的牛肉麵, 道地的四川口味~~";
}

@Override
double getPrice() {
return 120;
}

@Override
boolean isVegetarian() {
return false; // 牛肉麵不是素食
}

// 不是菜單, 所以只要呼叫名稱與描述就好
@Override
void print() {
System.out.println(this.getName());
System.out.println(this.getDescription());
System.out.println(this.getPrice());
}
}

由於牛肉麵是菜單的項目,所以只要實作跟他自己有關的方法就好,在print()也只要就印出自己名稱是什麼,以及描述, 價格。

實作子菜單: 甜點菜單

跟剛剛實作自助餐的細節非常類似

DessertMenu.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class DessertMenu extends MenuComponent {
// 宣告要放置菜單項目與子菜單的ArrayList
ArrayList<MenuComponent> menuItems = new ArrayList<>();
// 菜單名稱
String name;

DessertMenu() {
this.name = "甜點菜單";
}

@Override
void add(MenuComponent menuComponent) {
this.menuItems.add(menuComponent);
}

@Override
void remove(MenuComponent menuComponent) {
this.menuItems.remove(menuComponent);
}

@Override
MenuComponent getChild(int i) {
return this.menuItems.get(i);
}

@Override
String getName() {
return this.name;
}

@Override
String getDescription() {
return "甜點菜單提供了一星米其林主廚所製作的甜點們!";
}

// 不用實作getPrice 因為菜單沒有價格

// 不用實作isVegetarian 因為菜單沒有價格

// 如果是菜單的話, print使用iterator, 將ArrayList型態的menus中的menuComponent一一取出來, 並呼叫
@Override
void print() {
System.out.println(this.getName());
System.out.println(this.getDescription());

Iterator iterator = menuItems.iterator();

while(iterator.hasNext()) {
// 取出來的可能是菜單項目, 也可能是子菜單
MenuComponent menuComponent = (MenuComponent)iterator.next();
menuComponent.print();
}
}
}

最後也是一樣,將甜點項目都實作一輪,不過這邊都很類似就不敘述了

重新定義服務生

可以看到服務生很快樂, 只要呼叫MenuComponent的print(), 就把所有菜單都介紹出來囉

1
2
3
4
5
6
7
8
9
10
11
12
class Waitress {
MenuComponent menuComponent;

Waitress(MenuComponent menuComponent) {
this.menuComponent = menuComponent;
}

public void introduceMenu() {
// 這時只要呼叫print(), 就可以把全部菜單的項目與名稱都介紹出來了!
this.menuComponent.print();
}
}

客戶開始進來,請服務生介紹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class main {
public static void main(String[] args) {
MenuComponent buffetMenu = new BuffetMenu();
MenuComponent beefNoodles = new BeefNoodles();
MenuComponent seaRice = new SeaRice();
MenuComponent steak = new Steak();
buffetMenu.add(beefNoodles);
buffetMenu.add(seaRice);
buffetMenu.add(steak);
// 定義子菜單
MenuComponent dessertMenu = new DessertMenu();
MenuComponent eggCookie = new EggCookie();
MenuComponent cottonCandy = new CottonCandy();
dessertMenu.add(eggCookie);
dessertMenu.add(cottonCandy);
// 將子菜單加入自助式菜單
buffetMenu.add(dessertMenu);

Waitress waitress = new Waitress(buffetMenu);
// 請服務生開始介紹!
waitress.introduceMenu();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
自助式菜單
自助式菜單提供了主菜們(牛肉麵, 海鮮飯, 牛排), 以及甜點菜單, 請盡情享用!
牛肉麵
好吃的牛肉麵, 道地的四川口味~~
120.0
海鮮飯
好吃的海鮮飯~~
200.0
牛排
好吃的牛排~~
400.0
甜點菜單
甜點菜單提供了一星米其林主廚所製作的甜點們!
雞蛋餅乾
雞蛋口味的餅單, 大人小孩都愛~~
60.0
棉花糖
棉花糖好滋味~~
30.0

小結

透過合成模式, 將取得菜單與子菜單的動作給一致化了

服務生只要呼叫實作MenuComponent的菜單與子菜單們, 就可以一口氣將所有菜單與子菜單的項目們全都介紹出來!

未來若要擴充子菜單的項目,也不用擔心需要再修改服務生的類別,只要新的菜單有實作MenuComponent就好

達到 "對修改關閉, 對擴充開放"的原則!

將每個菜單的Iterator給合併起來: IteratorComposite

還記得剛剛有提到如何判斷該菜單是否是素食的情況呢??

(待續)