第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)

组合模式的核心逻辑

组合模式结构图 MenuComponent 抽象接口 MenuItem 叶子节点 没有子节点 MenuCategory 容器节点 包含子节点列表 递归包含 客户端 客户端统一调用 display(),无需区分叶子节点和容器节点

避坑指南

我曾经踩过的坑:

  • 递归深度问题:树形结构如果层级太深(比如超过1000层),递归调用可能导致栈溢出。我建议在display等递归方法中考虑迭代实现,或者设置最大深度限制。
  • 共享所有权问题:使用shared_ptr管理子节点时,要小心循环引用。如果子节点持有父节点的指针,记得用weak_ptr。
  • 性能陷阱:每次调用getChildCount()都遍历整个子节点列表?没必要。可以在add/remove时维护一个计数器。

面试官会问什么?

面试中,组合模式经常和以下问题一起出现:

问题 考察点 回答要点
组合模式和装饰器模式的区别? 模式辨析 组合模式强调「部分-整体」层次,装饰器模式强调「动态扩展功能」
安全模式和透明模式的区别? 设计取舍 安全模式在Composite中声明子节点管理方法,透明模式在Component中声明
如何遍历组合树? 实现能力 可以用迭代器模式,或者简单的递归/栈遍历
组合模式违反了哪个设计原则? 深度理解 一定程度上违反了接口隔离原则(ISP),因为叶子节点不需要add/remove

我的建议:

面试时如果被问到组合模式,不要只背定义。拿出一个具体的例子——比如菜单系统、文件系统、组织架构树——然后画出类图,手写核心代码。面试官更看重你能不能把模式用在实际场景中。

另外,记得提一下组合模式的变体:有时候我们不需要严格的树形结构,可以用「组合+迭代器」的组合拳,让遍历更灵活。

总结

组合模式的核心价值在于:让客户端代码与树形结构的复杂性解耦。你不需要关心当前操作的是叶子节点还是容器节点,统一调用接口就行。

实际项目中,我见过很多人在处理树形数据时,写出一堆if (hasChildren)的分支判断。其实用组合模式重构后,代码会清晰很多。嗯,这就是设计模式的意义——不是炫技,而是让代码更可维护。


公众号:蓝海资料掘金营,微信deep3321