第45章:设计模式面试题10:实现一个组合模式,构建树形菜单系统
组合模式,说白了就是处理「树形结构」的利器。你想想看,菜单系统天然就是一棵树——菜单项下面有子菜单,子菜单下面还有子菜单。如果不用设计模式,你可能会写出满屏的if-else来判断「这个节点有没有孩子」。嗯,组合模式就是来解决这个问题的。
什么是组合模式?
组合模式允许你将对象组合成树形结构,并且让客户端可以统一地对待单个对象和组合对象。核心思想就一句话:让叶子节点和容器节点拥有相同的接口。
我个人习惯把组合模式拆成三个角色来看:
- 组件(Component):抽象接口,声明了所有节点共有的操作
- 叶子(Leaf):没有子节点的具体节点
- 容器(Composite):可以包含子节点的节点
我在项目中遇到过这样一个场景:一个权限管理系统,菜单树需要支持「显示」「隐藏」「统计子节点数」「递归查找」等操作。如果每个层级都写不同的处理逻辑,代码会变得非常臃肿。组合模式让这一切变得优雅。
树形菜单系统的实现
我们先定义菜单组件的抽象接口:
// 菜单组件抽象基类
class MenuComponent {
public:
virtual ~MenuComponent() = default;
// 公共接口
virtual std::string getName() const = 0;
virtual void display(int indent = 0) const = 0;
// 容器节点才需要实现的方法
virtual void add(std::shared_ptr<MenuComponent> component) {
throw std::runtime_error("不支持的操作:叶子节点不能添加子节点");
}
virtual void remove(std::shared_ptr<MenuComponent> component) {
throw std::runtime_error("不支持的操作:叶子节点不能移除子节点");
}
virtual std::shared_ptr<MenuComponent> getChild(int index) {
throw std::runtime_error("不支持的操作:叶子节点没有子节点");
}
virtual int getChildCount() const {
return 0;
}
};
这里有个设计上的取舍:到底该不该在基类中提供add/remove的默认实现?
我曾经在这个问题上纠结过。一种做法是把add/remove放到Composite里,叶子节点不暴露这些方法。但这样客户端就需要做类型判断——这恰恰是组合模式想要避免的。所以我选择了「安全组合模式」,在基类中提供默认实现,叶子节点调用时会抛出异常。这样客户端可以统一调用,但需要做好异常处理。
叶子节点:菜单项
// 叶子节点:具体的菜单项
class MenuItem : public MenuComponent {
private:
std::string name;
double price; // 菜单项价格,演示用
public:
MenuItem(const std::string& name, double price)
: name(name), price(price) {}
std::string getName() const override {
return name;
}
void display(int indent = 0) const override {
for (int i = 0; i < indent; ++i) std::cout << " ";
std::cout << "- " << name << " ($" << price << ")" << std::endl;
}
};
容器节点:菜单分类
// 容器节点:菜单分类(可以包含子菜单或菜单项)
class MenuCategory : public MenuComponent {
private:
std::string name;
std::vector<std::shared_ptr<MenuComponent>> children;
public:
MenuCategory(const std::string& name) : name(name) {}
std::string getName() const override {
return name;
}
void add(std::shared_ptr<MenuComponent> component) override {
children.push_back(component);
}
void remove(std::shared_ptr<MenuComponent> component) override {
auto it = std::find(children.begin(), children.end(), component);
if (it != children.end()) {
children.erase(it);
}
}
std::shared_ptr<MenuComponent> getChild(int index) override {
if (index < 0 || index >= children.size()) {
throw std::out_of_range("索引越界");
}
return children[index];
}
int getChildCount() const override {
return children.size();
}
void display(int indent = 0) const override {
for (int i = 0; i < indent; ++i) std::cout << " ";
std::cout << "+ " << name << " (" << children.size() << " 项)" << std::endl;
for (const auto& child : children) {
child->display(indent + 1);
}
}
};
客户端使用示例
int main() {
// 构建根菜单
auto root = std::make_shared<MenuCategory>("主菜单");
// 添加子分类
auto appetizers = std::make_shared<MenuCategory>("开胃菜");
appetizers->add(std::make_shared<MenuItem>("凯撒沙拉", 8.99));
appetizers->add(std::make_shared<MenuItem>("蒜香面包", 5.99));
auto mainCourse = std::make_shared<MenuCategory>("主菜");
mainCourse->add(std::make_shared<MenuItem>("牛排", 25.99));
mainCourse->add(std::make_shared<MenuItem>("烤鸡", 18.99));
auto desserts = std::make_shared<MenuCategory>("甜点");
desserts->add(std::make_shared<MenuItem>("巧克力蛋糕", 7.99));
desserts->add(std::make_shared<MenuItem>("冰淇淋", 4.99));
// 组装树
root->add(appetizers);
root->add(mainCourse);
root->add(desserts);
// 统一调用display,无需关心节点类型
root->display();
return 0;
}
输出结果:
+ 主菜单 (3 项)
+ 开胃菜 (2 项)
- 凯撒沙拉 ($8.99)
- 蒜香面包 ($5.99)
+ 主菜 (2 项)
- 牛排 ($25.99)
- 烤鸡 ($18.99)
+ 甜点 (2 项)
- 巧克力蛋糕 ($7.99)
- 冰淇淋 ($4.99)
组合模式的核心逻辑
避坑指南
我曾经踩过的坑:
- 递归深度问题:树形结构如果层级太深(比如超过1000层),递归调用可能导致栈溢出。我建议在display等递归方法中考虑迭代实现,或者设置最大深度限制。
- 共享所有权问题:使用shared_ptr管理子节点时,要小心循环引用。如果子节点持有父节点的指针,记得用weak_ptr。
- 性能陷阱:每次调用getChildCount()都遍历整个子节点列表?没必要。可以在add/remove时维护一个计数器。
面试官会问什么?
面试中,组合模式经常和以下问题一起出现:
| 问题 | 考察点 | 回答要点 |
|---|---|---|
| 组合模式和装饰器模式的区别? | 模式辨析 | 组合模式强调「部分-整体」层次,装饰器模式强调「动态扩展功能」 |
| 安全模式和透明模式的区别? | 设计取舍 | 安全模式在Composite中声明子节点管理方法,透明模式在Component中声明 |
| 如何遍历组合树? | 实现能力 | 可以用迭代器模式,或者简单的递归/栈遍历 |
| 组合模式违反了哪个设计原则? | 深度理解 | 一定程度上违反了接口隔离原则(ISP),因为叶子节点不需要add/remove |
我的建议:
面试时如果被问到组合模式,不要只背定义。拿出一个具体的例子——比如菜单系统、文件系统、组织架构树——然后画出类图,手写核心代码。面试官更看重你能不能把模式用在实际场景中。
另外,记得提一下组合模式的变体:有时候我们不需要严格的树形结构,可以用「组合+迭代器」的组合拳,让遍历更灵活。
总结
组合模式的核心价值在于:让客户端代码与树形结构的复杂性解耦。你不需要关心当前操作的是叶子节点还是容器节点,统一调用接口就行。
实际项目中,我见过很多人在处理树形数据时,写出一堆if (hasChildren)的分支判断。其实用组合模式重构后,代码会清晰很多。嗯,这就是设计模式的意义——不是炫技,而是让代码更可维护。
公众号:蓝海资料掘金营,微信deep3321