將物件與容器一視同仁操作

Composite Pattern 是 OOP 中著名的 Design Pattern,無論是 物件容器,都能使用相同 interface 一視同仁的操作。

Version


macOS High Sierra 10.13.3
.NET Core SDK 2.1.101
Rider 2017.3.1
C# 7.2

User Story


由購物車計算商品價錢,但在活動期間,Apple 產品組合有特惠 :

  • MacBook Pro 15”:6 萬
  • iPad Air:1 萬
  • Apple Watch:1 萬
  • Apple 套餐組合 : (Macbook Pro + iPad Air + Apple Watch) 總價打九折:(6 + 1 + 1) * 0.9 = 7.2 萬

composite011

若同時買 MacBook Pro + iPad Air + Apple Watch + Apple 套餐為 15.2 萬。

Task


在不改變產品 interface 的原則下,計算購物車價錢。

Definition


Composite Pattern

單一資料整體資料 同時存在時,讓 client 有一致性的操作方式

composite008

  • ClientLeafComposite 的 user,實務上可能是 component 或 controller

  • Component:定義 LeafComposite 的共用 interface

  • Leaf:表示 單一資料

  • Composite:表示 整體資料

composite009

由於 Composite 表示 整體資料,實務上可能還包含其他 Composite,而產生類似 tree 的結構。

Composite Pattern 的確可以處理 tree 結構,但 tree 並非必要條件

適用時機

  • 同時存在 單一資料整體資料
  • 想讓 client 不需分辨 單一資料整體資料,都以相同的方式操作資料
  • 不想使用 繼承 來描述 樹狀結構

優點

  • ​ Client 程式碼精簡,不需寫一堆 if else 判斷是 單一資料整體資料,完全發揮 OOP 的 多型
  • 增加新的 LeafComposite,也不用修改 client 程式碼,符合 開放封閉原則

Architecture


composite000

  • ShoppingService:相當於 Client

  • IPrice:定義 MabookProIPadAirAppleWatchAppleCombo 的共用 interface,相當於 Component interface

  • MacbookPro:單一產品,相當於 Leaf

  • IPadAir:單一產品,相當於 Leaf

  • AppleWatch:單一產品,相當於 Leaf

  • AppleCombo:套餐組合,相當於 Composite

Implementation


composite001

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using ShoppingCartLibrary;

namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{

var shoppingCartService = new ShoppingCartService();
shoppingCartService.AddCart(new MacBookPro());
shoppingCartService.AddCart(new IPadAir());
shoppingCartService.AddCart(new AppleWatch());
shoppingCartService.AddCart(new AppleCombo());

var result = shoppingCartService.CalculatePrice();
Console.WriteLine("{0}", result);
}
}
}

無論是 MacBook Pro、IPad Air、Apple Watch 或 AppleCombo 套餐組合,都統一用 AddCart() 新增。

且無論是 單一產品套餐組合,都使用 CalculatePrice() 統一計算。

可以猜想 AddCart()CalculatePrice() 應該遵循特定 interface 使用,且 單一產品套餐組合 也應該遵循特定 interface 實踐,client 才有可能這麼漂亮的實作

Composite Pattern

IPrice

composite002

IPrice.cs

1
2
3
4
5
6
7
namespace ShoppingCartLibrary
{
public interface IPrice
{
double GetPrice();
}
}

訂定 單一產品套餐組合 所遵守的共同 interface,這也是 Composite Pattern 的關鍵。

MacBookPro

composite003

MacBookPro.cs

1
2
3
4
5
6
7
8
9
10
namespace ShoppingCartLibrary
{
public class MacBookPro : IPrice
{
public double GetPrice()
{

return 60000.0;
}
}
}

單一產品 MacBookPro 實踐 IPrice

IPadAir

composite004

IPadAir.cs

1
2
3
4
5
6
7
8
9
10
namespace ShoppingCartLibrary
{
public class IPadAir : IPrice
{
public double GetPrice()
{

return 10000.0;
}
}
}

單一產品 IPadAir 實踐 IPrice

在 C# 世界習慣 interface 以 I 為 prefix,理論上 class name 不建議以 I 為開頭,這裡是因為 Apple 產品就叫做 I PadAir

AppleWatch

composite005

AppleWatch.cs

