设计模式[七]组合模式

202 阅读8分钟

这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

组合模式的定义

组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身也许是由更小的“孙对象”构成

组合模式的用途

回顾命令模式的宏命令,marcoCommand被称为组合对象,其他三个方法都是叶对象。在marcoCommand的execute方法里,并不执行正在的操作,而是遍历它所包含的叶对象,把真正的execute请求委托给这些叶对象

组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。

在这实际开发中会给客户带来相当大的便利性,当我们往万能遥控器里面添加一个命令的时候,并不关心这个命令是宏命令还是普通子命令。这点对于我们不重要,我们只需要确定他是一个命令,并且这个命令拥有可执行的execute方法,那么这个命令就可以被添加进万能遥控器

请求在树中的传递

在组合模式中,请求在树中传递的过程总是遵循一种逻辑

以宏命令为例,请求从树的最顶端的对象往下传递,如果当前处理请求的对象是叶对象(普通子命令),叶对象自身会对请求做出相应的处理;如何处理的是组合对象(宏命令),组合对象则会遍历它属下的子节点,将请求继续传递给这些子节点

更强大的宏命令

目前的要控制,包含了开门、开电脑和登录QQ这三个命令。现在我们需要一个超级遥控器,可以控制家里所有的电器,这个遥控器有如下功能

  1. 打开空调
  2. 打开电视和音响
  3. 关门、开电脑、登录QQ

首先在节点中放置一个button来表示这个超级万能遥控器,万能遥控器上安装了一个宏命令,当执行这个宏命令时,会依次遍历执行它所包含的子命令,代码如下。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">万能遥控器</button>

    <script>
        const MacroCommand = () => ({
            commandsList: [],
            add(command) {
                this.commandsList.push(command)
            },
            execute() {
                for(let i = 0, command; command = this.commandsList[i++];) {
                    command.execute()
                }
            }
        })

        const openAcCommand = {
            execute() {
                console.log('打开空调')
            }
        }

        // 家里的殿试和音响是连接在一起的,所以可以用一个宏命令来组合打开电视和打开音响的命令

        const openTvCommand = {
            execute() {
                console.log('打开电视')
            }
        }
        const openSoundCommand = {
            execute() {
                console.log('打开音响')
            }
        }
        const macroCommand1 = MacroCommand()
        macroCommand1.add(openTvCommand)
        macroCommand1.add(openSoundCommand)

        // 关门、打开电话和登录QQ的命令

        const closeDoorCommand = {
            execute() {
                console.log('关门')
            }
        }
        const openPcCommand = {
            execute() {
                console.log('开电脑')
            }
        }
        const openQQCommand = {
            execute() {
                console.log('打开QQ')
            }
        }

        const macroCommand2 = MacroCommand()
        macroCommand2.add(closeDoorCommand)
        macroCommand2.add(openPcCommand)
        macroCommand2.add(openQQCommand)

        // 现在把所有命令合成一个超级命令

        const macroCommand = MacroCommand()
        macroCommand.add(openAcCommand)
        macroCommand.add(macroCommand1)
        macroCommand.add(macroCommand2)

        // 给遥控器绑定 ”超级命令“

        btn.onclick = () => {
            macroCommand.execute()
        }
    </script>
</body>
</html>

从这个例子可以看到,基本对象可以组合为更复杂的组合对象,组合对象又可以继续组合,这样不断递归下去,这棵树的结构可以支持任意多的复杂度。在树最终被构造完成后,让整棵树运转起来非常简单,只需要调用最上层的execute方法。每当对最上层的对象进行一次请求时,实际上是在对整个树进行深度优先搜索。而创建组合对象的程序员并不关心这些内在的细节。

抽象类在组合模式中的作用

前面说到,组合模式最大的有点在于可以一致地对待组合对象和基本对象。这种透明性带来的遍历,在静态类型语言中体现得尤为明显。下面看一段Java代码

public abstract class Component {
    // add方法,参数为Component类型
    public void add(Component child) {}
}
public class Composite extends Component {
    // add方法,参数为Component类型
    public void add(Component child) {}
}
public class Leaf extends Component {
    // add方法,参数为Component类型
    public void add(Component child) {
        throw new UnsupportedOperationExcption() // 叶对象不能再添加子节点
    }
}

public class client() {
    public static void main(String args[]) {
        Component root = new Composite();

        Component c1 = new Composite();
        Component c2 = new Composite();

        Component leaf1 = new Leaf();
        Component leaf2 = new Leaf();

        root.add(c1);
        root.add(c2);

        c1.add(leaf1);
        c1.add(leaf2)
    }
}

然后再JavaScript这种动态类型语言中,对象的多态性是与人俱来的,也没有编译器去检查变量的类型,JavaScript中实现组合模式的难点在于要保证组合对象和叶对象拥有相同的方法,这通常需要用鸭子类型的思想进行接口检查

在JavaScript中实现组合模式,看起来缺乏一些严谨性,我们的代码算不上安全,但能更快速和自由的开发,这既是JavaScript的缺点,也是它的优点

透明性带来的问题

