Go 语言设计模式:组合

2014-11-17
编程语言 系统设计

GoF 对组合模式的定义是,将对象组合成树形结构以表示“部分整体”的层次结构,组合模式使得用户对单个对象和组合对象的使用具有一致性

对于这句话我是有异议的,这里先卖个关子,我们先从实际例子说起。

组合模式的例子大家都见得很多了,比如文件系统(文件/文件夹)、GUI 窗口(Frame/Control)、菜单(菜单/菜单项)等等,我这里也举个菜单的例子,不过不是操作系统里的菜单,是真正的菜单,KFC 的……

姑且把 KFC 里的食物认为是菜单项,一份套餐是菜单。菜单和菜单项有一些公有属性:名字、描述、价格、都能被购买等,所以正如 GoF 所说,我们需要一致性地使用它们。它们的层次结构体现在一个菜单里会包含多个菜单项或菜单,其价格是所有子项的和。嗯,这个例子其实不是很恰当,不能很好的体现菜单包含菜单的情况,所以我多定义了一个“超值午餐”菜单,其中包含若干个套餐。

用代码归纳总结一下,最终我们的调用代码是这样的:

 1func main() {
 2	menu1 := NewMenu("培根鸡腿燕麦堡套餐", "供应时间:09:15--22:44")
 3	menu1.Add(NewMenuItem("主食", "培根鸡腿燕麦堡1个", 11.5))
 4	menu1.Add(NewMenuItem("小吃", "玉米沙拉1份", 5.0))
 5	menu1.Add(NewMenuItem("饮料", "九珍果汁饮料1杯", 6.5))
 6
 7	menu2 := NewMenu("奥尔良烤鸡腿饭套餐", "供应时间:09:15--22:44")
 8	menu2.Add(NewMenuItem("主食", "新奥尔良烤鸡腿饭1份", 15.0))
 9	menu2.Add(NewMenuItem("小吃", "新奥尔良烤翅2块", 11.0))
10	menu2.Add(NewMenuItem("饮料", "芙蓉荟蔬汤1份", 4.5))
11
12	all := NewMenu("超值午餐", "周一至周五有售")
13	all.Add(menu1)
14	all.Add(menu2)
15
16	all.Print()
17}

得到的输出如下:

超值午餐, 周一至周五有售, ¥53.50
------------------------
培根鸡腿燕麦堡套餐, 供应时间:09:15--22:44, ¥23.00
------------------------
  主食, ¥11.50
    -- 培根鸡腿燕麦堡1个
  小吃, ¥5.00
    -- 玉米沙拉1份
  饮料, ¥6.50
    -- 九珍果汁饮料1杯

奥尔良烤鸡腿饭套餐, 供应时间:09:15--22:44, ¥30.50
------------------------
  主食, ¥15.00
    -- 新奥尔良烤鸡腿饭1份
  小吃, ¥11.00
    -- 新奥尔良烤翅2块
  饮料, ¥4.50
    -- 芙蓉荟蔬汤1份

面向对象实现

先说明一下:Go 语言不是面向对象语言,实际上只有 struct 而没有类或对象。但是为了说明方便,后面我会使用这个术语来表示 struct 的定义,用对象这个术语来表示 struct 实例。

按照惯例,先使用经典的面向对象来分析。首先我们需要定义菜单和菜单项的抽象基类,这样使用者就可以只依赖于接口了,于是实现使用上的一致性。

Go 语言中没有继承,所以我们把抽象基类定义为接口,后面会由菜单和菜单项实现具体功能:

 1type MenuComponent interface {
 2	Name() string
 3	Description() string
 4	Price() float32
 5	Print()
 6
 7	Add(MenuComponent)
 8	Remove(int)
 9	Child(int) MenuComponent
10}

菜单项的实现:

 1type MenuItem struct {
 2	name        string
 3	description string
 4	price       float32
 5}
 6
 7func NewMenuItem(name, description string, price float32) MenuComponent {
 8	return &MenuItem{
 9		name:        name,
10		description: description,
11		price:       price,
12	}
13}
14
15func (m *MenuItem) Name() string {
16	return m.name
17}
18
19func (m *MenuItem) Description() string {
20	return m.description
21}
22
23func (m *MenuItem) Price() float32 {
24	return m.price
25}
26
27func (m *MenuItem) Print() {
28	fmt.Printf("  %s, ¥%.2f\n", m.name, m.price)
29	fmt.Printf("    -- %s\n", m.description)
30}
31
32func (m *MenuItem) Add(MenuComponent) {
33	panic("not implement")
34}
35
36func (m *MenuItem) Remove(int) {
37	panic("not implement")
38}
39
40func (m *MenuItem) Child(int) MenuComponent {
41	panic("not implement")
42}

