火车残骸和基本类型偏执问题解决方案

2023-02-13 15:11:10 浏览数 (2)

坏味道:缺乏封装。封装,将碎片式代码封装成可复用模块。但不同级别程序员对封装理解程度差异大,往往写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里。

1 火车残骸

获得一篇博客作者名字:

代码语言:javascript复制
String name = article.getAuthor().getName();

博客里有作者信息,想要获得作者名,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,这有问题!

是不是感觉自己无法理解封装了?

若你想写出上面这段,是不是得先了解Article、Author两个类的实现细节?即我们得知道,作者的姓名存储在作品的作者字段

这就是问题:当你须先了解一个类的细节,才能写代码,这只能说明这封装不优雅。

翻翻你手头的项目,这种在一行代码中有连续多个方法调用的情况是不是随处可见?

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

解决这种代码的重构方案叫隐藏委托关系(Hide Delegate),即把这种调用封装:

代码语言:javascript复制
class Book {
  ...
  public String getAuthorName() {
    return this.author.getName();
  }
  ...
}

String name = book.getAuthorName();

2 产因

对封装理解不够,大部分人对封装仅停留在:数据结构 算法。 学习数据结构时,写代码都是拿到各种细节直接操作,但那是在做练习,不是工程。有人编写一个新类:

  • 第一步是写出这类要用的字段
  • 然后给这些字段生成各种 getXXX

很多语言或框架提供的约定就是基于这种 getter的,就像 Java 里的 JavaBean,相应配套工具lombok也很方便。让暴露越来越容易,封装反而无人在意了。

但要想成为架构师,就从少暴露细节开始。声明完一个类的字段后,请停下生成 getter 的手,思考类应该提供的行为。

3 迪米特法则

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

该原则需要思考:哪些是直接朋友、陌生人。 火车残骸代码就是没考虑这些问题,直接闷头写代码。写时一时爽,重构火葬场。

按迪米特法写代码,会不会让代码里有太多简单封装的方法?

有可能,不过,这也是单独解决这一个坏味道可能带来的结果。这种代码本质是缺乏对封装的理解,而一个好的封装需要基于行为。所以,把视角再提升,应考虑类应该提供哪些行为,而非简单地把数据换一种形式呈现就止步了。

有些内部 DSL 的表现形式也是连续的方法调用,但 DSL 是声明性的,在说做什么(What),而这里的坏味道是在说怎么做(How),二者抽象级别不同,不要混谈。

4 基本类型偏执

代码语言:javascript复制
public double getEpubPrice(final boolean highQuality,
						   final int chapterSequence) {
  ...
}

根据章节信息获取 EPUB 价格。问题在返回值类型,即价格类型。在DB存储价格时,就是用一个浮点数,用 double 可保证计算的精度,这设计有问题?确实,这就是很多人使用基本类型(Primitive)作为变量类型思考的角度。但这种采用基本类型设计缺少一个模型。

虽价格本身用浮点数存储,但价格和浮点数本身不是同一概念,有着不同行为需求。一般要求商品价格大于 0,但 double 类型本身没这限制。

以“价格大于0”这个需求为例,使用 double 类型怎么限制?

代码语言:javascript复制
if (price <= 0) {
  throw new IllegalArgumentException("Price should be positive");
}

如果使用 double 作为类型,那我们要在使用的地方都保证价格的正确性,像这样的价格校验就应该是使用的地方到处写。若补齐这缺失的模型,可引入一个 Price 类型,校验就可放在初始化时:

代码语言:javascript复制
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 类里提供一个方法:

代码语言:javascript复制
public double getDisplayPrice() {
  BigDecimal decimal = new BigDecimal(this.price);
  return decimal.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
}

使用基本类型和使用继承出现的问题异曲同工。大部分程序员都了解组合优于继承,即不要写出这样的代码:

代码语言:javascript复制
public Books extends List<Book> {
  ...
}

而应该写成组合:

代码语言:javascript复制
public Books  {
  private List<Book> books;
  ...
}
  • 把Books写成继承,是因为在开发者眼里,Books 就是一个书的集合
  • 有人用 double 做价格的类型,因为在他看来,价格就是一个 double

误区在于,一些程序员只看到模型相同之处,却忽略差异。Books 可能不需要提供 List 的所有方法,价格的取值范围与 double 也有差异。

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

这种以基本类型为模型的坏味道称为基本类型偏执(Primitive Obsession)。这基本类型,不限于程序设计语言提供的各种基本类型,像字符串也是。很多对集合类型(比如数组、List、Map 等等)的使用也属于这坏味道:

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

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

所以,真正要写好代码,要对软件设计有深入学习。

5 总结

与封装有关的坏味道:

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

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

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

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

构建模型,封装散落的代码。

6 怎样的封装算高内聚?

链式调用不一定都是火车残骸,比如:

  • builder模式,每次调用返回的都是自身,不牵涉到其他对象,不违反迪米特法则
  • java stream操作,就是声明性操作。

构建模型还有一个好处是加了一层抽象,屏蔽了外部变化,类似防腐层。 比如DDD中领域内只处理本领域的对象,使用其他领域的对象要先经过转换而非直接使用。

JavaBean,用MyBatis Genarater或Lombok生成都会有Setter方法,这样DB查询或接受参数时,数据自动映射到这个对象。如果不用setter,怎么赋值? 现在的数据库映射用的都是反射实现,与setter关系不大。

  1. 若你的编码方式是置顶向下的,且当前层都只面向意图定义空类和空函数。写出提倡的这种风格其实很正常。
  2. 结合1描述的编码方式。顶层类中不会有基础类型,每个属性的类型都会是一个面向意图的类来承接。顶层函数的实现部分只会有一个个函数,哪怕函数实现只有一行。

0 人点赞