读书笔记 | 代码之丑2

2,050 阅读17分钟

前言

前面一篇文章我们介绍了一些代码坏味道,以及如何解决他们,比如命名、长函数和重复结构,本篇文章我们接着说一些常见的代码坏味道。

正文

本篇文章我们从一个大家很熟悉且讨厌的点开始,就大类。

大类

说起大类,人们就会和想到长函数一样,满满屏幕的代码,这滋味可不好受。类之所以称为了大类,一种表现形式就是前面文章所说的长函数,一个类只要有几个长函数,那么它肯定是一眼望不到边了。

大类还有一种表现形式,类里面有特别多的字段和函数,也许每个函数都不大,但是架不住数量多啊,这也是成为大类的一个主要原因。本节主要说第二种形式的大类,长函数导致的原因我们前面文章已经分析过了。

分模块

假如有人问:为什么不把所有代码都写到一个文件里?

你心里肯定觉得这个问题很傻,正经人谁会把项目代码都写在一个文件里。确实没人会这么做,把文件都写到一个文件里问题是什么呢?

一方面,相同的功能模块没法复用;另一方面,也是最关键的,把代码写在一个文件里,其复杂度会超出一个人能够掌握的认知范围。也就是说,一个人理解的东西是有限的,没有人能同时面对所有细节

人类面对复杂事物给出的解决方案是分而治之,所以我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上都是一种模块划分的方式。

当模块划分得足够细,人们面对的就不是细节,而是模块,理解成本就降低了。所以这样我们再来看本节所说的坏味道,如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了

大类的产生

要想理解如何拆分一个大类,我们需要知道,这些大类是如何变大的。

职责不单一

最容易产生大类的原因就是职责的不单一,我来看一段代码:

public class User {
  private long userId;
  private String name;
  private String nickname;
  private String email;
  private String phoneNumber;
  private AuthorType authorType;
  private ReviewStatus authorReviewStatus;
  private EditorType editorType;
  ...
}

这个User类拥有一个大类的典型特性,其中包含了一大堆字段;面对这样一个类时,我们要问的第一个问题就是,这个类的字段都是必需的吗?

我们仔细分析一下该类,首先是用户ID(userId)、姓名(name)、昵称(nickname)之类应该是一个用户的基本信息,后面的邮箱(email)和电话(phoneNumber)用作登录方式也和用户关联,这里放入User类也好理解。

再往后看,作者类型(authorType),这里表示作者是签约作者还是普通作者,签约作者可以设置作品的付费信息,而普通作者不能;后面的字段是作者审核状态(authorReviewStatus),也就是说,作者成为签约作者,需要一个审核过程,用该字段表示。

再往后,又有一个编辑类型(editorType)字段,这是因为编辑可以是主编,也可以是小编,他们权限是不一样的。

这还没有过完所有字段,不过相信你已经发现问题了。首先普通用户既不是作者,也不是编辑,作者和编辑这些相关的字段对于普通用户来说,都是没有意义的;其次对于成为作者的用户,编辑的意义也不大,因为不会成为编辑。

在这个类的设计里面,其实有普通用户、作者、编辑这3个不同的角色,都有不同的述求方向和关心的内容,为什么放在一块,仅仅因为他们都是这个系统的用户,所以放在一个类中。这种做法,严重违反了单一职责原则

单一职责原则非常重要,它可以让我们把模块的变化纳入考量,单一职责原则是衡量软件好坏的一把简单而有效的尺子。所以,破解大类的方法,关键就是能够把不同的职责拆分开来

回到上面的类中,在业务上有普通用户、作者和编辑3个身份,所以我们需要把系统中默认的User类给拆分开:

public class User {
  private long userId;
  private String name;
  private String nickname;
  private String email;
  private String phoneNumber;
  ...
}
public class Author {
  private long userId;
  private AuthorType authorType;
  private ReviewStatus authorReviewStatus;
  ...
}
public class Editor {
  private long userId;
  private EditorType editorType;
  ...
}

