代码质量随想录(二):必也正名乎
不必被我的标题吓到哈,孔老夫子时代没有电脑。如果有,估计诸子百家们还得针对软件工程抒发一系列代码质量伦理学的教条。
上回文章说到,代码品质改进应该在三个层面上展开,其中最微观的就是代码段的质量考究了。很多时候我在针对一些项目做工程分析和大规模重构之前,首先希望对大概的工作原理有些了解,这个时候就要深入核心模块的文件之中,挑选代码来阅读,以求理顺思路了。根据个人的经验来说,微观的改进往往能够激发大规模的结构重组。所以一连几篇文章,分别会谈到“好名称”、“好格式”、“好注释”三个微观的表层质量改进问题。
深入到函数或方法内部的代码之后,就要面对一行行具体的代码了。此时最应该关注的首先就是标识符的命名问题。这个问题基本上是讲重构或代码质量的书所必谈的话题之一。记得马叔叔曾经在《Clean Code》中说,给标识符起名时,应该像给你们家小朋友起名字一样认真(大意,并非原文)。当时我看到此话不禁微笑了一下。是哇,很多时候我在代码评审中遇到的思维不顺都是源于名字问题。
一直以来,朋友和同事都偶尔会拿整个项目或是代码片段来和我讨论,对于企业级开发领域,我看的代码不多,对代码质量不便妄言,不过具体到和我关系比较密切的移动开发领域,可就真的是令我非常头疼了。由于移动软件或游戏的开发经常周期很短,而且重结果,轻过程,更不讲求后续的版本更新、维护与复用。所以经常在开发过程中程序员容易在工期的压力下过于随心所欲,导致项目的代码理解起来大费周折。有时候我越是急于理解,就越是摸不着头绪。后来想想,很多困难都源于具体的标识符名称。必须理解了它们,才有可能理解更高层级的内容。
通过阅读《The Art of Readable Code》以及其他相关的书,我渐渐把原来学到的一些代码质量知识总结起来了。ARC这本书的好处之一就是,它讲的东西不见得多新,很多都是Clean Code或者类似的书中讲了又讲的话题,不过,它善于把这些零散的知识点按照一定的框架整合起来,让我能够更系统地归纳并巩固这些知识。
简单的说,好的标识符名称,必须封装恰当的信息,同时不致误解。
至于如何封装恰当的信息,这个问题要看个人的把握,有几条能够作为指导的建议,不妨梳理给大家来看。
1. 选择更具表达力词语
我自己在代码中就经常忽视这一点,用惯了get和size之后,遇到什么情况,不管具体细节,一律使用getXXX或size作为方法名称。今天就看到了几个反例。例如
class BinaryTree{ public int size(){...}}
这个size到底获取的是高度,节点数还是占据的内存字节数?这三种情况应该分别用更为特定的height、nodeCount或occupiedMemoryBytes来表示,而不是空泛的size。
说到这个问题,我觉得增加个人的词汇量是非常有好处的。可以经常翻看英英词典来了解各个词语之间的细微差别。例如用“deliver, dispatch, announce, distribute, route”(投递、派发、播报、分配、按指定线路发送,就是路由)之中的某个词代替send(送),用“search, extract, locate, recover”(搜索、提取、定位、重新找回)代替find(找)等等。
有一个问题,就是命名含义丰富了会不会影响以后的修改。有同学可能会说,我故意放一个朦胧且暧昧的size来代替height、nodeCount或occupiedMemoryBytes,这样将来万一内部的逻辑有变化,我直接修改具体代码就行了,连size这个方法名都不用修改,岂不是更符合“针对接口而非实现来编程”的面向对象设计理论么?一开始我也有这个想法,后来想想后果十分可怕,这样做根本就没有明确表述出该接口的具体意图:一旦将表示height的size方法之中的算法改为返回nodeCount,而保留size方法名不做修改,那么这会害苦了该API的客户代码编写者们。
你的同事仍然以为size返回的是二叉树的高度,殊不知现在它返回的是节点数目了。一旦出现这样的bug,除非两人紧密配合,否则调试很费时,而且随着时间的推移更为难办。反之如果方法名从height改为nodeCount,那么下游开发者在源码管理系统中更新代码时立刻就看出其中的差别,从而能够很从容地修改已有的逻辑,避免了频繁调试。总之,我同意ARC作者的看法:应该选择更具表现力、含义更为丰富的词语。
当然,特定不等于标新立异或者耸人听闻。友人goldlion曾经在学习NDK开发时被Android的诗意文档所苦。当时我看到“punch a hole”这个表述(参见这里,类的概览部分,第二段首句),就笑得三分钟没停下来,是有点可爱。文档可爱一点还好,如果具体的函数就麻烦了,比如ARC作者所提到的PHP的explode()函数。初看莫名其妙,定神想了想才明白可能是用于打散字符串用的。如果温柔一点儿,应该叫做split或者delimit。而且更有趣的则是新支持的第三个参数。
array explode ( string $delimiter , string $string [, int $limit ] )
这个参数如果取负值,则最后的-limit组小字符串会被丢弃,例如
explode('|', 'one|two|three|four', -1)
只会返回“one、two、three”三个子串所合成的数组。这种一鱼两吃的豪爽颇有古典程序员的遗风。不过我还是建议在工作代码中将这种特定的处理命名为splitButLast(char delimiter, String str, int thrownCount)更清爽,这样一来写的人和看的人都不累。
2. 避免空泛的名称
tmp(temp)和retVal(returnValue、result)是十大空泛名称排行榜上的前两名(其余请读者补充)
public double euclideanNorm(int values){ double result = 0.0; for(int i = 0, count < values.length; i < count;i++) result += values[i]*values[i]; } return Math.sqrt(result); }
这种命名不当我也常犯,第一句不假思索就用result了。上述代码的result应该被squareSum代替,这样一旦将for循环中的代码误写为squareSum+=values[i](忘记求平方了,直接加),立刻就能看出错误来。因为sum前面的square已经明示了+=运算符后面必须是平方形式。
temp这种名字也不是不能用。如果某个变量唯一存在目的就是交换数据的暂存空间,那么也很贴切。
if (right < left) { temp = right; right = left; left = temp; }
反之如果是
String temp = user.name(); temp += " " + user.phoneNumber(); temp += " " + user.email(); ... template.set("user_info", temp);
那么以上代码的temp就明显是userInfo的偷懒写法了,必须纠正。
有时可以使用temp修饰另一个中心词,将此偏正短语作为标识符,倒也恰当,比如:
tempFile = namedTemporaryFile();
...
saveData(tempFile, ...);
temp修饰了File,如果仅用saveData(temp, …),人们要去猜temp到底是临时文件本身,还是临时文件名,又或是被写入的临时数据?
在循环语句所使用的迭代变量中,尤其要注意命名问题。空泛的i、j、k有时合适,有时则不行。尤其是会导致下标错乱的情况下,更要注意循环变量的起名。例如:
for (int i = 0; i < clubs.size(); i++) for (int j = 0; j < clubs[i].members.size(); j++) for (int k = 0; k < users.size(); k++) if (clubs[i].members[k] == users[j]) System.out.println("user[" + j + "] is in club[" + i + "]");
很难注意到其中的bug,如果写成
if (clubs[ci].members[ui] == users[mi])
一下子就看到问题所在了。members数组的下标居然是ui(user index),users的下标居然是mi(member index),很明显,这两个写反了。
3. 名称对内容的描述要具体而准确
比如经常会定义如下的宏来防止生成默认的拷贝构造器与复制操作符。
#define DISALLOW_EVIL_CONSTRUCTORS(ClassName) \ ClassName(const ClassName&); \ void operator=(const ClassName&);
这个evil constructors就太过感情化,不具体(怎么evil了?),而且不甚准确(operator=并不是一个构建子)。所以莫如更为精确的好:
#define DISALLOW_COPY_AND_ASSIGN(ClassName) ...
上文一望即知:禁止提供拷贝构造器和赋值操作符。
正交性也是考量准确度的一个标准。比如在设计参数选项时,经常会犯这样的错误:有时候我们开发的某个手机程序需要打印调试信息到手机屏幕,同时需要屏蔽内嵌的程序广告,有些小朋友以为,开发的时候总是用模拟器来运行程序,所以就把这两个功能强行塞入一个对应的选项中,并命名为on_emulator。这样的话有时候需要在真机上运行程序,而且要看调试信息,那么不得不把on_emulator选项设定为true。这看起来很容易造成误解,而且一旦这样设计,如果在真机上即要打印调试信息,同时还要显示内嵌广告,那么on_emulator便怎么设置都不对了。所以常犯的错误就是:根据表面现象,将两个毫不相关或可以各自独立存在的功能强行塞入一个选项中,既造成了误解,又丧失了使用的灵活度。上述这种情况莫如分别设计成print_debug_on_screen和show_ads比较好。
4. 将重要信息纳入名称中
如果某个附加信息,代码使用者非得知道它,才能正确地使用代码的话,那它就得被纳入标识符的名称当中了。比如:
String id; // 使用范例: "af84ef845cd8"
如果id一定要用十六进制字符串,否则后续程序无法正常执行的话,那么这个信息必须让大家知道。所以最好将代码改成:
String hexID;
这样的话,大家看到了hex前缀,都会明白代码作者的本意:非使用十六进制字符串不可。
除了进制信息,计量的单位也应该被纳入命名之中。
例如:
long start = (new Date()).getTime(); ... long elapsed = (new Date()).getTime() - start; System.out.println("Load time was: " + elapsed + " seconds");
上面这段代码很容易出错,因为elapsed并没有指明计时单位,是微秒?毫秒?秒?还是分钟?小时?如果加上了计量单位:
long startMs = (new Date()).getTime(); ... long elapsedMs = (new Date()).getTime() - start; System.out.println("Load time was: " + elapsedMs/1000 + " seconds");
这样的代码一目了然。而且有了错误也非常好查找。万一把“elapsedMs/1000”错写成“elapsedMs”,那么一眼就能看到:明明后面是“seconds”,前面却是“Ms”,单位明显不统一,当即知道漏掉了“/1000”。
根据以上这个例子,我们建议将左边的参数改为右边的式样:
public void start(int delay ){...}; //delay改为delaySecs public void createCache(int size){...}; //size改为sizeMB public void throttleDownload(float limit){...}; //limit 改为maxKBPS public void rotate(float angle){...}; //angle改为degreesClockwise
上面之中的第4条最为严重。angle既没说是角度还是弧度,又没说是顺时针还是逆时针,如果不配合详细的Javadoc说明文档,很难一眼读透该方法所要表达的意思。
除了计量单位之外,其余代码读者或代码使用者必须注意的信息也要纳入命名之中。这样以后该部分若有变动,可以在重构时及时更动变量名及使用它的其他语句,以维护代码语义的一致性。例如:明文密码应该叫plaintextPassword,以提醒使用者加密后方可使用,不宜直接叫做password。
以后如果决定将初始的代码由明文变为已经加密好的,那么只需要使用开发环境的重构功能将plaintextPassword变为encryptedPassword即可,然后藉助开发工具找出所有使用encryptedPassword的地方,一一对照,如有逻辑不符,即行修改——这样就维护了代码逻辑的一致性,不会因为是否加密而导致bug或程序行为改变。同理,用户提供的注释里面可能包含需要进行转义处理的字符,此时应叫unescapedComment而非comment;已经转换为UTF-8格式的html字节序应叫htmlUTF8而非html;经由URL编码形式传入的数据应叫dataURLEnc而非data。
很久以前,我也是一名Win32的API研究爱好者,当然忘不了匈牙利命名法了,那么“将重要信息纳入名称中“与”匈牙利命名法“有何区别呢?它们的区别是,后者是一套正规的强制规范,纳入名称中的一般是指针(p)、映射表(m)、零终结字符串(sz)、计数(c)等特定属性,而前者则无此强制属性规定,凡对用户重要的属性均可纳入。可以仿称其为“要素命名法”(”Essential Factor Notation”)。(ARC的作者用“English Notation”来命名它,小翔觉之不确)
5. 标识符的长短应符合其作用域的大小
if (debug) { Map<String, int> m=...; ... print(m); }
变量m的作用域很小,所以短命称不会带来问题。但是如果是在一个很大的作用域中,比如有上千行代码的类中:
public class PhoneBook{ private Map<String, int> m=...; ... //几千行代码之后 public void someFun(){ ... print(m); // m是啥咪东东呀? ... } ... //还有数千行代码 }
那么m这样的短名显然不太合适。现在的编辑环境一般都有自动补完功能,按下某个组合键就好了,比如常见的几种编辑器:
编辑器/开发环境 | 自动补完快捷键 |
---|---|
Vi | Ctrl-p |
Emacs | Meta-/ |
Eclipse | Alt-/ |
IntelliJ IDEA | Alt-/ |
TextMate | ESC |
我常用的是eclipse,其余的欢迎大家补充。
当然啦,将不必要的词汇省略是好的。例如convertToString()简称toString(),doServerLoop()简称serverLoop()。翔以为主要是将不言自明的动词(比如convert,do等)省去。
6. 使用格式来传达信息
使用特殊的符号来表示特殊的对象,同其他普通对象区隔开来。例如在JavaScript中,用$为前缀来表示经由jQuery的$(“…”)选择子而选中的一系列具有某名称的DOM节点。(小翔对JS不是很熟悉,因为日常工作是单机的手机应用/游戏开发。目前正在学习中,这部分代码有错误还望朋友们赐教)
var $all_images = $("img"); // $all_images是jQuery对象 var height = 250; //而height则是普通变量
每种特殊标识符都用一套特殊命名法来区隔。例如HTML/CSS中,id与class都是特殊属性,所以分别采用下划线与连字符来命名这两种标识符。(再次捂脸:HTML/CSS苦手飘过,仍然是在努力学习这项技术之中)例如:
<div id="middle_column" class="main-content"> ...
嗯,写了这么多,休息一下吧。轻松地总结一下啦:
”以语句行为单位的微观代码管控如何入手呢?”“必也正名乎!”——将信息纳入名称,使读者通过名字就能领会到其中的含义。
特定技巧:
- 使用更具表达力词语:例如以在BinaryTree类的设计中以height或nodeCount代替size。
- 避免空泛名称:tmp、retval、i、j、k等,除非确有必要,否则不用。
- 使用具体而准确的名称:描述更多细节的CanListenPort()优于ServerCanStart()。
- 附加重要属性:将Ms缀于以毫秒计时的值名称之后,将Raw缀于未经处理的数据名称之前。
- 大作用域用长名:不要把一两个字符的名称用在一大段代码中,短的代码可以有短名。
- 特殊名称用特殊格式:类成员可以_结尾,以与局部变量相区隔。$符号、大写或下划线等特殊格式可以区隔特殊的名称。
嗯,这篇文章写了好几个小时,休息一下。正名大业分为上下两部分,这一篇主要是从正面给大家总结一些标识符命名的建议,下一篇则将从反面讲解何种名称会给人带来误解。