1
2
3
4
5
6
7
8
9
10
namespace ShoppingCartLibrary
{
public class AppleWatch : IPrice
{
public double GetPrice()
{

return 10000.0;
}
}
}

單一產品 AppleWatch 實踐 IPrice

AppleCombo

composite006

AppleCombo.cs

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
using System.Collections.Generic;

namespace ShoppingCartLibrary
{
public class AppleCombo : IPrice
{
private readonly List<IPrice> _products;

public AppleCombo()
{

_products = new List<IPrice>
{
new MacBookPro(),
new IPadAir(),
new AppleWatch()
};
}

public double GetPrice()
{

var sum = 0.0;

foreach (var product in _products)
{
sum += product.GetPrice();
}

return 0.9 * sum;
}
}
}

第 5 行

1
public class AppleCombo : IPrice

Apple 套餐組合 也要實踐 IPrice,重點在於不再是 單一產品 要實踐 interface,而是連 套餐組合 也要實踐 interface。

也就是在將來,我們會將 單一產品套餐組合 都一視同仁使用,也就是 OOP 的 多型 的實踐。

第 7 行

1
private readonly List<IPrice> _products;

將套餐中所有產品 封裝_products field,注意 List 的泛型為 IPrice interface,也就是只有實踐 IPrice 的單一產品才能加入,若該產品沒有實踐 IPrice,compiler 在編譯階段就可以擋掉。

這也是 OOP 處理 side effect 方式,使用 private field 封裝在 class 內,避免資料被外界修改

第 9 行

1
2
3
4
5
6
7
8
9
public AppleCombo()
{

_products = new List<IPrice>
{
new MacBookPro(),
new IPadAir(),
new AppleWatch()
};
}

因為 Apple 套餐組合同時包含 MacBook Pro、iPad Air 與 Apple Watch,因此在 constructor 同時將產品建立好。

19 行

1
2
3
4
5
6
7
8
9
10
11
public double GetPrice()
{

var sum = 0.0;

foreach (var product in _products)
{
sum += product.GetPrice();
}

return 0.9 * sum;
}

將所有產品加總,並打九折。

也因為所有產品都遵守 IPrice() interface 的 GetPrice(),因此可以使用 foreach() 一視同仁以 GetPrice() 計算,這就是 interface 與 OOP 多型 的優點。

ShoppingCartService

composite007

ShoppingCartService.cs

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
using System.Collections.Generic;

namespace ShoppingCartLibrary
{
public class ShoppingCartService
{
private readonly List<IPrice> _carts = new List<IPrice>();

public void AddCart(IPrice product)
{

_carts.Add(product);
}

public double CalculatePrice()
{

var sum = 0.0;

foreach (var product in _carts)
{
sum += product.GetPrice();
}

return sum;
}
}
}

第 7 行

1
private readonly List<IPrice> _carts = new List<IPrice>();

將購物車的購物資料封裝在 _carts field,注意 List 的泛型為 IPrice interface,也就是無論是 單一產品 或者 套餐組合,只要實踐 IPrice interface 就一視同仁,只要任何產品或套餐沒實踐 interface 而加入 List,compiler 在 compile-time 就會報錯。

第 9 行

1
2
3
4
public void AddCart(IPrice product)
{

_carts.Add(product);
}

AddCart() 新增至購物車,只要實踐 IPrice單一產品套餐組合 都可加入購物車。

14 行

1
2
3
4
5
6
7
8
9
10
11
public double CalculatePrice()
{

var sum = 0.0;

foreach (var product in _carts)
{
sum += product.GetPrice();
}

return sum;
}

CalculatePrice() 計算購物車內的產品總金額,也由於 單一產品套餐組合 都實踐 IPrice interface,因此可以安心用 foreach() 一視同仁的使用 GetPrice() 計算。

這就是 Composite Pattern 的關鍵,也因為都有實踐 IPrice interface,我們能安心使用 IPriceGetPrice(),且 IDE 的 intellisense 也能顯示

Refactoring

目前為止 Composite Pattern 已經完成,但我們發現有不少 code smell,需要進一步的重構。

MacBookPro

composite003

MacBookPro.cs

1
2
3
4
5
6
7
8
9
10
11
12
namespace ShoppingCartLibrary
{
public class MacBookPro : IPrice
{
private const double Price = 60000.0;

public double GetPrice()
{

return Price;
}
}
}