组合对象可以拥有字节的,叶对象下面就没有子节点,所以我们也许会发生一些误操作,比如试图往叶节点中添加子节点。解决方案通常是给叶对象加上add方法,并且在里面抛出异常。

    const openTvCommand = {
        execute() {
            console.log('打开电视')
        },
        add() {
            throw new Error('叶对象不能添加子节点')
        }
    }

组合模式的例子————扫描文件夹

文件夹和文件之间的关系,非常适合用组合模式来描述。

class Folder {
    constructor(name) {
        this.name = name
        this.files = []
    }

    add(file) {
        this.files.push(file)
    }

    scan() {
        console.log(`开始扫描文件夹:${this.name}`)
        for(let i = 0, file, files = this.files; file = files[i++];) {
            file.scan()
        }
    }
}

class File {
    constructor(name) {
        this.name = name
    }

    add() {
        throw new Error('文件下面不能添加文件')
    }

    scan() {
        console.log(`开始扫描文件:${this.name}`)
    }
}

// 接下来创建一些文件夹合文件对象,并且让他们组合成一棵树

const folder = new Folder('学习资料')
const folder1 = new Folder('JavaScript')
const folder2 = new Folder('jQuery')

const file1 = new File('JavaScript设计模式')
const file2 = new File('精通jQuery')
const file3 = new File('css3')

folder1.add(file1)
folder2.add(file2)

folder.add(folder1)
folder.add(folder2)
folder.add(file3)
   

const folder3 = new Folder('Nodejs')
const file4 = new File('深入浅出Nodejs')
folder3.add(file4)


folder.scan()


一些需要注意的地方

  1. 组合模式不是父子关系

    组合模式是一种HAS-A(聚合)的关系,而不是IS-A。它们能够合作的关键是拥有相同的接口

  2. 对叶对象操作的一致性

    组合模式除了要求组合对象和叶对象拥有相同的接口外,还有一个必要条件,就是一组叶对象的操作必须具有一致性

    比如公司要给全体员工发放元旦的过节费1000块,这个场景就可以运用组合模式,但如果公司要给今天过生日的员工发送一封生日祝福的邮件,组合模式就用不上了,除非把今天过生日的员工筛选处理。

  3. 双向映射关系

    发过节费的通知步骤是从公司到各个部门,再到各个小组,最后到每个员工的邮箱里。这本书是一个组合模式的好例子,但要考虑一种请看,也许某个员工属于多个组织架构。比如某个架构师属于开发组,又属于架构组,对象之间的关系并不是严格意义上的层次结构,在这种情况下,是不适合使用组合模式的,该架构师可能会收到两份过节费

  4. 用职责链模式提高组合模式性能

    在组合模式中,如果树的结构很复杂,节点数量很多,在遍历树的过程中,性能方面也许不够理想。我们可以借助职责链模式提高性能。

    职责链模式一般需要我们手动设置链条,但在组合模式中,父对象和子对象之间实际上形成了天然的职责链。让请求顺着链条从父对象往子对象传递,或者是反过来从子对象往父对象传递,知道遇到可以处理该请求的对象为止,这也是职责链模式的经典运用场景之一

引用父对象

现在来改写扫描文件夹的代码,使得在扫描整个文件夹之前,我们可以先移除某一个具体的文件

首先改写Folder类和File类,在这两个类的我构造函数中,增加this.parent属性,并且在调用add方法的时候,正确设置文件和文件夹的父节点

class Folder {
    constructor(name) {
        this.name = name
        this.parent = null // 增加parent属性
        this.files = []
    }

    add(file) {
        file.parent = this
        this.files.push(file)
    }

    scan() {
        console.log(`开始扫描文件夹:${this.name}`)
        for(let i = 0, file, files = this.files; file = files[i++];) {
            file.scan()
        }
    }

    // 增加remove方法

    remove() {
        if (!this.parent) {
            // 根节点或者树外的游离节点
            return
        }
        for (let files = this.parent.files, l = files.length - 1; l >= 0; l --) {
            const file = files[l]
            if (file === this) {
                files.splice(l, 1)
            }
        }
    }
}

class File {
    constructor(name) {
        this.name = name
        this.parent = null
    }

    add() {
        throw new Error('文件下面不能添加文件')
    }

    scan() {
        console.log(`开始扫描文件:${this.name}`)
    }

    // 增加remove方法

    remove() {
        if (!this.parent) {
            // 根节点或者树外的游离节点
            return
        }
        for (let files = this.parent.files, l = files.length - 1; l >= 0; l --) {
            const file = files[l]
            if (file === this) {
                files.splice(l, 1)
            }
        }
    }
}

// 下面测试一下移除文件的功能

const folder = new Folder('学习资料')
const folder1 = new Folder('JavaScript')

const file1 = new File('深入浅出Node.js')
folder1.add(new File('JavaScript设计模式与开发实践'))
folder.add(folder1)
folder.add(file1)

folder1.remove()
folder.scan()

何时使用组合模式

  1. 表示对象的部分-整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构。特别是在我们开发期间不确定到此存在多少层次的时候。在树构造完成后,只需要请求树的最顶层对象,便能对整棵树做统一的操作

  2. 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就是不用写一堆if、else语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力

小结

组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来与其他对象差不对多。它们的区别只有在运行的时候才会显现处理,这会使代码难以理解。此外,如果通过组合创建了太多的对象,那么这些对象会让系统负担不起