Clean Code|如何写出让同事拍手称快的代码

1,757 阅读23分钟

最近读了Uncle Bob的整洁代码一书,抱着以教代学的心态写下本文,愿与诸君共勉。本文一共分为5个小节:

  • Clean Code?
  • Meaningful Names
  • Functions
  • Comments
  • Formatting 第一小节主要讲的是什么是整洁代码,为什么要保持代码整洁;第二节到第五节则是分别从命名,函数,注释和格式化四个方面来具体阐述如何保持代码整洁。

友情提示:本文篇幅较长(7千多字),建议先点赞收藏后再慢慢观看

Clean Code?

混乱的代价?

如果你是一个有过一段时间开发经验的程序员,那你可能有过被其他童鞋的混乱代码拖慢开发速度的经历;如果你是一个工作了两三年以上的程序员,那你大概率会从心底厌恶接手那些已经腐烂的遗留代码。混乱的代码让我们无法快速了解代码的意图,无法准确评估自己的改动所带来的影响,并因此大大降低我们的开发效率,影响我们的心情。设想一下,你加了个功能,结果无意之间原先的功能就用不了,然后你又要花时间修复bug,如果这时候还有客户,老板在不断追问你什么时候能够修复,这感觉,简直了~

如何保持代码整洁?

要想保持代码整洁我们首先需要知道是什么导致了混乱的代码。在我看来有三点原因:

  • 上线时间太紧,我们没有时间整理代码
  • 环境问题导致自动化测试不受重视,进而导致代码上线之后我们无法通过持续重构来清理代码
  • 我们不够专业,我们缺乏对于如何保持代码整洁的专业化训练 对应的,我觉得可以从以下三个方面来加以改进,尽我们所能来保持代码整洁:
  • 对不合理的需求说不,或者分期实现对应的功能,我们是专业的程序员,不能为了上线时间而写出糟糕的代码,我们应该像项目经理捍卫上线时间一样捍卫代码的整洁度
  • 学习自动化测试相关知识,在个人项目或者公司的一些新项目中使用自动化测试技术,体验在自动化测试的帮助下不断重构代码所带来的愉快工作体验
  • 读经典的书,比如说《Clean Code》,《Clean Coder》,《Working Effectively With Legacy Code》等,看优秀的项目源码,可以从自己日常使用的框架源码看起;培养写完代码清理之后再提交到版本管理中的好习惯。

何时开始?

种一棵树最好的时间是十年前,其次是现在。

Meaningful Names

命名应该体现意图

所谓命名要体现意图指的是一个好的命名需要回答以下几个问题,这个命名是什么意思?这个命名是用来干什么的?确实需要这个命名吗?举个栗子:

function getItem(list) {
  return list.find(item => item.active === true);
}

看到这个函数我们可能会感到很迷惑,list里面是什么?我该如何使用getItem?我们简单重命名下改成下面的函数。

function getActiveAccount(accounts) {
  return accounts.find(acc => acc.active === true);
}

可以看到两个函数的代码逻辑一模一样,但是第二个函数的可读性就明显比第一个好很多,我们知道了list里面存的是账户信息,函数的功能是从一堆账户中找到当前的活跃账号。由此可见,好的命名对于代码的可读性来说尤为重要。

既然好的命名对于代码的可读性来说如此重要,那我们该如何选择好的命名呢?我觉得可以从以下几方面着手:

  • 平常多读一些优秀的项目源码,不用太大,先从小而美的入手,比如说 underscore,也不用一口气读完,可以每次只读一小段,慢慢积累。这就像写文章一样,先从阅读别人写的优秀文章开始,学习别人的写作思路,慢慢积累,久而久之就能写出好文章了。
  • 学好英语,平时多阅读一些英文的技术文章,没事在youtube上看看视频,比如说 Uncle Bob(Clean Code作者)关于整洁代码的分享,相信我,你看完之后一定会被这个有趣的老头吸引的。
  • 先命名,日后看代码时想到更好的命名再加以替换(当然,这要在自动化测试的保护之下,确保自己的每次改动都是安全的)

使用可搜索的命名

比如说下面的代码,5是什么意思?为什么是5,不能是1或者4?如果日后需求变更要把5变成4怎么办?全局搜索所有的5显然是一件不靠谱的事情。

if(days > 5){
  ...
}

下面我们稍微改下,

const WORKING_DAYS_PER_WEEK = 5;

if(days > WORKING_DAYS_PER_WEEK){
  // 这个童鞋的工作时间超出正常的每周工作时间,列入加薪名单
}

代码的逻辑完全和之前的一样,但是代码的可读性和可维护性相比于之前的代码来说就会好很多,比如说日后国家推行4天工作制,那我们只需要修改下WORKING_DAYS_PER_WEEK的取值就可以了,岂不美哉。

避免编码

早期由于编程环境的限制,程序员们会在变量名前加上数据类型来帮助记忆数据类型 比如说 phoneString,ageInt,很显然今天的我们没有必要再这么做了。

类名

类名只能是名词或者名词短语,不能是动词。

方法名

方法名只能是动词或者动词短语,不能是名词。

添加有意义的上下文信息

class User {
  private name:string;
  private age:number;
}

上述代码中,上下文信息 User 就告诉我们里面的name和age分别表示用户名和用户年龄,这时候就没有必要再命名为 userName或者是userAge了。

Functions

简短

为什么说函数要尽可能简短呢?简短意味着函数不会干太过事情,这样为函数选择合适的命名就会更为容易,理解代码的意图就会相对来说更为容易。

Do One Thing

对于这条规则相信很多童鞋都不陌生,那么,怎么才算是只做一件事呢?或者换句话说我们如何定义一件事?这看起来是一件非常主观的事情,每个人对于一件事的定义都不尽相同。那有什么相对客观的方式来判断某个函数是否符合单一职责原则吗?

Uncle BobClean Code 一书中给出了自己的判断指标:“如果一个函数,你无法再从中抽取任何有意义的子函数,那这个函数就是符合单一职责的”,也就是说,我们可以通过不断的从大函数中抽取小函数,小函数抽取出更小的函数,如此往复直到无法再从中抽取任何有意义的函数来写出符合单一职责原则的函数。

看到这里有人可能会觉得,作者疯了吧,这样写代码的话我的代码里岂不是会充斥着成千上万的小函数。其实不是这样的,抽取的过程实际上是通过命名来阐述代码所干的事情的过程,通过不断地抽取我们可以大大提高代码的可读性,同时,小函数更有利于复用,这也在很大程度上减少了冗余代码,诸君不妨在自己的项目中一试(小编在项目中实际使用过,很好用,代码的可读性和可维护性都得到了很大的提升)。

One Level of Abstraction Per Function

所谓抽象层次指的是语句所表达的概念大小,比如说,我们在读报纸的时候会有大标题,小标题,前言和正文,所有的大标题同属于一个抽象层次,大标题下的小标题则属于更小的抽象层次,以此类推,所谓One Level of Abstraction Per Function指的就是大标题应该和大标题放一起,小标题应该和小标题放一起,不能混起来。

这样做有个很大的好处就是我们在阅读代码的时候能够获得与阅读报纸类似的阅读体验,我们会先看到大概念,如果对大概念感兴趣我们可以跳入子函数中看相对较小的概念。相反,如果将不同抽象层次的代码混合在同一函数中那我们将不得不一次性读完所有细节才能函数本身所做的事情,这无疑大大降低了代码的可读性。

Talk is cheap, show me the code

function sliceMediaBySeconds(mediaPath, maxSeconds) {
  const duration = getDuration(mediaPath);

  const match = duration.match(/(\d{1,2}):(\d{1,2}):(\d{1,2})\.(\d{2})/);
  if (!match) {
    return;
  }
  const [, hours, minutes, seconds] = match;
  const mediaSeconds = [hours, minutes, seconds]
    .map(val => parseInt(val, 10))
    .reduce((acc, cur, i) => acc + cur * 60 ** (2 - i), 0);

  const chunks = Math.ceil(mediaSeconds / maxSeconds);

  Array(chunks)
    .fill(0)
    .forEach((_, i) =>
      sliceMedia({
        inputMediaPath: mediaPath,
        startSeconds: i * maxSeconds,
        endSeconds: (i + 1) * maxSeconds,        
      }),
    );
}