之前將 60000.0 hardcode 在 GetPrice(),這樣會造成將來維護上的困然,應該使用 Introduce Field 重構到 field。

IPadAir

composite004

IPadAir.cs

1
2
3
4
5
6
7
8
9
10
11
12
namespace ShoppingCartLibrary
{
public class IPadAir : IPrice
{
private const double Price = 10000.0;

public double GetPrice()
{

return Price;
}
}
}

之前將 10000.0 hardcode 在 GetPrice(),這樣會造成將來維護上的困然,應該使用 Introduce Field 重構到 field。

AppleWatch

composite005

AppleWatch.cs

1
2
3
4
5
6
7
8
9
10
11
12
namespace ShoppingCartLibrary
{
public class AppleWatch : IPrice
{
private const double Price = 10000.0;

public double GetPrice()
{

return Price;
}
}
}

之前將 10000.0 hardcode 在 GetPrice(),這樣會造成將來維護上的困然,應該使用 Introduce Field 重構到 field。

AppleCombo

composite006

AppleCombo.cs

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
using System.Collections.Generic;
using System.Linq;

namespace ShoppingCartLibrary
{
public class AppleCombo : IPrice
{
private const double Discount = 0.9;
private readonly List<IPrice> _products;

public AppleCombo()
{

_products = new List<IPrice>
{
new MacBookPro(),
new IPadAir(),
new AppleWatch()
};
}

public double GetPrice()
{

return Discount * _products.Sum(product => product.GetPrice());
}
}
}

21 行

1
2
3
4
public double GetPrice()
{

return Discount * _products.Sum(product => product.GetPrice());
}

0.9 使用 Introduce Field 重構到 field,並將 for loop 重構成 LINQ 的 Sum()

實務上若搭配 List,使用 foreach() 的機率幾乎為 0,因為 LINQ 對 IEnumerable 的支援已經很完整,可以使用 FP 的方式使用 List,更加精簡,可讀性也更高

ShoppingCartService

composite007

ShoppingCartService.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System.Collections.Generic;
using System.Linq;

namespace ShoppingCartLibrary
{
public class ShoppingCartService
{
private readonly List<IPrice> _carts = new List<IPrice>();

public void AddCart(IPrice product)
{

_carts.Add(product);
}

public double CalculatePrice()
{

return _carts.Sum(product => product.GetPrice());
}
}
}

15 行

1
2
3
4
public double CalculatePrice()
{

return _carts.Sum(product => product.GetPrice());
}

將 for loop 重構成 LINQ 的 Sum()

Summary


Q:Proxy Pattern、Decorator Pattern 與 Composite Pattern 有何差異?

Proxy Pattern
composite014

Decorator Pattern
composite014

Composite Pattern
composite008

以 class diagram 角度,Proxy Pattern、Decorator Pattern 與 Composite Pattern 的確非常類似。

學 Design Pattern 不能以 class diagram 的角度去思考,而要以他要解決什麼問題來思考

Similarity

  • 都在解決 繼承濫用,將原本 垂直結構 變成 水平結構
  • Interface 都不會改變

Difference

  • Proxy 目的在對 service 做 控制存取
  • Decorator 目的在對 service 增加功能
  • Composite 目的在於對單一 model/DTO 與套餐組合 model/DTO 一視同仁

Conclusion


  • 單一產品套餐組合,原本是完全不同的東西,但藉由實踐相同的 interface,在邏輯上視為相同的物件,因此可藉由 OOP 的 多型 加以操作,而不須在 client 做任何判斷,將來新增任何 單一產品套餐組合,也不用修改 client,符合 開放封閉原則 的要求
  • 計算 List 的總和可使用 LINQ 的 Sum(),可讀性更高也更精簡
  • 大部分書籍資料都將 Composite Pattern 用來實作 tree 資料結構,因此很多初學者以為此 pattern 就是為了用來實作 tree,但 tree 並非 Composite Pattern 的必要條件,事實上只要同時需要處理 單一資料整體資料,就適合使用 Composite Pattern,因此也稱為 Part-Whole Pattern,讓單一資料與整體資料有一致操作方式,這就是 Composite Pattern 的本質

Sample Code


完整的範例可以在我的 GitHub 上找到

2018-04-18