08 缺乏封装:如何应对火车代码和基本类型偏执问题?

  你好,我是郑晔。

  上一讲,我们讲的是控制语句体现出的坏味道,它们不是一种坏味道,而是一类坏味道。这一讲,我们再来讲一类代码的坏味道:缺乏封装。

  在程序设计中,一个重要的观念就是封装,将零散的代码封装成一个又一个可复用的模块。任何一个程序员都会认同封装的价值,但是,具体到写代码时,每个人对于封装的理解程度却天差地别,造成的结果就是:写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里。

  这一讲,我们就来看看,那些被封装遗忘的角落。

火车残骸

  我们先从一段你可能很熟悉的代码开始:

  String name = book.getAuthor().getName();

  这段代码表达的是“获得一部作品作者的名字”。作品里有作者信息,想要获得作者的名字,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,不过它是有问题的。

  如果你没看出这段代码的问题,说明你可能对封装缺乏理解。

  你可以想一想,如果你想写出上面这段代码,是不是必须得先了解 Book 和 Author 这两个类的实现细节?也就是说,我们必须得知道,作者的姓名是存储在作品的作者字段里的。这时你就要注意了:当你必须得先了解一个类的细节,才能写出代码时,这只能说明一件事,这个封装是失败的。

  这段代码只是用来说明这种类型坏味道是什么样的,在实际工作中,这种在一行代码中有连续多个方法调用的情况屡见不鲜,数量上总会不断突破你的认知。

  Martin Fowler 在《重构》中给这种坏味道起的名字叫过长的消息链(Message Chains),而有人则给它起了一个更为夸张的名字:火车残骸(Train Wreck),形容这样的代码像火车残骸一般,断得一节一节的。

  解决这种代码的重构手法叫隐藏委托关系(Hide Delegate),说得更直白一些就是,把这种调用封装起来:

  class Book {

    ...

    public String getAuthorName() {
      return this.author.getName();
    }

    ...

  }

  String name = book.getAuthorName();

  前面我说过,火车残骸这种坏味道的产生是缺乏对于封装的理解,因为封装这件事并不是很多程序员编码习惯的一部分,他们对封装的理解停留在数据结构加算法的层面上。

  在学习数据结构时,我们所编写的代码都是拿到各种细节直接操作,但那是在做编程练习,并不是工程上的编码方式。遗憾的是,很多人把这种编码习惯带到了工作中。

  比如说,有人编写一个新的类,第一步是写出这个类要用到的字段,然后,就是给这些字段生成相应的 getter,也就是各种 getXXX。很多语言或框架提供的约定就是基于这种 getter 的,就像 Java 里的 JavaBean,所以相应的配套工具也很方便。现在写出一个 getter 往往是 IDE 中一个快捷键的操作,甚至不需要自己手工敲代码。

  诸如此类种种因素叠加,让暴露细节这种事越来越容易,封装反而成了稀缺品。

  要想摆脱初级程序员的水平,就要先从少暴露细节开始。声明完一个类的字段之后,请停下生成 getter 的手,转而让大脑开始工作,思考这个类应该提供的行为。

  每个单元对其它单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的;

  每个单元只能与其朋友交谈,不与陌生人交谈;

  只与自己最直接的朋友交谈。

  这个原则需要我们思考,哪些算是直接的朋友,哪些算是陌生人。火车残骸般的代码显然就是没有考虑这些问题而直接写出来的代码。

  或许你会说,按照迪米特法则这样写代码,会不会让代码里有太多简单封装的方法?

  确实有可能,不过,这也是单独解决这一个坏味道可能带来的结果。正如我前面所说,这种代码的出现,根本的问题是缺乏对封装的理解,而一个好的封装是需要基于行为的,所以,如果把视角再提升一个角度,我们应该考虑的问题是类应该提供哪些行为,而非简简单单地把数据换一种形式呈现出来。

  最后,还有一个问题我要提醒你一下。有些内部 DSL 的表现形式也是连续的方法调用,但 DSL 是声明性的,是在说做什么(What),而这里的坏味道是在说怎么做(How),二者的抽象级别是不同的,不要混在一起。

