代码里的坏味道

431 阅读27分钟

本文前部分来自于极客时间课程《代码之丑》的学习笔记,详情可以查看:gk.link/a/10q4Y

1、命名

1.1、错误命名

1.1.1、宽泛的命名

public void processChapter(long chapterId) {
  Chapter chapter = this.repository.findByChapterId(chapterId);
  if (chapter == null) {
    throw new IllegalArgumentException("Unknown chapter [" + chapterId + "]");  
  }
  
  chapter.setTranslationState(TranslationState.TRANSLATING);
  this.repository.save(chapter);
}

该代码不容易懂,经过认真阅读才能看懂这段代码做的就是把一个章节的翻译状态改成翻译中。

问题就出在函数的名字叫 processChapter(处理章节),这个函数确实是在处理章节,但是,这个名字太过宽泛。如果说“将章节的翻译状态改成翻译中”叫做处理章节,那么“将章节的翻译状态改成翻译完”是不是也叫处理章节呢?“修改章节内容”是不是也叫处理章节呢?换句话说,如果各种场景都能够叫处理章节,那么处理章节就是一个过于宽泛的名字,没有错,但不精准。

命名过于宽泛,不能精准描述,这是很多代码在命名上存在的严重问题,也是代码难以理解的根源所在。

类似data、info、flag、process、handle、build、maintain、manage、modify 等等,这些名字都属于典型的过于宽泛的名字。

首先,命名要能够描述出这段代码在做的事情。这段代码在做的事情就是“将章节修改为翻译中”。那是不是它就应该叫 changeChapterToTranlsating 呢?不可否认,相比于“处理章节”,changeChapterToTranlsating 这个名字已经进了一步,然而,它也不算是一个好名字,因为它更多的是在描述这段代码在做的细节。我们之所以要将一段代码封装起来,一个重要的原因就是,我们不想知道那么多的细节。如果把细节平铺开来,那本质上和直接阅读代码细节差别并不大。所以,一个好的名字应该描述意图,而非细节。 就这段代码而言, 我们为什么要把翻译状态修改成翻译中,这一定是有原因的,也就是意图。具体到这里的业务,我们把翻译状态修改成翻译中,是因为我们在这里开启了一个翻译的过程。所以,这段函数应该命名 startTranslation。

1.1.2 用技术命名

List<Book> bookList = service.getBooks();

可以说这是一段常见得不能再常见的代码了,但这段代码却隐藏另外一个典型得不能再典型的问题:用技术术语命名。这个 bookList 变量之所以叫 bookList,原因就是它声明的类型是 List。这种命名在代码中几乎是随处可见的,比如 xxxMap、xxxSet。这是一种不费脑子的命名方式会带来很多问题,因为它是一种基于实现细节的命名方式。我们都知道,编程有一个重要的原则是 面向接口编程 ,这个原则从另外一个角度理解,就是不要面向实现编程,因为接口是稳定的,而实现是易变的。

改成:

List<Book> books = service.getBooks();

1.2、用业务语言写代码

无论是不精准的命名也好,技术名词也罢,归根结底,体现的是同一个问题:对业务理解不到位。编写可维护的代码要使用业务语言。怎么才知道自己的命名是否用的是业务语言呢?一种简单的做法就是,把这个词讲给产品经理,看他知不知道是怎么回事。从团队的角度看,让每个人根据自己的理解来命名,确实就有可能出现千奇百怪的名字,所以,一个良好的团队实践是,建立团队的词汇表,让团队成员有信息可以参考。团队对于业务有了共同理解,我们也许就可以发现一些更高级的坏味道,比如说下面这个函数声明:

public void approveChapter(long chapterId, long userId) {
  ...
}

这个函数的意图是,确认章节内容审核通过。这里有一个问题,chapterId 是审核章节的 ID,这个没问题,但 userId 是什么呢?了解了一下背景,我们才知道,之所以这里要有一个 userId,是因为这里需要记录一下审核人的信息,这个 userId 就是审核人的 userId。你看,通过业务的分析,我们会发现,这个 userId 并不是一个好的命名,因为它还需要更多的解释,更好的命名是 reviewerUserId,之所以起这个名字,因为这个用户在这个场景下扮演的角色是审核人(Reviewer)

1.3、命名规范

常见的命名规则是:类名是一个名词,表示一个对象,而方法名则是一个动词,或者是动宾短语,表示一个动作。

对于一个团队的实践是:

  1. 制定代码规范,比如,类名要用名词,函数名要用动词或动宾短语;
  2. 要建立团队的词汇表(是的,我们在上一讲也提到了);
  3. 要经常进行代码评审

