组合模式完全指南:从 DOM 到表达式树的统一抽象

"目录树""组织架构""菜单和子菜单""DOM"—— 凡是有"整体由部分组成,部分又可以由更小的部分组成"这种结构的地方,组合模式就在那里。它的核心目的是:让客户端用统一方式对待"单个对象"和"对象的组合"。这篇文章带你从最朴素的文件系统例子讲起,看清组合模式在 DOM / 菜单 / 表达式树 / 公司组织里的相同骨架,以及它和访问者、迭代器、装饰器的边界。

问题:对待"单个"和"集合"用不同代码

看一个朴素实现:

class File {
    String name;
    long size;
    public long getSize() { return size; }
}

class Folder {
    String name;
    List<File> files = new ArrayList<>();
    List<Folder> subFolders = new ArrayList<>();   // 嵌套自己

    public long getSize() {
        long total = 0;
        for (File f : files) total += f.getSize();
        for (Folder sf : subFolders) total += sf.getSize();   // 递归
        return total;
    }
}

问题:

  • 客户端要分别处理 File 和 Folder。打印目录树时要 if(isFile) ... else ...。
  • 新加一种"叶子节点"(比如 Symlink、Shortcut)要在所有地方加一个分支。
  • Folder 内部要维护两个列表,代码冗余。

组合模式提出:让叶子(File)和容器(Folder)实现同一个接口,客户端只调接口方法,递归在容器内部自然发生。

组合模式的标准结构

// 1. 共同接口:Component
interface FsNode {
    String getName();
    long getSize();
    void print(String indent);
}

// 2. 叶子:Leaf
class File implements FsNode {
    private final String name;
    private final long size;
    public File(String name, long size) { this.name = name; this.size = size; }
    public String getName() { return name; }
    public long getSize() { return size; }
    public void print(String indent) {
        System.out.println(indent + name + " (" + size + " B)");
    }
}

// 3. 容器:Composite
class Folder implements FsNode {
    private final String name;
    private final List<FsNode> children = new ArrayList<>();

    public Folder(String name) { this.name = name; }
    public String getName() { return name; }

    public void add(FsNode node) { children.add(node); }
    public void remove(FsNode node) { children.remove(node); }

    public long getSize() {
        long total = 0;
        for (FsNode child : children) total += child.getSize();
        return total;
    }

    public void print(String indent) {
        System.out.println(indent + name + "/");
        for (FsNode child : children) child.print(indent + "  ");
    }
}

// 客户端:不区分 File 和 Folder,只用 FsNode 接口
FsNode root = new Folder("/");
Folder docs = new Folder("docs");
docs.add(new File("a.txt", 100));
docs.add(new File("b.txt", 200));

Folder src = new Folder("src");
src.add(new File("main.go", 1500));
src.add(new File("util.go", 800));

((Folder) root).add(docs);
((Folder) root).add(src);
((Folder) root).add(new File("README.md", 50));

System.out.println("总大小: " + root.getSize());   // 2650
root.print("");

关键变化:getSizeprint 这两个递归操作完全藏在结构内部。客户端调用 root.getSize() 不需要知道 root 是文件还是目录 —— 内部递归无感发生。

透明 vs 安全:子节点管理方法放哪里

组合模式有个经典争论:add / remove / getChildren 这些"管理子节点"的方法,应该放在 Component 接口上,还是只放在 Composite 上?

透明方式:Component 接口包含 add/remove

interface FsNode {
    long getSize();
    void add(FsNode node);     // 即使是 File 也要"实现"这个方法
    void remove(FsNode node);
}

class File implements FsNode {
    public void add(FsNode node) {
        throw new UnsupportedOperationException("不能给文件加子节点");
    }
    // ...
}

优点:客户端可以完全统一处理。
缺点:File 被迫提供它不该有的方法,运行时才暴露错误。

安全方式:只在 Composite 暴露 add/remove

这就是上面例子的写法 —— Folder 有 add,File 没有。客户端用 File 的引用调不到 add,编译期就发现错误。
代价是客户端有时需要"我知道这是 Folder 才能加东西"的局部类型判断。

GoF 原书倾向透明,但实际工程多用安全。Java 集合框架就是安全派:List 接口不继承自 Collection"加孩子"的方法。

实战 1:HTML DOM

浏览器 DOM 是组合模式的教科书例子:

// 抽象:Node
// 叶子:Text、Comment
// 容器:Element、Document(可以有子节点)