基本类型偏执

  我们再来看一段代码:

  public double getEpubPrice(final boolean highQuality, final int chapterSequence) {

    ...

  }

  这是我们上一讲用过的一个函数声明,根据章节信息获取 EPUB(一种电子书的格式) 的价格。也许你会问,这是一个看上去非常清晰的代码,难道这里也有坏味道吗?

  没错,有。问题就出在返回值的类型上,也就是价格的类型上。

  那么,我们在数据库中存储价格的时候,就是用一个浮点数,这里用 double 可以保证计算的精度,这样的设计有什么问题吗?

  确实,这就是很多人使用基本类型(Primitive)作为变量类型思考的角度。但实际上,这种采用基本类型的设计缺少了一个模型

  虽然价格本身是用浮点数在存储,但价格和浮点数本身并不是同一个概念,有着不同的行为需求。比如,一般情况下,我们要求商品价格是大于 0 的,但 double 类型本身是没有这种限制的。

  就以“价格大于 0”这个需求为例,如果使用 double 类型你会怎么限制呢?我们通常会这样写:

  if (price <= 0) {
    throw new IllegalArgumentException("Price should be positive");
  }

  问题是,如果使用 double 作为类型,那我们要在使用的地方都保证价格的正确性,像这样的价格校验就应该是使用的地方到处写的。

  如果补齐这里缺失的模型,我们可以引入一个 Price 类型,这样的校验就可以放在初始化时进行:

  class Price {

    private long price;

    public Price(final double price) {
      if (price <= 0) {
        throw new IllegalArgumentException("Price should be positive");
      }
      this.price = price;
    }
  }

  这种引入一个模型封装基本类型的重构手法,叫做以对象取代基本类型(Replace Primitive with Object)。一旦有了这个模型,我们还可以再进一步,比如,如果我们想要让价格在对外呈现时只有两位,在没有 Price 类的时候,这样的逻辑就会散落代码的各处,事实上,代码里很多重复的逻辑就是这样产生的。而现在我们可以在 Price 类里提供一个方法:

  public double getDisplayPrice() {
    BigDecimal decimal = new BigDecimal(this.price);
    return decimal.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();

  }

  其实,使用基本类型和使用继承出现的问题是异曲同工的。大部分程序员都学过这样一个设计原则:组合优于继承,也就是说,我们不要写出这样的代码:

  public Books extends List<Book> {

    ...

  }

  而应该写成组合的样子,也就是:

  public Books  {

    private List<Book> books;

    ...

  }

  之所以有人把 Books 写成了继承,因为在代码作者眼中,Books 就是一个书的集合;而有人用 double 做价格的类型,因为在他看来,价格就是一个 double。这里的误区就在于,一些程序员只看到了模型的相同之处,却忽略了差异的地方。Books 可能不需要提供 List 的所有方法,价格的取值范围与 double 也有所差异。

  但是,Books 的问题相对来说容易规避,因为产生了一个新的模型,有通用的设计原则帮助我们判断这个模型构建得是否恰当,而价格的问题却不容易规避,因为这里没有产生新的模型,也就不容易发现这里潜藏着问题。

  这种以基本类型为模型的坏味道称为基本类型偏执(Primitive Obsession)。这里说的基本类型,不限于程序设计语言提供的各种基本类型,像字符串也是一个产生这种坏味道的地方。

  这里我稍微延伸一下,有很多人对于集合类型(比如数组、List、Map 等等)的使用也属于这种坏味道。之前课程里我提到过“对象健身操(出自《ThoughtWorks 文集》)”这篇文章,里面有两个与此相关的条款,你可以作为参考:

  这一讲我们讲到的坏味道都是关于封装的。不过,正如我在开头所说,封装是一个人人都懂的道理,但具体到代码上,就千差万别了。

  封装之所以有难度,主要在于它是一个构建模型的过程,而很多程序员写程序,只是用着极其粗粒度的理解写着完成功能的代码,根本没有构建模型的意识;还有一些人以为划分了模块就叫封装,所以,我们才会看到这些坏味道的滋生。

  这里我给出的坏味道,其实也是在挑战一些人对于编程的认知:那些习以为常的代码居然成了坏味道。而这只是一个信号,一个起点,告诉你这段代码存在问题,但真正要写好代码,还是需要你对软件设计有着深入的学习。

总结时刻

  这一讲,我们讨论的是与封装有关的坏味道:

  火车残骸的代码就是连续的函数调用,它反映的问题就是把实现细节暴露了出去,缺乏应有的封装。重构的手法是隐藏委托关系,实际就是做封装。软件行业有一个编程指导原则,叫迪米特法则,可以作为日常工作的指导,规避这种坏味道的出现。

  基本类型偏执就是用各种基本类型作为模型到处传递,这种情况下通常是缺少了一个模型。解决它,常用的重构手法是以对象取代基本类型,也就是提供一个模型代替原来的基本类型。基本类型偏执不局限于程序设计语言提供的基本类型,字符串也是这种坏味道产生的重要原因,再延伸一点,集合类型也是。

  这两种与封装有关的坏味道,背后体现的是对构建模型了解不足,其实,也是很多程序员在软件设计上的欠缺。想成为一个更好的程序员,学习软件设计是不可或缺的。

  如果今天的内容你只能记住一件事,那请记住:构建模型,封装散落的代码

  

思考题

  这一讲提到的坏味道可以说是在代码里随处可见,也挑战了很多人的编程习惯。我想请你结合实际的工作,谈谈你对这一讲内容的理解,欢迎在留言区分享你的看法。