2、重复代码

2.1、代码内容重复

工作中很多人会从各种地方负责代码,导致代码存在多份,修改的时候需要多处修改。我们开发过程中一定遵从 DRY 原则 ,对重复两次以上的代码进行封装。

2.2、代码结构重复

重复代码不仅仅是代码重复,也包括结构重复,如:

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


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

可以抽象为:

private void executeTask(final Runnable runnable) {
  try {
    runnable.run();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}

@Task
public void sendBook() {
  executeTask(this.service::sendBook);
}

方便后面统一修改执行任务逻辑,比如日志打印等

2.3 if 和 else 代码块中的语句高度类似。

if (user.isEditor()) {
  service.editChapter(chapterId, title, content, true);
} else {
  service.editChapter(chapterId, title, content, false);
}

if 和 else 两段代码几乎是一模一样的。在经过仔细地“找茬”之后,才能发现,原来是最后一个参数不一样。

写代码要有表达性。把意图准确地表达出来,是写代码过程中非常重要的一环。显然,这里的 if 判断区分的是参数,而非动作。所以,我们可以把这段代码稍微调整一下,会让代码看上去更容易理解:

boolean approved = user.isEditor();
service.editChapter(chapterId, title, content, approved);

3、长函数

函数应该以20行为上限,长函数的出现主要有下面几个原因

3.1、平铺直叙

平铺直叙的代码存在的两个典型问题:

  1. 把多个业务处理流程放在一个函数里实现;
  2. 把不同层面的细节放到一个函数里实现。

解决办法是采用关注点分离,分拆成不同的函数去实现。

3.2、一次加一点

有的函数开始的时候并不长,后面逐步加的时候就慢慢变长了

任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:

让营地比你来时更干净。 --童子军军规

4、大类

有些类过于大,导致难维护,大类产生的原因主要是:

4.1、职责不单一

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;
  ...
}

普通用户、作者、编辑,这是三种不同角色,来自不同诉求的业务方关心的是不同的内容。只是因为它们都是这个系统的用户,就把它们都放到用户类里,造成的结果就是,任何业务方的需求变动,都会让这个类反复修改。这种做法实际上是违反了单一职责原则。

单一职责原则是衡量软件设计好坏的一把简单而有效的尺子,通常来说,很多类之所以巨大,大部分原因都是违反了单一职责原则。而想要破解“大类”的谜题,关键就是能够把不同的职责拆分开来。

4.2、字段未分组

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

public class User {
  private long userId;
  private String name;
  private String nickname;
  private String email;
  private String 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;
  ...
}

或许你已经发现了,所谓的将大类拆解成小类,本质上在做的工作是一个设计工作。我们分解的依据其实是单一职责这个重要的设计原则。没错,很多人写代码写不好,其实是缺乏软件设计的功底,不能有效地把各种模型识别出来。所以,想要写好代码,还是要好好学学软件设计的

5、长参数


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);
}

5.1、将参数列表封装成对象

优化解决第一个办法是把参数封装成一个类


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) {
  ...
}

这里你看到了一个典型的消除长参数列表的重构手法:将参数列表封装成对象。

但是这样当用到这些参数的时候,还需要把它们一个个取出来,这会不会是多此一举呢?

如果你也有这样的想法,那说明一件事:你还没有形成对软件设计的理解。我们并不是简单地把参数封装成类,站在设计的角度,我们这里引入的是一个新的模型,而一个模型的封装应该是以行为为基础的。


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);
}

5.2、动静分离

把长参数列表封装成一个类,这能解决大部分的长参数列表,但并不等于所有的长参数列表都应该用这种方式解决,因为不是所有情况下,参数都属于一个类。


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);
}

在这几个参数里面,每次传进来的 bookId 都是不一样的,是随着请求的不同而改变的。但 httpClient 和 processor 两个参数都是一样的,因为它们都有相同的逻辑,没有什么变化。换言之,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);
}

5.3、告别标记


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

这几个参数分别表示,待修改章节的 ID、标题和内容,最后一个参数表示这次修改是否直接审核通过。

使用标记参数,是程序员初学编程时常用的一种手法,不过,正是因为这种手法实在是太好用了,造成的结果就是代码里面彩旗(flag)飘飘,各种标记满天飞。不仅变量里有标记,参数里也有。很多长参数列表其中就包含了各种标记参数。这也是很多代码产生混乱的一个重要原因。

