Clean Code之封装:把野兽关进笼子

如何把复杂这头野兽关进笼子,的确是我们程序员面临的巨大挑战。

正如Dijkstra所说:“软件是唯一的职业,人的思维要从一个字节大幅跨越到几百兆字节,也就是九个数量级(放在今天的话,恐怕还要再加上几个数量级)”。对于这么多的信息,如果没有应对策略,其复杂度将远超人类大脑的处理能力。

把复杂比喻成洪水猛兽一点都不为过,我们有多少个不眠之夜,有多少的996,有多少的头发...... 都是因为深陷复杂的泥潭而不能自拔。

这篇文章,我会给大家介绍一个控制复杂度的利器——封装。你将发现唯有深入理解并运用封装,我们才有可能把复杂这头野兽“装”进笼子,并贴上“封”印;才有可能写出Clean Code。否则,无论你付出多少努力,加多少班,都将寸步难行。因为你的努力不是在实现需求,而是在应对混乱。

认识封装

封装(encapsulation)这个概念大家都很熟悉,上学的时候就背的滚瓜烂熟——面向对象的三要素:封装、继承、多态。书本上对封装的解释就是把数据和操作放在一起,封装起来。

然而,封装不仅仅是一种面向对象技术,它的意义要远大于OO的范畴。它更像一把利剑,直刺复杂野兽的心脏。何出此言?这一切还要先从我们大脑的认知结构说起。

有研究表明,我们大脑短期记忆最多只能记住7个记忆项目,这是为什么电话号码只有7位的原因。当面对过多的信息,特别是这些信息又呈现出混乱状态时,我们的大脑就会晕菜,就会觉得很复杂。所以信息过载,大脑认知负荷是造成复杂的重要因素

关于这一点,下面这张图能很好地说明问题。 

1967ddf04eb4164997cc0b26bc1af088.png

左图的一堆玩具是杂乱的堆放在一起,看起来一片混乱,非常复杂,一点也不clean。这是因为所有的玩具都暴露在外面,信息量太大,我们大脑应付不了。

而同样数量的玩具,如果用右图的方式呈现出来,看起来就很舒服,非常的clean。区别就在于右图进行了归纳整理,把琐碎的玩具“封装”在收纳盒里,呈现给我们的界面只是10个摆放整齐的盒子,信息量减少了很多,因此显得整洁、清爽。

同样的道理,也适用于软件设计。通过封装,我们可以实现信息隐藏(information hiding),把底层细节信息封装起来,隐藏起来,为上一层提供信息量更少的界面。通过这种方式,可以减少认知成本和大脑记忆负担,从而降低复杂度

不幸的是,软件不像收纳盒那样是一个物理的盒子,有物理边界。软件是软的,软件的收纳,只能通过“逻辑盒子”的封装来实现。另外,软件系统要更加复杂,往往有多个层次。在不同层次上,要封装不同的“逻辑盒子”。

最底层是方法,一个方法就是一个“逻辑盒子”,它封装了这个方法要实现的功能;其次,一个类也是一个“逻辑盒子”,它封装了这个类的属性和方法;再往上,一个包(package),一个模块(module),一个应用(applicaiton),都是一个个的“逻辑盒子”。

b3cd7b4aa7d4b656ee6414a2550ecc86.png

从某种意义上来说,软件设计就是在设计这些逻辑边界,所谓的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);
}

通过上面的案例,我们可以发现,如果缺少类封装,会带来两个后果:

  1. 没有类封装会导致概念缺失。(如果没有ExchangeRate这个类,ExchangeRate这个概念就不能被清晰的、显性化的表达出来,概念缺失会增加理解成本)

  2. 没有类封装会导致代码重复和散弹式修改。(如果没有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

  • 12
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值