代码整洁之道笔记
代码整洁之道
序
-
为什么相比于任何宏伟愿景,对细节的关注是更为关键的专业性基础?因为
-
开发者通过小型实践获得可用于大型实践的技能和信用度;
-
宏大建筑中最细小的部分,比如关不紧的门、凌乱的桌面,都足以使大局的魅力遗失殆尽。
-
-
重构的时机:每段代码都应该在你希望它所在的地方,否则就需要重构了。对于那种四处遗弃的带注释的代码及反映过往或期望的无注释代码,应该除之而后快。
-
在实践中贯彻规程,并时时体现于个人工作上,而且要乐于改进。对于代码,应无情地做重构。还可以更进一步:一开始就打造更易维护、可读的代码,重要程度不亚于写出可执行的代码。
-
前后一致的缩进风格明显标志了较低的缺陷率,这种风格远不止是一种单纯的能力那么简单。过硬的质量是由无数个细小的细节堆叠出来的,而绝非借助任何神奇的魔法——这些行为简单而不简陋,是人力所能及的通往伟大的方法。
-
应当视代码作为过程而非终点的设计的这种高尚行为的漂亮体现。耦合与内聚的架构韵律在代码中脉动。
前言
- 你应当习得有关原则、模式和实践的知识,穷尽应知之事,并且要对其了如指掌,通过刻苦实践掌握它。你须自行实践,且体验自己的失败。你须观察他人的实践与失败。你须看看别人是怎样蹒跚学步,再转头研究他们的路数。你须看看别人是如何绞尽脑汁做出决策,又是如何为错误决策付出代价。启示本身不值钱,启示与案例研究中清理代码的具体决策之间的关系才有价值。站在作者的角度,迫使自己以作者的思维路径考虑问题,就能更深刻地理解这些原则、模式、实践和启示。
第1章 整洁代码
1. 为何代码将永存?
因为我们永远无法抛弃必要的精确性。代码呈现了需求的细节,在某些层面上,这些细节无法被忽略或抽象,必须被明确。将需求明确到机器可以执行的细节程度,就是编程要做的事。而这种规约正是代码。在较高层次上用领域特定语言撰写的规约也将是代码!它也得严谨、精确、规范和详细,好让机器理解和执行。归置良好的需求就像代码一样正式,也能作为代码的可执行测试来使用。代码仍然是最终用来表达需求的那种语言。
2. 整洁不会拖慢你的进度
程序员遵从不了解混乱风险的经理的意愿,也是不专业的做法。混乱只会立刻拖慢你,叫你错过期限。赶上期限的唯一方法——做得快的唯一方法 ——就是始终尽可能保持代码整洁。
3. 何为好代码
-
代码逻辑应当直截了当,叫缺陷难以隐藏;尽量减少依赖关系,使之便于维护;依据某种分层战略完善错误处理代码;性能调至最优,省得引诱别人做没规矩的优化,搞出一堆混乱来。
-
整洁的代码只做好一件事,力求集中。
每个函数、每个类和每个模块都全神贯注于一事。整洁的代码应当明确地展现出要解决问题的张力。它应当将这种张力推至高潮,以某种显而易见的方案解决问题和张力。代码应当讲述事实,不引人猜测。它只该包含必需之物。读者应当感受到我们的果断决绝。它应当有单元测试和验收测试。它使用有意义的命名。它只提供一种而非多种做一件事的途径。它只有尽量少的依赖关系,而且要明确地定义和提供清晰、尽量少的API。代码应通过其字面表达含义,因为不同的语言导致并非所有必需信息均可通过代码自身清晰表达。 -
消除重复和提高表达力
如果同一段代码反复出现,就表示某种想法未在代码中得到良好的体现。我尽力去找出到底那是什么,然后再尽力更清晰地表达出来。检查对象或方法是否想做的事太多。如果对象功能太多,最好是切分为两个或多个对象。如果方法功能太多,我总是使用抽取手段(Extract Method)重构之,从而得到一个能较为清晰地说明自身功能的方法,以及另外数个说明如何实现这些功能的方法。消除重复和提高表达力让我在整洁代码方面获益良多,只要铭记这两点,改进脏代码时就会大有不同。
这么多年下来,我发现所有程序都由极为相似的元素构成。例如“在集合中查找某物”。不管是雇员记录数据库还是名-值对哈希表,或者某类条目的数组,我们都会发现自己想要从集合中找到某一特定条目。一旦出现这种情况,我通常会把实现手段封装到更抽象的方法或类中。由于对搜索功能的引用指向了我那个小小的抽象,就能随需应变,修改实现手段。这样就既能快速前进,又能为未来的修改预留余地。该集合抽象常常提醒我留意“真正”在发生的事,避免随意实现集合行为,因为我真正需要的不过是某种简单的查找手段。减少重复代码,提高表达力,提早构建简单抽象。这就是我写整洁代码的方法。 -
你不会为整洁代码所震惊。你无需花太多力气。那代码就是深合你意。它明确、简单、有力。每个模块都为下一个模块做好准备。每个模块都告诉你下一个模块会是怎样的。整洁的程序好到你根本不会注意到它。设计者把它做得像一切其他设计般简单。
-
语言是冥顽不化的!是程序员让语言显得简单。
-
实际上,作者有责任与读者做良好沟通。下次你写代码的时候,记得自己是作者,要为评判你工作的读者写代码。
第2章 有意义的命名
1. 注意命名
一旦发现有更好的名称,就换掉旧的。这么做,读你代码的人(包括你自己)都会更开心。变量、函数或类的名称应该已经答复了所有的大问题。它该告诉你,它为什么会存在,它做什么事,应该怎么用。如果名称需要注释来补充,那就不算是名副其实。
2. 名副其实
问题不在于代码的简洁度,而是在于代码的模糊度:即上下文在代码中未被明确体现的程度。上列代码要求我们了解类似以下问题的答案:
-
theList
中是什么类型的东西? -
theList
零下标条目的意义是什么? -
值4的意义是什么?
-
我怎么使用返回的列表?
public List<int[]> getFlaggedCells() {
List<int[]> flaggedCells = new ArrayList<int[]>();
for (int[] cell : gameBoard)
if (cell[STATUS_VALUE] == FLAGGED)
flaggedCells.add(cell);
return flaggedCells;
}
注意,代码的简洁性并未被触及。运算符和常量的数量全然保持不变,嵌套数量也全然保持不变。但代码变得明确多了
public List<Cell> getFlaggedCells() {
List<Cell> flaggedCells = new ArrayList<Cell>();
for (Cell cell : gameBoard)
if (cell.isFlagged())
flaggedCells.add(cell);
return flaggedCells;
}
只要简单改一下名称,就能轻易知道发生了什么。这就是选用好名称的力量。
3. 避免误导
程序员必须避免留下掩藏代码本意的错误线索。应当避免使用与本意相悖的词。例如,hp
、aix
和sco
都不该用做变量名,因为它们都是UNIX平台或类UNIX平台的专有名称。即便你是在编写三角计算程序,hp
看起来是个不错的缩写,但那也可能会提供错误信息。
别用accountList
来指称一组账号,除非它真的是List
类型。List一词对程序员有特殊意义。如果包纳账号的容器并非真是个List
,就会引起错误的判断。所以,用accountGroup
或bunchOfAccounts
,甚至直接用accounts
都会好一些。
提防使用不同之处较小的名称。想区分模块中某处的XYZControllerForEfficientHandlingOfStrings
和另一处的XYZControllerForEfficientStorageOfStrings
,会花多长时间呢?这两个词外形实在太相似了。
以同样的方式拼写出同样的概念才是信息。拼写前后不一致就是误导。
假如相似的名称依字母顺序放在一起,且差异很明显,那就会相当有助益,因为程序员多半会压根不看你的详细注释,甚至不看该类的方法列表就直接看名字挑一个对象。
误导性名称真正可怕的例子,是用小写字母l
和大写字母O
作为变量名,尤其是在组合使用的时候。当然,问题在于它们看起来完全像是常量“壹”和“零”
4. 做有意义的区分
如果程序员只是为满足编译器或解释器的需要而写代码,就会制造麻烦。例如,因为同一作用范围内两样不同的东西不能重名,你可能会随手改掉其中一个的名称。有时干脆以错误的拼写充数,结果就是出现在更正拼写错误后导致编译器出错的情况
废话是另一种没意义的区分。假设你有一个 Product
类。如果还有一个 ProductInfo
或ProductData
类,那它们的名称虽然不同,意思却无区别。Info
和Data
就像a
、an
和the
一样,是意义含混的废话
只要体现出有意义的区分,使用 a
和the
这样的前缀就没错。例如,你可能把 a
用在域内变量,而把the
用于函数参数。但如果你已经有一个名为zork
的变量,又想调用一个名为theZork
的变量,麻烦就来了。
废话都是冗余。Variable
一词永远不应当出现在变量名中。Table
一词永远不应当出现在表名中。NameString
会比Name
好吗?难道Name
会是一个浮点数不成?如果是这样,就触犯了关于误导的规则。设想有个名为Customer
的类,还有一个名为CustomerObjec
t的类。区别何在呢?哪一个是表示客户历史支付情况的最佳途径?
5. 使用读得出来的名称
人类长于记忆和使用单词。大脑的相当一部分就是用来容纳和处理单词的。单词能读得出来。人类进化到大脑中有那么大的一块地方用来处理言语,若不善加利用,实在是种耻辱
如果名称读不出来,就很难进行有效的讨论。这不是小事,因为编程本就是一种社会活动。
6. 使用可搜索的名称
长名称胜于短名称,搜得到的名称胜于用自造编码代写就的名称。名称长短应与其作用域大小相对应,若变量或常量可能在代码中多处使用,则应赋其以便于搜索的名称。
7. 避免使用编码
不必用 m_前缀来标明成员变量。应当把类和函数做得足够小,消除对成员前缀的需要。你应当使用某种可以高亮或用颜色标出成员的编辑环境。
人们会很快学会无视前缀(或后缀),只看到名称中有意义的部分。代码读得越多,眼中就越没有前缀。最终,前缀变作了不入法眼的废料,变作了旧代码的标志物。
ShapeFactoryImp
,甚至是丑陋的CShapeFactory
,都比对接口名称编码来得好。
8. 避免思维映射
不应当让读者在脑中把你的名称翻译为他们熟知的名称。这种问题经常出现在选择是使用问题领域术语还是解决方案领域术语时。
9. 类名和对象名应该是名词或名词短语
如Customer
、WikiPage
、Account
和AddressParser
。避免使用Manager
、Processor
、Data
或Info
这样的类名。类名不应当是动词
10. 方法名应当是动词或动词短语
如postPayment
、deletePage
或save
。属性访问器、修改器和断言应该根据其值命名,并依Javabean标准[10]加上get、set和is前缀。
11. 每个概念对应一个词,并且一以贯之
不要用不同的词给多个类的同种方法命名,不要同一单词用于不同目的。
12. 使用解决方案领域名称
尽管使用cs术语、算法名、模式名、数学术语。否则,就采用所涉及问题领域的名称,优秀的程序员和设计师的工作之一就是分离解决方案领域和问题领域的概念,和所涉及问题领域更贴近的代码,应当采用源自问题领域的名称。
13. 添加有意义的语境
多数名称都不能自我说明,所以你需要用有良好命名的类、函数或命名空间来放置名称,给读者提供语境。
14. 不要添加没用的语境
只要短名称足够清楚,就比长名称好,别给名称添加不必要的语境。对于Address
类的实体来说,accountAddress
和customerAddress
都是不错的名称,不过用在类名上就不太好了。Address
是个好类名。如果需要与MAC地址、端口地址和Web地址相区别,我会考虑使用PostalAddress
、MAC
和URI
。这样的名称更为精确,而精确正是命名的要点。
多数时候我们并不记忆类名和方法名。我们使用现代工具对付这些细节,好让自己集中精力于把代码写得就像词句篇章、至少像是表和数据结构(词句并非总是呈现数据的最佳手段)。
第3章 函数
如何才能让函数表达其意图?该给函数赋予哪些属性使得读者一看就明白函数在做什么?
1. 短小
函数应该有多短小?每个函数都只说一件事,且每个函数依次把你带到下一个函数,这就是函数应该达到的短小程度。if语句、else语句、while语句等,其中的代码块应该只有一行。该行大抵应该是一个函数调用语句。这样不但能保持函数短小,而且,因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。这也意味着函数不应该大到足以容纳嵌套结构。所以,函数的缩进层级不该多于一层或两层。当然,这样的函数易于阅读和理解。
2. 只做一件事
如果函数只是做了该函数名下同一抽象层上的步骤,则函数还是只做了一件事。编写函数毕竟是为了把大一些的概念(换言之,函数的名称)拆分为另一抽象层上的一系列步骤。所以,要判断函数是否不止做了一件事,还有一个方法,就是看是否能再拆出一个函数,该函数不仅只是单纯地重新诠释其实现[G34]。只做一件事的函数无法被合理地切分为多个区段。
3. 每个函数一个抽象层级
函数中混杂不同抽象层级,往往让人迷惑。读者可能无法判断某个表达式是基础概念还是细节。更恶劣的是,就像破损的窗户,一旦细节与基础概念混杂,更多的细节就会在函数中纠结起来。我们想要让代码拥有自顶向下的阅读顺序。我们想要让每个函数后面都跟着位于下一抽象层级的函数,这样一来,在查看函数列表时,就能偱抽象层级向下阅读了。我把这叫做向下规则。换一种说法。我们想要这样读程序:程序就像是一系列 TO起头的段落,每一段都描述当前抽象层级,并引用位于下一抽象层级的后续TO起头段落。
4. switch语句
写出只做一件事的switch语句也很难。Switch天生要做N件事。不幸我们总无法避开switch语句,不过还是能够确保每个switch都埋藏在较低的抽象层级,而且永远不重复。当然,我们利用多态来实现这一点。对于switch语句,我的规矩是如果只出现一次,用于创建多态对象,而且隐藏在某个继承关系中,在系统其他部分看不到,就还能容忍[G23]。当然也要就事论事,有时我也会部分或全部违反这条规矩。
5. 使用描述性的名称
别害怕长名称。长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的长注释好。使用某种命名约定,让函数名称中的多个单词容易阅读,然后使用这些单词给函数取个能说清其功用的名称。别害怕花时间取名字。你当尝试不同的名称,实测其阅读效果。在Eclipse或IntelliJ等现代IDE中改名称易如反掌。使用这些IDE测试不同名称,直至找到最具有描述性的那一个为止。选择描述性的名称能理清你关于模块的设计思路,并帮你改进之。追索好名称,往往导致对代码的改善重构。命名方式要保持一致。使用与模块名一脉相承的短语、名词和动词给函数命名。
6. 函数参数
最理想的参数数量是零(零参数函数),其次是一(单参数函数),再次是二(双参数函数),应尽量避免三(三参数函数)。有足够特殊的理由才能用三个以上参数(多参数函数)——所以无论如何也不要这么做。参数与函数名处在不同的抽象层级,它要求你了解目前并不特别重要的细节。
从测试的角度看,参数甚至更叫人为难。想想看,要编写能确保参数的各种组合运行正常的测试用例,是多么困难的事。如果没有参数,就是小菜一碟。如果只有一个参数,也不太困难。有两个参数,问题就麻烦多了。如果参数多于两个,测试覆盖所有可能值的组合简直让人生畏。
输出参数比输入参数还要难以理解。读函数时,我们惯于认为信息通过参数输入函数,通过返回值从函数中输出。我们不太期望信息通过参数输出。所以,输出参数往往让人苦思之后才恍然大悟。
相较于没有参数,只有一个输入参数算是第二好的做法。SetupTeardownInclude.render(pageData)
也相当易于理解。很明显,我们将渲染pageData
对象中的数据。
a.一元函数的普遍形式
向函数传入单个参数有两种极普遍的理由。你也许会问关于那个参数的问题,就像在boolean fileExists("MyFile")
中那样。也可能是操作该参数,将其转换为其他什么东西,再输出之。例如,InputStream fileOpen("MyFile")
把String
类型的文件名转换为InputStream
类型的返回值。这就是读者看到函数时所期待的东西。你应当选用较能区别这两种理由的名称,而且总在一致的上下文中使用这两种形式。
还有一种虽不那么普遍但仍极有用的单参数函数形式,那就是事件(event)。在这种形式中,有输入参数而无输出参数。程序将函数看作是一个事件,使用该参数修改系统状态,例如void passwordAttemptFailedNtimes(int attempts)
。小心使用这种形式。应该让读者很清楚地了解它是个事件。谨慎地选用名称和上下文语境。
尽量避免编写不遵循这些形式的一元函数,例如,void includeSetupPageInto(StringBuffer pageText)
。对于转换,使用输出参数而非返回值令人迷惑。如果函数要对输入参数进行转换操作,转换结果就该体现为返回值。
b.标识参数
向函数传入布尔值是很丑陋的做法,因为这意味着函数不止做一件事情。方法调用render(true)
对于可怜的读者来说仍然摸不着头脑。卷动屏幕,看到render(Boolean isSuite)
,稍许有点帮助,不过仍然不够。应该把该函数一分为二:reanderForSuite( )
和renderForSingleTest( )
。
c.二元函数
二元函数不算恶劣,而且你当然也会编写二元函数。不过,你得小心,使用二元函数要付出代价。你应该尽量利用一些机制将其转换成一元函数。例如,可以把 writeField
方法写成outputStream
的成员之一,从而能这样用:outputStream.writeField(name)
。或者,也可以把outputStream
写成当前类的成员变量,从而无需再传递它。还可以分离出类似 FieldWriter
的新类,在其构造器中采用outputStream
,并且包含一个write
方法。
d.参数对象
如果函数看来需要两个、三个或三个以上参数,就说明其中一些参数应该封装为类了。当一组参数被共同传递,就像Point
中的x
和y
那样,往往就是该有自己名称的某个概念的一部分。
e.参数列表
同理,有可变参数的函数可能是一元、二元甚至三元。超过这个数量就可能要犯错了。void monad(Integer... args);void dyad(String name, Integer... args);void triad(String name, int count, Integer... args);
f.动词与关键字
对于一元函数,函数和参数应当形成一种非常良好的动词/名词对形式。例如,write(name)
就相当令人认同。不管这个“name”是什么,都要被“write”。更好的名称大概是writeField(name)
,它告诉我们,“name”是一个“field”。最后那个例子展示了函数名称的关键字(keyword)形式。使用这种形式,我们把参数的名称编码成了函数名。例如,assertEqual
改成assertExpectedEqualsActual(expected,actual)
可能会好些。这大大减轻了记忆参数顺序的负担。
7. 无副作用
副作用是一种谎言。函数承诺只做一件事,但还是会做其他被藏起来的事。有时,它会对自己类中的变量做出未能预期的改动。有时,它会把变量搞成向函数传递的参数或是系统全局变量。无论哪种情况,都是具有破坏性的,会导致古怪的时序性耦合及顺序依赖。代码清单3-6中看似无伤大雅的函数checkPassword
的副作用就在于对Session.initialize( )
的调用。checkPassword
函数,顾名思义,就是用来检查密码的。该名称并未暗示它会初始化该次会话。所以,当某个误信了函数名的调用者想要检查用户有效性时,就得冒抹除现有会话数据的风险。这一副作用造出了一次时序性耦合。也就是说,checkPassword
只能在特定时刻调用(换言之,在初始化会话是安全的时候调用)。如果在不合适的时候调用,会话数据就有可能沉默地丢失。时序性耦合令人迷惑,特别是当它躲在副作用后面时。如果一定要时序性耦合,就应该在函数名称中说明。在本例中,可以重命名函数为checkPasswordAndInitializeSession
,虽然那还是违反了“只做一件事”的规则。
对于输出参数,应避免使用输出参数。如果函数必须要修改某种状态,就修改所属对象的状态吧。
8. 分隔指令与询问
函数要么做什么事,要么回答什么事,但二者不可得兼。函数应该修改某对象的状态,或是返回该对象的有关信息。两样都干常会导致混乱。
if (set("username", "unclebob"))...
从读者的角度考虑一下吧。这是什么意思呢?它是在问username
属性值是否之前已设置为unclebob
吗?或者它是在问username
属性值是否成功设置为unclebob
呢?从这行调用很难判断其含义,因为set
是动词还是形容词并不清楚。作者本意,set
是个动词,但在if语句的上下文中,感觉它像是个形容词。该语句读起来像是说“如果username
属性值之前已被设置为uncleob
”,而不是“设置username
属性值为unclebob
,看看是否可行,然后……”。要解决这个问题,可以将 set 函数重命名为setAndCheckIfExists
,但这对提高 if 语句的可读性帮助不大。真正的解决方案是把指令与询问分隔开来,防止混淆的发生:
if (attributeExists("username")) {
setAttribute("username", "unclebob");
...
}
9. 使用异常替代返回错误码
从指令式函数返回错误码轻微违反了指令与询问分隔的规则。它鼓励了在if语句判断中把指令当作表达式使用。
if (deletePage(page) == E_OK)
这不会引起动词/形容词混淆,但却导致更深层次的嵌套结构。当返回错误码时,就是在要求调用者立刻处理错误。 另一方面,如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来,得到简化:
try {
deletePage(page);registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}catch (Exception e) {
logger.log(e.getMessage());
}
a.抽离Try/Catch代码块
Try/catch代码块丑陋不堪。它们搞乱了代码结构,把错误处理与正常流程混为一谈。最好把try和catch代码块的主体部分抽离出来,另外形成函数。错误处理可以忽略掉。有了这样美妙的区隔,代码就更易于理解和修改了。
b.错误处理就是一件事
函数应该只做一件事。错误处理就是一件事。因此,处理错误的函数不该做其他事。这意味着(如上例所示)如果关键字try
在某个函数中存在,它就该是这个函数的第一个单词,而且在catch/finally代码块后面也不该有其他内容。
c.Error.java
依赖磁铁
返回错误码通常暗示某处有个类或是枚举,定义了所有错误码。这样的类就是一块依赖磁铁(dependency magnet);其他许多类都得导入和使用它。当Error枚举修改时,所有这些其他的类都需要重新编译和部署。这对Error类造成了负面压力。程序员不愿增加新的错误代码,因为这样他们就得重新构建和部署所有东西。于是他们就复用旧的错误码,而不添加新的。使用异常替代错误码,新异常就可以从异常类派生出来,无需重新编译或重新部署。
10. 别重复自己
重复可能是软件中一切邪恶的根源。许多原则与实践规则都是为控制与消除重复而创建。例如,全部考德(Codd)[14]数据库范式都是为消灭数据重复而服务。再想想看,面向对象编程是如何将代码集中到基类,从而避免了冗余。面向方面编程(Aspect Oriented Programming)、面向组件编程(Component Oriented Programming)多少也都是消除重复的一种策略。看来,自子程序发明以来,软件开发领域的所有创新都是在不断尝试从源代码中消灭重复。
11. 结构化编程
结构化编程规则认为,每个函数、函数中的每个代码块都应该有一个入口、一个出口。遵循这些规则,意味着在每个函数中只该有一个return语句,循环中不能有break或continue语句,而且永永远远不能有任何goto语句。
但对于小函数,这些规则助益不大。只有在大函数中,这些规则才会有明显的好处。所以,只要函数保持短小,偶尔出现的return、break或continue语句没有坏处,甚至还比单入单出原则更具有表达力。另外一方面,goto只在大函数中才有道理,所以应该尽量避免使用。
12. 如何写出这样的函数
写代码和写别的东西很像。在写论文或文章时,你先想什么就写什么,然后再打磨它。初稿也许粗陋无序,你就斟酌推敲,直至达到你心目中的样子。我写函数时,一开始都冗长而复杂。有太多缩进和嵌套循环。有过长的参数列表。名称是随意取的,也会有重复的代码。不过我会配上一套单元测试,覆盖每行丑陋的代码。然后我打磨这些代码,分解函数、修改名称、消除重复。我缩短和重新安置方法。有时我还拆散类。同时保持测试通过。最后,遵循本章列出的规则,我组装好这些函数。我并不从一开始就按照规则写函数。我想没人做得到。
第4章 注释
不准确的注释比没注释坏得多,只有代码是唯一真正准确的信息来源。注释的恰当用法是弥补我们用代码表达意图时遭遇的失败。
1. 注释不能美化糟糕的代码
带有少量注释的整洁有表达力的代码要比带有大量注释的零碎而复杂的代码像样得多,与其花时间编写解释搞出的糟糕的代码的注释,不如花时间清理糟糕的代码。
2. 用代码来阐释
很多时候,只需要创建一个描述与注释所言同一事物的函数即可。
你愿意看到这个:
// Check to see if the employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) &&(employee.age > 65))
还是这个?
if (employee.isEligibleForFullBenefits())
3. 何为好注释?唯一好的注释是你想办法不去写的注释。
a.法律信息
只要有可能,就指向一份标准许可或其他外部文档,而不要把所有条款放到注释中。
b.对意图的解释
有时,注释不仅提供了有关实现的有用信息,而且还提供了某个决定后面的意图。
c.阐释
如果参数或返回值是某个标准库的一部分,或是你不能修改的代码,帮助阐释其含义的代码就会有用。在写这类注释之前,考虑一下是否还有更好的办法,然后再加倍小心地确认注释正确性。
d.警示
有时,用于警告其他程序员会出现某种后果的注释也是有用的。
e.TODO注释
TODO是一种程序员认为应该做,但由于某些原因目前还没做的工作。它可能是要提醒删除某个不必要的特性,或者要求他人注意某个问题。它可能是恳请别人取个好名字,或者提示对依赖于某个计划事件的修改。无论TODO的目的如何,它都不是在系统中留下糟糕的代码的借口。如今,大多数好IDE都提供了特别的手段来定位所有TODO注释,这些注释看来丢不了。你不会愿意代码因为TODO的存在而变成一堆垃圾,所以要定期查看,删除不再需要的。
f.强调
注释可以用来放大某种看来不合理之物的重要性。
String listItemContent = match.group(3).trim(); // the trim is real important. It removes the starting// spaces that could cause the item to be recognized// as another list.new ListItemWidget(this, listItemContent, this.level + 1);return buildList(text.substring(match.end()));
g.公共API中的Javadoc
没有什么比被良好描述的公共API更有用和令人满意的了。标准Java库中的Javadoc就是一例。没有它们,写Java程序就会变得很难。如果你在编写公共API,就该为它编写良好的Javadoc。不过要记住本章中的其他建议。就像其他注释一样,Javadoc也可能误导、不适用或者提供错误信息。
4. 坏注释:
a.喃喃自语
如果只是因为你觉得应该或者因为过程需要就添加注释,那就是无谓之举。如果你决定写注释,就要花必要的时间确保写出最好的注释。任何迫使读者查看其他模块的注释,都没能与读者沟通好,不值所费。
b.多余的注释
读注释的时间比读代码还长。
c.误导性注释
e.循规式注释
所谓每个函数都要有 Javadoc 或每个变量都要有注释的规矩全然是愚蠢可笑的。这类注释徒然让代码变得散乱,满口胡言,令人迷惑不解。
f.日志式注释
g.废话注释
用整理代码的决心替代创造废话的冲动吧。你会发现自己成为更优秀、更快乐的程序员。
h.可怕的废话
如果作者在写(或粘贴)注释时都没花心思,怎么能指望读者从中获益呢?
i.能用函数或变量时就别用注释
j.位置标记
k.括号后面的注释
程序员会在括号后面放置特殊的注释以标志右括号匹配的左括号,尽管这对于含有深度嵌套结构的长函数可能有意义,但只会给我们更愿意编写的短小、封装的函数带来混乱。如果你发现自己想标记右括号,其实应该做的是缩短函数。
l.归属与署名
源代码控制系统非常善于记住是谁在何时添加了什么。没必要用那些小小的签名搞脏代码。
m.注释掉的代码
他人不敢删除注释掉的代码。他们会想,代码依然放在那儿,一定有其原因,而且这段代码很重要,不能删除。注释掉的代码堆积在一起,就像破酒瓶底的渣滓一般。我们已经拥有优良的源代码控制系统如此之久,这些系统可以为我们记住不要的代码。我们无需再用注释来标记,删掉即可,它们丢不了。
n.HTML注释
源代码注释中的HTML标记是一种厌物,编辑器/IDE中的代码本来易于阅读,却因为HTML 注释的存在而变得难以卒读。如果注释将由某种工具(例如Javadoc)抽取出来,呈现到网页,那么该是工具而非程序员来负责给注释加上合适的HTML标签。
o.非本地信息
假如你一定要写注释,请确保它描述了离它最近的代码。别在本地注释的上下文环境中给出系统级的信息。以下面的Javadoc 注释为例,除了那可怕的冗余之外,它还给出了有关默认端口的信息。不过该函数完全没控制到那个所谓默认值。这个注释并未描述该函数,而是在描述系统中远在他方的其他函数。当然,也无法担保在包含那个默认值的代码修改之后,这里的注释也会跟着修改。
p.信息过多
别在注释中添加有趣的历史性话题或者无关的细节描述。
q.不明显的联系
注释及其描述的代码之间的联系应该显而易见。如果你不嫌麻烦要写注释,至少让读者能看着注释和代码,并且理解注释所谈何物。注释的作用是解释未能自行解释的代码。如果注释本身还需要解释,就太遗憾了。
r.函数头
为只做一件事的短函数选个好名字,通常要比写函数头注释要好。
s.Javadoc
虽然Javadoc对于公共API非常有用,但对于不打算作公共用途的代码就令人厌恶了。为系统中的类和函数生成Javadoc页并非总有用,而Javadoc注释额外的形式要求几乎等同于八股文章。
t.范例
第一个注释完全是多余的,因为它读起来非常像是generatePrimes 函数自身。不过,我认为这段注释还是省了读者去读具体算法的精力,所以我倾向于留下它。第二个注释显然很有必要。它解释了平方根作为循环限制的理由。我找不到能说明白这个问题的简单变量名或者其他编程结构。另外,对平方根的使用可能也有点武断。通过限制平方根循环,我是否真节省了许多时间?平方根计算所花的时间会不会比省下的时间还要多?这些都值得考虑。使用平方根作为循环限制,满足了我这种旧式C语言和汇编语言黑客,不过我可不敢说抵得上其他人为理解它而花的时间和精力。
第5章 格式
你应该保持良好的代码格式。你应该选用一套管理代码格式的简单规则,然后贯彻这些规则。如果你在团队中工作,则团队应该一致同意采用一套简单的格式规则,所有成员都要遵从。使用能帮你应用这些格式规则的自动化工具会很有帮助。
1. 格式的目的
代码格式关乎沟通,而沟通是专业开发者的头等大事。或许你认为“让代码能工作”才是专业开发者的头等大事。然而,我希望本书能让你抛掉那种想法。你今天编写的功能,极有可能在下一版本中被修改,但代码的可读性却会对以后可能发生的修改行为产生深远影响。原始代码修改之后很久,其代码风格和可读性仍会影响到可维护性和扩展性。即便代码已不复存在,你的风格和律条仍存活下来。
2. 垂直格式
a.向报纸学习
源文件也要像报纸文章那样。名称应当简单且一目了然。名称本身应该足够告诉我们是否在正确的模块中。源文件最顶部应该给出高层次概念和算法。细节应该往下渐次展开,直至找到源文件中最底层的函数和细节。报纸由许多篇文章组成;多数短小精悍。有些稍微长点儿。很少有占满一整页的。这样做,报纸才可用。假若一份报纸只登载一篇长故事,其中充斥毫无组织的事实、日期、名字等,没人会去读它。
b.概念间垂直方向上的区隔
在封包声明、导入声明和每个函数之间,都有空白行隔开。这条极其简单的规则极大地影响到代码的视觉外观。每个空白行都是一条线索,标识出新的独立概念。往下读代码时,你的目光总会停留于空白行之后那一行。
c.垂直方向上的靠近
如果说空白行隔开了概念,靠近的代码行则暗示了它们之间的紧密关系。所以,紧密相关的代码应该互相靠近。
d.垂直距离
关系密切的概念应该互相靠近[G10]。显然,这条规则并不适用于分布在不同文件中的概念。除非有很好的理由,否则就不要把关系密切的概念放到不同的文件中。实际上,这也是避免使用protected变量的理由之一。
对于那些关系密切、放置于同一源文件中的概念,它们之间的区隔应该成为对相互的易懂度有多重要的衡量标准。应避免迫使读者在源文件和类中跳来跳去。变量声明。变量声明应尽可能靠近其使用位置。因为函数很短,本地变量应该在函数的顶部出现。偶尔,在较长的函数中,变量也可能在某个代码块顶部,或在循环之前声明。你可以在以下摘自TestNG中一个长函数的代码片段中找到类似的变量。
实体变量应该在类的顶部声明。这应该不会增加变量的垂直距离,因为在设计良好的类中,它们如果不是被该类的所有方法也是被大多数方法所用。关于实体变量应该放在哪里,争论不断。在C++中,通常会采用所谓“剪刀原则”(scissors rule),所有实体变量都放在底部。而在Java中,惯例是放在类的顶部。没理由去遵循其他惯例。重点是在谁都知道的地方声明实体变量。大家都应该知道在哪儿能看到这些声明。
相关函数。若某个函数调用了另外一个,就应该把它们放到一起,而且调用者应该尽可能放在被调用者上面。这样,程序就有个自然的顺序。若坚定地遵循这条约定,读者将能够确信函数声明总会在其调用后很快出现。
概念相关的代码应该放到一起。相关性越强,彼此之间的距离就该越短。如上所述,相关性应建立在直接依赖的基础上,如函数间调用,或函数使用某个变量。但也有其他相关性的可能。相关性可能来自于执行相似操作的一组函数。
e.垂直顺序
像报纸文章一般,我们指望最重要的概念先出来,指望以包括最少细节的方式表述它们。我们指望底层细节最后出来。这样,我们就能扫过源代码文件,自最前面的几个函数获知要旨,而不至于沉溺到细节中。
3. 横向格式
a.水平方向上的区隔和靠近
我们使用空格字符将彼此紧密相关的事物连接到一起,也用空格字符把相关性较弱的事物分隔开。在赋值操作符周围加上空格字符,以此达到强调目的。赋值语句有两个确定而重要的要素:左边和右边。空格字符加强了分隔效果。
在赋值操作符周围加上空格字符,以此达到强调目的。赋值语句有两个确定而重要的要素:左边和右边。空格字符加强了分隔效果。乘法因子之间没加空格,因为它们具有较高优先级。加减法运算项之间用空格隔开,因为加法和减法优先级较低。
b.缩进
源文件是一种继承结构,而不是一种大纲结构。其中的信息涉及整个文件、文件中每个类、类中的方法、方法中的代码块,也涉及代码块中的代码块。这种继承结构中的每一层级都圈出一个范围,名称可以在其中声明,而声明和执行语句也可以在其中解释。要让这种范围式继承结构可见,我们依源代码行在继承结构中的位置对源代码行做缩进处理。
c.空范围
如果无法避免while或for语句的空语句体,就确保空范围体的缩进,用括号包围起来。
4. 团队规则
一组开发者应当认同一种格式风格,每个成员都应该采用那种风格。我们想要让软件拥有一以贯之的风格。我们不想让它显得是由一大票意见相左的个人所写成。好的软件系统是由一系列读起来不错的代码文件组成的。它们需要拥有一致和顺畅的风格。读者要能确信,他们在一个源文件中看到的格式风格在其他文件中也是同样的用法。绝对不要用各种不同的风格来编写源代码,这样会增加其复杂度。
第6章 对象和数据结构
1. 数据抽象
隐藏实现关乎抽象,要以最好的方式呈现某个对象包含的数据,需要做严肃的思考。
2. 数据、对象的反对称性
数据结构暴露其数据,没提供有意义的函数;对象暴露操作数据的函数,把数据隐藏在抽象之后。这种差异看似微小,却有深远的含义。
在过程式形状的代码的类中,添加一个新形状,所有的函数都要做修改来处理它,而添加函数,原有的形状不会受影响;在多态式形状中,添加一个函数,所有的形状都要做修改,而添加新形状,现有函数不会受影响。
这说明了对象和数据结构的二分原理:过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新函数。面向对象代码便于在不改动既有函数的前提下添加新类。
在任何一个复杂系统中,都会有需要添加新数据类型而不是新函数的时候。这时,对象和面向对象就比较适合。另一方面,也会有想要添加新函数而不是数据类型的时候。在这种情况下,过程式代码和数据结构更合适。
3. The Law of Demeter
模块不应了解它所操作对象的内部情形。如上节所见,对象隐藏数据,曝露操作。这意味着对象不应通过存取器曝露其内部结构,因为这样更像是曝露而非隐藏其内部结构。
更准确地说,得墨忒耳律认为,类C
的方法f
只应该调用以下对象的方法:C
由f
创建的对象;作为参数传递给f
的对象;由C
的实体变量持有的对象。方法不应调用由任何函数返回的对象的方法。换言之,只跟朋友谈话,不与陌生人谈话。
a.火车失事
代码是否违反得墨忒耳律,取决于ctxt
、Options
和ScratchDir
是对象还是数据结构。如果是对象,则它们的内部结构应当隐藏而不曝露,而有关其内部细节的知识就明显违反了得墨忒耳律。如果ctxt
、Options
和ScratchDir
只是数据结构,没有任何行为,则它们自然会曝露其内部结构,得墨忒耳律也就不适用了。属性访问器函数的使用把问题搞复杂了。如果像下面这样写代码,我们大概就不会提及对得墨忒耳律的违反。
final String outputDir = ctxt.options.scratchDir.absolutePath;
如果数据结构只简单地拥有公共变量,没有函数,而对象则拥有私有变量和公共函数,这个问题就不那么混淆。然而,有些框架和标准甚至要求最简单的数据结构都要有访问器和改值器。
b.混杂
对象和数据结构的混杂增加了添加新函数的难度,也增加了添加新数据结构的难度,两面不讨好。应避免创造这种结构。
c.隐藏结构
通过对象的函数去实现功能,而不要用函数暴露内部结构再在对象的外部实现功能。
d.数据传送对象
不要往这类数据结构中塞进业务规则方法,把这类数据结构当成对象来用。这是不智的行为,因为它导致了数据结构和对象的混杂体。
第7章 错误处理
错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。
1. 使用异常而非返回码
使用错误返回码的问题在于,调用者必须在调用之后即刻检查错误。不幸的是,这个步骤很容易被遗忘。所以,遇到错误时,最好抛出一个异常。调用代码很整洁,其逻辑不会被错误处理搞乱。
2. 先写Try-Catch-Finally语句
异常的妙处之一是,它们在程序中定义了一个范围。执行try-catch-finally语句中try部分的代码时,你是在表明可随时取消执行,并在catch语句中接续。在某种意义上,try 代码块就像是事务。catch 代码块将程序维持在一种持续状态,无论 try代码块中发生了什么均如此。所以,在编写可能抛出异常的代码时,最好先写出try-catch-finally语句。这能帮你定义代码的用户应该期待什么,无论try代码块中执行的代码出什么错都一样。
尝试编写强行抛出异常的测试,再往处理器中添加行为,使之满足测试要求。结果就是你要先构造try代码块的事务范围,而且也会帮助你维护好该范围的事务特征。
3. 使用不可控异常
可控异常的代价就是违反开放/闭合原则。如果你在方法中抛出可控异常,而catch语句在三个层级之上,你就得在catch语句和抛出异常处之间的每个方法签名中声明该异常。这意味着对软件中较低层级的修改,都将波及较高层级的签名。修改好的模块必须重新构建、发布,即便它们自身所关注的任何东西都没改动过。
以某个大型系统的调用层级为例。顶端函数调用它们之下的函数,逐级向下。假设某个位于最底层级的函数被修改为抛出一个异常。如果该异常是可控的,则函数签名就要添加throw 子句。这意味着每个调用该函数的函数都要修改,捕获新异常,或在其签名中添加合适的throw子句。以此类推。最终得到的就是一个从软件最底端贯穿到最高端的修改链!封装被打破了,因为在抛出路径中的每个函数都要去了解下一层级的异常细节。既然异常旨在让你能在较远处处理错误,可控异常以这种方式破坏封装简直就是一种耻辱。
如果你在编写一套关键代码库,则可控异常有时也会有用:你必须捕获异常。但对于一般的应用开发,其依赖成本要高于收益。
4. 给出异常发生的环境说明
应创建信息充分的错误消息,并和异常一起传递出去。在消息中,包括失败的操作和失败类型。如果你的应用程序有日志系统,传递足够的信息给catch块,并记录下来。
5. 依调用者需要定义异常类
在应用程序中定义异常类时,最重要的考虑应该是它们如何被捕获。
LocalPort
类就是个简单的打包类,捕获并翻译由ACMEPort
类抛出的异常:
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
…
}
public class LocalPort {
private ACMEPort innerPort;
public LocalPort(int portNumber) {
innerPort = new ACMEPort(portNumber);
}
public void open() {
try {
innerPort.open();
} catch (DeviceResponseException e) {
throw new PortDeviceFailure(e);
} catch (ATM1212UnlockedException e) {
throw new PortDeviceFailure(e);
} catch (GMXError e) {
throw new PortDeviceFailure(e);
}
}
…
}
类似我们为ACMEPort
定义的这种打包类非常有用。实际上,将第三方API打包是个良好的实践手段。当你打包一个第三方API,你就降低了对它的依赖:未来你可以不太痛苦地改用其他代码库。在你测试自己的代码时,打包也有助于模拟第三方调用。打包的好处还在于你不必绑死在某个特定厂商的API 设计上。你可以定义自己感觉舒服的API。在上例中,我们为port
设备错误定义了一个异常类型,然后发现这样能写出更整洁的代码。对于代码的某个特定区域,单一异常类通常可行。伴随异常发送出来的信息能够区分不同错误。如果你想要捕获某个异常,并且放过其他异常,就使用不同的异常类。
6. 定义常规流程
如果你遵循前文提及的建议,在业务逻辑和错误处理代码之间就会有良好的区隔。大量代码会开始变得像是整洁而简朴的算法。然而,这样做却把错误检测推到了程序的边缘地带。你打包了外部API以抛出自己的异常,你在代码的顶端定义了一个处理器来应付任何失败了的运算。在大多数时候,这种手段很棒,不过有时你也许不愿这么做。
public class PerDiemMealExpenses implements MealExpenses {
public int getTotal() {
// return the per diem default
}
}
这种手法叫做特例模式(SPECIAL CASE PATTERN,[Fowler])。创建一个类或配置一个对象,用来处理特例。你来处理特例,客户代码就不用应付异常行为了。异常行为被封装到特例对象中。
7. 别返回null值
要讨论错误处理,就一定要提及那些容易引发错误的做法。第一项就是返回null值。我不想去计算曾经见过多少几乎每行代码都在检查null值的应用程序。返回null值,基本上是在给自己增加工作量,也是在给调用者添乱。只要有一处没检查null值,应用程序就会失控。
如果你打算在方法中返回null值,不如抛出异常,或是返回特例对象。如果你在调用某个第三方API中可能返回null值的方法,可以考虑用新方法打包这个方法,在新方法中抛出异常或返回特例对象。
现在,getExployees
可能返回null,但是否一定要这么做呢?如果修改getEmployee
,返回空列表,就能使代码整洁起来:
List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}
所幸Java有Collections.emptyList( )
方法,该方法返回一个预定义不可变列表,可用于这种目的。
8. 别传递null值
在方法中返回null值是糟糕的做法,但将null值传递给其他方法就更糟糕了。除非API要求你向它传递null值,否则就要尽可能避免传递null值。
在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。事已如此,恰当的做法就是禁止传入null值。这样,你在编码的时候,就会时时记住参数列表中的null值意味着出问题了,从而大量避免这种无心之失。
9.小结
可读与强固并不冲突。如果将错误处理隔离看待,独立于主要逻辑之外,就能写出强固而整洁的代码。做到这一步,我们就能单独处理它,也极大地提升了代码的可维护性。
第8章 边界
我们很少控制系统中的全部软件。有时我们购买第三方程序包或使用开放源代码,有时我们依靠公司中其他团队打造组件或子系统。不管是哪种情况,我们都得将外来代码干净利落地整合进自己的代码中。本章将介绍一些保持软件边界整洁的实践手段和技巧。
1. 使用第三方代码
建议不要将Map(或在边界上的其他接口)在系统中传递。如果你使用类似Map这样的边界接口,就把它保留在类或近亲类中。避免从公共API中返回边界接口,或将边界接口作为参数传递给公共API。
2. 浏览和学习边界
学习第三方代码很难。整合第三方代码也很难。同时做这两件事难上加难。如果我们采用不同的做法呢?不要在生产代码中试验新东西,而是编写测试来遍览和理解第三方代码。Jim Newkirk把这叫做学习性测试(learning tests)。在学习性测试中,我们如在应用中那样调用第三方代码。我们基本上是在通过核对试验来检测自己对那个API的理解程度。测试聚焦于我们想从API得到的东西。
3. 学习性测试的好处不只是免费
学习性测试不光免费,还在投资上有正面的回报。当第三方程序包发布了新版本,我们可以运行学习性测试,看看程序包的行为有没有改变。
学习性测试确保第三方程序包按照我们想要的方式工作。一旦整合进来,就不能保证第三方代码总与我们的需要兼容。原作者不得不修改代码来满足他们自己的新需要。他们会修正缺陷、添加新功能。风险伴随新版本而来。如果第三方程序包的修改与测试不兼容,我们也能马上发现。
无论你是否需要通过学习性测试来学习,总要有一系列与生产代码中调用方式一致的输出测试来支持整洁的边界。不使用这些边界测试来减轻迁移的劳力,我们可能会超出应有时限,长久地绑在旧版本上面。
3. 使用尚不存在的代码
还有另一种边界,那种将已知和未知分隔开的边界。在代码中总有许多地方是我们的知识未及之处。有时,边界那边就是未知的(至少目前未知)。有时,我们并不往边界那边看过去。编写我们想得到的接口,好处之一是它在我们控制之下。这有助于保持客户代码更可读,且集中于它该完成的工作。
一旦发送器API被定义出来,我们就编写TransmitterAdapter
来跨接。ADAPTER
封装了与API的互动,也提供了一个当API发生变动时唯一需要改动的地方。
4. 整洁的边界
边界上会发生有趣的事。改动是其中之一。有良好的软件设计,无需巨大投入和重写即可进行修改。在使用我们控制不了的代码时,必须加倍小心保护投资,确保未来的修改不至于代价太大。
边界上的代码需要清晰的分割和定义了期望的测试。应该避免我们的代码过多地了解第三方代码中的特定信息。依靠你能控制的东西,好过依靠你控制不了的东西,免得日后受它控制。
我们通过代码中少数几处引用第三方边界接口的位置来管理第三方边界。可以像我们对待Map
那样包装它们,也可以使用ADAPTER
模式将我们的接口转换为第三方提供的接口。采用这两种方式,代码都能更好地与我们沟通,在边界两边推动内部一致的用法,当第三方代码有改动时修改点也会更少。
第9章 单元测试
1. TDD三定律
定律一 在编写不能通过的单元测试前,不可编写生产代码。
定律二 只可编写刚好无法通过的单元测试,不能编译也算不通过。
定律三 只可编写刚好足以通过当前失败测试的生产代码。
这三条定律将你限制在大概30秒一个的循环中。测试与生产代码一起编写,测试只比生产代码早写几秒钟。这样写程序,我们每天就会编写数十个测试,每个月编写数百个测试,每年编写数千个测试。这样写程序,测试将覆盖所有生产代码。测试代码量足以匹敌生产代码量,导致令人生畏的管理问题。
2. 保持测试整洁
脏测试等同于——如果不是坏于的话——没测试。问题在于,测试必须随生产代码的演进而修改。测试越脏,就越难修改。测试代码越缠结,你就越有可能花更多时间塞进新测试,而不是编写新生产代码。修改生产代码后,旧测试就会开始失败,而测试代码中乱七八糟的东西将阻碍代码再次通过。于是,测试变得就像是不断翻番的债务。
如果测试不能保持整洁,你就会失去它们。没有了测试,你就会失去保证生产代码可扩展的一切要素。覆盖了生产代码的自动化单元测试程序组能尽可能地保持设计和架构的整洁。测试带来了一切好处,因为测试使改动变得可能。
3. 整洁的测试
整洁的测试有什么要素?有三个要素:可读性,可读性和可读性。在单元测试中,可读性甚至比在生产代码中还重要。测试如何才能做到可读?和其他代码中一样:明确,简洁,还有足够的表达力。在测试中,你要以尽可能少的文字表达大量内容。
代码清单9-2 SerializedPageResponderTest.java(重构后)
public void testGetPageHierarchyAsXml() throws Exception {
makePages("PageOne", "PageOne.ChildOne","PageTwo");
submitRequest("root", "type:pages");
assertResponseIsXML();
assertResponseContains(
"<name>PageOne</name>",
"<name>PageTwo</name>",
"<name>ChildOne</name>"
);
}
public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception {
WikiPage page = makePage("PageOne");
makePages("PageOne.ChildOne", "PageTwo");
addLinkTo(page, "PageTwo", "SymPage");
submitRequest("root", "type:pages");
assertResponseIsXML();
assertResponseContains(
"<name>PageOne</name>",
"<name>PageTwo</name>",
"<name>ChildOne</name>"
);
assertResponseDoesNotContain("SymPage");
}
public void testGetDataAsXml() throws Exception {
makePageWithContent("TestPageOne", "test page");
submitRequest(
"TestPageOne",
"type:data"
);
assertResponseIsXML();
assertResponseContains("test page", "<Test");
}
这些测试显然呈现了构造-操作-检验(BUILD-OPERATE-CHECK)[3]模式。每个测试都清晰地拆分为三个环节。第一个环节构造测试数据,第二个环节操作测试数据,第三个部分检验操作是否得到期望的结果。注意,那些恼人的细节大部分消失了。测试直达目的,只用到那些真正需要的数据类型和函数。读测试的人应该都能够很快搞清楚状况,不至于被细节误导或吓倒。
a. 面向特定领域的测试语言
代码清单9-2中的测试展示了为测试构造一种面向特定领域的语言的技巧。我们没有直接使用程序员用来对系统进行操作的API,而是打造了一套包装这些API的函数和工具代码,这样就能更方便地编写测试,写出来的测试也更便于阅读。那正是一种测试语言,可以帮助程序员编写自己的测试,也可以帮助后来者阅读测试。
这种测试API并非起初就设计出来,而是在对那些充满令人迷惑细节的测试代码进行后续重构时逐渐演进。如同你看见我将代码清单9-1重构为代码清单9-2一般,守规矩的开发者也将他们的测试代码重构为更简洁和具有表达力的形式。
b. 双重标准
测试代码应当简单、精悍、足具表达力,但它该和生产代码一般有效。毕竟它是在测试环境而非生产环境中运行,这两种环境有着截然不同的需求。
代码清单9-4 EnvironmentControllerTest.java(重构后)
@Testpublic void turnOnLoTempAlarmAtThreshold() throws Exception {
wayTooCold();
assertEquals("HBchL", hw.getState());
}
assertEquals
中的那个奇怪的字符串,大写表示“打开”,小写表示“关闭”,那些字符遵循以下次序:{heater, blower, cooler, hi-temp-alarm, lo-temp-alarm}。尽管这破坏了思维映射[4]的规则,看来它在这种情况下还是适用的。只要你明白其含义,你就能一眼看到那个字符串,迅速译解出结果。
代码清单9-6 MockControlHardware.java
public String getState() {
String state = "";
state += heater ? "H" : "h";
state += blower ? "B" : "b";
state += cooler ? "C" : "c";
state += hiTempAlarm ? "H" : "h";
state += loTempAlarm ? "L" : "l";
return state;
}
StringBuffer
有点丑陋。即便在生产代码中,假使代价较小,我都会避免使用StringBuffer
;而且你可以看到,清单9-6中代码的代价的确很小。这套应用显然是嵌入式实时系统,计算机和内存资源都很有限。不过,测试环境大概完全不必做限制。这就是双重标准。有些事你大概永远不会在生产环境中做,而在测试环境中做却完全没问题。通常这关乎内存或CPU效率的问题,不过却永远不会与整洁有关。
4. 每个测试一个概念
每个测试函数中只测试一个概念。我们不想要超长的测试函数,测试完这个又测试那个。
5. F.I.R.S.T.
快速(Fast)
测试应该够快。测试应该能快速运行。测试运行缓慢,你就不会想要频繁地运行它。如果你不频繁运行测试,就不能尽早发现问题,也无法轻易修正,从而也不能轻而易举地清理代码。最终,代码就会腐坏。
独立(Independent)
测试应该相互独立。某个测试不应为下一个测试设定条件。你应该可以单独运行每个测试,及以任何顺序运行测试。当测试互相依赖时,头一个没通过就会导致一连串的测试失败,使问题诊断变得困难,隐藏了下级错误。
可重复(Repeatable)
测试应当可在任何环境中重复通过。你应该能够在生产环境、质检环境中运行测试,也能够在无网络的列车上用笔记本电脑运行测试。如果测试不能在任意环境中重复,你就总会有个解释其失败的接口。当环境条件不具备时,你也会无法运行测试。
自足验证(Self-Validating)
测试应该有布尔值输出。无论是通过或失败,你不应该查看日志文件来确认测试是否通过。你不应该手工对比两个不同文本文件来确认测试是否通过。如果测试不能自足验证,对失败的判断就会变得依赖主观,而运行测试也需要更长的手工操作时间。
及时(Timely)
测试应及时编写。单元测试应该恰好在使其通过的生产代码之前编写。如果在编写生产代码之后编写测试,你会发现生产代码难以测试。你可能会认为某些生产代码本身难以测试。你可能不会去设计可测试的代码。
第10章 类
1. 类的组织
遵循标准的Java约定,类应该从一组变量列表开始。如果有公共静态常量,应该先出现。然后是私有静态变量,以及私有实体变量。很少会有公共变量。
公共函数应跟在变量列表之后。我们喜欢把由某个公共函数调用的私有工具函数紧随在该公共函数后面。这符合了自顶向下原则,让程序读起来就像一篇报纸文章。
我们喜欢保持变量和工具函数的私有性,但并不执着于此。有时,我们也需要用到受护(protected)变量或工具函数,好让测试可以访问到。对我们来说,测试说了算。若同一程序包内的某个测试需要调用一个函数或变量,我们就会将该函数或变量置为受护或在整个程序包内可访问。然而,我们首先会想办法使之保有隐私。放松封装总是下策。
2. 类应该短小
类的名称应当描述其权责。实际上,命名正是帮助判断类的长度的第一个手段。如果无法为某个类命以精确的名称,这个类大概就太长了。类名越含混,该类越有可能拥有过多权责。例如,如果类名中包括含义模糊的词,如Processor
或Manager
或Super
,这种现象往往说明有不恰当的权责聚集情况存在。
我们也应该能够用大概25个单词简要描述一个类,且不用“若(if)”、“与(and)”、“或(or)”或者“但(but)”等词汇。
a. 单一权责原则
单一权责原则(SRP)认为,类或模块应有且只有一条加以修改的理由。该原则既给出了权责的定义,又是关于类的长度的指导方针。类只应有一个权责——只有一条修改的理由。
鉴别权责(修改的理由)常常帮助我们在代码中认识到并创建出更好的抽象。SRP是OO设计中最为重要的概念之一,也是较为容易理解和遵循的概念之一。奇怪的是SRP往往也是最容易被破坏的类设计原则。经常会遇到做太多事的类。为什么呢?
让软件能工作和让软件保持整洁,是两种截然不同的工作。我们中的大多数人脑力有限,只能更多地把精力放在让代码能工作上,而不是放在保持代码有组织和整洁上。这全然正确。分而治之,其在编程行为中的重要程度等同于在程序中的重要程度。
问题是太多人在程序能工作时就以为万事大吉了。我们没能把思维转向有关代码组织和整洁的部分。我们直接转向下一个问题,而不是回头将臃肿的类切分为只有单一权责的去耦式单元。与此同时,许多开发者害怕数量巨大的短小单一目的类会导致难以一目了然抓住全局。他们认为,要搞清楚一件较大工作如何完成,就得在类与类之间找来找去。
每个达到一定规模的系统都会包括大量逻辑和复杂性。管理这种复杂性的首要目标就是加以组织,以便开发者知道到哪儿能找到东西,并且在某个特定时间只需要理解直接有关的复杂性。反之,拥有巨大、多目的类的系统,总是让我们在目前并不需要了解的一大堆东西中艰难跋涉。
再强调一下:系统应该由许多短小的类而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为。
b. 内聚
类应该只有少量实体变量。类中的每个方法都应该操作一个或多个这种变量。通常而言,方法操作的变量越多,就越黏聚到类上。如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。
一般来说,创建这种极大化内聚类是既不可取也不可能的;另一方面,我们希望内聚性保持在较高位置。内聚性高,意味着类中的方法和变量互相依赖、互相结合成一个逻辑整体。
保持函数和参数列表短小的策略,有时会导致为一组子集方法所用的实体变量数量增加。出现这种情况时,往往意味着至少有一个类要从大类中挣扎出来。你应当尝试将这些变量和方法分拆到两个或多个类中,让新的类更为内聚。
c. 保持内聚性就会得到许多短小的类
当类丧失了内聚性,就拆分它!所以,将大函数拆为许多小函数,往往也是将类拆分为多个小类的时机。程序会更加有组织,也会拥有更为透明的结构。
3. 为了修改而组织
出现了只与类的一小部分有关的私有方法行为,意味着存在改进空间。然而,展开行动的基本动因却应该是系统的变动。若我们认为Sql
类在逻辑上已具足,则无需担心对权责的拆分。如果在可预见的未来无需增加update功能,就该不去动Sql
类。不过,一旦打开了类,就应当修正设计方案。
代码清单10-10 一组封闭类
abstract public class Sql {
public Sql(String table, Column[] columns)
abstract public String generate();
}
public class CreateSql extends Sql {
public CreateSql(String table, Column[] columns)
@Override public String generate()
}
public class SelectSql extends Sql {
public SelectSql(String table, Column[] columns)
@Override public String generate()
}
public class InsertSql extends Sql {
public InsertSql(String table, Column[] columns,Object[] fields)
@Override public String generate()
private String valuesList(Object[] fields, final Column[]columns)
}
public class SelectWithCriteriaSql extends Sql {
public SelectWithCriteriaSql(String table, Column[] columns,
Criteria criteria)
@Override public String generate()
}
public class SelectWithMatchSql extends Sql {
public SelectWithMatchSql(String table, Column[] columns,
Column column,String pattern)
@Override public String generate()
}
public class FindByKeySql extends Sql{
public FindByKeySql(String table, Column[] columns, String keyColumn,
String keyValue)
@Override public String generate()
}
public class PreparedInsertSql extends Sql {
public PreparedInsertSql(String table, Column[]columns)
@Override public String generate(){
private String placeholderList(Column[] columns)
}
public class Where {
public Where(String criteria)
public String generate()
}
public class ColumnList {
public ColumnList(Column[] columns)
public String generate()
}
每个类中的代码都变得极为简单。理解每个类花费的时间缩减到近乎为零。函数对其他函数造成毁坏的风险也变得几近于无。从测试的角度看,验证方案中每一处逻辑都成了极为简单的任务,因为类与类之间相互隔离了。
当需要增加update语句时,现存类无需做任何修改,这也同等重要!我们在Sql
类的新子类UpdateSql
中构建update语句的逻辑。系统中的其他代码都不会因为这个修改而被破坏。
重新架构的Sql
逻辑百利而无一弊。它支持SRP。它也支持其他面向对象设计的关键原则,如开放-闭合原则(OCP):类应当对扩展开放,对修改封闭。通过子类化手段,重新架构的Sql
类对添加新功能是开放的,而且可以同时不触及其他类。只要将 UpdateSql
类放置到位就行了。
我们希望将系统打造成在添加或修改特性时尽可能少惹麻烦的架子。在理想系统中,我们通过扩展系统而非修改现有代码来添加新特性。
需求会改变,所以代码也会改变。在OO 101中,我们学习到,具体类包含实现细节(代码),而抽象类则只呈现概念。依赖于具体细节的客户类,当细节改变时,就会有风险。我们可以借助接口和抽象类来隔离这些细节带来的影响。
部件之间的解耦代表着系统中的元素互相隔离得很好。隔离也让对系统每个元素的理解变得更加容易。通过降低连接度,我们的类就遵循了另一条类设计原则,依赖倒置原则(Dependency Inversion Principle,DIP)。本质而言,DIP认为类应当依赖于抽象而不是依赖于具体细节。
第11章 系统
1. 如何建造一个城市
城市能运转,还因为它演化出恰当的抽象等级和模块,好让个人和他们所管理的“组件”即便在不了解全局时也能有效地运转。尽管软件团队往往也是这样组织起来,但他们所致力的工作却常常没有同样的关注面切分及抽象层级。整洁的代码帮助我们在较低层的抽象层级上达成这一目标。本章将讨论如何在较高的抽象层级——系统层级——上保持整洁。
2. 将系统的构造与使用分开
软件系统应将启始过程和启始过程之后的运行时逻辑分离开,在启始过程中构建应用对象,也会存在互相缠结的依赖关系。每个应用程序都该留意启始过程。那也是本章中我们首先要考虑的问题。将关注的方面分离开,是软件技艺中最古老也最重要的设计技巧。不幸的是,多数应用程序都没有做分离处理。启始过程代码很特殊,被混杂到运行时逻辑中。
仅出现一次的延迟初始化不算是严重问题。不过,在应用程序中往往有许多种类似的情况出现。于是,全局设置策略(如果有的话)在应用程序中四散分布,缺乏模块组织性,通常也会有许多重复代码。
如果我们勤于打造有着良好格式并且强固的系统,就不该让这类就手小技巧破坏模块组织性。对象构造的启始和设置过程也不例外。应当将这个过程从正常的运行时逻辑中分离出来,确保拥有解决主要依赖问题的全局性一贯策略。
a. 分解main
将构造与使用分开的方法之一是将全部构造过程搬迁到main
或被称之为main
的模块中,设计系统的其余部分时,假设所有对象都已正确构造和设置(如图11-1所示)。
b. 工厂
有时应用程序也要负责确定何时创建对象。比如,在某个订单处理系统中,应用程序必须创建LineItem
实体,添加到Order对象。在这种情况下,我们可以使用抽象工厂模式[2]让应用自行控制何时创建LineItems
,但构造的细节却隔离于应用程序代码之外。
所有依赖都是从 main
指向OrderProcessing
应用程序。这代表应用程序与如何构建LineItem
的细节是分离开来的。构建能力由LineItemFactoryImplementation
持有,而LineItemFactoryImplementation
又是在main
这一边的。但应用程序能完全控制LineItem
实体何时构建,甚至能传递应用特定的构造器参数。
c. 依赖注入
有一种强大的机制可以实现分离构造与使用,那就是依赖注入(Dependency Injection,DI),控制反转(Inversion of Control,IoC)在依赖管理中的一种应用手段[3]。控制反转将第二权责从对象中拿出来,转移到另一个专注于此的对象中,从而遵循了单一权责原则。在依赖管理情景中,对象不应负责实体化对自身的依赖。反之,它应当将这份权责移交给其他“有权力”的机制,从而实现控制的反转。因为初始设置是一种全局问题,这种授权机制通常要么是main例程,要么是有特定目的的容器。
3. 扩容
“一开始就做对系统”纯属神话。反之,我们应该只去实现今天的用户故事,然后重构,明天再扩展系统、实现新的用户故事。这就是迭代和增量敏捷的精髓所在。测试驱动开发、重构以及它们打造出的整洁代码,在代码层面保证了这个过程的实现。
但在系统层面又如何?难道系统架构不需要预先做好计划吗?系统理所当然不可能从简单递增到复杂,它能行吗?
软件系统与物理系统可以类比。它们的架构都可以递增式地增长,只要我们持续将关注面恰当地切分。如我们将见到的那样,软件系统短生命周期本质使这一切变得可行。
4. Java代理
5. 纯Java AOP框架
6. AspectJ的方面
通过方面来实现关注面切分的功能最全的工具是AspectJ语言,一种提供“一流的”将方面作为模块构造处理支持的Java扩展。
7. 测试驱动系统架构
在没能真正得到使用时,设计得再好的API也等于是杀鸡用牛刀。优秀的API在大多数时间都该在视线之外,这样团队才能将创造力集中在要实现的用户故事上。否则,架构上的约束就会妨碍向客户交付优化价值的软件。
概言之,最佳的系统架构由模块化的关注面领域组成,每个关注面均用纯Java(或其他语言)对象实现。不同的领域之间用最不具有侵害性的方面或类方面工具整合起来。这种架构能测试驱动,就像代码一样。
8. 优化决策
延迟决策至最后一刻也是好手段。这不是懒惰或不负责;它让我们能够基于最有可能的信息做出选择。提前决策是一种预备知识不足的决策。如果决策太早,就会缺少太多客户反馈、关于项目的思考和实施经验。拥有模块化关注面的POJO系统提供的敏捷能力,允许我们基于最新的知识做出优化的、时机刚好的决策。决策的复杂性也降低了。
9. 明智使用添加了可论证价值的标准
有了标准,就更易复用想法和组件、雇用拥有相关经验的人才、封装好点子,以及将组件连接起来。不过,创立标准的过程有时却漫长到行业等不及的程度,有些标准没能与它要服务的采用者的真实需求相结合。
10. 系统需要领域特定语言
在软件领域,领域特定语言(Domain-Specific Language,DSL)最近重受关注。DSL是一种单独的小型脚本语言或以标准语言写就的API,领域专家可以用它编写读起来像是组织严谨的散文一般的代码。优秀的DSL填平了领域概念和实现领域概念的代码之间的“壕沟”,就像敏捷实践优化了开发团队和甲方之间的沟通一样。如果你用与领域专家使用的同一种语言来实现领域逻辑,就会降低不正确地将领域翻译为实现的风险。
DSL在有效使用时能提升代码惯用法和设计模式之上的抽象层次。它允许开发者在恰当的抽象层级上直指代码的初衷。
领域特定语言允许所有抽象层级和应用程序中的所有领域,从高级策略到底层细节,使用POJO来表达。
11. 小结
在所有的抽象层级上,意图都应该清晰可辨。只有在编写POJO并使用类方面的机制来无损地组合其他关注面时,这种事情才会发生。无论是设计系统或单独的模块,别忘了使用大概可工作的最简单方案。
第12章 迭进
1. 通过迭进设计达到整洁目的
据Kent所述,只要遵循以下规则,设计就能变得“简单”:
- 运行所有测试;
- 不可重复;
- 表达了程序员的意图;
- 尽可能减少类和方法的数量;
以上规则按其重要程度排列。
2. 简单设计规则1:运行所有测试
设计必须制造出如预期一般工作的系统,这是首要因素。系统也许有一套绝佳设计,但如果缺乏验证系统是否真按预期那样工作的简单方法,那就无异于纸上谈兵。
全面测试并持续通过所有测试的系统,就是可测试的系统。看似浅显,但却重要。不可测试的系统同样不可验证。不可验证的系统,绝不应部署。
幸运的是,只要系统可测试,就会导向保持类短小且目的单一的设计方案。遵循SRP的类,测试起来较为简单。测试编写得越多,就越能持续走向编写较易测试的代码。所以,确保系统完全可测试能帮助我们创建更好的设计。
紧耦合的代码难以编写测试。同样,编写测试越多,就越会遵循DIP之类规则,使用依赖注入、接口和抽象等工具尽可能减少耦合。如此一来,设计就有长足进步。
遵循有关编写测试并持续运行测试的简单、明确的规则,系统就会更贴近OO低耦合度、高内聚度的目标。编写测试引致更好的设计。
3. 简单设计规则2~4:重构
有了测试,就能保持代码和类的整洁,方法就是递增式地重构代码。添加了几行代码后,就要暂停,琢磨一下变化了的设计。设计退步了吗?如果是,就要清理它,并且运行测试,保证没有破坏任何东西。测试消除了对清理代码就会破坏代码的恐惧。
在重构过程中,可以应用有关优秀软件设计的一切知识。提升内聚性,降低耦合度,切分关注面,模块化系统性关注面,缩小函数和类的尺寸,选用更好的名称,如此等等。这也是应用简单设计后三条规则的地方:消除重复,保证表达力,尽可能减少类和方法的数量。
4. 不可重复
重复是拥有良好设计系统的大敌。它代表着额外的工作、额外的风险和额外且不必要的复杂度。重复有多种表现。极其雷同的代码行当然是重复。类似的代码往往可以调整得更相似,这样就能更容易地进行重构。重复也有实现上的重复等其他一些形态。
要想创建整洁的系统,需要有消除重复的意愿,即便对于短短几行也是如此。小规模复用”可大量降低系统复杂性。要想实现大规模复用,必须理解如何实现小规模复用。
可以通过应用模板方法模式来消除明显的重复。
abstract public class VacationPolicy {
public void accrueVacation() {
calculateBaseVacationHours();
alterForLegalMinimums();
applyToPayroll();
}
private void calculateBaseVacationHours() { /* ... */ };
abstract protected void alterForLegalMinimums();
private void applyToPayroll() { /* ... */ };
}
public class USVacationPolicy extends VacationPolicy {
@Override protected void alterForLegalMinimums() {
// US specific logic
}
}
public class EUVacationPolicy extends VacationPolicy {
@Override protected void alterForLegalMinimums() {
// EU specific logic
}
}
子类填充了accrueVacation
算法中的“空洞”,提供不重复的信息。
5. 表达力
写出自己能理解的代码很容易,因为在写这些代码时,我们正深入于要解决的问题中。代码的其他维护者不会那么深入,也就不易理解代码。软件项目的主要成本在于长期维护。为了在修改时尽量降低出现缺陷的可能性,很有必要理解系统是做什么的。当系统变得越来越复杂,开发者就需要越来越多的时间来理解它,而且也极有可能误解。所以,代码应当清晰地表达其作者的意图。作者把代码写得越清晰,其他人花在理解代码上的时间也就越少,从而减少缺陷,缩减维护成本。
可以通过选用好名称来表达。我们想要听到好类名和好函数名,而且在查看其权责时不会大吃一惊。
也可以通过保持函数和类尺寸短小来表达。短小的类和函数通常易于命名,易于编写,易于理解。
还可以通过采用标准命名法来表达。例如,设计模式很大程度上就关乎沟通和表达。通过在实现这些模式的类的名称中采用标准模式名,例如COMMAND或VISITOR,就能充分地向其他开发者描述你的设计。
编写良好的单元测试也具有表达性。测试的主要目的之一就是通过实例起到文档的作用。读到测试的人应该能很快理解某个类是做什么的。
不过,做到有表达力的最重要方式却是尝试。有太多时候,我们写出能工作的代码,就转移到下一个问题上,没有下足功夫调整代码,让后来者易于阅读。记住,下一位读代码的人最有可能是你自己。
所以,多少尊重一下你的手艺吧。花一点点时间在每个函数和类上。选用较好的名称,将大函数切分为小函数,时时照拂自己创建的东西。用心是最珍贵的资源。
6. 尽可能少的类和方法
即便是消除重复、代码表达力和SRP等最基础的概念也会被过度使用。为了保持类和函数短小,我们可能会造出太多的细小类和方法。所以这条规则也主张函数和类的数量要少。
类和方法的数量太多,有时是由毫无意义的教条主义导致的。例如,某个编码标准就坚称应当为每个类创建接口。也有开发者认为,字段和行为必须切分到数据类和行为类中。应该抵制这类教条,采用更实用的手段。
我们的目标是在保持函数和类短小的同时,保持整个系统短小精悍。不过要记住,这在关于简单设计的四条规则里面是优先级最低的一条。所以,尽管使类和函数的数量尽量少是很重要的,但更重要的却是测试、消除重复和表达力。
第13章 并发编程
“对象是过程的抽象。线程是调度的抽象。”——James O Coplien
编写整洁的并发程序很难——非常难。编写在单线程中执行的代码简单得多。编写表面上看来不错、深入进去却支离破碎的多线程代码也简单。系统一旦遭受压力,这种代码就扛不住了。
1. 为何要 并发
并发是一种解耦策略。它帮助我们把做什么(目的)和何时(时机)做分解开。在单线程应用中,目的与时机紧密耦合,很多时候只要查看堆栈追踪即可断定应用程序的状态。调试这种系统的程序员可以设定断点或者断点序列,通过查看到达哪个断点来了解系统状态。
解耦目的与时机能明显地改进应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。系统因此会更易于被理解,给出了许多切分关注面的有力手段。
以下是一些常见的误解:
(1)并发总能改进性能
并发有时能改进性能,但只在多个线程或处理器之间能分享大量等待时间的时候管用。事情没那么简单。
(2)编写并发程序无需修改设计
事实上,并发算法的设计有可能与单线程系统的设计极不相同。目的与时机的解耦往往对系统结构产生巨大影响。
(3)在采用Web或EJB容器的时候,理解并发问题并不重要
实际上,你最好了解容器在做什么,了解如何对付本章后文将提到的并发更新、死锁等问题。
下面是一些有关编写并发软件的中肯说法:
并发会在性能和编写额外代码上增加一些开销;
正确的并发是复杂的,即便对于简单的问题也是如此;
并发缺陷并非总能重现,所以常被看做偶发事件而忽略,未被当做真的缺陷看待;
并发常常需要对设计策略的根本性修改。
2. 挑战
public class X {
private int lastIdUsed;
public int getNextId() {
return ++lastIdUsed;
}
}
创建x的一个实体,将lastIdUsed
设置为42,在两个线程中共享这个实体。就生成的字节码而言,对于在getNextId
方法中执行的那两个线程,有12870种不同的可能执行路径。如果lastIdUsed
的类型从int
变为long
,则可能路径的数量将增至2704156种。当然,多数路径都得到正确结果。问题是其中一些不能得到正确结果。
3. 并发防御原则
a. 单一权责原则
单一权责原则(SRP)认为,方法/类/组件应当只有一个修改的理由。并发设计自身足够复杂到成为修改的理由,所以也该从其他代码中分离出来。
不幸的是,并发实现细节常常直接嵌入到其他生产代码中。下面是要考虑的一些问题:
- 并发相关代码有自己的开发、修改和调优生命周期;
- 开发相关代码有自己要对付的挑战,和非并发相关代码不同,而且往往更为困难;
- 即便没有周边应用程序增加的负担,写得不好的并发代码可能的出错方式数量也已经足具挑战性。
建议:分离并发相关代码与其他代码。
b. 推论:限制数据作用域
两个线程修改共享对象的同一字段时,可能互相干扰,导致未预期的行为。
解决方案之一是采用 synchronized
关键字在代码中保护一块使用共享对象的临界区(critical section)。限制临界区的数量很重要。更新共享数据的地方越多,就越可能有以下问题:
- 你会忘记保护一个或多个临界区——破坏了修改共享数据的代码;
- 得多花力气保证一切都受到有效防护(破坏了DRY原则);
- 很难找到错误源,也很难判断错误源。
建议:谨记数据封装;严格限制对可能被共享的数据的访问。
c. 推论:使用数据复本
避免共享数据的好方法之一就是一开始就避免共享数据。在某些情形下,有可能复制对象并以只读方式对待。在另外的情况下,有可能复制对象,从多个线程收集所有复本的结果,并在单个线程中合并这些结果。
如果有避免共享数据的简易手段,结果代码就会大大减少导致错误的可能。你可能会关心创建额外对象的成本。值得试验一下看看那是否真是个问题。然而,假使使用对象复本能避免代码同步执行,则因避免了锁定而省下的价值有可能补偿得上额外的创建成本和垃圾收集开销。
d. 推论:线程应尽可能独立
让每个线程在自己的世界中存在,不与其他线程共享数据。每个线程处理一个客户端请求,从不共享的源头接纳所有请求数据,存储为本地变量。这样一来,每个线程都像是世界中的唯一线程,没有同步需要。
建议:尝试将数据分解到可被独立线程(可能在不同处理器上)操作的独立子集。
4. 了解Java库
在用Java 5编写线程代码时,要注意以下几点:
- 使用类库提供的线程安全群集;
- 使用executor框架(executor framework)执行无关任务;
- 尽可能使用非锁定解决方案;
- 有几个类并不是线程安全的。
建议:检读可用的类。对于Java,掌握java.util.concurrent
、java.util.concurrent.atomic
和java.util.concurrent.locks
。
5.了解执行模型
a. 生产者-消费者模型
一个或多个生产者线程创建某些工作,并置于缓存或队列中。一个或多个消费者线程从队列中获取并完成这些工作。生产者和消费者之间的队列是一种限定资源。
b. 读者-作者模型
当存在一个主要为读者线程提供信息源,但只偶尔被作者线程更新的共享资源,吞吐量就会是个问题。增加吞吐量,会导致线程饥饿和过时信息的累积。更新会影响吞吐量。协调读者线程,不去读作者线程正在更新的信息(反之亦然),这是一种辛苦的平衡工作。作者线程倾向于长期锁定许多读者线程,从而导致吞吐量问题。
挑战之处在于平衡读者线程和作者线程的需求,实现正确操作,提供合理的吞吐量,避免线程饥饿。
c. 宴席哲学家
想象一下,一群哲学家环坐在圆桌旁。每个哲学家的左手边放了一把叉子。桌面中央摆着一大碗意大利面。哲学家们思索良久,直至肚子饿了。每个人都要拿起叉子吃饭。但除非手上有两把叉子,否则就没法进食。如果左边或右边的哲学家已经取用一把叉子,中间这位就得等到别人吃完、放回叉子。每位哲学家吃完后,就将两把叉子放回桌面,直到肚子再饿。用线程代替哲学家,用资源代替叉子,就变成了许多企业级应用中进程竞争资源的情形。如果没有用心设计,这种竞争式系统就会遭遇死锁、活锁、吞吐量和效率降低等问题。
你可能遇到的并发问题,大多数都是这三个问题的变种。请研究并使用这些算法,这样,遇到并发问题时你就能有解决问题的准备了。建议:学习这些基础算法,理解其解决方案。
6. 警惕同步方法之间的依赖
同步方法之间的依赖会导致并发代码中的狡猾缺陷。Java 语言有synchronized概念,可以用来保护单个方法。然而,如果在同一共享类中有多个同步方法,系统就可能写得不太正确了。
建议:避免使用一个共享对象的多个方法。
有时必须使用一个共享对象的多个方法。在这种情况发生时,有3种写对代码的手段:
基于客户端的锁定——客户端代码在调用第一个方法前锁定服务端,确保锁的范围覆盖了调用最后一个方法的代码;
基于服务端的锁定——在服务端内创建锁定服务端的方法,调用所有方法,然后解锁。让客户端代码调用新方法;
适配服务端——创建执行锁定的中间层。这是一种基于服务端的锁定的例子,但不修改原始服务端代码。
7.保持同步区域微小
关键字synchronized制造了锁。同一个锁维护的所有代码区域在任一时刻保证只有一个线程执行。锁是昂贵的,因为它们带来了延迟和额外开销。所以我们不愿将代码扔给synchronized语句了事。另一方面,临界区应该被保护起来。所以,应该尽可能少地设计临界区。
有些天真的程序员想通过扩大临界区面积达到这个目的。然而,将同步延展到最小临界区范围之外,会增加资源争用、降低执行效率。
建议:尽可能减小同步区域。
8. 很难编写正确的关闭代码
编写永远运行的系统,与编写运行一段时间后平静地关闭的系统是两码事。平静关闭很难做到。常见问题与死锁有关,线程一直等待永远不会到来的信号。
例如,想象一个系统中有个父线程分裂出数个子线程,父线程等待所有子线程结束,然后释放资源并关闭。如果其中一个子线程发生死锁会怎样?父线程将一直等待下去,而系统就永远不能关闭。
或者,考虑一个被指示关闭的类似系统。父线程告知全体子线程放弃任务并结束。如果其中两个子线程正以生产者/消费者模型操作会怎样呢?假设生产者线程从父线程处接收到信号,并迅速关闭。消费者线程可能还在等待生产者线程发来消息,于是就被锁定在无法接收到关闭信号的状态中。它会死等生产者线程,永不结束,从而导致父线程也无法结束。
这类情形并非那么不常见。如果你要编写涉及平静关闭的并发代码,请多预留一些时间搞对关闭过程。
建议:尽早考虑关闭问题,尽早令其工作正常。这会花费比你预期更多的时间。检视既有算法,因为这可能会比想象中难得多。
9. 测试线程代码
证明代码的正确性不切实际。测试并不能确保正确性。然而,好的测试却能尽量降低风险。这对于所有单线程解决方案都是对的。当有两个或多个线程使用同一代码段和共享数据,事情就变得非常复杂了。
建议:编写有潜力曝露问题的测试,在不同的编程配置、系统配置和负载条件下频繁运行。如果测试失败,跟踪错误。别因为后来测试通过了后来的运行就忽略失败。
a. 将伪失败看作可能的线程问题
多数开发者缺乏有关线程如何与其他代码(可能由其他作者编写)互动的直觉。线程代码中的缺陷可能在一千或一百万次执行中才会显现一次。重复执行想要复现问题令人沮丧。所以开发者常常会将失败归咎于其他“偶发事件”。最好假设这种偶发事件根本不存在。“偶发事件”被忽略得越久,代码就越有可能搭建于不完善的基础之上。
建议:不要将系统错误归咎于偶发事件。
b. 先使非线程代码可工作
确保线程之外的代码可工作。通常,这意味着创建由线程调用的POJO。POJO与线程无涉,所以可在线程环境之外测试。能放进POJO中的代码越多越好。
建议:不要同时追踪非线程缺陷和线程缺陷。确保代码在线程之外可工作。
c. 编写可插拔的线程代码
编写可在数个配置环境下运行的线程代码:单线程与多个线程在执行时不同的情况;线程代码与实物或测试替身互动;用运行快速、缓慢和有变动的测试替身执行;将测试配置为能运行一定数量的迭代。
建议:编写可插拔的线程代码,这样就能在不同的配置环境下运行。
d. 编写可调整的线程代码
要获得良好的线程平衡,常常需要试错。一开始,在不同的配置环境下监测系统性能。要允许线程数量可调整。在系统运行时允许线程发生变动。允许线程依据吞吐量和系统使用率自我调整。
e. 运行多于处理器数量的线程
系统在切换任务时会发生一些事。为了促使任务交换的发生,运行多于处理器或处理器核心数量的线程。任务交换越频繁,越有可能找到错过临界区或导致死锁的代码。
f. 在不同平台上运行
不同操作系统有着不同线程策略的事实,不同的线程策略影响了代码的执行。在不同环境中,多线程代码的行为也不一样。应该在所有可能部署的环境中运行测试。
建议:尽早并经常地在所有目标平台上运行线程代码。
g. 装置试错代码
线程中的缺陷之所以如此不频繁、偶发、难以重现,是因为在几千个穿过脆弱区域的可能路径当中,只有少数路径会真的导致失败。经过会导致失败的路径的可能性惊人地低。所以,侦测与调试也非常之难。怎么才能增加捕捉住如此罕见之物的机会?
可以装置代码,增加对 Object.wait( )
、Object.sleep( )
、Object.yield( )
和Object.priority( )
等方法的调用,改变代码执行顺序。这些方法都会影响执行顺序,从而增加了侦测到缺陷的可能性。有问题的代码,最好尽早、尽可能多地通不过测试。
有两种装置代码的方法:硬编码;自动化。
h. 硬编码
这种手法有许多毛病:你得手工找到合适的地方来插入方法调用;你怎么知道在哪里插入调用、插入什么调用?不必要地在产品环境中留下这类代码,将拖慢代码执行速度;这是种无的放矢的手段。你可能找不到缺陷。实际上,这不在你把握之中。我们所需要的,是一种在测试中但不在生产中实现的手段。我们还需要为多次运行轻易地调整配置,从而增加总的发现错误机会。
无疑,如果将系统分解为对线程及控制线程的类一无所知的POJO,就能更容易地找到装置代码的位置。而且,还能创建许多个以不同方式调用sleep、yield等方法的POJO测试。
i. 自动化
可以使用Aspect-Oriented Framework、CGLIB或ASM之类工具通过编程来装置代码。
例如,可以使用有单个方法的类:
public class ThreadJigglePoint {
public static void jiggle() {}
}
可以在代码的不同位置调用这个方法:
public synchronized String nextUrlOrNull() {
if(hasNext()) {
ThreadJiglePoint.jiggle();
String url = urlGenerator.next();
ThreadJiglePoint.jiggle();
updateHasNext();
ThreadJiglePoint.jiggle();
return url;
}
return null;
}
如此,你就得到了一个随机选择无所作为、睡眠或让步的方面。
或者,想象ThreadJigglePoint
类有两种实现。第一种实现jiggle什么都不做,在生产环境中使用。第二种实现生成一个随机数,在睡眠、让步或径直执行间做选择。如果上千次地做这种随机测试,大概就能找到一些缺陷的根源。假如测试都通过了,至少你可以说自己已谨慎对待。这种方法看似有点过于简单,但确是替代复杂工具的一种可选方案。
有一种叫做ConTest的工具,由IBM开发,能做类似的事情,但做法却稍微复杂些。要点是让代码“异动”,从而使线程以不同次序执行。编写良好的测试与“异动”相组合,能有效地增加发现错误的机会。
建议:使用异动策略搜出错误。
10. 小结
加入多线程和共享数据后,简单的代码也会变成噩梦。要编写并发代码,就得严格地编写整洁的代码,否则将面临微细和不频繁发生的失败。
第一要诀是遵循单一权责原则。将系统切分为分离了线程相关代码和线程无关代码的POJO。确保在测试线程相关代码时只是在测试,没有做其他事情。线程相关代码应该保持短小和目的集中。
了解并发问题的可能原因:对共享数据的多线程操作,或使用了公共资源池。类似平静关闭或停止循环之类边界情况尤其棘手。
学习类库,了解基本算法。理解类库提供的与基础算法类似的解决问题的特性。
学习如何找到必须锁定的代码区域并锁定之。不要锁定不必锁定的代码。避免从锁定区域中调用其他锁定区域。这需要深刻理解某物是否已共享。尽可能减少共享对象和共享范围。修改对象的设计,向客户代码提供共享数据,而不是迫使客户代码管理共享状态。
问题会跳出来。那种在早期没跳出来的问题往往是偶发的。这种所谓偶发问题,通常仅在高负载下出现或者偶然出现。所以,你要能在不同平台上、以不同配置持续重复运行线程代码。跟随 TDD 三要则而来的可测试性意味着某种程度的可插拔性,从而提供了在大量不同配置下运行代码的必要支持。
如果花点时间装置代码,就能极大地提升发现错误代码的机会。可以手工做,也可以使用某种自动化技术。尽早这么做。在将线程代码投入生产环境前,就要尽可能多地运行它。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY