14、组合模式:树形结构、递归遍历、统一接口
组合模式,说白了就是处理「树形结构」的一把利器。我最早接触它是在做嵌入式文件系统的时候,目录和文件的关系,天然就是一棵树。你想想看,一个目录下面可以挂文件,也可以挂子目录,子目录下面还能再挂东西——这种「部分-整体」的层次结构,用组合模式来建模,再合适不过了。
什么是组合模式?
组合模式的核心思想,就是让「单个对象」和「组合对象」拥有统一的接口。客户端不需要关心自己操作的是一个叶子节点,还是一个容器节点。你调用同一个方法,叶子节点做自己的事,容器节点则递归地把调用转发给它的子节点。
嗯,这里要注意:组合模式不是用来「优化性能」的,它是用来「简化客户端代码」的。我见过不少新手一上来就想用组合模式解决所有树形问题,结果反而把代码搞复杂了。记住,组合模式解决的是「透明性」问题——让调用方不用区分叶子与容器。
- 抽象组件(Component):定义叶子节点和容器节点的统一接口
- 叶子节点(Leaf):没有子节点的具体实现
- 容器节点(Composite):持有子节点列表,负责递归遍历
嵌入式场景中的组合模式
我在项目中遇到过最典型的例子,是设备树(Device Tree)的解析器。设备树本身就是一棵树:根节点下有各种外设节点,外设节点下又有子节点,比如I2C总线节点下面挂着多个从设备节点。每个节点都需要支持「显示信息」「获取属性」「遍历子节点」等操作。
如果用组合模式来建模,代码会非常干净。抽象组件定义一个 show_info() 接口,叶子节点直接打印自己的信息,容器节点先打印自己的信息,再遍历所有子节点调用 show_info()。你看,递归就藏在这里面。
代码示例:嵌入式菜单系统
我拿一个嵌入式菜单系统来举例。菜单项有两种:一种是具体的功能项(叶子节点),比如「设置亮度」;另一种是子菜单(容器节点),比如「系统设置」下面可以挂多个功能项。
// 抽象组件
typedef struct MenuItem {
char name[32];
void (*execute)(struct MenuItem *self);
void (*add_child)(struct MenuItem *self, struct MenuItem *child);
struct MenuItem **children;
int child_count;
} MenuItem;
// 叶子节点:具体功能
void execute_leaf(MenuItem *self) {
printf("执行功能: %s\n", self->name);
}
void add_child_leaf(MenuItem *self, MenuItem *child) {
// 叶子节点不支持添加子节点
printf("错误:%s 不支持子菜单\n", self->name);
}
// 容器节点:子菜单
void execute_composite(MenuItem *self) {
printf("进入菜单: %s\n", self->name);
for (int i = 0; i < self->child_count; i++) {
self->children[i]->execute(self->children[i]);
}
}
void add_child_composite(MenuItem *self, MenuItem *child) {
self->children = realloc(self->children,
(self->child_count + 1) * sizeof(MenuItem*));
self->children[self->child_count] = child;
self->child_count++;
}
// 工厂函数
MenuItem* create_menu(const char *name, int is_composite) {
MenuItem *item = malloc(sizeof(MenuItem));
strncpy(item->name, name, sizeof(item->name) - 1);
item->children = NULL;
item->child_count = 0;
if (is_composite) {
item->execute = execute_composite;
item->add_child = add_child_composite;
} else {
item->execute = execute_leaf;
item->add_child = add_child_leaf;
}
return item;
}
// 使用示例
int main() {
MenuItem *root = create_menu("主菜单", 1);
MenuItem *settings = create_menu("系统设置", 1);
MenuItem *brightness = create_menu("设置亮度", 0);
MenuItem *volume = create_menu("设置音量", 0);
root->add_child(root, settings);
settings->add_child(settings, brightness);
settings->add_child(settings, volume);
root->execute(root); // 递归遍历整个菜单树
return 0;
}
组合模式的变体:安全式 vs 透明式
组合模式有两种实现风格,我分别说说它们的适用场景。
| 风格 | 特点 | 适用场景 |
|---|---|---|
| 透明式 | 叶子节点和容器节点接口完全一致,叶子节点也有 add_child 方法(但会报错或空实现) | 客户端代码极简,不需要做类型判断 |
| 安全式 | 叶子节点不提供 add_child 方法,容器节点单独定义管理子节点的接口 | 需要编译期检查,避免误调用 |
我个人习惯用透明式。为什么?因为嵌入式系统的客户端代码往往写在中断上下文或者时间关键路径上,我不想在调用方加一堆 if (is_composite) 的判断。叶子节点的 add_child 返回错误码或者直接断言,调试阶段就能发现问题,不会留到线上。
递归遍历的陷阱
我曾经在一个传感器数据聚合的项目里吃过亏。当时用组合模式管理多个传感器节点,每个节点采集数据后要向上汇报。递归遍历本身没问题,但传感器节点数量一多(超过100个),栈空间就爆了。
- 如果树深度可控(比如菜单系统通常不超过5层),放心用递归
- 如果深度不可控(比如文件系统),改用迭代遍历,自己维护一个栈
- 实在要用递归,在函数入口加一个深度计数器,超过阈值直接返回错误
组合模式与状态机的结合
嗯,这里分享一个我最近在做的项目。一个智能家居控制器,它的配置界面是树形菜单,但每个菜单项的执行逻辑又涉及状态机。比如「设置温度」这个叶子节点,执行时不是简单打印一句话,而是要进入一个温度设置的状态机流程。
我的做法是:组合模式负责菜单的遍历和展示,每个叶子节点内部持有一个状态机指针。当 execute() 被调用时,叶子节点把控制权交给状态机。这样就把「树形结构」和「状态流转」解耦了,代码非常清晰。
SVG 结构图:组合模式的核心逻辑
什么时候不该用组合模式?
组合模式不是银弹。我见过有人把线性结构硬套成树形,就为了用组合模式,结果代码变得又臭又长。以下几种情况,我建议你绕道走:
- 树结构几乎不变:如果节点类型固定,没有递归嵌套的需求,用简单的数组或链表就够了
- 性能敏感且递归深度大:每次遍历都要递归调用,函数调用开销不可忽视
- 节点类型差异极大:如果叶子节点和容器节点的行为完全不同,强行统一接口会导致大量空实现或异常分支
总结
组合模式的价值,在于用递归把复杂性藏起来。客户端只需要对着抽象组件编程,剩下的遍历、聚合、分发都由容器节点自己搞定。在嵌入式世界里,菜单系统、设备树、传感器网络、GUI控件树,都是组合模式的天然舞台。
我个人觉得,掌握组合模式的关键不是记住UML图,而是理解「统一接口 + 递归遍历」这个组合拳。你写一遍菜单系统的代码,跑起来看看调用栈,比看十遍理论都管用。