谈谈代码风格

这次先不说具体技术,讲讲我对代码风格的看法吧。

实际上代码规范说明也不少,但是都有很多,一条一条比较细地列下来,但是比较乏味,背后的道理讲得也不够详细。我就打算选取少量个人觉得重要的点,详细地讲一讲,希望能有趣一点。
所谓好的代码,就是易维护的代码,易维护的代码需要易读,易修改;其中易读是基础,不易读怎么能易修改,本次就重点讲讲易读的代码。

命名和作用域

有意义的变量及函数名

使用有意义的变量及函数名,可以显著地提高代码的可读性,我觉得好处大家都懂。

具体的做法上呢,我们不要害怕函数和变量名太长,现在的IDE和编辑器都能提供代码自动补全的功能。使用缩写一定要使用通用的,大家都承认的缩写,没有的话宁愿写很长的全称,让大家一看就知道是什么;如果不开源的话,只要自己公司或部门能看懂也可以接受。

再比如一般不应该在名字中出现数字表示函数的两个版本,比如add(x, y)add2(x, y),如果确实有必要分成两个函数,应该在函数名上让明确其具体区别,比如add_integer(x ,y)add_float(x, y),不需要逼读者去了解实现。
当然也有一些例外,比如函数名中的数字确实和函数的具体区别相关比如add2(x, y)add3(x, y, z)表示相加的数字个数不同,是可以接受的。有些地方用2来谐音表示to,也勉强可以接受(个人觉得这个还是不要提倡)。

多使用中间变量简化复杂表达式

多使用中间变量,能够有效控制代码每行的长度;把复杂表达式分解成一个个简单表达式,更能帮助理解。同时,有意义的中间变量名能进一步把整体流程解释清楚。

变量作用域尽可能小

对于局部变量,我们应该尽量能够做到,一眼看去,变量的整个生命周期一览无余,包含其声明及每个使用的地方。这样我们能够非常有自信这个变量从哪里来,到哪里去;没有被意外修改,可以放心地使用;同时也不会误会它可能在未来被使用。

不要重用变量

不要重用变量,属于变量作用域尽可能小的一个具体细节。重用变量显然是无必要地增大了变量的作用域,容易造成混淆和误用。最好分开定义,能够清晰看出它们毫无关系。一个非常常见的场景是,用途近似甚至相同的变量,比如循环变量,经常有人喜欢重复使用同一个;这里我们应该把作用域隔离好,之后使用同样的变量名也是可以接受的。

局部变量名尽量简短

这个看起来和前面有意义的变量名有矛盾。
但是我们需要注意这里是局部变量,而且我们提倡作用域尽可能小,在这种情况下其上下文是一目了然的一小段,如果我们能快速根据其近距离的上下文判断其含义,就不需要用过长的变量名扰乱视线。

1
2
3
4
5
6
7
bool successInPing = ping("8.8.8.8");
if (successInPing) {
/* code */
}
else {
/* code */
}

如以上代码,其中successInPing这个名字就没有必要,从上下文可以非常轻易地看出这个变量指ping的返回值,我们只需要success,就能很清晰地看出这是指ping是否成功。

1
2
3
for (int i = 0; i < n; i += 1) {
a[i] = b[i];
}

再比如由于i同时是iterate和index的首字母,所以使用i作为循环变量和下标已经非常普遍,约定俗成了,也是可以接受的。

模块化

大家都知道好的代码要模块化,那如何模块化?其实编程语言给我们提供了最基础的模块化单元,就是函数。函数就是最重要的模块化手段,没有之一;利用好函数,其他的模块化都是锦上添花;函数层面没模块化好,其他什么都是白搭。

模块化能减少代码重复,提高复用率,符合DRY(Don’t repeat yourself)原则;减少重复也是提高可读性的途径之一。

多提取帮助函数

和提取中间变量,简化复杂表达式一样。我们同样可以通过提取帮助函数简化复杂的处理逻辑,提高可读性。

提取函数的时候,不管多短,只要是一个重复使用(甚至暂时还没有重复)的基本功能的单元,哪怕只有一两句也可以提取成函数,有这样的认识,我们会发现,代码里面的重复比我们想象还要多些。

一个函数只做一件事

一个函数只做一件事情,其实可以有效地帮助代码复用,越是把函数拆分提取成小的基本功能单元,它就越是通用,越能在自由组合后用于更广泛的地方。

短小的函数

多提取帮助函数,一个函数只做一件事情,那我们自然就得到了很多短小的函数。

而且短小的函数和前面讲的变量作用域尽可能小也是一致的。都能帮助一目了然地看明白整段逻辑。而且函数本身也是个作用域,短小的函数能帮助,某些生命周期不得不贯穿整个函数的变量,缩小作用域。