sliceMediaBySeconds 的功能是将一个大的媒体文件按照指定的秒数切分成很多个小文件(部分引用函数考虑到篇幅原因未在此列出)。可以看到 sliceMediaBySeconds 是不符合同一抽象层次原则的。第一行代码调用了封装的函数 getDuration 来获取文件的时长,紧接着就徒手将第一步拿到的 duration 解析成秒数,可以看到这两步操作是在两个不同的抽象层次上的(第一步是直接告诉我们要拿文件的时长,但是怎么拿是封装在子函数里面的,这就相当于只告诉我们小标题,具体的内容被函数封装起来了,而第二步则是将怎么解析的过程明明白白的告诉我们,这就相当于把正文直接写在标题应该出现的地方,我们必须读完整个解析过程才能知道第二步是在把 duration 解析成秒数,这无疑在很大程度上降低了代码的可读性)。那么,我们该如何改进这段代码呢?没错,就是上一小节提到的持续抽取。

function sliceMediaBySeconds(mediaPath, maxSeconds) {
  const mediaSeconds = getDurationInSeconds(mediaPath);
  const chunks = Math.ceil(mediaSeconds / maxSeconds);

  sliceMediaByChunks(mediaPath, chunks);
}

function getDurationInSeconds(mediaPath) {
  const duration = getDuration(mediaPath);
  return duration2Seconds(duration);
}

const durationRegExp = /(\d{1,2}):(\d{1,2}):(\d{1,2})\.(\d{2})/;
export function duration2Seconds(duration = '') {
  const match = duration.match(durationRegExp);
  if (!match) {
    return 0;
  }

  const [, hours, minutes, seconds] = match;

  return [hours, minutes, seconds]
    .map(val => parseInt(val, 10))
    .reduce((acc, cur, i) => acc + cur * 60 ** (2 - i), 0);
}

function sliceMediaByChunks(mediaPath, chunks) {
  Array(chunks)
    .fill(0)
    .forEach((_, i) =>
      sliceMedia({
        inputMediaPath: mediaPath,
        startSeconds: i * maxSeconds,
        endSeconds: (i + 1) * maxSeconds,
      }),
    );
}

我们再来看 sliceMediaBySeconds, 第一步获取文件的秒数,第二步计算切片数,第三步根据切片数切分原先的文件,看起来是不是比混合不同抽象层次的版本要好很多?我们几乎可以毫不费力的就能读懂 sliceMediaBySeconds 的执行逻辑,这是为什么呢?两个版本的逻辑是一模一样的,为什么第二个版本的可读性会比第一个版本的可读性好很多?

这是因为在抽取子函数的过程中我们需要以函数干了什么来为子函数命名,这就相当于为一段文本添加小标题,这样,读者只需要读小标题就能知道这段文本的大致内容,这无疑在很大程度上为我们快速理解代码提供了有利条件。

函数名称应该体现函数所做的事情

这点无需多言,除非你想故意迷惑你的同事,那另当别论,小心被打就是了。

参数

越少越好,避免三个或三个以上的参数

为什么说参数越少越好呢?不知道大家在日常写代码的时候有没有过弄错参数位置的情况,我就经常搞糊涂参数位置,比如说 extend 函数,调用的时候我就经常搞不清楚dest在前还是src在前;此外,多个参数还会出现某些中间的参数日后会出现不需要传入的场景,这时候我们就会看到 func(a,b,undefined,undefined,c) 这种奇怪的调用方式。这时候可能有童鞋就会问了,如果我确实需要那么多参数该怎么办呢?可以考虑使用对象来传入。

function extend(dest, src) {
  for (const key in src) {
    dest[key] = src[key];
  }

  return dest;
}

