《代码之丑》学习总结(下)

Demon.Lee 2023年08月27日 895次浏览

接着上篇总结,这篇文章继续梳理剩下的几种坏味道。

8 缺乏封装:火车残骸和基本类型偏执

说起火车残骸(Train Wreck)可能会让人一愣,但如果提及迪米特法则(Law of Demeter),可能就明白了:

又被称作最少知识原则(Principle of Least Knowledge)

  1. 每个单元对于其他的单元只能拥有有限的知识:只是与当前单元紧密联系的单元;
  2. 每个单元只能和它的朋友交谈:不能和陌生单元交谈;
  3. 只和自己直接的朋友交谈。

这个原理的名称来源于希腊神话中的农业女神,孤独的Demeter

下面的示例代码就像火车残骸一般,断得一节一节的:

System.out.println("getBean for: " + beanName);

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

之所以说它们有坏味道,是因为我们必须了解 System/PrintStream 和 Book/Author 的细节才能编写代码,这无疑就增加了成本,而解决方案就是将它们封装起来:

class Book {
  private Author author;
  ...
  
  public String getAuthorName() {
    return this.author.getName();
  }
  
  ...
}

可能有的小伙伴一看这里,就脱口而出:这不就是 Getter/Setter 方法吗?我用 IDE 自带的快捷键就能生成。

对不起,不是。这里之所以要有一个 getAuthorName()/getName() 方法,是因为我们有这样一个行为

一个好的封装是基于行为的。要摆拖初级程序员的水平,先从少暴露细节开始。

如果可以,请禁用相关插件中的 Getter/Setter/Data 方法,以 lombok 为例:

lombok.setter.flagUsage = error
lombok.getter.flagUsage = error
lombok.data.flagUsage = error

我们要考虑的是,这个类要提供哪些行为,而不是一上来就让 Getter/Setter 布满屏幕,我们要的是业务动作,而非简简单单的数据展示。

但需要指出的是,之前说过的函数式编程,好像跟火车残骸很像,但它们不是。函数式编程中的表达是声明式的,而不是命令式,二者不能混淆

再说说基本类型偏执,对象健身操中说:封装所有的原生类型和字符串。也就是说,尽量不要使用基本类型,而是把它们封装成类,以表现出业务逻辑:

class Book {
  ...
  private double price;
  ...
}

这里用一个 double 属性表示书籍的价格,有什么问题?

  • 如何校验价格不能为负数?
  • 如何显示元、角、分等各种格式?
  • 如何显示小数后两位?
  • ……

也许有人会说,我把这些方法都写在 Book 类里面不就完了?这并非好方案,因为没有体现出封装:

class Book {
  ...
  private Price price;
  ...
}

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

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

有了 Price 类,业务逻辑就体现出来了,这也是大家经常说的充血模型。还有一种类似的问题,就是集合

class UserBO {
  ...
  private List<Book> books;
  ...
}

这里假设用户业务模型中有一个字段表示该用户拥有的书籍列表,其实这也是缺乏封装的,更好的方案是:

class UserBO {
  ...
  private Books books;
  ...
}

class Books {
  ...
  private List<Book> books;
  ...
}

Books 单独拎出来,里面是什么逻辑,完全由 Books 决定,哪天把 List<Book> 调整为 Set<Book> ,变动也不会扩散,只需要改 Books 类就好了。

郑晔老师说,封装之所以有难度,就在于这是一个构建模型的过程,而模型构建,是一种设计工作,需要有意识地训练,把散落在各处的代码集结到一起。

9 可变的数据

如果问 Java 程序中,哪种类型的代码会让数据发生不可控的变化,Setter 方法可能会排到 No.1。上文已经提到,要将这类操作禁用掉。

class User {
  private String id;
  private String name;
  ...
  
  public void setId(String id){
    this.id = id;
  }
  
  public void setName(String name){
    this.name = name;
  }
}

封装就是为了应对数据的变化,但比数据变化更可怕的是:不可控的变化。这些屏幕满天飞的 Setter 方法就会导致这样的结果,你不知道谁在什么时候就调用一个 setName 方法,然后把你的类实例给搞崩掉。

但我就是要改名字呢?我们应该按照业务动作封装一个函数,如果是重命名,就是 rename,如果是重置名称,就是 resetName

class User {
  ...
  private String name;
  ...
  
  public void rename(String name){
    this.name = name;
  }
  
  public void resetName() {
    this.name = ...;
  }
}

既然数据可变的风险这么大,最好的方法就是让它不能变,也就是不可变类,比如 Java 中的 String/LocalDateTime 等类:

public final class String implements ...{
    ...
    public String substring(int beginIndex) {
        return substring(beginIndex, length());
    }