// 所有操作都通过 Node 接口
let root = document.body;
console.log(root.textContent);          // 递归收集所有后代的文本
root.querySelectorAll('a').forEach(...) // 递归遍历

// 添加 / 删除子节点
let div = document.createElement('div');
div.appendChild(document.createTextNode('hello'));   // Text 是叶子
div.appendChild(document.createElement('span'));     // span 是容器

root.appendChild(div);

// 计算所有后代的"渲染高度":递归调用 getBoundingClientRect

你写 document.querySelectorAll('a') 时不需要管它要穿过多少层 div、section、article —— DOM 内部递归处理。这正是组合模式给你的力量。

实战 2:菜单系统

interface MenuItem {
    String getTitle();
    void render(String indent);
    void onClick();
}

class Action implements MenuItem {
    private final String title;
    private final Runnable handler;
    public Action(String title, Runnable handler) { this.title = title; this.handler = handler; }
    public String getTitle() { return title; }
    public void render(String indent) { System.out.println(indent + "• " + title); }
    public void onClick() { handler.run(); }
}

class Menu implements MenuItem {
    private final String title;
    private final List<MenuItem> items = new ArrayList<>();

    public Menu(String title) { this.title = title; }
    public String getTitle() { return title; }
    public Menu add(MenuItem item) { items.add(item); return this; }   // 链式
    public void render(String indent) {
        System.out.println(indent + title + " ▼");
        for (MenuItem item : items) item.render(indent + "  ");
    }
    public void onClick() { render(""); }    // 点击菜单 = 展开
}

Menu file = new Menu("File")
    .add(new Action("New", () -> System.out.println("new")))
    .add(new Action("Open", () -> System.out.println("open")))
    .add(new Menu("Recent")
        .add(new Action("a.txt", () -> System.out.println("a.txt")))
        .add(new Action("b.txt", () -> System.out.println("b.txt"))));

Menu root = new Menu("Menu").add(file);
root.render("");

Menu 里可以放 Action,也可以放 Menu —— 任意嵌套不需要特殊代码。VSCode、IntelliJ、Office 的菜单系统都是这套结构。

实战 3:表达式树

编译器或计算器里,数学表达式天然是树:

interface Expr {
    double eval();
}

class Num implements Expr {
    private final double value;
    public Num(double v) { this.value = v; }
    public double eval() { return value; }
}

class BinOp implements Expr {
    private final char op;
    private final Expr left, right;
    public BinOp(char op, Expr left, Expr right) { this.op = op; this.left = left; this.right = right; }
    public double eval() {
        return switch (op) {
            case '+' -> left.eval() + right.eval();
            case '-' -> left.eval() - right.eval();
            case '*' -> left.eval() * right.eval();
            case '/' -> left.eval() / right.eval();
            default  -> throw new IllegalStateException();
        };
    }
}

// (3 + 5) * (10 - 4)
Expr e = new BinOp('*',
    new BinOp('+', new Num(3), new Num(5)),
    new BinOp('-', new Num(10), new Num(4))
);
System.out.println(e.eval());    // 48

Num 是叶子,BinOp 是容器(嵌套两个子 Expr)。eval() 递归求值,客户端完全不感知层次。这就是 LISP、Haskell 操作 AST 时的常见模式。

实战 4:组织架构与权限

interface OrgUnit {
    String getName();
    int getEmployeeCount();
    BigDecimal getTotalSalary();
    boolean has(String userId);
}

class Employee implements OrgUnit {
    private final String id, name;
    private final BigDecimal salary;
    // ...
    public int getEmployeeCount() { return 1; }
    public BigDecimal getTotalSalary() { return salary; }
    public boolean has(String userId) { return id.equals(userId); }
}

class Department implements OrgUnit {
    private final String name;
    private final List<OrgUnit> members = new ArrayList<>();   // 既可以是 Employee 也可以是子部门

