跳转至

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 的手,转而让大脑开始工作,思考这个类应该提供的行为。

在软件行业中,有一个编程的指导原则几乎就是针对这个坏味道的,叫做迪米特法则(Law of Demeter),这个原则是这样说的:

  • 每个单元对其它单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的;
  • 每个单元只能与其朋友交谈,不与陌生人交谈;
  • 只与自己最直接的朋友交谈。

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

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

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

最后,还有一个问题我要提醒你一下。有些内部 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文集》)”这篇文章,里面有两个与此相关的条款,你可以作为参考:

  • 封装所有的基本类型和字符串;
  • 使用一流的集合。

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

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

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

总结时刻

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

  • 过长的消息链,或者叫火车残骸;
  • 基本类型偏执。

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

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

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

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

思考题

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

参考资料:

面向对象之封装:怎样的封装才算是高内聚?

DSL:你也可以设计一门自己的语言

精选留言(15)
  • qinsi 👍(49) 💬(1)

    个人理解: * 链式调用不一定都是火车残骸。比如builder模式,每次调用返回的都是自身,不牵涉到其他对象,不违反迪米特法则。又比如java stream操作,应该就是文中提到的声明性的操作 * 构建模型还有一个好处是增加了一层抽象,屏蔽了外部的变化,类似防腐层的作用。比如写可移植的c代码用typedef定义内部使用的类型而非直接使用基本类型,或是DDD中领域内只处理本领域的对象,使用其他领域的对象要先经过转换而不会直接使用

    2021-01-16

  • adang 👍(9) 💬(2)

    课程里讲的坏味道自己写的代码上都有,虽然知道不好,但是一直不知道应该怎样优化。没有好好系统地学习过软件设计,凭直觉感觉好或者不好,因为软件设计功底薄弱,明知道不好,却不知道怎样改进。几节课里,老师也一直在强调软件设计的重要性。发现自己确实需要系统的学习一下软件设计,好好扎扎马步,打实基本功了。

    2021-01-16

  • dev.bin 👍(7) 💬(2)

    今天说的我都干过,一直都没意识到,但总是觉得有许多重复的代码,看着别扭,能早点看到就好了

    2021-01-16

  • 克一 👍(7) 💬(1)

    请教老师个问题:Java 里的 JavaBean,用MyBatis Genarater或者Lombok生成都会有Setter方法,这样数据库查询或者接受参数时,数据自动映射到这个对象。如果不用setter的话,应该怎么赋值?

    2021-01-16

  • 大京 👍(5) 💬(1)

    我设计了一个客hu模型,包含客hu基本信息(证jian类型,证jian号码,名称),个人信息(有些客hu是自然人,客hu不是用户),企业信息,联xi电话List,地址List(注册地址,经营地址,身份证地址),等等;个人信息、企业信息、联xi电话等都是懒加载,需要用到的时候get才执行查询。如果按照本节的说法,可能这种设计就有问题,但是不知道怎么解决

    2021-01-20

  • 邵俊达 👍(4) 💬(2)

    对这句话不太明白「使用一流的集合」,请问什么是一流的集合?

    2021-08-10

  • 大京 👍(4) 💬(1)

    郑老师,我们实际开发中,每个模型都有对应的ModelService, service里专门负责该模型相关的业务逻辑,譬如某个Model有状态status字段, 如果单考虑模型封装,会在Model里写 激活 active(),取消 cancel(),冻结frozen(),实际状态set成什么service里完全不需要知道。但如果考虑service层就是写业务逻辑的,那Model只需要一个setStatus就行,他们还会觉得封装起来了service层还看不清了。

    2021-03-22

  • 杯莫停 👍(2) 💬(1)

    迪米特法则说的其实就是解耦,“火车残骸”链式调用String name = book.getAuthor().getName();是间接的Author和当前对象发生了直接关系,产生了耦合,也破坏了封装。所以应该通过直接联系的book对象第三方建立间接联系,所谓“隐藏委托关系”。 构建模型封装了数据和处理数据的逻辑,使得外部不需要关心数据的内部逻辑,只关心数据本身。这就是封装性。不知道理解的对不对。

    2022-07-02

  • AE86 👍(2) 💬(1)

    “根本的问题是缺乏对封装的理解,而一个好的封装是需要基于行为的,所以,如果把视角再提升一个角度,我们应该考虑的问题是类应该提供哪些行为,而非简简单单地把数据换一种形式呈现出来。” 文中提到的这句话,请问能有个较直观具体的例子么?个人感觉这句话很重要,但是理解稍有问题

    2021-01-20

  • 岁月神偷 👍(2) 💬(1)

    这一章的讲解令人醍醐灌顶,尤其是利用模型解决基本类型偏执的问题讲解上,那个天天环绕在身边的坏味道竟然拨云见雾,感谢

    2021-01-20

  • Geek_3b1096 👍(2) 💬(1)

    "根本没有构建模型的意识"...说得就是我啊

    2021-01-18

  • Jxin 👍(1) 💬(1)

    1.如果你的编码方式是置顶向下的,且当前层都只面向意图定义空类和空函数。那么写出郑大佬说提倡的这种风格其实很正常。 2.结合1描述的编码方式。顶层类中不会有基础类型,每个属性的类型都会是一个面向意图的类来承接。顶层函数的实现部分只会有一个个函数,哪怕函数实现只有一行。 3.曾经这样试过,写起来其实很舒服,多一点工作量其实也还好。但后面放弃了。主要还是与其他小伙伴合作的话会很难让人接受。毕竟认知是有差异的(非高低),达成共识并不容易。而如果达成共识的成本高过这种编码方式带来的收益,那么去坚持就没有太大必要了。(如果我的沉默成本足以说法其他人无条件信任,或者有借用大佬沉默成本的方式,或许可以拿个项目试试)

    2021-01-24

  • 刘大明 👍(1) 💬(1)

    String name = book.getAuthor().getName(); 最开始没有往下看的时候,根本就没有意识到这个代码有什么坏味道。 这是最可怕的,你不知道你不知道的事情。

    2021-01-18

  • pc 👍(0) 💬(1)

    老师,我有个疑问: 封装基础类型,就是导致一个问题,出现很多类型转换,比如IDL中的类型和代码中定义的新类型肯定是需要转换的。之前就是遇到这个问题,需要各种转换,觉得很烦。 这种是没法避免的吗?

    2021-07-07

  • UncleNo2 👍(2) 💬(0)

    解决链式调用的方案,是否与处理大类的方案矛盾呢? (导致大类原因:字段未分组)

    2021-11-28