LeeYzero的博客

自强不息,厚德载物

0%

编写可读代码的艺术

最近花了一个周的零星时间,看了《编写可读代码艺术》,收获颇多。虽然平时也经常使用书中提到的一些方法编写代码,但只是一种直观感觉认为这种方式是“正确”的。书中将这些方法提炼成一条条原则,并采用大量实例佐证这些原则。书中系统化的介绍了如何编写可读代码,并提出很多指导性原则,整本书不到200页,非常值得阅读。

基本原则

何为好代码,何为坏代码,每个人的判断标准可能并不一样,但作者提了总的指导原则:代码应当易于理解来诠释什么是好的代码,并用可读性基本定理:代码的写法应当全人理解它所需的时间最小化来衡量代码的可理解程度。即“可读”是一个总的指导原则,而可读性基本定理则是对“可读”的定量判断。当我们在犹豫不决时,可读性基本定理可以帮你做判断。

命名

在编写代码时,做的最多的一件事情就是命名,我们会对变量、函数、类、包等命名,命名的本质是把信息封装到名字中,好的命名,可以帮忙你快速理解代码。书中提到了很多指导原则,我认为比较重要有些原则的如下:

选择专业的词

有很多专业词汇是计算机从业者都比较熟悉的,如get、set、find、download,upload等,通常选用这些专业词汇,更容易让别人阅读你的代码。但在不同场景,应该选用更具表现力的词汇,比如get一般来讲是获取比较轻量级的对象,但当我们想从网络或数据库中获取一个对象时,可能用fetch会更好。

使用前缀或后缀给名字附带更多信息

对于一些具体单位属于的名字,比如delay,当你看到这个变量时,你可能并不知道它是delay毫秒、秒、分钟,甚至是小时,你只有进一步查看该变量使用的地方看能否提供更多的信息。如果将单位编码到名字中,如delay_sec,则一眼就能理解其含义。更多具有相似的变量如:

函数参数 带单位的参数
Start(int delay) delay -> delay_sec
CreateCache( int size) size -> size_mb
Download(float limit) limit -> max_kbps
Rotate(float angle) angle -> degrees_cw

利用名字格式来传递含义

有目的的使用大小写、下划线等。比如对成员变更后面加上”“来区分,全局变更使用大写,使用”“分隔。采用哪些规范由并不重要,重要的是在你的项目中要保持一致。

决定名字的长度

好的名字并不意味着很长,名字太长也很难记,还占用屏幕空间。有一些指导原则可以使用:
在小的作用域里可以使用短的名字

1
2
3
4
if (debug) {
map<string, int> m;
LooUpNamesNumbers(&m);
}

上述列子中,尽量m没有包含什么信息,但这个小的作用域范围内,并不影响理解这段代码。但如果m是一个全局变量,那就需要更多信息来标识这个变量了。

使用缩略词
我们经常使用str代替stringdoc代替documentpic代替picture等,使用这些共识的缩略词,并不影响理解代码。

风格

有三条原则:

  • 使用一致的布局,让读者很快就习惯这种风格
  • 让相似的代码看上去相似
  • 把相关的代码行分组,形成代码块

另外需要注意的是,每个人的编码风格可能并不相同,例如,类定义的大括号该放在哪里:

1
2
3
class Logger {
....
};

还是:

1
2
3
4
class Logger 
{
....
};

这里两种风格都不影响可读性,但如果两种风格同时放在一起,就会对可读性有影响了。有时,即使我们感觉使用了“错误”的风格,但我们还是需要遵守项目的习惯,因为一致的风格比“正确”的风格更重要。

注释

注释的目的是帮助读者了解作者在写代码时已经知道的那些事情。 如果代码本身就能很好的反映事实,那基本不需要注释;另外,如果需要靠一些“拐杖式注释”来粉饰代码,那应该把代码改好。 对于为什么代码写成这样而不是那样的内在理由或这样写的背景以及代码中的一些缺陷,需要写下注释;

对于写完的注释,看是否能真实表达代码意图,最好的办法就是让同事看你写的注释能否看懂。最后就是注释一定要与代码同步,如果修改完代码后,注释却没有同步修改,将会误导读者,这种比没有注释更严重。

控制流

条件语句中参数的顺序

先来比较一下下面两段代码的易读性:

1
if (length >= 10)

1
if (10 <= length)

对大多数据程序员来说,第一段更易读。为什么为这样呢?因为这符合自然语言的语法习惯,我们通常会说“如果你小于18岁”而不是说”如果18岁大于等于你的年龄”。下面指导原则很有帮助:

比较的左侧 比较的右侧
“被问询的”表达式,它的值更倾向于不断变化 用来做比较的的表达式,它的值更倾向于常量

if/else语句块的顺序

  • 首先处理正逻辑而不是负逻辑,如if (debug)而不是if (!debug)
  • 先处理简单的情况,这种方式可能会使得if和else会在屏幕内都可见。
  • 先处理有趣或者是可疑的情况

三目运算符

它对于可读性的影响是富有争议的。拥挤者认为可以只写一行而不用写成多行;反对者则说可能会造成阅读的混乱而且难以调试。但如果三目运算符的值都是常量,其可读性是比较高的:

1
time_str += (hour >= 12) ? "pm" : "am"

如果要避免使用三目运算符,那就得写成:

1
2
3
4
5
if (hour >= 12) {
time_str += "pm";
} else {
time_str += "am";
}

