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

Demon.Lee 2023年08月26日 2,539次浏览

代码之丑(极客时间)》是郑晔老师在极客时间上的一门课,专门讲述日常开发中比较典型的代码坏味道。这门课我很早就学完了,但一直没有来得及总结。其他课也一样,这是我个人的问题。最近我又学了一遍,借此机会将其主要内容进行梳理和归纳,锁定学习闭环。

郑晔老师讲了 13 种常见的编码陋习,其中很多内容是对《重构(豆瓣)》这本书的提炼,但结合现实案例后,对我们日常编码具有非常大的冲击力。我们可能一直在错误道路上行走,把错误的编码方式当成了习惯,但只要理解一点基本的底层逻辑,也许就能颠覆认知,再次成长。

1 没有业务含义的命名

(1)宽泛不精准的命名

这类命名,常见的有:datainfoflagprocesshandlebuildmaintainmanagemodify 等,当我们看到这些词时,脑子要警觉起来。太宽泛会带来什么问题呢?就是理解这段代码的成本高,你需要把代码读完才能知道它干了啥。

虽然「望文生义」是一个贬义词,但在软件命名上,我们就要「望文生义」。一看到函数名,就能理解,而不用消耗脑细胞去读细节。

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,即处理章节,那处理了啥?我们不知道,因为名称太宽泛了。为此,我们必须去读代码,了解其逻辑是:根据章节 id 查询章节内容,然后将翻译状态改成“翻译中”,再保存。

那函数名称叫啥合适?代码逻辑是“将章节修改为翻译中”,不就是 changeChapterToTranslating?可这真是个好名称吗?还不是,因为它暴露太多细节了,我们封装类,封装函数的目的是什么?就是把细节隐藏起来。

一个好的名字应该描述意图(做什么),而非细节(怎么做)。

这也不行,那也不行,那你说啥名称合适?因为这里开启了一个翻译过程,所以其意图就是“开始翻译”,将其调整为 startTranslation 就是合适的。

(2)用技术术语命名

这类问题也很常见,比如 xxxListxxxSetxxxMap

List<Book> bookList = bookService.findBooks();

那么问题出在哪呢?这是一种基于实现细节的命名方式,而不是前面说的意图。面向接口编程告诉我们,要用 List<Book> 而不是 ArrayList<Book>,那命名不也一样吗?如果有一天需要过滤重复,把 List<Book> 改成 Set<Book>,名称还叫 bookList 就不合适了。所以,更好的方案是:

List<Book> books = bookService.findBooks();

用技术方式命名,有时候还意味着缺乏一个模型,怎么理解?

Book cachedBook = redisBookStore.get(isbn);

这里的 redisBookStore 表示书籍信息在 redis 缓存中,但 redis 只是一种实现,这里应该有一个 cache 的缓存模型,即:

Book cachedBook = cache.get(isbn);

但 cache 本质上也是一个技术术语,还有更好的解决方案吗?Spring 中的 @Cacheable 注解是一个值得参考的案例:

@Cacheable("books")
public Book getByIsbn(String isbn) {
  ...
}

(3)用业务语言写代码

如果确实涉及到一些技术实现,比如通用的工具类,那使用技术术语没啥问题。但在业务系统中,大多数情况下都是业务,此时用技术语言就不合适了。如果想不到好名字,那只能说明还没理解业务:

public void approveChapter(long chaperId, long userId);

上面审核章节文章的方法有一个参数是 userId,为啥要一个 userId,写这段代码的人可能知道,但换个人,可能就是一脸懵,然后硬着头皮看代码,才发现原来是审核人的角色,记录是谁审核的。审核人用 userId 合适吗?为啥不用具备业务含义的 reviewerUserId

public void approveChapter(long chaperId, long reviewerUserId);

那怎么知道自己用的词到底是不是业务语言呢?如果不确定,就把这个词发给身边的产品经理,看 Ta 们能否看懂。另外,团队内要指定一套业务语言表,英文的,让大家保持一致。

2 乱用英文的命名

用拼音进行命名的代码已经突破底线了,我们这里不聊。

(1)语法错误

一般来说,类名是一个名词,表示一个对象;

而方法名则是一个动词,或者是动宾短语,表示一个动作。

名词可能相对简单一些,如果类名使用了动词说明用错了,需要调整;

方法名则是动词或动宾短语,其中动宾短语可能更容易出错:

// 重新翻译文章章节,使用名词 ❌,应该调整为 retranslate
public void retranslation(long chapterId);

// 完成翻译,使用完成时 ❌,应该调整为 completeTranslation
public void completedTranslate(long chapterId);

(2)不准确的词汇

作品审核使用 audit 还是 review?

对于不确定的词,查字典是最好的方式,但如果字典有多个不同的词怎么办?

