[原文]编写易于理解代码的六种方式
如何让您免受读不懂代码的折磨
对于一名开发人员,时间是最宝贵的资源。本文所要介绍的这六种编写可维护代码的方法可以保证让您节省时间和少受挫折:在编写注释上多花一分钟,会让您少受一小时研读代码的痛苦折磨。
我学习编写、改善和维护代码的过程是很艰苦的。在过去的 12 年里,我一直在编写计算机游戏并通过曾红极一时的共享软件技术进行网络销售,并以此为生。这就是说,我常常要从空白的屏幕开始从头编码,当代码达到数万行之后才能拿去销售。
这也就是说,如果我出了错,我必须要自己去解决问题。当我在凌晨三点还在竭力寻找 bug 的时候,看着这些不知所云的晦涩代码,我不禁自问:“我的天啊,这些垃圾代码究竟是哪个笨家伙写的啊?”,很不幸,问题的答案是 “我”。
在学习了良好、正规的编码技巧之后,我大受其益。本文就包括了其中的一些实践。具有丰富经验的资深程序员大都对这些内容烂熟于心。他们可以通过本文优美的散文式的介绍再重温一遍,并可回顾一下在采取清晰编码的理念之前,编码是多么地令人头痛。
但更多的人会如同我一样,是无意间跌跌绊绊地闯入编程领域的,而且没有人为其灌输这些编程技巧和理念。本文所要介绍的这些内容对很多人来说也许很基础,但对于其他人来说却是极为宝贵的资源,因为之前没有人告诉过他。所以,如果您不想走弯路,那么本文将非常适合您。
示例
为了便于解释,本文全篇都将使用一个示例太空游戏程序,称为 Kill Bad Aliens。在这个游戏中,您将通过键盘来控制一个宇宙飞船,它可以在屏幕底端水平向前或向后移动,还可以向上发射子弹。
图 1. 我们的假想游戏
游戏发生在称为 Wave 的各个时间段。在每个 wave,外星人都会一个接一个地出现在屏幕顶端。它们到处飞,还会投掷炸弹。外星人将按固定时间间隔出现。在杀死一定数量的外星人之后,一个 Wave 就告结束。
杀死一个外星人会给您加分。当结束一个 wave 时,根据您完成游戏所需时间的长短还会有额外的分数奖励。
如果被炸弹击中,您的当前飞船就会炸毁,另一个飞船继而出现。如果被炸毁超过三次以上,游戏就结束了。如果您的得分很高,就会被晋级为 “人”,如果分数很低,就不能。
现在,我们可以坐下来开始用 C++ 编写这个 Kill Bad Alients 游戏了。首先定义几个对象来分别代表飞船、玩家的子弹、敌人和敌人的子弹。然后再编写代码来绘制这些对象。还需要编写代码来让这些对象可以随着时间的推移而到处移动。另外,也需要编写游戏逻辑、外星人 AI 以及能感知用户击键用意的代码等等。
那么,我们该如何实现这些以便当游戏编制完毕后,代码易懂、易维护,最起码地,不会一团糟呢?
提示 1:经常注释
请经常为代码添加注释。假设您编写了一个过程,但没有为它做注释,几个月后,您再回过头来想对它进行一些修整(您绝对会这么做),将需要花费很多时间去研读这些代码,原因就是因为您之前没有做注释。而时间是您最为宝贵的资源。丢失的时间是永远也找不回来的。
但注释和其他事情一样也是需要技巧的。只要多练习,在这方面的技能就会不断提高。注释有好有坏。
最好不要将注释写得过长。假设为一个函数做了注释,而这个注释在将来可以节省您理解代码所需的时间,比如说 10 分钟。这很好。现在假设所编写的注释过长,您花了 5 分钟编写这个注释,之后还要再花 5 分钟读懂这个注释。这样一来,实际上没有节省任何时间。这不是一种很好的做法。
当然,也不要将注释写得过短。如果在一两页之长的代码中找不到任何注释,那么这段代码最好清晰得 “晶莹剔透”,否则将来研读所需的时间将会很长。
再有,注释的方式不能太死板。当刚刚开始编写注释时,人们往往会头脑一热,写下这样的注释:
// Now we increase Number_Aliens_on_screen by one. Number_Aliens_on_screen = Number_Aliens_on_screen + 1;
这么明显的东西显然不需要注释。如果代码非常混乱以致于需要逐行注释,那么更有利的方式是首先简化代码。在这种情况下,注释并不能节省时间,反倒会消耗时间。因为注释需要时间去研读,而且它们分布于屏幕上的实际代码中的不同位置,所以在显示器上一次只能看少许的注释。
此外,千万不要这么写注释:
Short get_current_score() { [insert a whole bunch of code here.] return [some value]; // Now we're done. }
“We're done” 这样的注释有何用处呢?真是感谢您让我知晓。注释下面的这个大括号以及其后跟随的大片空白难道还不足以让我明白这是一段代码的结束么?同样,在返回语句之前也不需要使用类似 “Now we return a value” 这样的注释。
那么,如果您正在编写代码,而又没有上司或公司的规定可以做指导,这时,又该如何注释呢?我的做法是:对于由我自己维护的代码,我会写一个简介。这样一来,当我返回一个我很久以前编写的过程时,我就可以查看对它的解释。一旦我了解了其工作原理之后,我就可以很容易地理解实际的编码了。这通常会涉及:
- 过程/函数之前写几句话,说明其功能。
- 对传递给它的数值的一个描述。
- 如果是函数,对其返回结果的一个描述。
- 在过程/函数内部,能将代码分解为更短小的任务的注释。
- 对于看起来有些难懂的大块代码,对其成因给与简短的解释。
总之,我们需要在开始时给出一个描述,然后再在整个代码内部的几个位置加以注释。这种做法需时不多,但却可在将来节省大量的时间。
如下所示是另一个取自假想的 Kill Bad Alients 游戏的例子。考虑代表玩家子弹的那个对象。需要频繁地调用函数来将其向上移动以便检查该子弹是否会击中任何目标。我可能会按如下所示编写实现这个功能的代码:
// This procedure moves the bullet upwards. It's called //NUM_BULLET_MOVES_PER_SECOND times per second. It returns TRUE if the //bullet is to be erased (because it hit a target or the top of the screen) and FALSE //otherwise. Boolean player_bullet::move_it() { Boolean is_destroyed = FALSE; // Calculate the bullet's new position. [Small chunk of code.] // See if an enemy is in the new position. If so, call enemy destruction call and // set is_destroyed to TRUE [small chunk of code] // See if bullet hits top of screen. If so, set is_destroyed to TRUE [Small chunk of code.] // Change bullet's position. [Small chunk of code.] Return is_destroyed; }
如果代码足够清晰,如上所示的注释应该就已经足够。对像我这样需要不时地返回这个函数来修复错误的人来说,这将能够节省大量时间。
提示 2:大量使用 #define。没错,是要大量使用。
假设,在我们这个假想的游戏中,希望玩家在射中一个外星人时即可获得 10 分。有两种方法可以实现这个目的。如下所示的是其中一个比较糟糕的做法:
// We shot an alien. Give_player_some_points(10); This is the good way: In some global file, do this: #define POINT_VALUE_FOR_ALIEN 10
之后,当我们需要给出一些分数时,我们很自然地会这么写:
// We shot an alien. Give_player_some_points(POINT_VALUE_FOR_ALIEN);
在某种程度上,大多数程序员都知道该这么做,但是需要遵守一定之规,才能将其做好。比如,每次在定义常数时,都需要考虑在某个中心位置对其进行定义。假设,要将玩游戏的区域设置成 800 * 600 像素,请务必这么做:
#define PIXEL_WIDTH_OF_PLAY_AREA 800 #define PIXEL_HEIGHT_OF_PLAY_AREA 600
如果,在某个日期,又想更改游戏窗口的大小了(您很可能需要这么做),若在此处就能更改数值将会节省您双倍的时间。这是因为:第一,无需在全部代码中查找所有提到游戏窗口是 800 像素宽的地方(800!我当时是怎么想的?)第二,无需总要修复那些由于漏掉了引用而引起的无法避免的 bug。
当我制作 Kill Bad Aliens 游戏时,我要决定需要杀掉多少外星人一个 wave 才算结束、屏幕上一次能有多少外星人、这些外星人又以多快的速度出现。例如,如果我想让每个 wave 中的外星人的人数相同,并且他们都以相同的速度出现,我可能会编写如下所示的代码:
#define NUM_Aliens_TO_KILL_TO_END_WAVE 20 #define MAX_Aliens_ON_SCREEN_AT_ONCE 5 #define SECONDS_BETWEEN_NEW_Aliens_APPEARING 3
这段代码很清晰。此后,若我觉得这个 wave 太短或外星人相继出现的时间间隔过短,我就可以立即调整相应的值并立即让游戏重新生效。
如此设置游戏值的一个妙处是能快速地做出更改,这种立竿见影的施控感觉实在是很好。比如,如果将上述代码改写成如下所示:
#define NUM_Aliens_TO_KILL_TO_END_WAVE 20 #define MAX_Aliens_ON_SCREEN_AT_ONCE 100 #define SECONDS_BETWEEN_NEW_Aliens_APPEARING 1
那么,您就无法享受上述的快感和兴奋了。
图 2. 处理常量之前的 Kill Bad Aliens
图 3. 处理常量之后的 Kill Bad Aliens(这样的游戏可能不够好,但却十分有趣,可供参考)
顺便说一下,您可能已经注意到,我没有为上述值做任何注释,这是因为从变量名可以很明显地看出这些值的意义。这正是接下来我要讨论的内容。
提示 3:不要使用弄巧成拙的变量名。
总的目标很简单:编写代码以便让那些不知道其用意的人能读懂,让知道其用意的人能尽快地理解。
实现这一目标最好的策略是为变量、过程等赋以含义鲜明的名字。当他人看到这个变量名时,就会立刻清楚其意义,您也不必搜索整个程序来寻找 incremeter_side_blerfm
的用意何在,这大约会节省五分钟左右的时间。
这里需要进行一些均衡。所给出的命名应该尽量长且足够清晰以便您能理解其含义,但也不能过长或太过怪异,如果这样,代码的可读性就会受到影响。
例如,在实际中,我可能不会像上一节所示的那样给常量命名。我之前之所以这么做是为了让读者在没有任何上下文的情况下也能充分理解这些常量的含义。在程序本身的上下文中,与如下所示的相比:
#define MAX_Aliens_ON_SCREEN_AT_ONCE 5
我会毫不犹豫地这样编码:
#define MAX_NUM_Aliens 5
这个简短的名字所引起的疑惑很快就会迎刃而解,而简短的命名还会增加代码的可读性。
现在来看看在本文中我经常要调用的那个用来将外星人在屏幕上到处移动的代码片段,我会毫不犹豫地这样编码:
// move all the Aliens for (short i = 0; I < MAX_NUM_Aliens; i++) if (Aliens[i].exists()) // this alien currently exist? Aliens[i].move_it();
请注意,包含所有外星人的这个数组的名称很简单,叫做 Aliens
。这很棒。它恰好就是我想要的那种描述性名称,这个名称又很简短,即使键入千遍之多,我也不会感到烦闷。此数组将会经常用到。如果将其命名为类似 all_Aliens_currently_on_screen
这样的名称,那么所编写的最终代码将会长出很多,而且代码还会因此变得不怎么清晰。
同样,我还将循环变量直接命名为 i
,无任何额外的说明。若是初次接触描述性变量名这个概念,您很可能会忍不住将此循环变量命名为 "counter" 之类的名字。实际上,没有必要这么做。命名变量的意义在于让读者能够立即理解该变量的用意。人人都知道 "i"、"j" 这类名称常常用于循环变量,所以将循环变量如此命名是完全可以的,无需多加解释和说明。
当然,有关变量命名还是需要多加注意。比如,有一种称为 Hungarian Notation 的东西。其种类很多,但基本的理念是在变量名的开始添加一个标记以表示其类型(例如,所有无符号长型变量都以 ul
开头)。这比我希望的要多少麻烦一些,但这个概念必须要了解。为了弄清楚事情可能需要花费太多时间,但还是值得的。
提示 4:进行错误检查。
一个正常大小的程序往往都会有大量的函数和过程。而且更为麻烦的是,其中的每一个都需要进行稍许错误检查。
当创建过程/函数时,应该总要考虑这样的一个问题:“假如一些怀有恶意的人故意向函数或过程传递进各种怪异的值,这段刚刚创建的代码如何能自保并且让计算机也能免受破坏呢?”然后,编写代码来检查这些恶意数据以保护自身免受这些数据的破坏。
举个例子。我们的这个太空游戏的主要目标是杀掉外星人并积分,所以我们需要一个过程来更改分数。而且,当加分时,我们需要调用一个例程来实现分数上星光闪烁的效果。如下所示的是第一个过程:
Void change_score(short num_points) { score += num_points; make_sparkles_on_score(); }
到目前为止还不错。现在请思考一下:这里可能出现的错误是什么呢?
首先,一个很明显的问题是:如果 num_points
是负值该如何呢?我们能让玩家的分数降低么?就算我们能降低分数,但在我之前给出的关于该游戏的描述中,没有提到过失分。而且,游戏应该有趣,但失分无论如何不能算是一个有趣的事情。所以,我们将分数负值视为一个错误并必须要捕获。
上述错误相对容易,但这里有一个很微妙的问题(也是我在游戏中经常要处理的)。如果 num_points
为零又会怎么样呢?
这是一个很似是而非的情景。还记得么,我们会在每个 wave 结束时根据玩家完成速度的快慢给一个奖励分数。如果玩家速度极慢,我们是否应该给他一个值为零的奖励分数呢?在凌晨三点,调用 change_score
并传递值 0,这完全可行。
现在的问题是我们可能不想让计分板在显示的数值没有变化时仍旧五颜六色地闪个不停。所以我们要先捕获这个问题。让我们尝试如下代码:
Void change_score(short num_points) { if (num_points < 0) { // maybe some error message return; } score += num_points; if (num_points > 0) make_sparkles_on_score(); }
好了,情况好多了。
请注意这是很简单的一个函数。里面并没有用到任何极受新手推崇的新奇指针。如果要传递数组或指针,那么最好小心错误和坏数据的出现。
这样做的好处并不仅仅限于让程序免遭破坏。好的错误检查还能让调试更为迅速。假设,您知道写入的数据超出了数组的范围,为了发现可能出现的错误,您需要详细检查代码。若所查看的这个过程中的错误检查均已就绪,那么就无需花很多时间去专门通查它来寻找错误。
这种做法将节省大量时间,而且还能重复。还是那句话,时间是我们所拥有的宝贵资源。
提示 5:“不成熟的优化是麻烦的根源” —— Donald Knuth
上述格言非我个人所造,它可以在 Wikipedia 中找到,所以必定是十分睿智的。
除非是想找别人麻烦,否则编写代码的首要目标就是简明性。简单的代码更易于编写、易于日后理解,也更易于调试。
优化与简明性是相悖的。但有时,却必须要进行优化,在游戏中尤其如此。这个问题至关重要,您可能直到用解析器实际对工作代码进行测试时才会意识到需要进行优化。(解析器 是一种程序,用来监视其他程序并找出该程序使用不同的调用所花费的时间。这些都是很棒的程序。您可以找一个来试试。)
每次当我优化游戏时,常常都禁不住会大出所料。我十分担心的那些代码总是问题不大,相反,我觉得万无一失的代码反倒会运行得十分缓慢。由于我对运行速度的快慢并没有什么概念,在获得实际数据之前我所进行的优化根本就是浪费时间。比浪费时间更糟糕的是它还让代码变得有些混乱。
这个规则看来很难遵守。但,如果规则很容易,它也就称不上规则了。好的程序员大都更痛恨将原本可以运行迅速的代码弄得臃肿笨拙。
但好消息是,在我不断 “该这样不该那样的” 布道式的介绍中, 这是惟一的一个您可以稍微懈怠一些的地方!
请让自己编写的代码尽量整洁和有效一些吧。在后面的优化阶段,可能需要将其变得面目全非。所以如非必要,请慎重。
说到伤害,接下来,就来看看最后的这条建议。
提示 6:不要一知半解、自作聪明。
您可能听说过 IOCCC 吧,即 International Obfuscated C Code Contest。大家都知道,C 和 C++,不管其优势如何卓越,都会最终导致编写的代码噩梦般地复杂。这个比赛就是要通过评选出最离谱的代码来展示简明代码的价值,真是别具匠心。
让我们来看看在您自认为具有了编程的全部知识并甘愿冒险的情况下,您能制造什么样的麻烦。足够的知识让您信心百倍地将十行代码压缩进一行代码内。付出的代价就是您绝对无法快速修复其中可能存在的 bug。
这里所需吸取的教训就是如果您所编写的代码要求您必须具有有关复杂优先规则的详细知识或让您不得不翻看某些书的后面章节才能弄清来龙去脉,那么您在编写这段代码时就犯了一知半解、自作聪明的毛病了。
每个人对代码的复杂性都有自己的容忍程度。就我个人而言,我编写的程序往往呈比较典型的保守风格。我个人认为,如果一段 C 代码需要您必须知道 i++ 和 ++i 之间的差别,那么这段代码就过于复杂了。
您尽可以把我想象成一个循规蹈矩的人。没错,我的确如此。但循规蹈矩却可以让我花很少的时间就可以读懂我的代码。
结束语
至此,您可能会想:“哇哦,真是浪费时间。您介绍的所有这些东西都是显而易见,尽人皆知的。为何还多此一举,写这样的文章呢?” 实际上,我很希望您会这么想,因为这意味着您已经进步了,变得明智了。这很好。
但不要错认为所有这些内容对每个人都是不言自明的。事实并非如此。糟糕的代码随处可见,但实际上这些代码本不应如此。
如果您正在努力编写大量代码并想让自己不受其所累。那么就请让代码尽量简单明了一些,这样,您就可以节省大量时间和免受很多挫折。