Design-Pattern-Composite

组合模式(composite)是一种结构型设计模式,可以使用它将对象组合成树状结构, 并且能像使用独立对象一样使用它们。

场景

在开发中会遇到需要处理/统计的对象们,属于层级或者包含关系。学过数据结构的同学肯定知道,通常用来组织这些对象。

树由节点和边构成。其中节点用于存储数据,而边构建了节点之间的关系。一个节点通过持有另一些节点的引用,来构建边。一段典型树结构代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 定义树的节点类
class TreeNode {
int value;
List<TreeNode> children;

// 构造函数,初始化节点
public TreeNode(int value) {
this.value = value;
this.children = new ArrayList<>();
}

// 添加子节点
public void addChild(TreeNode child) {
this.children.add(child);
}
}

在此基础上进行一些拓展。上例中,所有节点都属于同一类TreeNode。而在实际建模时,不同维度节点需要储存和表示的信息不同,通常会使用不同类。例如想统计国内经济指标,节点可以是省、市、区、县类。当节点属于不同类时,我们没法在所有节点类中都使用List<TreeNode> children去定义所有可能的子节点。一种朴素的解决方法是,每种类仅持有其子类的节点。例如可以在定义时,持有的节点,中再持有的节点。但也存在例外,例如直辖市不属于任一省,某些市没有设置区。为了兼容这些特殊情况,需要定义多种类型子节点变量,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Country {
private String name;
// 国家特有的属性
private String president;
// 省子节点
private List<Province> childrenProvinces;
// 直辖市子节点
private List<City> childrenCities;
}

class Province {
...
}

class City {
...
}

而在客户端代码中,当需要统计某个省或者某个市的经济时,可能会定义如下方法。

1
2
public Bigdecimal calculateGdpProvince(Province province);
public Bigdecimal calculateGdpCity(City city);

可以发现节点种类越多,定义节点的代码和客户端引用节点的代码就会越复杂。

此外也会设计容器节点。容器节点指只提供结构和组织,而不直接存储数据的节点。例如上例中,想要统计一个省加一个市加一个区的数据,可以在容器节点中持有这三个不同类节点的引用。客户端只与容器节点交互,而不关心容器内具体包含什么。所以容器节点类需要依赖其他所有节点类。容器节点类内通常也会定义递归遍历其子节点的方法,当子节点种类越多时,容器节点类的代码也会越复杂。

参考数据结构中经典的树结构,我们期望对所有节点类型一视同仁。如此节点内定义子节点和客户端引用的代码会更简洁。又期望保持每种节点类的特色。这显然要利用到java多态的特性。

组合模式

组合模式中,所有节点类型都实现一个Component接口,用来表示是树结构中的一个节点。客户端通过接口与不同的节点类交互,并不关心其具体类型,移除了对具体节点类的依赖。Component接口中定义节点类需要向外暴露的共同特性,例如上例中统计GDP指标的方法。对于非叶子节点类,这些方法实现通常为调用其子节点的同方法,并汇总返回;而在叶子节点中,实际进行属性查询并计算。这种调用方式也被称为树的递归。

根据组合模式,我们可以重构上例中的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
interface Component {
BigDecimal calculateGdp();
}

class Country implements Component {
private String name;
// 国家特有的属性
private String president;
// 任意类型子节点
private List<Component> children;

@Override
public BigDecimal calculateGdp() {
return children.parallelStream()
.map(Component::calculateGdp)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}

组合模式的类图相对比较简单,核心便是代表节点的接口Component。对于节点类,通常还会分为叶子节点Leaf和非叶子节点(也称组合或容器)Composite。叶子节点由于不包含子节点,因此无需定义子节点的成员变量,也不需要提供添加/删除子节点的方法。

classDiagram
    direction TB
    class Client
    
    class Component {
        << interface >>
    	+methodA() ResultA
    }

	Client --> Component
	
	class Leaf {
		+methodA() ResultA
	}
	
	class Composite {
		-List~Composite~ children
		+methodA() ResultA
		+addChild(Component) void
		+removeChild(Component) Component
	}

	
    Leaf ..|> Component
    Composite ..|> Component

添加和删除子节点的方法

由于客户端与接口进行交互,而上例中接口中并不包含添加或删除子节点的方法。因此当客户端需要进行这类操作时,不可避免会依赖具体容器节点类。如果想移除这种依赖,可以将这些方法同样定义在接口中。但注意,这违反了单一职责原则,因为对于叶子节点来说,添加和删除子节点的方法是无意义,且不该被支持的。

不论是否在接口中声明这类方法,都是合理的选择。通常根据客户端使用这类方法的频率,来决定是否在接口中添加。这是对耦合性和接口隔离性的权衡与取舍。

总结

组合模式通常使用于,需要处理统计的对象属于层级关系或包含关系。

组合模式通过定义一个Component接口来表示树上的一个节点,以此对所有具体节点类一视同仁,移除了对具体节点类的依赖,简化了具体节点类和客户端的代码。

Component接口内需要声明对所有节点类都有意义的方法。

实现接口声明的方法时,容器应该将大部分工作交给其子元素来完成,也就是递归。

也可以在接口中定义添加或删除子节点的方法。这样允许客户端通过接口对容器元素进行操作,但也会违反单一职责原则。


Design-Pattern-Composite
http://dracoyus.github.io/2024/09/29/Design-Pattern-Composite/
作者
DracoYu
发布于
2024年9月29日
许可协议