集体智慧往往高于个人水平,听听大家的意见,然后把这些词添加到团队词汇表中。

(3)拼写错误

现在大多数 IDE 都有单词拼写检查,所以只要稍微注意一下,就能避免。另外,如果可以,代码中的注释、readme 等文档可以强制自己使用英文写,时间长了,语感会有所提升。

如何避免这些问题?制定代码规范、制定团队词汇表以及经常进行代码评审

3 重复代码

重复代码怎么来的?大多数时候,可能源于我们天天敲击的 Ctrl-CCtrl-V。如何破?

不使用复制粘贴,如果一个函数需要被反复调用,就要将其提取出来,放到一个模型中。

也许有人说,复制粘贴没问题啊,很好用。但如果修改呢?软件设计的本质就是应对变化。如果只改你复制的部分还好,若要修改所有类似的代码呢?一旦一个地方漏改,直接凉凉~

复制粘贴的代码,结合 IDE 提示,很好识别。而代码结构重复,可能就没那么容易了:

@Task
public void sendBook(){
  try{
    bookService.sendBook();
  }catch(Exception ex){
    notifyService.sendMsg(new sendError(ex));
    throw ex;
  }
}

@Task
public void startTranslation(){
  try{
    bookService.startTranslation();
  }catch(Exception ex){
    notifyService.sendMsg(new sendError(ex));
    throw ex;
  }
}

上面两个函数,咋一看还比较简洁,但它们在结构上是重复的,主要体现在异常处理那部分。而优化的方式就是将其抽象成一个通用的结构:

@Task
public void sendBook(){
  executeTask(bookService::sendBook);
}

@Task
public void startTranslation(){
  executeTask(bookService::startTranslation);
}

private void executeTask(final Runnable runnable){
  try{
    runnable.run();
  }catch(Exception ex){
    notifyService.sendMsg(new sendError(ex));
    throw ex;
  }
}

还有一种重复,在 if...else if...else 结构化逻辑中经常出现,比如:

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

这种重复,让人不好理解的原因就在于没有看出开发人员想表达啥,也就是意图不明,换一个同事来维护,也得去读细节代码。另外,如果 editChapter 函数的参数再多两个,想发现二者之间的差异可能就得一个一个参数的对比了。

那如何表现出意图?很简单,把判断条件单独移出来:

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

这样一改,我们立马就能明白,当用户是一个编辑的角色时,可以对章节进行审核通过。如果将来条件变了,逻辑越来越复杂,那也没有关系,我们只要聚焦到 approved 这个变量的获取上即可,比如将其封装到一个独立的函数中:

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


private boolean isApproved(final User user){
  // 这里可以添加更复杂的逻辑判断
  return user.isEditor();
}

说到重复,就绕不开 DRY(Don’t Repeat Yourself) 原则:

在一个系统中,每一处知识都必须有单一、明确、权威地表述。

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

复制粘贴虽然一时爽,可后续变更就如同陷入泥沼,眼睁睁地看着时间一点一点被消耗殆尽。

有时候,慢,就是快。

4 长函数

曾经见过 3000 行的函数,这些都已经突破底线,我们不讨论。

那多长的函数才算长?每个人,每个团队可能都有不同的答案,但这个值不能太大,比如 100,所以是越小越好。郑晔老师对自己的要求是:

像Python,Ruby 等动态语言,由于其表达能力强,一行代码可以解决很多问题,所以一般情况下,函数的长度在 5 行左右;对于像 Java 这样表达能力弱一点的静态语言,一般在 10 行左右。

在很多业务逻辑中,可能 10 行搞不定,那么降低一点标准,翻一番,即 20 行。也就是说,大多数情况下,我们应该在 10 行内解决问题,特殊情况可以容忍到 20 行。

