坏味道:缺乏封装。封装,将碎片式代码封装成可复用模块。但不同级别程序员对封装理解程度差异大,往往写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里。
获得一篇博客作者名字:
String name = article.getAuthor().getName();
博客里有作者信息,想要获得作者名,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,这有问题!
若你想写出上面这段,是不是得先了解Article、Author两个类的实现细节?即我们得知道,作者的姓名存储在作品的作者字段。
这就是问题:当你须先了解一个类的细节,才能写代码,这只能说明这封装不优雅。
翻翻你手头的项目,这种在一行代码中有连续多个方法调用的情况是不是随处可见?
Martin Fowler 在《重构》中给这种坏味道起的名字叫过长的消息链(Message Chains),而有人则给它起了一个更为夸张的名字:火车残骸(Train Wreck),形容这样的代码像火车残骸一般,断得一节一节。
解决这种代码的重构方案叫隐藏委托关系(Hide Delegate),即把这种调用封装:
class Book {
...
public String getAuthorName() {
return this.author.getName();
}
...
}
String name = book.getAuthorName();
对封装理解不够,大部分人对封装仅停留在:数据结构+算法。 学习数据结构时,写代码都是拿到各种细节直接操作,但那是在做练习,不是工程。有人编写一个新类:
很多语言或框架提供的约定就是基于这种 getter的,就像 Java 里的 JavaBean,相应配套工具lombok也很方便。让暴露越来越容易,封装反而无人在意了。
但要想成为架构师,就从少暴露细节开始。声明完一个类的字段后,请停下生成 getter 的手,思考类应该提供的行为。
该原则需要思考:哪些是直接朋友、陌生人。 火车残骸代码就是没考虑这些问题,直接闷头写代码。写时一时爽,重构火葬场。
按迪米特法写代码,会不会让代码里有太多简单封装的方法?
有可能,不过,这也是单独解决这一个坏味道可能带来的结果。这种代码本质是缺乏对封装的理解,而一个好的封装需要基于行为。所以,把视角再提升,应考虑类应该提供哪些行为,而非简单地把数据换一种形式呈现就止步了。
有些内部 DSL 的表现形式也是连续的方法调用,但 DSL 是声明性的,在说做什么(What),而这里的坏味道是在说怎么做(How),二者抽象级别不同,不要混谈。
public double getEpubPrice(final boolean highQuality,
final int chapterSequence) {
...
}
根据章节信息获取 EPUB 价格。问题在返回值类型,即价格类型。在DB存储价格时,就是用一个浮点数,用 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 可能不需要提供 List 的所有方法,价格的取值范围与 double 也有差异。
但 Books 问题相对容易规避,因为产生了一个新模型,有通用的设计原则帮助我们判断这个模型构建得是否恰当,而价格问题却不容易规避,因为这里没有产生新的模型,也就不容易发现问题。
这种以基本类型为模型的坏味道称为基本类型偏执(Primitive Obsession)。这基本类型,不限于程序设计语言提供的各种基本类型,像字符串也是。很多对集合类型(比如数组、List、Map 等等)的使用也属于这坏味道:
封装之所以有难度,在于它是一个构建模型的过程,而很多程序员写程序,只是用粗粒度理解写着完成功能的代码,没有构建模型意识;还有一些人以为划分模块就叫封装,所以,才会看到这些坏味道。
所以,真正要写好代码,要对软件设计有深入学习。
与封装有关的坏味道:
火车残骸的代码就是连续的函数调用,它反映的问题就是把实现细节暴露了出去,缺乏应有的封装。重构的手法是隐藏委托关系,实际就是做封装。软件行业有一个编程指导原则,叫迪米特法则,可以作为日常工作的指导,规避这种坏味道的出现。
基本类型偏执就是用各种基本类型作为模型到处传递,这种情况下通常是缺少了一个模型。解决它,常用的重构手法是以对象取代基本类型,也就是提供一个模型代替原来的基本类型。基本类型偏执不局限于程序设计语言提供的基本类型,字符串也是这种坏味道产生的重要原因,再延伸一点,集合类型也是。
这两种与封装有关的坏味道,背后体现的是对构建模型了解不足,其实,也是很多程序员在软件设计上的欠缺。想成为一个更好的程序员,学习软件设计是不可或缺的。
构建模型,封装散落的代码。
链式调用不一定都是火车残骸,比如:
构建模型还有一个好处是加了一层抽象,屏蔽了外部变化,类似防腐层。 比如DDD中领域内只处理本领域的对象,使用其他领域的对象要先经过转换而非直接使用。
JavaBean,用MyBatis Genarater或Lombok生成都会有Setter方法,这样DB查询或接受参数时,数据自动映射到这个对象。如果不用setter,怎么赋值? 现在的数据库映射用的都是反射实现,与setter关系不大。