每个月最开心的,是发薪日。
上午正写着代码,手机在桌角一震:工资到账。嘴角刚翘起来,手机就开始陆续地震。
房贷扣款 5800,老家那套房。信用卡自动还款 3000,上个月的零零碎碎。房租 4500,这间租屋,转给房东。水电代扣 230。车贷 2500。话费 100。
楼下贴着取暖费通知:中旬开始供暖,两千四,管一季。地铁每天来回十块,一个月二百二。
晚上到家瘫在沙发上,把今天的短信翻出来,一条条看。工资在卡里待了不到一个白天,大头就各就各位了。
一数,八笔。这事儿跟代码里的一种结构,是一回事。
短信一条条来,钱一笔笔走
今天聊的组合模式(Composite):一张账单能算钱,一摞账单也能算钱,问法不用换。系列第十八篇。
01 挨个加起来
最直接的写法,一项一个变量,最后一把加:
int mortgage = 5800; // 房贷
int rent = 4500; // 房租
int utility = 230; // 水电
int heating = 2400; // 取暖费
int carLoan = 2500; // 车贷
int metro = 220; // 地铁
int creditCard = 3000; // 信用卡
int phone = 100; // 话费
int total = mortgage + rent + utility + heating
+ carLoan + metro + creditCard + phone;
八项八个变量,一把加完
能跑,总数也对。但问一句“住这一块,一个月多少”——房贷、房租、水电、取暖费,得从那条加法里把这四项挑出来,再写一条新的。下个月添了物业费,总数公式要改,住的小计公式也要改。漏改哪条,没人提醒你。
02 先让每张账单长一个样
把“一张账单”抽出来:有名字,有金额。
interface BillItem {
String name();
int amount();
}
class SingleBill implements BillItem {
private final String name;
private final int amount;
SingleBill(String name, int amount) {
this.name = name;
this.amount = amount;
}
public String name() {
return name;
}
public int amount() {
return amount;
}
}
单张账单:一个名字,一个数
装进一个 List,遍历求和,添项不用再动求和的代码:
List<BillItem> bills = List.of(
new SingleBill("房贷", 5800),
new SingleBill("房租", 4500),
// ……还有六张
new SingleBill("话费", 100)
);
int total = 0;
for (BillItem bill : bills) {
total += bill.amount();
}
清单平了,但“住一共多少”还是答不上来
漏加的问题解决了,清单却是平的。“住这一块多少”,还得按名字一张张往回挑。可账单天生是分摞的:住一摞,行一摞,剩下的散着。一摞也是账单,也该能直接问“多少”。
03 让一摞账单也长这个样
组合模式就一招:让“一摞”也实现同一个接口。
class BillGroup implements BillItem {
private final String name;
private final List<BillItem> items = new ArrayList<>();
BillGroup(String name) {
this.name = name;
}
void add(BillItem item) {
items.add(item);
}
public String name() {
return name;
}
public int amount() {
int total = 0;
for (BillItem item : items) {
total += item.amount();
}
return total;
}
}
一组账单:里面装的还是 BillItem
注意 amount() 里那句 item.amount():组不关心手里这张是单是摞——是单张,自己报数;是摞,就继续往下问,问到单张为止。树就是这么长出来的。
拼一棵这个月的:
BillGroup living = new BillGroup("住");
living.add(new SingleBill("房贷", 5800));
living.add(new SingleBill("房租", 4500));
living.add(new SingleBill("水电", 230));
living.add(new SingleBill("取暖费", 2400));
BillGroup commute = new BillGroup("行");
commute.add(new SingleBill("车贷", 2500));
commute.add(new SingleBill("地铁", 220));
BillGroup november = new BillGroup("11月账单");
november.add(living);
november.add(commute);
november.add(new SingleBill("信用卡", 3000));
november.add(new SingleBill("话费", 100));
int salary = 20000;
System.out.println("住 小计:" + living.amount());
System.out.println("行 小计:" + commute.amount());
System.out.println(november.name() + " 总计:" + november.amount());
System.out.println("到账 " + salary + ",还剩 " + (salary - november.amount()));
住里装四张,行里装两张,剩下的直接挂月账单上
跑一遍:
住 小计:12930
行 小计:2720
11月账单 总计:18750
到账 20000,还剩 1250
代码实际运行输出
单张账单和一整月账单,对上的是同一个 amount()。调用的人不用问“你是一张还是一摞”——这就是组合模式说的:把对象组成树,表示“部分-整体”;让单个对象和组合对象,用起来一个样。
04 树管算账,不管日子
先把失真点挑明。
现实里扣款有先后:房贷划走,卡里少了 5800,才有后面的事;余额不足,还会扣失败。组合不管这个——它管的是结构和总数,不管顺序。真要排队分先后,那是责任链(Day⑪)的路子;要一张张过一遍,那是迭代器的活。
还有,代码里这棵树是当月现拼的。现实的账单月月变:取暖费只这几个月有,信用卡时多时少。树跟着月份变,正常;别想着把它固化成一套用一辈子的配置。
最要紧的一条:现实里忘了缴取暖费,顶多是催缴单贴在门上;代码里漏 add 一项,编译不报错,运行不报警,总数悄无声息地就错了。树不会替你对账,对账还是得靠人。
JDK 里的 Swing 是这个结构:Container 里 add 的可以是按钮,也可以是面板,面板里还能再装面板;布局一声令下,整棵树重新排。文件系统也是:文件和文件夹都能问“多大”,文件夹的大小,是里面所有东西一层层报上来的。
05 什么时候别用
账单就是一张流水清单,没有分组小计的需求——一个 List 遍历就够了,别硬造树。
叶子和容器的行为差得太远,也别硬凑:文件能“打开”,文件夹的“打开”是另一个意思,硬统一成一个接口,接口里就会长出对一半成员没意义的方法。
还有,你要的如果是“挨个处理每一件”,而不是“把一整组当一件用”,那要找的可能是迭代器,不是组合。
06 还剩一千二百五
晚上的沙发上,总数对上了:18750。剩 1250。
一张能算,一摞也能算,问法都是同一个 amount()
跟前面十七篇对一句:单例管造几个,工厂管怎么造,模板方法管事情怎么走,策略管这一步用什么做法,装饰器管外面加几层,命令管把请求做成对象,代理管谁能碰到真东西,观察者管变化后通知谁,外观管一堆零件只留一个入口,状态管当前状态下怎么做,责任链管请求由哪一环接住,原型管拿哪个现成对象复制,桥接管抽象和实现怎么独立扩展,享元管把能共享的部分只留一份,备忘录管把某个时刻的状态存下来、再原样恢复,生成器管把复杂对象的装配拆成一步一步、最后一次性成型,访问者管对一批稳定的元素做什么操作由递进来的单子说了算。本篇组合,管单个和一组长一个样,整棵树当一件东西用。
带走一句话:组合 = 部分和整体长一个样;一张账单能算钱,一摞账单也能算钱,问法不用换。
账单是一张张来的,
日子是一月月过的。
树还是那棵树,下个月换几片叶子。
参考资料
- 《设计模式:可复用面向对象软件的基础》(GoF 原著)——组合模式的原始定义:将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性
- Refactoring Guru · Composite——组合模式的结构:Component、Leaf、Composite 的分工与递归求值
- JDK · java.awt.Container——Container 是一种 Component:组件能装进容器,容器还能装进容器