python设计模式–组合模式(Composite Pattern)

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 里提供 addremove,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”体会递归,这样对组合模式会掌握得比较扎实。

发表评论