tulip notes
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Star-Lord

希望一天成为大师的学徒
首页
  • 学习笔记

    • 《Vue》
  • 踩坑日记

    • JavaScript
  • MQ
  • Nginx
  • IdentityServer
  • Redis
  • Linux
  • Java
  • SpringBoot
  • SpringCloud
  • MySql
  • docker
  • 算法与设计模式
  • 踩坑与提升
  • Git
  • GitHub技巧
  • Mac
  • 网络
  • 项目构建合集
  • 一些技巧
  • 面试
  • 一些杂货
  • 友情链接
  • 项目发布
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Java基础与面向对象

  • 高级进阶

  • 并发合集

  • JVM合集

  • 实战与细节

  • 代码之丑与提升

    • 如何精准命名
    • 重复代码、长函数与大类的优化
    • 缺乏封装与可变的数据
      • 缺乏封装
        • 火车残骸
        • 基本类型偏执
      • 可变的数据
        • 满天飞的 Setter
        • 可变的数据
  • 《Java》学习笔记
  • 代码之丑与提升
EffectTang
2024-10-26
目录

缺乏封装与可变的数据

# 缺乏封装与可变的数据

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

# 缺乏封装

# 火车残骸

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

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

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

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

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

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

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

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

  class Book {

    ...

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

    ...

  }

  String name = book.getAuthorName();
1
2
3
4
5
6
7
8
9
10
11
12
13

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

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

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

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

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

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

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

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

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

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

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

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

# 基本类型偏执

我们再来看一段代码:

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

    ...

  }
1
2
3
4
5

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

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

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

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

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

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

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

问题是,如果使用 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;
    }
  }
1
2
3
4
5
6
7
8
9
10
11

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

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

  }
1
2
3
4
5

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

  public Books extends List<Book> {

    ...

  }
1
2
3
4
5

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

  public Books  {

    private List<Book> books;

    ...

  }
1
2
3
4
5
6
7

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

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

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

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

# 可变的数据

# 满天飞的 Setter

还记得我们在[开篇词]里提到过的一个坏味道吗?我们复习一下:

  public void approve(final long bookId) {
    ...
    book.setReviewStatus(ReviewStatus.APPROVED);
    ...
  }
1
2
3
4
5

这是一段对作品进行审核的代码,通过 bookId,找到对应的作品,接下来,将审核状态设置成了审核通过。

我当时之所以注意到这段代码,就是因为这里用了 setter。setter 往往是缺乏封装的一种做法。对于缺乏封装的坏味道,我们上节课已经用了一讲的篇幅在说,我提到,很多人在写代码时,写完字段就会利用 IDE 生成 getter,实际情况往往是,生成 getter 的同时,setter 也生成了出来。setter 同 getter 一样,反映的都是对细节的暴露。

这就意味着,你不仅可以读到一个对象的数据,还可以修改一个对象的数据。相比于读数据,修改是一个更危险的操作。

我在《[软件设计之美]》专栏里讲函数式编程的不变性时,曾经专门讨论过可变的数据会带来许多问题,简言之,你不知道数据会在哪里被何人以什么方式修改,造成的结果是,别人的修改会让你的代码崩溃。与之相伴的还有各种衍生出来的问题,最常见的就是我们常说的并发问题。

可变的数据是可怕,但是,比可变的数据更可怕的是,不可控的变化,而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。

缺乏封装再加上不可控的变化,在我个人心目中,setter 几乎是排名第一的坏味道。

在开篇词里,我们针对代码给出的调整方案是,用一个函数替代了 setter,也就是把它用行为封装了起来:

  public void approve(final long bookId) {

    ...
    book.approve();
    ...

  }
1
2
3
4
5
6
7

通过在 Book 类里引入了一个 approve 函数,我们将审核状态封装了起来。

  class Book {

    public void approve() {
      this.reviewStatus = ReviewStatus.APPROVED;
    }
  }
1
2
3
4
5
6

作为这个类的使用者,你并不需要知道这个类到底是怎么实现的。更重要的是,这里的变化变得可控了。虽然审核状态这个字段还是会修改,但你所有的修改都要通过几个函数作为入口。有任何业务上的调整,都会发生在类的内部,只要保证接口行为不变,就不会影响到其它的代码。

setter 破坏了封装,相信你对这点已经有了一定的理解。不过,有时候你会说,我这个 setter 只是用在初始化过程中,而并不需要在使用的过程去调用,就像下面这样:

  Book book = new Book();

  book.setBookId(bookId);
  book.setTitle(title);
  book.setIntroduction(introduction);
1
2
3
4
5

实际上,对于这种只在初始化中使用的代码,压根没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数:

  Book book = new Book(bookId, title, introduction);
1

消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)。总而言之,setter 是完全没有必要存在的。