避免Output Arguments

所谓Output Arguments指的就是函数没有返回值,输入的参数同时也是输出。举个栗子:

function extend(dest, src) {
  for (const key in src) {
    dest[key] = src[key];
  }
}

可以看到 dest 既是输入,也是输出。那为什么我们要避免参数既是输入又是输出呢?因为这违反直觉,按照常理,我们调用一个函数肯定期望输出是通过返回值给我的。

避免使用flag arguments

所谓 flag arguments 指的就是作为开关值传入的参数,参数为true的时候进入一个分支,参数为false的时候进入另一个分支。不知道大家对于 flag arguments 是怎么看的,小编是不太喜欢的。为什么呢?我们来看个例子:

function extend(dest, src, ignoreUndefined = false) {
  for (const key in src) {
    if (src[key] === undefined && ignoreUndefined === true) {
      continue;
    }
    dest[key] = src[key];
  }

  return dest;
}

然后我们在代码中就会看到这样的调用 extend(dest,src,true)。如果不知道 extend 内部实现的话,最后的参数 true 是什么意思?为什么是 true 不能是 false ?这无疑为我们理解代码设置了障碍。那我们该如何避免这个问题呢?最好的方式当然是不用,那如果不得不用该怎么办呢?私以为有两种方式:

  • 在调用的地方明确告知参数的意思
const ignoreUndefined = true;
extend(dest,src,ignoreUndefined);
  • 添加注释
extend(dest,src,true /* ignoreUndefined */);
  • 抽取函数
function extendNonUndefined(dest, src) {
  const ignoreUndefined = true;
  return extend(dest, src, ignoreUndefined);
}
// 后续需要用到 extend(dest,src,true) 的地方都可以用 extendNonUndefined(dest,src)来替代

看到这里可能有童鞋会说,有必要吗?我就喜欢用 flag arguments 。哦,那你继续用呗。

避免副作用

副作用指的是函数做了职责之外的事情。举个栗子: 可以看到 checkPassword 的作用是检查用户id与密码是否匹配,但是,函数的第7行初始化了Session,这就是副作用,即函数改变了本不属于自己操作范围的东西。类似的还有改变全局变量(比如说在window上添加属性,改变原生对象上的方法)、dom操作等。那我们该怎么应对副作用呢?

  • 尽量避免
  • 通过名字告知这里有副作用,比如说我们可以将 checkPassword 重命名为 checkPasswordAndInitializeSession

Don’t Repeat Yourself

这条规则就不必多说了,大家都知道,重复可能是软件的万恶之源。

How Do You Write Functions Like This?

有这么多关于如何写好一个函数的规则,那我们该如何写出这样的函数呢?首先,要明白好的代码不是一次性就能写出来的,好的函数亦是如此,我们可以先写,添加测试,然后通过不断清理来使得函数一步步符合上述原则。

为什么要这样呢?因为实现功能与保持代码整洁其实是两件事情,实现功能已经足够复杂了,如果我们还要同时记住这么多的规则来写出整洁的代码那大概率我们会什么都做不好,更好的方式是在某个时间保持专注的只做一件事情,写代码的时候就专注于实现功能,之后再开始清理代码,让代码保持整洁。拿装修为例,如果要求师傅装修的同时不能弄脏地板,那大概率师傅的工作效率会大大降低,甚至会根本不可能实现,更合理的做法是先专注于装修这件事,等装修完毕之后再来清理地板和各种垃圾。

Comments

我们什么时候会需要注释呢?我们感觉代码无法表达出我们的意图,只好借助于注释来表达我们的意图。所以,应当把注释看成一种失败,你无法用代码准确表达出自己的意图,只好借助注释来弥补这种不足。(当然,并不是呼吁大家不写注释,不要误解我的意思,这里要说的是如何保持注释整洁,而不是不写注释)

注释无法拯救糟糕的代码

不知道大家在工作过程中干过这种事没有,我写的这段代码极其糟糕,混乱不堪,甚至我都不知道这段代码是如何正常工作的,于是,我们在这段代码前面添加注释,说明这段代码的意图,小编就干过这样的事情。

