80、STL与组合模式:树形结构、组合节点、叶子节点

组合模式,说白了就是处理「部分-整体」关系的一种设计套路。你想想看,文件系统里一个文件夹可以包含文件,也可以包含子文件夹——这种树形结构在代码里怎么优雅地表示?我早年做图形编辑器时,一个画布上有基本图形(圆、矩形),也有组合图形(由多个基本图形拼成的复杂图形)。如果对每个类型都写一套操作逻辑,代码会膨胀到没法看。组合模式就是来解决这个问题的。

组合模式的核心思想

组合模式让客户端可以统一对待单个对象和组合对象。什么意思呢?就是叶子节点(Leaf)和组合节点(Composite)实现同一个接口,调用者不用关心自己操作的是单个元素还是一堆元素。

举个例子:

  • 叶子节点:没有子节点,是树的最底层。比如文件系统中的单个文件。
  • 组合节点:可以包含叶子节点,也可以包含其他组合节点。比如文件夹。
  • 统一接口:两者都提供相同的操作,比如 display()getSize()

我在项目中遇到过这样一个场景:一个UI框架里的控件树。按钮是叶子,面板是组合节点,面板里可以放按钮,也可以放其他面板。渲染整个界面时,只需要递归调用每个节点的 draw() 方法就行——完全不用区分节点类型。

STL 如何助力组合模式

STL 的容器和算法,天然适合实现组合模式。尤其是 std::vectorstd::shared_ptr,配合起来能写出既安全又灵活的树形结构。

我个人习惯用 std::vector<std::shared_ptr<Node>> 来存储子节点。为什么用 shared_ptr?因为树形结构里,一个子节点可能被多个父节点引用(虽然组合模式里通常是一对多,但用 shared_ptr 可以避免手动管理内存的麻烦)。

关键点:组合模式的核心是「递归组合」。STL 的容器和智能指针让这种递归变得异常简洁。

代码实现:文件系统示例

我们来实现一个简单的文件系统。先定义抽象基类:

#include <iostream>
#include <vector>
#include <memory>
#include <string>
#include <algorithm>

// 抽象组件
class FileSystemNode {
public:
    virtual ~FileSystemNode() = default;
    virtual void display(int depth = 0) const = 0;
    virtual size_t getSize() const = 0;
    virtual void add(std::shared_ptr<FileSystemNode> child) {
        // 叶子节点默认不支持添加
        throw std::runtime_error("Leaf node does not support add");
    }
};

然后实现叶子节点——文件:

class File : public FileSystemNode {
    std::string name_;
    size_t size_;
public:
    File(const std::string& name, size_t size) 
        : name_(name), size_(size) {}

    void display(int depth = 0) const override {
        std::cout << std::string(depth * 2, ' ') 
                  << "📄 " << name_ 
                  << " (" << size_ << " bytes)" << std::endl;
    }

    size_t getSize() const override {
        return size_;
    }
};

再实现组合节点——文件夹:

class Directory : public FileSystemNode {
    std::string name_;
    std::vector<std::shared_ptr<FileSystemNode>> children_;
public:
    Directory(const std::string& name) : name_(name) {}

    void add(std::shared_ptr<FileSystemNode> child) override {
        children_.push_back(std::move(child));
    }

    void display(int depth = 0) const override {
        std::cout << std::string(depth * 2, ' ') 
                  << "📁 " << name_ << "/" << std::endl;
        for (const auto& child : children_) {
            child->display(depth + 1);
        }
    }

    size_t getSize() const override {
        size_t total = 0;
        for (const auto& child : children_) {
            total += child->getSize();
        }
        return total;
    }
};

使用起来非常直观:

int main() {
    auto root = std::make_shared<Directory>("root");
    auto docs = std::make_shared<Directory>("docs");
    auto pics = std::make_shared<Directory>("pics");

    docs->add(std::make_shared<File>("readme.txt", 1024));
    docs->add(std::make_shared<File>("notes.md", 2048));
    pics->add(std::make_shared<File>("photo.jpg", 512000));

    root->add(docs);
    root->add(pics);
    root->add(std::make_shared<File>("config.json", 256));

    root->display();
    std::cout << "Total size: " << root->getSize() << " bytes" << std::endl;

    return 0;
}

输出结果:

📁 root/
  📁 docs/
    📄 readme.txt (1024 bytes)
    📄 notes.md (2048 bytes)
  📁 pics/
    📄 photo.jpg (512000 bytes)
  📄 config.json (256 bytes)
Total size: 515328 bytes

小技巧:如果你需要频繁遍历树,可以用 std::stackstd::queue 配合迭代器模式实现深度优先或广度优先遍历。STL 的算法库在这里帮不上太多忙,但容器和智能指针的组合已经足够强大。

组合模式的结构图

下面这张图展示了组合模式的核心结构:

FileSystemNode(抽象组件) +display() +getSize() +add() File(叶子节点) -name: string -size: size_t Directory(组合节点) -name: string -children: vector<shared_ptr> 继承 继承 包含 包含 组合模式结构 客户端通过统一接口操作叶子节点和组合节点 组合节点内部递归调用子节点的相同接口 叶子节点 组合节点 抽象组件

避坑指南

我曾经在实现组合模式时踩过一个坑:叶子节点和组合节点的接口设计不一致。比如叶子节点的 add() 方法直接抛异常,但调用者如果不小心调用了,程序就崩了。后来我改用编译期约束——用 static_assert 或者模板特化来区分叶子节点和组合节点。不过话说回来,运行时抛异常也是一种合理的设计,关键是要在文档里写清楚。

注意:组合模式虽然灵活,但不要滥用。如果你的树只有两层(根节点直接挂叶子),用组合模式反而显得臃肿。我建议在树的深度可能超过3层时再考虑使用。

性能与内存管理

std::shared_ptr 管理子节点时,要注意循环引用的问题。组合模式里父节点持有子节点的 shared_ptr,如果子节点也持有父节点的 shared_ptr,就会形成循环引用,导致内存泄漏。我的做法是:父节点用 shared_ptr,子节点如果需要反向引用父节点,用 weak_ptr

场景 推荐做法 原因
父节点持有子节点 shared_ptr 所有权明确,自动释放
子节点引用父节点 weak_ptr 避免循环引用
临时遍历 原始指针或引用 性能最优,不涉及引用计数

另外,如果你对性能有极致要求,可以考虑用 std::unique_ptr 替代 shared_ptr。组合模式里每个子节点只有一个父节点,用 unique_ptr 完全够用,而且开销更小。我个人的经验是:除非你需要共享所有权(比如一个文件被多个文件夹引用),否则优先用 unique_ptr

总结

组合模式加上 STL,就像螺丝刀配上了合适的螺丝头——干活特别顺手。核心就三点:统一接口、递归组合、STL容器加持。你想想看,如果没有 std::vector 和智能指针,手动管理子节点列表和内存得有多痛苦?

嗯,这一讲就到这里。代码示例可以直接拿去用,稍微改改就能适配你自己的场景。记住:组合模式不是为了炫技,而是为了让代码更清晰、更易扩展。


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