Python 设计模式——组合模式(Composite Pattern)详解
本文面向零基础新手,从“树形结构”到叶子与组合节点、从统一接口到递归操作,全方位讲解组合模式。
一、什么是组合模式?
1.1 通俗理解
组合模式是一种结构型设计模式,核心思想是:
把“单个对象”(叶子)和“由多个对象组成的组合对象”(容器/树枝)用同一套接口表示,让客户可以一致地对待它们;组合对象内部持有一批“子节点”(可以是叶子,也可以是更小的组合),对子节点执行的操作可以递归下去,从而用同一套代码处理整棵“树”。
可以这样类比:
- 文件系统:有“文件”(叶子)和“文件夹”(组合)。你对“文件”可以查大小;对“文件夹”也可以查大小——文件夹的大小 = 里面所有文件/子文件夹大小之和。你不需要写两套逻辑:“如果是文件就返回大小,如果是文件夹就遍历子节点再算”——只要都实现“获取大小”,文件夹在实现里递归问每个子节点的大小再相加即可。叶子与组合用同一接口,组合内部递归处理子节点。
- 礼盒与商品:有“单件商品”(叶子)和“礼盒”(组合,里面有多件商品或小礼盒)。算总价时:单件返回自己的价格,礼盒返回“所有子项价格之和”。客户只调“总价()”,不关心当前是叶子还是礼盒。
所以,组合模式解决的是:需要表达“部分-整体”的树形结构,并希望用统一的方式处理“单个元素”和“由多个元素组成的组合”,避免客户写一堆 if-else 区分叶子和容器。
1.2 为什么需要组合模式?
场景一:树形结构
组织架构(部门下有子部门、有员工)、菜单(菜单下有子菜单、有菜单项)、文件系统(文件夹下有文件、子文件夹)。这类结构天然是“树”:有叶子、有容器,容器里又能放叶子或更小的容器。
场景二:希望对“整体”和“部分”做同一类操作
例如:算总价、算总大小、打印结构、查找。若不用组合模式,代码里会充满“如果是叶子就…,如果是容器就遍历…”,逻辑分散且难扩展。用组合模式后,叶子和容器都实现同一接口(如 get_price()、get_size()),容器在实现里递归调用子节点的同一接口,客户代码只需“调接口”,不用区分叶子和容器。
场景三:要动态增删“部分”
容器可以动态添加、删除子节点(叶子或子容器),客户通过统一接口(如 add(child))添加,不必关心加的是叶子还是另一个容器。
1.3 适用场景(什么时候用?)
| 场景 | 说明 |
|---|---|
| 部分-整体树形结构 | 文件/文件夹、部门/员工、菜单/菜单项、商品/礼盒。 |
| 希望一致对待叶子和容器 | 同一操作(价格、大小、打印)对叶子和容器都有意义,且容器要递归到子节点。 |
| 需要递归处理 | 对“整棵树”做统计、遍历、查找,用递归自然。 |
简单记忆:有“树”结构,且希望用同一套接口处理“叶子”和“装叶子的容器”、容器递归处理子节点时,用组合模式。
二、组合模式的结构(三个角色)
| 角色 | 说明 |
|---|---|
| 组件(Component) | 抽象类/接口,定义叶子和组合共有的方法(如 get_price、add、remove、get_children)。 |
| 叶子(Leaf) | 没有子节点,实现组件接口;对“子节点相关”的方法可以是空实现或抛错(如 add 在叶子上无意义)。 |
| 组合节点(Composite) | 有子节点列表,实现组件接口;对“整体性”操作(如 get_price)递归调用每个子节点的同一方法并汇总。 |
关系可以理解为:
- 客户只依赖 Component,不区分 Leaf 和 Composite。
- Composite 持有一个“子节点列表”(每个元素类型是 Component,可能是 Leaf 或另一个 Composite),对子节点做递归操作。
- 这样形成一棵树:根是 Composite,下面挂 Leaf 或更小的 Composite,一直到叶子为止。
三、透明方式 vs 安全方式(add/remove 放在哪)
- 透明方式:在 Component 里就声明
add(child)、remove(child),Leaf 和 Composite 都实现。Leaf 上调用 add 可以抛异常或忽略,这样客户可以“统一把任何节点当组件”,不用先判断是不是 Composite 才能 add。优点:客户代码一致;缺点:叶子也暴露了 add/remove,可能误用。 - 安全方式:只在 Composite 里提供
add、remove,Component 里没有。客户要添加子节点时必须拿到的确实是 Composite。优点:不会误在叶子上 add;缺点:客户有时要先判断类型。
本文示例采用透明方式(Component 有 add/remove,叶子实现为空或抛错),便于体现“统一接口”;实际项目可按需要选安全方式。
四、示例一:文件与文件夹(大小 + 打印结构)
需求:文件有大小,文件夹有子项(文件或子文件夹);要能“获取大小”(文件返回自己的,文件夹返回子项之和)和“打印结构”(树形展示)。
4.1 组件抽象
from abc import ABC, abstractmethod
class FileSystemComponent(ABC):
"""组件:文件系统节点(文件或文件夹)"""
@abstractmethod
def get_size(self) -> int:
pass
def add(self, child: "FileSystemComponent"):
"""透明方式:叶子可空实现或抛错"""
raise NotImplementedError("叶子节点不能添加子节点")
def get_children(self):
return []
def __str__(self):
return self.get_name()
@abstractmethod
def get_name(self) -> str:
pass
4.2 叶子:文件
class File(FileSystemComponent):
def __init__(self, name: str, size: int):
self._name = name
self._size = size
def get_name(self) -> str:
return self._name
def get_size(self) -> int:
return self._size
4.3 组合节点:文件夹
class Folder(FileSystemComponent):
def __init__(self, name: str):
self._name = name
self._children = []
def get_name(self) -> str:
return self._name
def add(self, child: FileSystemComponent):
self._children.append(child)
def get_children(self):
return self._children
def get_size(self) -> int:
return sum(c.get_size() for c in self._children)
4.4 使用:建树并统一调用
root = Folder("根目录")
root.add(File("a.txt", 100))
root.add(File("b.txt", 200))
sub = Folder("子目录")
sub.add(File("c.txt", 50))
root.add(sub)
print(root.get_size()) # 350 = 100+200+50
# 客户不区分 File 和 Folder,都调 get_size()
要点:客户只调 get_size(),不写“如果是文件…如果是文件夹…”;文件夹的 get_size 递归调用子节点的 get_size。
五、示例二:商品与礼盒(总价)
需求:单件商品有价格;礼盒里可放多件商品或小礼盒,礼盒总价 = 所有子项总价之和。
5.1 组件
class ProductComponent(ABC):
@abstractmethod
def get_price(self) -> float:
pass
def add(self, child: "ProductComponent"):
raise NotImplementedError("叶子不能添加子项")
def get_children(self):
return []
5.2 叶子:单件商品
class Product(ProductComponent):
def __init__(self, name: str, price: float):
self.name = name
self._price = price
def get_price(self) -> float:
return self._price
5.3 组合:礼盒
class Box(ProductComponent):
def __init__(self, name: str):
self.name = name
self._children = []
def add(self, child: ProductComponent):
self._children.append(child)
def get_children(self):
return self._children
def get_price(self) -> float:
return sum(c.get_price() for c in self._children)
5.4 使用
box = Box("礼盒A")
box.add(Product("巧克力", 20))
box.add(Product("饼干", 15))
small_box = Box("小礼盒")
small_box.add(Product("糖果", 5))
box.add(small_box)
print(box.get_price()) # 40.0
六、示例三:打印树形结构(带缩进)
给组件加一个“打印结构”的方法,组合节点递归打印子节点并缩进,便于看清整棵树。
class FileSystemComponent(ABC):
# ... 前面同上 ...
def print_tree(self, indent: int = 0):
"""打印树形结构,indent 为缩进层级"""
pass
class File(FileSystemComponent):
# ...
def print_tree(self, indent: int = 0):
print(" " * indent + f"文件: {self._name} ({self._size})")
class Folder(FileSystemComponent):
# ...
def print_tree(self, indent: int = 0):
print(" " * indent + f"文件夹: {self._name}")
for c in self._children:
c.print_tree(indent + 1)
# 使用
root.print_tree()
# 文件夹: 根目录
# 文件: a.txt (100)
# 文件: b.txt (200)
# 文件夹: 子目录
# 文件: c.txt (50)
要点:叶子和组合都实现 print_tree,组合里对每个子节点调用 print_tree(indent+1),形成递归,客户只调一次根节点的 print_tree()。
七、示例四:菜单与子菜单(可选)
需求:菜单项(叶子)可点击;子菜单(组合)下有多项。统一接口:显示名称、执行(叶子执行动作,子菜单可能只展示或递归)。
class MenuComponent(ABC):
@abstractmethod
def show(self) -> str:
pass
def add(self, child: "MenuComponent"):
raise NotImplementedError
def get_children(self):
return []
class MenuItem(MenuComponent):
def __init__(self, name: str):
self._name = name
def show(self) -> str:
return self._name
class Menu(MenuComponent):
def __init__(self, name: str):
self._name = name
self._children = []
def add(self, child: MenuComponent):
self._children.append(child)
def get_children(self):
return self._children
def show(self) -> str:
parts = [self._name]
for c in self._children:
parts.append(" " + c.show())
return "n".join(parts)
客户对根菜单调 show(),就会递归得到整棵菜单树的可读表示。
八、组合模式 vs 其他
| 模式 | 目的 | 与组合的区别 |
|---|---|---|
| 组合 | 树形结构,叶子和容器统一接口、递归操作 | 强调“部分-整体”、同一接口、递归。 |
| 装饰器 | 给对象包一层,增强行为 | 通常不形成树形子节点,而是链式包装。 |
| 简单树 | 自己写节点类 + 子节点列表 | 若没有“统一接口”、客户要区分叶子和容器,就不是组合模式;组合模式强调“一致对待”。 |
九、常见问题与注意点
9.1 叶子上的 add/remove
透明方式下,叶子可实现 add 为“什么都不做”或“抛出异常”,避免客户误以为可以往文件下加子节点。安全方式下则不把 add 放在 Component 里,只在 Composite 里提供。
9.2 循环引用
组合节点不要把自己或祖先加入自己的子节点,否则递归会死循环。建树时注意父子关系是单向的(父→子)。
9.3 父引用
若需要从子找父,可以在 Component 里加 parent 引用,在 add 时设置;注意维护一致性(删除子节点时清空其 parent)。
十、小结
- 组合模式:用同一套接口表示“叶子”和“组合节点”,组合节点持有一批子组件(可为叶子或更小组合),对子组件递归调用同一接口,从而用同一套代码处理整棵树。
- 三个角色:组件(Component)、叶子(Leaf)、组合节点(Composite);客户只依赖 Component。
- 典型用途:文件系统、商品/礼盒、菜单、组织架构等“部分-整体”树形结构,且要对整体做统一操作(价格、大小、打印、遍历)。
- 实现要点:叶子和组合都实现“整体性”方法(如 get_price、get_size);组合在实现里遍历子节点并递归调用同一方法再汇总。
建议先写“文件/文件夹 + get_size”,再写“商品/礼盒 + get_price”,最后加“print_tree”体会递归,这样对组合模式会掌握得比较扎实。