但是,作为一位专业的程序员,这么做显然是不被允许的,也是极其不专业的做法。正确的做法应该是清理代码,将混乱不堪的代码整理成整洁干净的代码以至于不需要注释也能明确知道代码的意图。

用代码来表达自己的意图

每当你发现自己需要为一个复杂的表达式添加注释的时候不妨先想一想能不能通过添加描述性的变量或者函数来表达自己的意图。

好的注释

上面说了注释是一种失败,是对无法用代码表达意图的一种补偿,那什么才是好的注释呢?

法律信息

// Copyright (C) 2003,2004,2005 by Object Mentor, Inc. All rights reserved.
// Released under the terms of the GNU General Public License version 2 or later

Focus on why instead of how

所谓的 Focus on why instead of how 指的是注释应该告诉我为什么要这么做,而不是怎么做。举个栗子:

  // 音频切割过程中若结尾处存在音频空白则直接拼接字幕会导致后面的字幕提前出现(末尾的空白时间被跳过了)
  // 此处手动修复下字幕文件的结束时间,使得字幕的结束时间与音频的结束时间保持同步
private async fixEndTimeOfChunks() {
  ...
}

fixEndTimeOfChunks 是我之前写的一个自动解析视频中的音频,并通过语音识别出字幕的工具(英语不行,没法听懂不带字幕的视频)的一个小函数。这个工具会先从视频中抽取出音频文件,然后将音频切分成固定时长的小音频文件,接着借助一个网站来将这些短的音频文件解析出字幕文件并下载到本地,最后把下载的字幕拼接成完整的字幕文件。拼接字幕文件的过程中有个问题,字幕的结束时间并不等于音频的时长,因为人不可能一直在说话,总有停顿的时候,这就导致拼接之后的字幕与音频对不上。fixEndTimeOfChunks 的作用就是修复这个问题,而为 fixEndTimeOfChunks 添加的注释也是为了说明这么做的意图。

Clarification

这里的注释 a < b 就是澄清前面代码的意图

a.compareTo(b) == -1 // a < b

Warning of Consequences

比如说下面的例子,我们写了个执行起来非常耗时的测试用例,那我们可以通过注释警告别人这条测试用例的执行耗时非常长,不要所以运行这条测试。当然,更好的注释可能是明确告知测试运行的大概时间,同时警告不要随意运行这条测试。

// Don't run unless you
// have some time to kill.

差的注释

TODO Comments

啥,TODO Comments 也成了糟糕的注释了,小编怕是脑子有问题吧。先不要急,且听我狡辩。为什么说 TODO Comments 是糟糕的注释呢?不知道大家有过这种经历没有,代码写的不够好,我搞个 TODO Comments 注明后面有时间会来清理这段代码的,然后提交到代码仓库中,上线,然后就没有然后了。

为什么会这样呢?因为稍后等于永不,当 TODO Comments 被提交到版本管理系统中之后我们就再也不可能花时间去处理它们了。

当然,如果你能记得清理仓库中自己留下的 TODO Comments 的话还是可以继续使用的,如果不能的话最好借助于其他方式来加以记录。比如说,保持一个活跃的待办事项列表,闲下来的时候就来清理一下代办事项列表。

自言自语

loadProperties 的职责是从指定位置加载配置文件,如果加载出错(比如说文件不存在或者解析错误等)就什么都不做。然后作者在加载出错的逻辑里面加了一行注释“No properties files means all defaults are loaded”。

这有什么问题?没有配置文件就说明默认的配置文件被加载了,这从哪里能体现出来?如果日后加载默认配置文件的逻辑去掉了那这里的注释岂不是成了错误的注释?注释要与代码相对应。

冗余的注释

我从 i++ 上就能看出来i自增,不用你添加注释来告诉我这行代码的意图是i自增。

Misleading Comments

所谓 Misleading Comments 指的是注释与代码表达的意思不符,这在持续腐烂的代码中尤为常见。

Journal Comments