这就有点冗长了。然而如果表达式很复杂,就会变得难于理解:

1
return exponent >= 0 ? mantissa * (1 << exponent) : mantissa / (1 << -exponent);

避免do/while循环

通常来讲,逻辑条件应该出现在它们所“保护”的代码之前,这也是if、while和for语句的工作方式,因为你通常会从前往后读代码,而do/while循环就有点不自然了。很多读者会读这段代码两遍。幸运的是,实践中大多数的do/while循环都可以写成while循环。

最小化嵌套

嵌套很深的代码很难理解。每个嵌套层次都在读者的“思维栈”上又增加了一个条件。当读者见到一个右大括号}时,可能很难“出栈”来回忆它背后的条件是什么。可以通过提早返回来减少嵌套或减少循环内的嵌套。

从函数中提前返回

有些程序员认为函数永远不应该出现多条return语句。其实从函数中提前返回没有任何问题,而且很受欢迎。如:

1
2
3
4
5
public boolean Contains(String str, String substr) {
if (str == null || substr == null) return false;
if (substr.equals("")) return true;
...
}

通常来说想要单一出口的一个动机是保证调用函数结尾的清理代码。但现在编码语言为这种保证提供了更精细的方式:

语言 清理代码的结构化术语
C++ 析构函数
Java try finally
Python with
Go defer
C# using

简化表达式

使用解释变量

当一个表达式巨大时,是很难理解和思考的。一个简单的技术是引入“解释变量”来代表较长的子表达式。这有三个好处:

  • 它把巨大的表达式拆成小段
  • 它通过用简单的名字描述子表达式来让代码文档化
  • 它帮助读者识别代码中的主要概念
1
2
if line.split(':')[0].strip() == "root":
....

如果引入一个解释变量就容易理解了:

1
2
3
username = line.split(':')[0].strip()
if username == "root":
....

使用德摩根定理

对于布尔表达式,有两种等价写法:

  • not (a or b or c ) <=> (not a) and (not b) and (not c)
  • not (a and b and c) <=> (not a) or (not b) or (not c)

有时,使用这些法则可以让布尔表达式更具可读性,如:

1
2
3
if (!(file_exists && !is_protected)) {
....
}

可心把它改写成:

1
2
3
if (!file_exists || is_protected) {
....
}

变量

对于变量的运用,主要有以下三个问题:

  1. 变量越多,就越难全部跟踪它们的动向;
  2. 变量的作用域越大,就需要跟踪它的动向越久;
  3. 变量改变的越频繁,就越难以跟踪它的当前值;

针对这三个问题,看如何让变量更容易理解:

减少变量

之前说增加解析性变量让代码更易读,这量的减少变量是指减少不能改进可读性的变量。当移除这种变量后,新的代码会更精练而且同样容易理解。可以通过移除一些没有价值的临时变量、减少中间结果和减少控制流变量来实现。

缩小变更作用域

我们都听过“避免全局变量”这条建议。这是一条好建议,因为很难跟踪这些全局变量在哪里以及如何使用它们。并且通过“命名空间污染”,代码可能会意外地改变全局变量的值。实际上,让所有变量都“缩小作用域”是一个好主意,并非只针对全局变量。关键思想是:让你的变量对尽量少的代码行可见

只写一次的变量更好

“永久固定”的变量更容易思考,因为读者不需要思考更多。基于同样的原因,C++中鼓励使用const。而对于变量,有一个建议是:只写一次的变量更好。因为操作一个变量的地方越多,越难确定它的当前值。

代码组织

对于函数级别的代码,具体来讲,有三种组织代码的方法:

抽取出那些与程序主要目的“不相关的子问题”

我对它的理解是抽象层次的不同,相同层次的代码应该放到一起。比如,我们从main函数入口,接着是抽取出主框架层次的代码,在主框架中又抽取出子框架层次的代码,在各子框架中又抽出出各模块,在各模块抽取出各个类,等等。

重新组织代码使它只做一什事情

这是对单一职责的一个应用。同时在做几件事的代码很难理解。一个代码块可能初始化对象,清除数据,解析输入,处理业务逻辑,格式化输出。如果把这些代码都纠缠在一起,对于每个“任务”都很难靠其自身来帮你理解它从哪里开始,到哪里结束。

先用自然语言描述代码,然后用这个描述帮助你找到更整洁的解决方案

当把一件复杂的事向别人解释时,那些小细节很容易就会让他们迷惑。把一个想法用“自然语言”解释是个很有价值的能力,因为这样其它知识没有你这么渊博的人才可以理解它。这需要把一个想法精炼成最重要的概念。这样做不仅帮助他人理解,而且也帮助你自己把这个想法想得更清晰。

熟悉你周边的库

很多时候,程序员并不知道再有的库可以解决他们的问题。如果你的库能做什么以便你可以使用它,这一点很重要。这里有一条中肯的建议:每隔一段时间,花15分钟来阅读标准库中的所有函数、模块、类型的名字。这并不是说要记住这些库,只是为了在你的脑中建议一个索引,到你在写新代码时,你会想起:
“等一下,有一个库好像可以处理这种情况…”。

总结

以上很多原则是可以遵守的,当你犹豫不决时,可以使用可读性定理帮助你判断。对于一些表面上的改进,如变量命名、统一风格、简化表达式等,是比较容易做到的,但对代码的组织,则需要综合考虑个人的一些抽象能力和归纳能力,需要更多的实践。