如何把复杂这头野兽关进笼子,的确是我们程序员面临的巨大挑战。
正如Dijkstra所说:“软件是唯一的职业,人的思维要从一个字节大幅跨越到几百兆字节,也就是九个数量级(放在今天的话,恐怕还要再加上几个数量级)”。对于这么多的信息,如果没有应对策略,其复杂度将远超人类大脑的处理能力。
把复杂比喻成洪水猛兽一点都不为过,我们有多少个不眠之夜,有多少的996,有多少的头发...... 都是因为深陷复杂的泥潭而不能自拔。
这篇文章,我会给大家介绍一个控制复杂度的利器——封装。你将发现唯有深入理解并运用封装,我们才有可能把复杂这头野兽“装”进笼子,并贴上“封”印;才有可能写出Clean Code。否则,无论你付出多少努力,加多少班,都将寸步难行。因为你的努力不是在实现需求,而是在应对混乱。
认识封装
封装(encapsulation)这个概念大家都很熟悉,上学的时候就背的滚瓜烂熟——面向对象的三要素:封装、继承、多态。书本上对封装的解释就是把数据和操作放在一起,封装起来。
然而,封装不仅仅是一种面向对象技术,它的意义要远大于OO的范畴。它更像一把利剑,直刺复杂野兽的心脏。何出此言?这一切还要先从我们大脑的认知结构说起。
有研究表明,我们大脑短期记忆最多只能记住7个记忆项目,这是为什么电话号码只有7位的原因。当面对过多的信息,特别是这些信息又呈现出混乱状态时,我们的大脑就会晕菜,就会觉得很复杂。所以信息过载,大脑认知负荷是造成复杂的重要因素。
关于这一点,下面这张图能很好地说明问题。
左图的一堆玩具是杂乱的堆放在一起,看起来一片混乱,非常复杂,一点也不clean。这是因为所有的玩具都暴露在外面,信息量太大,我们大脑应付不了。
而同样数量的玩具,如果用右图的方式呈现出来,看起来就很舒服,非常的clean。区别就在于右图进行了归纳整理,把琐碎的玩具“封装”在收纳盒里,呈现给我们的界面只是10个摆放整齐的盒子,信息量减少了很多,因此显得整洁、清爽。
同样的道理,也适用于软件设计。通过封装,我们可以实现信息隐藏(information hiding),把底层细节信息封装起来,隐藏起来,为上一层提供信息量更少的界面。通过这种方式,可以减少认知成本和大脑记忆负担,从而降低复杂度。
不幸的是,软件不像收纳盒那样是一个物理的盒子,有物理边界。软件是软的,软件的收纳,只能通过“逻辑盒子”的封装来实现。另外,软件系统要更加复杂,往往有多个层次。在不同层次上,要封装不同的“逻辑盒子”。
最底层是方法,一个方法就是一个“逻辑盒子”,它封装了这个方法要实现的功能;其次,一个类也是一个“逻辑盒子”,它封装了这个类的属性和方法;再往上,一个包(package),一个模块(module),一个应用(applicaiton),都是一个个的“逻辑盒子”。
从某种意义上来说,软件设计就是在设计这些逻辑边界,所谓的clean code,就是尽量让每一个“逻辑盒子”都封装合理——隐藏该隐藏的,暴露该暴露的,让系统呈现出一个清晰、可理解的结构,而不至于失控。很多的设计思想,譬如SOLID原则,高内聚低耦合等,都是在指导我们要如何设计这些“逻辑盒子”。
接下来,我们就一起来看一下,封装在软件的不同层次上是如何把野兽关进笼子的?
方法封装
长方法之所以是典型的坏味道,正是因为它暴露了太多的信息,导致难以理解,有必要将细节封装起来。
举个例子,假如有一个冲泡咖啡的原始需求,其制作咖啡的过程分为三步:1)倒入咖啡粉。2)加入沸水。3)搅拌。于是我们写了下面的代码。
public void makeCoffee() {
//选择咖啡粉
pourCoffeePowder();
//加入沸水
pourWater();
//搅拌
stir();
}
好景不长,很快新的需求就过来了,需要允许选择不同的咖啡粉,以及选择不同的风味。于是我们的代码从一开始的眉清目秀变成了下面这样。
public void makeCoffee(boolean isMilkCoffee, boolean isSweetTooth, CoffeeType type) {
//选择咖啡粉
if (type == CAPPUCCINO) {
pourCappuccinoPowder();
}
else if (type == BLACK) {
pourBlackPowder();
}
else if (type == MOCHA) {
pourMochaPowder();
}
else if (type == LATTE) {
pourLattePowder();
}
else if (type == ESPRESSO) {
pourEspressoPowder();
}
//加入沸水
pourWater();
//选择口味
if (isMilkCoffee) {
pourMilk();
}
if (isSweetTooth) {
addSugar();
}
//搅拌
stir();
}
如果再有更多的需求过来,代码还会进一步恶化,最后就变成一个谁也看不懂的逻辑迷宫,一个难以维护的“焦油坑”。
为了提升代码的可读性和可理解性,我们可以把细节代码通过私有方法封装起来,保证入口方法看起来还是clean的。为
达到此目的,我们把平铺在makeCoffee中的“选择咖啡粉”和“选择口味”的实现细节分别封装成子方法pourCoffeePowder()和flavor(),以确保主方法makeCoffee()的clean和抽象层次一致性。重构后的代码如下所示:
public void makeCoffee(boolean isMilkCoffee, boolean isSweetTooth, CoffeeType type) {
//选择咖啡粉
pourCoffeePowder(type);
//加入沸水
pourWater();
//选择口味
flavor(isMilkCoffee, isSweetTooth);
//搅拌
stir();
}
private void flavor(boolean isMilkCoffee, boolean isSweetTooth) {
if (isMilkCoffee) {
pourMilk();
}
if (isSweetTooth) {
addSugar();
}
}
private void pourCoffeePowder(CoffeeType type) {
if (type == CAPPUCCINO) {
pourCappuccinoPowder();
}
else if (type == BLACK) {
pourBlackPowder();
}
else if (type == MOCHA) {
pourMochaPowder();
}
else if (type == LATTE) {
pourLattePowder();
}
else if (type == ESPRESSO) {
pourEspressoPowder();
}
}
通过上面的案例,不难看出对于方法细节的封装带来的好处:子方法封装了实现细节,从而让主方法变得清晰可理解。
在这方面,我认为Spring中最核心的上下文初始化代码给我们做了一个很好的示范,其核心类AbstractApplicationContext的refresh( )是这样写的:
public void refresh() throws BeansException, IllegalStateException {
synchronized (this.startupShutdownMonitor) {
prepareRefresh();
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
prepareBeanFactory(beanFactory);
try {
postProcessBeanFactory(beanFactory);
invokeBeanFactoryPostProcessors(beanFactory);
registerBeanPostProcessors(beanFactory);
initMessageSource();
initApplicationEventMulticaster();
onRefresh();
registerListeners();
finishBeanFactoryInitialization(beanFactory);
finishRefresh();
}
catch (BeansException ex) {
destroyBeans();
cancelRefresh(ex);
throw ex;
}
finally {
resetCommonCaches();
}
}
}
试想一下,像上面这样,有如此复杂逻辑的代码没有进行方法封装,而是把所有代码都平铺在refresh()方法中,其结果将是怎样一番可怕的景象。
方法封装,实际上和Kent Beck说的CMP(Composed Method Pattern,组合方法模式),以及SLAP(Single Level of Abstration Principle,抽象层次一致性)是一件事情,有兴趣的读者,可以进一步研究。
类封装
说完方法封装,我们把粒度放大到类这个层次。关于面向对象的封装,大家都不陌生。然而,熟悉不代表理解,理解不代表会用,会用不代表用地好。之所以这么说,是因为放眼望去,到处都是类封装的缺失(不仅是我司,全世界的公司都差不多)。
类封装是对数据和方法的封装。除了有上文说的把细节信息隐藏起来的好处之外。它还有个作用,就是功能内聚,这种内聚不仅避免了散弹式修改,也让业务语义的表达更加清晰,这一点对于代码的可读性和可维护性至关重要。
举个例子,假设现在要实现一个国际支付功能,即一个国家的用户可以给另一个国家的用户转账,可能的实现如下:
public void internationalTransfer(String fromAccount, String toAccount,
Money amount, Currency toCurrency) {
if (amount.getCurrency().equals(toCurrency)) {
MoneyTransferService.transfer(fromAccount, toAccount, amount);
} else {
BigDecimal rate = ExchangeService.getRate(amount.getCurrency(), toCurrency);
BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
Money targetMoney = new Money(targetAmount, toCurrency);
MoneyTransferService.transfer(fromAccount, toAccount, targetMoney);
}
}
观察上面的代码,我们发现汇率转换和计算的逻辑,涉及到2个Currency,2个Money,是以一种细节平铺的方式被实现的。单看这一个case,代码也还算clean。但存在隐患:即汇率转换是一个基础功能,除了internationalTransfer,很多地方都可能会用,所以有必要将其封装起来。
更好的做法是将汇率转换功能封装成一个新的类叫ExchangeRate,把汇率查询和货币转换的的细节封装起来。
public class ExchangeRate {
private Currency toCurrency;
public ExchangeRate(Currency toCurrency) {
this.toCurrency = toCurrency;
}
public Money exchange(Money fromMoney) {
notNull(fromMoney);
Currency fromCurrency = fromMoney.getCurrency();
if(fromCurrency == toCurrency){
return fromMoney;
}
//调用汇率系统获取最新的汇率
BigDecimal rate = ExchangeService.getRate(fromCurrency, toCurrency);
BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
return Money.valueOf(targetAmount, toCurrency);
}
}
有了这个新的ExchangeRate之后,所有需要汇率转换的代码只要调用ExchangeRate的exchang()方法即可,exchang()方法隐藏了实现细节,提供了语义明确的接口,理解成本低了,复用性也更好了。
public void internationalTransfer(String fromAccount, String toAccount,
Money amount, Currency toCurrency) {
ExchangeRate rate = ExchangeService.getRate(toCurrency);
Money targetMoney = rate.exchange(money);
MoneyTransferService.transfer(fromAccount, toAccount, targetMoney);
}
//不仅internationalTransfer可以用,其它地方也可以直接用
public Money calculate(Money fromMoney, Currency toCurrency){
ExchangeRate rate = ExchangeService.getRate(toCurrency);
return rate.exchange(fromMoney);
}
通过上面的案例,我们可以发现,如果缺少类封装,会带来两个后果:
没有类封装会导致概念缺失。(如果没有ExchangeRate这个类,ExchangeRate这个概念就不能被清晰的、显性化的表达出来,概念缺失会增加理解成本)
没有类封装会导致代码重复和散弹式修改。(如果没有ExchangeRate,转换逻辑就不能被收拢,代码散落在各处,要修改汇率转换逻辑的话,需要改N多个地方,维护成本高)
鉴于此,关于类封装。我们一定要用面向对象的思维方式,把系统中的重要领域概念挖掘出来,封装成可以复用的类,把业务语义显性化的表达出来,这种方式可以极大的增加系统的可理解性。
这种对领域概念的类封装,也是DDD(Domain Driven Design,领域驱动设计)所倡导的,即明晰领域概念,并以领域模型为核心驱动系统设计。
不过,关于如何发现这些隐式的领域概念?如何抽象建模?如何搭建DDD架构的系统?是另外很大的话题。你可以看看我的另外两篇文章,一文教会你领域建模和Clean Architecture,这里就不过多展开了。
补充说一下,这里介绍的类封装,和Martin Fowler说的基础类型偏执(Primitive Obession),以及基础领域对象(Domain Primitive)这两个概念是类似的,有兴趣的读者可以进一步研究。
封装不等于private
做业务开发的同学,会经常用到很多的纯数据类,比如DTO(Data Transfer Object)和DO(Data Object),DTO是用来在服务之间传递数据的,DO是和数据库字段一一对应的。
大部分情况下,这些数据类里面就是一堆成员变量,再加上操作这些变量的getter和setter,为了减少编写这些boilerplate代码,有些同学会用lombok自动生成这些getter和setter。
既然如此,你有没有考虑过,为什么不直接把这些成员变量直接设置成public呢?这样不就省去了那些烦人的boilerplate代码了么。
是的,我认为你可以这样去做,而且这样做并不会破坏对象的封装性和信息隐藏。我能预见到,这将是一个很有争议的话题,也肯定会引起很多反对的声音。不要着急,先听听我的理由。
前文已经说过了,封装的要义在于信息隐藏,隐藏该隐藏的信息,暴露该暴露的信息。对于DTO和DO来说,其作用是承载数据,其目的就是要暴露自己所承载的所有数据。因此,在这种情况下,我认为通过public来暴露信息,和通过getter、setter来暴露信息相比,并没有太多区别。
为了给我的“反动言论”做背书,我们不妨看一下flink的源码,在flink的org.apache.flink.api.java.tuple包下定义了25个tuple类,其功能类似于DTO,是为了在不同算子之间传递数据使用,比如Tuple3是这样写的:
public class Tuple3<T0, T1, T2> extends Tuple {
private static final long serialVersionUID = 1L;
public T0 f0;
public T1 f1;
public T2 f2;
public Tuple3() {
}
public Tuple3(T0 value0, T1 value1, T2 value2) {
this.f0 = value0;
this.f1 = value1;
this.f2 = value2;
}
}
如下所示,在使用的时候直接通过value.f0、value.f1、value.f2的方式直接访问就好,还挺方便,不是吗。
keyBy(new KeySelector<Tuple2<String,Integer>, String>() {
@Override
public String getKey(Tuple2<String, Integer> value) throws Exception {
return value.f0;
}
因此,在软件的世界里,千万不能教条,学习编程的艺术就是要学会各种规则和原则,然后知道什么时候去打破它。当你真正理解了封装的目的和内涵之后,即使把类变量都设置为public,也不妨碍你做出良好的封装设计,写出漂亮的clean code。
总结
封装不仅仅是面向对象的要素,它更是一种设计哲学,是把野兽关进笼子的关键。
复杂的系统都会呈现出层次结构,要想控制复杂度,在软件设计的各个层次上,进行封装必不可少。除了方法封装和类封装,在包、组件、模块、应用等层次上,我们都应该遵循高内聚、低耦合的原则,进行良好的封装设计。唯有如此,我们才有可能把不必要的信息隐藏起来(再强调一遍,信息多了,就会杂乱,就会复杂,大脑就要晕菜),才有可能在不同的抽象层次上提供整洁的界面,才有可能写出clean code,才有可能打造出clean的系统。
Steve McConnell在《代码大全》中说“软件的首要技术使命是控制复杂度”。我非常赞同这句话,我们工程师的业务使命是交付软件产品,助力业务发展。但就技术本身而言,我们的使命、尊严和良心一定是控制复杂度,写出clean code。