零基础设计模式——结构型模式 - 组合模式
第三部分:结构型模式 - 组合模式 (Composite Pattern)
在学习了桥接模式如何分离抽象和实现以应对多维度变化后,我们来探讨组合模式。组合模式允许你将对象组合成树形结构来表现“整体-部分”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
- 核心思想:将对象组合成树状结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
组合模式 (Composite Pattern)
“将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象(叶子节点)和组合对象(容器节点)的使用具有一致性。” (Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.)
想象一下电脑文件系统的目录结构。一个目录可以包含文件(叶子节点),也可以包含其他目录(容器节点),这些子目录又可以包含文件或其他目录,形成一个树形结构。无论你操作的是一个文件还是一个目录(比如计算大小、显示名称),你可能希望用类似的方式来对待它们。
组合模式正是为了解决这类问题而设计的。
1. 目的 (Intent)
组合模式的主要目的:
- 表示部分-整体层次结构:清晰地表示对象之间的树形结构关系。
- 统一操作接口:使得客户端可以一致地处理单个对象(叶子)和对象的组合(容器/分支)。客户端不需要区分它正在处理的是一个叶子节点还是一个容器节点,从而简化了客户端代码。
- 递归组合:容器节点可以包含叶子节点,也可以包含其他容器节点,形成递归结构。
2. 生活中的例子 (Real-world Analogy)
-
公司组织架构:
- 一个公司(
Company
- 根容器)由多个部门(Department
- 容器)组成。 - 每个部门可以包含员工(
Employee
- 叶子),也可以包含子部门(SubDepartment
- 容器)。 - 无论是计算整个公司的总薪资,还是某个部门的总薪资,或者某个员工的薪资,都可以通过一个统一的操作(如
getSalary()
)来递归地完成。
- 一个公司(
-
图形用户界面 (GUI):
- 一个窗口(
Window
- 根容器)可以包含面板(Panel
- 容器)和各种控件如按钮(Button
- 叶子)、文本框(TextBox
- 叶子)。 - 面板本身也可以包含其他面板或控件。
- 当需要绘制整个窗口时,窗口会调用其子组件的绘制方法,子面板会再调用其子组件的绘制方法,直到叶子控件被绘制。这个过程对调用者(如窗口管理器)来说是统一的。
- 一个窗口(
-
菜单系统:
- 一个菜单栏(
MenuBar
- 根容器)包含多个菜单(Menu
- 容器)。 - 每个菜单可以包含菜单项(
MenuItem
- 叶子),也可以包含子菜单(SubMenu
- 容器)。 - 点击一个菜单项执行操作,点击一个子菜单则展开它。对用户来说,操作菜单和菜单项的方式是相似的。
- 一个菜单栏(
-
装配线上的部件:
- 一个复杂产品(如汽车 - 根容器)由许多主要部件(如引擎、底盘 - 容器)组成。
- 每个主要部件又由更小的零件(如螺丝、活塞 - 叶子)或子部件(如变速箱 - 容器)组成。
- 计算总成本或总重量时,可以递归地累加所有部分的成本或重量。
3. 结构 (Structure)
组合模式通常包含以下角色:
- Component (组件接口):为组合中的对象声明接口。它既可以代表叶子 (Leaf) 也可以代表容器 (Composite)。它通常声明了所有类共有的一系列操作,如
add()
,remove()
,getChild()
,operation()
等。对于叶子节点,add()
,remove()
,getChild()
等管理子节点的操作可能没有意义,可以提供默认实现(如抛出异常或空操作)。 - Leaf (叶子节点):在组合中表示叶节点对象,叶节点没有子节点。它实现了 Component 接口中定义的操作。
- Composite (容器/组合节点):定义有子部件的那些部件的行为。存储子部件并在 Component 接口中实现与子部件相关的操作(如
add()
,remove()
,getChild()
)。Composite 的operation()
方法通常会递归调用其子组件的operation()
方法。 - Client (客户端):通过 Component 接口操纵组合部件的对象。
透明方式 vs 安全方式:
- 透明方式 (Transparent):
Component
接口中声明所有管理子对象的方法(add
,remove
,getChild
)。这样客户端可以统一对待所有组件,无需区分叶子和容器。但叶子节点需要对这些管理方法提供空实现或抛出异常,因为它们没有子节点。这是上图所示的方式。 - 安全方式 (Safe):只在
Composite
类中声明管理子对象的方法。客户端在调用这些方法前需要判断对象类型是否为Composite
。这样叶子节点就不需要实现不相关的方法,更安全,但客户端处理起来稍微麻烦一些。
通常推荐透明方式,因为它简化了客户端代码,尽管可能牺牲一点类型安全(叶子节点可能会被错误地调用 add
方法)。
4. 适用场景 (When to Use)
- 当你希望表示对象的部分-整体层次结构时。
- 当你希望客户端代码可以统一处理组合结构中的单个对象和组合对象时,而无需关心其具体是叶子还是容器。
- 当对象的结构是递归的,并且你想以统一的方式处理这种结构时。
- 当你想让客户能够增加新的组件类型(叶子或容器)而无需修改现有使用该结构的代码时。
5. 优缺点 (Pros and Cons)
优点:
- 简化客户端代码:客户端可以一致地使用组合结构中的所有对象,无需编写特殊的代码来区分叶子和容器。
- 易于增加新类型的组件:可以很容易地增加新的
Leaf
或Composite
类,它们都遵循Component
接口,对现有结构和客户端代码影响小,符合开闭原则。 - 使层次结构更清晰:代码直接反映了对象的树形结构。
缺点:
- 使设计过于通用(透明方式下):如果采用透明方式,
Component
接口需要包含所有可能的操作,包括那些只对Composite
有意义的操作(如add
,remove
)。这可能使得叶子节点的接口不太纯粹,因为它们不得不实现一些无用的方法。 - 难以限制组合中的组件类型:有时你可能希望一个
Composite
只能包含特定类型的Component
。标准组合模式很难直接实现这种约束,可能需要额外的类型检查或使用泛型(如果语言支持)。 - 如果管理子节点的操作过于复杂,Component接口会变得臃肿。
6. 实现方式 (Implementations)
让我们以一个图形绘制系统为例,其中可以有简单的图形(如点、线、圆 - 叶子)和复杂的组合图形(可以包含其他简单或复杂图形 - 容器)。
组件接口 (Graphic - Component)
// graphic.go (Component)
package graphicsimport "fmt"// Graphic 组件接口
type Graphic interface {Draw() // 所有图形对象共有的操作:绘制Add(g Graphic) error // 添加子图形 (对叶子节点无意义)Remove(g Graphic) error // 移除子图形 (对叶子节点无意义)GetChild(index int) (Graphic, error) // 获取子图形 (对叶子节点无意义)GetName() string // 获取图形名称
}
// Graphic.java (Component)
package com.example.graphics;// 组件接口
public interface Graphic {void draw(); // 所有图形对象共有的操作:绘制String getName(); // 获取图形名称// 管理子图形的方法 (透明方式)// 对于叶子节点,这些方法通常会抛出UnsupportedOperationException或空实现default void add(Graphic graphic) {throw new UnsupportedOperationException("Cannot add to a leaf graphic.");}default void remove(Graphic graphic) {throw new UnsupportedOperationException("Cannot remove from a leaf graphic.");}default Graphic getChild(int index) {throw new UnsupportedOperationException("Leaf graphic has no children.");}
}
叶子节点 (Dot, Line, Circle - Leaf)
// dot.go (Leaf)
package graphicsimport "fmt"// Dot 叶子节点
type Dot struct {Name stringX, Y int
}func NewDot(name string, x, y int) *Dot {return &Dot{Name: name, X: x, Y: y}
}func (d *Dot) GetName() string {return d.Name
}func (d *Dot) Draw() {fmt.Printf("Drawing Dot '%s' at (%d, %d)\n", d.Name, d.X, d.Y)
}func (d *Dot) Add(g Graphic) error {return fmt.Errorf("cannot add to a leaf graphic (Dot: %s)", d.Name)
}func (d *Dot) Remove(g Graphic) error {return fmt.Errorf("cannot remove from a leaf graphic (Dot: %s)", d.Name)
}func (d *Dot) GetChild(index int) (Graphic, error) {return nil, fmt.Errorf("leaf graphic (Dot: %s) has no children", d.Name)
}// line.go (Leaf) - 类似 Dot,省略 Add, Remove, GetChild 的错误实现
type Line struct {Name stringStartX, StartY intEndX, EndY int
}func NewLine(name string, sx, sy, ex, ey int) *Line {return &Line{Name: name, StartX: sx, StartY: sy, EndX: ex, EndY: ey}
}func (l *Line) GetName() string {return l.Name
}func (l *Line) Draw() {fmt.Printf("Drawing Line '%s' from (%d,%d) to (%d,%d)\n", l.Name, l.StartX, l.StartY, l.EndX, l.EndY)
}func (l *Line) Add(g Graphic) error { return fmt.Errorf("cannot add to Line") }
func (l *Line) Remove(g Graphic) error { return fmt.Errorf("cannot remove from Line") }
func (l *Line) GetChild(index int) (Graphic, error) { return nil, fmt.Errorf("Line has no children") }
// Dot.java (Leaf)
package com.example.graphics;public class Dot implements Graphic {private String name;private int x, y;public Dot(String name, int x, int y) {this.name = name;this.x = x;this.y = y;}@Overridepublic String getName() {return name;}@Overridepublic void draw() {System.out.printf("Drawing Dot '%s' at (%d, %d)%n", name, x, y);}// add, remove, getChild 使用接口的默认实现 (抛出UnsupportedOperationException)
}// Line.java (Leaf)
package com.example.graphics;public class Line implements Graphic {private String name;private int startX, startY, endX, endY;public Line(String name, int startX, int startY, int endX, int endY) {this.name = name;this.startX = startX;this.startY = startY;this.endX = endX;this.endY = endY;}@Overridepublic String getName() {return name;}@Overridepublic void draw() {System.out.printf("Drawing Line '%s' from (%d,%d) to (%d,%d)%n", name, startX, startY, endX, endY);}
}
容器节点 (CompoundGraphic - Composite)
// compound_graphic.go (Composite)
package graphicsimport ("fmt"
)// CompoundGraphic 容器节点
type CompoundGraphic struct {Name stringchildren []Graphic
}func NewCompoundGraphic(name string) *CompoundGraphic {return &CompoundGraphic{Name: name,children: make([]Graphic, 0),}
}func (cg *CompoundGraphic) GetName() string {return cg.Name
}func (cg *CompoundGraphic) Draw() {fmt.Printf("Drawing CompoundGraphic '%s':\n", cg.Name)for _, child := range cg.children {fmt.Print(" ") // Indent for claritychild.Draw()}fmt.Printf("Finished Drawing CompoundGraphic '%s'\n", cg.Name)
}func (cg *CompoundGraphic) Add(g Graphic) error {cg.children = append(cg.children, g)fmt.Printf("Added %s to %s\n", g.GetName(), cg.GetName())return nil
}func (cg *CompoundGraphic) Remove(g Graphic) error {found := falsenewChildren := make([]Graphic, 0)for _, child := range cg.children {if child == g { // Pointer comparison for simplicity, or use unique IDfound = truefmt.Printf("Removed %s from %s\n", g.GetName(), cg.GetName())continue}newChildren = append(newChildren, child)}cg.children = newChildrenif !found {return fmt.Errorf("child graphic '%s' not found in '%s'", g.GetName(), cg.GetName())}return nil
}func (cg *CompoundGraphic) GetChild(index int) (Graphic, error) {if index < 0 || index >= len(cg.children) {return nil, fmt.Errorf("index out of bounds for CompoundGraphic '%s'", cg.Name)}return cg.children[index], nil
}
// CompoundGraphic.java (Composite)
package com.example.graphics;import java.util.ArrayList;
import java.util.List;public class CompoundGraphic implements Graphic {private String name;private List<Graphic> children = new ArrayList<>();public CompoundGraphic(String name) {this.name = name;}@Overridepublic String getName() {return name;}@Overridepublic void draw() {System.out.printf("Drawing CompoundGraphic '%s':%n", name);for (Graphic child : children) {System.out.print(" "); // Indent for claritychild.draw();}System.out.printf("Finished Drawing CompoundGraphic '%s'%n", name);}@Overridepublic void add(Graphic graphic) {children.add(graphic);System.out.printf("Added %s to %s%n", graphic.getName(), this.name);}@Overridepublic void remove(Graphic graphic) {if (children.remove(graphic)) {System.out.printf("Removed %s from %s%n", graphic.getName(), this.name);} else {System.out.printf("Graphic %s not found in %s for removal%n", graphic.getName(), this.name);}}@Overridepublic Graphic getChild(int index) {if (index >= 0 && index < children.size()) {return children.get(index);}throw new IndexOutOfBoundsException("Index out of bounds for CompoundGraphic '" + name + "'");}
}
客户端使用
// main.go (示例用法)
/*
package mainimport ("./graphics""fmt"
)func main() {// Create leaf objectsdot1 := graphics.NewDot("Dot1", 10, 20)line1 := graphics.NewLine("Line1", 0, 0, 100, 100)dot2 := graphics.NewDot("Dot2", 50, 60)// Create a compound graphiccompound1 := graphics.NewCompoundGraphic("Picture1")compound1.Add(dot1)compound1.Add(line1)// Create another compound graphic and add the first one to itmainPicture := graphics.NewCompoundGraphic("MainCanvas")mainPicture.Add(compound1)mainPicture.Add(dot2)fmt.Println("\n--- Drawing Main Canvas ---")mainPicture.Draw() // Client treats mainPicture (Composite) and dot1 (Leaf) uniformly via Graphic interfacefmt.Println("\n--- Trying to add to a leaf (should fail) ---")err := dot1.Add(line1)if err != nil {fmt.Println("Error as expected:", err)}fmt.Println("\n--- Removing an element ---")compound1.Remove(dot1)fmt.Println("\n--- Drawing Main Canvas After Removal ---")mainPicture.Draw()// Accessing a childchild, err := compound1.GetChild(0)if err == nil {fmt.Printf("\n--- Child 0 of Picture1 is: %s ---\n", child.GetName())child.Draw()} else {fmt.Println("Error getting child:", err)}
}
*/
// Main.java (示例用法)
/*
package com.example;import com.example.graphics.*;public class Main {public static void main(String[] args) {// Create leaf objectsGraphic dot1 = new Dot("Dot1", 10, 20);Graphic line1 = new Line("Line1", 0, 0, 100, 100);Graphic dot2 = new Dot("Dot2", 50, 60);// Create a compound graphicCompoundGraphic compound1 = new CompoundGraphic("Picture1");compound1.add(dot1);compound1.add(line1);// Create another compound graphic and add the first one to itCompoundGraphic mainPicture = new CompoundGraphic("MainCanvas");mainPicture.add(compound1);mainPicture.add(dot2);System.out.println("\n--- Drawing Main Canvas ---");mainPicture.draw(); // Client treats mainPicture (Composite) and dot1 (Leaf) uniformly via Graphic interfaceSystem.out.println("\n--- Trying to add to a leaf (should throw exception) ---");try {dot1.add(line1);} catch (UnsupportedOperationException e) {System.out.println("Exception as expected: " + e.getMessage());}System.out.println("\n--- Removing an element ---");compound1.remove(dot1);System.out.println("\n--- Drawing Main Canvas After Removal ---");mainPicture.draw();// Accessing a childtry {Graphic child = compound1.getChild(0);System.out.printf("%n--- Child 0 of Picture1 is: %s ---%n", child.getName());child.draw();} catch (IndexOutOfBoundsException e) {System.out.println("Error getting child: " + e.getMessage());}}
}
*/
7. 总结
组合模式通过引入一个共同的组件接口,使得客户端可以统一处理单个对象(叶子)和对象的组合(容器)。它非常适合用来表示具有“部分-整体”层次结构的对象,并能有效地简化客户端与这种复杂结构交互的方式。通过递归组合,可以构建出任意复杂的树形结构,同时保持操作的一致性。
记住它的核心:统一对待单个对象和组合对象,形成树形结构。