有两点请留意一下。

  1. NewMenuItem() 创建的是 MenuItem,但返回的是抽象的接口 MenuComponent。(面向对象中的多态)
  2. 因为 MenuItem 是叶节点,无法提供 Add() Remove() Child()这三个方法的实现,所以若被调用会 panic。

下面是菜单的实现:

 1type Menu struct {
 2	name        string
 3	description string
 4	children    []MenuComponent
 5}
 6
 7func NewMenu(name, description string) MenuComponent {
 8	return &Menu{
 9		name:        name,
10		description: description,
11	}
12}
13
14func (m *Menu) Name() string {
15	return m.name
16}
17
18func (m *Menu) Description() string {
19	return m.description
20}
21
22func (m *Menu) Price() (price float32) {
23	for _, v := range m.children {
24		price += v.Price()
25	}
26	return
27}
28
29func (m *Menu) Print() {
30	fmt.Printf("%s, %s, ¥%.2f\n", m.name, m.description, m.Price())
31	fmt.Println("------------------------")
32	for _, v := range m.children {
33		v.Print()
34	}
35	fmt.Println()
36}
37
38func (m *Menu) Add(c MenuComponent) {
39	m.children = append(m.children, c)
40}
41
42func (m *Menu) Remove(idx int) {
43	m.children = append(m.children[:idx], m.children[idx+1:]...)
44}
45
46func (m *Menu) Child(idx int) MenuComponent {
47	return m.children[idx]
48}

其中 Price() 统计所有子项的 Price 后加和,Print() 输出自身的信息后依次输出所有子项的信息。另注意 Remove() 的实现(从 slice 中删除一项)。

好,现在针对这份实现思考下面 3 个问题。

  1. MenuItemMenu 中都有 name、description 这两个属性和方法,重复写两遍明显冗余。如果使用其它任何面向对象语言,这两个属性和方法都应该移到基类中实现。可是 Go 没有继承,这可真是坑爹。
  2. 这里我们真正实现了用户一致性访问了吗?显然没有,当使用者拿到一个 MenuComponent 后,依然要知道其类型后才能正确使用,假如不加判断在 MenuItem 使用 Add() 等未实现的方法就会产生 panic。类似地,我们大可以把文件夹/文件都抽象成“文件系统节点”,可以读取名字,可以计算占用空间,但是一旦我们想往“文件系统节点”中添加子节点时,还是必须得判断它到底是不是文件夹。
  3. 接着第 2 条继续思考:产生某种一致性访问现象的本质原因是什么?一种观点: MenuMenuItem 某种本质上是(is-a)同一个事物(MenuComponent),所以可以对它们一致性访问;另一种观点:MenuMenuItem 是两个不同的事物,只是恰巧有一些相同的属性,所以可以对它们一致性访问。

用组合代替继承

前面说到 Go 语言没有继承,本来属于基类的 name 和 description 不能放到基类中实现。其实只要转换一下思路,这个问题是很容易用组合解决的。如果我们认为 MenuMenuItem 本质上是两个不同的事物,只是恰巧有(has-a)一些相同的属性,那么将相同的属性抽离出来,再分别组合进两者,问题就迎刃而解了。

先看抽离出来的属性:

 1type MenuDesc struct {
 2	name        string
 3	description string
 4}
 5
 6func (m *MenuDesc) Name() string {
 7	return m.name
 8}
 9
10func (m *MenuDesc) Description() string {
11	return m.description
12}

改写 MenuItem

 1type MenuItem struct {
 2	MenuDesc
 3	price float32
 4}
 5
 6func NewMenuItem(name, description string, price float32) MenuComponent {
 7	return &MenuItem{
 8		MenuDesc: MenuDesc{
 9			name:        name,
10			description: description,
11		},
12		price: price,
13	}
14}
15
16// ... 方法略 ...

