你是个将军。
这是个假设。
但请你带入这个角色。
你想发起一场总攻。
你手下有 3 个师。
每个师有 4 个旅。
每个旅有 5 个团。
一直到最底层的列兵。
如果你的代码逻辑是这样的:
void General::attack() {
for (auto& division : divisions) {
for (auto& brigade : division.brigades) {
for (auto& regiment : brigade.regiments) {
// ... 写了五层循环
// 终于找到士兵
soldier.fire();
}
}
}
}
那你这个将军当得太累了。
你不仅要认识师长。
还得认识旅长。
还得知道团长下面是营长。
一旦编制改了。
比如团下面多加了个“特战队”。
你的 attack 方法就得重写。
这不叫指挥。
这叫微操。
真正的指挥是什么?
是你只管下令:“全军出击”。
师长听到命令。
传给旅长:“全旅出击”。
旅长传给团长。
一直传到列兵。
列兵没有下属了。
他听到命令。
就是扣动扳机。
对你来说。
指挥一个师。
和指挥一个兵。
动作应该是一样的。
这就是组合模式 (Composite)。
它想解决的问题只有一个:
如何把“整体”和“部分”,当成同一个东西来用。
1. 经典痛点:总是区别对待,真的很烦
在没有组合模式的代码里。
我们总是有意无意地在搞歧视。
比如文件系统。
文件是文件。
文件夹是文件夹。
你想算总大小。
long calculateSize(Node* node) {
if (node->isFolder()) {
long sum = 0;
for (auto child : node->getChildren()) {
sum += calculateSize(child);
}
return sum;
} else {
return node->getFileSize();
}
}
你看。
你就得先 if 一下。
如果是容器,就遍历。
如果是叶子,就干活。
这只是算大小。
如果你还要“搜索文件”。
还要“复制目录”。
还要“删除只读属性”。
每一个操作。
你都得写一遍这个 if-else 的递归逻辑。
客户端代码变得极度复杂。
它必须知道“我现在手里拿的是个容器,还是个叶子”。
一旦你想加一种新类型。
比如“快捷方式”。
它是叶子吗?
有点像。
但它指向另一个东西。
那你所有用到 if 的地方。
可能都得改。
这就是区别对待带来的维护成本。
2. 抹平差异:大家都是 Component
组合模式的解法非常暴力。
既然“容器”和“叶子”这么难区分。
那就别分了。
大家都签同一份合同。
都叫 Component。
struct Component {
virtual ~Component() = default;
// 核心业务接口
virtual void operation() = 0;
// 树结构管理接口(可选,后面细说)
virtual void add(Component* c) {}
virtual void remove(Component* c) {}
virtual Component* getChild(int i) { return nullptr; }
};
这就好比。
师长是军人。
列兵也是军人。
大家都得服从命令。
叶子节点(列兵)最简单。
它只负责干活。
struct Leaf : Component {
void operation() override {
std::cout << "Leaf: 干活了\n";
}
};
组合节点(师长)稍微复杂点。
它要负责“摇人”。
struct Composite : Component {
void operation() override {
std::cout << "Composite: 兄弟们,跟我上\n";
for (auto c : children_) {
c->operation();
}
}
void add(Component* c) override {
children_.push_back(c);
}
// remove, getChild...
private:
std::vector<Component*> children_;
};
现在。
作为客户端(将军)。
你的代码变成了这样:
void clientCode(Component* c) {
c->operation();
}
没了。
就这一行。
你给我的。
可以是一个兵(Leaf)。
可以是一个团(Composite)。
甚至可以是一整个方面军(嵌套了无数层的 Composite)。
我不在乎。
我调用的接口永远是 operation()。
具体的递归逻辑。
被“多态”这个机制。
分发到了每个对象内部去执行。
这就是递归的自然生长。
你不再需要在外部手写 for 循环。
对象自己知道该怎么把任务分发下去。
3. 一个两难的选择:透明还是安全?
这时候。
细心的同学可能发现了盲点。
在 Component 基类里。
我定义了 add / remove。
但是。
Leaf 节点明明不能加孩子啊。
给一个文件添加子文件。
这不科学。
在 C++ 里。
这确实是个让人纠结的设计权衡。
GoF 原书里提到了两种流派。
流派一:透明性 (Transparency)
就是我上面写的那样。
把 add / remove 放在基类 Component 里。
默认实现可以是抛异常,或者是空操作。
优点:
客户端完全不需要知道它是 Leaf 还是 Composite。
拿过来就能调 add。
(虽然可能没效果)。
真正做到了“视万物为一”。
缺点: 在编译期。 你没法阻止别人给 Leaf 加孩子。 只能在运行期报错。 这不太“类型安全”。
流派二:安全性 (Safety)
只在 Component 里放 operation。
把 add / remove 移到 Composite 子类里去。
优点: 编译期就能卡住。 想给 Leaf 加孩子? 编译器直接打你手板。 绝对安全。
缺点: 客户端得转型。 或者得先判断。 “诶,你是不是 Composite 啊?是的话我才给你加孩子”。 这就又回到了“区别对待”的老路。
怎么选?
在现代 C++ 工程里。
如果不追求极致的动态性。
我们通常倾向于安全性。
或者用一种折中的办法:
在基类留一个 virtual bool isComposite() { return false; }。
或者利用 dynamic_cast。
但在大多数只读遍历的场景下(比如渲染 UI、计算大小)。
根本不需要调 add。
那时候。
组合模式的威力是最大的。
因为你真的不需要关心结构。
只管调 draw() 就行。
4. 实战场景:不只是树,还有宏命令
除了文件系统、UI 控件树这些显而易见的树形结构。
组合模式还有一个非常酷的应用。
宏命令 (Macro Command)。
我们在写“命令模式”时。
通常会有各种具体的 Command。
比如 CopyCommand、PasteCommand。
如果我想搞一个“一键三连”呢?
先复制,再粘贴,再保存。
你不需要重新写一个复杂的 OneKeyThreeComboCommand 类。
你只需要用组合模式。
搞一个 MacroCommand。
它里面存着一个 vector<Command*>。
它的 execute()。
就是遍历执行里面所有命令的 execute()。
struct MacroCommand : Command {
void add(Command* c) { cmds.push_back(c); }
void execute() override {
for (auto c : cmds) {
c->execute();
}
}
std::vector<Command*> cmds;
};
对调用者来说。
按下一个按钮。
触发一个命令。
至于这个命令背后是干了一件事。
还是干了一串事。
按钮不关心。
它只管 execute()。
这就是组合模式的魅力。
它把**“复杂性”隐藏在了“结构”**里。
而不是暴露在**“接口”**上。
5. 总结:让递归消失在多态里
组合模式教给我们的。
其实不仅仅是处理树形结构。
而是一种**“去特权化”**的思维。
在你的系统里。
是不是有些对象被当成了“容器”。
有些对象被当成了“内容”。
你为了管理它们。
写了一堆“如果是容器就遍历,如果是内容就处理”的代码。
如果有。
试试组合模式。
把它们拉平。
给它们穿上同样的制服。
当你发现。
你指挥千军万马的代码。
简单得像是在指挥一个人的时候。
你就懂了。
什么叫。
以一当十。