函数的一般没有严格的硬上限,但是一般大致的软上限在四十行左右,主要来源于,屏幕不用拉动能一次性显示的代码行数。

避免使用全局变量传递信息

除非必要,不要使用全局变量传递信息。一旦使用了全局变量,函数就具有了状态,不再对确定的输入有唯一确定的输出,而是依赖于这个全局的状态,而这个全局的状态可能被任何地方修改,使代码的正确性更难保证;同时单元测试也变得难以编写。使用全局变量传递信息的这几个函数也被耦合在一起,而仅通过参数传递进行信息传递的函数可以自由地组合使用。

注释

自解释的代码代替注释

经常看到提倡程序员写注释的说法,其实注释并不是越多越好。良好的代码本身应该是自解释的,如果需要很多注释来说明,往往代码本身是糟糕的。

如何写自解释的代码,其实方法在前面都讲到了。多使用中间变量,有意义的变量名自然就代替注释进行了解释;多提取帮助函数,有意义的函数名也代替了注释进行解释。

仍然需要注释的地方

  • 为了性能使用了一些底层操作或复杂算法
  • 各种原因导致的一些不得不存在的丑陋代码或奇技淫巧
  • 函数的参数和返回值说明,特别是具体调用示例可以更清晰快速地说明一个函数的使用
  • 整个模块的总体介绍,架构思路,存在原因等

不要过分容错

在编程中程序会碰到各种各样的错误,需要继续错误处理或者说容错。

但是错误并不都一样。有些错误是由于IO等需要和外部交互导致的,无法避免其失败的可能性,必须进行处理。

有些错误是属于代码的逻辑错误,如果程序本身是正确的,根本不应该出现,这种情况,其实并不应该容错。一个非常普遍的例子就是空指针,其实很多时候,函数的输入参数是一个指针,按正确逻辑根本不应为空,要是在C++里,我们可以直接使用引用,让类型检查来帮助我们保证不会出现空指针,但是C里面没有这种功能,有时候我们就人为进行容错。

接受到本就不该出现的空指针后,这个函数本身又返回一个值表示错误,一般也是空指针,然后空指针的影响在程序中扩展开来,等最终发现空指针导致的问题后,我们已经很难定位到问题的源头。

其实正确的态度是,强硬地不接受这种调用代码逻辑问题导致的错误输入,直接让程序崩溃就好了,这就是一个需要马上发现修复的问题。当然空指针和函数传入错误参数都只是举例,实际上强硬对待所有逻辑错误的场景不止这些。

有时候我们把把程序崩溃看得过于严重,实际上真正在工作环境崩溃才严重。让程序强硬对待错误,易于崩溃,其实是让错误更易于在开发测试阶段发现,从而更少地把Bug流到实际工作环境才发现,一则含有Bug行为出现错误的程序严重程度未必就低于崩溃,二则这种处理后真正到工作环境的崩溃搞不好反而更少。同时,还能防止错误在代码中扩散,变得难以追踪;帮助快速定位问题的源头,进行快速的修复,节约大量排查的时间。

这就是”fail fast”思想,一旦发现问题,就让软件尽可能快,尽可能显眼地失败,实践证明是一种能显著减少代码bug数量的技术手段。

统一规范

在项目不是只有一个人进行开发的时候,统一规范相当重要的,即使这可以不算在代码质量。

统一编码

在项目中,大家往往喜欢使用自己的母语进行注释,但除了英语之外的语言由于历史原因,有各自杂七杂八的不同编码。如果不统一编码,就会造成经常打开代码后看到注释全是乱码的问题;更严重的是代码在使用不同编码的人手上转了一圈,修改保存好几次之后,可能会变成彻底的乱码,用啥编码都看不到原来的注释写的是什么了。

如果你觉得自己英语水平还可以的话,干脆全部注释使用英语写是避免编码问题的好办法。

不然,我们最好就统一编码,当前业界绝大多数地方都统一使用UTF-8编码,它的好处:一是包含了几乎所有语言的字符,不像过去的各种编码,每个语言都有各自的标准,不同的语言间的编码是互相重叠冲突的;二是这是一种变长编码,原来ASCII码中的英文字符和常见符号仍然是一个字节,而其他字符可能是两个或这三个字节长,对于主要是英语的文本来说,长度短于每个字符固定两个字节长的编码。

统一缩进

在一个项目中,不同的人使用了不同的缩进,很可能造成打开代码发现缩进没有对齐,虽然对逻辑没有影响,但是缩进的视觉效果对人类理解代码的层级还是非常重要的。

缩进主要有两个分歧点,一是使用Tab还是Space,二是缩进的长度。

Tab的一个好处是可以避免第二个分歧点,如果我们所有的缩进的严格使用Tab,则不同的人想要把缩进长度显示为几格都没有关系。