改写 Menu:

 1type Menu struct {
 2	MenuDesc
 3	children []MenuComponent
 4}
 5
 6func NewMenu(name, description string) MenuComponent {
 7	return &Menu{
 8		MenuDesc: MenuDesc{
 9			name:        name,
10			description: description,
11		},
12	}
13}
14
15// ... 方法略 ...

Go 语言中善用组合有助于表达数据结构的意图。特别是当一个比较复杂的对象同时处理几方面的事情时,将对象拆成独立的几个部分再组合到一起,会非常清晰优雅。例如上面的 MenuItem 就是描述+价格,Menu 就是描述+子菜单。

其实对于 Menu,更好的做法是把 childrenAdd() Remove() Child() 也提取封装后再进行组合,这样 Menu 的功能一目了然。

 1type MenuGroup struct {
 2	children []MenuComponent
 3}
 4
 5func (m *Menu) Add(c MenuComponent) {
 6	m.children = append(m.children, c)
 7}
 8
 9func (m *Menu) Remove(idx int) {
10	m.children = append(m.children[:idx], m.children[idx+1:]...)
11}
12
13func (m *Menu) Child(idx int) MenuComponent {
14	return m.children[idx]
15}
16
17type Menu struct {
18	MenuDesc
19	MenuGroup
20}
21
22func NewMenu(name, description string) MenuComponent {
23	return &Menu{
24		MenuDesc: MenuDesc{
25			name:        name,
26			description: description,
27		},
28	}
29}

Go 语言的思维方式

以下是本文的重点。使用 Go 语言开发项目 2 个多月,最大的感触就是:学习 Go 语言一定要转变思维方式,转变成功则其乐无穷,不能及时转变会发现自己处处碰壁。

下面让我们用真正 Go 的方式来实现 KFC 菜单。首先请默念三遍:没有继承,没有继承,没有继承;没有基类,没有基类,没有基类;接口只是函数签名的集合,接口只是函数签名的集合,接口只是函数签名的集合;struct 不依赖于接口,struct 不依赖于接口,struct 不依赖于接口。

好了,与之前不同,现在我们不是先定义接口再具体实现,因为 struct 不依赖于接口,所以我们直接实现具体功能。先是 MenuDescMenuItem,注意现在 NewMenuItem 的返回值类型是*MenuItem

 1type MenuDesc struct {
 2	name        string
 3	description string
 4}
 5
 6func (m *MenuDesc) Name() string {
 7	return m.name
 8}
 9
10func (m *MenuDesc) Description() string {
11	return m.description
12}
13
14type MenuItem struct {
15	MenuDesc
16	price float32
17}
18
19func NewMenuItem(name, description string, price float32) *MenuItem {
20	return &MenuItem{
21		MenuDesc: MenuDesc{
22			name:        name,
23			description: description,
24		},
25		price: price,
26	}
27}
28
29func (m *MenuItem) Price() float32 {
30	return m.price
31}
32
33func (m *MenuItem) Print() {
34	fmt.Printf("  %s, ¥%.2f\n", m.name, m.price)
35	fmt.Printf("    -- %s\n", m.description)
36}

接下来是 MenuGroup。我们知道 MenuGroup 是菜单/菜单项的集合,其 children 的类型是不确定的,于是我们知道这里需要定义一个接口。又因为 MenuGroup 的逻辑是对 children 进行增、删、读操作,对 children 的属性没有任何约束和要求,所以我们这里暂时把接口定义为空接口 interface{}

 1type MenuComponent interface {
 2}
 3
 4type MenuGroup struct {
 5	children []MenuComponent
 6}
 7
 8func (m *Menu) Add(c MenuComponent) {
 9	m.children = append(m.children, c)
10}
11
12func (m *Menu) Remove(idx int) {
13	m.children = append(m.children[:idx], m.children[idx+1:]...)
14}
15
16func (m *Menu) Child(idx int) MenuComponent {
17	return m.children[idx]
18}

