复合设计模式:如何通过相同的接口来操作单个对象以及由这些对象组成的群体
结构设计模式关注的是对象在结构和层次结构方面的创建方式。 其中一种专门用于帮助你管理复杂层次结构的模式就是组合设计模式。 那么,这种模式究竟能起到什么作用呢? 让我举个例子来说明。假设你有一个数据集,你需要一个统一的接口来管理这个数据集,确保所有操作都使用相同的方法进行。同时,你也希望这个系统能够灵活地扩展,以便数据量可以不断增加。在这种情况下,组合设计模式就非常适用了。 以下是一些其他典型的应用场景: 购物车由单个商品以及捆绑销售的商品组成,这两种情况都需要确定价格。 税收系统既包含单独的纳税人,也涉及家庭团体和企业团体,所有这些对象都需要进行税费计算、折扣处理以及累计金额的计算。 文件系统
结构设计模式关注的是对象在结构和层次结构方面的创建方式。
其中一种专门用于帮助你管理复杂层次结构的模式就是组合设计模式。
那么,这种模式究竟能起到什么作用呢?
让我举个例子来说明。假设你有一个数据集,你需要一个统一的接口来管理这个数据集,确保所有操作都使用相同的方法进行。同时,你也希望这个系统能够灵活地扩展,以便数据量可以不断增加。在这种情况下,组合设计模式就非常适用了。
以下是一些其他典型的应用场景:
购物车由单个商品以及捆绑销售的商品组成,这两种情况都需要确定价格。
税收系统既包含单独的纳税人,也涉及家庭团体和企业团体,所有这些对象都需要进行税费计算、折扣处理以及累计金额的计算。
文件系统中既有单独的文件,也有包含其他文件或文件夹的目录,这两种情况都需要知道文件的大小。
一种简单的处理方法是分别为单个对象和团体编写不同的逻辑代码,然后在需要同时处理这两种情况的地方添加类型检查。但这种做法会导致代码逻辑变得复杂,类型检查的数量也会大幅增加,而且每当有新的操作需求出现时,都需要同时更新这两部分代码,这使得代码既难以扩展,也难以维护。
而组合设计模式完全避免了这些问题。它定义了一个所有单独对象和团体都必须实现的通用接口,调用代码无需进行类型检查,无论是处理单个对象还是复合对象,都会使用相同的方法来获得正确的结果。
目录
先决条件
在阅读本文之前,你应当已经掌握以下内容:
面向对象编程:抽象类、接口和继承机制
设计模式的概念及其作用
Dart或C#的基本语法
你不需要具备结构设计模式方面的相关经验。本文将从基础原理入手,结合实际生产案例来介绍组合模式。
什么是组合设计模式?
组合设计模式是一种结构设计模式。创建型模式关注对象的创建方式,行为型模式关注对象之间的交互方式,而结构型模式则关注对象是如何组合以及它们之间是如何关联的。
组合模式专门用于处理部分-整体层次结构。它允许你将对象组合成树形结构,然后像对待同一类型的对象一样来操作这些树结构。
其核心思想非常简单:定义一个共同的接口,让各个单独的对象都实现这个接口,同时也让对象组也实现这个接口。这样一来,层次结构中的所有元素都会对相同的接口方法做出响应,调用代码就无需区分是单个对象还是对象组。
这就是“通过统一的接口来处理单个对象和对象组”在实践中的具体含义。无论你是调用层次结构中的某个单独对象,还是调用一个包含数百个对象的嵌套对象组,一次方法调用总能得到正确的结果。
三层结构
组合模式由三个不同的层次组成。在查看代码之前先理解这三个层次,会让你更容易理解其实现原理。
组件层
这一层包含定义了整个层次结构中所有对象都必须遵循的契约的抽象类或接口。它规定了各个单独的对象以及对象组都必须实现的方法。
正是由于层次结构中的所有元素都实现了这个接口,因此它们才能对相同的接口方法做出响应,从而实现了统一处理的目的。
叶子层
叶子节点是组件接口的具体实现。它代表没有子对象的单独实体,比如购物车中的某一件商品、某个纳税人或一个文件。叶子节点会用自己的特定逻辑来实现组件接口中定义的方法。
组合层
组合节点也是组件接口的具体实现。但与叶子节点不同,它包含多个子对象。这些子对象既可以是叶子节点,也可以是另一个组合节点。
组合节点通过将请求委托给其子对象,并汇总这些子对象的执行结果来实现组件接口中的方法。
正是这种层次结构使得树形组织和统一接口的同时成为可能。
第一个现实世界案例:购物车定价
购物车需要计算商品的总价。单个商品有固定的价格,而商品组合的价格则是其中所有商品价格的总和。无论是处理单个商品还是商品组合,都需要实现getPrice()方法。
组件
abstract class PriceComponent {
double getPrice();
}
PriceComponent实际上是一种契约。购物车结构中的每一个对象都必须实现getPrice()方法。这就是整个接口的定义:所有对象都需要遵循的这一统一规范,即包含这个唯一的方法。
叶子节点
class CartItem extends PriceComponent {
final int id;
final String name;
final double price;
CartItem({required this.id, required this.name, required this.price});
@override
double getPrice() {
return price;
}
}
CartItem代表了购物车中的单个商品。它的getPrice()>方法会直接返回该商品的价格,不存在任何委托或子对象的结构,只包含它自身的价格信息。
复合结构
class ItemBundle extends PriceComponent {
final int bundleId;
final String bundleName;
final List _items = [];
ItemBundle({required this.bundleId, required thisbundleName});
void add(PriceComponent component) {
_items.add(component);
}
void remove(PriceComponent component) {
_items.removecomponent);
}
@override
double getPrice() {
return _items.fold(0, (total, item) => total + item.getPrice());
}
}
ItemBundle是一种复合结构。它包含一个PriceComponent类型的子对象列表。getPrice()>方法会通过fold操作将这些子对象的价格相加,从而得到最终的总价。
关键点在于:_items是一个List类型,而不是List类型。这意味着一个ItemBundle》既可以包含普通的CartItem,也可以包含其他的ItemBundle》复合结构。这种层次结构可以根据需要进行任意深度的嵌套。
使用方法
void main() {
final burger = CartItem(id: 1, name: '汉堡', price: 5.99);
final fries = CartItem(id: 2, name: '薯条', price: 2.99);
final drink = CartItem(id: 3, name: '饮料', price: 1.99);
final apple = CartItem(id: 4, name: '苹果', price: 0.99);
final comboMeal = ItemBundlebundleId: 1, bundleName: '组合餐');
comboMeal
..add(burger)
..add(fries)
..add(drink);
final cart = ItemBundle(bundleId: 0, bundleName: '我的购物车');
cart
..add(comboMeal)
..add(apple);
// 对所有对象都使用相同的方法来获取价格
print('汉堡:\$${burger.getPrice()}');
print('组合餐:\$${comboMeal.getPrice()}');
print('完整购物车总价:\$${cart.getPrice()}');
}
burger.getPrice()直接调用了CartItem中的实现方法。comboMeal.getPrice()则调用了ItemBundle的复合结构实现,该方法会依次调用其三个子对象burger、fries和drink的getPrice()>方法来计算总价。cart.getPrice()同样调用了复合结构实现,但这次是计算包含comboMeal和apple在内的整个购物车的总价。
调用代码对所有这些对象都采用相同的方式进行处理:getPrice(),得到结果,完成操作。
现实世界案例二:税收管理
这个例子展示了复合模式在更复杂的领域中的应用。一个税收管理系统需要计算税款金额、应用折扣,并计算截至目前的累计总额。这些计算过程对于单个纳税人以及纳税人群体来说,都必须通过完全相同的接口来执行。
组件
abstract class TaxManager {
num getTaxAmount();
num getTaxDiscount();
num getTotalTaxYTD();
}
TaxManager定义了三个方法。税收层次结构中的每一个对象都必须实现这三个方法。单个纳税人会使用自己的数据来实现这些方法;而纳税人群体则通过汇总所有成员的数据来实现这些方法。调用代码可以针对任何对象调用这些方法,从而得到正确的结果。
叶子节点
class SingleUser extends TaxManager {
final num _amount;
final List〈num〉_allTaxes;
SingleUser(this._amount, this._allTaxes);
@override
num getTaxAmount() {
return _amount;
}
@override
num getTaxDiscount() {
return _amount % 2 == 0 ? _amount : (_amount / 2);
}
@override
num getTotalTaxYTD() {
num total = 0;
for (final tax in _allTaxes) {
total += tax;
}
return total;
}
}
SingleUser就是叶子节点。它代表一个单独的纳税人。_amount表示他们的当前税款金额,_allTaxes则记录了他们一年中所有的纳税记录。每个方法都仅针对这个人的数据进行操作。
getTaxDiscount()采用了一个简单的折扣规则:如果税款金额是偶数,则全额享受折扣;如果是奇数,则只享受一半的折扣。这个规则直接应用于单个纳税人,同时也会自动应用到包含该纳税人的任何纳税人群体中。
getTotalTaxYTD()会将这个人过去所有的纳税记录加起来,从而计算出他截至目前的累计税款总额。
复合对象
class UserGroup extends TaxManager {
final String groupName;
final List〈TaxManager〉_members = [];
UserGroup(this.groupName);
void add(TaxManager member) {
_members.add(member);
}
void remove(TaxManager member) {
_members.remove/member);
}
@override
num getTaxAmount() {
return _members.fold(0, (total, member) => total + member.getTaxAmount());
}
@override
num getTaxDiscount() {
return _members.fold(0, (total, member) => total + member.getTaxDiscount());
}
@override
num getTotalTaxYTD() {
return _members.fold(0, (total, member) => total + member.getTotalTaxYTD());
}
}
UserGroup就是复合对象。它包含一个TaxManager对象的列表。每个方法都会遍历列表中的所有成员,并将它们的结果进行合并计算。
请注意,_members被定义为List类型,而不是List类型。这意味着一个UserGroup》可以包含多个SingleUser》成员,也可以包含其他UserGroup》对象。一个家庭群体可以包含多个个体成员,而一个企业集团则可以包含多个家庭群体。这种层次结构可以根据需要进行扩展,而且接口本身始终保持不变。
使用方法
void main() {
final seyisTax = SingleUser(100000, List.generate(12, (_) => 20000));
final ronkesTax = SingleUser(5000, List.generate(12, _) => 50000);
final inisTax = SingleUser(20000, List.generate(12, _) => 20000);
final tiwasTax = SingleUser(10000, List.generate(12, _) => 10000);
print('Seyi的税款金额: ${seyisTax.getTaxAmount()}');
print('Ronke的税款金额: ${ronkesTax.getTaxAmount()}');
print('Seyi的折扣金额: ${seyisTax.getTaxDiscount()}');
print('Seyi今年的累计税款: ${seyisTax.getTotalTaxYTD()}');
// 创建一个家庭群体
final fatunmoles = UserGroup('Fatunmoles');
fatunmoles
..add(seyisTax)
..add(ronkesTax)
..add(inisTax)
..add(tiwasTax);
print('总税款金额: ${fatunmoles.getTaxAmount()}');
print('总折扣金额: ${fatunmoles.getTaxDiscount()}');
print('总今年的累计税款: ${fatunmoles.getTotalTaxYTD()}');
// 创建第二个家庭群体
final child1 = SingleUser(100000, List.generate(12, _) => 20000);
final child2 = SingleUser(100000, List.generate(12, _) => 20000);
final child3 = SingleUser(100000, Listgenerate(12,_) => 20000);
final unknownFamily = UserGroup('UnknownFamily');
unknownFamily
..add(child1)
..add(child2)
..add(child3);
print('税款金额: ${unknownFamily.getTaxAmount()}');
print('折扣金额: ${unknownFamily.getTaxDiscount()}');
print('今年的累计税款: ${unknownFamily.getTotalTaxYTD()}');
}
对于seyisTax(一个人)、fatunmoles(四个人)以及unknownFamily(三个人),使用相同的三种方法进行操作时,代码是完全一样的,而且每种层次结构下的计算结果也都是正确的。
嵌套组合体的强大之处
组合模式最强大的特点在于:一个组合体可以包含其他组合体。一个UserGroup》可以包含其他的UserGroup》对象。这种设计使得我们可以构建出复杂的层次结构,同时确保每一层都使用相同的接口。
void demonstrateNesting() {
final seyi = SingleUser(100000, List.generate(12, _) => 20000);
final ronke = SingleUser(5000, List.generate(12, _) => 50000);
final ini = SingleUser(20000, List.generate(12, _) => 20000);
final tiwa = SingleUser(10000, List.generate(12, _) => 10000);
final child1 = SingleUser(100000, List.generate(12,_) => 20000);
final child2 = SingleUser(100000, List.generate(12, _) => 20000);
final child3 = SingleUser(100000, List.generate(12, _) => 20000);
// 家庭群体
final fatunmoles = UserGroup('Fatunmoles');
fatunmoles
..add(seyi)
..add(ronke)
..add(ini)
..add(tiwa);
final unknownFamily = UserGroup('UnknownFamily');
unknownFamily
..add(child1)
..add(child2)
..add(child3);
// 一个包含其他组合体的复合体
// 这两个家庭群体被视为一个整体来处理
final allFamilies = UserGroup('AllFamilies');
allFamilies
..add(fatunmoles)
..add(unknownFamily);
// 同一的接口,现在可以涵盖这两个家庭群体中的所有7个人
print('两个家庭群体合并后的结果:');
print('总税款金额: ${allFamilies.getTaxAmount()}');
print('总折扣金额: ${allFamilies.getTaxDiscount()}');
print('今年的累计税款: ${allFamilies.getTotalTaxYTD()}');
}
allFamilies.getTaxAmount()会遍历整个结构:它首先向fatunmoles请求其总税额,而fatunmoles则会再询问它的四个成员各自的税额;接着它还会向unknownFamily请求其总税额,而unknownFamily同样会询问它的三个成员各自的税额。整个过程中只进行了一次方法调用,但涉及了七个人。调用者并不了解这个结构的层次结构,也不知道每一层中包含多少个成员。
正是在这里,这种设计模式展现出了它真正的价值。你可以构建一个由部门、团队和个人组成的组织结构,所有这些组件都实现了相同的TaxManager接口,然后你就可以对这个整个组织调用getTaxAmount()方法来获取其中每个人的税额总和。或者也可以只对某个具体的部门或个人调用这个方法。无论在哪种情况下,接口的使用方式都是完全相同的。
C#中的组合模式
在C#中运用同样的设计模式,可以清楚地看到:这其实是一种普遍适用的结构原则。下面是一个为企业的工资发放系统设计的税务管理系统示例,该系统就是用C#实现的。
组件
public interface ITaxManager
{
decimal GetTaxAmount();
decimal GetTaxDiscount();
decimal GetTotalTaxYTD();
}
叶子节点
public class Employee : ITaxManager
{
private readonly string _name;
private readonly decimal _taxAmount;
private readonly List〈decimal〉 _yearlyTaxes;
public Employee(string name, decimal taxAmount, List〈decimal〉 yearlyTaxes)
{
_name = name;
_taxAmount = taxAmount;
_yearlyTaxes = yearlyTaxes;
}
public decimal GetTaxAmount() => _taxAmount;
public decimal GetTaxDiscount()
{
return _taxAmount % 2 == 0 ? _taxAmount : _taxAmount / 2;
}
public decimal GetTotalTaxYTD()
{
return _yearlyTaxes.Sum();
}
}
组合对象
public class Department : ITaxManager
{
private readonly string _name;
private readonly List〈ITaxManager〉 _members = new();
public Department(string name)
{
_name = name;
}
public void Add(ITaxManager member) => _members.Add(member);
public void Remove(ITaxManager member) => _members.Remove.member);
public decimal GetTaxAmount()
{
return _members.Sum(m => m.GetTaxAmount());
}
public decimal GetTaxDiscount()
{
return _members.Sum(m => m.GetTaxDiscount());
}
public decimal GetTotalTaxYTD()
{
return _members.Sum(m => m.GetTotalTaxYTD());
}
}
在C#中的应用
var alice = new Employee("Alice", 150000, Enumerable.Repeat(25000m, 12).ToList());
var bob = new Employee("Bob", 80000, Enumerable.Repeat(15000m, 12).ToList());
var carol = new Employee("Carol", 120000, Enumerable.Repeat(20000m, 12).ToList());
var dave = new Employee("Dave", 95000, Enumerable.Repeat(18000m, 12).ToList>();
var engineering = new Department("Engineering");
engineering.Add(alice);
engineering.Add(bob);
var design = new Department("Design");
design.Add(carol);
design.Add(dave);
var company = new Department("TechCorp");
company.Add(engineering);
company.Add(design);
Console.WriteLine($"Alice的税额:{alice.GetTaxAmount()}");
Console.WriteLine($"Engineering部门的总税额:{engineering.GetTaxAmount()}`);
// 计算整个公司的总税额
Console.WriteLine($"整个公司的总税额:{company.GetTaxAmount()}`);
Console.WriteLine($"整个公司的总折扣金额:{company.GetTaxDiscount()}");
Console.WriteLine($"整个公司今年至今的总税额:{company.GetTotalTaxYTD()}");
这种结构与Dart语言中的实现方式完全相同。ITaxManager代表组件,Employee代表叶节点,而Department则代表复合节点。这种层次结构是嵌套的:多个Department>组合在一起就构成了整个公司。无论访问树中的哪个节点,调用代码都会使用相同的三种方法,从而得到正确的聚合结果。
这其实是一种用不同语言来解决相同问题的模式。这种结构原则具有普遍性。
何时使用组合模式
当存在部分与整体的层次结构,并且需要以统一的方式处理这两者时,就应该使用组合模式。
此外,当调用代码无需区分单个对象和对象组时,这种模式也是一个不错的选择。如果你经常需要编写条件语句来判断某个对象是单独存在的还是属于某个组,那么使用组合模式就可以避免这些繁琐的操作。
当层次结构需要具备灵活性且能够深度嵌套时,组合模式也非常有用。文件系统、组织结构图、UI组件树、分类体系、税收系统,以及包含多种商品的购物车等等,凡是那些容器可以容纳其他容器的场景,都可以从组合模式中受益。
另外,如果未来可能会添加新的叶节点或复合节点类型,组合模式也是个不错的选择。因为所有节点都实现了相同的组件接口,所以新增一种叶节点类型(比如CorporateTaxpayer)或一种复合节点类型(比如TaxBracketGroup),只需要创建一个新的类即可,而调用代码以及现有的所有类都不会因此发生任何变化。
何时不应使用它
当层次结构简单且不会发生嵌套时,应避免使用组合模式。如果只有单个对象以及一层简单的分组结构,而没有嵌套关系,那么使用组合模式反而会增加不必要的抽象层,而采用更简单的方法反而会更合适。
另外,当单个对象和对象组确实需要不同的接口时,组合模式也不适用。如果对象组需要许多额外的方法,而这些方法单个对象并不需要,那么强制将它们统一到同一个接口中,会导致接口过于复杂,从而违反接口隔离原则。
在性能要求极高、递归遍历带来的开销较大的情况下,也应避免使用组合模式。对于那些包含数百万个节点且需要频繁进行遍历的深层层次结构来说,采用其他能够缓存聚合结果的方法可能会更合适。
结论
组合设计模式解决了一个在层次化系统中普遍存在的问题:如何既能对单个对象执行操作,也能对对象组执行相同的操作,而无需编写两种不同的实现代码,也不必在代码中添加大量的类型检查语句?
答案就是使用一个共同的接口。层次结构中的每一个对象都实现了这个接口,单个对象会用自己的数据来实现该接口,而对象组则会通过委托子节点并汇总结果来实现该接口。无论调用代码是与叶节点交互,还是与包含数百层嵌套结构的复合节点交互,它使用的都是同一个方法,最终得到的也会是正确的结果。
购物车示例说明了这一点:对于定价而言,无论是对单个商品还是包含其他商品的组合进行调用,getPrice()方法的使用方式都是完全相同的。税收管理示例则进一步证明了这一原则:对于涉及多种操作的场景来说,getTaxAmount()、getTaxDiscount()和getTotalTaxYTD()等方法在针对单个纳税人、家庭群体或由多个家庭群体组成的复合实体进行调用时,其使用方式也完全一致。
嵌套组合结构的示例充分展现了这一模式的力量:一个UserGroup可以包含其他UserGroups,而每个UserGroup又可以包含SingleUsers;所有这些组件都遵循相同的接口进行交互,从而在每一个层级上都能产生正确的聚合结果。七个人,只需要调用一次方法即可完成整个处理流程,系统会自动遍历这个嵌套结构。
C#语言在企业薪资管理领域的应用也体现了这一原则:员工被视为叶子节点,部门被视为复合实体,而整个公司则是由多个部门组成的复合体。无论处于层次结构的哪个层级,使用相同的接口就能得到正确的结果。
组合模式并不会消除复杂性,而是将这种复杂性包含在内部结构中。在处理具有深层层次结构的数据时,所有聚合操作的逻辑都集中在组合模式的fold方法调用中,而不会分散到其他代码中。这样一来,调用代码本身依然保持简洁明了,层次结构也依然具备灵活性——即使添加新的类型,也不需要修改现有的任何代码。
这就是组合模式所提供的结构化解决方案。
祝编程愉快! 相关文章
深入探讨行为模式:访问者设计模式及其在复杂对象结构中的应用
几乎每一个正在发展的软件系统中都会出现这样一个问题,而大多数开发人员直到问题造成了实际的损害之后才意识到自己遇到了它。 你有一组对象:它们具有不同的类型、形状和数据结构。而在某个时刻,会有人要求你对这些对象执行某种操作,比如将它们导出为PDF格式、向它们发送通知、生成报告或计算相关费用。 你的第一反应可能是编写一个函数,根据对象的类型来决定执行哪些操作,例如使用if-else语句或switch结构。这样的代码逻辑是:如果这个对象是NewUser类型,就执行这个操作;如果是JointAccountUser类型,就执行另一个操作。这种方法确实有效,你将其实现后,大家都很满意。 然而,后来又出现了新
阅读全文
《设计模式手册:通过C#代码示例学习常见的设计模式》
设计模式是针对软件设计中常见问题的、可复用的解决方案。可以把它们看作是蓝图:并非完整的代码,而是经过验证的模板,你可以根据自己的需求将其调整过来,用于解决自己代码库中的特定问题。 这本手册旨在帮助大家切实理解软件设计模式。我编写这本书是为了所有开发者,无论你使用哪种编程语言。书中的示例是用C#编写的,但这里提到的每一个概念同样适用于Python、Java、TypeScript、Go等语言。 源代码可以在这里找到: github.com/Clifftech123/design-patterns-handbook 。 需要注意的事项: 设计模式本身并不是代码。 它们是一种思考代码结构的方式,是解决
阅读全文
构建者设计模式:构建复杂对象的一种更有效的方法
有些对象非常简单,比如字符串、数字或布尔值。你可以用一行代码创建它们,然后继续进行其他操作。 而另一些对象则完全不简单。例如,一个轮播组件就需要包含项目数量、项目生成函数、控制器、高度、视图窗口比例、自动播放设置、页面切换回调函数以及无限滚动配置等信息。再比如,一个HTTP请求需要URL地址、请求头信息、认证令牌、请求体内容、超时时间以及重试逻辑。又或者,一条通知消息需要标题、正文、图标、发送渠道、优先级、声音效果、振动功能以及操作按钮等等。 当你需要构建这类对象时,一种常见的方法是使用带有大量参数的构造函数。这种方法虽然可行,但随着对象结构变得越来越复杂,就会带来一系列问题:这些参数很难区分
阅读全文
责任链设计模式:逐步解耦复杂的业务规则,每次只处理一个逻辑环节
任何系统在某个阶段都会出现这样一种情况:存在某个没人愿意去修改的功能。 一开始,这种问题很轻微:只是一个简单的验证逻辑,这里加一个if语句,那里再加一个。但随着需求的变化,越来越多的条件被添加进去,相应的功能代码也变得越来越长。有人还会留下注释,提醒别人“在修改之前一定要先阅读整个代码”。于是,这个功能就变成了新开发者入职培训中的必讲内容。 当复杂的业务规则毫无秩序地堆积在一起时,就会发生这种情况。 责任链模式正是为了解决这类问题而存在的。它不是让一个方法包揽所有事务,而是通过一系列专门处理特定任务的组件来解决问题。每个组件只负责执行其中一条业务规则,并判断请求是否符合该规则;如果符合,则将请
阅读全文