13_组合模式 Composite Pattern
1. 基本信息
中文名称: 组合模式
英文名称: Composite Pattern
模式类型: 结构型设计模式
Rust 中常见实现方式: enum 递归结构、Box、Vec、trait object、Rc<RefCell<T>>
组合模式的核心是:将对象组织成树形结构,使调用方可以用统一方式处理单个对象和组合对象。
在 Rust 中,组合模式最常见的实现方式是:
1. enum + Box / Vec 表达树形结构
2. trait + Box<dyn Trait> 表达统一接口
3. Rc / RefCell 处理共享和可变树结构
2. 模式核心思想
组合模式的核心思想是:
把“单个对象”和“由多个对象组成的复合对象”统一看待。
也就是说,调用方不需要区分:
这是一个单独节点?
还是一个包含多个子节点的节点?
它只需要通过统一接口进行操作。
例如文件系统中:
文件:单个对象
文件夹:组合对象,里面可以包含文件和其他文件夹
但是调用方希望统一调用:
计算大小
打印名称
遍历结构
删除节点
搜索节点
组合模式就是用来处理这种“整体—部分”关系的。
3. 这个模式解决什么问题
组合模式主要解决的是:树形结构中,如何统一处理叶子节点和组合节点。
在实际开发中,经常会遇到层级结构:
1. 文件系统:文件和文件夹
2. UI 界面:按钮、文本框、面板、窗口
3. 组织结构:员工、部门、公司
4. 表达式树:数字、加法、乘法
5. 文档结构:段落、章节、整篇文档
6. 菜单系统:菜单项、子菜单
这些结构共同特点是:
一个对象可以是单独元素;
也可以是包含多个元素的容器;
容器里面还可以继续包含容器。
如果没有组合模式,调用方可能要反复判断:
如果是文件,就直接处理;
如果是文件夹,就递归处理里面的内容;
如果是子文件夹,还要继续递归。
组合模式的目标是:让叶子对象和组合对象对外表现出一致的接口。
4. 不使用这个模式会怎样
如果不使用组合模式,可能出现以下问题:
1. 调用方需要区分叶子节点和组合节点。
2. 树形结构的递归处理逻辑分散在多个地方。
3. 新增节点类型时,调用方需要修改很多判断逻辑。
4. 整体和部分的接口不统一。
5. 树结构越复杂,代码越难维护。
例如文件系统中,如果没有统一抽象,可能会写很多类似逻辑:
如果是文件,打印文件名;
如果是目录,打印目录名,然后遍历子节点;
如果子节点还是目录,继续递归。
这些逻辑如果散落在多个函数中,后续维护会很麻烦。
组合模式的作用就是:把层级结构本身组织起来,并让调用方用统一方式处理它。
5. 传统面向对象中的实现思路
在传统 OOP 中,组合模式通常包含三个角色:
1. Component:抽象组件,定义叶子和组合对象的统一接口。
2. Leaf:叶子节点,表示没有子节点的对象。
3. Composite:组合节点,内部包含多个 Component。
典型结构是:
Component
├── operation()
└── add/remove/get_child()
Leaf
└── operation()
Composite
├── children: Vec<Component>
└── operation() { 遍历 children 并调用 operation() }
例如文件系统:
FileSystemNode
├── size()
└── print()
File
└── size()
Directory
├── children
└── size() = 子节点 size 总和
调用方只依赖 Component 接口,不需要关心当前对象到底是叶子还是组合对象。
6. Rust 中的实现思路
6.1 Rust 中通常怎么实现
Rust 中组合模式有两种常见写法。
第一种是 enum + 递归结构。 如果节点类型是固定的,这种方式最自然。
enum FileNode {
File {
name: String,
size: u64,
},
Directory {
name: String,
children: Vec<FileNode>,
},
}
impl FileNode {
fn total_size(&self) -> u64 {
match self {
FileNode::File { size, .. } => *size,
FileNode::Directory { children, .. } => {
children.iter().map(|child| child.total_size()).sum()
}
}
}
fn print(&self, depth: usize) {
let indent = " ".repeat(depth);
match self {
FileNode::File { name, size } => {
println!("{}文件: {} ({} bytes)", indent, name, size);
}
FileNode::Directory { name, children } => {
println!("{}目录: {}", indent, name);
for child in children {
child.print(depth + 1);
}
}
}
}
}
fn main() {
let root = FileNode::Directory {
name: "root".to_string(),
children: vec![
FileNode::File {
name: "a.txt".to_string(),
size: 100,
},
FileNode::Directory {
name: "src".to_string(),
children: vec![
FileNode::File {
name: "main.rs".to_string(),
size: 300,
},
],
},
],
};
root.print(0);
println!("总大小: {}", root.total_size());
}
这个例子中:
File 是叶子节点。
Directory 是组合节点。
FileNode 统一表达两者。
调用方只需要调用:
print()
total_size()
不需要自己在外部区分文件和目录。
第二种是 trait + Box<dyn Trait>。
如果节点类型需要动态扩展,可以用 trait object。
#![allow(unused)]
fn main() {
trait Component {
fn name(&self) -> &str;
fn size(&self) -> u64;
fn print(&self, depth: usize);
}
struct File {
name: String,
size: u64,
}
impl Component for File {
fn name(&self) -> &str {
&self.name
}
fn size(&self) -> u64 {
self.size
}
fn print(&self, depth: usize) {
let indent = " ".repeat(depth);
println!("{}文件: {} ({} bytes)", indent, self.name, self.size);
}
}
struct Directory {
name: String,
children: Vec<Box<dyn Component>>,
}
impl Component for Directory {
fn name(&self) -> &str {
&self.name
}
fn size(&self) -> u64 {
self.children.iter().map(|child| child.size()).sum()
}
fn print(&self, depth: usize) {
let indent = " ".repeat(depth);
println!("{}目录: {}", indent, self.name);
for child in &self.children {
child.print(depth + 1);
}
}
}
}
这种写法更接近传统组合模式:
Component trait 是统一接口。
File 是 Leaf。
Directory 是 Composite。
6.2 和传统 OOP 写法相比有什么不同
和传统面向对象语言相比,Rust 的组合模式有几个明显特点。
第一,Rust 可以直接用 enum 表达固定类型的树。
传统 OOP 中通常需要抽象类或接口;Rust 中如果节点类型有限,用 enum 更直接。
第二,Rust 的 match 可以清楚地区分不同节点。
例如文件和目录可以作为不同枚举变体,编译器会检查是否处理完整。
第三,递归结构通常需要 Box 或 Vec。
因为 Rust 需要在编译期知道类型大小,如果一个类型直接包含自己,会导致无限大小。
所以树形结构通常写成:
Box<Node>
Vec<Node>
Vec<Box<dyn Trait>>
第四,Rust 中共享可变树结构需要更谨慎。 如果一个节点可能被多个地方引用,并且还需要修改,就可能涉及:
Rc<RefCell<T>>
Arc<Mutex<T>>
第五,Rust 不鼓励为了模拟继承而过度使用 trait object。
如果节点类型固定,enum + match 往往更简单、更符合 Rust 风格。
6.3 Rust 中是否有更自然的替代写法
在 Rust 中选择组合模式写法时,可以这样判断。
如果节点类型固定,例如只有文件和目录:
优先使用 enum + Vec
如果节点类型可能扩展,例如 UI 组件有很多种,并且未来不断新增:
可以使用 trait + Box<dyn Trait>
如果树结构只读:
普通 Box / Vec 就够了
如果树节点需要共享所有权:
Rc<T> 或 Arc<T>
如果树节点既要共享又要修改:
Rc<RefCell<T>> 或 Arc<Mutex<T>>
所以 Rust 中组合模式不一定要照搬 OOP 的 Component / Leaf / Composite 类结构。 更自然的思路是:
先判断节点类型是否固定;
再决定用 enum 还是 trait;
最后根据所有权关系决定 Box、Rc、Arc、RefCell 或 Mutex。
7. Rust 中涉及的语言特性
组合模式在 Rust 中主要涉及以下语言特性:
1. enum
2. match
3. struct
4. trait
5. Box
6. Vec
7. 递归数据结构
8. Rc
9. RefCell
10. Arc
11. Mutex
12. 所有权和借用
这些特性分别解决的问题是:
enum:表达有限节点类型
match:根据节点类型执行不同逻辑
trait:定义统一组件接口
Box:处理递归结构或动态分发
Vec:保存多个子节点
Rc / Arc:多个地方共享节点
RefCell / Mutex:在共享情况下修改节点
所有权:管理树中节点归属关系
其中最核心的是:
enum + Vec
trait + Box<dyn Trait>
递归结构
8. 性能与工程代价
组合模式的性能主要取决于树结构的表示方式。
如果使用 enum + Vec:
1. 不需要动态分发。
2. 节点结构清晰。
3. 编译器容易优化。
4. 适合节点类型固定的树结构。
如果使用 Box<dyn Trait>:
1. 支持不同类型节点放在同一个集合中。
2. 有动态分发开销。
3. 通常涉及堆分配。
4. 扩展性更强,但性能略低。
如果使用 Rc<RefCell<T>>:
1. 支持共享和内部可变性。
2. 会有引用计数成本。
3. RefCell 的借用检查发生在运行时。
4. 设计不当可能导致运行时 borrow panic。
如果使用 Arc<Mutex<T>>:
1. 支持多线程共享。
2. 有原子引用计数和锁开销。
3. 可能出现锁竞争。
4. 结构更复杂。
工程代价方面,组合模式会引入递归结构和层级遍历逻辑。
需要注意:
树的深度是否可能过深?
是否需要避免递归栈溢出?
节点是否需要父指针?
是否存在循环引用?
是否需要修改树结构?
一句话总结:
组合模式适合表达树形结构,但在 Rust 中需要认真设计所有权、共享和可变性。
9. 典型应用场景
组合模式适合用于整体和部分具有层级关系的场景。
常见应用包括:
1. 文件系统:文件和目录
2. UI 组件树:按钮、面板、窗口
3. 文档结构:章节、段落、文本
4. 组织结构:员工、部门、公司
5. 菜单系统:菜单项和子菜单
6. 表达式树:数字、运算符、表达式
7. JSON / XML 树结构
8. 游戏场景树
9. 编译器 AST
10. 权限树或分类树
在 Rust 项目中,组合模式可能出现在:
编译器或解释器
命令行工具的命令树
配置文件解析
文件扫描工具
UI 框架
规则引擎
文档转换工具
例如 Rust 中表达 AST 时:
Expr::Number
Expr::Add
Expr::Mul
Expr::Call
本质上就是组合模式和解释器模式的结合。
10. 和相似模式的区别
组合模式容易和解释器模式、访问者模式、迭代器模式、装饰器模式混淆。
10.1 组合模式和解释器模式
二者都可能使用树形结构,但关注点不同。
组合模式:关注如何把对象组织成整体—部分结构。
解释器模式:关注如何解释和执行这个树形结构。
例如:
文件夹包含文件和子文件夹:组合模式
数学表达式树递归求值:解释器模式
解释器模式经常会用到组合结构,但它的重点不是“组织结构”,而是“解释执行”。
10.2 组合模式和访问者模式
组合模式关注结构,访问者模式关注操作。
组合模式:如何构造树。
访问者模式:如何遍历树并对不同节点执行操作。
例如:
UI 组件树:组合模式
对 UI 树统计组件数量或生成布局:访问者模式
一个系统中可以同时使用组合模式和访问者模式。
10.3 组合模式和迭代器模式
组合模式关注层级结构。
迭代器模式关注遍历方式。
组合模式:树里有哪些节点,节点之间如何包含。
迭代器模式:如何按顺序访问这些节点。
例如:
目录树本身是组合模式。
深度优先遍历目录树可以用迭代器模式。
10.4 组合模式和装饰器模式
二者都可能表现为“对象包含对象”,但目的不同。
组合模式:表示整体和部分的层级关系。
装饰器模式:通过包装对象增强功能。
例如:
窗口中包含按钮、文本框、面板:组合模式
给按钮增加边框、阴影、日志:装饰器模式
组合模式强调“包含关系”,装饰器模式强调“功能增强”。
11. 使用该模式的优点
组合模式的优点主要有:
1. 可以清晰表达树形层级结构。
2. 统一处理叶子节点和组合节点。
3. 调用方不需要频繁区分单个对象和组合对象。
4. 适合递归处理整体—部分关系。
5. 易于扩展复杂层级结构。
6. 在 Rust 中可以通过 enum 获得良好的类型安全。
从工程角度看,组合模式的价值在于:
把层级结构显式表达出来,
并让调用方以统一方式处理这个结构。
12. 使用该模式的代价
组合模式的代价主要包括:
1. 树结构会引入递归设计复杂度。
2. 如果使用 trait object,会有动态分发和堆分配开销。
3. 如果使用 Rc/RefCell,可能增加运行时借用检查成本。
4. 父子双向引用容易引入循环引用。
5. 树结构过深时递归遍历可能有栈风险。
6. 对简单扁平结构使用组合模式可能过度设计。
在 Rust 中尤其要注意:
不要为了方便修改树结构,随意使用 Rc<RefCell<T>>。
这类结构虽然灵活,但会把一部分借用检查推迟到运行时。
13. 什么时候不应该使用
以下情况不适合使用组合模式:
1. 数据结构不是层级结构。
2. 对象之间没有整体—部分关系。
3. 节点类型很少,且不会递归嵌套。
4. 普通 Vec 或 HashMap 已经足够。
5. 树结构需要频繁复杂修改,但所有权关系没有设计清楚。
6. 为了套设计模式而强行把简单对象组织成树。
在 Rust 中可以遵循一个简单原则:
有明显树形结构,再考虑组合模式。
节点类型固定,优先 enum。
节点类型开放扩展,再考虑 trait object。
14. 一个简单例子思路
场景: 文件系统目录树
需求:
文件有名称和大小。
目录有名称和多个子节点。
子节点可以是文件,也可以是目录。
需要计算整个目录树的总大小。
需要打印目录结构。
处理流程:
1. 定义 FileNode enum。
2. FileNode::File 表示叶子节点。
3. FileNode::Directory 表示组合节点。
4. Directory 内部保存 Vec<FileNode>。
5. total_size() 递归计算大小。
6. print() 递归打印树形结构。
适合练习的 Rust 特性:
enum
match
Vec
递归结构
impl
所有权
借用
Box
如果想练习传统组合模式,可以改成:
trait Component
File impl Component
Directory impl Component
Vec<Box<dyn Component>>
15. 总结一句话
组合模式的本质是:
把对象组织成树形结构,使调用方可以用统一方式处理单个对象和组合对象。
在 Rust 中,如果节点类型固定,组合模式通常优先使用 enum + Vec;如果节点类型需要动态扩展,可以使用 trait + Box<dyn Trait>。使用时要重点关注递归结构、所有权、共享可变性和树形遍历的复杂度。