Space的一个好处是,有的时候我们有很长的条件表达式,希望换行并对齐以获得更清晰的结构,但是这个时候对齐需要的缩进不一定能能被正常缩进的单位长度整除,就比如如下例子中默认缩进是四个,但是对齐需要六个。这里Tab就无法做到这样的效果,或者即使对于编写者使用Tab对齐了,其他人换个长度来显示Tab仍然没对齐。

1
2
3
4
5
while (long_condition1() &&
long_condition2() &&
long_condition3()) {
/* code */
}

这两者最大的区别是,Tab缩进允许不同的解释,相对自由,但是可能由于某些原因,导致换个显示长度就破坏了原作者的意图;Space缩进对所有人看起来都是一样的,相对不自由但是严格。到底使用Tab还是Space其实不重要,重要的是要统一。不过其实业界大多数地方是四个空格缩进的,顺便一提,四个空格缩进不是让你打字的时候每次按四下空格,你应该配置你的IDE或编辑器在按Tab键的时候自动扩展成四个空格。

写”愚蠢”的代码

良好的代码,往往是简单有效的代码,是不耍小聪明的代码。这就是鼎鼎大名的KISS(keep it simple stupid)原则。

运算符优先级

充分利用运算符优先级,无非能少打几个括号,没有有效的好处,反倒在过度依赖优先级的情况下会严重影响可读性,省略括号后大多的人不清楚运算顺序。
正确的态度是不需要记忆复杂的运算符优先级,也不要让以后的读者需要陪你一起来了解这种东西,不确定就加括号。
当然这个问题我在实际工作中看到不多,在学校的教材和考试里面比较多见。

C的宏本质上属于代码在文本层面上的替换,如果你见识的语言特性够多的话,应该能认识到这是个功能有限,又容易出错的东西。功能有限解释起来麻烦,这里就先不解释了,以后有空介绍一些其他语言中也叫宏,但是比C宏强大得多的东西(当然它们都有个统一的特点是用起来很爽,但是定义实现复杂且可读性差,都不建议滥用);以及C里面需要用宏实现的功能,在其他语言里用更易用不易错的特性也可以做到。容易出错方面,一是它没有函数的类型检查对正确性进行一定地保证,二是难以直观地看出文本替换完之后代码到底会变成什么样。

但是客观地说呢,由于C语言本身在特性上的缺乏,恰当地使用宏确实能弥补一下这个问题,减少一些代码重复(经常是人为地想要绕开类型检查,写一些类型通用地操作),在一定程度上让代码简洁可读。可以考虑使用宏的场景,例如在一定程度上模拟范型,用宏实现类似于泛型函数或泛型结构;例如用宏模拟迭代器,方便地遍历链表,哈希表等结构。

完全不应该使用宏的场景包括,定义常量,强制内联等。

自增自减

自增自减操作符其实就是一个失败设计,一开始就不应该存在。

自增自减操作符鼓励程序员使用其返回值,把两个操作合为一个语句,同时由于增加减少变量这个副作用的发生时间到底在前还是在后,又搞出两个版本的自增自减,增加使用者心智负担。

更严重的是在一个复杂表达式里面对同一个变量进行多次自增自减是未定义行为,即语言标准没有规定其求值顺序,在不同编译器甚至同一个编译器的不同版本里面可以有不同行为,换句话说,根本不应该用,但是在语法上它是合法的。

从可读性上来讲,增加减少变量作为一个操作,读取使用这个变量应该作为另一个操作才是正确的,其顺序能更加自然且显然地通过语句地顺序展示。

我个人地习惯是使用x += 1代替x++++x,一个原因是+=这个符号本身相对可以提供一种它没有返回值的心理暗示(虽然绝大数语言是有的)

有些人觉得自增自减比分开来写快,然而代码和生成的机器码又不是一一对应的。
还有些人觉得++i效率高于i++,因为分别等价于({i += 1; i;})({int tmp = i; i += 1; tmp})。我觉得他们应该去看一下一种编译优化叫做死代码消除。

写编译器的人又不是傻子,几十年前技术不完善的时候确实一部分观点是属实,也因此发展出一些奇技淫巧,但是随着时代的进步,这些早就成了历史,然而有些人现在还相信其有效性。

没有遗漏的分支

有时候多层嵌套分支的时候,我们容易漏考虑一些情况。如何减少这种情况,尽可能写出没有遗漏,无懈可击的分支判断。

其实诀窍在于宁愿有小的重复,尽量不要省略任何一个else分支。如果依赖于把某些分支的公共逻辑放在分支的后面,让所有应该执行公共逻辑的分支掉下去,逻辑复杂后就越难梳理出哪些情况掉下去了,容易造成逻辑的错误。

当然你要是说有时候这样些重复部分太长了,代码很冗长怎么办?其实我前面早就说过了,提出去做成函数。