解决标记参数,一种简单的方式就是,将标记参数代表的不同路径拆分出来。回到这段代码上,这里的一个函数可以拆分成两个函数,一个函数负责“普通的编辑”,另一个负责“可以直接审核通过的编辑”


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


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

标记参数在代码中存在的形式很多,有的是布尔值的形式,有的是以枚举值的形式,还有的就是直接的字符串或者整数。无论哪种形式,我们都可以通过拆分函数的方式将它们拆开。在重构中,这种手法叫做移除标记参数(Remove Flag Argument)。

6、滥用控制语句

6.1、嵌套的代码

image.png

具体案例如下:

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);
      }
    }                                            
  }
}

这段代码之所以会写成这个样子,其实就是“长函数”里所说的:“平铺直叙地写代码”。优化方法是减少嵌套,把循环中的内容提取成一个函数,让这个函数只处理一个元素,就像下面这样:


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);
    }
  }
}

6.2、if 和 else

对应if\else嵌套可以使用以卫语句取代嵌套的条件表达式,如上面的distributeEpub可以优化为:


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

6.3、重复的 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,通常都是缺少了一个模型。所以,应对这种坏味道,重构的手法是:以多态取代条件表达式(Relace Conditional with Polymorphism)。具体到这里的代码,我们可以引入一个 UserLevel 的模型,将 switch 消除掉:


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);
}

7、缺乏封装

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

7.1、火车残骸

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

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

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

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

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

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


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


String name = book.getAuthorName();

另外在编写类的时候,有人第一步是写出这个类要用到的字段,然后,就是给这些字段生成相应的 getter,也就是各种 getXXX,导致暴露的细节比较多。

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

其中一种方式是遵守叫做迪米特法则(Law of Demeter):

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

7.2、基本类型偏执

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

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

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)。

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

8、可变的数据

8.1、满天飞的 Setter


public void approve(final long bookId) {
  ...
  book.setReviewStatus(ReviewStatus.APPROVED);
  ...
}

这里直接修改了数据,而不是采用封装的接口调用,这意味着不仅可以读到一个对象的数据,还可以修改一个对象的数据。相比于读数据,修改是一个更危险的操作。可变的数据是可怕,但是,比可变的数据更可怕的是,不可控的变化,而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。

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

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

8.2、可变的数据

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

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

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

String replace(char oldChar, char newChar);

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

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

9、变量声明与赋值分离

9.1、变量的初始化

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

虽然 status 这个变量在声明的时候,就赋上了一个 null 值,但实际上,这个值并没有起到任何作用,因为 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;
}

9.2、集合初始化

List<Permission> permissions = new ArrayList<>();
permissions.add(Permission.BOOK_READ);
permissions.add(Permission.BOOK_WRITE);
check.grantTo(Role.AUTHOR, permissions);

这种代码是非常常见的,声明一个集合,然后,调用一堆添加的方法,将所需的对象添加进去,也是违反了声明和赋值一起的原则,可以优化为:

List<Permission> permissions = ImmutableList.of(
  Permission.BOOK_READ, 
  Permission.BOOK_WRITE
);
check.grantTo(Role.AUTHOR, permissions);

10、23种坏味道总结

10.1、函数的问题 (5)

过长函数(Long Method)

优化方法:建议不超过20行,解决长函数的方法有:函数提取实现关注点分离、遵从童子军规。

过长参数列(Long Parameter List)

优化方法:将长函数封装成对象、动静分离、告别标记

基本类型偏执(Primitive Obsession)

优化方法:对应变量的类型可以定义为更有意义的类型,而不是基本类型。

如price可以定义为double类型,但其反应不出来价格的特性,比如不能为负、小数点后面只保留2位。

重复的Switch(Repeat Switch)

优化方法:以多态取代条件表达式

循环语句(Loops)

优化方法:我们应该用管道操作(如filter和map)来替代循环,这样能更快的看清被处理的元素和处理他们的动作。

10.2、单个类的问题(6)

过大的类(Large Class)

优化方法:遵从职责单一原则,对类属性进行动静分组

临时字段(Temporary Field)

优化方法:通过提炼类(Extract Class)将临时字段和操作它们的所有代码提炼到一个单独的类中。

数据泥团(Data Clumps)

优化方法:把这些「总是绑在一起出现的数据」放进属于它们自己的独立对象中。

纯数据类(Data Class)

