80、STL与组合模式:树形结构、组合节点、叶子节点
组合模式,说白了就是处理「部分-整体」关系的一种设计套路。你想想看,文件系统里一个文件夹可以包含文件,也可以包含子文件夹——这种树形结构在代码里怎么优雅地表示?我早年做图形编辑器时,一个画布上有基本图形(圆、矩形),也有组合图形(由多个基本图形拼成的复杂图形)。如果对每个类型都写一套操作逻辑,代码会膨胀到没法看。组合模式就是来解决这个问题的。
组合模式的核心思想
组合模式让客户端可以统一对待单个对象和组合对象。什么意思呢?就是叶子节点(Leaf)和组合节点(Composite)实现同一个接口,调用者不用关心自己操作的是单个元素还是一堆元素。
举个例子:
- 叶子节点:没有子节点,是树的最底层。比如文件系统中的单个文件。
- 组合节点:可以包含叶子节点,也可以包含其他组合节点。比如文件夹。
- 统一接口:两者都提供相同的操作,比如
display()、getSize()。
我在项目中遇到过这样一个场景:一个UI框架里的控件树。按钮是叶子,面板是组合节点,面板里可以放按钮,也可以放其他面板。渲染整个界面时,只需要递归调用每个节点的 draw() 方法就行——完全不用区分节点类型。
STL 如何助力组合模式
STL 的容器和算法,天然适合实现组合模式。尤其是 std::vector 和 std::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::stack 或 std::queue 配合迭代器模式实现深度优先或广度优先遍历。STL 的算法库在这里帮不上太多忙,但容器和智能指针的组合已经足够强大。
组合模式的结构图
下面这张图展示了组合模式的核心结构:
避坑指南
我曾经在实现组合模式时踩过一个坑:叶子节点和组合节点的接口设计不一致。比如叶子节点的 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