关于本系列

这个系列首先是关于Go语言实践的。在项目中实际使用Go语言也有段时间了,一个体会就是不论是官方文档、图书还是网络资料,关于Go语言惯用法(idiom)的介绍都比较少,基本只能靠看标准库源代码自己琢磨,所以我特别想在这方面有一些收集和总结。

然后这个系列也是关于设计模式的。虽然Go语言不是一门面向对象编程语言,但是很多面向对象设计模式所要解决的问题是在程序设计中客观存在的。不管用什么语言,总是要面对和解决这些问题的,只是解决的思路和途径会有所不同。所以我想就以经典的设计模式作为切入点来展开这个系列,毕竟大家对设计模式都很熟悉了,可以避免无中生有想出一些蹩脚的应用场景。

本系列的具体主题会比较灵活,计划主要包括这些方面的话题:

  1. Go语言惯用法。
  2. 设计模式的实现。特别是引入了闭包,协程,DuckType等语言特性后带来的变化。
  3. 设计模式思想的探讨。会有一些吐槽。

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

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

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

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

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

func main() {
    menu1 := NewMenu("培根鸡腿燕麦堡套餐", "供应时间:09:15--22:44")
    menu1.Add(NewMenuItem("主食", "培根鸡腿燕麦堡1个", 11.5))
    menu1.Add(NewMenuItem("小吃", "玉米沙拉1份", 5.0))
    menu1.Add(NewMenuItem("饮料", "九珍果汁饮料1杯", 6.5))

    menu2 := NewMenu("奥尔良烤鸡腿饭套餐", "供应时间:09:15--22:44")
    menu2.Add(NewMenuItem("主食", "新奥尔良烤鸡腿饭1份", 15.0))
    menu2.Add(NewMenuItem("小吃", "新奥尔良烤翅2块", 11.0))
    menu2.Add(NewMenuItem("饮料", "芙蓉荟蔬汤1份", 4.5))

    all := NewMenu("超值午餐", "周一至周五有售")
    all.Add(menu1)
    all.Add(menu2)

    all.Print()
}

得到的输出如下:

超值午餐, 周一至周五有售, ¥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语言中没有继承,所以我们把抽象基类定义为接口,后面会由菜单和菜单项实现具体功能:

type MenuComponent interface {
    Name() string
    Description() string
    Price() float32
    Print()

    Add(MenuComponent)
    Remove(int)
    Child(int) MenuComponent
}

菜单项的实现:

type MenuItem struct {
    name        string
    description string
    price       float32
}

func NewMenuItem(name, description string, price float32) MenuComponent {
    return &MenuItem{
        name:        name,
        description: description,
        price:       price,
    }
}

func (m *MenuItem) Name() string {
    return m.name
}

func (m *MenuItem) Description() string {
    return m.description
}

func (m *MenuItem) Price() float32 {
    return m.price
}

func (m *MenuItem) Print() {
    fmt.Printf("  %s, ¥%.2f\n", m.name, m.price)
    fmt.Printf("    -- %s\n", m.description)
}

func (m *MenuItem) Add(MenuComponent) {
    panic("not implement")
}

func (m *MenuItem) Remove(int) {
    panic("not implement")
}

func (m *MenuItem) Child(int) MenuComponent {
    panic("not implement")
}

有两点请留意一下。

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

下面是菜单的实现:

type Menu struct {
    name        string
    description string
    children    []MenuComponent
}

func NewMenu(name, description string) MenuComponent {
    return &Menu{
        name:        name,
        description: description,
    }
}

func (m *Menu) Name() string {
    return m.name
}

func (m *Menu) Description() string {
    return m.description
}

func (m *Menu) Price() (price float32) {
    for _, v := range m.children {
        price += v.Price()
    }
    return
}

func (m *Menu) Print() {
    fmt.Printf("%s, %s, ¥%.2f\n", m.name, m.description, m.Price())
    fmt.Println("------------------------")
    for _, v := range m.children {
        v.Print()
    }
    fmt.Println()
}

func (m *Menu) Add(c MenuComponent) {
    m.children = append(m.children, c)
}

func (m *Menu) Remove(idx int) {
    m.children = append(m.children[:idx], m.children[idx+1:]...)
}

func (m *Menu) Child(idx int) MenuComponent {
    return m.children[idx]
}

其中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)一些相同的属性,那么将相同的属性抽离出来,再分别组合进两者,问题就迎刃而解了。