最后是 Menu 的实现:

 1type Menu struct {
 2	MenuDesc
 3	MenuGroup
 4}
 5
 6func NewMenu(name, description string) *Menu {
 7	return &Menu{
 8		MenuDesc: MenuDesc{
 9			name:        name,
10			description: description,
11		},
12	}
13}
14
15func (m *Menu) Price() (price float32) {
16	for _, v := range m.children {
17		price += v.Price()
18	}
19	return
20}
21
22func (m *Menu) Print() {
23	fmt.Printf("%s, %s, ¥%.2f\n", m.name, m.description, m.Price())
24	fmt.Println("------------------------")
25	for _, v := range m.children {
26		v.Print()
27	}
28	fmt.Println()
29}

在实现 Menu 的过程中,我们发现 Menu 对其 children 实际上有两个约束:需要有 Price() 方法和 Print() 方法。于是对 MenuComponent 进行修改:

1type MenuComponent interface {
2	Price() float32
3	Print()
4}

最后观察 MenuItemMenu,它们都符合 MenuComponent 的约束,所以二者都可以成为 Menuchildren,组合模式大功告成!

比较与思考

前后两份代码差异其实很小:

  1. 第二份实现的接口简单一些,只有两个函数。
  2. New 函数返回值的类型不一样。

从思路上看,差异很大却也有些微妙:

  1. 第一份实现中接口是模板,是 struct 的蓝图,其属性来源于事先对系统组件的综合分析归纳;第二份实现中接口是一份约束声明,其属性来源于使用者对被使用者的要求。
  2. 第一份实现认为 children 中的 MenuComponent 是一种具体对象,这个对象具有一系列方法可以调用,只是其方法的功能会由于子类覆盖而表现不同;第二份实现则认为 children 中的 MenuComponent 可以是任意无关的对象,唯一的要求是他们“恰巧”实现了接口所指定的约束条件。

注意第一份实现中,MenuComponent 中有 Add()Remove()Child() 三个方法,但却不一定是可用的,能不能使用由具体对象的类型决定;第二份实现中则不存在这些不安全的方法,因为 New 函数返回的是具体类型,所以可以调用的方法都是安全的。

另外,从 Menu 中取出某个 child,其可用方法只有 Price()Print(),一样可以完全安全的调用。如果想在 MenuComponentMenu 的情况下往其中添加子项呢?很简单:

1if m, ok := all.Child(1).(*Menu); ok {
2	m.Add(NewMenuItem("玩具", "Hello Kitty", 5.0))
3}

清晰明了,如果某 child 是一个 Menu,那么我们可以对其进行 Add() 操作。

更进一步,这里我们对类型的要求其实并没有那么强,并不需要它一定要是 Menu,只是需要其提供组合 MenuComponent 的功能,所以可以提炼出这样一个接口:

1type Group interface {
2	Add(c MenuComponent)
3	Remove(idx int)
4	Child(idx int) MenuComponent
5}

前面的添加子项的代码改成这样:

1if m, ok := all.Child(1).(Group); ok {
2	m.Add(NewMenuItem("玩具", "Hello Kitty", 5.0))
3}

再考虑一下“购买”这个操作,面向对象的实现中,购买的类型是 MenuComponent,所以购买操作同时可以应用于 MenuMenuItem。如果用 Go 语言的思维方式来考察,可购买对象的唯一要求是有 Price(),所以购买操作的参数是这样的接口:

1type Product interface {
2	Price() float32
3}

于是购买操作不仅可应用于 MenuMenuItem,还可用于任何提供了价格的对象。我们可以任意添加产品,不论是玩具还是会员卡或者优惠券,只要有 Price() 方法就可以被购买。

总结

最后总结一下我的思考:

  1. 在组合模式中,一致性访问是个伪需求。一致性访问不是我们在设计时需要去满足的需求,而是当不同实体具有相同属性时自然产生的效果。上面的例子中,我们创建的是 menu 和 MenuItem 两种不同的类型,但由于它们具有相同属性,我们能以相同的方式取价格,取描述,加入 menu 成为子项。
  2. Go 语言中的多态不体现在对象创建阶段,而体现在对象使用阶段,合理使用“小接口”能显著减少系统耦合度。

PS. 本文所涉及的三份完整代码,我放在 play.golang.org 上了:(需翻墙)


欢迎加入技术讨论 QQ 群: 745157974

Go语言泛型初体验

2022-03-11
编程语言 Go

五句话理解 Rust 所有权

2020-02-08
编程语言 rust

Go 语言设计模式:单例

最没意思的设计模式,Go 语言能玩出花样吗?
编程语言