代码整洁之道
1. 有意义的命名
-
名副其实
变量、函数或类的名称应该告诉你,它为什么会存在,它做什么事,应该怎么用,如果需要注释来补充,那就不算是名副其实。 -
避免误导
- 某些系统的专有名词不要使用。
- 堤防使用不同之处较小的名称。比如XYZControllerForEfficientHandlingOfStrings和另一处XYZControllerForEfficientStorageOfStrings,就难以区分。
- 拼写前后不一致就是误导。
- 不要使用误导性的名称,比如使用小写字母l和大写字母O。
-
做有意义的区分
光是添加数字序列(a1,a2,……aN)或是废话(比如ProductInfo和ProductData,意思无啥区别,还不如叫Product)远远不够,应该依义命名。
错误的示例如下:程序员很难确定该调用哪个函数。
getActiveAccount();
getActiveAccounts();
getActiveAccountInfo();
-
使用读的出来的名称
如果名称读不出来,讨论的时候就会像个傻鸟。 -
使用可搜索的名称
名称长短应与其作用域大小相对应。e就是个不便于搜索的名称。 -
避免使用编码
带编码的名称通常也不便发音,容易打错。- 匈牙利命名法:在C语言API的时代,编译器不会做类型检查,程序员需要使用这种标记法来帮助自己记住类型。Java和C#都是强类型(静态类型)的语言,在编译开始前就会进行类型检测,已经不需要这种标记法了。
- 成员前缀:不必使用“m_”来标明成员变量。应当将类和函数做的足够小,消除对成员前缀的需要。而且现在的编辑器可以通过颜色来区分变量类型。
-
避免思维映射
在作用域小时,循环计数器可能被命名为i,然后在多少情况下,单字母名称不是一个好的选择。 -
类名
类名和对象名应该是名词或名词短语。 -
方法名
方法名应当是动词或动词短语。属性访问器(get-)、修改器(set-)和断言(is-)应该根据其值命名。 -
别使用俚语
-
每个概念对应一个词
给每个抽象概念选一个词,并且一以贯之。例如:fetch、retrieve和get来给多个类中的同种方法命名。
函数名称应当独一无二,而且保持一致,这样才能不借助多余的浏览就能找到正确的方法。 -
别用双关语
比如add方法,如果这些add方法的参数列表和返回值在语义上等价,就没有问题。或者需要考虑是否用insert或append之类的词来命名才对。 -
使用解决方案领域名称
尽管用计算机术语、算法名、模式名、数学术语。不该让协作者老是跑去问客户每个名称的含义,他们早该通过另一名称了解这个概念了。 -
使用源自所涉及问题领域的名称
优秀的程序员和设计师,工作之一就是分离解决方案领域和问题领域的概念。与所涉领域更为贴近的代码,应当采用源自领域的名称。 -
添加有意义的语境
很少有名称能够自我说明的——多少都不能。需要使用良好命名的类、函数或命名空间来放在名称,提供语境。如果没这么做,最后一招——添加前缀。 -
不要添加没用的语境
只要短名称足够清楚,就比长名称要好。将项目缩写作为所有方法的前缀是非常糟糕的做法。
2. 函数
-
短小
函数该有多长?每个函数都一目了然,都只说一件事,而且都依序把你带到下一个函数。- 代码块:if语句、else语句、while语句等,其中的代码块应该只有一行(调用一个函数,这个函数拥有具有说明性的名称,从而增加文档上的价值)。
- 缩进:函数不该大到足以容纳嵌套结构。所以函数的缩进层级不该多于一层或两层。
-
只做一件事
该条建议以各种形式存在。单一职责原则等等。
如果函数只做了该函数名下同一抽象层上的步骤,则函数只做了一件事。- 函数中的区段:如果函数被分成declaration、Initialization、和sieve等区段,则说明函数做了太多的事了。只做一件事的函数无法被合理地切分为多个区段。
-
每个函数一个抽象层级
函数中混杂不同的抽象层级,往往令人迷惑。如同破窗一样,一旦细节与基础概念混杂,更多的细节就会在函数中纠结起来。- 向下规则:自顶向下读代码,每个函数下面跟着下移抽象层级的函数。
-
switch语句
写出短小的switch语句(包括if-else)很难。Switch天生做N件事。我们可以吧Switch都埋藏在较低的抽象层级,而且永远不重复。利用多态来实现这一点。
Switch语句的问题:- 太长;
- 明显做了不只一件事;
- 违反了单一职责原则;
- 违反了开闭原则;
- 最麻烦的是类似结构可能到处都是。
问题的解决方案是将Switch语句埋到抽象工厂底下。
-
使用描述性的名称
长而具有描述性的名称,要比短而令人费解的名称好,也要比描述性的长注释好。
高级的编辑器将重命名的成本降到了最低,追索好的名称,往往能助你理清关于模块的设计思路,导致代码的改善性重构。 -
函数参数
最理想的参数数量是零,其次是一,再次是二,应尽量避免三。- 参数带有太多的概念性。参数可能与函数名各处在不同的抽象层级。这要求你了解并不特别重要的细节。
- 从测试上讲,编写确保各种组合的参数运行正常的测试用例实在是强人所难。
建议如下: - 一元函数:只有两种形式的一元函数——询问某个参数的状态,或者操作该参数,转换为其它类型,并返回它。避免编写不是这些类型的一元函数。
- 标识参数:大声宣布本函数不只做一件事,应该将这种函数一分为二。
- 二元参数:使用二元参数要小心(参数顺序),尽量利用一些机制(写成某个参数的成员之一,或将某个参数作为当前类的成员变量)将其转换为一元函数。
- 三元函数:参数的排序、琢磨、忽略问题都会加倍体现。编写三元函数要三思。
- 参数对象:如果函数需要两个、三个或三个以上的参数,说明其中一些参数应该封装为类了。如果一些参数总是被共同传递,它们往往该有自己的名称(类型)来表示某个概念了。
- 参数列表:数量可变的参数列表,可变参数可以看成是单个参数。
- 动词与关键字:对于一元函数,函数和参数应当形成一种动词/名称对形式。可以将参数的名称编码(关键字)成函数名来指明参数顺序。比如:
assertExpectedEqualsActual(expected, actual)
-
无副作用
函数承诺只做一件事,但很可能是一种谎言:有时它会对自己类的变量做出未能预期的改动。有时会把变量变成向函数传递的参数或是系统的全局变量。无论是哪种情况都会导致古怪的时序性耦合和顺序依赖(只能在特定的时刻调用)。- 输出参数:参数会被自然认为是函数的输入,在面向对象之前的时期,有时需要输出参数,然而,面向对象语言中已经无需输出参数。如果函数必须修改某种状态,就修改所属对象的状态吧。
-
分割指令和询问
函数要么做什么事,要么回答什么事,二者不可兼得。解决方案就是一分为二。 -
使用异常替代返回错误码
当返回错误码时,就是在要求调用者立刻处理错误。如果使用异常替代返回错误码,错误处理代码就能够从主路径中分离出来。- 抽离try/catch代码块:把错误处理提取成一个函数,使代码更易于理解和修改。
- 错误处理就是一件事:处理错误的函数不该做其它事,也就是说try应该是函数的第一个单词并且catch/finally代码块后也没有其它语句。
- 依赖磁铁:返回错误码通常暗示某处存在一个类或者枚举定义了所有的错误码,这样的类就是依赖磁铁——许多类都得导入并且使用它,以至于一旦修改就得重新编译和部署。
-
不要重复
重复可能是软件中一切邪恶的根源。数据库范式是为了消灭数据重复、面向对象编程将代码集中到基类来避免冗余、面向方面编程、面向组件编程都是消灭重复的一种策略。软件开发领域的所有创新都是在不断尝试从源代码中消灭重复。 -
尽量避免使用goto语句
在大函数中应当遵循结构化编程的规范——每个函数只有一个return语句,循环中没有break和continue语句,而且没有goto语句。小函数中偶尔出现return、break或continue并没有坏处。 -
持续重构
一开始就遵循上述规则不现实,通过配以单元测试、覆盖每行丑陋的代码,持续重构它们。
编程艺术是且一直就是语言设计的艺术。函数是语言的动词、类是名词。目标在于讲述系统的故事。如果你的函数干净利落地拼接在一起,形成一种精确而清晰的语言,帮助你讲故事。
3. 注释
“别给糟糕的代码加注释——重新写吧。”
注释不是“纯然的好”,最多也就是一种必须的恶。注释总是一种失败,意味着我们无法找到不用注释就能表达自我的方法。
为何要如此贬低注释呢?因为注释会撒谎!注释存在的时间越久,就离其所描述的代码越远,越来越变的全然错误,原因很简单。程序员不能坚持维护注释。不准确的注释要比没有注释坏得多。只有代码能忠实地告诉你它做的事。那是唯一真正准确的信息来源。所以,尽管有时也需要注释。我们应该花心思尽量减少注释量。
-
注释不能美化糟糕的代码
与其花时间编写解释代码的注释,不如花时间清洁那堆糟糕的代码。 -
用代码来阐述
有时代码不足以解释其行为,正确的做法是创建一个描述行为的函数即可,即使代码很少。//Check to see if the emplyee is eligible for full benefits
if((emplyee.flags & HOURLY_FLAG)&&(emplyee.age >65))
还是这个:
if(emplyee.isEligibleForFullBenefits())
-
好注释
有些注释是必须的并且是有利的。- 法律信息:版权及著作权声明。
-
提供信息的注释:比如参数的格式信息,正则表达式的格式信息。
//format matched kk:mm:ss EEE, MM dd, yyyy
Pattern timeMatcher =Pattern.compile("\d*:\d*:\d* \w*, \w* \d*, \d*");
-
对意图的解释:有时注释不仅提供了有关实现的有用信息,而且还提供了某个决定后面的意图。比如:
//This is our best attempt to get a race condition
//by creating large number of threads.
for(int i =0; i <25000; i++)
{
WidgetBuilderThread widgetBuilderrThread =newWidgetBuilderThread(widgetBuilder, text, parent, failFlag);
Thread thread =newThread(widgetBuilderThread);
thread.start();
}
-
阐释: 如果参数或返回值是某个标准库的一部分,或者你不能修改的代码,帮助阐释其含义就会有用。但是它本身存在不正确的风险。
-
警告: 用于警告其他程序员会出现某种后果的注释。
// Don't run unless you have some time to kill
publicvoid testWithRealBigFile()
{
writeLinesToFile(100000000000);
response.setBody(testFile);
response.readyToSend(this);
String responseString = output.toString();
assertSubString("Content-Length:100000000000", responseString);
assertTrue(bytesSent >100000000000);
}
-
TODO注释:程序员认为应该做,但是由于某种原因没做的工作。目前大多数IDE都提过定位TODO注释的手段(VS在视图->任务列表中打开查看,在工具->选项->文本编辑器->C/C++->格式设置->杂项->枚举注释任务-> True中开启)。
-
放大:用来放大不合理之物的重要性。比如:
String listItemContent = match.group(3).trim();
//trim非常重要,它移除了开始位置的空格,如果不移除可能将这一项识别为另一个list。
newListItemWidget(this, listItemContent,this.level +1);
return buildList(text.substring(match.end()));
-
公共API中的Javadoc:如果编写公共API,就该为它编写良好的Javadoc。
- 坏注释
大多数注释都属于此类。通常坏注释都是糟糕代码的支持或借口,或对错误决策的修正,基本上等于自说自话。- 喃喃自语:如果决定写注释,就花时间确保写出最好的注释。
- 多余的注释:不能提供比代码本身更多的信息。
- 误导性的注释:不够精确的注释。
- 循规式注释:每个函数都要有Javadoc或每个变量都有注释非常愚蠢可笑。
- 日志式注释:如今有源代码控制系统,这种冗长的记录应当全部删除。
- 废话注释:把整理代码的决心替代创造废话的冲动吧。
- 能用函数或变量时就别用注释:应该重构,然后删掉注释。
- 位置标记:尽量少用位置标记。如果滥用,会被忽略掉。
- 括号后面的注释:如果发现自己想要标记右括号,其实应该做的是缩短函数。
- 归属与署名:源代码控制系统应该是这类信息最好的归属地。
- 注释掉的代码:直接注释掉代码非常令人讨厌,其他人不敢删除。应该直接删掉它们,如果有源代码管理系统,它们不会丢的。
- HTML注释:在注释中插入html标记已经完全没必要了,目前有很多工具可以用。
- 非本地的信息:确保你的注释描述了离它最近的代码。
- 信息过多: 别在注释添加有趣的历史性话题或无关的细节描述。对读者完全没必要。
- 不明显的联系:注释及其描述的代码之间的联系应该显而易见。
- 函数头:短函数无需太多描述,起个好名字比什么都强。
- 非公共代码中的javadoc:Javadoc注释额外的形式要求几乎等于八股文章,如果代码不打算作为公共用途,这种注释就太讨厌了。
4. 格式
应该保持良好的代码格式——选用一套管理代码格式的简单规则,然后在团队中贯彻这些规则。代码格式关乎沟通,而沟通是专业开发者的头等大事。
- 垂直格式
用大多数为200行,最长500行的单个文件来构造出色的系统。因为短文件总是比长文件容易理解。- 概念间垂直方向上的间隔
思路用空白行区隔开来,每个空白行都是一条线索,标识出新的独立概念。 - 垂直方向上的靠近
紧密相关的代码应该互相靠近 - 垂直距离
应避免迫使读者在源文件和类中跳来跳去,因此,除非有很好的理由,否则不要把关系密切的概念放在不同的文件中。 - 变量声明:变量声明应尽可能靠近其使用位置。
- 局部变量应该在函数的顶部出现。
- 循环中的控制变量应该总在循环语句中声明。
- 在较长的函数中,变量也可能出现在某个代码块顶部。
- 实体变量:实体变量应该在类的顶部声明。
- 相关函数:若某个函数调用了另一个函数,就应该把它们放在一起,而且调用者应该尽可能放在被调用者上面。
- 概念相关:概念相关的代码应该放在一起,相关性越强,彼此间的距离就该越短。相关性可能来自于执行相似操作的一组函数,因为他们拥有共同的命名模式。
- 垂直顺序
一般自上向下展示函数的调用依赖顺序。
- 概念间垂直方向上的间隔
- 横向格式
应该尽量保持代码行短小。遵循无需拖动滚动条到右边的原则。大概上限是120个字符。- 水平方向上的区隔和靠近
-
在赋值操作符周围加空格,达到强调目的。
int lineSize = line.Length();
-
不在函数名和左括号之间加空格,因为函数与其参数密切相关。
- 乘法因子之间不加空格来表示它们具有较高优先级,加减法运算符之间用空格隔开。
privatedouble determinat(double a,double b,double c)
{
return b*b -4*a*c;
}
-
- 水平对齐
-
对齐风格:
publicclassTest
{
privateSocket socket;
privateFitNessContext context;
protectedlong requestParsingTimeLimit;
}
-
不对齐风格
publicclassTest
{
privateSocket socket;
privateFitNessContext context;
protectedlong requestParsingTimeLimit;
}
-
- 缩进
源文件是一种继承结构,要让这种范围式的继承结构可见,相当依赖缩进模式。
- 水平方向上的区隔和靠近
5. 对象和数据结构
对象把数据隐藏于抽象之后,暴露操作数据的函数,数据结构暴露其数据,没有提供有意义的函数。
- 数据、对象的反对称性
它们本质上是对立的:- 过程式代码(使用数据结构)便于在不改动既有数据结构的前提下添加新函数,面向对象代码便于在不改动既有函数的前提下添加新类。
- 过程式代码难以添加新数据结构,因为必须修改所有的函数。面向对象代码难以添加新函数,因为必须修改所有类。
- 得墨忒耳定律(最少知识原则)
类C的方法f只应该调用以下对象的方法:- C类的方法;
- f创建的对象的方法;
- 作为参数传递给f的对象的方法;
- 由C的实体变量持有的对象。
即方法不应该调用任何函数返回对象的方法——只和朋友谈话。
下例明显违反最少知识原则,被称为火车失事:
String outputDir = ctxt.getOption().getScrachDir().getAbsolutePath();
如果使用属性访问器函数问题将会更复杂:
String outputDir = ctxt.options.scratchDir.absolutePath;
这种结构拥有执行操作的函数,也有公共变量的公共访问器及改值器。这诱导外部函数把对象当成数据结构来使用。既增加了添加新函数的难度,也增加了添加新数据结构的难度。
- 数据传送对象
只有公共变量、没有函数的类称为数据传送对象(DTO,Data Transfer Objects)。一般用在数据库通信、解析套接字传递的消息之类的场景。不要往这类数据结构中塞入业务规则方法,不要把这类数据结构当成对象使用,把它们当做数据结构使用,并创建包含业务规则,隐藏内部数据的独立对象。
6. 错误处理
错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。
-
使用异常而非返回码
返回码的问题在于,它们搞乱了调用者代码。调用者必须在调用之后即可检查错误。遇到错误时,最好抛出一个异常。这样调用代码很整洁,逻辑不会被错误处理搞乱。 -
先写Try-Catch-Finally语句
异常给程序定义了一个范围。在Try时,你实际上表明可随时取消执行,并在Catch语句中继续。Try代码块就是事务,catch代码块将程序维持在一种持续状态。先写Try-Catch-Finally语句帮助你定义代码的用户期待什么。 -
使用不可控异常
C#中没有可控异常,在Java中的可控异常的代价是违反开闭原则。如果在方法中抛出可控异常,你就得在catch语句和抛出异常处的每个方法签名中声明该异常。这意味着对软件较低层次的修改,都将波及较高级的签名。 -
给出异常发生的环境说明
创建信息充分的错误信息(包括失败的操作和失败类型),并和异常一起传递出去。 -
依调用者需要定义异常类
不太好的异常分类例子:ACMEPort port =newACMEPort();
try
{
port.open();
}
catch(DeviceResponseException e)
{
logger.log("Device response execuption", e);
}
catch(ATM1212UnlockedException e)
{
logger.log("Unlock exception", e);
}
catch(GMXError e)
{
logger.log("Device response exception");
}
finally
{
...
}
语句包含了一大堆重复代码。实际上,将第三方API打包(替换为抛出一个指定的异常)是个良好的实践手段。打包第三方API不仅可以降低了对它的依赖,还可以定义自己舒服的API。
-
定义常规流程
下面例子使用抛出异常的手法来处理特殊情况:try
{
MealExpense expense = expenseReportDAP.getMeals(emplyee.getID());
m_total += expense.getTotal();
}
catch(MealExpensesNotFound e)
{
m_total += getMealPerDiem();
}
这个手法很不好,使用异常打断了业务逻辑。更好的手段是使用特例模式:创建一个类或配置一个对象用来处理特例。客户代码就不用应付异常行为了。
-
别返回null值
如果打算在方法中返回null值,不如抛出异常,或是返回特例对象。这样客户代码就无需检查返回值了。 -
别传递null值
除非API要求你向它传递null值,否则尽可能避免传递null值。在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。恰当的方法就是禁止传入null值。