    public String substring(int beginIndex, int endIndex) {
        int length = length();
        checkBoundsBeginEnd(beginIndex, endIndex, length);
        if (beginIndex == 0 && endIndex == length) {
            return this;
        }
        int subLen = endIndex - beginIndex;
        return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen)
                          : StringUTF16.newString(value, beginIndex, subLen);
    }

    ...
}

final class StringLatin1 {
    ...
    public static String newString(byte[] val, int index, int len) {
        if (len == 0) {
            return "";
        }
        return new String(Arrays.copyOfRange(val, index, index + len), LATIN1);
    }
    ...
}

public final class LocalDateTime implements ...{
    ...
    public LocalDateTime plusDays(long days) {
        LocalDate newDate = date.plusDays(days);
        return with(newDate, time);
    }
  
    private LocalDateTime with(LocalDate newDate, LocalTime newTime) {
        if (date == newDate && time == newTime) {
            return this;
        }
        return new LocalDateTime(newDate, newTime);
    }
    ...
}

这其实是来自函数式编程中的概念,任何改动,都会生成一个新副本,而不修改原有的对象。既然原有对象不再被改变,那自然是安全的。不可变类的设计主要有三点:

  • 所有的字段只在构造函数中初始化;
  • 所有的方法都是纯函数;
  • 如果需要有改变,返回一个新的对象,而不是修改已有字段。

什么是纯函数,维基百科上的定义是这样的:

在程序设计中,若一个函数符合以下要求,则它可能被认为是纯函数:

  • 此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关;
  • 该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。

为此,尽可能编写不可变类是我们追求的目标。在 DDD 中有实体和值对象的概念,对于实体,应该尽可能限制数据变化,对于值对象则要设计成不可变类。

10 变量声明与赋值分离

相信很多人看到这个小标题会大吃一惊,这么简单的东西也有问题?确实有点挑战认知:

int total = 0;
char *p = NULL;
char arr[];
...
  
total = ...;
p = ...;
arr = ...;

上面一段 C 语言代码的样例,有什么问题?大家不都是这么写的吗?

这种写法其实已经过时了,之所以要将变量的声明与赋值分开,只是因为当时的计算机才刚刚开始发展,为了减小编译器的编写难度才这样做的。很多 Java 程序员从 C 转过来的,仍然这么写,其实已经落伍了:

变量应该尽量在一次性完成初始化,即变量声明与赋值不分离。

EpubStatus status = null;
CreateEpubResponse response = createEpub(request);
if (response.getCode() == 201) {
  status = EpubStatus.CREATED;
} else {
  status = EpubStatus.TO_CREATE;
}

可以看到,上面的 status 字段第一次赋值其实是个假赋值,没有任何作用,而后面的一大坨逻辑,就是为了拿到值,这其实是很混乱的:即把赋值过程与业务逻辑处理混在了一起。从声明到赋值的距离太远,注定需要我们花费额外的精力去理解,而更简洁的方案并不难:

final CreateEpubResponse response = createEpub(request);
final EpubStatus status = toEpubStatus(response);


private EpubStatus toEpubStatus(final CreateEpubResponse response) {
  if (response.getCode() == 201) {
    return EpubStatus.CREATED;
  }
    return EpubStatus.TO_CREATE;
}

除了将变量与赋值一次搞定之外,我们还在每个变量的前面加上了 final 修饰符,这么做就是为了防止它们被二次修改,也就是不可变变量。

在能够使用 final 的地方尽量使用 final,限制变量的赋值,这其中包括普通变量声明,参数声明,类字段声明,以及类和方法的声明。

另一个经常出现问题的地方就是集合的声明与赋值,比如:

List<Permission> permissions = new ArrayList<>();
permissions.add(Permission.BOOK_READ);
permissions.add(Permission.BOOK_WRITE);
private static Map<Locale, String> CODE_MAPPING = new HashMap<>();
...

static {
  CODE_MAPPING.put(LOCALE.ENGLISH, "EN");
  CODE_MAPPING.put(LOCALE.CHINESE, "CH");
}

这些其实都是坏味道,尤其是下面的 Map 变量定义,引入一个 static 静态代码段。有人可能抱怨说,Java 8 没有好的方式,这其实是借口,我们完全可以用 Google Guava 库 来实现:

List<Permission> permissions = ImmutableList.of(
  Permission.BOOK_READ, 
  Permission.BOOK_WRITE
);

private static Map<Locale, String> CODE_MAPPING = ImmutableMap.of(
  LOCALE.ENGLISH, "EN",
  LOCALE.CHINESE, "CH"
);

而 Java 9 以后,JDK 已经内置了更简洁的方式,而且返回的实例也是不可变的:

List<Permission> permissions = List.of(
  Permission.BOOK_READ, 
  Permission.BOOK_WRITE
);

private static Map<Locale, String> CODE_MAPPING = Map.of(
  LOCALE.ENGLISH, "EN",
  LOCALE.CHINESE, "CH"
);

两种方式的差别除了可读性/可维护性外,还有一点:声明一个集合,然后往里面添加元素,这其实是命令式编程(怎么做);而一次性完成声明与赋值,是声明式编程(做什么),即我要一个包含两个元素的集合。

用声明式的标准来看代码,是一个发现代码坏味道的重要参考。

另外提一下,Java 7 中引入的 try-with-resource,它让我们不用把变量的声明与赋值拆开:

try (InputStream is = new FileInputStream(...)) {
  ...
}

11 依赖混乱

程序中的依赖是怎么来的,仔细想一想,其实源于我们对系统的拆分,子系统,子模块,包,类等等。拆的越细,依赖的东西可能就越多,由此可能会带来一些混乱。

Spring 中的 MVC 框架,我们经常这样写:

@PostMapping("/books")
public NewBookResponse createBook(final NewBookRequest request) {
  boolean result = this.service.createBook(request);
  ...
}

有什么问题?service 层直接依赖 controller 层的参数,是不可取的,因为 service 层里面更多的是核心业务逻辑处理,相对来说要稳定一些。也就是说,service 层不应跟 rest 接口参数绑定,如果哪天要新增 rpc 接口,service 层就得维护两套逻辑。

再回到 controller 层的入参,一般来说,我们要对其进行入参校验和参数转换,这些工作放在 service 层并不合适。

之所以让人有些纠结,是因为这里缺少了一个模型,也被称为防腐层(Anti-corruption layer, ACL):

class NewBookParameter {
  ...
}

class NewBookRequest {
  public NewBookParameters toNewBookParameter() {
    ...
  }
}

@PostMapping("/books")
public NewBookResponse createBook(final NewBookRequest request) {
  NewBookParameters newBookParameters = request.toNewBookParameter();
  boolean result = this.service.createBook(newBookParameters);
  ...
}

**注意:**这里 NewBookRequest(高层)依赖 NewBookParameters(低层)是可以的,但反过来不行。

还有一种依赖混乱的场景:业务代码中混杂着与业务无关的东西,比如:

@Task
public void sendBook() {
  try {
    this.service.sendBook();
  } catch (Throwable t) {
    this.feishuSender.send(new SendFailure(t)));
    throw t;
  }
}

使用飞书来发送任务失败的消息,有什么问题?问题就出在这个飞书上,它是一种具体实现,与业务无关,它很可能会被其他实现替代,比如微信、短信、钉钉等等。为此,我们需要引入一个新模型,抽象出业务含义,即失败消息发送者:

interface FailureSender {
  void send(SendFailure failure);
}

class FeishuFailureSenderS implements FailureSender {
  ...
}

@Task
public void sendBook() {
  try {
    this.service.sendBook();
  } catch (Throwable t) {
    this.failureSender.send(new SendFailure(t)));
    throw t;
  }
}

了解 SOLID 原则的小伙伴,可能已经知道,这里违背了依赖倒置原则

高层模块不应依赖于低层模块,二者应依赖于抽象。

High-level modules should not depend on low-level modules. Both should depend on abstractions.

抽象不应依赖于细节,细节应依赖于抽象。

Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

识别一个东西是业务的一部分,还是一个可以替换的实现,可以问问自己,如果不用它,是否还有其它的选择?如果有,那就应该构建一个模型,将其抽象出来。


12 不一致的代码

一致性,是指团队内应该对代码编写有统一的标准,有些是行业标准,有些是团队自身制定的标准。比如业务服务层,大家的命名可能都是 xxxService,如果出现不同的名字,说明有特殊的含义。

不一致主要有下面三种:

(1)命名中的不一致

enum DistributionChannel {
  WEBSITE,
  KINDLE_ONLY,
  ALL
}

示例代码中的书籍分发渠道,第二个 KINDLE_ONLY 表示只有 Kindle 渠道,那第一个 WEBSITE 呢?表示只有网站。问题就出在这,如果都表示一个独立渠道,要么都加 ONLY,要么都不加:

enum DistributionChannel {
  WEBSITE,
  KINDLE,
  ALL
}

// 或
enum DistributionChannel {
  WEBSITE_ONLY,
  KINDLE_ONLY,
  ALL
}

从代码简洁度来说,不加 ONLY 显然更好。

(2)方案中的不一致