记录修改的版本信息,这应该是git干的事情,不是注释该干的事情,添加这样的注释只会降低代码的可读性。

Position Markers

所谓 Position Markers 指的是通过注释来分割不同的代码块,举个栗子:

// Actions start //////////////////////////////////
// Actions end //////////////////////////////////

为什么说通过注释来分割代码块不是一种值得推荐的做法呢?因为注释通常会被我们所忽略,如果我们日后在一段 start 和 end 之间加入了本不属于这段代码块的逻辑,那这些分割信息岂不是成了错误的提示?

其实我们可以通过npm包,拆分文件,拆分模块等诸多方式来拆分不同的代码逻辑,没必要借助注释这种不靠谱的东西来分割代码块。

注释代码

不知道大家遇到过这种情况没有,有些代码可能因为逻辑变更而变得不需要了,但是担心后面还会用到,于是直接将这部分代码注释掉,然后,这段代码再也没被解除注释,成为垃圾代码遗留在代码仓库中。

其实大可不必,git会帮我们记得所有修改记录,如果某段逻辑已经不再使用了大胆删除就是,日后想再使用完全可以通过git找回来(当然,这要依赖于整洁的提交信息)

HTML Comments

在注释中写标记语言,如何展示注释不是注释本身该干的事情,直接将html标记添加在注释当中是不妥的。

Nonlocal Information

所谓 Nonlocal Information 指的是注释说明的信息并不能从当前被注释的代码片段上看出来,举个例子: 上面的注释中 “Defaults to 8082” 就无法从当前代码片段上体现出来,作者这里说的是其他地方的逻辑。这么做的问题就在于注释与代码不是保持同步的,比如说日后默认端口不再是 8082 了,那这段注释就是错误的信息。

Formatting

看到这里估计有的童鞋就会说了,格式化有什么好说的,直接交给 prettiereslint 这些工具去做不就好了。非也非也,此格式化非彼格式化,且听我慢慢道来。

使用空行分隔不同的代码块

举个栗子: vs 两张图的代码逻辑是一模一样的,为什么我们会觉得第二张图里的代码可读性更高呢?原因就在于我们借助空行来分割不同的代码块使得结构更为清晰。

Dependent Functions

所谓 Dependent Functions 指的是被调用的函数应该出现在调用方之下,尽可能接近。为什么要这样呢?还是以读文章为例,我们在读一篇文章的时候肯定会先关注大标题,如果对大标题感兴趣才会去看小标题,然后是正文。读代码也是如此,大的概念在上面,稍小一些的概念则紧随其后,这样我们在阅读代码的时候就可以从上到下,从大的概念读到小的概念。

Conceptual Affinity

所谓 Conceptual Affinity 指的是概念上相近的代码应该在距离上保持足够接近。举个栗子,Vue 2.0 中如果要实现一个轮播图组件那我们可能会在 data 中存入所有的轮播图以及当前轮播图容器的偏移值,然后在 computed 中添加计算属性来修改容器位置,最后在 created hook中搞个定时器定时改变当前的偏移值,这样,我们的轮播图就能动起来了;随后,产品童鞋觉得轮播图应该由用户手动切换,于是你在 methods 中添加 touchstart ,touchmove, touchend 对应的处理函数来修正容器位置、停止和启动定时器;再然后,产品童鞋觉得木得变化的轮播图太单调了,如果能加上放大缩小的效果就更好了。。。,如此往复,你会发现你的轮播图组件代码变成了下面的样子: 逻辑上相近的代码块被 options 强行分隔开,然后你就会发现自己需要在不同的options 之间跳来跳去才能弄明白一个功能的所有细节。所以后来尤大也在 Vue 3.0 中引入 Composition Api 来解决这个问题。

总结

本文先简述整洁代码的重要性,然后从命名,函数,注释和格式化4个方面详细阐述如何保持代码整洁。

最后,我想以《Working Effectively with Legacy Code》作者 Michael Feathers 对于什么是整洁代码的定义来结束本文。

Clean Code always looks like it was written by someone who cares