文章目录
- 一、引言
- 二、组合模式
- 三、总结
一、引言
组合模式是一种结构型设计模式, 可以使用它将对象组合成树状结构, 并且能像使用独立对象一样使用它们。代码实现中涉及了递归调用。组合模式与传统上的“类与类之间的组合关系”没有关联,不要混为一谈。
组合模式主要用来处理树形结构的数据,例如Windows或者类UNIX操作系统中文件的组织方式就是典型的树形结构。这里所指的数据就是这些文件或者文件夹,处理树形结构数据是指例如可以对它们进行遍历以显示目录或文件名(查看目录文件结构)、进行某些动作(例如信息统计、文件杀毒)等操作。
二、组合模式
组合模式主要是用来表达和处理树形结构数据的,作为树形结构的数据,显然要有一个树根,树根下面可以有树枝和树叶两种节点,而树枝下面又可能进一步生长出新的树枝和叶(树叶属于末端节点,其上不会生长出任何其他内容),以此类推。
例如操作系统的文件系统:
看一看如何用程序来把这个目录层次结构组织并输出(绘制出来),输出的结果类似于
用tree命令显示root目录产生的结果(考虑到组合模式不太好理解,可以先抛开这个模
式),这个范例的难点在于目录中还会包含更深层次的目录和文件,而这些目录和文件的名字都要求输出出来,所以实现思路应该涉及递归编程。首先创建一个用于表示文件的类File
和Dir
,代码如下,注意代码中的注释:
// 文件相关类
class File {
public:File(const string& name) : m_sname(name) {}void ShowName(const string& lvlstr) const { // lvlstr:为了显示层次关系的缩进字符串内容cout << lvlstr << "-" << m_sname << endl;// 显示”-”代表是一个文件,属末端节点(不会再有子节点)}private:string m_sname; // 文件名
};// 目录
class Dir {
public:Dir(const string& name) : m_sname(name) {}// 目录中可以增加其他文件void AddFile(shared_ptr<File> pfile) {m_childFiles.push_back(pfile);}// 目录中可以增加其他目录void AddDir(shared_ptr<Dir> pdir) {m_childDirs.push_back(pdir);}// 显示目录名,同时也负责其下面的文件和目录名的显示工作void ShowName(const string& lvlstr) const {// (1) 输出本目录名cout << lvlstr << "+" << m_sname << endl; // 显示” + "代表是一个目录,其中会包含其他内容// (2) 输出所包含的文件名string newLvlStr = lvlstr + " ";for (const auto& file : m_childFiles) {file->ShowName(newLvlStr + " "); // 本目录中的文件和目录的显示,要缩进一些来显示}// (3) 输出所包含的目录名for (const auto& dir : m_childDirs) {dir->ShowName(newLvlStr + " "); // 显示目录名,这里涉及了递归调用}}private:string m_sname; // 目录名list<shared_ptr<File>> m_childFiles; // 目录中包含的文件列表list<shared_ptr<Dir>> m_childDirs; // 目录中包含的子目录列表
};
我们给个案例使用该函数
// 创建文件
auto file1 = make_shared<File>("file1.txt");
auto file2 = make_shared<File>("file2.txt");
auto file3 = make_shared<File>("file3.txt");// 创建目录
auto dir1 = make_shared<Dir>("dir1");
auto dir2 = make_shared<Dir>("dir2");
auto dir3 = make_shared<Dir>("dir3");// 组装目录结构
dir1->AddFile(file1.get());
dir1->AddDir(dir2.get());
dir2->AddFile(file2.get());
dir2->AddDir(dir3.get());
dir3->AddFile(file3.get());// 显示目录结构
dir1->ShowName(""); // 从根目录开始显示/*输出如下
+dir1-file1.txt+dir2-file2.txt+dir3-file3.txt
*/
以一个树根为起点,可以遍历(访问)到所有该根下的树节点(既包含树枝,又包含树叶)。在本范例中,File
类和Dir
类的ShowName
函数虽然名字相同,但它们做的事情并不相同,因为Dir
类的ShowName
不但要显示自身的名字,还要显示其下的文件和目录名字,而其下目录名字的显示,使用的正是递归调用,当然这里所说的递归区别于传统意义上的递归(函数调用自身),而是一种针对对象本身的递归。
上面这个范例代码中存在的问题是:为了区分文件和目录,分别创建了File
和Dir
两个类,这种区分比较多余,为此,引人了组合模式,该模式专门针对以树形结构的形式组织对象时,不再将File
和Dir
类单独分开,而是引人一个新的抽象类(例如FileSystem
)并提供公共的接口(成员函数),而后让File
和Dir
类分别继承自FileSystem
类。看一看如何采用组合模式改造上述范例代码。
class FileSystem {
public:virtual void ShowName(int level) const = 0;virtual int Add(shared_ptr<FileSystem> pfilesys) = 0;virtual int Remove(shared_ptr<FileSystem> pfilesys) = 0;virtual ~FileSystem() {}
};// 文件相关类
class File : public FileSystem {
public:File(string name) : m_sname(name) {}virtual void ShowName(int level) const override {for (int i = 0; i < level; ++i) cout << " ";cout << "-" << m_sname << endl;}virtual int Add(shared_ptr<FileSystem> pfilesys) override {return -1; // 文件不能添加子文件或子目录}virtual int Remove(shared_ptr<FileSystem> pfilesys) override {return -1; // 文件不能移除子文件或子目录}private:string m_sname; // 文件名
};// 目录
class Dir : public FileSystem {
public:Dir(const string& name) : m_sname(name) {}virtual void ShowName(int level) const override {// (1) 显示若干空格用于对齐for (int i = 0; i < level; ++i) cout << " ";// (2) 输出本目录名cout << "+" << m_sname << endl;// (3) 显示的层级向下走一级level++;// (4) 输出所包含的子内容(可能是文件,也可能是子目录)for (const auto& child : m_child) {child->ShowName(level); // 显示子内容}}virtual int Add(shared_ptr<FileSystem> pfilesys) override {m_child.push_back(pfilesys);return 0;}virtual int Remove(shared_ptr<FileSystem> pfilesys) override {m_child.remove(pfilesys);return 0;}private:string m_sname; // 目录名list<shared_ptr<FileSystem>> m_child; // 目录中包含的文件和子目录
};
给一个使用案例:
// 创建文件和目录
auto root = make_shared<Dir>("root");
auto dir1 = make_shared<Dir>("dir1");
auto dir2 = make_shared<Dir>("dir2");
auto file1 = make_shared<File>("file1.txt");
auto file2 = make_shared<File>("file2.txt");
auto file3 = make_shared<File>("file3.txt");// 构建文件结构
root->Add(file1);
root->Add(dir1);
dir1->Add(file2);
dir1->Add(dir2);
dir2->Add(file3);// 显示整个文件结构
root->ShowName(0);// 移除文件和目录
dir1->Remove(file2); // 从 dir1 中移除 file2
cout << "\nAfter removing file2.txt:\n";
root->ShowName(0) ;
/* 案例输出结果
+root-file1.txt+dir1-file2.txt+dir2-file3.txtAfter removing file2.txt:
+root-file1.txt+dir1+dir2-file3.txt
*/
树形结构是一种广泛应用的数据结构,它在多种场景中都有体现,例如:
- 在操作系统中,文件系统的目录结构就是一个树形结构;
- 在各种软件工具中,菜单的层级关系也构成了一个树形结构;
- 在办公软件中,公司的组织架构,包括公司下的多个部门以及分公司及其部门,形成了一个树形组织结构;
- 在窗口应用程序中,主窗口与其包含的子窗口以及其他控件共同构成了一个树形结构;
- 在编程时,TreeCtrl和TreeViewUI等控件也是树形结构的实例。
组合模式非常适合处理这种树形结构,它允许通过简单的代码实现,例如执行pdir1->ShowName(0);
,就能够遍历整个树形结构,并通过递归调用来一致性地处理树中的所有节点。这里的例子展示了无论节点是树枝(包含其他节点的节点)还是树叶(没有子节点的节点),都可以调用ShowName
成员函数,这就是组合模式一致性处理树形结构的一个体现。
引人组合设计模式的定义:将一组对象(如文件和目录)组织成树形结构以表示“部分-整体”的层次结构(如目录中包含文件和子目录)。使得用户对单个对象(文件)和组合对象(目录)的操作/使用/处理(递归遍历并执行ShowName
逻辑等)具有一致性。
总之,组合模式之所以称为结构型模式,是因为该模式提供了一个结构,可以同时包容单个对象和组合对象。组合模式发挥作用的前提是具体数据必须能以树形结构的方式表示,树中包含了单个对象和组合对象。该模式专注于树形结构中单个对象和组合对象的递归遍历(只有递归遍历才能体现出组合模式的价值),能把相同的操作(FileSystem
定义的接口)应用在单个以及组合对象上,并且可以忽略单个对象和组合对象之间的差别。从模式命名上,笔者认为命名成组合模式其实并不太恰当,命名成树形模式似乎更好。
组合模式的一般包含3种角色。
- 抽象组件(Component):为树枝和树叶定义接口(例如,增加、删除、获取子节点等),可以是抽象类,包含所有子类公共行为的声明或默认实现体。这里指FileSystem类。
- 叶子组件(Leaf):用于表示树叶节点对象,这种对象没有子节点,因此抽象组件中定义的一些接口(例如Add、Remove)实际在这里没有实现的意义。这里指File类。
- 这种叶子组件(类)对于组合模式可能不止一个,例如,若对某个目录进行杀毒,可以在抽象组件中提供
KillVirus
成员函数,类似ShowName
,而后可以定义若干个不同的叶子类,例如定义ExeFile
类并实现KillVirus
专门灭杀可执行文件中的病毒,定义ImgFile
类并实现KillVirus
专门灭杀图像文件中的病毒等。
- 这种叶子组件(类)对于组合模式可能不止一个,例如,若对某个目录进行杀毒,可以在抽象组件中提供
- 树枝组件(Composite):用于表示一个容器(树枝)节点对象,可以包含子节点,子节点可以是树叶,也可以是树枝,其中提供了一个集合用于存储子节点(以此形成一个树形结构,可以通过递归来访问所有节点)。实现了抽象组件中定义的接口。这里指
Dir
类。- Dir类中提供的集合是一个用于存储子节点的list容器,当然用其他容器保存子节点也完全可以。
组合模式结构
三、总结
组合模式的主要优点包括:
- 客户端一致性处理:组合模式允许客户端以相同的方式对待单个对象和组合对象,无需关心它们在层次结构中的位置,从而简化了客户端代码的编写。
- 易于扩展:无论是添加新的叶子组件还是树枝组件,都只需添加一个新的继承自抽象组件的类,这符合开闭原则,即对扩展开放,对修改关闭。
- 灵活的树形结构实现:组合模式为树形结构的面向对象实现提供了一种灵活的方法,通过递归遍历单个对象和组合对象,可以处理复杂的树形结构。
在使用组合模式时,需要注意以下问题:
- 抽象组件的设计:为了使客户端能够一致地使用组件,抽象组件应该定义尽可能多的公共操作,并为这些操作提供默认实现。叶子组件和树枝组件可以根据需要重写这些操作。
- 父节点指针:根据具体业务需求,组件可能需要包含一个指向父节点的指针,这有助于在遍历节点或执行删除操作时更加方便。
- 遍历顺序和管理:在某些场景下,如语法分析树的表示,需要考虑节点的遍历顺序。这可能需要在添加和删除子节点时进行更复杂的管理,可能需要修改相关类的代码。
与遍历顺序相关的,还有子节点的存储问题。在示例中使用的是list这种顺序容器,但也可以根据实际情况选择其他顺序容器。C++标准库还提供了关联容器和无序容器,可以根据使用便利性和访问效率来选择合适的容器。
桥接模式、 状态模式和策略模式 (在某种程度上包括适配器模式) 模式的接口非常相似。 实际上, 它们都基于组合模式——即将工作委派给其他对象, 不过也各自解决了不同的问题。 模式并不只是以特定方式组织代码的配方, 你还可以使用它们来和其他开发者讨论模式所解决的问题。
可以在创建复杂组合树时使用生成器模式, 因为这可使其构造步骤以递归的方式运行。
责任链模式通常和组合模式结合使用。 在这种情况下, 叶组件接收到请求后, 可以将请求沿包含全体父组件的链一直传递至对象树的底部。
可以使用迭代器模式来遍历组合树。也可以使用访问者模式对整个组合树执行操作。当然,使用享元模式实现组合树的共享叶节点以节省内存。组合和装饰模式的结构图很相似, 因为两者都依赖递归组合来组织无限数量的对象。
装饰类似于组合, 但其只有一个子组件。 此外还有一个明显不同: 装饰为被封装对象添加了额外的职责, 组合仅对其子节点的结果进行了 “求和”。但是, 模式也可以相互合作: 你可以使用装饰来扩展组合树中特定对象的行为。
大量使用组合和装饰的设计通常可从对于原型模式的使用中获益。 可以通过该模式来复制复杂结构, 而非从零开始重新构造。