可借助编译器的特例

还有一个特例,如果已经到函数结尾了,每个分支都会直接返回一个值结束函数。这种风格还能借助编译器来检查。如果你的编译器足够成熟或版本够新的话,就应该有完善的分支流程分析。

它能告诉你这段代码可能遗漏了返回。

1
2
3
4
5
int foo() {
if (condition()) {
return 0;
}
}

这段代码的return 2是无用的死代码。

1
2
3
4
5
6
7
8
9
int foo() {
if (condition()) {
return 0;
}
else {
return 1;
}
return 2;
}

那么我们不要在函数末尾加上哨兵return,这也可以被认为是多个分支的共同逻辑,所有return放在分支里可以让编译器帮助我们判断分支是否遗漏。

假如我的意图如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
int foo() {
if (condition1()) {
if (condition2()) {
return 1;
}
else {
return 2;
}
}
else {
return 3;
}
}

漏写成这样就能被编译器提醒有个分支没有返回值。

1
2
3
4
5
6
7
8
9
10
int foo() {
if (condition1()) {
if (condition2()) {
return 1;
}
}
else {
return 3;
}
}

写成这样编译器就只能当它是正常的,然而这仍然和意图不符,是错误的逻辑。

1
2
3
4
5
6
7
8
int foo() {
if (condition1()) {
if (condition2()) {
return 1;
}
}
return 3;
}

小结

关于代码简短

我们说代码优雅简洁并不是单纯从字符数量或行数上的短小。我们应该通过减少重复,简化逻辑来达成代码的逻辑层面上的简洁;不要滥用一些特性,盲目追求周期语法层面的简洁。

关于编译优化

我们应该认识并善于利用编译器的能力,这也是用自动化代替人工的一种。高级语言就是为了人类能够更加方便地表达计算逻辑而出现的,人类只需关注代码对自己友好,除了高层逻辑以外的优化交给编译器才是应有的思路。

虽然由于现实的技术水平限制,编译器还不能完全理想地达到我们的目标,其实以及远比很多人认为地强大了。有些自以为是的优化,其实除了降低可读性,提高出错率之外毫无用处。真想要进行一些偏底层地优化的话,与其瞎折腾,不如先去了解编译器优化,了解编译器做不了什么。

即使你真的对编译器有一定了解,你也会发现,除非你在一个非常冷门的硬件架构上开发,或者使用的编译器不够成熟,否则底层优化少上较少会有你发挥的余地。编译优化涉及大量收益和代价的权衡,虽然编译器是按照死板规则的程序,但是这些规则好歹是很多专家花费大量时间精力总结出来的,不是那么容易就能做地更好的。

编译优化展开来讲可以造就一个博士,我的了解也没那么多,这里就大概概述一下,以后可能会专门写几篇文章,进行稍微详细些的介绍。

编译优化可以这样定义,编译优化就是编译器尝试去最大化或者最小化一个可执行文件一些属性。最常见的需求是最小化运行时间;稍微少见一点的是最小化存储使用;近些年移动端的兴起带动了最小化耗电量的需求。当然前提是程序的行为不能有变化。

为了达到优化的目的,其方向大概有更少的代码(指生成的代码,一般是机器码),更少的分支(或跳转),对分级存储的利用,并行化(对偏底层的语言主要是指令级,存储级的并行),提高访问局部性,用快速或简单的指令代替缓慢或复杂的指令等。

编译器分析代码后,常常会形成SSA(static single assignment form,静态单赋值形式),不管我们使用多少中间变量,是否重用变量,其实其SSA是完全相同的。通过算法分析后,进行的寄存器分配和内存分配,其实和原本代码里声明的局部变量数量没有什么关系。经过条件常数传播,全域数值编号,死代码消除后绝大多数的多余操作都被消除了。

另外编译优化的一个重头戏是循环优化,有统计数据证明程序的大部分时间跑在循环里,循环优化一点,整个程序性能就能提高一大截。分支判断和循环有些重叠,都是需要靠分支和跳转实现,而且分支预测失败也是对性能影响也不小。编译器既可能把一个循环拆成多个,把多层循环地里外层互换,提高访问的局部性;也可能把多个循环合并成一个减少循环本身的分支代价;或者展开循环减少循环次数同时提高指令级并行。简直是各种捏扁搓圆地变换。

函数内联方面函数本身指令数量小于等于函数调用需要额外增加指令数量的,以及只调用一次的函数都是内联了只有好处没有坏处的可以无脑内联,但是其他的情况就要考虑各种权衡,同一个函数在不同的调用处是否内联甚至是分开考虑,进行不同处理的。

看到这里大家还觉得我们有能力且应该替编译器做决定吗?还要为之付出可读性的代价吗?