先看抽离出来的属性:

type MenuDesc struct {
    name        string
    description string
}

func (m *MenuDesc) Name() string {
    return m.name
}

func (m *MenuDesc) Description() string {
    return m.description
}

改写MenuItem

type MenuItem struct {
    MenuDesc
    price float32
}

func NewMenuItem(name, description string, price float32) MenuComponent {
    return &MenuItem{
        MenuDesc: MenuDesc{
            name:        name,
            description: description,
        },
        price: price,
    }
}

// ... 方法略 ...

改写Menu:

type Menu struct {
    MenuDesc
    children []MenuComponent
}

func NewMenu(name, description string) MenuComponent {
    return &Menu{
        MenuDesc: MenuDesc{
            name:        name,
            description: description,
        },
    }
}

// ... 方法略 ...

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

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

type MenuGroup struct {
    children []MenuComponent
}

func (m *Menu) Add(c MenuComponent) {
    m.children = append(m.children, c)
}

func (m *Menu) Remove(idx int) {
    m.children = append(m.children[:idx], m.children[idx+1:]...)
}

func (m *Menu) Child(idx int) MenuComponent {
    return m.children[idx]
}

type Menu struct {
    MenuDesc
    MenuGroup
}

func NewMenu(name, description string) MenuComponent {
    return &Menu{
        MenuDesc: MenuDesc{
            name:        name,
            description: description,
        },
    }
}

Go语言的思维方式

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

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

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

type MenuDesc struct {
    name        string
    description string
}

func (m *MenuDesc) Name() string {
    return m.name
}

func (m *MenuDesc) Description() string {
    return m.description
}

type MenuItem struct {
    MenuDesc
    price float32
}

func NewMenuItem(name, description string, price float32) *MenuItem {
    return &MenuItem{
        MenuDesc: MenuDesc{
            name:        name,
            description: description,
        },
        price: price,
    }
}

func (m *MenuItem) Price() float32 {
    return m.price
}

func (m *MenuItem) Print() {
    fmt.Printf("  %s, ¥%.2f\n", m.name, m.price)
    fmt.Printf("    -- %s\n", m.description)
}

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

type MenuComponent interface {
}

type MenuGroup struct {
    children []MenuComponent
}

func (m *Menu) Add(c MenuComponent) {
    m.children = append(m.children, c)
}

func (m *Menu) Remove(idx int) {
    m.children = append(m.children[:idx], m.children[idx+1:]...)
}

func (m *Menu) Child(idx int) MenuComponent {
    return m.children[idx]
}

最后是Menu的实现:

type Menu struct {
    MenuDesc
    MenuGroup
}

func NewMenu(name, description string) *Menu {
    return &Menu{
        MenuDesc: MenuDesc{
            name:        name,
            description: description,
        },
    }
}

func (m *Menu) Price() (price float32) {
    for _, v := range m.children {
        price += v.Price()
    }
    return
}

func (m *Menu) Print() {
    fmt.Printf("%s, %s, ¥%.2f\n", m.name, m.description, m.Price())
    fmt.Println("------------------------")
    for _, v := range m.children {
        v.Print()
    }
    fmt.Println()
}

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

type MenuComponent interface {
    Price() float32
    Print()
}

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

比较与思考

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

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

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

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

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

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

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

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

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

type Group interface {
    Add(c MenuComponent)
    Remove(idx int)
    Child(idx int) MenuComponent
}

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

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

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

type Product interface {
    Price() float32
}

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

总结

最后总结一下我的思考,欢迎各位讨论或抨击:

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

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