这里根据业务需求拆分出了Author和Editor这俩个类,把作者和编辑相关的字段分别移除,而和User类的关系通过userId字段来关联,这样每个类的职责就单一了。

字段未分组

大类的产生往往还有一个常见的原因,就是字段未分组。有时候,我们会觉得一些字段确实属于某个类,结果是该类还是很大,比如上面拆分后的User类:

public class User {
  private long userId;
  private String name;
  private String nickname;
  private String email;
  private String phoneNumber;
  ...
}

前面我们分析这些字段都是必需的,但是再仔细分析,我们可以发现userId、name和nickname字段信息基本是不怎么会改变,而email和phoneNumber则都属于联系方式字段,根据绑定各种媒体账号可能会变化较多,所以根据这个理解,我们可以把User类的字段分组,把不同的信息放到不同的类里面:

public class User {
  private long userId;
  private String name;
  private String nickname;
  private Contact contact;
  ...
}
public class Contact {
  private String email;
  private String phoneNumber;
  ...
}

这里我们引入了Contact类(联系方式),把邮箱和电话号码放入了进去,之后任何联系方式相关的调整都可以放入到这个类里面。

通过调整,我们会发现类变小了,这2种拆分方式总结就是:前面是根据职责,拆分出了不同的实体;后面将字段做了分组,用类把不同的信息分别做了封装。

小结

坏味道:大类。

产生大类的原因:

  • 职责不单一;
  • 字段未分组。

软件设计的原则:

  • 单一职责原则;
  • 类写的越小越好。

长参数列表

前面我们说了大类和长函数这俩种非常容易发现的坏味道,还有一种非常容易发现的坏味道,就是长参数列表。

当一个函数参数有十几个甚至几十个时,不仅会让函数变得非常长,而且在调用函数时传递参数也是一件非常痛苦的事。

首先,我们思考一下为什么要有参数呢?我们知道,参数用于函数之间共享信息。但是函数间共享信息的方式不止一种,除了参数,最常见的就是全局变量。

在我们初学编程时,老师就说过不要使用全局变量,全局变量有非常多的不确定性,可修改地方太多,所以在日常编程中,尽量少使用全局变量。

长参数列表的问题和我们之前所说长函数和大类一样,人们能够掌握的东西有限,一旦参数过长,就很难对内容进行把控。

所以一贯思路,就是减少参数的数量,有如下方法。

参数封装成类

我们先来看一段代码:

public void createBook(final String title, 
                       final String introduction,
                       final URL coverUrl,
                       final BookType type,
                       final BookChannel channel,
                       final String protagonists,
                       final String tags,
                       final boolean completed) {
  ...
  Book book = Book.builder
    .title(title) 
    .introduction(introduction)
    .coverUrl(coverUrl)
    .type(type)
    .channel(channel)
    .protagonists(protagonists)
    .tags(tags)
    .completed(completed)
    .build();
    
  this.repository.save(book);
}

这是一个创建作品的函数,参数包括了创建一个作品需要的所有信息,包括标题、简介、封面URL、类型、归属频道等等。这里看起来没啥问题,只是参数较多而已,但是假如后续需求变化了,创建一个作品又要多2个参数,是不是很自然的就会再加2个参数,时间久了之后,就会更长了。

和大类"每次只加一点点"类似,我们知道了长函数坏味道形成的原因,那如何解决呢?

这里所有参数都是和创建作品相关,也是创造作品所必须的,所以我们可以把参数封装成一个类

public class NewBookParamters {
  private String title;
  private String introduction;
  private URL coverUrl;
  private BookType type;
  private BookChannel channel;
  private String protagonists;
  private String tags;
  private boolean completed;
  ...
}

这样一来,我们上面函数就变成了下面这样:

public void createBook(final NewBookParamters parameters) {
  ...
  Book book = Book.builder
    .title(parameters.getTitle()) 
    .introduction(parameters.getIntroduction())
    .coverUrl(parameters.getCoverUrl())
    .type(parameters.getType())
    .channel(parameters.getChannel())
    .protagonists(parameters.getProtagonists())
    .tags(parameters.getTags())
    .completed(parameters.isCompleted())
    .build();
    
  this.repository.save(book);
}

看着参数列表由多个变成了一个,但是在函数内使用还是一个一个地取出来,这不是多此一举吗?

这就涉及到对软件设计的理解,我们并不是简单地把参数封装成类。站在软件设计的角度,我们引入了一个新的模型,而一个模型的封装应该是以行为为基础的

之前没有这个模型,我们想不到它应该有什么行为,现在模型产生了,它就应该有配套的行为。从代码我们不难看出,这个模型的行为应该就是创建一个作品对象出来,理解了这一点,代码调整如下:

public class NewBookParamters {
  private String title;
  private String introduction;
  private URL coverUrl;
  private BookType type;
  private BookChannel channel;
  private String protagonists;
  private String tags;
  private boolean completed;
  
  public Book newBook() {
    return Book.builder
      .title(title) 
      .introduction(introduction)
      .coverUrl(coverUrl)
      .type(type)
      .channel(channel)
      .protagonists(protagonists)
      .tags(tags)
      .completed(completed)
      .build();
  }
}

创建作品的函数简化为:

public void createBook(final NewBookParamters parameters) {
  ...
  Book book = parameters.newBook();
    
  this.repository.save(book);
}

这里的关键不仅是把参数封装为类多了一个模型,更重要的是理解模型的行为,即任何模型的封装都是以行为为基础。

动静分离

接下来我们来看一段参数列表不是那么长的代码:

public void getChapters(final long bookId, 
                        final HttpClient httpClient,
                        final ChapterProcessor processor) {
  HttpUriRequest request = createChapterRequest(bookId);
  HttpResponse response = httpClient.execute(request);
  List<Chapter> chapters = toChapters(response);
  processor.process(chapters);
}

这个函数的作用是根据作品ID获取其对应章节的信息,单纯看参数个数,这个函数的参数个数不算多,但是这里的参数列表依旧有问题。

在这几个参数里面,每次传递进来的bookId都是不一样的,是随着请求的不同而改变。但是httpClient和processor这2个参数是一样的,因为他们都有相同的逻辑。

换言之,就是bookId的变化频率和httpClient和processor变化频率不一样。这种变化频率不一样的情况,是分离关注点的典型情况即动数据(bookId)和静数据(httpClient、processor)应该分离开来

具体到场景中,静态不变的数据完全可以成为函数所在类的一个字段,所以代码可以改写如下:

public void getChapters(final long bookId) {
  HttpUriRequest request = createChapterRequest(bookId);
  HttpResponse response = this.httpClient.execute(request);
  List<Chapter> chapters = toChapters(response);
  this.processor.process(chapters);
}

这个坏味道其实是软件设计问题,代码缺乏应有的结构,将本来属于静态的数据却以动态参数的方式传来传去。

这里静态分离,也说明了前面说的可以把长参数列表用一个类进行封装的前提是:这些参数属于一个类,拥有相同的变化频率

告别标记

什么是标记,即我们代码中常见的flag,来看一段代码:

public void editChapter(final long chapterId, 
                        final String title, 
                        final String content, 
                        final boolean apporved) {
  ...
}

这个函数的作用是编辑章节,前3个参数是待编辑章节的信息,后面表示是否审核通过,这是因为假如是作者角色调用该函数,是不能审核通过的(没有权限),如果是编辑角色调用该函数,是直接审核通过的。

这里逻辑看起来没啥问题,使用flag也是程序员常用的一个手段,但是也正是假如代码标记过多,就会造成逻辑混乱。

最简单的解决方式,就是将标记参数代表的不同路径拆分出来。比如上面的函数就可以拆分为2个函数,一个函数负责普通的编辑,另一个负责可直接审核通过的编辑:

// 普通的编辑,需要审核
public void editChapter(final long chapterId, 
                        final String title, 
                        final String content) {
  ...
}


// 直接审核通过的编辑
public void editChapterWithApproval(final long chapterId,
                                    final String title,
                                    final String content) {
 ...
}

标记参数在代码中的表现形式不仅仅是布尔值类型,有的是以枚举形式,还有的直接是以字符串或者整数形式。不论哪种形式,我们都可以通过拆分函数的方式将他们拆开,这种手法叫做移除标记参数

小结

这里所说的解决长函数的3个方法,其实都在强调一个主题:我们应该编写"短小"的代码

坏味道:长参数

消除长参数:

  1. 参数数量多导致的长参数
  • 变化频率相同,则封装为一个类。
  • 变化频率不同的,其中静态不变的,可以成为软件结构的一部分;多个变化频率的,可以封装为几个类。
  1. 标记参数导致的长函数
  • 根据标记参数,将函数拆分为多个函数。

滥用控制语句

本小节开始说一些代码中的坏味道,包括我们常见的嵌套代码和if、else语句等,可能会刷新我们之前的编程习惯认知。

嵌套的代码

嵌套的代码大家肯定都写过,效果就是函数结尾会有一大堆括号,给我们阅读代码带来了极度不方便,下面给出一个简单的代码:

public void distributeEpubs(final long bookId) {
  List<Epub> epubs = this.getEpubsByBookId(bookId);
  for (Epub epub : epubs) {
    if (epub.isValid()) {
      boolean registered = this.registerIsbn(epub);
      if (registered) {
        this.sendEpub(epub);
      }
    }                                            
  }
}

这段代码逻辑用来分发EPUB电子书,首先根据bookId查询到EPUB电子书集和,对于每一本电子书如果有效则进行注册,注册成功后,进行分发。

这个逻辑不复杂,但是函数后面我们发现已经有4个右括号了,代码再稍微复杂一点,嵌套层次就更多了。

这里出现的问题非常简单,在长函数那节我们说过,就是使用"平铺直叙"的方式写代码。这段代码,就是按照需求一步一步实现的,但是问题在于最后没有把代码整理一下。

消除缩进的第一个着手点就是for循环里面的逻辑,我们可以单独拎出来:

public void distributeEpubs(final long bookId) {
  List<Epub> epubs = this.getEpubsByBookId(bookId);
  for (Epub epub : epubs) {
    this.distributeEpub(epub);
  }
}


private void distributeEpub(final Epub epub) {
  if (epub.isValid()) {
    boolean registered = this.registerIsbn(epub);
    if (registered) {
      this.sendEpub(epub);
    }
  }
}

我们对每一本电子书的判断逻辑是一样的,所以可以把这部分逻辑抽离为函数给单独出来,这样原来的函数就少了一层缩进了。

if和else

在上面拆分后的distributeEpub方法中,还是有很长的缩进,这里的缩进是由if语句造成的。

通常来说,if语句造成的缩进,很多时候都是在检查某个先决条件,只有条件通过,才继续执行后面的代码。这样的代码,可以使用卫语句来解决,即设置单独的检查条件,不满足条件直接返回。

这是一种典型的重构:以卫语句取代嵌套的条件表达式。修改后代码:

private void distributeEpub(final Epub epub) {
  if (!epub.isValid()) {
    return;
  }
  
  boolean registered = this.registerIsbn(epub);
  if (!registered) {
    return;
  }
  
  this.sendEpub(epub);
}

修改后的代码嵌套就少了很多,也更利于阅读。

这里说了if关键字,与之对应的是else关键字,对于else关键字也是一种坏味道,这挑战了很多程序员的认知

我们同样可以使用卫语句来消除else关键字,比如下面代码:

public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
  double price = 0;
  if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
    price = 4.99;
  } else if (sequenceNumber > START_CHARGING_SEQUENCE
        && sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
    price = 1.99;
  } else if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
    price = 2.99;
  } else {
    price = 0.99;
  }
  
  return price;
}

