代码整洁之道(Clean Code)读书笔记
PS:本文读书笔记主要截取了部分章节的个人认为关键内容,整理精华,以供分享。
介绍好代码与糟糕代码的差异,了解如何写出好代码,以及将糟糕代码改成好代码。
糟糕的代码
-
因为时间赶着推产品,代码写的乱七八糟,特性越加越多,代码越来越烂,认为会有朝一日回头清理。注意勒布朗(LeBlanc)法则:稍后等于永不(Later equals never)
混乱的代价
-
项目初期进展迅猛,然而随着时间代码腐化,每次修改代码都需要了解已有混乱的代码,才往上添加依旧混乱的代码,导致乱麻越来越多。
-
混乱增加,导致生产力下降,增加更多的人手尝试解决,然而新人不熟悉系统,同时背负提升生产力压力,导致制造更多混乱。
-
程序员遵从不了解混乱风险的产品经理的意愿,奋力追赶进度和需求,是不专业的。
-
制造混乱无助于赶上期限,赶上期限唯一方法:保持代码整洁
什么是整洁的代码
作者通过询问知名且经验丰富的程序员得到什么是整洁代码的回答
-
令人愉悦且高效,不会“引诱”代码越改越乱;完善的错误处理代码,还有重视如内存泄露、竞态条件代码的;整洁代码只做好一件事。
-
不隐藏设计者意图,明确展现解决问题的张力。
-
有单元测试和验收测试,使用有意义的命名,尽量少的依赖;易于其他人加以增补,区分易读和易修改的代码;代码应在字面上表单含义,用人类可读方式写代码。
-
整洁代码看起来是某位特别在意它的人写的,几乎没有改进余地。
-
消除重复,提高表达力,提早构建简单抽象(小规模抽象既能快速前进,又能为未来修改留有余地)。
-
每个例程深合己意,专为解决特定问题存在。
我们是作者
-
Javadoc中的@author告诉我们是作者,作者都有读者;作者有责任与读者做良好沟通
-
Emac的“编辑器回放”功能显示,多数时间都在滚动屏幕,浏览其他模块
-
读与写代码花费的时间比例超过10:1,写新代码时,会一直阅读旧代码
童子军军规
-
美国童子军军规:让营地比你来时更干净
-
每次签入时,代码都比签出时干净(如修改变量名、拆分过长函数、消除重复代码、清理嵌套if)
-
持续改进,防止代码腐化
介绍在给变量、函数、参数、类和封包(jar包、war包、ear包等)、源代码及源代码目录命名时的简单规则
名副其实
-
一旦发现更好的名称,应该换掉旧的
-
名称应该已经答复了所有的大问题:做什么,该怎么用,若名称需要注释补充,则不算名副其实
-
体现本意的名称更容易理解和修改代码,如指明计量对象/计量单位的名称
-
用名副其实的函数名称替换魔术数
if (cell[STATUS_VALUE] == FLAGGED) // 替换为 if (cell.isFlagged)
避免误导
-
避免留下掩藏代码本意的错误信息
-
别用accountList表示一组账号,除非真是List类型,若包纳账号的容器不是List,会导致错误的判断,可用accountGroup或accounts
-
避免使用不同之处较小的名称,会导致难以区分两个名称,如XYZControllerForEfficientHandlingOfStrings和另一个XYZControllerForEfficientStorageOfStrings
-
避免使用小写字母l和大写字母O作为变量名,和常量“一”和“零”很相似
做有意义的区分
-
仅仅添加数据系列号或是废话是不够的,即使此可以满足编译器需要。
-
如果名称相异,则其意思应该不同才对。
-
区分名称,要以读者能鉴别不同之处的方式区分
添加数字
数字系列命名(a1,a2,….,aN)这样的名称没有提供正确信息,纯属误导
废话
-
如Product类和ProductInfo、ProductData,名称虽然不同,但意义却无区别,属于意义含糊的废话
-
废话是冗余的。Variable永远不该出现在变量名中,Table永远不该出现表名中
使用读的出来的名称
-
名称读不出来,讨论的时候就无法保持良好沟通,编程是一种“社会活动”
-
避免不合理的自造词(genmdhms),使用恰当的英语词
使用可搜索的名称
-
单字母名称和数字常量难以在一大篇文字中搜索出来,可见,长名称胜于短名称。
-
单字名称仅适用于短方法中的本地变量。名称的长短应与其作用域大小相对应
避免思维映射
-
避免让读者将你的名称翻译为他们熟知的名称
-
单字母变量名就有思维映射问题。除非是在作用域小,没有名称冲突时,传统上惯用i、j、k代表循环计数器
-
名称的明确是王道
类名和方法名
-
类名、对象名应该是名称或名词短语
-
方法名应该是动词或动词短语
-
重载构造器时,使用描述了参数意义的静态工厂方法名
Complex fulcrumPoint = Complex.FromRealNumber(23,0);// 将相应构造器设置为private // 通常好于 Complex fulcrumPoint = new Complex(23,0);
每个概念对应一个词
每个抽象概念选一个词,并且一以贯之。如避免使用fetch、get、retrieve不同的词语表示同种方法命名。
别用双关语
避免将同一个单词用于不同目的,遵循“一词一义”原则。如好多类都有add方法,需要保持这些add方法在语义上等价。
添加有意义的语境
-
很少有名称能自我说明,需要给读者提供语境信息
-
可以添加前缀,如addrFirstName,addrState等表示地址相关信息。更好的方案时创建相关类Address
-
只要短名称足够清楚,就比长名称号,别给名称添加不必要的语境。
函数是所有程序中的第一组代码,本章讨论如何写好函数
短小
短小程度
每个函数尽量保持到两行、三行或四行长,每个函数的事情一目了然,并依次把你带到下一个函数。
代码块和缩进
-
if、else、while语句等中的代码块应该只有一行(一个函数的调用语句),不仅能保持短小,同时由于调用函数有具有说明性的名称,增加文档上的价值
-
函数不应大到足以容纳超过两次的嵌套结构,否则不易阅读和理解
只做一件事
-
函数应该只做一件事。若函数只是做了该函数名下同一抽象层级上的步骤,则函数只做了一件事
-
判断函数是否做了一件事:看是否能再拆出一个函数,且该函数不仅只是单纯地重新诠释其实现(并未改变抽象层级)
-
函数中的区段:通常只做一件事的函数无法被合理地切分为多个区段
每个函数一个抽象层级
-
自顶向下读代码:向下规则,每个函数后面都跟着位于下一抽象层级的函数
-
让代码就像是一系列TO do起头的段落,每一段描述当前抽象层级,并引用位于下一抽象层级的后续TO do起头段落
Switch语句
-
switch天生要做N件事,虽无法避开switch语句,但确保每个switch都埋藏较低的抽象层级,且永远不重复,利用多态实现。
举例
反例
// 可能依赖于雇员类型的一种操作 public Money calculatePay(Employee e) { switch (e.type) { case HOURLY: return calculateHourlyPay(e); case SALARIED: return calculateSalariedPay(e); default: return null; } }
此例有四种问题:
-
函数太长,增加新类型后,会更长
-
做了不止一件事,违反了单一权责原则
-
违反了开放封闭原则,添加新类型时,必须修改之
-
该结构可能到处重复出现,可能会有其他方法,isPayday(Employee e)等等方法
正例
-
将switch语句埋藏到抽象工厂,该工厂使用switch语句为Employee的继承派生物创建适当实体,不同的函数calculatePay、isPayday则由Employee接口多态的接受调用派遣
-
switch语句尽量只出现一次,用于抽象工厂中创建多态对象,系统其它部分看不到。
// Employee与使用switch的抽象工厂 public abstract class Emloyee { public abstract boolean isPayday(); public abstract Money calculatePay(); } ---------- public class EmployeeFactoryImple implements EmployeeFactory { public Employee makeEmployee(EmployeeRecord r) { switch(r.type) { case HOURLY: return new HourlyEmployee(r); // 返回Employee派生类对象 case SALARIED: return new SalariedEmployee(r); default: return null; } } }
使用描述性的名称
-
函数越短小、功能越集中,就越便于取好名字
-
别害怕花时间取名字,尝试不同的名称,实测其阅读效果
-
描述性的名称能够理清你关于模块的设计思路,并可改进之
-
命名方式保持一致,使用与模块名一脉相承的短语、名词和动词等措辞给函数命名
-
注意完整函数签名中,考虑函数所在类名的namespace的描述作用和函数参数名的描述作用,在函数被调用使用时可组合起来描述函数作用(可以避免函数名过度冗长,如下例)
// 如EmployeeManager类中保存Employee的操作 public class EmployeeManager { // 写法一 public void saveEmployee(EmployeeDO employeeDO); // 写法二:考虑实际使用时会通过类对象调用方法,如类对象employeeManager本身也有描述意义,即通过employeeManager.save(employeeDO)使用,函数名不用再用较为冗长的saveEmployee public void save(EmployeeDO employeeDO); }
函数参数
-
最理想情况参数数量是零,其次是一,再次是二,应尽量避免三参数
-
从测试角度,对于多参数,要编写所有可能的参数组合正常的测试用例很困难
-
输入参数与输出参数。习惯通过参数(即输入参数)输入函数,通过返回值从函数输出。不太期望通过参数(输出参数)输出结果
一元函数
-
单参函数作用:
-
询问关于入参的问题,如Boolean fileExists("MyFile")
-
-
操作该参数
-
转换。InputStream fileopen("MyFile")
-
事件(event),使用该参数修改系统状态,有入参而无返回值(void)
-
注意选用能区别上述两种理由的函数名称
-
对于转换。使用输出参数(即函数入参中在函数执行完后带有输出结果)而非返回值令人迷惑。转换结果体现在返回值上。
二元函数
-
有时两个参数正好。如Point p = new Point(0,0),因为此为单个值的有序组成部分。对应自然组合的参数适合二元函数,如笛卡尔坐标
-
尽量将二元函数转换为一元函数。如对于writeField(outputStream, name)的改进方法:
-
把writeField写成outputStream的成员方法,调用方式改为:outputStream.writeField(name)
-
outputStream写为当前类的成员变量,从而无需再传递它
-
分离出类似FieldWriter新类,在构造器中采用outputStream,新类包含一个write方法
-
三元函数
-
三元函数会使排序、琢磨、忽略(即忽略某个参数)等问题加倍体现,慎写三元函数
标识参数
-
向函数传入Boolean值不可取,表示该函数不止做一件事:true一种做法,false一种做法
-
应该将带有标识参数的函数一分为二:如render(Boolen isSuite)改为renderForSuite()和renderForSingleTest()
参数对象
-
如果函数参数需要两个、三个或三个以上的参数,说明其中一些参数需要封装为类
-
从参数创建对象,减少参数数量,同时一组参数被共同传递,则说明需要有自己名称的某个概念。如下例子
Circle makeCircle(double x, double y, double radius); // 修改为 Circle makeCircle(Point center, double radius);
动词与关键词
-
对于一元函数,函数和参数应形成良好的动/名词对形式,如write(name),更好的是writeField(name)
-
通过在函数名中使用关键字,将参数名编码到函数名,如assertEqual改成assertExpectedEqualsActual(expected, actual),减轻记忆参数顺序负担
要无副作用
-
副作用是一种谎言,函数有时会对自己类中的变量做出未能预期的改动,有时会把变量搞成向函数传递的参数或是系统全局变量,都是具有破坏性的,会导致古怪的时序性耦合和顺序依赖(即函数能否正常调用依赖于其他函数调用顺序)。可通过重命名函数声明表达潜在的时序性问题,如将简单的checkPassword()重命名为checkPasswordAndInitializeSession()解决其中对Session.initialize()的调用依赖(此即为副作用)。
输出参数
-
避免被参数是用作输入还是输出而迷惑,如appendFooter(s),该函数是否会向s后面添加东西?此时会付出检查函数声明的代价而中断思路
-
避免输出参数方式
-
使用report.appendFooter()调用,可避免输出参数,即若函数需要修改某种状态,修改所属对象状态
-
直接使用返回值
-
分离指令与询问
-
函数要么做某事(指令),要么回答某事(询问),二者不可得兼,否则会导致混乱。要么修改某对象状态,要么返回该对象的有关信息
if (set("username","unclebob")) // 是在问unclebob是否设置过?还是是否成功设置unclebob? // 将上述设置与返回分开 if (attributeExists("username")) { setAttribute("username","unclebob") }
使用异常代替错误码
-
错误码会导致更深层次的嵌套结构(需要判断:if (deletePage(page) == E_OK));使用异常能将错误处理代码从主路径分离出来
-
错误码依赖磁铁问题。使用错误码意味着某处有个类或枚举定义这所有错误码,许多其他依赖错误的地方都需要导入它,当修改时需要其他类重新编译部署;使用异常,新异常从异常类派生即可,无需重新编译部署。
抽离try/catch代码块
-
将错误处理与正常流程分离,把try和catch代码块中的主体部分抽离出,形成函数
public void delete(Page page) { try { deletePageAndAllReferences(page); } catch(Exception e) { logError(e); } } private void deletePageAndAllReferences(Page page) { .... } private void logError(Exception e) { ... }
上例将delete函数至于错误处理有关,很容易理解并忽略掉即可。如此美妙的区隔,代码更易理解和修改。
错误处理就是一件事
-
函数应该只做一件事,错误处理就是一件事
-
处理错误的函数不应该做其他事,意味着(如上例):
-
如果try存在于某个函数,它就是该函数的第一个单词
-
catch/finally代码块后面不该有其他内容
-
别重复自己
-
重复是软件中一切邪恶的根源
如何写出好函数
-
并不从一开始就完全按照规则写函数。先想怎么写就怎么写,一开始都冗长而繁杂,有很多缩进、嵌套、过长的参数列表。名称随意取,重复的代码。不过注意配上一套单元测试,覆盖代码,用于重构的回归测试
-
再打磨。分解函数、修改名称、消除重复、缩短和重新安置方法、拆散类等。同时

浙公网安备 33010602011771号