所谓Data Class是指:它们拥有一些值域(fields),以及用于访问(读写〕这些值域的函数,除此之外一无长物。这样的classes只是一种「不会说话的数据容器」,它们几乎一定被其他classes过份细琐地操控着。这些classes早期可能拥有public值域,果真如此你应该在别人注意到它们之前,立刻运用Encapsulate Field (封装值域,(就是将public 域变成private然后设置set和get)将它们封装起来。如果这些classes内含容器类的值域(collection fields),你应该 检査它们是不是得到了恰当的封装;

对于那些不该被其他classes修改的值域,请运用 Remove Setting Method(移除设置函数)。然后,找出这些「取值/设值」函数(getting and setting methods)被其他classes运用的地点。尝试以Move Method(搬移函数) 把那些调用行为搬移到Data Class来。如果无法搬移整个函数,就运用 Extract Method(提炼函数) 产生一个可被搬移的函数。不久之后你就可以运用Hide Method (隐藏某个函数)把这些「取值/设值」函数隐藏起来了。

神秘命名(Mysterious Name)

整洁代码最重要的一环就是好的名字,所以我们会深思熟虑如何给函数、模块、变量和类命名,使它们能清晰地表明自己的功能和用法。命名是编程中最困难的两件事之一。

为一个恼人的名字所付出的纠结,常常能推动我们对代码进行精简

全局变量(Global Data)

全局数据的问题在于,从代码库的任何一个角落都可以修改它,而且没有任何机制可以探测出到底哪段代码做出了修改。

良药和毒药的区别在于剂量。有少量的全局数据或许无妨,但数量越多,处理的难度就会指数上升。

可变数据(Mutable Data)

对数据的修改经常导致出乎意料的结果和难以发现的 bug 。 在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据。

10.3、类关系的问题(8)

发散式变化(Divergent Change)

一旦需要修改,我们希望能够跳到系统的某一点,只在该处做修改。如果不能做到这点,你就嗅出两种紧密相关的刺鼻味道中的一种了。

散弹式修改(Shotgun Surgery)

Shotgun Surgery类似Divergent Change, 但恰恰相反。如果每遇到某种变化,你都必须在许多不同的类内做出许多小修改,你所面临的坏味道就是Shotgun Surgery。如果需要修改的代码散布四处,你不但很难找到它们,也很容易忘记某个重要的修改。

这种情况下你应该使用Move Method和Move Field把所有需要修改的代码放进同一个类。如果眼下没有合适的类可以安置这些代码,就创造一个。通常可以运用Inline class把一系列相关行为放进同一个类。这可能会造成少量Divergent Change,但你可以轻易处理它。

Divergent Change是指“一个类受多种变化的影响”,Shotgun Surgery则是指“一种变化引发多个类相应修改”。这两种情况下你都会望整理代码,使 “外界变化” 与 “需要修改的类”趋于一一对应。

依恋情节(FeatureEnvy)

对象技术的全部要点在于:这是一种“将数据和对数据的操作行为包装在一起”的技术。有一种经典气味是:函数对某个类的兴趣高过对自己所处类的兴趣。这种仰慕之情通常的焦点便是数据。

无数次经验里,我们看到某个函数为了计算某个值,从另一个对象那儿调用几乎半打的取值函数。疗法显而易见:把这个函数移至另一个地点。你应该使用Move Method把它移到它该去的地方。有时候函数中只有一部分受这种依恋之苦,这时候你应该使 用Extract Method把这一部分提炼到独立函数中,再使用Move Method带它去它的梦中家园。

夸夸其谈通用性(Speculative Generality

当有人说“噢,我想我们总有一天需要做这事”,并因而企图以各式各样的钩子和特殊情况来处理一些非必要的事情,这种坏味道就出现了。那么做的结果往往造成系统更难理解和维护。如果所有装置都会被用到,那就值得那么做:如果用不到,就不值得。用不上的装置只会挡你的路,所以,把它搬开吧。

过长的消息链(Message Chain)

如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象, 然后再请求另一个对象……这就是消息链。实际代码中你看到的可能是一长串getThis()或一长串临时变量。采取这种方式,意味客户代码将与査找过程中的导 航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不做出相应修改。

这时候你应该使用Hide Delegate。你可以在消息链的不同位置进行这种重构手法。理论上可以重构消息链上的任何一个对象,但这么做往往会把一系列对象(intermediate object)都变成Middle Man。通常更好的选择是:先观察消息链最终得到的对象是用来干什么的,看看能否以Extract Method把使用该对象的代码提炼到一个独立函数中,再运用Move Method把这个函数推入消息链。如果这条链上的某个对象有多位客户打算航行此航线的剩余部分,就加一个函数来做这件事。

中间人(Middle Man)

对象的基本特征之一就是封装——对外部世界隐藏其内部细节。封装往往伴随委托。比如说你问主管是否有时间参加一个会议,他就把这个消息“委托”给他的记事簿,然后才能回答你。很好,你没必要知道这位主管到底使用传统记事簿或电子记車簿亦或秘书來记录自己的约会。

伹是人们可能过度运用委托。你也许会看到某个类接口有一半的函数都委托给其他类,这样就是过度运用。这时应该使用Remove Middle Men,直接和真正负责的对象打交道。如果这样“不干实事”的函数只有少数几个,可以运用Inline Method把它们放进调用端。如果这些Middle Man还有其他行为,可以运用replace Delegate with Inheritance把它变成实责对象的子类,这样你既可以扩展原对象的行为,又不必负担那么多的委托动作。

内幕交易(Insider Trading)

软件开发者喜欢在模块之间建起高墙,极其反感在模块之间大量交换数据,因为这会增加模块间的耦合。 在实际情况里,一定的数据交换不可避免,但我们必须尽量减少这种情况,并把这种交换都放到明面上来。

被拒绝的遗赠(Refuse Bequest)

如果subclass复用了superclass的行为(实现),却又不愿意支持superclass的接口,Refused Bequest的坏味道就会变得浓烈。拒绝继承superclass的实现,这一点我们不介意;但如果拒绝继承superclass的接口,我们不以为然。不过即使你不愿意继承接口,也不要胡乱修改继承体系,你应该运用Replace Inheritance with Delegation (以委托取代继承)来达到目的。

10.4、需要删除 (4)

注释(Comments)

从嗅觉上说,Comments不是一种坏味道;事实上它们还是一种香味呢。我们之所以要在这里提到Comments,因为人们常把它当作除臭剂来使用。常常会有这样的情况:你看到一段代码有着长长的注释,然后发现,这些注释之所以存在乃是因为代码很糟糕。这种情况的发生次数之多,实 在令人吃惊。

Comments可以带我们找到本章先前提到的各种坏味道。找到坏味道后,我们首先应该以各种重构手法把坏味道去除。完成之后我们常常会发现:注释已经变得多余了,因为代码已经清楚说明了一切。

如果你需要注释来解释一块代码做了什么,试试 Extract Method(提炼函数);如果method已经提炼出来,但还是需要注释来解释其行为,试试Rename Method(重新命名函数);如果你需要注释说明某些系统的需求规格,试试 Introduce Assertion(引入断言)。

TIP:当你感觉需要撰写注释,请先尝试重构,试着让所有注释都变得多余。

如果你不知道该做什么,这才是注释的良好运用时机。除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己「为什 么做某某事」。这类信息可以帮助将来的修改者,尤其是那些健忘的家伙。

重复代码(Duplicated Code)

坏味道行列中首当其冲的就是DuplicatedCode。如果你在一个以上的地点看到相同的程序结构,那么可以肯定:设法将它们合而为一,程序会变得更好。

最单纯的DuplicatedCode就是 “同一个类的两个函数含有相同的表达式”。这时候你需要做 的就是采用Extract Method提炼出重复的代码,然后让这两个地点都调用被提炼出来的那一段代码。

另一种常见情况就是“两个互为兄弟的子类内含相同表达式”。要避免这种情况,只需对两个类都使用Extract Method,然后再对被提炼出来的代码使用Pull Up Method,将它推入超类内。如果代码之间只是类似,并非完全相同,那么就得运用Extract Method将相似部分和差异部分割开,构成单独一个函数。然后你可能发现可以运用Form Template Method获得一个Template Method设计模式。如果有些函数以不同的算法做相同的事,你可以选择其中较清晰的一个,并使用Substitute Algorithm将其他函数的算法替换掉。

如果两个毫不相关的类出现Duplicated Code ,你应该考虑对其中一个使用Extract Class,将重复代码提炼到一个独立类中,然后在另一个类内使用这个新类。但是,重复代码所在的函数也可能的确只应该属于某个类,另一个类只能调用它,抑或这个函数可能属于第三个类,而另两个类应该引用这第三个类。你必须决定这个函数放在哪儿最合适,并确保它被安置后就不会再在其他任何地方出现。

冗赘的元素Lazy Element

异曲同工的类(Alternative Class With Diffenter iterface)

如果两个函数做同一件事,却打着不同的签名,请运用Rename Method根据它们的用途重新命名。但这往往不够,请反复运用Move Method将某些行为 移入类,直到两者的协议一致为止。如果你必须重复而赘余地移入代码才能完成这些,或许可运用Extract Superclass为自己赎点罪