这可以作为一把尺子,衡量我们自己所写代码的粗细程度:对于长函数容忍度高,会导致长函数一直滋生。而究其原因,主要有以下三种:

  • 以性能为由,不愿意把函数拆小

    拆分方法就涉及到函数的出入栈操作,很多人以此为由,拒绝拆分。但这个理由在当下大多数场景下是站不住的。一方面,硬件机器性能以摩尔定律再往前翻番;另一方面,编译器,解释器等都在进化,以 JVM 为例,内部自带优化,会对很多方法进行内联操作。

    更重要的是,写代码第一考量要素是可读性,可维护性,而不是性能。如果追求性能,换个人接手你写的代码,可能完全看不懂,对团队来说,经济成本更高。

  • 平铺直叙写代码,多个业务流程,不同层面的细节都搅在一起

    public void executeTask() {
        ObjectMapper mapper = new ObjectMapper();
        CloseableHttpClient client = HttpClients.createDefault();
        List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
        for (Chapter chapter : chapters) {
            // Send Chapter
            SendChapterRequest sendChapterRequest = new SendChapterRequest();
            sendChapterRequest.setTitle(chapter.getTitle());
            sendChapterRequest.setContent(chapter.getContent());
    
            HttpPost sendChapterPost = new HttpPost(sendChapterUrl);
            CloseableHttpResponse sendChapterHttpResponse = null;
            String chapterId = null;
            try {
                String sendChapterRequestText = mapper.writeValueAsString(sendChapterRequest);
                sendChapterPost.setEntity(new StringEntity(sendChapterRequestText));
                sendChapterHttpResponse = client.execute(sendChapterPost);
                HttpEntity sendChapterEntity = sendChapterPost.getEntity();
                SendChapterResponse sendChapterResponse = mapper.readValue(sendChapterEntity.getContent(), SendChapterResponse.class);
                chapterId = sendChapterResponse.getChapterId();
            } catch (IOException e) {
                throw new RuntimeException(e);
            } finally {
                try {
                    if (sendChapterHttpResponse != null) {
                        sendChapterHttpResponse.close();
                    }
                } catch (IOException e) {
                    // ignore
                }
            }
    
            // Translate Chapter
            HttpPost translateChapterPost = new HttpPost(translateChapterUrl);
            CloseableHttpResponse translateChapterHttpResponse = null;
            try {
                TranslateChapterRequest translateChapterRequest = new TranslateChapterRequest();
                translateChapterRequest.setChapterId(chapterId);
                String translateChapterRequestText = mapper.writeValueAsString(translateChapterRequest);
                translateChapterPost.setEntity(new StringEntity(translateChapterRequestText));
                translateChapterHttpResponse = client.execute(translateChapterPost);
                HttpEntity translateChapterEntity = translateChapterHttpResponse.getEntity();
                TranslateChapterResponse translateChapterResponse = mapper.readValue(translateChapterEntity.getContent(), TranslateChapterResponse.class);
                if (!translateChapterResponse.isSuccess()) {
                    logger.warn("Fail to start translate: {}", chapterId);
                }
            } catch (IOException e) {
                throw new RuntimeException(e);
            } finally {
                if (translateChapterHttpResponse != null) {
                    try {
                        translateChapterHttpResponse.close();
                    } catch (IOException e) {
                        // ignore
                    }
                }
            }
        }
    

    看到上面这段代码,很多人就会头疼,因为从函数名完全看不出干了啥,意图不明。只能啃细节:找到没有翻译的章节发给翻译引擎,由于翻译引擎是以 HTTP 服务形式提供,所以又包含了 HTTP 请求相关内容。一部分是业务逻辑,一部分又是技术细节(比如 http request 初始化,对象序列化成 json 等),这就是前面说的逻辑纠缠,职责不清。

    解决这个问题也很简单,就是提取函数,把各自的逻辑拆出来,而不是像面条一样放在一起。另外,因为函数长,想要体现出业务逻辑,函数名、变量名自然也跟着变长。但拆小以后,这些问题就自然消失了。此刻,你也许发现了,复杂度怎么来的?往往是我们自己种下的

  • 一次加一点点,最终把团队压死

    在前人留下的逻辑中每次加一点点,最终也可能会导致大厦将倾,比如:

    if (code == 400 || code == 401){
      ...
    }
    

    几次之后:

    if (code == 400 || code == 401 || code == 403 || code == 404
       || code == 503 || ...){
      ...
    }
    

    谁在作恶?可能大家都是无辜的,但每次哪怕只加一个条件,时间一长,代码就开始快速腐烂。面对这类问题,Bob 大叔引入了美国童子军一条简单的军规:

    Leave the campground cleaner than you found it.

    让营地比你来时更干净。

    如果你的改动让代码腐烂地更快,那么请立即改进它。

5 大类

前面讲了长函数,现在聊聊大类。本质上它们是一类问题,就是抬高了人对复杂问题的理解。人类的认知能力是有限的,大脑 cover 不住复杂问题。怎么办?分而治之,也就是任务分解,拆大化小。

拆模块,拆包,拆类,拆函数,所有这些都是为了分而治之,降低理解难度,提高代码的可读性和可维护性。当你看到模块名,包名,类名,函数名就能知道它们是干什么的(意图),而无需投入细节。

大类的坏味道一目了然,解决方案也简单:拆小。可问题是,怎么拆?面对这个问题,我们可以反过来想,如果知道了大类是怎么产生的,再逆向操作,不就是拆小?

  • 职责不单一

    一个类里面的字段很多,但它们表达的业务有较大差异,比如下面这段代码:

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

    前面几个字段(userId, name…)表示用户信息,中间部分(authorType, authorReviewStatus)表示用户作为作者角色的信息,而最后几个字段(editorType…)则表示用户作为编辑角色的信息。那么这个类到底想表达啥?用户,作者,还是文章编辑?

    职责不单一,不明确,从而导致类不断膨胀,而我们要做的,就是将它们拆开。

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

    拆开之后,每个小类就相对稳定了,而不像之前那样,User/Author/Editor 任何局部信息变化都会导致 User 大类改变。也就是说,一开始的 User 大类是易变的,Kent Beck 曾指出:

    每个类其实就是这样一个声明:这些逻辑应该放在一起,它们的变化不像它们所操作的数据那么频繁;这些数据也应该放在一起,它们变化的频率差不多,并且由与之关联的逻辑来负责处理。

    没错,变化频率,上面之所以把它们拆开,就是因为它们的变化频率不同。

  • 字段未分组

    相对于前面依据职责不同的划分,这种坏味道可能没那么容易被挖出来,因为它要做的是对不同维度信息的封装。继续看上面拆分过的 User 小类:

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

    有问题吗?没错,就是 email 和 phoneNumber,它们是什么?联系信息:

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

    类的三大特性,大家都如数家珍,可真正理解并能在工作中实践落地,却不容易,比如这里的封装。本质上,拆大变小,是一个软件设计的工作,这里涉及到的就是单一职责原则

如果想追求极致,可以看看 Jeff Bay 编写的对象健身操,一共 9 条规则:ThoughtWorks文集-第1章


6 长参数列表

笔者之前单独总结过,请看这里


7 滥用控制语句

滥用 if...else... 这样的控制语句,你会想到什么样的场景?

(1)嵌套

课程中提供了一个稍微简洁一点的案例:

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

如何消除这些缩进,需要我们理解整个逻辑。比如 for 循环中一个个元素的处理逻辑,就是分发 epub 格式的电子书。我们把它单独拆出来:

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

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

至多一层缩进,拆出来后,再看原来的 distributeEpubs 函数,简单清晰。

(2)if else

前面提到的对象健身操中有一条:

不要使用 else 关键词,应该使用卫语句替代嵌套的条件表达式,即当条件不满足时,立即从函数中返回。

为此,我们继续对上面的 distributeEpub 重构:

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

  this.sendEpub(epub);
}