Go语言设计模式实践:组合(Composite)的更多相关文章

  1. Go语言设计模式实践:迭代器(Iterator)

    关于本系列 决定开个新坑. 这个系列首先是关于Go语言实践的.在项目中实际使用Go语言也有段时间了,一个体会就是不论是官方文档.图书还是网络资料,关于Go语言惯用法(idiom)的介绍都比较少,基本只 ...

  2. C++设计模式实现--组合(Composite)模式

    一. 举例 这个样例是书上的,如果有一个公司的组结结构例如以下: 它的结构非常像一棵树,当中人力资源部和財务部是没有子结点的,详细公司才有子结点. 并且最关健的是,它的每一层结构非常相似. 代码实现例 ...

  3. Java设计模式之 — 组合(Composite)

    转载请注明出处:http://blog.csdn.net/guolin_blog/article/details/9153753 听说你们公司最近新推出了一款电子书阅读应用,市场反应很不错,应用里还有 ...

  4. Head First 设计模式 —— 11. 组合 (Composite) 模式

    思考题 我们不仅仅要支持多个菜单,升值还要支持菜单中的菜单.你如何处理这个新的设计需求? P355 [提示]在我们的新设计中,真正需要以下三点: P354 我们需要某种属性结构,可以容纳菜单.子菜单和 ...

  5. C#设计模式(10)——组合模式(Composite Pattern)

    一.引言 在软件开发过程中,我们经常会遇到处理简单对象和复合对象的情况,例如对操作系统中目录的处理就是这样的一个例子,因为目录可以包括单独的文件,也可以包括文件夹,文件夹又是由文件组成的,由于简单对象 ...

  6. 乐在其中设计模式(C#) - 组合模式(Composite Pattern)

    原文:乐在其中设计模式(C#) - 组合模式(Composite Pattern) [索引页][源码下载] 乐在其中设计模式(C#) - 组合模式(Composite Pattern) 作者:weba ...

  7. Ruby设计模式透析之 —— 组合(Composite)

    转载请注明出处:http://blog.csdn.net/sinyu890807/article/details/9153761 此为Java设计模式透析的拷贝版,专门为Ruby爱好者提供的,不熟悉R ...

  8. C#设计模式(10)——组合模式(Composite Pattern)(转)

    一.引言 在软件开发过程中,我们经常会遇到处理简单对象和复合对象的情况,例如对操作系统中目录的处理就是这样的一个例子,因为目录可以包括单独的文件,也可以包括文件夹,文件夹又是由文件组成的,由于简单对象 ...

  9. 设计模式:组合(Composite)模式

    设计模式:组合(Composite)模式 一.前言   关于Composite模式,其实就是组合模式,又叫部分整体模式,这个模式在我们的生活中也经常使用,比如说如果读者有使用Java的GUI编写过程序 ...

随机推荐

  1. ASP.NET Core 上传大文件无法接收的问题

    解决办法:在API项目中配置 1. 在 web.config 文件中 <system.webServer>里加入 <security> <requestFiltering ...

  2. 窗口生效函数UpdateData

    Invalidate()使整个窗口客户区无效.窗口的客户区无效意味着需要重绘,例如,如果一个被其它窗口遮住的窗口变成了前台窗口,那么原来被遮住的部分就是无效的,需要重绘.这时Windows会在应用程序 ...

  3. nio笔记

    http://blog.csdn.net/z69183787/article/category/2191483此人的博客 首先你要知道阻塞和非阻塞的概念,阻塞体现在这个线程不能干别的了,只能在这里等着 ...

  4. 以太坊go-ethereum客户端(三)两种全节点启动模式

    这篇博客介绍一下go-ethereum全节点的两种启动模式:主网络快速启动和测试网络快速启动.这也是客户端所提供的两种启动方式,直接拿来使用即可.下面具体介绍一下使用方法. 主网络快速启动 其实,我们 ...

  5. hdu 1850(尼姆博弈)

    Being a Good Boy in Spring Festival Time Limit: 1000/1000 MS (Java/Others)    Memory Limit: 32768/32 ...

  6. 洛谷P1339 [USACO09OCT]热浪Heat Wave 题解

    题目传送门 这道题实际非常简单好奇是怎么变黄的... 其实也就是一个SPFA,本人非常懒,不想打邻接表,直接用矩阵就好啦... #include<bits/stdc++.h> using ...

  7. 响应式之像素和viewport

    引言 按照pc尺寸做好的网页,在手机端打开,看起来像是pc的缩小版,东西都在只是字太小都看不清了,有什么办法放大呢? 于是去google一下,发现,贴了这么一行代码就轻松解决了: <meta n ...

  8. IEEEXtreme 10.0 - Food Truck

    这是 meelo 原创的 IEEEXtreme极限编程大赛题解 Xtreme10.0 - Food Truck 题目来源 第10届IEEE极限编程大赛 https://www.hackerrank.c ...

  9. LoadRunner的Capture Level说明

    LoadRunner的Capture Level说明 Capture Level的设置说明: 1.Socket level data. Capture data using trapping on t ...

  10. 在linux下安装sbt

    1.到官方网站下载deb包,下载地址:https://dl.bintray.com/sbt/debian/sbt-1.0.3.deb 2.点击下载的deb包进行安装 3.安装完成后,在terminal ...