    public int getEmployeeCount() {
        return members.stream().mapToInt(OrgUnit::getEmployeeCount).sum();
    }
    public BigDecimal getTotalSalary() {
        return members.stream().map(OrgUnit::getTotalSalary).reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    public boolean has(String userId) {
        return members.stream().anyMatch(m -> m.has(userId));
    }
}

"我能不能访问这个资源" — 给资源挂一个所属部门,然后递归查"用户是不是在这个部门或其子部门里"。组合模式让权限系统的递归判断变得极其干净。

组合 + 迭代器 / 访问者

组合模式经常和这两个搭配:

组合 + 迭代器:统一遍历

// 给 Folder 加一个递归迭代器,叶子先出
class FolderIterator implements Iterator<FsNode> {
    private final Deque<Iterator<FsNode>> stack = new ArrayDeque<>();
    public FolderIterator(Folder root) { stack.push(root.children().iterator()); }
    public boolean hasNext() {
        while (!stack.isEmpty() && !stack.peek().hasNext()) stack.pop();
        return !stack.isEmpty();
    }
    public FsNode next() {
        FsNode node = stack.peek().next();
        if (node instanceof Folder f) stack.push(f.children().iterator());
        return node;
    }
}

组合 + 访问者:对树上每个节点做事

interface FsVisitor {
    void visit(File f);
    void visit(Folder f);
}

// 给 FsNode 加 accept 方法
class File implements FsNode { public void accept(FsVisitor v) { v.visit(this); } }
class Folder implements FsNode {
    public void accept(FsVisitor v) {
        v.visit(this);
        for (FsNode child : children) child.accept(v);
    }
}

// 一个计算总大小的访问者
class SizeVisitor implements FsVisitor {
    long total = 0;
    public void visit(File f) { total += f.getSize(); }
    public void visit(Folder f) {}
}

SizeVisitor v = new SizeVisitor();
root.accept(v);
System.out.println(v.total);

访问者让"对树做事"和"树的结构"分离 —— 加一种新操作(查找 / 序列化 / 转换格式)只需要写一个新 Visitor,不动 FsNode 类层次。

共享叶子:享元模式与组合结合

组合树里如果有大量重复叶子(同一个文件被链接到多处、同一个字符在文档里出现千万次),叶子可以做成不可变 + 共享(享元模式)。这能把"看似 N 个叶子"变成"实际只有几个原型 + N 个引用"。文档编辑器、游戏地图、CAD 系统都用这种组合 + 享元。

组合的常见坑

坑 1:循环引用导致栈溢出。 如果不小心让 Folder A 包含 Folder B,Folder B 又包含 A,递归 getSize() 立刻爆栈。add 时必须检测环(用 IdentityHashMap 记录已访问的节点)。

坑 2:深度过大栈溢出。 极深(几万层)的递归会撑爆 JVM 栈。改用显式栈迭代:

public long iterativeSize() {
    long total = 0;
    Deque<FsNode> stack = new ArrayDeque<>();
    stack.push(this);
    while (!stack.isEmpty()) {
        FsNode n = stack.pop();
        if (n instanceof File f) total += f.getSize();
        else if (n instanceof Folder f) f.getChildren().forEach(stack::push);
    }
    return total;
}

坑 3:父节点引用维护忘记同步。 子节点要不要知道自己的父节点?知道的话方便上溯,但 add 时必须设置 parent,remove 时必须清空 parent —— 否则容易内存泄漏(被删的子树还引用着旧父节点)或孤儿(parent 是错的)。要么彻底单向,要么严格维护双向。

坑 4:性能黑洞。 getSize 每次都递归整个子树,N 个节点 + M 次调用 = O(N×M)。常见优化:在 Folder 里缓存 size,子节点变更时 invalidate;或者完全只读时一次性算完缓存。

什么时候不用组合

  • 结构不是真的树:平面列表用普通集合就够,别硬套。
  • 叶子和容器的操作差异巨大:强行用统一接口会把所有方法都退化到"通用空壳"或"unsupported"。
  • 层次永远只有两层(根 + 直接子):简单的"父-子"对象模型更直观,不需要组合。

写在最后

组合模式的本质是"用接口抹平层次差异",让客户端写出 node.getSize() 这种代码时不用关心 node 是叶子还是分支。这种"统一抽象"是构建任何树形系统的基石 —— 文件系统、UI 组件、表达式求值、组织架构、游戏场景图,只要存在嵌套结构,组合模式就在不远处等着。

下次设计一个对象模型时,如果你发现要写"if 是 X 类就这样,if 是 Y 类就那样"的分支,而 X 和 Y 在概念上是"一个是单个,一个是一群" —— 停下来,把它们抽象到同一个接口下。客户端代码会瞬间清爽,新加节点类型也变成"只动一处"的小事。这是设计模式里少数"几乎没有副作用、几乎全是收益"的好牌。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

桥接模式完全指南:从笛卡尔积爆炸到 N+M 的优雅设计

2026-5-15 11:50:57

技术教程

装饰器模式完全指南:从 Java IO 流到 HTTP 中间件的工程实战

2026-5-15 11:57:55

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索