一个经常出现的案例是,工程中引入了很多类似的类库,比如处理字符串为空判断之类的工具包,像 Google Guava,Apache Commons Lang 等都有支持,但每个开发人员习惯不同,混着用的情况可能就发生了。针对这类场景,我们应该在团队内约定,使用哪一个类库。一般来说,Google Guava 库更现代,优先推荐。

还有一个案例是 Java 中的日期时间相关类,在 Java 8 之前的操作可能是:

public String nowTimestamp() {
  DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  Date now = new Date();
  return format.format(now);
}

但在 Java 8 及之后的项目中,我们就不能再这样写了,应该使用新版本的 LocalDateTime:

public String nowTimestamp() {
  LocalDateTime now = LocalDateTime.now()
  return now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}

如果在新版本中还使用老版本的 API,这也是一种不一致,我们要与时俱进。

(3)代码中的不一致

最后一种不一致,主要体现在代码的层次上,在一个函数中有些是业务动作,有些是业务动作的细节,比如下面这个代码示例:

public void createBook(final List<BookId> bookIds) throws IOException {
  List<Book> books = bookService.getApprovedBook(bookIds)
  CreateBookParameter parameter = toCreateBookParameter(books)
  HttpPost post = createBookHttpRequest(parameter)
  httpClient.execute(post)
}

这段代码中的第 2 行是业务动作,而后面 3 行是业务动作的细节(构造参数并调用函数),所以它们并不在一个层次上,为此我们需要将其抽取出来:

public void createBook(final List<BookId> bookIds) throws IOException {
  List<Book> books = bookService.getApprovedBook(bookIds)
  createRemoteBook(books)
}

private void createRemoteBook(List<Book> books) throws IOException {
  CreateBookParameter parameter = toCreateBookParameter(books)
  HttpPost post = createBookHttpRequest(parameter)
  httpClient.execute(post)
}

现在再看这段代码,createBook 里面都是业务动作,而 createRemoteBook 里面都是业务动作细节,它们在层次上是一致的。在此基础上,我们还可以进一步重构,比如 createRemoteBook 是一个通用操作,与当前类无关,那么就能将其提取到一个独立的类中。

可以看到,当有了不同层次的视角,分离关注点,代码在我们眼中就不只是一个个函数,而是业务逻辑,表现的是一个个不同的行为。


13 落后的代码风格

其实在前面的梳理中已经提及了一部分,比如 for 循环等,这里仍然以 Java 为例,再做一些补充。既然 JDK 在不断推新,作为开发者也要与时俱进,不断抛弃之前已经过气的写法,让代码更简洁,更安全。

(1)Optional

NullPointerException 是 Java 程序员最熟悉的一个异常,这背后体现的是对容错处理的忽视。这不能完全怪程序员,如果可以,编程语言层面应该要有方案来避免它。Java 8 引入 Optional 就是为了弥补这个错误,如果可以,请在所有可能会返回 null 地方,使用 Optional 来替代。

而从另一方面说,对 null 判断无疑会使代码量膨胀,并且不美观:

Object a = ...;
if(a == null){
  return;
}

Object b = a.xxx();
if(b == null){
  return;
}

b.xxx();

...

笔者曾单独对这一块进行了总结,有兴趣可以参阅这里:Optional——让你远离空指针的利器

(2)函数式编程

函数编程的一个核心点是,大部分操作都可以归结为列表转换。其主要动作有下面几种:

  • filter:过滤

接受一个条件判断(返回值为 boolean 类型)的函数参数,然后返回符合条件的流(截图来自《Java实战(第2版)》,下同):

  • map:映射

接受一个函数作为参数,这个函数会被应用到流中的每个元素上,并将其映射(或者说转换)成一个新的元素:

  • reduce:规约

通过一个函数,把流中的元素汇总或计算,比如之前示例中使用 collect 函数来将流中的所有元素组合成一个 List,另外还有计算流中的最大值,最小值,求和等。

如果想将 Java 8 及以后版本中的流运用的如火纯青,《Java实战(第2版)》这本书是一个不错的选择,下面是书中列出的一部分流操作的 API,供参考:


小结

到此,笔者便将 13 种常见的代码问题梳理完毕,知道并不等于做到,每当在工作中意识到问题时,都应该停下来想一想,这么做是不是一种坏味道。

写出好代码,本质上还是要对软件设计有深刻理解,封装、多态、函数式编程、依赖倒置原则等等,一方面是道,一方面是术,二者相辅相成。

最后,不断革新,及时换血,把过气的知识和方法抛弃掉,哪怕它们已经成为日常开发中一部分。郑晔老师说,作为一个精进的程序员,我们要不断地学习“新”的代码风格,改善自己的代码质量,不让自己停留在上一个时代。