在今天的软件开发中,人们为了简化代码的编写做出了各种努力,用 IDE 生成的代码是一种,还有一种常见的做法就是,通过工具和框架生成相应代码的。在 Java 世界中,Lombok 就是这样的一种程序库,它可以在编译的过程中生成相应的代码,而我们需要做的,只是在代码上加上对应的 Annotation。它最大的优点是不碍眼,也就是不会产生大量可以看见的代码。因为它的代码是在编译阶段生成的,所以,那些生成的代码在源码级别上是不存在的。下面就是一个例子:

  @Getter
  @Setter
  class Book {

    private BookId bookId;

    private String title;

    private String introduction;

  }
1
2
3
4
5
6
7
8
9
10
11

这里的 @Getter 表示为这个类的字段生成 getter,相应地,@Setter 表示生成 setter。也是因为这些 Annotation 的存在,让代码看上去清爽了不少。所以,像 Lombok 这样的程序库赢得了许多人的喜爱。

# 可变的数据

我们反对使用 setter,一个重要的原因就是它暴露了数据,我们前面说过,暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug。在上面的代码中,我们把 setter 封装成一个个的函数,实际上是把不可控的修改限制在一个有限的范围内。

那么,这个思路再进一步的话,如果我们的数据压根不让修改,犯下各种低级错误的机会就进一步降低了。没错,在这种思路下,可变数据(Mutable Data)就成了一种坏味道,这是 Martin Fowler 在新版《重构 (opens new window)》里增加的坏味道,它反映着整个行业对于编程的新理解。

这种想法源自函数式编程这种编程范式。在函数式编程中,数据是建立在不改变的基础上的,如果需要更新,就产生一份新的数据副本,而旧有的数据保持不变。随着函数式编程在软件开发领域中的地位不断提高,人们对于不变性的理解也越发深刻,不变性有效地解决了可变数据产生的各种问题。

函数式编程(Functional Programming, FP)是一种编程范式,它将计算视为数学函数的求值,并避免了改变状态和可变数据。在函数式编程中,程序的构造单元是函数,这些函数可以像其他数据类型一样被传递、返回或者存储。

函数式编程强调不可变性和无副作用的函数(纯函数),并鼓励使用递归来解决问题。

所以,Martin Fowler 在《重构》第二版里新增了可变数据作为一种坏味道,这其实反映了行业的理解也是在逐渐推进的。不过,Martin Fowler 对于可变数据给出的解决方案,基本上是限制对于数据的更新,降低其风险,这与我们前面提到的对 setter 的封装如出一辙。

解决可变数据,还有一个解决方案是编写不变类。

我在《[软件设计之美]》专栏中已经讲过函数式编程的不变性,其中的关键点就是设计不变类。Java 中的 String 类就是一个不变类,比如,如果我们把字符串中的一个字符替换成另一个字符,String 类给出的函数签名是这样的:

  String replace(char oldChar, char newChar);
1

其含义是,这里的替换并不是在原有字符串上进行修改,而是产生了一个新的字符串。

那么,在实际工作中,我们怎么设计不变类呢?要做到以下三点:

所有的字段只在构造函数中初始化;

所有的方法都是纯函数;

如果需要有改变,返回一个新的对象,而不是修改已有字段。

回过头来看我们之前改动的“用构造函数消除 setter”的代码,其实就是朝着这个方向在迈进。如果按照这个思路改造我们前面提到的 approve 函数,同样也可以:

  class Book {

    public Book approve() {
      return new Book(..., ReviewStatus.APPROVED, ...);
    }
  }
1
2
3
4
5
6

这里,我们创建出了一个“其它参数和原有 book 对象一模一样,只是审核状态变成了 APPROVED ”的对象。

在 JDK 的演化中,我们可以看到一个很明显的趋势,新增的类越来越多地采用了不变类的设计,比如,用来表示时间的类。原来的 Date 类里面还有各种 setter,而新增的 LocalDateTime 则一旦初始化就不会再修改了。如果要操作这个对象,则会产生一个新的对象:

  LocalDateTime twoDaysLater = now.plusDays(2);
1

就目前的开发状态而言,想要完全消除可变数据是很难做到的,但我们可以尽可能地编写一些不变类。

一个更实用的做法是,区分类的性质。我《[软件设计之美]》中讲 DDD 的战术设计时提到过,我们最核心要识别的对象分成两种,实体和值对象。实体对象要限制数据变化,而值对象就要设计成不变类。

如果你还想进一步提升自己对于不变性的理解,我们可以回到函数式编程这个编程范式的本质,它其实是对程序中的赋值进行了约束。基于这样的理解,连赋值本身其实都会被归入到坏味道的提示,这才是真正挑战很多人编程习惯的一点。

不过,我们现在看到,越来越多的语言中开始引入值类型,也就是初始化之后便不再改变的值,比如,Java 的 Valhalla 项目 (opens new window),更有甚者,像 Rust 这样的语言中,缺省都是值类型,而如果你需要一个可以赋值的变量,反而要去专门的声明。

#code review
上次更新: 2025/04/23, 16:23:16
重复代码、长函数与大类的优化

← 重复代码、长函数与大类的优化

最近更新
01
面向切面跟自定义注解的结合
05-22
02
时间跟其他数据的序列化
05-19
03
数据加密与安全
05-17
更多文章>
Theme by Vdoing | Copyright © 2023-2025 EffectTang
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式