当消除了多层缩进后,理解这段代码的难度瞬间就小了,但如果有很多层 if...else if...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;
}

还按照方式改吗?可以,但却不是最优解,因为这种逻辑本质上是缺少一个模型,应该拆出一个接口,然后通过多态解决。

(3)循环

循环有什么问题?没啥大问题,就是有些过时了,因为函数式编程的普及,我们应该用更现代的方式来写循环语句。Java 8 中引入 Stream 流就是干这个事的:

public void distributeEpubs(final long bookId) {
  List<Epub> epubs = this.getEpubsByBookId(bookId);
  epubs.stream()
    .filter(Epub::isValid)
    .filter(this::registerIsbn)
    .forEach(this::sendEpub);
}

在《重构 (豆瓣)》第一版中并未提及循环语句有问题,但在最新的第二版中却将其纳入其中,为什么?上面的写法到底有什么优势?代码的抽象层次不同。

循环语句表现的是细节(怎么做),而流中的列表转换表现的是意图(做什么)。

(4)switch

跟上面 if...else 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;
  }
}

前面说过,这里少了一个模型:

interface UserLevel {
  double getBookPrice(Book book);
}

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

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

...

如果想更简约一点,可以使用枚举类的特性来实现:

public enum UserLevel {
    SILVER() {
        @Override
        public double getPrice(Book book) {
            return book.getPrice() * 0.9;
        }
    },
  
    GOLD() {
        @Override
        public double getPrice(Book book) {
            return book.getPrice() * 0.8;
        }
    },
  
    PLATINUMP() {
        @Override
        public double getPrice(Book book) {
            return book.getPrice() * 0.75;
        }
    };

    public abstract double getPrice(Book book);
}

小结

这篇小结中,主要对日常开发中的命名,重复,长函数,长参数列表,大类,控制语句滥用等问题进行了剖析,如果你已经发现了相关问题,那么立即动手调整是一个好习惯。

大多数时候,我们的主要精力是在看代码和理解代码上,所以写代码时,可读性和可维护性,是第一考量。这也是为什么前文中反复在强调意图,强调做什么,而不是怎么做。一个好的名字,可能会帮我们节约半个小时甚至几个小时。那意图又是什么呢?是对业务的深刻理解。

另一方面,人脑的认知有限,把复杂的内容拆解成一个个小部分,那么这些小部分所造成的影响也将是局部的。既然是局部影响,那便可控,造成重大软件事故的概率就小。