这是一个给电子书定价的函数,逻辑如下:当是高品质电子书,且页数大于起始收费页数则定价4.99,对于非高品质书来说,页数大于起始收费页小于进一步收费页数定价1.99,大于进一步收费页数定价2.99,其他默认定价为0.99。

这一段代码在日常生活中经常写,因为我们一直以为if/else就是天生一对,这里同样可以使用卫语句进行优化:

public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
  if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
    return 4.99;
  } 
  
  if (sequenceNumber > START_CHARGING_SEQUENCE
        && sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
    return 1.99;
  } 


  if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
    return 2.99;
  } 


  return 0.99;

优化后的代码,多使用return来提前结束,也更加方便阅读。

不论是嵌套的代码,还是else语句,我们之所以视为坏味道并且改进,本质上都是在追求简单因为一段代码的分支过多,复杂度就会大大提高。也是我们一直所说的,人脑能够理解的复杂度是有限的,分支过多的代码会超过理解范围。

重复的Switch

前面所说的if else都是坏味道,那我们熟悉的switch语句也可能是坏味道,我们来看段代码:

public double getBookPrice(final User user, final Book book) {
  double price = book.getPrice();
  switch (user.getLevel()) {
    case UserLevel.SILVER:
      return price * 0.9;
    case UserLevel.GOLD: 
      return price * 0.8;
    case UserLevel.PLATINUM:
      return price * 0.75;
    default:
      return price;
  }
}


public double getEpubPrice(final User user, final Epub epub) {
  double price = epub.getPrice();
  switch (user.getLevel()) {
    case UserLevel.SILVER:
      return price * 0.95;
    case UserLevel.GOLD: 
      return price * 0.85;
    case UserLevel.PLATINUM:
      return price * 0.8;
    default:
      return price;
  }
}

这里的逻辑是根据用户的等级来制定书籍和电子书的价格,但是代码部分仔细查看发现会有很多类似的代码,这就是一种典型的坏味道:重复的switch。

之所以会出现重复的switch,通常都是因为缺少一个模型,所以这种坏味道的重构手法是:以多态取代条件表达式

还记得前面我们说过引入模型的原则吗?要以行为为基础,这里的行为就是根据用户等级获取不同的书籍和电子书价格,所以引入UserLevel模型:

interface UserLevel {
  double getBookPrice(Book book);
  double getEpubPrice(Epub epub);
}

class RegularUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice();
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice();
}

class GoldUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice() * 0.8;
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice() * 0.85;
  }
}

class SilverUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice() * 0.9;
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice() * 0.85;
  }
}

class PlatinumUserLevel implements UserLevel {
  public double getBookPrice(final Book book) {
    return book.getPrice() * 0.75;
  }
  
  public double getEpubPrice(final Epub epub) {
    return epub.getPrice() * 0.8; 

上面代码针对不同的用户等级,在处理获取价格是不一样的,所以前面代码中的switch就可以去掉了:

public double getBookPrice(final User user, final Book book) {
  UserLevel level = user.getUserLevel()
  return level.getBookPrice(book);
}


public double getEpubPrice(final User user, final Epub epub) {
  UserLevel level = user.getUserLevel()
  return level.getEpubPrice(epub);
}

小结

本节内容直接刷新我们对编程的认知,因为这些坏味道包含在我们最常见的控制语句中。

坏味道:滥用控制语句。

呈现心态:

  • 嵌套的语句;
  • else语句;
  • 重复的switch。

解决手法:

  • 以卫语句取代嵌套的条件表达式;
  • 以多态取代重复switch表达式。

总结

本篇文章不仅介绍了容易发现的大类和长参数列表这种坏味道,还介绍了我们很熟悉的嵌套代码、if、else、switch等不容易发现的坏味道,在这之中,我们需要注意单一职责、动静分离等原则。

最后强烈建议阅读源文章和几本文章中提及的书籍,感兴趣的可以阅读源文章:time.geekbang.org/column/intr…