Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13_组合模式 Composite Pattern

1. 基本信息

中文名称: 组合模式

英文名称: Composite Pattern

模式类型: 结构型设计模式

Rust 中常见实现方式: enum 递归结构、BoxVec、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 可以清楚地区分不同节点。 例如文件和目录可以作为不同枚举变体,编译器会检查是否处理完整。

第三,递归结构通常需要 BoxVec。 因为 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>。使用时要重点关注递归结构、所有权、共享可变性和树形遍历的复杂度。