修改代码的艺术(中文pdf版)


一一一一一飞 - 固要噩自由 it ^^ :pj 酣睡富量'因E暨萄!lJ 1 Working Effectively with Legacy Code 乙 [美] Michael C. Feathers 著 Robert Martin 序 刘未鹏译 · 修改代码的集大成之作 • Amazon 全五星图书 · 适用于各种语言或平台 多人民邮电出版社 、熟 mm。 ilt 51111111 唱。唱[ffi]唱。唱。 010 唱[tltíIiIiIl刷:可l!l.IIDJ唱 010100 唱(iliilll。咱 唱 001010001010010 唱 (!llUHjld: Fd、 iiilíilìlliiltltlltl 唱。唱。唱 t (tftIt lltiltlltl , tlltllt 5(ll 刊。~可 0100100100100 iiltl(Ii( ll lHi líiltltDii、,、 ilthilll ItI. 5(IUtj [fíD]唱唱 0'" '1 1010100101 rtlt ltIt 5( tl 唱。唱 11001010 唱[ITI [tli] 唱 0100r 111001 唱 01C 唱。唱mJ节。唱ííU!ll!ilt1I Jj 01010001 ._JVUIV.‘". '001010 1010' .υ n:iJ唱[tliii(tltll lt1tltl、、 lïIID:JDl 唱 o _.1,) 1100唱rmI!l唱唱。唱rIDl唱.唱[lItlI 1 唱 0101r û01010100101 唱 001001001001 t '10唱[i] 101 ,01010101001100110010101010104 0101 000010 10咱 ltltil.UtUru.]唱 011 唱唱 rtí Di ltilt1 唱 t t 唱(lIì 1 1101 J101000111110111011101101010 唱 ~I. )唱[ffi] ilílil唱 00. )1001010010100 唱 10000101 (l. 101 (]'. i[tftQ.il.tllnjlthS'Dl Ulltltil'l唱唱 iiilmUltlt ltI tlîl 二 ~i l!i[i 000101C 10101010101100 唱 00101010010100' 01 吁 1 唱 0101tl010100101010010 4 吨 --、叶 ζ" O~ f.\.. , 1010 010 唱[tí[i]ηí('iI.i(tin,ltil !l俨 4 气 I .吁 。[tltltll t)气'、唱 0101 .' _ )1010( 1010 001 , ~IU1u. 10 唱[ili]斗 1'lItl ultllli I 、‘ 1001 ..'- J10" 、。 irtíltTi i It I.llt'ltltr:L'i 、 llil 唱 ζ: ,- J 10", 11 唱 E lItliiIil唱[tHtJ 电 0101010110 ",.‘ [ll1IIiiI11111l1t IIt iII ltl、 I J - 'r".... .‘001('0. 10100唱 i Hml的 0101010 tanding10nIE-"i ì t mu: 1m t....l K"cj 61.' t.-j ..itit.i t.r~ IDI 刊 fBJtillt:iDl仰n川 1111D~110111n11n4~~' 10~υ lIiJ'I户 ...inlj1 .ltlì ;23732iiLU J;二132 l m而 ;2332摆脱 íBIIJ的。iJ'il.ilU ,I ItI(' iI .I ,U.1唱 11 1 国要藐胃 , . " - "10' - 0010100101 OC 010100101010101010011001 010101001011 mmrr?fF J W叫叫阳kcom :;22%mm tJI.ilUtltl 节~tl.i:i(j]唱 00 4 ' 、 01000101 0C 10010100010 唱 00 " .、lfIl!il.il.i ü 01010101010101ullvo1ι 精-唱、(iIDIiiIiI'í 01010101001010100101r Q101 '00101 01001010101010100110; .01001 '1 01 唱唱 )000101010101001 01011~" ,10001' ‘ "'OC ,011010110101orQ11. 11101 ~ M.imtlifl.ilnfllt:l、 [[!UjþJ唱 r , 00101000101001010τOU.U .10 唱rfl.t t 斗飞 ~01010101010101100 0 、 0 唱 rlilllt u,i ♀ V 飞 1. 010101001010100 ry100. 10010 1 唱 F 飞 H 010101" 节 0101 Q1 !)011 t.百 rtUtI , il. !tJi(í] ~..' ;010101r '010 唱、 111 . J101 01 >. , • . O~ 1:' ,机,",01 唱 011 1..叫饥 ]!I[.Ut:i i lt5 ltl 唱。唱 11ι0" .01001 010010.~1000010 唱[!Dllil IilíII i'. ,0" .唱 [tU. ItUljljiU!tli5ltllh!HlltItI.'líníi 号?回国 国要霞厨湿计且有 E刮目圆圆1!JI';1f1l 1 修改代码的艺术 Working Effectively with Legacy Code [美 1 Michael C. Feathers 著 Robert Martin 序 刘未鹏译 人民邮电出版社 北京图书在版编目 (CIP) 数据 修改代码的艺术! C 美)费瑟 CFeathers , M.C.l著:刘未鹏 译.一北京·人民邮电出版社, 2007.11 (图灵程序设计丛书) ISBN 978-7-115-16362-2 1.修… 11. ①费…②刘… III 软件开发 IV. TP31 1.52 中国版本图书馆 CIP 数据核字 (2007) 第 084231 号 内容提要 修改代码是每一位软件开发人员的日常工作。开发人员常常面对的现实是,即便是最训练有素的开发 团队也会写出混乱的代码,而且系统的腐化程度也会日积月累。本书是一部里程碑式的著作,针对大型的、 无测试的遗留代码基,提供了从头到尾的方案,让你能够更有效地应付它们,将你的遗留代码基改善得具 有更高性能、更多功能、更好的可靠性和可控性。本书还包括了一组共 24 项解依赖技术,它们能帮助你 单独对付代码中的问题片段,并实现更安全的修改。 本书适合各层次软件开发人员、管理人员和测试人员阅读. 图灵程序设计丛书 修改代码的艺术 ·著[美 1 Michael C. Feathers 序 Robert Martin 译 刘未鹏 责任编辑陈兴璐 ·人民邮电出版社出版发行 北京市崇文区夕照寺街 14 号 邮编 100061 电于函件 315@ptpress.com.cn 网址 http://www.ptpress.com.cn 三河市海波印务有限公司印刷 新华书店总店北京发行所经销 • 开本 800xlOOO 1/1 6 印张 22.5 字数 5 38 千字 印数 1-5000 册 2007 年 11 月第 1 版 2007 年 11 月河北第 IIX 印刷 著作权合同登记号 图字 01-2006 - 3687 号 ISBN 978-7- 11 5- 16362- 2厅P 定价 59 . 00 元 读者服务热线 (010) 88593802 印装质量热线 (010) 67129223 版权 -::::f= j:Q 明 Authorized translation from the English language edition, entitled 胁rking Effective/y with Legacy Code by Michael C. F ea由ers , published by Pearson Education, Inc., publishing 臼 Prentice Hall, Copyright @ 2005 Pearson Education, Inc. All rights reserved. No p盯t of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without pe口nission from Pearson Education, Inc CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD.and POSTS & TELECOM PRESS Copyright @ 2007 本书中文简体字版由 Pearson Education Asia Ltd 授权人民邮电出版社独家出版。未经出版者书 面许可,不得以任何方式复制或抄袭本书内容 . 本书封面贴有 Pearson Education (培生教育出版集团)激光防伪标签,无标签者不得销售 . 版权所有,侵权必究。修改代码之三十六计 (译者序) 六六三十六,数中有术,术中有数.阴阳受理,机在其中.机不可设,设则不中。 一一 《 三十六计》 好的技术书籍一般有两种情况, 一种是介绍一些新奇而有趣的技术,另 一种是能将现有的技 术阐述或概括得通透淋漓。然而,实际上还有第三种一一既非介绍新奇的技术,也非阐述既有技 术,而是将被长期实践所证明了的大量技术手法囊括至一起。看起来琳琅满目五花八门,但又各 杳各的用武之地。这样的书一般较少见,因为需要长期的积累和时间的洗礼。 本书正是这样一本书。 说实话,对于这样一本由"鲍勃大叔"亲自作序,且Amazon 上篇篇书评都是五星加夸赞的 书,我这个译者反倒有点逞于置评了。要想知道本书为什么填补了 一项重要的空白(在KentBeck 的《测试驱动开发》、 Martin Fowler 的《重构.改善既有代码的设计》、 Robert C. Martin 的《敏捷 软件开发 原则、模式与实践》等重磅炸弹投下之后) ,可以看 Michael Feathers 的前言 。要想知 道这本书为什么值得你放在书架上,可以看鲍勃大叔的序。要想知道读者怎么认为,可以看 Amazon上的书评。 所以,与其画蛇添足,不如随手捎来Am回:on 上的一些书评片段 "大多数软件开发的图书都是关于原生开发的:教你如何从无到有地创建出 一个新的应用来。 然而实际情况是,真正身处业界但往往大部分时候面对的却是既有代码:添加特性、寻找bug 以 及重构别人写 的代码。因此,图书与实践这两个世界就产生了不平衡,而本书正是在平衡这两个 世界上迈出了漂亮的一步。" "Feathers 用简洁清晰的代码示例漂亮地阐述了我们面对的各种问题场景.....书中的代码示 例跟我在实际工作中常常遇到的那些问题代码非常相近" "总的来说,这本书写得非常漂亮,将一个以前很少被涉及但很重要的主题作了极好的阐述。" "我想在接下来的几年中我都会时常把这本书从书架上拿下来翻阅。" 那么,请带上这只妙计锦囊吧, enjoy! 最后,感谢刘江先生容忍我一而再的拖稿,让我得以在繁忙的一年仍能够认真译完这本好书。 感谢父母一直以来的支持和鼓励。 刘未鹏 2007年2 月 于南京序 6 …..所有的切就从那一刻开始……" Michael F eathers在对本书的介绍 中用 这句话来描述他当初是怎样迷上软件开发的。 4 …..所有的一切就从那一刻开始……" 你能够体会那种感觉吗?你是否能够回忆起你生命中的某个时刻,说"……所有的一切就从 那一刻开始……" ?有没有某一刻某件事改变了你生命的进程,最终,使你拿起了这本书读到了 这篇序言 ? 对我来说 ,所有的一切是从六年级的时候开始的.当时我对科学、太空以及一切与技术相关 的东西都感兴趣。母亲在店里发现了 一 台塑料电脑玩具并买下来送给了我,我还记得它的名字叫 "Dψ-Comp 1"0 40年过去了,那台 小小的塑料电脑玩具在我的书架上仍光荣地占有一席之地。它 点燃并催化了我对软件开发的持续热情,它让我第一次隐约感受到了编写程序来解决人们的问题 是多么有意思的一件事情 . 那只不过是一 台由 3 个塑料的 S -R触发器和6个与门组成的简单机器, 但这已经足够了。于是…·对我来说……一切就从那一刻开始…… 后来我的热情逐渐冷却下来,因为我发现现实中的软件系统几乎总是会慢慢变为 一个烂摊 子。程序员脑子里原先那些漂亮的设计随着时间的推移会慢慢"发出腐化的臭味"。我们往往会 发现,去年才构建的漂亮小巧的系统,到了今年却变成了由 一雄纠缠不清的函数和变量搅和在一 起的"代码浆糊"。 为什么会这样?为什么 一个原先好好的系统会逐渐"发出腐化的臭味" ?为什么它们不能保 , 持原先那样的清晰简洁呢? 有时候,我们会把原因归咎于客户,责怪他们总是改变需求 . 我们自我安慰地认为,只要客 户的需求仅限于他们最初所声明的,那么我们的设计就是没问题的,所以错就错在客户改变了他 们的需求。 昵……然而问题在于·需求总是在改交 。 那些不能适应未来需求变更的设计是糟糕的设计。 能够适应未来需求变更的设计是每一位合格的软件开发者的目标 。 这听起来似乎是个极难解决的问题 。 难到什么程度呢?实际上,迄今为止人们构建出的几乎 所有软件系统都遭遇了缓慢的、不可抗拒的腐化 . 这种现象是如此的普遍,以至于我们给那些腐 化得散发着臭味的程序起了一个别致的名字 z 遗留代码。 遗留代码, 一个令程序员感到头大的词。它往往令人联想到"在黑暗的、乱糟糟的激木丛中 艰难地跋涉,脚下有吸血的蚂麟,旁边还有蛮人的昆虫飞来飞去,它散发着某种黑暗的、粘乎乎 的、钝重的、腐烂的垃圾般的气味"。尽管我们初尝编程的滋味可能会是很美妙的,但在处理遗 留代码时的痛苦往往会无情地将你的热情之火浇灭 。2 序 我们中的许多人都曾尝试过以某种方式避免让代码沦为遗留代码。我们已经编 写 了关于原 则、模式和实践的书以帮助程序员保持其软件系统的清晰简洁。然而 Michael Feathers具有我 们许多人都没有的洞察力。他指出,光采用预防措施是不够的。即便是最训练有素的开发团队(通 晓最佳原则,使用最佳模式,遵循最佳实践方式)也常常会写出混乱的代码,而且系统的腐化程 度也会日积月累。所以,仅是努力防止腐化是不够的,你必须设法扭转它。 这便是本书所要讲述的内容。简言之,本书教你如何扭转腐化,教你在面对一个错综复杂的、 不透明的、令人费解的系统时如何慢慢地、逐步地将其变成一个简单的、有良好组织和设计的系 统。打个比方,就好比扭转一个热力学系统在自发状态下;脑增的趋势。 在你摩拳擦掌、跃跃欲试之前,我得先警告你.扭转腐化趋势的过程并不轻松,而且也算不 上迅速 。 Michae l 在本书中给出的技术、模式及工具是非常有效的,但仍需要你花费精力、时间、 耐心以及细致。本书并不是什么神丹妙药。它不会告诉你如何在一夜之间就把系统中积累的腐化 成分统统去除,而是告诉你一些在今后的开发中应当谨记的原则、概念和态度,这样才能帮助你 将逐渐退化的系统转变为渐趋完善。 Robert C. Martin 面向对象技术大师 Object Mentor公司总裁 2004年 6 月 29 日 l 指 Robe业 Martin 的 《敏捷软件开发 原则、模式与实践》。人民邮电出版社即将推出此书 Java版的英文注释版和c# 版的英文注释版和中士版。一一编者注目 IJ 1=1 还记得你自己编写的第一个程序吗?我可记得。当时我编写的是早期PC上的一个小小的图 形程序。虽然在孩提时代我就已经见过计算机了,但我开始编程的年龄比我的大部分朋友都要大。 我记得很清楚,有一次我在一间办公室里见到了微机,当时留下了深刻印象。在后来好几年 I町, 我都没有任何机会接触计算机。直到我十来岁的时候,我的几个朋友买了儿台第一代的 TRS-80 型计算机。我跃跃欲试,但又有点儿担心,因为我知道一旦我开始接触了计算机,就会深陷其中 不能自拔。我不知道当时为什么会有这种担心,但我的确退缩了。后来,我进了大学,一位室友 买了台电脑,而我买了 一套C编译器,这样就能够自学编程了。于是,一切就从那一刻开始了。 我在不断地尝试,在尝试中度过了一个又一个不眠之夜,我一遍一遍地啃编译器附带的 ema臼编 辑器的源代码。我上瘾了,这项工作充满了挑战性,我喜爱它。 我希望你也有过类似的体验一一那种因程序终于在计算机上运行起来而产生的巨大成功带 来的喜悦。几乎所有我问过的程序员都说曾有过类似的感觉。这种感觉正是让我们喜爱这个行业 的原因之一 。然而,在日复一日的编程中,这种感觉为何消失得无影无踪了呢? 几年前的某个晚上,我结束了手头的工作,给我的朋友 Erik Meade打了 一个电话。当时我知 道他正在给一个新的开发团队做咨询,所以我就问他"他们做得怎么样? "Erik回答道"唉, 他们在编写遗留代码。"他的话令我心头一震,但我打心底里觉得他说的是对的。 E出精确地描 述出了我第 次接触开发团队时的感觉:他们工作非常努力,然而一天下来,由于进度压力、"历 史"包袱,或者由于没有任何更好的代码能够与他们的成果相比之类的种种原因,结果许多人编 写的代码都成了"遗留代码 什么是遗留代码 9 我未加定义就直接使用了这一术语。现在来看一看它的严格定义·遗留代 码就是指从其他人那儿得来的代码。导致这一点的原因很多,例如,可能是我们的公司从其他公 司那儿获取了代码,可能是原来的团队转而去做另 一个项目了(从而遗留下了 一堆代码)。总而 言之,遗留代码就是指其他人编写的代码。不过,在程序员的口中,该术语所蕴涵的意义却远远 不只这些。遗留代码这一说法随着时间的推移已经拥有了某些独特的含义。 那么,当你初次听到"遗留代码"这一名词的时候,心里是怎么想的呢?如果你也和我一样, 那么大抵会联想到错综复杂的、难 以理清的结构, 需要改变然而实际上又根本不能理解的代码: 你会联想到那些不眠之夜,试图添加一个本该很容易就添加上去的特性,你会联想到自己是如何 的垂头丧气,以及你的团队中的每个人对一个似乎没人管的代码基是如何打心底里感到厌烦的, 这种代码正是你希望彻底扔进垃圾堆的那种。你内心深处甚至对于想一想怎样才能改善这种代码 都感到痛苦。这种事情似乎太不值得我们付出努力了 。 此外,遗留代码的定义中没有任何地方提 到代码编写者。实际上,代码退化的方式是多种多样的,其中许多与是否来自另一个开发团队根2 前 言 本没有任何关系. 在业内人士的口中"遗留代码"一词常常是"无法理解的、难以修改的代码"的代名词. 然而,在多年来与形形色色的开发团队共事,并帮助他们解决重大的编码问题的过程中,我总结 出了 一个不同的定义 。 对我来说,遗留代码就是那些没有编写相应测试的代码。明白这一点是很痛苦的。人们会问, 代码的好坏与是否编写了测试有什么关系呢?答案很明显,而这也正是我将要在本书中阐述的· 没有编写测试的代码是糟糕的代码.不管我们有多细心地去编写它们,不管它们有多漂亮、 面向对象或封装良好,只要没有编写测试,我们实际主就不知道修改后的代码是变得灵好了还 是灵糟了.反之,有了测试,我们就能够迅速、可验证地修改代码的行为. 你可能会觉得这有点危言耸听了。难道那些干净的代码也需要这样吗?只要一个代码基是非 常干净且结构良好的不就得了?呢……别误会,我当然喜欢干净的代码,然而只是干净还不够。 在没有相应的测试的情况下就进行大规模的修改是要冒很大风险的。这就好像在没有防护网的情 况下进行高空体操表演。总之,需要极高的技巧,并要对每一步会发生什么有着清晰的认识。而 在软件开发中,精确地预知在改变了几个变量后会发生什么,通常无异于在高空体操中预知另 一 位体操运动员是否会准确地在你翻完-个筋斗之后抓住你的胳膊。如果你所在团队的代码有那么 清晰,那么你比大多数程序员都要幸运。根据我个人的工作经验,我发现团队拥有的代码极少是 处处都那么清晰的,其概率微乎其微。而且,即便你很幸运,只要你们的代码没有编写相应的测 试,其进行修改时的速度仍然比不上那些有测试的团队。 开发团队的水平在不断提高,他们编写的代码也变得越来越清晰,然而旧代码要想变得更消 晰就要花更长的时间。许多情况下旧代码甚至永远都不可能变得完全清晰。正因如此,我将"遗 留代码"定义为"没有编写测试的代码"而且它本身就提出了问题的一条解决方案。 到目前为止,我已经就测试说了很多,然而本书并不是关于测试的,而是关于如何才能够放 心地对任何代码基进行修改的。在后面的章节中,我描述了许多技术,有些是关于理解代码的, 有些是用于将代码放入测试之下的,有些是关于重构代码的,还有些是关于添加特性的。 通过阅读本书,你将会注意到一 点.这并非是一本关于漂亮代码的书。我在书中使用的例子 都是虚构的,这是因为我与客户之间有保密协议,不能泄漏他们的源代码。不过 , 在许多例子中, 我都尽量保留了在业界见到的实际代码的精神。我不敢说这些代码全都具有代表性。在实际中当 然存在着大量不错的代码,不过坦白地说,我也遇到过一些远远不够资格用作本书例子的代码。 对于这些代码,即使没有客户保密协议的约束,我也不会将它们用作书中的例子,我可不想把读 者弄得一头雾水,更不想把重点埋进细节的沼泽中.因此,你会看到,书 中 的许多例子相对来说 都是比较简洁的。如果你会有异议"不,他没弄明白 我的函数可要比这大得多、糟糕得多" 那么,我建议你逐字逐句地读一读我给 出 的相应的建议,看看它是否适用(即使书中的例子看似 更简单)。 书中的技术已经在充分大的代码段上得到了验证。只不过由于篇幅限制,例子的长度缩减了。 特别地,当你在一个代码片段中看到省略号(……)时,可以将它想象成"在这里插入 500行丑前言 3 陋的代码" m-pDispatcher->register(listener) ; 11 想阜成"在这里插入 500 行丑陋的代码" ffi_nMarglns++ i 本书不仅不是关于漂亮代码的,它甚至也不能算是关于漂亮设计的。良好的设计应当是所有 开发者的追求,然而对于遗留代码来说,良好的设计只是我们不断逼近的目标。在某些章节中, 我描述了用于向既有代码基中 添加新代码的方法,并指出了如何在头脑中保持良好设计原则的前 提下做这件事情。你可以在遗留代码基上"培养"出高质量的代码,不过倘若你在修改的某些步 骤中发现某些代码变得比原来更丑陋了,千万别感到惊讶。因为这就像动手术一样,先开一个切 口,进而在五脏六腑中动手术,先别管是否美观。这个病人的病可以医治好吗?是的。那么我们 是否应把他的迫在眉睫的问题放在一旁,缝合伤 口 ,然后告诉他注意饮食并立刻进行马拉松锻 炼?我们当然可以这么做,但我们真正需要做的是医好他的病,让他更健康。他可能永远也不会 成为 一位奥运会运动员, 但我们不能让追寻"最好"之心妨碍了我们去实现"更好"。代码基可 以变得更好,更有利于我们在其上进行工作。同样地, 一位病人身体恢复一点的时候常常就是你 可以帮他实现更健康的生活方式的时候。这也正是我们对于遗留代码所要做的。我们设法到达一 种时常感到轻松的状态,并积极设法让代码修改工作变得更轻松。当我们能够在一个团队中保持 这种感觉时,就意味着设计变得更好了。 书中描述的技术是我与同事和客户在多年的工作(设法建立起对难以驾驭的代码基的控制) 中发现并总结出来的。我的工作重点转向遗留代码完全出于偶然。当时我刚开始在 Object Mentor 工作,大部分工作是帮助有严重问题的团队提高他们的技术水平以及增进他们之间的交流,直到 他们能够定期交付高质量的代码。我们常常使用极限编程实践来帮助团队控制他们的工作、实现 通力合作以及代码的交付。我常常觉得极限编程 (XP) 与其说是一种软件开发的方式,倒不如 说是一种有助于组建起→支良好合作的工作团队的思想理念,而这个团队能够每两星期交付漂亮 的软件则只不过碰巧是这一理念的副产品之一而己。 话虽如此,在一开始的时候还是有点问题。最初的许多E项目都是哺开"的项目。我的 客户都是那些拥有相当庞大的代码基且遇到麻烦的客户。他们需要某种办法来控制其工作,并开 始交付。随着时间的推移,我发现自己重复地做着同样的工作。这种感觉在一次与一个金融业的 团队一起工作的时候强烈到了顶点 。当 时的情况是:在我加入他们之前,他们己经意识到单元测 试非常有用,然而实际上进行的却是全景式的测试,他们写的测试很繁琐,需要多次调用数据库 并执行大量的代码。这种测试难于编写,而且也并不常用,因为运行耗费的时间实在是太长了。 后来,我帮助他们解开代码间的依赖。在将较小块的代码纳入到测试当中的时候,我有一种强烈 的似曾相识的感觉 z 我在每个团 队 中做的都是同样的工作种没有人真正想去深入思考的工作。 然而,当任何人想要控制并处理他们的代码时〔如果他们知道怎么做的话) ,这一工作恰恰又是 必不可少的 。于是,当时我决定了一件事,即思考我们该如何处理这类问题,并将它们记下来, 这样就能够帮助开发团队将他们的代码基变得更易"相处 另外,关于书中的例子还有一点要注意的,它们并非使用同一种语言编写,其 中多数是 Java 、4 前言 C++和C代码。我选择了 Java ,因为它是→门非常常用的语言: 我也选择了 C++因为在处理C++遗 留代码时有一些特有的挑战,我还选择了 C. 因为 C遗留代码突出了在处理过程式遗留代码时会 出现的许多问题。这些语言的代码覆盖了在处理遗留代码时需要考虑的大多数因素。然而,即使 例子中没有使用你所使用的语言,通过这些例子你照样可以学到东西。书中描述的许多技术都可 以在其他语言环境下使用,例如 Delphi 、 Visual Basic 、 COBOL 以及FORTRAN. 我希望你能认为本书中的技术对你有所帮助,并助你重拾编程的乐趣。编程可以是一项回报 丰厚并让人感觉是一种享受的工作。如果你在日复一日的编程生涯中并没有感受到这一点,希望 书中提供的技术能够帮你找到这种感觉,并把它带给你的整个团队。 致谢 首先我想真诚地感谢我的妻子Ann.还有我的两个孩子. Deborah和 Ryan 。没有他们的爱和 支持我绝对无法完成本书以及之前的种种准备工作。同样也要感谢 Object Mentor的总裁和创建 者,人称 "Bob大叔"的 Martin; 他在开发和设计中的严谨务实的态度使当年(大约十年前吧) 被一大堆不切实际的鼓吹弄得晕头转向的我从迷惘中走了出来。另外还要感谢Bob. 他让我在过 去的五年中有机会接触更多的代码与更多的人一起工作,这样的机会我以前是不敢想象的。 此外我还要感谢Kent Beck 、 Martin Fowler 、 Ron Jeffries和 Ward Cunningham. 他们不仅给了 我许多宝贵的建议,还在团队工作、设计以及编程方面令我获益良多。尤其要感谢所有审稿人, 其中 正式的有 ; Sven Gorts 、 Robert C. Martin 、 ErikMeade 、 Bill Wake; 非正式的有; Dr. Robert Koss 、 J ames Grenning 、 Lowell Lindstrom 、 Micah Martin 、 RussRufer ,还有硅谷模式小组和 Jam国 Newkirk 。 此外同样也要感谢另 一组审稿人 Darren Hobbs 、 Martin Lippert 、 Keith Nicholas 、 Phlip Plumlee 、 C. Keith Ray 、 RobertBlum 、 Bill Burris 、 WilliamCaputo 、 Brian Marick 、 Steve Freeman 、 David Putman 、 Emily Bache 、 Dave Astels 、 Russel Hill 、 Christian Sepulveda 、 Brian Christopher Robinson等人。在创作的早期,我将草稿放在了网上,他们的反馈对书(在我重新组织内容之后) 的导向起了很大的影响。 感谢 Joshua Kerievsky对本书做了关键的早期审稿,感谢Jeff Langr给本书提的宝贵建议和审 校意见。 在我完善草稿的时候,所有审阅本书的人都对我提供了莫大的帮助。但如果你发现本书还是 有错误的话,那肯定是我个人造成的。 感谢 Martin Fowler 、 Ralph Johnson 、 Bill Opdyke 、 DonRoberts 、 JohnBrant在重构领域的工作, 给了我许多灵感。 还要特别感谢 Jay Packlick 、 Jacques Morel 、 Sabre Holdings 的 Kelly Mower 、 Workshare Technology的 Graham Wright. 他们的支持和意见给了我不少帮助。 特别感谢Prentice-Hall 出版社的 Paul Petralia 、 Michelle Vincenti 、 Lori Lyons 、Kris阳 Hansing 以及团队中的其余所有成员。感谢 Paul对一个写作新手的帮助和鼓励。 特别感谢Gary和 J oan F eathers 、 April Robe由、D r. Raimund Ege 、 David Lopez de Quintana 、前言 5 Carlos Perez 、 Carlos M. Rodriguez ,还有Dr. John C. Comfort. 在过去的几年中他们一直帮助和鼓 励我。还要感谢 Brian But!on为本书第 21 章提供的例子,那次我们在一起做一个重构课程的时候 他只用了大约 l 个小时就写出了这个例子,它现在已经是我在教学中最喜欢用的一个例子了。 感谢Janik Top的歌 UDeFutura" ,在我写作本书的最后几个星期一直陪伴我。 最后,感谢我过去几年工作中的所苟同事,他们的意见和质疑令本书的内容更经得起考验。 如何使用本书 Michael Feathers mfeathers@objectmentor.com www.objectmentor.com www.michaelfeathers.com 本书的形式在最终确定之前曾儿经易改。在修改遗留代码的过程中有许多不同的技术和实践 如果独立开来是很难阐述好的。考虑到一旦人们能在代码中找到接缝 (seam) 、制造伪对象 (fake objec t),并利用某些解依赖技术来解开代码中的依赖的话,简单的修改就会变得更容易。因此我 想,要想让本书用起来更方便更顺手,最简单的办法莫过于将其主要内容(第二部分一一修改代 码的技术)以 FAQ 的形式来组织了。因为特定的技术往往要用到其他技术,所以 FAQ章节之间经 常有交叉链接。几乎在每章你都会发现一些对其他章节的引用及页码,后者描述了特定的解依赖 或重构技术。如果这种组织形式使得你在寻找一个问题的解决方案的时候需要在书中翻来翻去的 话,我感到很抱歉,但我仍然觉得你宁可这样也肯定不愿意去一页一页地读,并试图去理解那些 技术都是怎样用的。 在修改软件的过程中我曾遇到过许多问题,我把其中比较常见的问题总结出来,本书每章都 对应 个特定的问题。当然,这使得每章的标题比较长,但我觉得这样也好,你能够很快就找到 对应你当前遇到的问题的章节。 在书的第二部分之前有一组介绍性章节(第一部分),之后则是一个重构技术的目录(第三 部分) ,这些技术在修改遗留代码时是非常有用的。我建议你先阅读引入章节,尤其是第 4章。这 些章节中包含了后面要涉及的所有技术的上下文和术语。如果后面你还发现没有在上下文中涉及 的术语,可以到术语表中去找。 解依赖技术 中的重构工作是比较特殊的,因为它们本就应该是在没杳测试的情况之下完成 的,它们的作用就是给后面安放测试铺好道路。我建议你把所有的解依赖技术都浏览一下,这有 助于你在修改代码的时候有更多的选择。目 录 第-部分修改机理 4.3 .2 连接期接缝 32 第 1 章修改软件 2 4.3.3 对象接缝 35 J.l 修改软件的四个起因 2 第 5 章工具 40 1.1. 1 添加特性和修正 bug............ . ... .......2 5.1 自动化重构工具 40 1.1.2 改善设计 4 5.2 仿对象 42 1.1.3 优化 4 5.3 单元测试用具 42 1.1.4 综合起来 4 5.3.1 JUnit................................................43 1.2 危险的修改 6 5.3 .2 CppUnitLite .................................... 44 第 2 章带着反馈工作 B 5.3.3 NUnit ..............................................46 2.1 什么是单元测试 10 2.2 高层测试 12 2.3 测试覆盖 12 5.3.4 其他 xUnit 框架 46 5.4 一般测试用具 46 5 .4 .1 集成测试框架 的 2 .4遗留代码修改算法 15 5.4.2 Fitnesse ...........................................47 2.4.1 确定修改点 16 第二部分修改代码的技术 2.4.2 找出测试点 16 2.4.3 解依赖 16 第 6 章时间紧迫,但必须修改 50 2.4.4 编写测试 16 6.1 新生方法 52 2.4.5 改动和重构 17 6.2 新生类 54 2.4.6 其他内容 17 6.3 外覆方法 58 第 3 章感知和分离 18 6.4 外覆类 61 3.1 伪装成合作者 19 6.5 小结 66 3. 1.1 伪对革 19 第 7 章漫长的修改 67 3. 1.2 伪对革的两面性 22 7.1 理解代码 67 3. 1.3 伪对革手法的核心理念 23 7.2 时滞 67 3.1 .4仿对卑 23 7.3 解依赖 68 第 4 章接缝模型 25 7 . 4 小结 73 4.1 大段文本 25 第 8 章添加特性 74 4.2 接缝 26 8.1 测试驱动开发 74 4.3 接缝类型 29 8. 1.1 编写一个失败测试用例 75 4.3.1 预处理期接缝 29 8.1.2 让它通过编译 75 2 目 录 8. 1.3 让测试通过 75 第 13 章修改时应该怎样写测试 153 8. 1.4 消除重复 ...................................76 13.1 特征测试 ..............................................153 8. 1.5 编写一个失败测试用例 76 13.2 刻画类 ...............................................156 8. 1.6 让它通过编译 76 13.3 目标测试 157 8. 1.7 让测试通过… .77 13.4 编写特征测试的启发式方法 ......161 8. 1.8 消除重复代码 77 第 14 章棘手的库依赖问题 162 8. 1.9 编写一个失败测试用例 77 8. 1.1 0 让它通过编译 77 第 15 章到处都是 API 调用 164 8. 1.1 1 让测试通过 78 第 16 章对代码的理解不足 172 8. 1.1 2 消除重复 79 16.1 注记/草图 172 8.2 差异式编程 .................................80 16.2 消单标注 173 8.3 小结 88 16.2.1 职责分离 ...............................173 第 9 章 无法将类放入测试用具中 ..........89 16.2.2 理解万法结构 174 9.1 令人恼火的参数 .............................89 9.2 隐藏依赖 ........................... ...................95 9.3 构造块 ...................................98 9.4 恼人的全局依赖 ..................................100 16.2.3 方法提取 174 16.2.4 理解你的修改产生的影响 174 16.3 草稿式重构 174 16.4 删除不用的代码 175 9.5 可怕的包含依赖 ......................107 第 17 章应用毫无结构可言 176 9.6 í' 洋葱"参数 110 17.1 讲述系统的故事 177 9.7 化名参数 。 112 17.2 NakedCRC ................................180 第 10 章 无法在测试用具中运行方法 115 17.3 反省你们的交流或讨论 182 10.1 隐藏的方法 115 第 18 章测试代码碍手碍脚 184 10.2 //有益的"语言特性 ...................118 18.1 类命名约定 184 10.3 无法探知的副作用 121 18.2 测试代码放在哪儿 185 第 11 章修改时应当测试哪些方法 127 第 19 章 对非面向对象的项目,如何安全 11. 1 推测代码修改所产生的影响 127 地对它进行修改 187 11.2 前向推测 ................................132 19.1 一个简单的案例 187 11.3 影响的传播 137 19.2 一个棘手的案例 188 11.4 进行影响推测的工具 … 138 19.3 添加新行为 191 11.5 从影响分析当中学习 140 1 9 .4利用面向对象的优势 193 11.6 简化影响结构示意图 141 19.5 一切都是面向对象 196 第 12 章在同一地进行多处修改,是否应 第 20 章处理大类 199 该将相关的所有类都解依赖 144 20.1 职责识别 。 202 12.1 拦截点 145 20.2 其他技术 213 12. 1.1 简单的情形 145 20.3 继续前进 。 213 12. 1.2 高层拦截点 147 20.3.1 战略 213 12.2 通过汇点来判断设计的好坏 150 20.3.2 战术................................ ........214 12.3 汇点的陷阱 152 20 .4 类提取之后 .215 目 录 3 第 21 章需要修改大量相同的代码 216 25.2 分解出方法对象 ......261 第 22 章要修改一个巨型方法,却没法 25.3 定义补全 266 为它编写测试 232 25.4 封装全局引用 268 22.1 巨型方法的种类 232 25.5 暴露静态方法 273 22. 1. 1 项目列在式方法 232 25.6 提取并重写调用。 275 22.1.2 锯齿状方法 233 25.7 提取并重写工厂方法 276 22.2 利用自动重构支持来对付巨型方法 236 25.8 提取并重写获取方法 278 22.3 手动重构的挑战 238 25.9 实现提取 281 22.3 .1 ìυ、感知主量 ....239 25.9.1 步骤 283 22.3 .2 只提取你所了解的 241 25.9.2 一个且主辜的例于 284 22.3.3 依赖收集 243 25.10 接口提取 285 22.3.4 分解出方法对革 243 25.11 引入实例委托 290 22.4 策略 244 25.12 引入静态设置方法 292 22.4.1 主干提取 244 25.13 连接替换 296 22 .4.2 序列发现 244 25.14 参数化构造函数 ......................297 22.4.3 优先提取到当前是中 245 25.15 参数化方法 301 22.4.4 小块提取 246 25.16 朴素化参数 302 22.4.5 时刻准备重新提取 246 25.17 特性提升 304 第 23 章降低修改的风险 247 25.18 依赖下推 307 25.19 换函数为函数指针 310 23.1 超感编辑 247 25 .20 以获取方法替换全局引用 313 23 .2 单一 目标的编辑 .....................................248 25.21 于类化并重写方法 314 23.3 签名保持 249 25.22 替换实例变量 317 23 .4 依靠编译器 251 25.23 模板重定义 。 320 第 24 章 当你感到绝望时 254 25.24 文本重定义 323 第三部分解依赖技术 附录重构 325 第 25 章解依赖技术 258 术语表 329 25.1 参数适配 .......................................258 索引 331 -EZ=Eii吕E 修改机理 本 、部分内J 容 「一 第 l 章修改软件 第 2 章带着反馈工作 第 3 章感知和分离 第 4 章接缝模型 第 5 章工具第 1 章 修改软件 修改(既有〕代码本身并无什么问题,我们正是以此谋生的 。 然而,如果修改的方式不当则会 招来麻烦,当然,只要方法正确,我们也可以令事情变得简单得多 。 在业界,对于修改代码的方法 学讨论得不是很多,其中最接近的恐怕是重构方面的文献了 。因此我觉得可以将讨论的范畴稍微扩 大一点,即讨论如何在最为棘手的情况下处理代码 . 为此,我们首先要深入了解修改的深层机理。 1.1 修改软件的四个起因 为了简明起见,让我们来看看修改软件的四个主要起因 (1) 添加新特性: (2) 修正 bug; (3) 改善设计: (4) 优化资源使用 。 1.1.1 添加特性和修正 bug 添加特性看起来似乎是最直接的一种改动·软件原先是以某种方式运作 的 ,现在用户提 出 需 要这个系统能够做其他事情。 假设我们正在构建一个基于Web的应用,这时经理告诉我们她(指客户)想要把公司的 logo 从页面的左侧移到右侧 . 于是我们与她交谈,发现这件事情并不是想象中那么简单。她不但要移 动 lo go. 还想进行其他改动。她希望在系统的下一个版本中能够让它动起来 。 那么,这算是修正 []] bug还是添加新特性呢?答案取决于你看待这个问题的角度 。从客户 的角度来看,她很明显是在 要求我们修正一个问题 。 因为不久前她预览了网站,然后召集其部门的人员举行了-个会议,最 后大家决定改动 logo 的位置 , 并要求更多一 点的功能而站在开发者的立场上,这种改动则可以 看成是添加-个全新的特性。开发者会说"如果客户不改变主意的话,我们的工作现在就已经 算是完成了。"然而,对于某些公司,移动 logo 的位置只是看作bug修正,他们并不管开发团队为 此而不得不从头开始做一些新工作的事实。 1 即动态 l ogo . 译者注第 1 章修改软件 3 我们可以认为以上这些纯属主观看法的差异·在你的眼里它是一次bug修正,而在我看来则 是添加新特性,就这么简单。然而实际情况是,许多公司出于合同或质量方面的某些原因和目的, bug 修正与特性添加是必须分开记录和解决的。从人的层面来看,我们可以在"我们是在添加特 性还是在修正bug" 这个问题上争论不休。然而从代码层面来说,这些终究不过是在修改代码以 及其他遗留下来的东西罢 了 。不幸的是,这种关于究竟是bug修正还是特性添加的争执掩盖了某 些从技术上来说对我们要重要得多 的东西行为改变 。事实上, 在添加新行为与改变旧行为之间 存在着巨大的差异。 行为对于软件来说是最重要的一样东西.软件的用户要依赖于软件的行为.用户喜欢我们 添加行为(前提是新的行为确实是他们所需要的),然而如采我们改变或移除了他们原本所依 赖的行为(引入bug ),那么他们就不会再相信我们. 回过头来,在前面提到的公司 logo 的案例中,我们是在添加新行为吗?是的。因为在改动之 后,系统将会在页面的右侧显示 logo 。那么我们是否移除掉了某些行为呢?是的,因为页面的左 侧将不会再布 logoo 让我们再来看一个更为复杂的案例。假设客户想要在页面的右侧添加一个 logo , 同时在页面 的左侧原本并没有任何 logo 。在这种情况下我们就是在添加新行为,但我们有没有移除任何已有 行为呢?我们即将要放置 logo 的地方原本是否是由其他图案或文字占据的呢? 我们是在改变行为,添加行为,还是两者皆是? 事实上,我们可以抽出 一个对于程序员来说更为有用的差别。即如果我们必须修改代码 CHTML某种程度上也算代码) ,那么我们就是在改变行为 。 如果我们只是往其中添加代码并调用 它,则通常是在添加行为。对此我们再来看一个例子。下面是一个 Java类的方法 w public class CDPlayer { public void addTrackListing(Track trackJ 该类拥有一个方法addTrackListing ,我们通过该方法能够添加音轨列衰。现在,让我们 添加-个用于替换音轨列表的新方法. public class CDPlayer { public void addTrackList 工 ng(Track trackJ public void replaceTrackListing(String name , Track trackl 4 第一部分修改机理 当添加该方法时 , 我们是在往该应用程序中添加新行为呢,还是改变了现有行为?答案是:两者 都不是。添加一个方法并不会改变代码的行为,除非我们以某种方式调用了该方法。 现在我们来进行另一处修改,往这个 CD播放软件的用户界面上放置一个新的按钮。该按钮 的功能是让用户能够替换音轨列表 .这一举动不仅添加了 replaceTrackListing方法所指定的 行为,同时也细微地改变了该软件的行为.因为有了这个新按钮,用户界面的渲染 (render) 就 与以前不一样了,用户界面的显示大概需要多耗一毫秒( 用于渲染新的按钮)。所以,想要完全 不改变现有行为地添加新行为几乎是不可能的 。 1.1.2 改善设计 改善设计则是另 一种软件修改。当我们想要改变既有软件的结构和组织,以令其更易于维护 时,通常也会希望能够在此过程中不改变其行为 。 倘若在这个过程中丢掉了某个行为,我们通常 会将其称作引入了 一个 bugo 许多程序员通常并不试图改善既有设计,其主要原因之一就是这一 举动相对容易导致行为丧失或坏行为的诞生。 在不改变软件行为的前提下改善其设计的举动称为重构( refactoring) 0 重构背后的理念是, 如果我们编写测试以确保现有行为不变,并在重构过程中的每一小步都小心验证其行为的不变性 的话,我们就可以在不改变软件行为的前提下通过重构使其更具可维护性。多年来人们一直都在 做着清理 l 系统中既有代码的事情,而重构的出现则是近几年的事 。重构与一般的代码清理不同, 在重构时我们并不只是在做那些低危险性的工作(如重整源代码的格式)或侵入性的危险工作(如 重写代码块),而是进行一系列 的结构上 的小改动,并通过测试的支持来使得代码的修改更容易 着手。从改变的角度来说,重构的关键在于在进行重构的过程中不应当有任何功能上的改变。(不 过行为可以稍有改变,因为你在代码结构上的改动可能会导致性能上的改变,其性能可能会变得 囚差一点 ,也可能会变得好一点。) 1.1.3 优化 优化与重构类似,但目标不同 。对于重构和优化,我们都可以说"我们在进行修改的过程 中将会保持功能不变,但我们可能会改变某些其他东西 。 "对于重构来说,这里的"某些其他东 西"就是指程序的结构,我们想让代码更容易维护。而对于优化来说"某些其他东西"则是指 程序所使用的某些资源,通常指时间或内存. 1.1 .4 综合起来 重构与优化的相似性看起来似乎有点奇怪。它们彼此间的相似性看上去比添加特性与修正 bug之间的相似性还要高。然而,真的是这样吗?重构与优化之间的共同点就是在改变某些东西 的过程中保持软件的功能不变 。 1 这里的"清理 " 井非"清扫掉凡的意思.原文为c1 ean up . 古有"使整洁干净"的意思,是指在这个过程中可能会 删掉代码,也可能会修改既高代码.之所以不译为"整理"是因为"整理"没有体现出潜在的无用代码消除这一 行为 , 而"清理"则吉有"清洁整理"的双重意思 . 译者注第 1 章修改软件 5 一般而言,当对一个系统进行修改的时候,其三个方面可能会发生改变:结构、功能以及资 源使用。 让我们来看一看,当进行上述四种修改的时候,系统通常在哪些方面发生改变,以及哪些方 面基本保持不变(通常其三个方面都会发生改变,但让我们来看看什么是典型的情况), 结构 功能 贵源使用 添加特性 改变 改变 重构 改变 优化 改变 从表面上看,重构和优化的确是蛮相似的 。它们都保持功能不变 。 但倘若我们将新功能的出 现分离出来考虑又会怎样呢?当我们添加一个新特性时,通常是在维持现有功能不变的前提下添 加新的功能 。 结构 新功能 功能 噩源使用 添加特性 改变 改变 修.iE bug 改变 改变 重构 改变 优化 改变 添加特性、重构以及优化这三种举动统统都维持既有功能不变 . 如果仔细观察bug修正的话, 我们会发现它是会改变(既有)功能的,只不过这种改变比起那些没被改变的既有功能通常显得 非常微小罢了。 特性添加和 bug修正与重构和优化是非常相似的。在所有四种情况下,我们都想要改变某些 功能、某些行为,但我们想要保持不变的地方则要多得多(见图卜I)。 E •• 既有行为 新行为 图 1-1 行为保持 图 1-1 很好地表现了当对系统进行修改时所发生的情况,那么在实际工作中这幅图对我们来 说又意味着什么呢?从积极的角度来说,这幅图似乎告诉我们应当将注意力集中在什么方面 . 我 们要确保所修改的少数几处已经正确修改了。从消极的角度来说,我们不仅需要关注这些,还得 知道如何保持其他行为不变 。 然而遗憾的是,保留既有行为不变并非意味着只要不碰那些代码就 成 。 我们要知道这些行为并没有发生改变,而这可能是件棘手的事情 。需要保持的行为的数量通 常是非常巨大的 ,不过这倒不是什么大问题.问题在于我们通常并不知道在修改的过程中哪些行 为存在被连带改变的风险 . 如果我们知道,就可以将精力集中在那些行为上而不用管其他的 . 所 因6 第一部分修改机理 以,要想安全地进行修改,关键就在于"理解" 保留既有行为不变是软件开发中最具挑战性的任务之一 . 即使是在改变主要特性时,通常 也有很多行为是必须保留不交的 . 1.2 危险的修改 行为保持是一项巨大的挑战。在我们需要作出修改并保持行为时,往往伴随着相 当大的风险。 [JJ 为了减小风险,我们要考虑下面这三个问题. (1) 我们要进行哪些修改? (2) 我们如何得知已经正确地完成了修改? (3) 我们如何得知没有破坏任何(既有的〉东西? 如果所作改动是有风险的话,你能够承担得起多少改动? 我曾共事过的大多数团队都曾试图以一种非常传统的方式来控制风险。他们把对代码基的改 动数量降至最低 。 有时候这是一种团队策略"如果没有被破坏,就别去修正。"开发者在进行修改 的时候是非常谨慎的"什么?为此创建一个新方法?不不不,我还是把这几行代码直接放到这个 现成的方法里面算了,这样我就可以看到新老代码在一起。况且这种做法费力 少,也更为安全 。 " 人们可能会认为可以通过"避免" 二字诀来将软件问题的数量降至最低,然而遗憾的是,问 题总是不可避免。当我们避免创建新类和新方法时,既有的类和方法就会变得越来越庞大,越来 越难以理解。当在任何大型系统中进行修改时,你可能需要一 点时间来熟悉一下将要修改的区域。 这时好的系统和差 的系统之间的差别就体现出来了。对于前者,当你熟悉了待修改的区域之后, 你会对将要进行的修改充满信心。而对于那些结构糟糕的代码,从理清存在的问题 ~Ü 着手进行修 改的过程简直就像是为了躲避一只老虎而跳下悬崖一样痛苦。你一再犹疑"我真的准备好这么 做了吗?晤,好吧,我想我别无选择 。 " 避免修改还会导致其他不良后果 。如果人们不做修改代码的工作的话,久而久之他们就会对 此感到生疏。即使是将一个较大的类分解成几个小类的工作也可能会因为生疏而变得棘手起来。 要想保持熟练,唯一的途径就是经常练习,如一个星期进行好儿次。熟能生巧之后,这件事情对 你来说就变得像例行公事一样自然而然了 。 你也会变得越来越精于判断,知道哪些代码可以分解 哪些则不可以,而且做起来也容易得多 。 避免改动的最后一个后果就是会带来恐惧心理。不幸的是, 许多团队都在忍受着很重的恐惧 心理,而且每况愈下 。通常他们并不知道他们的恐惧到底有多重,直到他们学到更好的技术,而 恐惧心理也随之减退 l 这里表面上讲恐惧心理,实质上是说且惧怕进行修改从而导致军统结构 日益糟糕的情况,而开发者们卫采取驼鸟 战术,所以"并不知道恐惧到底有多重.. (即系统到底变得有多糟糕),直到有 天他们学到了更好的技术,开始 击对系统进行改善的时候,他们才会发现原来系统已经在长期的积罩下变得难以理解了,这时他们才真正开始意 识到问题有事严重了.一一译者注第 l 章修改软件 7 我们已经讨论了,避免修改是不可取的做法,然而既然不能避免,那我们又该怎么做呢?答 案是我们应该更努力地去修改.或许我们可以雇佣更多的员工,这样每个人就会有足够的时间来 进行分析,仔细审查所有的代码并"正确地"进行修改 。 更多的时间和详细审查应该会让修改变 得更为安全。但果真如此吗?在所有的代码审查完毕之后,究竟有没有人确切知道他们是否把事 一­ 情做对了呢? W 第 2 章 带着反馈工作 对系统进行改ï;lJ 有两种主要方式.我喜欢将它们分别称为编辑并祈祷 (edit and pray) 和覆 盖并修改 (cover and modify) 。遗憾的是,前一种方式几乎可算是业界的标准做法。使用这种方 式来逃行改动时,先是仔细地计划你所要进行的改动,并确保自己理解了将要修改的代码,然后 再开始改动。结束之后,运行修改后的系统,看看所做的改动是否已经生效,再然后就是对系统 整体的修复,以确保改动没有破坏什么东西。这最后一个步骤是必不可少的,且非常重要。在进 行改动时,你希望并祈祷能把事情做对,在完成改动后,你要用额外的时间来验证是否真的把事 情做对了 。 从表面上来看"编辑并祈祷"这种方式似乎意味着"小心下手"这是一件需要专业水平 的工作。在事情的一开始就需要小心,而当改动变得非常具有侵入性的时候,你还需分外小心, 因为这时出错的可能性就更大了。然而可惜的是安全性并不取决于你的细心程度。我想谁都不会 仅仅因为 一位外科医生会非常细心地进行手术就允许他拿着切黄油的刀来切你吧。精湛的软件改 动就像精湛的外科手术一样,除了细心之外还要有深厚的技术 。如果没有辅以正确的工具和技术, 即便"小心下手"也起不到多大作用. 而"覆盖并修改"则是另一种方式。它背后的理念在于,在我们改动软件的时候张开一张安 全网 。 这里所谓的"安全网"并不是指放在桌子底下,当我们从椅子上跌下去时能够托住我们的 那种,而是像张斗篷一样"盖"在我们进行修改的代码上面,以确保糟糕的改动不会泄漏出去并 感染到软件的其他部分.覆盖软件即意味着用测试来覆盖它.当对一段代码有一组良好的测试时, 我们就可以放心对它进行修改,并快速检验出修改是好是坏。我们仍然会辅以同样的细心. 1.旦有 了测试的反馈结果,我们就得以进行更为细致的修改。 囚 如果对这种使用测试的方式不熟悉的话,上面这些话听起来或许就有点儿令人不解了。传统 上,测试总是在开发之后编写并执行的。 一组程序员编写代码,另 一组测试员在代码写好之后对 其进行测试,看看它们是否满足特定的要求。一些非常传统的开发团队正是以这种方式来开发软 件的 。这类 团队也能得到反馈,但整个反馈周期非常长,往往是在几个星期乃至几个月后,另一 个小组的人才会告诉你是否已经把事情做对了。 以这种方式进行的测试实际上可以表述为"通过测试来检验正确性"虽说这是一个很好的 目标,但测试还可以用来做其他事情,我们可以"通过测试来检测变化"。 用传统的术语来说,这叫做回归测试 (regression t田ting) 。我们周期性地运行测试来检验已第 2 章带着反馈工作 9 知的良好行为, 以便确诊软件是否还像它以前那样工作 。 当要动手进行改动的区域由测试包围着时,这些测试的作用就好 比一把"软件夹钳 (ωVI阻seω) 你可以用这把"软件夹钳"来固定住目标软件的大部分行为,只改动那些你真正想要改动的地方。 软件夹钳 夹钳:名词,由金属或木头做成的钳夫工具,通常由两个靠螺旋或杠杆进行开合的部件组 成,用于木工业或五企业中使物件定位。[ ((美国英语传统词典)) ,第 4版] 能够起到检测改动的作用的测试就好比是为我们的代码上了一祀夹钳,使代码的行为被固 定起来.于是当进行改动时,我们得以知道在一个特定的时间只改动某一处的行为.简而言之, 我们得以掌控我们的工作。 回归测试是一个好主意。那么为什么人们不更频繁地进行回归测试呢?这是因为回归测试存 在着一个小问题 通常进行回归测试时,都是在应用程序接口 (application interface) 层面进行 测试。不管目标应用是Web应用、命令行程序,还是GUI程序,回归测试在传统上都是看作一种 应用层面的测试。然而这只是一个不幸的传统观念。事实上,我们从回归测试中得到的反馈信息 是非常有用的。所以若能把回归测试运用到一个更细粒度的层面则将是大有神益的。 为证实这一琪,让我们来做一个思想实验假设我们正单步跟踪一个大函数,该函数包含大 量复杂的逻辑。我们分析、思考,并与更熟悉这块代码的人交流,然后我们着手进行修改。我们 想要确保所做的改动没有破坏任何东西,然而如何才能确保这一点呢?幸运的是我们有一个质量 小组,他们有一组回归测试,可以在夜里运行这组测试。于是我们打电话给他们,让他们安排一 次测试,他们答应了,可以替我们安排一次夜里的回归测试。幸运的是,这个电话打得早,因为 其他小组通常会在星期三左右要求他们安排回归测试,要是再晚一些的话,他们可能就腾不出可 [!QJ 用的时间段和机器了。听了这话我们就放心了,回头继续工作。我们还有五处像刚才那样的改动 要做,而且都是位于像刚才那样复杂的地方。同时我们心里也清楚,还有其他几个人也在像我们 一样忙活着修改代码并等着安排测试呢。 第二天早晨我们接到一个电话。测试部门的 Daìva告诉我们,第 AE1021 和AE1029号测试昨晚 失败了。她不能肯定是不是由于我们所做的改动导致的,但她给我们打了电话,因为她知道我们 会帮她搞定这件事情。我们会进行调试,查出失败是否由于所做的某处改动还是因为其他原因。 以上场景昕起来真实吗?不幸的是,它恰恰是非常真实的。 让我们来看另外一个场景。 我们需要对一个相当长且复杂的函数进行改动。幸运的是,我们发现有一组针对它的单元测 试。最后一次接触这段代码的人编写了 一组大约 20个单元测试,彻底地检验了这段代码。我们运行 这组测试,发现全部通过。接下来我们就浏览这些测试,以便对这段代码的实际行为有一些认识。 我们准备好做改动了,然而却发现很难想出具体进行改动的方法。代码模糊不清,我们很希 望能够在羞于之前先更好地认识代码。现有的测试并不能覆盖所有的情况,因此我们想让代码变 得更清晰一些,这样我们才能够对进行的改动更有信心。除此之外,我们不想自己或任何其他人10 第一部分修改机理 再次经历这样一个痛苦的过程,那实在太浪费时间了? 我们开始对代码做一点点重构.我们将一些方法抽取出来,并移动一些条件逻辑。在进行的 每一步细小的改动后,我们都会运行上面提到的那套单元测试。几乎我们每次运行它的时候都是 通过的。就在儿分钟前我们犯于 一个错误,反转了 一个条件逻辑,然而单元测试迅速给出了失败 的结果,于是我们得以在大约一分钟内纠正了所犯的错误。当我们完成重构之后,代码变得清晰 多了。我们完成了要做的改动,而且确信我们的改动是正确的。接下来,我们添加了几个测试来 验证新加上去的特性 .于是,面对这些代码的下一个程序员做起来就会轻松得多,而且他会有覆 盖其所有功能的全套测试。 你是希望在→分钟内就获得反馈呢?还是希望等-整个晚上 9 以上哪个场景更有效率? 单元测试是用于对付遗留代码的极其重要的组件之一.系统层面的回归测试的确很棒,然而 相比之下,小巧而局部性的测试才是无价之宝,它们能够在进行改动的过程中不断给你反馈,使 [ill 重构工作的安全性大大增强。 2.1 什么是单元测试 术语单元测试 (unit testing) 在软件开发领域有着悠久的历史。在大多数有关单元测试的观 念中都有这么 一个共同的理念,即它们由一组独立的测试构成,其中每个测试针对一个单独的软 件组件 . 那么组件又是什么呢?实际上组件有多种定义,不过在单元测试这一领域,我们通常关 心的是一个系统的最为"原子"的行为单元。譬如在过程式程序设计的代码中"单元" 一般来 说指的就是函数,而在面向对象的代码中则指的是类。 测试用具 本书中我使用了术语测试用具 (test harness) . 该术语是泛称,代表我们为了 检验软件的 某部分而编写的测试代码以及用来运行这些测试代码的代码.我们可以针对代码使用多种不同 的测试用具.第 5章中讨论了 xUnit测试框架以及FIT框架。这两者都可以用来进行本书中描述 的测试工作. 那么,我们究竟能否做到只测试系统中的某一个函数或类呢?在过程式系统中,通常是难以 孤立地测试函数的,因为这种系统中的情况往往是顶层的函数调用其他函数,后者再调用另 一些 函数,最后直到机器层。而在面向对象的系统中,单独测试类则要简单一点,然而实际上类却往 往并不是"离群索居"的生物。想想看,在你写过的所有类中有多少是没有使用到别的类的?非 常少这些极个别分子往往是那些小型的数据类或数据结构类,如校和队列(甚至就算是这样的类 也可能会用到其他类〕。 测试的隔离性是单元测试的一个重要方面,然而为什么说它是重要的呢?毕竟,当整合软件 的各个部分时还可能出现许多错误 . 难道不应该是那些能够覆盖代码中的广泛功能区域的大型测 试更为重要吗?诚然,它们是重要的,我并不否认这一点,然而大型测试存在着一些 问题: 口错误定位:测试离被测试者越远,就越难确定测试失败究竟意味着什么。要想、精确定位第 2 幸带着反馈工作 11 测试失败的根源往往需要耗费大量的工作。你得检查测试输入、还要检查失败本身,然 后还得确定这次失败发生在从输入到输出的执行路径上的哪一点。虽说对于单元测试来 说这样的工作也是免不了的,然而通常其工作量微乎其微。 口执行时间.大型测试往往需要更长时间味运行。而这种长时耗性往往让人无法忍受。市 一一 要太长时间运行的测试,结果往往是无法运行。lliJ 口覆盖.在大型测试中,往往难以看出某段代码与用来测试它的值之间的联系。我们通常 可以通过覆盖工具来查出某段代码是否被一个测试覆盖到了,但当添加新的代码时,可 能就需要花费可观的工作量来创建检验这段新代码的高层测试了。 大型测试最令人不能接受的事情就是可以通过频繁地运行测试来实现错误定位,然而这偏 偏又是难以实现的.假设我们运行测试并且测试通过,接着我们做了一点点改动于是测试失败 了,这时我们就能够精确地知道问题是在哪儿被触发的,就是我们最后做的那点改动中的某处 地方.于是我们可以将改动回滚,并重新尝试改动.这一场景看主去也许没什么问题,然而如 果我们的测试相当大,其执行时间可能长得令人无法忍受,可想而知我们更可能做的事情便是 尽量避免去运行它,于是就无法达到错误定位的目的了, 而单元测试则做到了大型测试所不能做到的那些事情。利用单元测试可以独立地对某一段代 码进行测试。我们可以将测试分组以便在某些特定条件下运行某些特定的测试,并在其他条件下 运行另 一些测试。我们还可以迅速定位错误。如果认为在某段代码中存在着一个错误而且又可以 在测试用具中使用这段代码的话,我们通常能够迅速地编写出一段测试,看看我们所推测的错误 是不是真的在那里。 下面是好的单元测试所应具备的品质: (1)运行快: (2) 能帮助我们定位问题所在。 在业界,人们在判断某个特定的测试是否是单元测试这个问题上常常摇摆不定。如果一个测 试中涉及了另外一个产品类,那它还能算是单元测试吗?为了回答这个问题,我们回到刚才提到 的两点品质上来,即该测试运行起来快不快?它能帮我们快速定位错误吗?比如有些测试较大, 其中用到了好多类。那么实际上这种测试或许看上去像是小型的集成测试。就它们自身而言,可 能运行起来比较快,然而要是你将它们一起运行呢?一个测试如果不仅测试了某个类还测试了与 该类一起工作的儿个类,那么它往往会"越长越大气如果你当时不花时间来使得-个类能够在 测试用具中单独实例化的话,难道你还能指望当更多的代码被添加进系统之后这件事会变得更容 易吗?永远也不会。人们会不断推渎,并且随着时间的推移,原本短小的测试可能会变得需要十 分之→秒才能执行完。 一个需要耗时十分之一秒才能执行完的羊元测试就已算是一个慢的单元测试了. 我说这话是认真的。在我写作本书时,十分之一秒对于单元测试来说简直就像一个世纪一样。12 第一部分修改机理 不信的话让我们来做一点简单的算术吧:假设你有一个项目,其中包含 3 000 个类,每个类平均 [ill 大约有 10 个测试, 一共算起来就是 30000个测试。倘若这些测试个个都耗时十分之一秒才能运行 完的话,那整个项目测试一遍需要多少时间呢?将近一个小时!对于反馈来说这段等待时间可不 短。什么 9 你的项目没有 3 000个类?那一半总有吧,这样算下来也仍然要等半个小时呢。另 一 方面,假如我们的测试只需耗时百分之一秒呢?很显然,我们一下子从需要等一个小时变成了只 需等 5 到 10 分钟 1 这样的话,虽说我还是比较谨慎的只取出其中的部分单元测试来用,但哪怕每 隔几个小时就将它们全部运行一遍我也不再害怕。 此外,根据摩尔定律,在我有生之年我有望看到即便是在最大型的系统上测试反馈也能在近 乎一瞬之间完成。我猜到那时候在这类系统中工作就会像是在一堆会"反咬一 口"的代码中工作 一样。一旦你改错了一步,系统就会立即"反咬一 口"让你知道。 单元测试运行得快.运行得不快的不是单元测试。 有些测试容易跟单元测试混淆起来.譬如下面这些测试就不是单元测试· (1) ;;r<-数据库有交互, (2) 进行了网络间通信, (3) 调用了文件系统, (4) 需要你对环境作特定的准备(如编辑配直文件)才能运行的. 当然,这并不是说这些测试就是坏的.编写它们常常也是有价值的,而且你通常也会在单 元测试用具内来编写它们。然而,将它们;;r<-真正的单元测试区分开来还是很有必要的,因为这 样你就能够知道哪些测试是你可以(在你进行代码修改的时候)快速运行的. 2.2 高层测试 单元测试的确很棒,但高层测试也有其一席之地。所谓高层测试便是那些覆盖了某个应用中 的场景和交互的测试。高层测试可以用来一下子就确定一组类的行为。能够这样做往往就意味着 你可以更容易地为单个类编写测试。 2.3 测试覆盖 那么在一个遗留项目中我们究竟该如何着手进行修改呢?我们首先注意到,如果可以选择的 话,在进行修改时有测试"罩着"总是要安全一些的。对代码的修改可能会引入 bug. 因为我们 [ill 毕竟是人而不是神。但假如在修改代码之前先用测试将代码"护住..我们就能更容易地捕获到 在改动过程中所犯的错误了。 图 2-1 展示了一小组类。我们想要改动工 nvoiceUpdateResponder 的 get ResponseText 方 法以及工 nVOlce 的 getValue方法。这两个方法是我们的改动点,我们可以通过为它们所在的类 编写测试来覆盖它们。第 2 章带着反馈工作 13 InvolceυpdateServlet # execute(HttpServletRequest, HttpServletResponse) DBConn田tlon # buildUpdateO + gettnvoices(Criteria) : List 1 <Init(); ffi_hSslDl12->Init(); retu rn t r u e ; 这看上去的确跟一段文本没什么区别,对不对?现在假设,我们想要运行除了以下这行之外 的所有代码: PostReceiveError(SOCKETCALLBACK, SSL_FAILURE) ; 第 4 章接缝模型 27 一 如何才能做到这一点呢? 很简单对不对?我们只需将上面这行代码从这个函数中删掉即可。 接下来让我们给问题再加点限制。我们想要避免执行这行代码,因为 PostReceiveError 是一个会跟另一个子系统进行交互的全局函数,如果在测试中牵扯到那个子系统的话就太麻烦 了。这样一来 问题就变为 ·如何才能做到在 测试中既执行了这个方法又避免了调用到 postRece 川eError 呢?这里的关键在于既要在测试中避免调用到 P ostRece 工 veError又不能 让最终的产品代码调用不到它 1 。 对我来说这个问题有多个解,这就引入了接缝这一概念。 我们先看一下接缝的定义,然后再看一些例子。 接缝 接缝 (seam ),顾名忠义,就是指程序中的一些特殊的点,在这些点上你无需作任何修改 就可以达到改动程序行为的目的. 那么 ,回过头来考虑前面给出的示例代码 ,在 PostRece川eError 的调用点存在接缝吗?答 案是肯定的。 我们可以有多种方式来修改该处的行为 。以下是最直接的一种 :PostRecelveError 是一个全局函数,并非 CAsyncSslRec类的一部分。所以,如果我们往CasyncSslRe c类中添加 一个签名一样的 PostReceiveError (不同之处在于后者是成员函数), 会导致什么结果? class CAsyncSslRec { virtual void PostReceiveError(U工 NT type, U工 NT errorcode) ; 因 其实现如下 z VQ 工 d CAsyncSslRec : : PostReceiveError (UINT type , UINT errorcode) { : : PostReceiveError(type, errorcode); 以上这一更动不会改变程序行为。 CAsyncSslR曰 :PostReceiveE口 or只是简单地将任务 转发给了全局的 :PostReceiveError来完成。这里虽说引入了 一个小小的间接层 ,但最终调用 到的还是同样的函数,即全局的 ::PostReceiveError 。 好的,现在如果我们把CAsyncSslRec给子类化 (subclass) 了,并覆盖 PostRece 工 veError , 将会怎么样. class TestingAsyncSslRec public CAsyncSslRec l 也就是说, 简单地将这行代码>>所在函数中删除掉的做法就不再可行了.为什么呢?因为产品代码还需要这行代 码.且不能在测试时把它删掉,而在产品编译构建时再添加进去吧 9 一行两行代码这样做还可以,然而如果有大 块、多处这种悄况(通常如此),这种方法根本就是不切实际的了. 译者注28 第一部分修改机理 virtual void PostReceiveError(UINT type , UINT errorcç 过 e) ( ] 如果像上面这样做了,并回到测试代码中创建 CAsyncSslRec 对象的地方,改为创建 Test ingAsyncSslRec ,就能够有效地将下面代码中的调用 PostRece 工 veError 的行为屏蔽掉: bool CAsyncSslRec : : Init() { if (ITLbSslInitializedl ( return true: m smutex . Unlock 门 , ffi_nSslRefCount++ ; ffi_bSsllnitialized true; FreeLibrary(m_hSslDlll1 ; ffi_hSslDlll=O i FreeLibrary(m_hSs lDl121 ; ffi_hSslDl12=O ; 主 f (!r飞..bFailureS en t) IDLÞFailureSent=TRUE: PostReceiveError(SOCKETCALLBACK, SSL_ FAILURE); CreateLibrary(m_hSslDlll , "syncesell . d ll 门, CreateLibrary (m_hSslDl12 , "syncese12 . d ll " ) ; m_hSslDlll->Init() ; m hSslD 1l 2-> 工 nit 门 , return true; [ill 现在我们在为以上代码编写测试的时候就可以避开那些肮脏的副作用了 上面讨论的这类接缝我把它们称之为对象接缝 (obj 四t seam) 。遇到此类接缝时,我们可以 在无需修改调用函数2 的情况下改变被调用的函数对象接缝存在于面向对象语言中,它们只不 过是许许多多不同种类的接缝中的一种 。 那么,为什么要关心接缝这个概念?它有什么好处呢? 在为遗留代码编写测试时, 一个最大的挑战就是解依赖。运气好的话这些依赖可能较小、较 局部化,然而碰到极端情况时,你可能得对付大量 的、 在代码基中分布得到处都是的依赖。而接 l 本例中特指 PostReceiveErr。主的副作用. 译者注 2 丰伊j 中 是CAsyncSslRec : : Inito 一一译者注 3 即选择哪个函数被调用一译者注第 4 幸接缝模型 29 缝的概念则帮助我们去发现代码基中既有的可利因素 。 倘若能够将按缝处的行为取代掉,我们就 等于有选择性地排除了某些依赖。我们还可以将被依赖方替换为其他代码,以此来感知被测试代 码对被依赖方的要求或影响,并针对这些要求或影响来编写测试 。 往往这么做了以后我们就能够 获得足够的测试以便采取更进一步的举动 。 4.3 接缝类型 对于不同的编程语言 ,可用的接缝类型也不同 。 考察它们的最佳途径是观察该语言的程序代 码被转换至机器代码的过程的各个阶段 , 其中每个显著的阶段都蕴涵着不同种类的接缝 。 4.3.1 预处理期接缝 对于大多数编程环境来说,都是由编译器读进代码然后生成目标代码或字节码 。 取决于编程 语言的不同,有可能后面还会添上一个或多个处理步骤 。 然而在编译器编译代码之前,有没有什 么更早期的步骤? 事实上,只有寥寥几 门语 言在编译前会有另 一个处理阶段(预处理), c与 C++就是这其中最 常见的代表 。 在C和 C++中,程序代码被编译之前会先由宏预处理器进行预处理 . 这么多年来宏预处理饱 受着人们的诅咒和讥讽 . 借助于它我们可以编写 出外表看起来无可厚非的代码= TEST (getBalance , Account) { Account account ; LONGS_EQUAL (Q, account . getBalance ()); 而实际上到了编译器眼里却成了这样: class AccountgetBalanceTest public Test / { pub lic : AccountgetBalanceTest ( ) Te st (-getBalance "Tes t .) {} void run (TestRes ult& result_l ; ) AccountgetBalancelnstance; void Ac countgetBalanceTest : :run (TestResult& result_l Account account ; { result_.countCheck() ; 10ng actua lTemp (account.getBalance() ); 10ng expectedTemp (0) ; if ((~xpectedTemp ) ! = (actua lTemp) ) result .a ddFa~lure (Failure (name_, " ç \\se amexamp le . cpp 圃 , 24 , StringFrom (expectedTemp) , S 巳 ring F rom(a ctualTe mp ))); ret urn; } } 我们也可以像下面这样用条件编译语句将代码包围起来,从而达到支持调试模式以及不同平 台 的目的· 因30 第一部分修改机理 m-pRtg->Adj(2 . 0) ; 样 ifdef DEBUG 件 ifndef WINOOWS { FILE 11" fp fope n (τ也 LOGNAME , ~ W~) ; if (fp) ( fprintf(fp ,~毡 s. , m--pRtg->pszState); fclose(fp); }} 得 endif m-pTSRTable->p_nFlush 1= GF_FLOT ; #endif 在产 品代码中过度使用预处理并不是个好主意,因为它会降低代码的清晰性 。 条件编译指令(如 hfdef 、 #ifndef和创 f 等)几乎等于是在强迫你在同一份源代码中维护多个不同的程序 。 宏(使 用 #d e fine来定义) 若是使用得当 固然可以被用来做一些很好的事情,但是别忘 了 它们的运作机 制只不过是文本替换,因此很容易就可以制造出隐藏了极度隐晦的 bug的宏 。 姑且先把这些考虑搁在一边 , 我对C和 C++ 中具有预处理功能还是感到高兴的,因为它给程 序中带来了更 多 的接缝 。 下面就是一个具体的例子 。这是一 个 C 程序,其中的问题在于对 db_update这个库 函数的依赖 o db_update会直接跟数据库进行沟通 。 因此,除非能用另 一个 实现来将 db_update原先 的实现给替换掉 ,否则就无法感知该函数的行为. 得 include 国 # include e xtern int db_update( int , struct DFHL工 t em 11"); void account_update ( int account_no, struct DHLSRecord 1I" record, int activate d ) if (act 主 vated) { if (record->dateStamped && record->quantity > MAX_ITEMS) db_update{account_no, record->item) i ) else ( db白update ( account_no , record - >backup_iter时 , db_update(MASTER_ACCOUNT , record->item) ; 我们可以使用预处理接缝来替换掉程序中对他,_update 的调用 . 为此,我们可以引入一个头 文件,称作 localdefs . h : #include #i nclude extern int db_update(int , struct DFHLltem 11" ) ; 4降 include "localdefs.h" 第 4 章接缝模型 31 void account_update( int account_no, struct DHLSRecord 舍 record . int activatedl if (activated) { if (record->dateStamped && record - >叩吐 antity > MA>CITEMS) { db_update(account_no, record->item) i } else ( db_update(account_no, record- >backup_ item) ; db_upda te (MASTER_ACCOUNT , record- >item) i 在该头文件中我们可以提供另一个db_update 的定义,以及一些有用的变量: 得 ifdef TESTING struct DFHLItem *last_ item NULL j int last_account_no - 1; 得 define db_update(account _no , i t em)\ {las t item (item) i las t _account_no 样 endif (account…no ) i } 有了这个实现来替换 db_updat e 原先的 实现,我们就可以编 写测试来验证db_update被调 用的时候接收到的是否是正确的参数了 。 我们之所以能够这么做是全靠C预处理指令 #include 提供的一个接缝,利用这个接缝我们得以在(代码〉文本被编译之前将它替换掉 。 预处理期接缝是个非常强大的手段。我不认为我会真的希望像 Java 以及其他更现代的语言 中 有这个功能,然而对于C和 C++来说则不同,因为它起到了弥补这两门语言 中的一些其他测试障 碍的作用,所以还是有好处的 。 关于接缝,还有一个重要的地方,即,每个接缝都有个所谓的激活点 (enabling point) 0 为此我们先来回顾一下接缝的定义: 接缝 接缝 (seam ),顾名忠义,就是指程序中的一些特殊的点,在这些点上你元需作任何修改 就可以达到改动程序行为的目的 . 当遇到一个接缝时,即意味着我们可以改变其所在处的行为 .当然 , 我们不能仅仅为 了 测试 就真的去修改其所在之处的代码,源代码在产 品阶段和测试阶段应当是完全一样的 。 在前面的例 子 中我们曾尝试改变db_update调用点处的行为,为了利用起该处的接缝,我们得在其他某个地 方进行改动。而对于 db_updat e 这个例子来说,其激活点就是TESTI NG这个预处理符号 的定义 。 当定 义了 TESTING 时, localde f s. h 文件中就会包含 一些宏 而这些宏 将会把源文件中对 他'_update 的调用替换(展开)为 localdefs . h 中的 db一叩 date定义 。 1 其中最主要的就是曲'_update宏.一一译者 注 国32 第一部分修改机理 激活点 每个接缝都有一个激活点,在这些点上你可以决定使用哪种行为. 4.3.2 连接期接缝 在许多语言系统中,编译并非构建过程的最后一步 . 编译器产生的只不过是代码的中间表 示, 其中包含了对其他文件中的代码的调用 。 然后,连接器负责将这些中间表示 1 连接起来。连接器 会对每个调用进行决议以便最终能得到一个可运行的完整程序。 对于C和 C++这类语言来说的确存在着一个单独的连接器,其作用如上所述。而在 Java 以及 类似 的语言 中则是编译器在幕后负责进行连接过程。当一个 Java源文件包含了 impo rt t吾句时, 编译器就会检查 工 mport 的类是否已被编译,如果没有,就先对其编译,然后再检查它的所有调 ffiJ 用是否都能够在运行期正确决议 。 不过,不管你的语言的编译系统是用哪种方式来进行符号引用决议的,你一般都可以利用它 来替换一个程序的某些部分。I;.( Java 为例,下面是一个名为 FitFilte r 的类 package fitnesse ; import fit . Parse; import fit .Fixture; import java . iO 舍, import java . util .Date; import j ava. io 食, import java.util .*; public class FitFilter ( public String input ; publ ic Parse tables; public Fixture fixture new Fixture () ; public PrintWriter output ; public static void main (String argv[]) ( new FitFilter() . run(argv) ; public void run (String argv[]) ( args(argv); process() ; exit () ; public void process () t 口, ( 1 在C和 C++ 中即目标士件.一一译者注一 tables new Parse (input) ; fixture . doTables (tables) ; } catch (Exception e ) { exception(e) ; tables . print(output) ; 第 4 章接缝棋型 33 该文件 lmport 了 f 此 . Parse和 fit . Fi x tur e 。 那么,编译器和NM究竟是如何找到这些类 的呢?在 Java 中你可以使用 一个名为 cla sspath 的环境变量来告诉 Java系统到哪里去寻找这些类 。 因此,你可以创建同名类,将它们置于另 一个目 录中,并令 classpath指向该目录,从而诱使编译 [ill 器去发现从而连接到你写 的另 一个版本的 fit . Parse和 fit. Fixture来 . 虽说这种技巧用在产 品代码中可能会带来混乱,但用在测试中却是个相 当 实用的解依赖手段 。 在上例中,假设我们想要为 P arse类提供另 一个版本的实现以使用于测试.我们想要的接 缝在哪里 9 答案是process 方法中的 new Parse( inpu t ) 处. 激活点又在哪里? 答案是 c l as spath. 许多语言 中 都有这种动态连接功能 。 对于其中大部分来说,我们都有办法来利用其连接期接 缝 。 但并非所有 的连接都是动态连接。对于许多较为古老的语言来说,几乎所有的连接都是跟在 编译之后静态连接。 \ C和 C++的许多编译构建系统都使用静态连接来产生可执行文件 . 通常来说利用这利 ' 连接的 接缝的最简单的途径是为你想要替换其实现的所有类和函数创建另一个单独的库文件,然后 , 当 你进行测试时,就可以通过修改构建脚本文件 1 来引导你的构建系统去连接到供测试之用的库文 件了,而另 一方面,在产品阶段的构建中, 则可以修改构建脚本使得程序连接到产品代码的库 。 这种做法可能要花点工夫 ,但倘若你的代码基中到处散布着对某个第三方库的调用的话,这样做 就是值得 的 了。 例如,设想这么 一个 CAD应用, 其代码里面嵌有很多对某个图形库的调用 . 下面 就是其 中 一种典型的代码: void CrossPlaneFigure : : rerende r() { // draw the label drawText(m_oX, m-pY, ffi-pchLabel, getCl 工 pLen () ) ; drawLine (m_nX, ffiJlV, ffi_nX + getClipLen ( ) . ffi_OY) ; drawL ine(m_oX , ~nY , rn_nX, rn_oY + getDropLen()); if (! m_bShadowBox) draw Line(~nX + getClipLen 仆, ITLnY . m_oX + getClipLen() , rn_oY + getDropLen()) ; l 如 makefile之类.一一译者注34 第一部分修改机理 drawLine(m_nX, m_oY + getDropLen() , m_oX + getClipLen () ,欧_oY + getDropLen()) ; /1 draw the figure for (int n 0 : n < edges . size(); 0++) [ill 以上代码中对一个图形库进行了许多直接调用。遗憾的是, 想要验证这些代码是否做了你想 要它们做的事情,唯一的途径就是运行它们并观察计算机屏幕上画出来的图形。然而当这种做法 遇到复杂的代码时便会非常容易出错 。一个替代方案就是使用连接期接缝。具体来说 ,如果其中 所有的画图函数都是来自某个特定的库,那么你就可以创建该库的一个即以,用它来替代原先的 库去连接到这个应用程序。如果你只想解开依赖的话,这个s阳b库里面就可以全放上相应的空函 数,如下: void drawText{int x , int y , char 舍 text , int textLength) { } void drawLine(int firstX , int firstY , int secondX , int secondY) 遇到带有返回值的函数,你则需要在相应的 stub 函数里面也返回某些东西。通常'你可以选择 返回一个代表成功的值或者返回某个类型的默认值,例如: int getStatus () { return FLAG_OKAY ; 这里举图形 (graphi c)库的例子有点儿不那么典型 . 但为什么又说它是适合使用该技术的一 个很好的例子呢?原因之一就是它几乎是个纯粹的命令型接口 。 换句话说,一般是你去调用其库 函数来命令它们做某些事情,而并不请求它们反馈什么信息 . 遇到后一种情况会比较难对付,因 为当试图测试你的代码时,为这种函数编写 的 s阳b版本-般来说不能简单地返回默认值 。 使用连接期接缝的目的之一是分离。当然你也可以实现感知,只不过后者需要多花点工夫。 例如,在刚才举的那个假想的图形库的例子中,可以引入某种额外的数据结构来记录对这些库函 数的调用: std: :queue actions; 1. stub在程序员口中一般是指" 一小段〈可能是由某种工具自动生成的〕代码(可能是二进制的),用来占职某个位 置,以达到某个特定目的{如转发,或这里的行为消除等) . "也有地方译为"桩于飞本书中选择保留不译 . 译者注第 4 章接缝模型 37 现在, buil dMartSheet 中对 cell .R ecalculate 的调用是不是一个接缝呢?是的 。 我们可 以在测试用例中创建一个Cust omSpreadshee t. 并使用我们愿意的任何种类的 Ce l1对象为参来 调用这个buil dMartSheet 方法。换句话说,我们无需修改 cell.Recalculate这行调用所在的 方法就可以达到改变这行调用所实际干的事情的目的 。 那么,既然这是一个接缝,它的激活点又在哪里呢? 答案是 buil dMart Shee t 的参数列表 . 通过给出不同类型的对象作为其参数,我们可以根据 测试需要任意改变bu il dMartSheet 里调用的 Rec alculate 的行为。 到目前为止我们看到的大部分对象接缝都还算是比较简单的。而下面我们就来看一个微妙的 对象接缝。在下面的代码中,在 Recalculate 的调用处存在一个对象接缝吗? public class CustomSpreadsheet extends Spreadsheet { public Spreadsheet buildMartSheet (Cell celll Recalculate(cell); private static void Recalculate(Cell cell) / 这里的 Re cal culat e 方法是个静态方法 。 那么, buil dMartSheet 中对 Recalculate 的调 用处存在一个接缝吗?答案是肯定的。我们无需修改 buil dMartSheèt 就可以改变该调用处的行 为 .具体做法是删掉 Recalculate方法定义前的 static关键字 ,并将其访问权限从私有改为受 保护的,这么 一来 我们就可以在测试中对 Cus tornSpreadsheet 进行子类化并重 写其 Recalculate 了,如下 public class CustomSpreadsheet extends Spreadsheet { public Spreadsheet buildMartSheet(Cell cell l Recalculate(cell); protected void Recalculate(Cell cell ) { public class TestingCustomSpreadsheet extends CustomSpreadsheet { protected void Recalculate(Cell celll 回E 38 第一部分修改机理 以上做法看起来是不是显得有点过于迁回了?如果我们不喜欢某处依赖,干嘛不直接进到代 码中去修改一通把它改掉呢?没错,有时候这的确可行,然而当你为那些特别脏乱的遗留代码安 放测试时,往往最好的途径是尽量少去修改其代码。如果知道你的语言所支持的接缝类型,并知 道怎样去使用它们,则通常可以更安全地将测试安置妥当。 到目前为止我们所展示的接缝类型都是一些主要的。你可以在许多编程语言中见到它们的身 影。现在就让我们来回顾一下本章开头给出的例子,看看里面能看到哪些接缝· bool CAsyncSslRec : : Init{) f if (m_bSslInitialized) ( return true ; rn_ smutex . Unlock() ; rn_nSslRefCount++ i m_bSsllnitialized true i FreeLibrary(m_hSslDlll); ffi_hSslDlll=O; FreeLibrary(m_hSslDl12); ffi_hSslDl12=O; if (!m_bFa 工 lureSent) ffi_bFailureSent=TRUE; PostReceiveError(SOCKETCALLBACK, SSL_FAILURE); CreateL 工 brary (m_hSslDll1, "syncesell . dll") ; CreateLibrary(m_hSslDl12 , "syncese12 .dll "); m hSslDlll->Init 门, m hSslDl12->Init 仆 , return true; 在对 PostRece 工 veError 的调用处存在哪些接缝呢?如下所示: (1) PostRece 工 veError是个全局函数,因此很容易就可以使用连接期接缝。具体做法可以 是创建一个库,其中包含 PostReceiveE口or 的一个 s阳b版本( 一般是一个空函数) ,并将原先 的程序连接到这个库,从而消除那个调用处的行为。这一接缝的激活点应该是项目的 makefile文 件,或者IDE垦的某些设置(如果你用了 IDE的话)。需要更改项目的构建设置,以便在测试的时 候连接到测试用库,而在构建真正系统的时候连接到产品库。 (2) 可以在代码中添加一个别 nclude语旬,并定义一个名为 PostReceiveError 的宏,当 我们进行测试时就激活这个宏。也就是说这里有一个预处理期接缝。那么,其激活点又在哪呢? 答案是可以使用 一个预处理宏定义来控制这个 PostReceiveError宏是否被定义。第 4 幸接缝模型 39 一 (3) 还可以定义一个名为 PostReceiveE口。r 的虚函数 ,就像本章的开始所做的那样 。也就 是说这里还存在一个对象接缝。激活点则在对象的创建处。可以创建一个 CAsyncSslRe c 对象, 也可以创建CAsyncSslRec 的某个为测试而写的并重写了 PostRe ceiveError 的子类化版本。 很令人惊讶,居然有这么多途径能达到这一 目的,不用修改下面这个方法,就可以替换掉 postReceiveError调用处的行为 0001 CAsyncSslRec :: Init () { if (!m_bFailureSent l { m_bFailureSent=TRUEi PostReceiveError(SOCKETCALLBACK, SSL_FAILURE); return truei 当想要将某段代码置入测试中时,选择正确类型的接缝很重要。 一般来说,如果你用的是面 向对象语言,则对象接缝是最佳选择。预处理期接缝以及连接期接缝某些时候是有用的,但它们 没有对象接缝那么清楚明显 . 此外依赖于这两种接缝的测试可能会难以维护。我个人倾向于将预 处理期接缝和连接期接缝这两种接缝保留到代码中到处浸透着依赖并且没有其他更好的方案可 选的情况下。 当习惯了以"接缝之眼"来看待代码时,就能更容易看出如何测试某段代码以及如何组织新 代码的结构以令其更具"测试友好性" 1441 第 5 章 工具 在对付遗留代码时,你需要哪些工具呢?首先需要一个编辑器(或IDE) 以及一个构建系统, 同时还需要一个测试框架。要是再有一个对应于你使用的语言的重构工具那就更好了,它们也可 能会带来很大的帮助。 本章会介绍一些现有的工具,以及它们在你对付遗留代码的过程中能够扮演的角色。 5.1 自动化重构工具 手动重构当然也没什么不可以,但若是有个工具能够帮你完成一些重构工作岂不更好?这样 你就能够节省许多时间了。 20世纪90年代. Bill Opdyke为写一篇以重构为主题的论文而开始做一 个 C++重构工具。尽管这一工具最终并没有商业化,但据我所知,他的工作提供了其他语言领域 的许多成果。其中最为著名的就要数 Smalltalk的重构浏览器了,这一重构浏览器由伊利诺斯大学 的 John Brant和 Don Robert开发,支持众多重构手法,并且很长时间以来都是自动化重构技术的经 典范例。自那时起,许多人开始尝试往各种被广泛使用的语言中加入重构支持。在本书写作时已 经出现了许多 Java重构工具,其中大多数都被集成到了 IDE 中。 Delphi 同样也有重构工具,而 C++ 也有一些相对较新的重构工具。在写作本书时一些C#的重构工具则正处于积极开发过程中 o 有了这些工具,重构似乎看上去简单许多了。就某些环境下来说,的确如此。只不过,这些 工具对于重构的支持良秀不齐。让我们再来回顾一下重构的概念。下面是 Martin Fowler对重构下 的定义(<<重构:改善既有代码的设计)) 1): 重构 名词.对软件内部结构的一种调整,目的是在不改变软件的外在行为的前提下,提高其可 [ill 理解性,降低其修改成本。 只有当你的修改不会改变行为时,才能算是重构。重构工具应当检验某处修改是否改变了行 为,而许多重构工具的确做到了这点。这在 Smalltalk的重构浏览器、前面提到的 Bill Opdyke 的工 作以及许多早期的 Java重构工具中都是个基本规则。不过实际上对于 些极少数情况,有些工 具却并不进行检查,那么你在重构的过程中很可能将 些细微的 bug带进去了。 1 此书英士注释版即将由人民邮电出版社出版 . 编者注第 5 章工 具 41 一 谨慎选择重构工具是有好处的。耍了解工具开发商们对他们的工具的安全性怎么说,并要自 己进行一些测试。我自己在遇到一个新的重构工具时 ,通常会对它做一点健全性检查 。 比如,当 你试图提取一个方法并将其命名为与它所在类中的某个既有方法同名的方法时,该重构工具会不 会将这个标示为一个错误?而倘若是跟该类的基类中 的一个方法同名 ,工具又能否检测出来? 如 果不能 ,你很可能在无意间错误地重写了基类的某个方法,从而破坏了现有代码 . 本书讨论了在有和没有自动化重构支持的情况下分别应该怎么做.我会在示例中说明是否假 定你使用了 自动化重构工具 . 任何情况下我都一律假定你的工具所提供的重构会保留行为。如果你发现你的工具支持的某 些重构不能保留行为,那就别用自动的重构 。遵循在没有重构工具情况下的建议,这样更安全。 测试与自动化重构 如采有一个工具,它能够替你完成重构工作,那么我们会倾向于认为无需为待重构的代码 编写测试.某些情况下的确如此.如果你的工具能够进行安全的重构,并且你是从一次自动化 重构到另一次自动化重构,其间不进行任何其他(人为)编辑修改的话,你就可以认为这些改 动是不会改变行为的.然而,事情并非总是如此,下面就是一个例子 public class A private int alpha 0 ; private int getValue () ( alpha++ i return 12 ; public void doSomething () { int v getValue () ; int total 0 ; for (int n 0 ; n < 10 ; n++) total += v ; / 在至少两款 Java重构工具下,我们都可以使用一次重构来消除doSomet hing 当中的变量 V. 而在重构之后,代码看上去则像这样: public class A private int alpha 0 ; private int getValue() { alpha++i return 12 ; public void doSomething () int total 0; for (int n 0; n < 10; n++) 回42 第一部分修改机理 total += getValue(); 看到问题了吗?交量虽被移除了,然而现在 al pha 的值却被递增了十次,而原先则是一次! 这一改动明显不能保留行为. 所以说在开始使用自动化重构之前先编写必要的测试还是有好处的.当然你也可以在没有 测试保护的情况下进行一些自动化重构,不过这时候你得清楚你的工具会进行和不会进行哪些 检查.我在开始使用一个新工具时所做的第一件事就是看看它对提取方法的支持怎么样,如果 发现对它的这一能力足够信任,能够在没有测试的情况下使用的话,我就可以使用它的这一功 能先将代码重构至一个测试起来容易得多的状态 . 5.2 仿对象 在对付遗留代码的过程中需要面对的一个大问题就是依赖 。想要单独执行某段代码来看看它 干了些什么的话,常常必须得先解开这段代码与其他代码之间的依赖。而这项工作几乎从来都不 那么简单。如果将它依赖的代码拿走的话,就得有什么东西来填补这些空档,以便在进行测试的 时候提供出 一些适当的值(如函数返回值),从而能够彻底地检测这段代码。在面向对象语言 的 代码中,这个填补物通常就被称为仿对象 (mock object) 。 @) 有好几个免费的仿对象库。其中大部分都可以通过阳w.rnockobjects.com找到。 5.3 单元测试用具 测试工具的历史是漫长而多彩多姿的 。 在不到 年的时间里我接触了四五个团队,他们花大 价钱买了一些昂贵的测试工具,最后却发现根本不值。公平地说,测试的确是个棘手的问题,而 且人们常常会被"只需通过程序的 GUI或Web界面即可对其进行测试而无需进行任何特别动作 " 这样的念头所引诱 。 这固然是可行的,然而其工作量却往往超过了团队中的任何一个成员所能承 受的量。 此外,用户界面通常也并非编写测试的最佳地点。用户界面往往是不稳定的、易于变化 的,而且离你想要测试的功能往往也太遥远了。而且当基于用户界面的测试失败时想要查明失败 的原因也会较困难 。 话虽如此,人们常常还是花相当多的钱在这些工具上面,试图通过它们来完 成所有的测试工作 。 我所见过的最有效的测试工具是一些免费的工具 . 首先是 xUnit测试框架, xUnit最初由 Kent Beck用 Smalltalk编写 ,接着由 Kent Beck丰OErich Gamma移植到 Java上,这是一个小巧而强大的单 元测试框架。 下面是它的关键特性 口它允许程序员使用开发语言来编写测试。 口所有测试互不干扰独立运行。 口 一组测试可以集合起来成为一个测试套件 (suite) ,根据需要不断运行 。 现在 xUnit框架已经被移植到了大部分主流语言乃至相当 一部分非主流的、冷僻的语言下。第 5 章工 具 43 一 xUnít 的设计里面最具革命性的特点便是其简单性和集中性 。 它使得我们可以投入最少的精 力和时间来完成测试的编写。尽管 xUnít最初是为单元测试编写的,但你也可以用它来编 写较大 的测试,因为 xUnit其实并不关心你的测试是大是小 . 只要你的测试可以用你开发用的语言来编 写 I xUnit就可以运行它 。 本书中的大部分例子都是用 Java和C忡写 的 " Java 中的首选 xUnit用具是贝Jnit ,它跟 xUnit家族 的大部分其他成员看上去儿乎没什么不同 . 在 C++ 中,我则常常使用一个我自己 写 的 名 为 CppUni tL íte 的测试用具,它看上去有一些不同之处,我会在本章予以介绍 。 顺便一提,我用 CppUni tL íte完全没有看不起 CppUnit原作者的意思,事实上我正是那个原作者。而在CppUnit发布 之后我发现,如果当初使用 一些C惯用法,并且去 除对 C++语言 中的那些不必要特性使用的话, 它还可以变得小巧易用得多,且可移植性也能得到大大增强 。 5.3.1 JUnit 在只Jnit中,你通过对一个名为 T es tCase 的类进行子类化来编 写测试,例如: import juni t . framework.*: public class FormulaTest ext ends TestCase ( public VQ 工 d testEmpty () { a s sertEquals(O , new F ormula( 田 ") . value()) ; public void testDigit () { assert Equals(l , new Formula( - l.) . value()) ; 测试类中每个具有vo i d testXXX ()这种签名的方法都定义了 一个测试,其中 xxx是你想要 给该测试起的名字 . 每个测试方法都可以包含代码和断言。 例如, 在上例中的 te s t Ernp ty 中,代 码是我们 new 了 一个 Fo rmula对象,并调用其上的 valu e 方法 . 而断言则是检查该value方法返 回的值是否等于 0 。 如果是则测试通过,否则测试失败 。 简言之,当你运行一个丑Jnit测试时会发生下列事情:首先是 JUnit 的测试运行器负责加载像 上述那样的测试类 (Fo rmu laTest) ,然后它使用反射机制来寻找其中所有的测试方法 . 而接下 来的工作则有点"不足为外人道飞它为找到的每一个测试方法都创建一个单独的对象 。 还以刚 才的测试类为例,其中有两个测试方法 , 于是丑Jnit就会为它们创建两个单独的对象:第一个对象 l 啦一 的任务就是运行 test Ernpty ,而第三个对象唯一 的任务就是运行 testDi g ito 如果你想知 道这两个对象的类型分别是什么,答案是它们都是同 一个类型一-FormulaTe st (即测试类 ) 。 每个对象都被配置用来运行 Formu l aTes t 上的某一个测试方法 。 这里的关键就是,每个测试方 法的运行都是由完全独立的对象来完成的,它们不可能互相影响 。 下面就是一个例子: public c l ass Emp!oyeeTest extends TestCase ( privat e Emp!oyee employee: protected void setUp () 国回 44 第一部分修改机理 employee new Employee( " Fred ~ , 0 , 10): TDate cardDate new TDate(10 , 10 , 2000); employee . addTimeCard(new TimeCard(cardDate , 40)}; publ ic void testOvertime(} TDate newCardDate new TDate( l1 , 10 , 2000}; employee.addTimeCard(new TimeCard(newCardDate, 50)} : assertTrue (employee.hasOvertimeFor (newCardDate) }; public void 乞 estNormalPay () assertEquals (400 , employee .getPay () } i 在上面的 E吨 loyeeTest 类中有一个特殊的方法 setUpo setUp 方法是在 TestCase 中定义 的,它会在每个测试对象的测试方法运行之前运行,还允许我们创建一组用于测试的对象。这组 对象是在每个测试方法被执行之前以相同方式创建的。在负责运行 testNormalPay 的那个对象 中, setUp 中创建的一个 Employee对象在 testNormalPay方法中被检查是否能够正确计算 张 考勤卡情况下的薪水,这张考勤卡是在 se tUp方法里面添加的。而在负责运行 testOverti肥的 那个对象中, setUp 中创建出来的那个 Employee对象则在 testOvertime 中被添加了另一张考 勤卡,然后检查第二张考勤卡是否触发了加班条件。每个 Employe eTest 对象的 setUp方法都会 得到调用,使得它们各自拥有独立的一组测试用对象。此外,如果你想要在一个测试方法执行结 束之后做一些其他事情,则可以重写另 一个方法 tearDown ,该方法也是定义在TestCase 中,它会在每个对象对应的测试方法执行完毕之后被运行。 人们第一次见到 xUnit 时或许会感到有点奇怪:为什么测试类要有 setUp和 tearDown这两个 方法呢?为什么不能在构造函数中创建测试所需的对象呢?答案是当然可以,但别忘了测试运行 器是怎样对待测试类的。它会创建测试类的一组对象,其中每个对象用于运行测试类中的一个特 定的测试方法。这可能是一组数目不菲的对象,但如果它们尚未分配所需测试用对象的话其资源 消耗可能并不那么多。通过将某些代码置于 se tUp方法之中,我们就可以做到只在需要的时候才 去创建对象,如此一来便可节省相当一部分资源。此外,通过延;Ìß setUp 的执行,我们还可以在 检测并汇报 setUp期间发生的问题时去运行它。 5.3.2 CppUnitLite 我在排行CppUnit最初的移植工作时曾试图尽量使其接近于mnito 我想这么 一来对于那些见 过 xUnit架构的人上手就会比较容易,因此这似乎是较好的做法。然而在我着手时立即就遇到了 麻烦,由于 C++和 Java 的语言特性的区别,导致有一些东西难于甚至根本无法在C++ 中干净地实 现出来。主要问题在于C++缺少反射机制。在 Java 中,你可以持有一个对派生类中方法的引用, 你可以在运行期查找方法等。而在C++中要实现这些则必须编写代码来预先注册那些你需要在运 行期访问的方法。因此 CppUnit要比且Jnit难于使用和理解一些。你得在测试类上编写你自己的 SUlte lliJ 函数,这样测试运行器才能够为每个单独的测试函数运行单独的对象,如下 z第 5 幸工 一 Test *EmployeeTest: :suite() TestSuit e *suite new TestSuitei suite.addTest(new TestCaller(~testNormalPay" , testNormalpay)) ; su 工 te.addTest( 口 ew TestCaller("testOvertime" , testOvertime)) ; return suite; 具 45 毋庸置疑,这种做法可能会相当烦人。当弄妥一个测试方法必须得涉及三处地方(在头文件 中声明,在源文件中定义,在 smte方法中注册)时,就很难保证人们还能维持编写测试的动力了。 当然,这个 问题是可以通过 一 个宏手法来弥补的不过我决 定推翻重来。于是就诞生了 CppUnitL ite. 在CppUni止ite采用的方案中,人们要编写一个测试只需在相应源文件中编写一些代 码即可,如下 : #include " testharness . h 刷 得 include "employee . h" 带 include using namespace std; TEST (testNormalPay , Employee) { auto-ptr employee (new Employee ("Fred" , 0 , 1 0)); LONGS_ EQUAL(400. employee->getPay{)); 上面这段测试使用了 一个名为 LONGS_EQUAL 的宏,该宏会比较两个长整型是否相等。其行 为跟贝Jnit 中的 assertEquals 一样,只不过前者是为长整型量身定做的。 TEST宏在幕后替你完成了许多麻烦事。它首先定义测试类 2 的 一个子类,该子类的名字是 TEST宏接受到的两个参数(即该测试的名字以及被测试类的名字)的文本连接3 。接着它创建该 子类的一个实例,注意,该子类被配置为能够自动执行大括号内的代码气此外,由于该实例是 全局静态分配的,因而在程序加载时它的构造函数就会被调用起来,后者会将这个实例自身添加 到一个包含测试对象的静态链表中。于是后面当测试运行器运行时便能通过遍历该链表来运行每 个测试。 在编写完这个微型框架之后,我决定不发布它,因为这个宏背后的代码并不是十分清晰明了, 而我曾花了许多时间来说服人们编写清晰的代码。我的一个朋友Mike Hill在我们俩认识之前也遇 到过→些类似的问题,并写了一个特定于微软开发平台的测试框架,叫做 TestKi t. 其中使用了与 1 请参考 CppUnit的官方文挡。一一译者注 2 这里指辅助进行测试的类(名为 Test ),而非被测试类. 译者注 3 即通过"时"这个预编译操作符进行的文本连接,将两个名字"粘"成 个.在实际实现中通常后面还要"粘" 上~个 "Test 飞 译者注 4 实际上这里用的技术很简单,译者建议你去阅读下 CppUnitL由的源代码.一一译者注46 第一部分修改机理 CppUnitL ite 同样的方式来解决测试对象的注册问题。受到tlMike 的鼓舞,于是我开始消除 CppUnitLite 中使用后期 C++特性的数盐,然后我发布了这个框架(别 小看这些问题,它曾是 CppUnit 中的一个大 问题 。 几乎每天我都能收到关于这些问题的电子邮件,有些问题是不会使用 [ill 模板或标准库,有些问题则是手头的编译器在语言特性方面有这样或那样的不支持 ) 0 CppUnit和CppUni止 lte都有作为测试用具的资格。考虑到使用 CppUni止 lte编写的测试要简洁 一些 , 因此书中出现的 C++示例皆使用它. 5.3.3 NUnit NUnit是 NET语言 的一个测试框架 .你可 以为 C# 、 VB.NET或其他运行于 .NET平台之上 的语 言的代码编写测试 o NUnit在操作上跟 JUnitl 1'.接近。 一个显著的区别就是它使用特性 (attribute) 来标识测试方法和测试类。特性的语法取决于编写测试所用的 阳T语言. 下面就是一个使用 VB.NET写的NUnit测试: 工 mports NUnit . Framework Public Class LogOηTest Inheri 巳 s Assertion Public Sub TestRunValid () Dim display As New MockDisplay () Dim reader As New MockATMReader () Dim logon As New LogOn (display, readerl logon . Run () AssertEquals( ftP l ease Enter Card" , display . Las 巳 DisplayedTex t) AssertEquals( "MainMenu" , logon.GetNextTransaction () . Getτ'ype .Name) End Sub End Class 这两个特性分别标识出了 LogonTest 作为一个测试类以及 TestRunValid作为 一个测试方法 。 5.3.4 其他 xUnit 框架 xUnit还被移植到了其他许多不同的语言和平台上 。一般来说它们都支持单元测试的指定、 分组及运行。如果你想看看你所用的平台或语言有没有相应的 xUnit ,请访问WWW.xprogramm皿g.com. 国在下载区查找 .这个网 站是Ron Jeffries 的, 它是所有xUni傣族成员的准仓库 . 5.4 一般测试用具 前面描述的 xUnit框架是为单元测试设计的。它们固然也可以被用来一趟测试多个类,但这 种工作更应该属于FIT和 Fitn白白的范畴 。第 5 章工具 47 一 5.4.1 集成测试框架 FIT是一个简练而优雅的集成测试框架,由 Ward Cunningham开发。其背后的理念是简单而强 大的。如果你可以为你的系统编写文档,并在其中嵌入描述了系统的输入和输出的表格,并且这 些文档可以被保存为 HTML的话. FIT就可以将它们作为测试来运行 . FIT接受HTML数据,运行其中的HTML表格所定义的测试,然后以 HTML的形式输出结果 。 FIT 的输出看上去~~输入一样,其中的所有文本和表格均被保留 。 然而,表格中的单元却被适当 着色了,绿色代表使测试通过的数据,红色代表使测试失败的数据。你还可以通过一些选项来告 诉它将测试摘要信息输出到结果HTML 中。 要实现这些目的,你只需自定义一些表格处理代码,这样FIT就能够知道如何去运行你的代 码块并取得返回结果 。 通常这项工作相当简单,因为框架本身就提供了支持一系列不同表格类型 的代码。 FIT的一个非常强大的地方是,它能够鼓励软件编写者与需要指定软件应当做什么的人之间 的沟通。指定软件应当做什么的人可以编写文档并将实际的测试嵌在里面。然后运行测试,无法 通过,不过别担心,接下来开发者会给软件添加特性,测试便会通过。如此一来用户和开发者都 能够对系统的能力有一个最近的共识。 这里所描述的远非Fπ的全部,更多信息请访问 http://fit. c2.com o 5.4.2 Fitnesse Fitnesse本质上是一个以 Wiki 为宿主的 FIT. 大部分由 Robert Martin和IMicah Martin开发,我参 与了其中的一点工作,后来因为要集中精力写作本书而退出了,希望不久能够再重新参加。[ill Fitnesse支持用于定义 FITi测试的分级网页 . 测试表格的页面可以单独运行,或放在测试套件 中运行,众多的选择使得团队间合作变得很容易 。 Fitn田se可以通过 http://www.fitnesse.org获取 . 和本章中提到的所有测试工具一样,它是免费的,并由 一个开发者社区支持 。 llilM -EP--=画画 修改代码的技术 第 6 章时间紧迫,但必须修改 第 7 章漫长的修改 第 8 章添加特性 第 9 章无法将类放入测试用具中 第 10 章无法在测试用具中运行方法 第 11 章修改时应当测试哪些方法 第 12 章在同一地进行多处修改,是否应该将相关的所有类都解依赖 第 13 章修改时应该怎样写测试 第 14 章棘手的库依赖问题 第 15 章到处都是 API 调用 第 16 章对代码的理解不足 第 17 章应用毫无结构可言 第 18 章测试代码碍手碍脚 第 19 章对非面向对象的项目,如何安全地对它进行修改 第 20 章处理大类 第 21 章需要修改大量相同的代码 第 22 章要修改一个巨型方法,却没法为它编写测试 第 23 章降低修改的风险 第 24 章当你感到绝望时国 第 6 章 时间紧迫,但必须修改 让我们面对现实 IIE: 本书中描述了 一些 "份外"的工作, 一些你可能目前并不在傲的工作, 一些可能会令你花更长时间来完成代码修改的工作。你或许会怀疑目前是否值得去做这些事情。 答案是:虽说不管是解依赖还是为所要进行的修改编写测试都要花上一些时间,但大部分情 况下最终还是节省了时间,同时也避免了一次又一次的沮丧感。那么,究竟什么 时候会发生这样 的事呢 9 这取决于项目本身。有些情况下可能要为一些需要进行修改的代码编写测试,假如花了 两个小 时。而在这之后修改代码才花了不过 15 分钟。当回顾这番工作的时候,你可能会说"唉, 我刚才浪费了两个钟头一一值得花这两个钟头的时间吗? "答案要视具体情况而定.因为你并不 知道当初如果你没有编写测试的话后面的工作要花多少时间。你同样也并不知道这时如果在修改 的过程中出了岔子的话需要花上多少时间去调试,而如果 当初编写 了测试的话这些时间是可以省 下来的。所以,测试可以"捕获"你在修改过程中不慎引入的错误,从而节省为此所花的时间 ; 另一方面,当试图寻找代码中的错误时测试也可 以帮你节省时间。有测试在,我们常常就可以更 容易地定位功能上的问题。 退一步说,假设遇到最坏的情况,即所要进行的改动很简单,但我们还是先编写了测试 , 然 后正确地完成了所要进行的改动。那么,这种情况下,是否值得编写测试呢?问题是,我们并不 知道什么时候会再回到这些代码上去进行其他的修改。最好的情况是,你在项目的下一个迭代期 就回到那块代码上,于是当初的"投资"很快得到了回报。而最坏的情况则是,几年之后这块古 老的代码才再次被人 问津 。不过最可能的情况则是我们会周期性地访问这块代码,哪怕只是为了 看看是否需要在它上面还是其他什么地方做些改动。那么这时候如果需要修改的类比较小,或者 说有现成的单元测试在那的话,是不是理解起来会容易 一些呢?答案是肯定的.不过别忘了这只 是最坏的情况,其发生的几率又能有多大呢?要知道,通常情况下系统中的修改是相对集中的。 今天修改了某处,很可能很快又要去修改附近的某地方了. 在跟团队合作的过程中,我常常一开始就请他们参与一个实验。在一个法代期,我们试着坚 持不要在没有测试覆盖的情况下去改动代码。如果某个人觉得他们无法编写某个测试,就得召集 一个临时会议,询问整个团队是否可能编写该测试。这样一个法代期在开始的时候是糟糕的。人 们觉得他们做了无用功 。但是慢慢地,他们就开始发现当重访代码时看到的是更好的代码 。并且 代码的修改也变得越来越容易,这时他们就会打心底里觉得这么做是值得的了。当然,一个团队 要想越过这个障碍期还是需要花上一些时间的,但如果说世界上还有一件事情是我可以立即为每第 6 章 时间紧迫,但必须修改 51 一个团队做的话,那就是与他们分享这样的体验"好家伙,我们再也不用遭那些罪了!" 业 ~l !黑你还没育这种体验,那么现在就开始 H巴。 这种方式最终将会大大提升工作效率,而这几乎在每个开发团体中都很重要。不过坦白地说, 作为一个程序员,我感到最庆幸的还是它令我们的工作变得远远不像原先那么令人沮丧了 。 不过,并不是说在越过了这个障碍之后一切就是无限光明了.当你意识到测试的价值,并且 感受到有和没有测试之间的差别之后,剩下来唯一要傲的事情便是针对每种特定的情况决定该怎 么做了。 这种情景每天都在重演 老板走了进来,说<<客户们嚷着要这个特性 . 今天能完成吗? " "我不知道 你检查了一下手头的项目,有现成的测试'马。没有. 你问<<到底有多急迫? .. 你知道自己可以在需要改动的一共 10处地方逐一就地修改,这事在五点之前就能完成.事 情非常紧急?明天我们会把代码修改一下的,对吧? 记住,代码就是你的家,你是得在其中生活的. 当处在期限压力之下时,决定是否编写测试就成了个难题,最困难的地方就在于你可能并不 知道添加某个特性需要花多少时间。尤其当对象是遗留代码时,更是难以作出有效的估计。有一 些技术可能帮得上一 点忙,具体阅读第 16章。当你并不知道添加某个特性会花费多少时间,并且 cru 怀疑无法在期限前完工时,很多人可能会不管三七二十一,忍不住以最快的速度先把这个特性给 弄出来再说,然后等有了充足时间之后再回过头去进行一些测试和重构.然而,这里的"回过头 去进行一些测试和重构"正是困难所在。人们在越过前面提到的障碍期之前常常是采取回避的态 度。这可能会成为一个"士气"上的问题.请阅读第 24章,这部分对此有一些建设性的意见。 到目前为止我们所描述的似乎是个左右为难、进退维谷的境地:是现在就花时间呢还是等到 以后付出更多的代价?要么在进行修改时编写测试,要么就得忍受系统变得越来越难对付的现 实。虽然有时候的情况的确会像这样棘手,但有些时候并不是。 如果现在就需要对某个类进行修改,可以先试着在测试用具中实例化这个类。若不能,请参 考第 9 T.f或者第 10 章。将要修改的代码放入测试用具可能比想象的要简单。如果在了解这两章所 提到的方法之后还是觉得实在没法承受现在就去解依赖并安置好测试的代价的话,那就仔细分析 一下你所要进行的修改。可以通过编写全新的代码来完成它吗?很多时候这是可行的。本主立的其 余部分就描述了这方面的几个技术。 阅读下面介绍的技术并考虑采用它们,但要记住,用这些技术的时候必须小心 。当你用这些 技术时,虽然是在系统中添加已被测试的代码,然而除非你用测试覆盖了所有调用这些代码的代 码,否则还是没有对这些代码的使用进行测试。所以,小心使用。国 52 第二部分修改代码的技术 6.1 新生方法 当需要往一个系统中添加特性且这个特性可以用全新的代码来编写时,建议你将这些代码放 在一个新的方法中 ,并在需要用到这个新功能的地方调用这一方法。 你可能没法很容易地将这些 调用点置于测试之下,但至少可以为新编 写 的那部分代码进行测试 。 下面就是一个例子 ? public class TransactionGate { public void postEntries(List entries) for (Iterator it entries.iterator(); it .hasNext() ; ) En巳 ry entry (Entry) it . next () i entry .postDate() ; transactionBundle . getListManager() . add(entries) ; 对于上面的类,我们需要添加代码 来检查 entries 中 的对象在日期被发送 并添加到 transactionBundle 中去之前是否 已经存在了 。从上面的代码来看,似乎这一检查需要放在 pos tEntries方法的一开始进行. ~P for循环的前面 。然而实际上它可以在循环 当 中进行 。我们 可以这样来进行修改 public class TransactionGate { public void postEntries(List entriesl List entriesToAdd new LinkedList () i for (Iterator it entries . iterator(); it . hasNext () ; ) Entry entry (Entry) i 巳 next() ; if (!transactionBundle.getListManager() . hasEntry(entry) entry.postDate(); entriesToAdd. add (entry) ; transactionBundle. getListManager() . add(entriesToAdd) ; 这看上去是个挺简单的修改,然而其侵入性相当强.比如说,我们怎么知道修改是正确的呢? 在添加的新代码与原有的老代码之间并没有任何分界 。更糟的是 , 代码的清晰性与原来相比稍稍 下降了 。 问题在于我们将两个操作混在一起了. 一个是日期发送,另 个是重复项检查.这个方 法尚且是相当小的,就 已经显出混淆不清的端倪了,而且还多出了一个新的临 时变盘 ,可想而知 大的方法会怎样。 临 时变量并不一定是坏事 , 但有些时候它们会招来新 的代码。试想,如果我们 接下来要进行 的 一项修改与那些未重复的项相关(当然,是在将它们添加到O transaction­ Bundle 中去之前)。这时你会发现代码中只有一个地方存在一个满足要求的变量 2 就是这个方法 中的 entriesToAdd临时变量。于是一个极具诱惑力的选择就产生了:将新的代码直接添加到这第 6 章 时间紧迫,但必须修改 53 一个方法当中 . 但是,这件事情能否用另 一种方式来完成呢 ? 答案是肯定的 . 我们可以将重复项移除看作一个完全独立的操作 . 可以使用测试驱动开发 (74 页)方式来创建一个新的方法unique E ntries . 如下所示 : public class TransactionGate { List uniqueEntries(List entriesl List resul t new ArrayList () ; for (Iterator it ent ries . iterator() ; it .hasNex t {); ) } Entry entry (Entry)it . next() ; if (! transactionBundle . getListManager() .hasEnt ry(entry) result . add(entry) ; return result ; 按照测试驱动开发的思想,编写→个测试以便驱动我们写 出上面这样的方法应当不算难事. 完成之后,可以回到原先的代码,也就是待修改的 p ostEntrie s 那里,添加一个对该方法 的调 用,如下所示: public class TransactionGate { public void postEntries (List entries) { List entriesToAdd uniqueEntries (entries) ; for (Ite r a tor it ent riesToAdd. iterator( ); it .hasNext () i ) Entry entry (Entry)it . next() ; entry . postDate() ; } transactionBundle . getLis tManager() .add(entriesToAdd); 你会发现上面的代码中依然出现了 一个临时变量,但关键是现在的代码清晰多了 . 如果我们 想要添加一些针对非重复项 1 的代码,就同样可以创建一个新方法来做这件事 ,然后在适当的地 方调用它即可 。 如果最后我们发现还有其他一些代码也是针对这些非重复项的话,便可以引入一 个新的类,将所有这些新生的方法移到该类 中 。 这样做的效果就是保持了 post Ent ri es 的简短, 同时所杳相关 的方法,无论是postEntries还是新生方法都保持了简短和易于理解性 。 以上就是新生方法 (Sprout Method) 技术的一个例子。实施这一技术时实际需要采取的步骤 如下: (1) 确定修改点 。 (2) 如果你的修改可以在一个方法中的一处地方以单块连续 的语句序列出现,那么在修改点 I 也就是 entriesTo Addo 一一译者注 固54 第二部分修改代码的技术 插入一个方法调用,而被调用的就是我们下面要编写的、用于完成有关工作的新方法。然后我们 将这一调用先注释掉(我个人喜欢把这一步放在编写新方法之前,因为这样我就能对新方法调用 [IiJ 在上下文中的样子有一个认识)。 回 (3) 确定你需要原方法中的哪些局部变量,并将它们作为实参传给新方法调用。 (4) 确定新方法是否需要返回什么值给原方法。如果需要的话就得相应修改对它的调用,使 用一个变量来接收其返回值。 (5) 使用测试驱动的开发方式来开发新的方法 。 (6) 使原方法中被注释掉的调用重新生效。 任何时候,只要你发现待添加的功能可以写成一块独立的代码,或者暂时还没法用测试来覆盖 待修改方法时,我都建议你采用新生方法。这比直接往原方法中添加代码好多了。 有时候,当你想要使用新生方法技术时,会发现方法所在类的依赖关系实在太恶劣,以至于 (为了测试这个新生方法〕要想创建 个该类的实例,就得先"伪造" 一大堆它的构造函数的实 参才行。这时,一个替代方案便是使用传 Null技术。而当这条路也行不通时,考虑将新生方法设 为一个公用静态方法。这样一来,你可能不得不将原类的实例作为实参 1 传给这个新生方法,但 至少你可以进行修改了。因此而将 个方法设为静态似乎有点奇怪,但对于遗留代码来说这种做 法有时是有用的。我倾向于将类的静态方法看成是一个"临时场地飞通常当静态方法的数目累 和UI'i\来了并且你发现它们共享了某些相通的变量时,就可以新建一个类,将这些静态方法移到这 个新类中,并让它们成为新类的实例方法。而后面当是时候把它们变回原先那个类的实例方法时 , 还可以移回去,当然,前提是你终于将原先的类置于测试之下了。 优点和缺点 新生方法技术有优点也有缺点。让我们先来看看它的缺点。首先,当使用它时,效果上等于 暂时放弃 了 原方法以及它所属的类,也就是说你暂时不打算将它们置于测试之下和改善它们了, 而只打算写一个新的方法来实现某个新功能。有些时候放弃一个方法或类是迫于现实,但还是有 点令人惋惜的,因为你的代码处于 个尴尬的境地。原方法可能包含了大量复杂的代码以及个 新生方法。有时候事情并不明朗,为什么偏偏就那点工作要放到其他地方去呢?况且它又令原方法 "身陷囹圄气不过至少它也提醒你,当终于将原类置于测试之下时,可以回头做一点补救。 尽管新生方法技术有一些缺点,但它也有一些突出的优点。比如新旧代码被清楚地隔离开。这 样即使暂时没法将旧代码置于测试之下,至少还能单独去关注所要作的改动,并在新旧代码之间建 立清晰的接口。你会看到所有被影响到的变量,更容易确定新的代码在上下文中是否是正确的。 6.2 新生类 虽然新生方法已算是一 门强大的技术,但在一些依赖关系错综复杂的场合,这一技术还不够 I 作用相当于 th 工 s 隐参. 译者注第 6 章 时间紧迫,但必须修改 55 一 强大。 设想,这样一种情形·你要修改一个类,但想尽一切办法也不可能在合理时间限期之内使这 个类在测试用具中被实例化,这就表示你不可能在这个类上创建新生方法并为其编写测试。或许 你的类在对象创建方面有大量的依赖(比如构造函数的参数的依赖),使得该类难以实例化。又 或者有许多隐藏的依赖.要想解除这些依赖,得进行大量侵入式重构将它们分离出来,从而最终 使该类能够在测试用具中编译。 遇到这类情况,就可以创建另一个类来容纳所要进行的改动,并在原类中使用这个新类。让 我们来看一个简化的例子。 下面是一个名 为 Qu arterlyReportGenerator 的 C++类上的一个古老方法: std: :string QuarterlyReportGenerator : :generate() { std : : vector results database . queryResults ( std: :string pageText ; pageText += ~. "Quarterly Report" ""; 正 f (results.size() != 0) { beginDate , endDate); for (std: : vector: : iterator it results . beg 工 n(); it ! = results . end() ; ++it) pageText +二 " "; pageText += ""; char buffer [1281 ; sprintf (buffer, " " , it->netProfit / 100); pageText += std: :string(buffer) ; sprintf (buffer, " " , it->operatingExpense / 100); pageText += std: : string(buffer) i pageText += -"i } else { pageText += "No results for this period" i pageText += "
" + it->department + "< /td>"; pageText += "" + it->manager + "$ 毡 d$ 警 d
"i pageText += ""i pageText += "- i return pageText i 现在我们要给它生成的 HTML表格添加一行标题栏,格式就像下面这样: "DepartmentManagerprofitExpenses" 此外,我们假设QuarterlyReportGenerator是一个巨大的类,要将它成功放入测试用具 中得花上大约一天工夫,而这么长的时间是我们无法忍受的. 固56 第二部分修改代码的技术 于是,我们可 以将改动做成一个小型的类 ,叫 QuarterlyReportTableHeaderProduc e r , 并使用测试驱动的开发方式来开发这个类 。 using namespace stdi class QuarterlyReportTableHeaderProducer { public string makeHeader() ; ); string QuarterlyReportTableProducer : :makeHeader ( ) { return MDepartmentManagerM " Prof 工 tExpenses" ; 完成这个类之后 ,可以创建它的 实例,并在 QuarterlyReportGenerator : : genera t e () 中使用它: Quart erlyReportTableHeaderProducer producer ; pageText += producer . makeHeader() ; 相信读到这里你肯定会忍不住说"嗨 1 这家伙肯定在说笑。 就为这点小事去创建一个新类 也太荒唐了?这只是一个小得不能再小的类 ,在设计上也没有带来任何好处 , 而且它引入的一个 全新概念使代码更混乱了 。 "诚然,这个时候你的确是对的 。 但请注意,之所以这么做,唯一 的 国 目的就是为了摆脱一个恶劣的依赖环境,让我们更仔细地考察这个问题。 如果我们将这个新类命名为 Quarterl y Rep o rt Tab leHeaderGene r a tor ,并把它的接口调 整为下面这样,情况又会如何呢? class QuarterlyReportTableHeaderGenerator { public string generat e (); 这么 来这个类就成了我们所熟悉的概念的一部分了 。 Quart e rl y ReportTableHead e r­ Generator 与 QuarterlyReportGenerator一样是个生成器, 它们同样都具有返回 字符串的 genera te ()方法。 因此我们用代码的形式来将这一共性文档化,即创建一个公共的接 口类,并 让这两个类都继承这个接口,如下· c lass HTMLGenerator public virtual -HTMLGene rator () 0 ; virtual string generate ( ) 0 ; 第 6 章 时间紧迫,但必须修改 57 class QuarterlyReportTableHeaderGenerator public HTMLGenerator { public virtual string generate () ; class QuarterlyReportGener ator public HTMLGenerator [ public V H 巳 ua l string gener ate(); 随着工作的进一步开展,我们最终可能将 Quart er l y ReportGen e r at o r 放入测试并修改其 实现,让它使用新的生成器类来完成其大部分工作 . 在这个例子中,我们能够快速将新类归入到应用中既有的那些概念中去 。 而在许多其他场合 下则做不到这一点,但这并不意味着应该退却 . 有些新生类可能永远都无法归入到当前应用中的 那些主要概念中 。 取而代之的是它们自身变成了新的概念 . 你最初新建一个类时,可能会觉得它 WJ 对于你的设计来说相当无关紧要,直到发现在某些其他地方也做了类似的事情并看到了它们之间 的相似之处 。 有时能够将那些新生类中重复的代码分解 出 来,通常你还得给它们重命名,但别指 望这个过程能够一盼而就。 你初次创建一个新生类时看待它的方式和几个月后看待它的方式往往会有相当大的差别。系 统中存在这么个怪异的新类,这一事实本身就带给了你大量的对它进行思考的机会。比如当你需 要做一个与它联系紧密的修改时,可能就会开始想这个修改是否是这个新生类的概念的一部分, 或者说这个概念本身需要做点调整。所有这些都是设计过程的一部分。 从根本上来说 , 两种情况下我们得使用新生类 (Spro ut C lass) 。 第一种情况.所要进行的修 改迫使你为某个类添加一个全新的职责。例如,在报税软件中,当前年份的某些特定时期某些课 税减免可能是不可行的 . 为此你可能想到往TaxCa l culator类中添加日期检查功能,但这个检 查已经偏离了 TaxCal c ula t or 的主要职责了 。 顾名思义, Ta xCalcula t or 的主要职责就是税的 计算 。 所以日期检查功能应该被做成一个新的类 . 另 一个例子就是本章开头的那个例子 。 我们想 要添加的只是一点小小的功能,可以将它放入一个现有的类中,但问题是我们无法将这个类放入 测试用具 。 哪怕至少能将它编译进测试用具,也还能试着用新生方法技术,只可惜有时候就连这 点运气都没有 . 弄清这两种情况的关键在于认识到虽说它们之间的动机不同但从结果来看其实并无显著 区别。 -个功能是否强大到足 以成为 一个新的职责,完全凭个人判断.此外,由于代码会随着时 间的推移不断变化,所以决定催生 出 一个新类常常在事后被证明是较好的选择。 1 个是为 了 避免职责混滑,另 个则是因原类无法放λ测试用具 . 一一译者注58 第二部分修改代码的技术 新生类技术的步骤如下: (1)确定修改点。 (2) 如果你的修改可以在一个方法中的一处地方以单块连续的语句序列出现,那么用一个类 来完成这些工作,并为这个类起一个恰当的名字。然后,在修改点插入代码创建该类的对象,调 用其上的方法(这个方法就是负责完成你需要完成的任务的方法),然后将刚插入的这几行代码 注释掉。 (3) 确定你需要原方法的哪些局部变量,并将它们作为参数传递给新类的构造函数。 (4) 确定新生类是否需要返回什么值给原方法,如果需要,则在该类中提供一个相应的方法, 并在原方法中插入对它的调用来获得其返回值。 ~ (5) 使用测试驱动的开发方式来开发这个新类。 (6) 使原方法中(第一步)被注释掉的代码重新生效。 优点和缺点 新生类技术的主要优点就在于,它让你在进行侵入性较强的修改时有更大的 自信去继续开展 自己的工作。在 c++ 中,新生类技术还有一个额外的好处,就是不必改动任何已有的头文件就可 以完成修改。你可以在原类的实现文件中包含新生类的头文件。此外,往项目中添加一个新头文 件也是件好事。随着时间的推移你会逐渐把声明都放到新头文件中去(否则最终这些声明就会落 到原类的头文件中了)。这一做法降低了原类的编译负担。至少你知道并没有雪上加霜。一段时 间以后,你或许能够重访原类,并将其置于测试之下 。 新生类技术的主要缺点在于它可能会使系统中的概念复杂化。随着程序员对一个新的代码基 的学习不断深入,他们会对其中的核心类如何协同工作建立起一种认识。而当使用新生类时,就 开始破坏系统中原杳的抽象,并将大批工作放在其他类中进行。不可否认,有些时候这么做的确 是完全正确的。但也有一些时候则是因为别无选择,为了能够安全地进行修改,理想情况下应当 呆在原杳类中的代码最终却栖身在新生的类当中,实属无奈之举。 6.3 外覆方法 给现有方法添加行为是件简单的事情, 但常常却并非正确。一个方法最初被建立起来时通常 只为 一个客户做单一的事情。此后往里面添加的任何代码某种程度上都是值得怀疑的。很可能你 添加这些代码只是因为它必须跟其他那些代码同时执行而己。早些年这被称为时间搞合,糯合的 过度出现是件相当可怕的事情。仅因为两段代码在同一时间发生就组织到一起,它们之间的关系 并不是很强的。一段时间后你可能又会发现只需要执行其中的一段代码而不需要同时执行另 一段 了,可是到那时候也许这两段代码已经"生长"在一起了。在没有接缝的情况下,要想将它们剥 [ill 离开来是件困难的工作。 当需要添加行为时,可以考虑使用不那么"纠缠"的方式。 可以使用的技术之一就是新生方 法,但还有一项技术有些时候也是很有用的。我把它称为外 clt方法 (Wrap Method) 。下面就是一个简单的例子· public class Employee [ publ 工 c void pay () ( Money amount new Money () i 第 6 章 时间紧迫,但必须修改 59 for (Iterator it timecards . iterator(); it . hasNext(); ) Timecard card (Timecard)it . next() ; if (payPeriod. contains(date)) amount.add (çard.getHours () * payRate) ; payDispatcher.pay(this , date, amount); 在上面的方法中,我们合计了一个雇员 (Ernployee) 每天的考勤卡 ( Timecard) ,然后将 他的薪水支付信息发给一个 PayDispatcher 。现在假设出现了一个新的需求。每次我们给一个 雇员支付薪水时都得做一下日志记录,以便将日志发给某个报表系统。不管怎样,这个新的功能 必须跟原有功能在同一时间发生。如果我们像下面这么做= public class Employee { private void dispatchPayment() { Money amount new Money 川, for (Iterator it timecards . iterator(); it .hasNext(); ) Timecard card (Timecardl 工t. next () ; if (payPeriod.contains(datel) ( amount.add(card.getHours() * payRate) i payDispatcher . pay(this , date, amount) i publ ic void pay () { logpayment ( ) ; dispatchPayment(); private void logPayment() 在上面的代码中,我们将原先的 pay() 重命名为 dispatchPayment 仆,并将它改为私有方 法。接着我们创建一个新的pay() 方法,它调用了重命名后的 dispatchPayrnent() 。不过,这 个新的pay() 方法会先将一次薪水支付记录下来,然后才"去调用 dispatchPayrnent() 。如此一 来 ,以前一直是调用 pay() 方法的客户则不必知道也不必关心这次改动.他们还像原来那样进行 ~~ 调用,一切都运行良好. 凶旦j60 第二部分修改代码的技术 这是外覆方法的运用形式之一 创建一个与原方法同名的新方法,并在新方法中调用更名后 的原方法。 当想要为原方法的既有调用添加行为时,就可以采用这种做法 。 如果希望客户每次调 用 pay() 时都会产生日志记录的话,该技术是十分有用的 。 外覆方法还有另一种运用形式,如果只是想增加一个尚未有任何人调用的新方法,就可以采 用这一形式 。 例如,在前面的例子中,如果想要将日志记录显式暴露 出来,则可以为 Empl oyee 类增加一个mak e LoggedPayment方法,如下: public class Employee { pub lic void makeLoggedPayment () logpayment() ; pay () ; public void pay () ( private voi d logPayment( ) 这样用户就可以在两种支付方式之间自由选择 。 Kent Beck在 Smalltalk Patterns: Best Practic町 (Pearson, 1996 )一书中描述了这一技术 。 要想在添加新特性的同时引入接缝,外覆方法是极好的选择。它只有少数几个缺点。第一 , 你添加的新特性无法跟旧特性的逻辑"交融"在一起 。 它们要么在旧特性之前要么在之后完成 。 事实上这并非坏事,建议你尽量这么做 。 第二个缺点,也是更为实际的一个缺点就是,你得为原 方法中的旧代码起 一 个新名 字 。本例中我是将原 pay() 方法中的那些代码命名为 di spatc hPaym e nt () 。 其实这并不十分恰当,而且说实话我也并不喜欢例子中代码最终的样子 。 dispa t chPayment() 其实并不仅仅是进行"你 patcb (分发)",它还负责计算薪水。如果它的测 试己被安置到位的话,很可能我会将它的第一部分提取到一个独立 的方法 cal c ulate Pay () 中 lliJ 去,并对pay() 方法作相应改动: public void pay () { logPayment ( ) ; Money amount c alculatePay () ; dispatchPayment (amountl; 这样所有职责就都被良好地分离开来了 。 外覆方法的第一种形式的实施步骤如下: (1)确定待修改的方法 。 (2) 如果你的修改可以在一处地方以单块连续的语句序列出现,那么将待修改方法重命名,第 6 章 时间紧迫,但必须修改 61 并使用其原先的名字和签名创建一个新方法。在这么傲的时候记住要签名保持 (249页)。 (3) 在新方法中调用重命名后的原方法。 (4) 为欲添加的新特性编写一个方法(当然,还是编写测试在先),并在第 2步创建的新方法 中调用这个方法。 在外覆方法的第二种运用形式中,并不一定要使用跟旧方法同样的名字来命名新方法,步骤 如下回 (1)确定待修改的方法。 (2) 如果你的修改可以在一处地方以单块连续的语句序列出现,那么用测试驱动的开发方式 编写一个新方法来容纳新的特性。 (3) 创建另一个函数来调用新 旧 两个方法。 优点和缺点 当我们没法为调用代码编写测试时,外覆方法是将新的、经过测试的功能添加进应用中的好 途径。新生方法和新生类都会将代码添加到现有方法中,至少增加一行,而外覆方法则不会增加 现有方法的体积。 外覆方法另 一个好处就是它显式地使新功能独立于既有功能。为某一 目的而作的代码不会 ß~ 另一意图的代码五相纠缠在一起。 外覆方法的主要缺点在于它可能会导致糟糕的命名。在上面的例子 中 ,我们将原方法pay{) 重命名为 dispatchPay ()只是因为要给它起一个新名字。如果代码并非十分脆弱或复杂,或者 如果有一个能够安全实施方法提取 02 5 页)的重构工具,就能够进行进一步 的提取,并最终得 到更好的命名。然而, 许多时候我们之所 以进行方法外覆正是因为缺少相应的测试,代码脆弱且 没有上述工具。 四 6.4 外覆类 外覆方法的类版本便是外覆类 (WrapC1ass) ,两者概念几乎一模一样。如果需要往一个系统 中添加行为,我们 固然可以将该行为放到一个现有的方法中,但我们同样可以将它放到一个使用 了该方法的类当 中 。 让我们来回顾一下 Employee类: class Employee ( public void pay () { Money amount new Money () ; for (Iterator it timecards . iterator (); it _ hasNext (); ) } Timecard card (Timecardlit . next() ; if (payPeriod.contains(date)) amount . add(card . getHours() * payRate); payDispatcher.pay(this , date , amount); 62 第二部分修改代码的技术 假设想要让日志记录我们正在向某个特定的雇员支付薪水的事实。可以新建一个具有 pay() 方法的类。该类的对象可以持有一个雇员对象,在这个pay ()方法中完成日志记录,然后将剩 下的任务委托给这个雇员对象上的相应方法 1 来完成。通常,如果无法在测试用具 中 实例化原 类的话,完成上述工作的最简单途径就是对原类使用实现提取 (281 页)或接口提取 (285 页), 并让外覆类实现该接口。 在下面的代码中,我们使用了实现提取手法来将 Employee类变成一个接口。然后我们新建 一 个名为 LoggìngEmployee 的新类,让它实现该接口。我们可以将任何 Employee 传递给 LoggingErnployee 以便后者用它来做曰志并同时付酬。 class Logg 工 ngEmployee extends Employee { public LoggingEmployee(Emp!oyee e) ernployee e i public void pay () { logPayrnent ( ) ; employee. pay ( ) ; 回 p rivate void 叩ayment () 该技术在设计模式里面被称作装饰模式。我们用一个类来外覆另一个类,并创建/传递那个 外覆类的对象。这里的外覆类须得具有与被外覆类相同的接口,这样一来使用者就不会知道他们 使用的是一个外覆类了。上例中的 LoggingErnp loyee是Emp loyee 的一个饰类,它得具有 pay() 方法,以及 Ernployee上会被客户用到的所有其他方法。 装饰模式 装饰模式允许你通过在运行期组合对象来构建复杂的行为.例如,在一个工业过程控制系 统中,我们可能会看到一个叫做 Too!Controller 的类,该类具有 ra 工 se( )、 lower ()、 step ()、 on() 以及off ()等方法。如果想要在每次 ra 立 se( )或 lower ()时都做一些额外的事 情(如发出蜂呜来警告人们在意操作安全),一条显而易见的途径就是将这一功能直接放到这 e 两个方法中.然而很可能接下来还想进行其他改进.例如最后我们可能需要记录下控制器开关 的次数.可能还想在我们进行 step( )时通知附近的其他控制器不要同时 step(). 伴随着这五 步简简单单的操作( raise 、 lower 、 step 、 on和 off) 可以进行的额外工作是无穷无尽的, l 上面的示例代码中的 pay () ð 译者注第 6 章 时间 紧迫 , 但必须修改 63 为其中每种组合都创建一个子类的做法显然是不实际的,因为可能的组合是无穷无尽的 . 装饰模式正适合用在这类场合下 . 使用该模式时 , 首先创建一个抽象类 , 该抽象类定义了 你需要支持的一纽操作.然后创建该抽象类的一个子类,该于类的构造函数接受任一从抽 象类 派 生出的实体类的对象,并且该子类为抽象类中的每个方法提供一个实现 .下面就是 ToolController 问题的解决方案: abstract class ToolControllerDecorator extends ToolController { protected ToolController controlleri public ToolControllerDecorator (ToolController controller) t his _ controller controller i public void raise () { controller . raise () ; public void lower () ( controller . lower () ; public void s 巳 ep () { controller. step () ; } public void on () { controller . on () ; } public 飞!O id off{) ( controller . off{) ; } 上面这个类看起来可能并不是很有用,但实际土恰恰相反 . 你可以对它进行子类化并重写 它的任一或所有方法来添加额外的行为 . 例如,如果我们需要在步进时通知其他控制 器 的话, 就可以编写一个名为 StepNot 江Y 工 ngController 的类 , 如下- public class StepNotifyingController extends ToolControllerDecorator { private List notifyees ; publi c StepNotifyingController(ToolController controller, List notifyees) s uper(controller) ; this . notifyees notifyees i public void step () ( 11 notify all notifyees here controller . step() ; 这一手法的真正漂 亮之处在于我们可以将 ToolCont rollerDecorator 的于类 "层层嵌 套"起来: ToolController controller new StepNotifyingController( new AlarmingController ( new ACMEController ( )) , notifyeeS) i 对于像上面这样创建起来的一个对象(以控制器持有),当我们调用控制器尘的某个操作 (如 step () )时,它不仅会通知其他相应的控制器,还会发出峰呜,同时最后会执行真正的 step( ) 方法,这最后一步走在 ACMEController 内部发生的, ACMEController 是 ToolController 的一个实体于类,而不是ToolControllerDecorator 的于类 . 它 并不将 回64 第二部分修改代码的技术 任务交给其他ToolController去做,而是自己完成.使用装饰模式的时候你至少需要一个像 这样的"基础"类,并从远种类开始一层层进行外覆 . 装饰是个不错的模式,但还是保守使用比较好.在一个装饰类套一个装饰类的代码中"行 走"就好像是在一层一层地剥洋葱皮一样,剥洋葱、皮固然是必要的工作,但弄不好会让你呛出 眼泪来. 当已经存在了许多对类似pay() 方法的调用时,以上就是一种很好的往其中添加功能的途 径。不过,还有一个不那么"装饰性"的方法也可以用来进行外覆。设想这样一种情况:只需在 唯一一处地方记录对pay() 的调用 . 这次我们不采取将这一功能作为饰类来5\>tpay ( ) 进行外覆的 做法,而是把它放到另一个类当中,该类接受一个 Employee对象,调用它的薪水支付方法,然 后用日志记录下关于这次支付的相关信息。 下面就是这样一个类: pe D 。 Y1 由闷闷叫 因 public LoggingPayDispatcher( Employee e) { th 主 S .e e; public void pay ( ) employee . pay ( ) ; logPayment() ; pri vate void logPayment () { 于是现在就可以在那处需要记录支付信息的地方改用 LogPayDispatcher (而不是 Employee) 了。 外覆类的关键在于,当用它往系统中添加新行为时,无需将新行为塞到现有类当中去。倘若 已经存在了一堆对你想要进行外覆的代码的调用,使用"装饰性"的外覆手法通常是不错的选择. 借助于装饰模式,你可以透明地一次性将新的行为添加到一组像pay() 这样的现存调用上 .另一 方面,如果你只需要将新的行为添加到少数几个地方的话,一个很有用的做法就是创建一个非"装 饰性"的外覆类 l 。并且,随着时间的推移,应该对外覆类的职责加以关注,看看它能否成为系 统中的另 一个更高级的概念。 下面就是外覆类手法的步骤: 1 见上面的例于 . 译者注第 6 章 时间紧迫,但必须修改 65 一 (1)确定修改点。 (2) 如果你的修改可以在一处地方以单块连续的语句序列出现,则新建 个类,该类的构造 函数接受需要被外覆的类的对象为参数。如果你无法在测试用具中创建外覆类的实例的话,你可 能需要先对被覆类使用实现提取或接口提取技术,以便能够实例化外覆类。 (3) 使用测试驱动的开发方式为你的外覆类编写一个方法,该方法负责完成你想要添加进系 统中去的工作。编写另一个方法,这个方法负责调用刚才创建的那个方法以及被覆类中的旧方法。 (4) 在系统中需要使用新行为的地方创建并使用外覆类的对象。 新生方法与外覆方法的区别是相当细微的。在使用新生方法时,你创建一个新方法,并在现 存方法中调用它。而当使用外覆方法时,则是先重命名 一个现有方法,然后用 个新建的方法来 llil 替代它(起名为它原先的名字) ,这个新建的方法会完成你想要往系统中添加的工作,并将剩下 的任务委托给重命名后的旧方法。通常,如果现有方法中的代码将一个清晰的算法传达给了读者, 我就会使用新生方法。而如果我觉得欲添加的新特性的重要性跟已经存在的特性不相上下,就会 转而使用外覆方法。在后一种情况下,完成外覆之后,通常会得到一个新的高层算法,如下所示: public void pay () ( logPayment () i Money amount calculatepay () i dispatchPayment{amount) i 而是否选择外覆类则完全是另一个问题。外覆类的"使用阙值"要更高一些。 一般来说,两 种情况促使我们去使用外覆类 z (1)欲添加的行为是完全独立的,并且我们不希望让低层或不相关的行为污染现有类。 (2) 原类已经够大了,我实在不能想像把它撑得更大会如何。遇到这类情况,使用外覆类只 是相当于在地上插个木桩,为后面的修改设下 个标识。 以上第二种情况比较难办,也比较难以习惯。设想你有一个非常大的类,具有例如说 10个甚 至 15 个不同的职责,这时只为了添加某个微不足道的功能就去新建一个外覆类似乎杳点说不过 去。事实上,如果你这么做而又不能在同事面前给出有说服力的理由的话,可能会被他们"优待" 一番,更糟的是你的同事可能从此对你嗤之以鼻。所以……让我告诉你怎么处理吧。 在对一个大的代码基进行改进时,最大的障碍就是现存代码。对此,你可能不以为然。但请 注意,关键不在于那些难对付的代码要花多少工夫去对付,而是这样的代码给你带来的心理负担。 如果你把一天中的大部分时间浪费在丑陋的代码之中,很快就会觉得这些代码永远也没有漂亮起 来的→天,而且试图对它进行改进的任何努力都是不值得的。你可能会想"我做的这点工作相 对于整个系统的糟糕现状来说无异于杯水车薪,又能起到多大作用呢?没错,我可以把这一小块 代码加以改进,但那对我今天下午的工作又有多大好处呢?明天又将如何呢? "唉,如果你这么 想的话,我当然没法不同意你。但如果你持续不断进行这类小改进的话,几个月后你的系统会变 illJ 得大不一样了 o 某天早晨当你一如既往地开始工作时,会惊讶地发现原来那团丑陋不堪的代码消 失了,你想"唔……这代码看起来相当不错嘛。似乎有人最近一直在对它进行重构。"那一刻你 会由衷发现好的代码跟糟糕的代码之间的区别,这就代表你的观念彻底转变了。你甚至发现自己66 第二部分修改代码的技术 主动想要去进行更多的重构了,而这么做只是为了令自己以后的工作轻松些。当然,如果你从未 经历过类似以上的场景,这一切对你来说可能有点滑稽,但可以告诉你的是,我一次又一次地见 证了这样的场景发生在一个又一个团队中。要想做到这些,最困难的地方就在于最初的几步,因 为有时候你可能会觉得没必要。"什么?就为了添加这么个小小的特性还要去大费周章地外覆一 个类 9 这下弄得似乎比之前还要糟糕了、更复杂了。"是的,就当时来说这的确没错,但当你开 始逐渐分解那个被覆类中的 10或 15 个职责时就不会这么想了. 6.5 小结 本章罗列了 一系列技术,借助于它们,你无需将现有类纳入测试便可以对其进行修改。从设 计的角度来说,很难说清到底应该怎么来理解它。许多时候,这么做使得我们能够在新旧职责之 间拉开一段距离。换句话说,我们正朝着更好的设计前进。然而在另 一些情况下,之所以去创建 一个新类唯一 的原因就是我们想要编写受测试的新代码 ,而目前还没有时间去将那个现有类纳入 测试。这是个很现实的情况。当你在实际项目中这样做时,将会看到新的类和方法在旧的、巨大 的类旁不断冒出来。但接下来会发生一件有趣的事情· 一段时间之后,你对于总是避开旧类庞大 的身躯感到厌烦了,于是开始试图将它纳入测试中。而这项工作的一个成分就是去熟悉你要纳入 测试的类,所幸的是,因为你常常要去查看这个巨大的未测试类来决定从什么地方抽生出新类来, 也变得越来越了解它。因此把它纳入测试也就变得越来越不可怕了。此外,这项工作的另 一个部 分就是要避开厌烦情绪。你看着屋子里的垃圾会感到厌烦,想要将它们扫地出门,请参考第 9 章cm 和第 20章。第 7 章 漫长的修改 实施一次修改要花多少时间?答案不一定,不同的情况下差异可能相当大。对于那些代码极 度不清晰的项目,大部分修改可能都需要花上很长时间.首先我们得阅读并认识代码,明白修改 会带来的所有影响,然后才能动手去修改。对于比较清晰的部分,修改起来可能很快 , 而对于那 些"盘根错节"的部分,则可能要花上很长时间.此外,团队与团队之间的情况也有所不同,有 些团 队情况很糟糕 , 即便是最简单的修改也要花上大量时 间 。他们知道需要添加的是哪些特性, 能够想像出应当在哪儿进行修改并在5 分钟内完成,然而几小时后仍无法发布他们的修改。 让我们来看一看其背后的原因以及一些可能的解决方案。 7.1 理解代码 随着代码量的增加 ,项目就会变得越来越难理解。于是人们也就需要花费越来越多的时间才 能弄消应当修改什么。 某种程度上这是不可避免的。当往一个系统中添加代码时,可以将代码添加到现有的类/方 法/函数中,也可以创建新的类/方法/函数。不管采取哪种做法,只要我们尚不熟悉相关的上下文, 就没法很快知道如何进行修改。 然而, 一个维护良好的系统和一个遗留系统之间有一个显著的区别:对于前者 , 你可能要花 上一点时间来想想该如何修改, 一旦想清楚了,改起来往往很容易,改完后的系统也感觉舒服多 了。而在一个遗留系统中,可能得花上很长一段时间来搞清楚应该怎么做,同时修改起来往往也 不容易.此外你可能还会觉得,除了因为修改而必须去理解的那一小块代码之外,并没有了解到 [TI] 多少其他东西。最糟的情况是,需要预先理解的代码好像不管花上多少时间都理解不完似的,最 后你不得不闭着眼睛冲进代码 , 心里默默祈祷自己能够搞定所有将要遇到的问题。 那些由小块的、命名良好的、可理解的部件组成的系统对付起来更为容易。如果在你的项目 中"代码理解"是一个大 问题的话 , 请阅读第 1 6章和第 17 章寻找应对之策。 7.2 时滞 还有一个非常普遍的因素会导致修改耗时的延长,这个因素就是时滞 Oag time) ,是指从做出68 第二部分修改代码的技术 修改到得到反馈所经历的时间。打个比方,在我写下这些时,火星漫步者 Spirit正在火星表面缓慢 爬行,拍摄照片。信号从地球传到火星大约要花上7分钟。 幸运的是, Spirit车载的导航软件能够帮 助它自行在地面上移动。不过你可以设想一下,若是在地球上手动驾驶这个远在火星的探测器会是 什么样的情形 .每一个操作都要等上 14分钟才能看到结果 .然后根据结果再决定下一步干什么,完 了之后再等 14分钟…..效率如此之低简直荒唐。然而,如果仔细想想,你会发现这正是大多数人目 前在软件开发中使用的方式。我们通常作一些改动,编译并构建,看看会发生什么。然而遗憾的是, 并没有一个软件知道如何躲避构建过程中的暗礁,所谓暗礁就是指像测试失败这类东西。取而代之 的是,我们试图一次性进行一系列的修改,以免构建活动太过频繁。如果修改没问题,则一切平安 无事,我们继续,行动速度就如 Spirit一样慢 。 而如果触礁了的话,则会慢上加慢。 这种工作方式的悲哀之处就在于,在大多数语言中完全没必要这样做,这完全是浪费时间 。 实际上,在大多数主流语言中,都可以通过解依赖在 10秒钟内实现对代码的重编译并运行测试。 在大多数情况下,如果-个团队真的鼓起劲来干的话,这个时间甚至可以缩短到 5秒以内。最终 的情况就像这样对于系统中的每个类或模块,你应当能够独立地在它们各自的测试用具中编译 [ill 它们。只要做到了这一点,就能得到快速反馈,从而提升开发效率。 人类大脑有一些有趣的地方。比如说,假设我们要执行一个短任务 (5-10秒) ,然而每分钟 却只能执行该任务的一个步骤。那么我们会怎么做呢?通常我们会完成一步,然后停下来等着 。 如果这期间需要做一些工作来决定下一步该干什么的话,我们就开始计划。计划完之后,大脑便 无事可做,等着下一步开始时刻的到来.然而,如果将步与步之间的问隔从一分钟缩短到儿秒钟, 情况就不同了 .我们可以利用反馈来快速尝试不同的方案.于是我们的工作更像是在驾驶汽车, 而不是在车站等公交车了。此外 , 我们也更能集中注意力,因为不再老是等着走下一步了。然而 最重要的还是,花在发现并改正错误上的时间大大缩短了 。 那么,究竟是什么使我们一直以来都无法以这种快捷的方式工作呢?事实上,有些人是可以 的 。 那些用解释型语言编程的程序员在工作当中通常可以获得几乎实时的反馈。而对于我们这些 使用编译型语言的程序,员,阻碍我们获得快速反馈的主要拦路石则是依赖,具体地说就是为了编 译我们想要编译的代码而不得不连带编译那些我们并不关心的代码。 7.3 解依赖 依赖可能会带来一些问题,不过所幸我们可以解开它们.对于面向对象的代码,通常第一步 就是试图在测试用具中实例化我们所需的类。在最简单的情况下,我们只需导入或者包含我们所 依赖的那些类即可。如果情况麻烦一些,可以试试第 9 章所描述的技术。此外,就算能够将某个 类放入测试用具中,但为了测试该类的个别方法,或许还得去解开其他某些依赖。对于这些情况, 请参考第 10章。 如果需要在测试用具中修改某个类,通常可以利用耗时较短的"编辅 编译-连接一测试" 过程 .一般来说,大多数方法的执行开销跟它们所调用的方法的开销相比都是相对较低的,尤其 是当被调用方是像数据库、硬件或通信设施之类的外部资源的情况下.而其他方法大都是计算密第 7 章 J呈长的修改 69 一 #l型的。对此第 22 章描述的技术会有所帮助。 许多 时 候我们所作 的修改都是 比较直观的,但面对遗留代码,人们往往在第一步就被卡死了 , 就是指将一个类放入测试用具中。对于某些系统来说这可能需要花很多工夫。比如,可能有些类 [EJ 非常大 , 而有些类当中的依赖是如此之多,以至于贯穿了你想要修改的功能。这种情况下建议你 考虑从中切出一大块代码放入测试。第 12章中包含的一组技术可以用于寻找到汇点 CI 4 9 页),在 这些地方编写测试 比较容易。 本章余下的部分将介绍如何着手修改代码的组织方式,以使其编译构建更为容易。 构建侬赖 在一个面 向对象系统中,如果想要让一簇类的构建更快,所耍弄清的第一件事就是哪些依赖 会成为拦路石。通常这很简单 : 你只需试着在测试用具中使用该类 , 所遇到的几乎所有问题都会 源于某个依赖。在成功在测试用具中运行该类之后,另 rJ 忘了还有一些依赖也可能会影响编译时间。 一个有益的做法就是看看有哪些代码是依赖于你已经能够在测试用具中实例化的那些类的。这些 代码在你重新构建系统时也需要被重新编译.那么如何将它们减到最少呢? 解决这一 问题的途径,是看看这簇类里面有哪些类是被簇外面的其他类使用 到 的, 对这些类 进行接口提取。 许多 ID E都提供了接口提取的功能,你只 需选定一个类 , 然后选择某个菜单来列 出该类的所有方法,然后就可以从中选择某些方法来构成一个新的接口.完了之后 IDE会让你为 新的接口起一个名字,同样,它还会让你选择是否将代码基中所有对该类的引用尽可能替换为对 该接口的引 用 。这是个极其有用的特性 。不过在 C++中 ,实现提取 ( 281 页)要比接 口 提取 (2 85 页)容易一些, 因为对于前者你无需到处修改引用的名字 , 不过还是得修改那些创建旧类 的对象 的地方 , 将它们改为创建新类的对象 ( 具体细节请参考关于 实现提取的描述 )。 一旦将这簇类置于测试之下, 我们便可以修改项 目 的物理结构从而使其构建更快了。具体做 法是将这簇类移到一个新的包或者库中去。之后我们的构建过程的确变得更复杂了,但这并不要 紧,关键在于随着我们解依赖并将这些类选出来放入新的包或库中,虽然重新构建整个系统的代 价增加了 , 但每次构建的平均时间却会降低。~国 让我们来看一个例子。图 7 -1 展示了 一组互相合作 的类,它们位于同 一个包 中 。 AddOpportunityFormHandler + AddOpport u n i t y 严。 rmHandler{Consu忧 anlSchedulerO罔 ConsultantSchedulerDB 图 7 -1 一组互相合作的类 AddOpportunity XMLGenerator 70 第二部分修改代码的技术 我们想要对AddOpportunìtyForrnHandler作一些改动,但若是顺便能让我们的构建过程 更快的话就更好了。第一步是尝试实例化AddOpportunityFormHandlero 然而遗憾的是,它 所依赖的类皆是具体类。 AddOpportunityFormHandler 需要 一个 ConsultantSchedulerDB 以及一个AddOpportunityXMLGenerator 。而很可能这两个类又依赖于其他不在这幅图中的类。 在目前的情况下,如果我们试图实例化AddOpportunityFormH andler ,天知道最终会波 及到哪些类。要想解决这个问题,可以开始进行解依赖。我们遇到的第一处依赖就是 ConsultantSchedulerDB 。需要创建一个 ConsultantSchedulerDB对象并将它传递给Add OpportunityFormHandler 的构造函数,而由于ConsultantSchedulerDB需要连接到数据库, 所以创建起来会不大方便,我们可不想在测试时惹上这个麻烦。然而,我们可以运用实现提取技 []I] 术,像图 7-2所示那样进行解依赖。 AddOpportunityFormHandler + AddOpportunilyFormHarn刀er(Consu 1t antSchedu lerDB) Opportunityltem AddOpportu而ty XMLGenerator 图 7-2 对ConsultantSchedulerDB进行实现提取 在 ConsultantSchedulerDB 变成了一个接口之后,我们便可以用 一 个实现了 ConsultantSchedulerDB接口的伪对象作为 AddOpportunityForrnHandler 的构造函数的参 数来创建 AddOpportunityFormHandler对象 了 。有趣的是,这个依赖被解开之后,构建过程 在某些情况下也变得更快了。比如说,下次我们对 ConsultantSchedulerDBlmpl 作修改时, AddOpportunityForrnHandler 就无需重编译了。为什么呢?因为 AddOpportunityForrn­ Handler 已不再直接依赖于ConsultantSchedulerDBlmpl 中的代码了。无论对 Consultant­ SchedulerDB 工 rnpl 所在文件作多少修改,只要这些修改不至于迫使我们去改动 Consultant­ SchedulerDB接口,就无需重编译AddOpportunityFormHandler类。 如果我们愿意的话,甚至还可以进一步避免连带性重编译,如图 7 - 3 所示。其中显示了另 一 @] 个设计,这是通过对Opportunityltem类使用实现提取技术达到的。一 AddOpportunityFormHandler + AddOp回归 nityFormHandler( ConsultantSchedulerDB) 第 7 章 ,呈长的修改 71 AddOpportunity XMLGenerator 图 7-3 对Opportunityltem进行实现提取 现在 , AddOpportunityFormHandler不再依赖于原先 Opportunityltern中的代码了.从 某种意义上来说,我们在代码中插入了 一面编译期的防火墙 。这么一来无论对 Consultant SchedulerDBlmpl 和 Opportunityltemlmpl 作多 少改动都不会导致 AddOpportunity FormHandler被重编译,也不会迫使任何使用了 AddOpportunityFormHandler 的代码进行重 编译.如果我们想让应用程序的包结构也明确反映这些,可以将设计分解成下面这几个分离的包, 如图 7-4所示: 。pportun i tyProcessing + AddOpporlunityFormHandler AddOp阳 rtunityXMlGeneralor Databaselmplementation -+ ConsultantSchedulerDBlmpl -+ Opportunityltemlmpl 图7-4 重构后的包结构 这么一来,我们的 OpportunityProcessing包实质上便不依赖于数据库实现了。那么任何 我们编写并放在该包中的测试都应该能够快速完成编译,而且当我们修改数据库实现类的代码 时, OpportunityProcess 工 ng包本身则不必重新编译. 因回 72 第二部分修改代码的技术 依赖倒置原则 如果你的代码依赖于一个接口,那么这个依赖一般来说是很次要的.除非这个接口发生改 变,否则你的代码是无需改变的,而接口的改动频率通常情况下要远远低于接口背后的那些代 码.在接口不交的前提下,不管是修改实现了该接口的类,还是添加实现了该接口的新类,接 口的客尸代码都不会受到影响. 因为这个原因,较好的做法是让代码依赖于接口或抽象类,而不是依赖于具体类.当代码 依赖的是较为稳定的东西时,因特定改动而导致大规模重编译的可能性也就被降到了最低. 到目前为止我们已经做了 一些工作来防止当 AddOpportun i tyFormHand l er依赖的类改变 时引起 AddOpportun 工 tyForrnHandler被重编译。这的确会令编译变得更快,但这还只是问题 的一半。剩下的一半是我们还可以令那些依赖于 AddOpportunityForrnHandler 的代码的编译 更快。让我们再来看一下包的设计,如图 7-5 所示: 。 pportunityProcesslng + AddOpportunityFormHandler + AddOpportunityFormHandler币丽 $1 - AddOpportunityXMLGenerator - AddOpporlunilyXMlGeneratorTest 图 7-5 包结构 Databaselmplementatlon + ConsultantSchedulerDBlmpl + Consulta时SchedulerDB l mplTest + Opportunityltemlmpl + OpportunityltemlmplTesl AddOpportunityFormH andler 是 OpportunityProcess 工 口g包中唯一 公共的产品(非测 试)类。当我们对其进行修改时,其他包中任何依赖于它的类都得重编译。我们同样可以通过接 口提取或实现提取技术来解开其他代码对它的依赖。之后其他包中的代码便可以依赖于提取出的 接口。如此一来大部分时候我们对该包的修改都不再会引起其客户代码的重编译了。 我们可以通过解依赖并将类分配给不同的包来加快编译过程,这么做是非常值得的。当你可 以快速编译并运行测试时,在开发过程中便能够获得更佳的反馈。大多数时候,这就意味着更少 的错误,另外情况也没那么令人恼火了。但这也并非免费的午餐,增加接口和包的数量也会增加 一些概念的复杂性。值得这么做吗?答案是肯定的。的确,有时候,包和接口的数量多了以后, 找起东西来要多花点工夫,但找到之后用它们工作会非常容易。 当为了解依赖而往设计中引入了额外的接口和包之后,重新构建整个系统的时间就会稍微 变长一点。因为有更多的文件要去编译.但基于需要被重编译的文件而进行的局部重建的平均 时耗反而大大缩短了。第 7 章 i呈长的修改 73 一当开始优化平均构建时间时,最终将会得到一块块非常易于工作的代码。是的,使一小组类 能够单独编译和测试这件工作做起来可能有点痛苦,但重点是,这是一劳永逸的。 一旦完成这件 工作 , 后面就能永远享受它带来的好处 。 7.4 小结 本章展示的技术可以用于使小簇类的构建变得更快,但这还只是一小部分,事实上,借助于 接 口和包来管理依赖,我们还能做其他很多事情。 Robert C. Martin 的书《敏捷软件开发 原则、 厅~ 模式与实践)) 1 展示了更多这方面的技术,每个开发者都应当知道这些技术. 应iJ l 此书英文注释版和四版的中文版即将由人民邮电出版社出版.一一编者注国 第 8 章 添加特性 "如何添加 个特性? "这或许是本书中最抽象、最与问题领域相关的问题了。但事实上 , 不管我们采取什么样的设计方案或面对什么样的特定约束,总还是有一些技术可以使我们的工作 变得更轻松。 让我们来看一看问题的上下文。面对遗留代码,最重要的考虑之一便是其中许多代码都是没 有测试的。更糟的是,我们难以将测试安置到位。因此许多团队都更倾向于退而采用第6章中描 述的技术。没错,第 6 章描述的技术是可以用于在没有测试的情况下添加代码,但除此之外也存 在着 些危险。 一方面,在使用新生方法/类或外覆方法/类时,我们并没有对既有代码作多么明 显的修改,因此也就不用指望这些代码会在短期内得到改善。代码重复则是另一个危险因素。如 果我们添加的代码跟既有的未被测试代码中的某些部分构成了重复,那么很可能那些旧代码便只 能躺在那儿等着烂掉了。更糟的是,很可能直到作了 一大堆修改之后我们才发现进行了重复编码。 最后一个危险因素就是恐惧乃至退缩 恐惧是指害怕无法修改某块特定的代码从而使其更易对 付,而退缩则是因为整块代码一点也没改观。恐惧使你无法作出好的决定。留在代码中的新生类 /方法和外覆类汇方法就是 明证 。 一般来说,面对问题比逃避问题更好。如果我们可以将代码置于测试之下,便可以使用本章 的技术来使系统朝好的方向发展。如果不知道怎么才能将测试安置到位,请参考第 13 章。如果觉 得依赖碍手碍脚的话,请参考第 9 章以及第 10章。 一旦测试到位,我们的系统便处于一个更利于添加新特性的位置上。我们便有了 一个坚固的 根基。 8.1 测试驱动开发 我所知道的最为强大的特性添加技术便是测试驱动开发 (TDD) 。简单地说,测试驱动的开 发过程是这样的:设想有这么 一个方法,能够帮我们解决问题的某个部分,接下来我们为这个设 想中的方法编写 一个失败测试用例。此时该方法尚不存在,但既然我们能够为它编写测试,我们 就对接下来将要编写的代码要做什么事情有一个确定的认识。 测试驱动开发使用的算法如下: (1) 编写一个失败测试用例。(2) 让它通过编译。 (3) 让测试通过。 (4) 消除重复。 (5) 重复上述步骤 。 第 8 章添加特性 75 下面就是一个具体的例子.假设现在有一个财务系统,我们需要一个能够利用某些高性能数 学方法来验证某些商品是否应该被买卖的类 。 为此需要一个能够计算有关某个点的所谓" 一阶统 计矩"的 Java类。目前这个方法尚不存在,但我们知道的是可以为它编写一个测试用例 。 我们了 解相关的数学知识,所以知道对于在测试中给出的数据,结果应该是 -0 .5 0 8.1.1 编写一个失败测试用例 下面就是用来测试我们所需的功能的用例: public void testFirstMoment() InstrumentCalculator calculator new InstrumentCalculator 门, calculator . addElement(1 . 0J i calcula tor . addElement(2 . 0J i assertEquals(-Q.5 , calculator . firstMomentAbout(2 . 01 , TOLERANCE) ; 8.1.2 让它通过编译 我们刚编写的这个测试没什么问题,但它无法通过编译。因为现在 InstrumentCa lculator 上还不存在一个名为 firstMomentAbout 的方法。但我们可以添加 一个空的 firstMoment AboutfÚ lnstrumentC alculator里面,这样测试用例便能够通过编译了。我们希望这个测试 失败,所 以让该方法返回NaN (当然不等于刷试里面所期望的 - 0.5 ) 0 public class InstrumentCalculator { double firstMomentAbout(double point) { return Double .NaN ; 8.1.3 让测试通过 有了这个测试,便可以编写相应的代码来让测试通过了: public double firstMomentAbout(double point) douhle numerat 。主 0 . 0 , for (Iterator it elements . iterator() ; it . hasNex 巳( ), ) { double element ((Double) (i t. nex 巳())) .d。飞.l bleValue () ; numerator += element - point ; return nurnerator / elements . size(); 因76 第二部分修改代码的技术 在测试驱动开发当中,对于仅仅为了今测试通过这一目的来说,以上代码量已经算是异常 多的了 . 通常情况下我们的步骤要细得多,不过,如采你对自己需要使用的算法有足够的信心, 也可以采取像上面这种做法. 8.1 .4 消除重复 上面的例子中有重复代码吗 9 没有 . 所以我们接着来看下一个用例。 8.1.5 编写-个失败测试用例 刚才我们编写的代码通过测试了,但显然它并未覆盖所有的情况。例如,在返回语句那行可 能会出现除零的情况。对于这种情况,我们该怎么办呢?如果 elements. size ()为 0的话,我们 该返回什么?答案是抛出一个异常。因为当 elements链表里面没有任何数据时,结果是无意义的 。 下面这个测试比较特殊.倘若没有抛出 InvalidBasisException异常,该测试就会失败 . 反之 ,如果抛出 工 nvalidBas isExcep tion异常,那么测试便通过.当运行该测试时,由于抛出 的是一个ArithmeticExcepti on( 由 firstMomentAbout 中的除0操作引发),因而测试失败了 。 public void testFirstMoment() try ( } new InstrumentCalculator() . firstMomentAbout(O . O)i fail (-expected' InvalidBasisException.) i 1891 catch (InvalidBasisException e) 8.1.6 让它通过编译 要想让上面的测试通过编译,我们得修改 f 工 rstMornentAbout 的声明,为它加上 Invalid- BasisException: public double firstMomentAbout(double point) throW8 InvalidBasisException { double numerator 0 . 0 i for (Iterator it elements . iterator(); it . hasNext() i ) double element ((Double) (it.next())) .doubleValue() ; numerator += element - point ; return numerator / elements . size() ; 但这还不够。编译错误告诉我们,既然在异常规格列表里面声明了 Inva lidBasis Exception异常,就得真的在该函数体中抛出这个异常。于是再做修改: public double first MomentAbout(double point) throws InvalidBasisException 第 8 章添加特性 77 一 if (element.size() .0) throw new InvalidBasisException( "no elements") i double numerator 0 . 0 i for (Iterator it elements 工 terator() ; it .hasNext() ; ) { douhle element ((Double) (it.next())) .doublevalue(); numerator += element - point; return numerator I elements . s 主 ze () ; 8.1.7 让测试通过 现在我们的测试通过了. 、 8.1.8 消除重复代码 本例中没有任何重复代码。 8.1.9 编写一个失败测试用例 接下来我们要写的一段代码是一个计算一个点的二阶矩的方法。实际上该方法只是前面那个 计算一阶矩的方法的变种.下面就是驱动我们编写相应代码的测试.注意,这里的期望值不再是 原来的 -0.5. 而是 0 . 5 0 下面便是为这样一个尚不存在的方法 s econdMomentAbout 所编 写 的测试: public void testSecondMoment{) throws Exception { InstrumentCalculator calculator new 工 nstrumentCalculator(); calculator . addElement(l . O) ; calculator . addElement(2 . 0) ; assertEquals(O . 5 , calculator . secondMomentAbout(2 . 0) , TOLERANCE) ; 8.1 .10 让它通过编译 要想让它通过编译,就得先定义 secondMomentAbouto 我们可以使用前面定义 firstMomentAbout 方法时所用的技巧,但看起来这个计算二阶矩的方法的代码实现跟前面计算 一阶矩的方法只有一些细微差别。 我们只需将 f且工 r臼st悦Morner川中的这行代码: numerator += element - pointi 改为 : numerator += Math .pow(element - point , 2 . 0) i 而且,这类函数具有一个一般模式。 一般而言 • n 阶统计矩的计算对应于如下 的表达式 numerator += Math.pow(element - point , N) ; 根据上面的公式 ,我们知道. f i r s tMomen tAbou t 的代码之所以是正确的,是因为 element 因78 第二部分修改代码的技术 poin t 其实就等于Math . pow(element - point , 1 .0). 事情进行到这里,我们有几个选择 。 首先,有了上面的一般公式,便可以编写 出一个一般性的、 计算N阶矩的方法,该方法的参数为 一个"中心"点以及N 的值 。 这样我们便可以将每个对 firstMomentAb out(doublel 的使用替换为对这个一般方法的调用了。以上这些当然是可以做到 的,只不过那样的话调用方就得多提供一个N值,而我们又不希望允许客户随意给定N的值。不过, 我们似乎想得太多了,暂且把这个问题搁在一边吧,先把手头的事情做完 。 目前唯一的任务就是让 测试代码通过编译。以后如果我们仍觉得需要将这个方法一般化的话,到时再着手也不迟 。 要让测试代码通过编译,我们可以将 firs tMornentAb out 复制 一 份并更改函数名为 囚 secondMorne川bout , public double secondMomentAbout(double point) throw s 工 nvalidBasisException { if (elements . size () 0) throw new InvalidBasisException( "no elements " ) ; double numerator 0 . 0 i for (Iterator it elements . iterator(); it . hasNext() ; ) double element ((Double) (it . next())) .doubleValue() i numerat。主+= element - point; return numerator / elernents .size() ; 8.1.11 让测试通过 光是复制代码,测试仍无法通过 。 既然如此,可以回头将代码修改一下,如下,测试便可以 通过了: public double secondMomentAbout (douhle pointl throws InvalidBasisException { if (elements.size() 0) throw new InvalidBasisException(-no elements"); double numerator 0 . 0 ; for (Iterator it elements . iterator() ; it . hasNext(); ) double element ((Double) (i t . nex 巳( ))) .doubleValue {)i numerat。主+= Math . pow(element - point , 2 . 0) i return numerator / elements . size{)i 你可能会被我们刚才进行的一番"剪切/复制/粘贴"吓一大跳,但别害怕,接下来就要开始 消除重复代码了 .虽说我们编写的是全新的代码,但在对付遗留代码时"剪剪贴贴再改改"还 是比较有效的办法 . 一般来说,当想要往特别糟糕的代码中添加特性时,如果能够将代码放到新 的地方,使我们能够直观对照新旧代码的话,就会比较有利于理解 。事后再去消除重复代码,从第 8 章添加特性 79 而可以将新代码更好地安置在类当中:或者我们也可以干脆撤销掠所作的修改,并重新进行,你 应该知道, 即使撤销 了原先所做的修改 ,仍然还有 旧代码可以参照。C2TI 8.1.12 消除重复 现在,既然两个测试都通过了,那么接着进行下一步:消除重复。具体怎么做呢? 一种做法是将 secondMornentAbout 的函数体完全提取出来,重新命名为 nt hMornentAbout , 并添加一个参数N ,如下所示: public double secondMomentAbout(double point) throws InvalidBasisException { return nthMomentAbout(point, 2.0); private double nthMomentAbout(double point, double n) throws InvalidBas!sException { if (elements.size() 0) throw new 工 nvalidBasisEx cept ion( "no elements" ); double nt皿erat 。主 0.0; for (Iterator it elements. iterator (); it . hasNext (); double element ( (D。也ble) (it . next())) . doubleValue(); nu皿erator += Math. pow(element - point, n): return numerator / e lements . s !ze (); 如果现在再运行测试,仍然可以通过。然后我们可以回到 firstMomentAbout 的定义,将它 修改为 一个简单的对nt hMoment About 的调用 , 如下所示· public double firstMomentAbout(double point) throws Invalid8asisException ( return ntbMomentAbout(point, 1.0); 这最后一步, 即 消除重复,是非常重要的。我们可以通过诸如复制粘贴整块代码这样的方式 来快速但粗暴地往既有代码中添加新特性,但如果事后不消 除重复代码的话,无异会带来麻烦和 维护负担.另一方面,有测试的帮助,我们便可以很容易地消除重复代码。前面的讨论显然已经 展示了这点,只不过,我们之所以能够在消除重复代码时得到测试的辅助,全要归功于在一开始 便选用了 的测试驱动 的开发方式。在修改遗留代码的过程中 ,当我们使用测试驱动开发时 ,为既 有代码编写的那些测试是非常重要的.有了这些测试作后盾,便可以放手去编写新特性的实现代 码了,而且最后我们可以妥善安全地把这些新代码安置到其余代码当中。[ill 测试驱动开发与遗留代码 测试驱动开发的 最有价值的一个方面是它使得我们可以在同一时间只关注于一件事情 . 要 么是在编码,要么是在重构,永远也不会在 同一时刻做两件事情。80 第二部分修改代码的技术 这一好处对于对付遗留代码的人们来说显得尤其有价值 ,因为它使我们能够独立地编写新 代码 . 在编写完一些新代码之后,我们便可以通过重构来消除新旧代码之间的任何重复 . 在遗留代码的工作场景中 ,我们可以将测试驱动开发的算法稍微扩展一 点: (1)将想要修改的类置于测试之下。 (2) 编写一个失败测试用例。 (3) 让它通过编译。 (4) 让测试通过(在进行这一步的过程中尽量不要改动既有代码〉。 (5) 消除重复。 (6) 重复上述步骤。 8.2 差异式编程 测试驱动开发并不仅仅是属于面向对象开发领域的东西.实际上,上一节中的例子其实只不 过是一段包裹在类之下的过程式代码 。只不过在面向对象系统中我们还有另 一个选择·借助于类 的继承,我们可以在不直接改动一个类的前提下引入新的特性。在添加完特性之后,我们便可以 弄清楚到底想要如何添加新特性。 要做到以上这些,关键的技术就是所谓的"差异式编程 ( programming by difference ) 飞这是 曾在 2 0世纪 80年代被讨论和使用得比较多的一项相当古老的技术,到 了 20世纪90年代,面向对象 系统社群中 的许多人注意到继承的滥用也会带来相当严重的问题,于是该技术也就逐渐淡出 了人 们的视线。但实际上开始使用继承并不意味着后面一直都得保持那个样子。有了测试的帮助, 一旦我们发现继承不再合适,便可以很容易地改成其他设计。 下面这个例子展示了这一技术的工作方式。我们有一个己测的 Java类 , 叫做MailForwarder , 该类是一个负责管理邮件列表的 Java程序的一部分。 MailForwarder类拥有一个名叫 ge tFrom CEJ Address 的方法,如下所示· private InternetAddress getFro~生ddress(Message message) throws MessagingException { Address (1 from message. getFrom (); if (from != null && frorn . length > 0) return new InternetAddress (from [0) . toString ()) i return new InternetAddress (getDefaultFrom()); 该方法的意图是从一则接收到的邮件消息中提取出发件人地址并返回它,以便后面被用来作 为转发 1 1i~件的发送地址。 该方法只在一处地方被用到,即一个名为 forwardMessage 的方法中的以下两行代码· MimeMessage forward new MimeMessage (session) ; forward.setFrom (getFromAddress (messagel ); 第 8 幸添加特性 81 -卢- 现在,假设我们面临一个新的需求,需要支持匿名邮件列表,那么,该怎么做呢?这类列表 中的成员当然还是可 以发送邮件的,但它们所发送 的邮件的发件人地址则应 当基于 domain (MesSageFowarder类的一个实例变量)的值而被设置为一个特殊的 电子邮件地址 。在进行修改 之前,先要编写一个相应的失败测试用例,如下所示 (expectedMessage变量会预先被设置为 MesSageFowarder转发的那则消息 。) public void testAnonymous () throws Except 工 on MessageForwarder forwarder new MessageForwarder () ; forwarder . forwardMessage (makeFakeMessage()); assertEquals ( " anon - me厅nbers@ " + forwarder. getDomain () , expectedMessage . getFrom () [0] . toString{)); 为了添 加这一功能,必须修 改 MessageForwarder 吗? 其实 并非如此。我们可以从 MessageForwarder派生出 一个新类An o町mousMessageForwarder. 并将测试中原先创建/使 用 MessageForwarder 的地方改为创建/使用 AnonymousMessageForwarder. 如下所示· public void testAnonymous () throws Except ion MessageForwarder forwarder new AnonymousMessageForwarder() ; forwarder . forwardMessage (makeFakeMessage()) ; assertEquals ( "anon-members@ " + forwarder . getDomain () , expectedMessage.getFrom () [OJ . toString()); 完成测试用例的修改之后,便可以着手进行MessageForwarder 的子类化了(见 图 也J) : [2I] MessageForwarder + MessageForwarderO + processMessage(Message) - forwardMessage(Messag 时 11 getFromAddress(Message) | An阳阳。町n叩、 ll'句叩g酬酬刨旺F阳阳rom削Add 盹阳酣咱啡酬e时} 图 8-1 子类化MessageForwarder . 这里,我们将MessageForwarder 中的 getFro础ddress 方法设为受保护而非私有 。然后在 AnonymousMessageForwarder 中重写该方法,如下所示 = protected InternetAddress getFromAddresS(Message message) throws MessagingException { Str 工 ng anonymousAddress "anon-" + listAddress ; 主 eturn new InternetAddress(anonymousAddress); 82 第二部分修改代码的技术 这给我们带来了什么呢?晤……答案是……问题解决了.但另一个事实是 , 为了添加一个非 常简单的行为而往系统中添加了一个新类。仅仅为了改变一个消息转发类的发件人地址就将这个 类整个子类化了,这样做是不是合理呢?从长远来看答案是否定的 ,但好就好在这样做能够让我 们的测试立 即通过 。 而且,一旦测试通过了,以后 当我们决定再改动设计时,便可以利用该测试 来确保我们的改动不会影响这一新行为。 public void testAnonymous () throws Excep 巳 ion MessageForwarder forwarder new AnonymousMessageForwarder () i forwarder.forwardMessage (makeFakeMessage()) i assertEquals (H anon-members@ " + forwarder. get 口。 main{) , expectedMessage.getFrom () (0) . toString ()); 以上这些看上去简直太简单了.但也有问题:如果我们一再使用该技术,并且对我们的设计 [EJ 中的某些重要方面不予关注 的话,情况便会很快恶化。为了说明情况会糟到什么地步,考虑另 一 个修改=我们想要将邮件转发给邮件列表内的收件人,但同样想要通过"密送 ( b∞ )"的方式发 给另 一些不能出现在正式邮件列表上的收件人。我们可以把这些人称为"编外"收件人. 这昕起来简单得不能再简单了 : 我们只需再对MessageForwarder进行一次子类化,重写相 关的邮件处理方法,让它不但转发给正式邮件列表内的收件人,而且发送给那些"编外"收件人 即可。如图 8-2 所示: . MessageForwarder 二 + MessageFo川arde r() +阳。cessMessage( M essage) lorwardMessage(Message) # geIFromAddress(Message) | Off list~阳晒 Forw时 er t 阳四M田叩阳叩} ?阿瓦斗s 而町|# getFromA剧陪臼阳 e目age) 图 8 -2 为两种不同的差异实施于类化 这的确可行,但有一个问题:如果我们既想要鱼又想要熊掌怎么办呢?也就是说,如果我们 窝耍一个既能发送邮件给不在列表中的收件人,又能进行匿名转发的Messag eForwarder ,该怎 么办呢? 这便是处处使用继承所带来的棘手问题之一 了。 倘若将不同 的特性放入不同 的子类 中,我们 在同 一时 间便只能展示其 中 一样特性了 。 那么,如何摆脱这个约束?办法之一就是在添加"编外收件人"特性之前做一点重构, 以便 让该特性能够"清爽"地进入系统。幸运的是 , 有前面编写的测试作后盾,当我们启用另 一种修 改策略时,可以利用该测试来检验之前的行为是否被保持下来了。第 8 章添加特性 83 一至于那个匿名转发的功能,其实我们原本可以不用子类化就实现它的。可以选择将匿名转发 功能做成一个配置选项。办法之一便是通过修改MessageForwarder 的构造函数,让官接受 组 剧性. properties configuration new properties () ; configuration. setProperty (M anonymous" , ft true-) ; MessageForwarder forwarder new MessageForwarder(configuration); 之后,如何才能让原先的测试通过呢?为此,让我们再来回顾一下前面的测试· public void testAnonymous () throws Exception MessageForwarder forwarder new An。口ymousMessageForwarder() ; forwarder . forwardMessage (makeFakeMessage()); assertEquals ( - anon-members@- + forwarder. getDomain () , expectedMessage.getFrom () (0) . toString()); 目前这个测试是通过的。 AnonymousMessageForwarder重写了 MessageForwarder 中的 getFro 皿I'.ddress 方法。 那么,假如我们像下面这样修改 MessageForwarder 中的 getFrorn Address方法 : private InternetAddress getFromAddress(Message message) throws MessagingException ( String fromAddress getDefaultFrom () ; if (configuration . getProperty("anonymous") .equals("true")) fromAddress "anon-members@ " + d。回\a ~ni else ( Address [1 from message . getFrom () i if (from !== null && from.length > 0) fromAddress from [0] .toString (); -return new InternetAddress (fromAddress) ; 这样一来, MessageFowarder 中就有了一个能够同时处理医名情况和正常情况的 getFrorn­ Address 方法 了 .要验证这一点, 可以将AnonymousMessageForwarder 中的那个重写版本注 释梅(如下),看看测试是否仍然通过。 public class AnonymousMessageForwarder extends MessageForwarder { /会 protected InternetAddress getFromAddress(Message message) throws MessagingException { String anonymousAddress "anon-" + listAddress ; return new InternetAddress(anonymousAddress) ; 金/ 因84 第二部分修改代码的技术 毫无疑问,测试仍然通过。 所以,现在我们不再需要AnonyrnousMessageForwarder类了,可以将它删除掉.接着我 们得找到所有创建AnonymousMessageForwarder 对象 的地方,将它们全部改为创建 MessageForwarder 的对象(其构造函数参数为一个属性集合( Properties对象) )0 当然,我们还可以利用这一属性集合来添加其他新特性。例如,我们可以加入一个属性用于 [ill 控制是否启用"编外收件人"功能。 完事儿了吗?还没有.现在的问题是我们把MessageForwarder 的 getFromAddress 方法弄 得有点儿乱了,但好在还有测试,所以我们可以很快地进行一次方法提取来让它变得干净点儿。 在这之前先来看一下该方法目前的样子: private InternetAddress getFrornAddresS(Message message) throws MessagingException ( String fromAddress getDefaultFrom(); if (configuratioo.getProperty (-anonymous.) . equals ( "true-) ) fromAddress "anon-members@" + domaini else { Address [1 from message. getFr四n 门, if (from != null && from.length > 0) fromAddress from [0] .toString (); return new InternetAddress (fr。回应 ddress) ; 做了 一些重构之后,变成下面这样: private InternetAddress getFro~民ddress(Message message) throws MessagingException ( String fromAddress getDefaultFrom(); if (configuration .getPr operty("anonymous 嗣) . equals("true")) ( from getAnonyrnousFrom{); else ( from getFrom{Message); return new InternetAddress (from); 这样看起来的确是干净了些,但是匿名发送以及"编外收件人"这两个特性现在全都被放到 MessageForwarder 中了.这岂不是不符合单一职责原则了?或许吧。答案取决于与该职责相关 的代码部分到底有多大,以及它们与其他代码"纠缠"得有多厉害。本例中,检测邮件列表是否 匿名算不上什么大动作。利用属性集的做法使得后续工作比较顺利。假设后面又多出了许多其他 属性,从而使MessageForwarder 的代码变得杂乱起来,到处都是条件判断语旬,那时该怎么办? 一个方案是放弃使用属性集而改用类。设想我们创建一个名 叫 MailingConfiguration 的类, [2Ð 让该类来持有以前的那个属性集,如图 8 -3 所示:MessageForwarder + MessageFo阳 arder() + processMessage(Message) . forwardMessa伊 (Message) 11 getFromAddress(Message) 第 8 幸添加特性 85 |MailingConfiguratlon -1 .,.---------------------------------, I + getProperty(Stri 吨): Slring 1+ addPrope内y(String name, String value) I 图 8-3 委托给Mail 工 ngConf 工 guration 看起来不错,但是不是有点小题大做了? MailingConfiguration~~ 原先那个属性集做的 似乎是一模一样的事情。 然而,假如我们将getFromAddress从MessageForwarder 中转移到MailingConfiguration 中去呢?这样一来MailingConfiguration便可以接受一则邮件消息并自行判断应当返回什么 样的发件入地址了.如果其中的配置被设置为匿名转发,那么它便返回匿名发件人地址。如果没 有 ,它便从邮件消息中提取第一个地址并返回它 。 我们的设计应当像图 8 -4所示的那样。注意, 现在我们再也不需要那些获取/设置属性的方法了。 MailingConfiguration现在本身就己支持 高阶的功能了。 M回国geForwarder + MessageForwarder() + processMessage(Message) forwardMessage(Message) l MallingC。州 guration-1 + gelFromAddre回阳sage) 图 8 -4 将行为移至 MailingConfiguration 中 我们同样可以开始往MailingConfiguration 中添加其他方法。比如说,如果想要实现前 面提到的"编外收件人"特性,那么只需往 MailingConfiguration 中添加 一 个名为 buildRecipientList 的方法,并让MessageForwarder使用该方法即可 。 如图 8-5 所示: MessageForwarder + MessageForwarderO + processMessage(Message) forwardMessage(Message) MallingConflguratlon + getFromAdd 晴 ss(Message) + buildR田 ipient Li st( Li sl recipien时List 图 8-5 往MailingConfiguration 中迁移更多的行为 在经历了以上这些改动之后, MailingConfiguration这个类名字就不再适合它了。所谓 "配置"通常是一件被动的工作,而这个类现在已经能够积极主动地根据MessageFowarder对象 的要求为它建立和修改数据了 o ;'MailingList" 倒是个蛮合适的名字,如果系统中尚没有其他 类起名为 MailingList 的话。 MessageForwarder对象请求MailingList (邮件列表对象)为 它计算发件人地址和建立收件人列表 。我们可 以把决定如何修饰邮件消息归为 MailingList (邮 件列表〕的责任。图 8-6 展示了重命名之后的状况: 回86 第二部分修改代码的技术 MessageForwarder 二 + MessageForwarderO Mal1lngLlst 一 + processMessage(Message) . forwardMessage(Messag时 + getFromAddress(Mes回ge) + buildRecipientList(List recipients) : List 图 8-6 MailingConfiguration被重命名为 Ma 工 lingL 工 st 有许多重构手法都是相当强大的,但重命名类( rename class )是其中最为强大的一项.它 能够改变人们看待代码的方式,并使他们〉王意到一些以前可能从未考虑过的可能性 . 差异式编程是一项有用的技术 。 它使你能够快速地作出改动,事后还可以再靠测试的帮助来 换成更干净的设计.但是,要想正确运用该技术,得注意一些小陷阱:其 中 之一便是小心别违反 Liskov 直换原则 ( LSP) 。 Li skov置换原则 在使用继承时我们可能会犯一些细微的错误.考虑如下代码: public class Rectangle { public Rectangle(int x , int y. int width, int height) { ... } public void setWidth (int width) { .. . public void setHeight (int height) { ... } public int getArea () { ... } [!QD 我们有一个 Recta叼 le类.那么,可以由它派生一个..t '~Square 的于类吗? public class Square extends Rectangle { public Square(int x , int y , int widthl { ... } Square继承了 Rectangle 的 setW idt h~~setHeight 这两个万法.那么,考虑下面的代 码,在这几行代码执行完毕之后,面积将是多少呢? Rectang!e r new Square () ; r . setWidth(3) ; r .setHeight(4) i 如果是 12 的话, r这个正方形便不能算是个真正的正方形了 . 于是,我们在 Square类中重 写 Rectangle 的 s etWi dth/setHeigh t 方法,以确保其正方形的身份不被改变.例如,我们 可以让 setW idth和 setHeight都去修改 Square 的宽( Square的面积用宽的平方来计算),但 这么一来又会造成违反直觉的结采了.如长宽分别被设为 3 和4之后人们当然期望面积为 12 了, 然而他们得到的结果却是 16.第 8 章添加特性 87 一这是违反 Liskov置换原则 (LSP) 的经典案例之一 。子类对象应当能够用于替换代码中出现 的它们的父类的对象,不管后者被用在什么地方。如果不能的话,代码中就有可能悄无声息地出 现一些错误. Liskov置换原则意味着一个给定类的客户代码应当能够在毫不知惰的情况下使用该类的任何 子类对象。不存在任何" 机械性"的方法来避免违反该原则。 一个类是否符合L i skov置换原则取决 于它的客户代码, 以及这些客户代码对代码行为或结果的期望.不过,还是存在一些一般规则的 z (1 ) 尽可能避免重写具体方法 1 。 (2) 倘若真的霆写了某个具体方法,那么看看能否在重写方法中调用被重写的那个方法。 然而,前面我们对MessageForwarder进行派生时并没有遵循这些规则。实际上我们所做的 恰恰相反。我们在其子类AnonyrnousMessageForwarder 中重写了 一个具体方法。这有什么问题吗? 当我们像(在AnonymousMessageForwarder 中)重写MessageForwarder的getFrom由.ddress 方法那样重写具体方法时,就可能会改变某些使用 MessageFowarder对象的代码的意义了。 例 如,假设应用中到处散布着对MessageForwa r der的引用,并且我们将其中 一处引用改为引用 一 个AnonyrnousMessageForwarder对象,那么此时使用该引用的人可能根本不知道他引用的不 再是一个简单的 MessageFowarder对象了, 他或许还以为该对象会从他处理的邮件消息中获取 收件人地址,并在处理邮件消息的时候使用该地址呢。那么,该对象究竟是像刚才描述的这样还 11021 是直接使用 一个特殊的收件人地址,对于客户代码来说有什么区别吗?答案取决于应用 。 一般来 说,当我们过于频繁地重写具体方法时,代码就容易变得混乱。 比如说,某个人可能会注意到代 码中使用的是个MessageForwarder 引用,于是他翻开MessageFowarder类的定义 , 并认为被 执行的会是Me ssageFowarder 的 getFromAdd ress 方法。 他可能根本不知道那个引用其实是指 向 一 个 AnonymousMessageForwarder 对象,因而实际上被调用的是 AnonyrnousMessage Forwarder 的 getFro rnA.d.d ress 方法。如果一定要保留继承的话,可以将Messag eForwarder 做成一个抽象类,其中包含一个抽象的 ge tFro础.d.dress 方法,并让子类各 自去提供具体的实现 。 因 ι7展示了这样的设计: {abstract} M田sageForwarder + MessageForwarder() + processMessage{M眉目ge ) - forwardMessage(Messag时 # getFromAddress(Message) {abslract} 1 i J I ……咖~ getFromAdd~叩叩酬 图 8-7 规范化继承体系 l 对应于抽卑方法,如,接口上的方法便不是具体方法, C++中纯虚函数不是具体方法. 译者注 回88 第二部分修改代码的技术 我把这种继承体系称作规范化的继承体系.在一个规范化的继承体系中,任何类都不会包含 同一方法的多个实现.换句话说,任何类都不会重写其父类中的具体方法 。 当你问"该类是怎样 完成工作X的? "时,只需翻开该类的定义看一看便可知道。要么赫然在目,要么就是一个抽象 方法,由该类的某个子类来实现。在一个规范化继承体系中,无需担心子类会重写从它们的父类 那儿继承来的行为. 那么,是不是任何时候都值得这么去做呢?答案是,偶尔重写一两次具体方法其实是无伤大 雅的,只要不违反 Liskov 置换原则。然而,在我们准备分离出类里面的职责时,最好想一想我们 的类从偶尔规范化的形式到整体朝规范化迈进还有多少距离。 差异式编程使我们能够快速往系统中引入变更 。 在这一过程中,我们可以利用测试来固定住 新的行为,便于以后根据需要改用更妥当的设计 . 有了测试的存在,修改设计就变得非常迅速了 。 8.3 小结 本章介绍的技术可以用来往任何能够置于测试之下的代码中添加新特性 。 近年来关于测试驱 动开发的书图不断增多.特别地,我推荐KentBeck的《测试驱动开发)) (Addison-Wesley, 2002), 11041 以及 Dave Aste l 的《测试驱动开发 实用指南)) (Prentice Hall, 2003) 。→一一「 第 9 章 无法将类放入测试用具, tþ 本章将要讨论的是一个困难的问题。如果在测试用具中实例化一个类总是那么容易的话,本 书也就会简短得多了。遗憾的是情况并非如此。 下面就是我们会遇到的 四种最为常见的 问题(其中 "该类"代表我们所针对的问题类) , (1) 无法轻易创建该类的对象。 (勾当该类位于测试用 具 中时,测试用具无法轻易通过编译构建。 (3) 我们需要用到的构造函数具有副作用。 (4) 构造函数中有一些要紧的工作,我们需要感知到它们。 本章我们将会看到一系列的例子,这些来自不同语言编程环境下的例子展示了上面提到的这 些问题。实际上,每个 问题都不止一种解决方案。不过,要熟悉二大堆解依赖技术,学习如何在 它们之间进行权衡, 以及如何在特定场合下运用的话,通过这些例子来学习是极好的途径。 11051 9.1 令人恼火的参数 当需要在一个遗留系统中作修改时,我通常是以一种轻松乐观的心态开始工作的。我不知道 为什么。虽然一直试图让自己尽量变得更现实一些,但总会存在那么 一 点儿乐观情绪"嗨"我 对自己(或工作伙伴)说"这昕起来蛮简单的,我们只需把这个类这么处理一下就成了。"然而, 虽然嘴上说得简单,当正儿八经打开那个类时却发现……"好吧,看来我们需要在这儿添加一个 方法,并修改这儿的另一个方法。当然,我们还得把它放入测试用具。"直到这时我才开始有点 怀疑。"唉……看来这个类就连最简单的构造函数也接受 3 个参数嘛,不过……"我乐观地安慰自 己"也许构造起来并没那么困难。" 让我们来看一个具体的例子,看看到底我的乐观是有道理的还是自我安慰。 在一个计费系统中,我们有一个未测试的 Java类 CreditValidatorc public class CreditValidator { public CreditVa l idator(RGHConnect 工 on connection, CreditMaster master, String validatorIDJ { 90 第二部分修改代码的技术 Certificate validateCustomer(Customer customer) throws Inval idCredi t { 该类的众多职责之一便是告诉我们某个客户是否有足够的余额。是则返回一个认证,告诉我 们该客户的余额到底是多少 。 否则抛出 一个异常 。 而我们的任务(如果我们选择接受该任务的话)则是往该类中添加一个新方法。这个方法将 被命名为 getValidationPercent ,其职责是告诉我们在一个Credit飞lalidator对象的生命周 期中对validateCustorner() 的调用的成功率 。 那么,我们该怎么做呢? 如果需要看看能否在测试用具中创建一个对象,最佳做法就是试一试。我们当然可以通过- 11061 系列的分析来确定在测试用具中创建某个类的对象容易与否;不过还有一个方法同样轻而易举, 即创建一个 JUnit测试类,并将下面的代码填到里面然后编译: public void testCreate() CreditValidator validator new CreditValidator () ; 要想知道能否在测试用具中实例化一个类,最佳途径就是试一试.编写一个测试用例并在 里面创建该类的对象.编译器会告诉你要令代码工作起来还需要哪些东西 . 以上测试是个构造测试 。 构造测试看起来的确有点怪异 。 编写这类测试时我通常并不在里面 放置任何断言,而只是试着创建对象.最后,当终于能够在测试用具中构造某类的对象时,我通 常会删掉该测试,或将它重命名以便能够用来做一些更为实质性的测试。 返回到我们的例子. 在上面那个简单的 testCreate方法中,我们在创建 CreditValidator对象时并未向它的 构造函数提供任何实参,因此编译会报错。错误消息会说 C reditValidator没有默认构造函数。 于是我们翻开 CreditValidat 町的代码一看,发现其构造函数需要 3 个参数·一个 RGHConnection 、 个CreditMaster 以及一个密码 。 而这3 个类全都只有一个构造函数 。 如下 所示 · public class RGHConnection { public RGHConnection(int port , String Name , String passwd) throws IOException { public class CreditMaster 第 9 章 无法将类放入测试用具中 91 一 P 丛 blic CreditMaster(String filename , boolean isLocal) RGHConnect 工。n在构造时会连接到一个服务器,这个连接被用来从服务器获取必要的信息 以检查客户的余额。 另一个类 CreditMaster , 则提供了 一些我们在检查余额的过程中会用到的策略信息 。 CreditMaster 的构造函数会从一个文件中加载相关信息,并把这些信息保存在内存中以备后用。 这么看来,把这个类放入测试用 具的确是举手之劳,对不对?如j 这么快下结论 。虽说我们 的 确可以为它编写出一个测试来,但我们能否忍受这个测试的速度呢?如下所示 1 107 1 public void testCreate() throws Exceptlon { RGHConnection connection 二 new RGHConnection(DEFAULT_PORT, ~admin. , -rii8ii9s.); Cred 主 tMaster master new CreditMaster( .crm2 .mas. , true) ; CreditValidator validator new Cred主 tValidator( connection, master, "a") ; 看起来,在测试中建立到服务器的 RGHConnection并不是个好主意。首先其耗时就比较长, 况且服务器也并不总是处于服务状态。相比之下 CreditMaster 倒不算什么问题了. CreditMaster在创建时会加载一个文件 ,而这个过程比较快速。况且它所加载的文件还是只读 的,这就确保了我们无需担心它被测试程序破坏掉 。 真正妨碍我们顺利创建Credit飞!alidator对象的其实是 RGHConnection 。这是个令人恼火 的参数。我们的设想是:若能够创建某种伪造的 RGHConnection对象并使CreditVal idator相 信它是一个真正的 RGHConnection 的话,就可以避开所有的连接问题了。所 以, 让我们来看一 看 RGHConnection所拥有的方法(如图 9 -1 所示) , RGHConnection + RGHConnectîon(por1. name, passward) + connect() +d田onnectO + RFD I Aepo时 For(id : int) : RFDIReport + ACTIOReportFor(customer旧 int) ACTIORepo同 relryO formPacket() : RFPacket 图 9-1 RGHConnection 看上去 RGHConnectlon 中有一些方法是用来处理与连接相关的任务的·如 conne町、 disconnect 以及 retry 。另外还有一些与业务相关的方法,如 RFDIReportFor 和 ACTIOReportForo 前面曾提过,我们的任务是往CreditValidator 中添加一个新方法,用于 获知在一个 CreditValidator对象的生命周期中它上面的 validateCustorner() 调用的成功 百分比,为此得调用 RFDIReportFor来获取我们所需要的一切信息,通常所有这些信息都是来92 第二部分修改代码的技术 自服务器的,所以,如果我们想如刚才所言伪造一个 RGHConnection 的话,就必须想办法让那 个伪造的 RGHConnection也能提供这些信息 。 在这些条件之下,伪造一个 RGHConnec t ion 的最佳方法是对 RGHConnection类应用接口提 取。如果你手头有一个支持重构的工具 ,那么它很可能也会支持接口提取手法 。 如果没有支持这 11081 一手法的工具的话,别担心,自己动手也同样简单 。 在对 RGHConnect 工。n运用接口提取技术之后,我们最终得到了如图 9 -2所示 的结构· + connectO + discon相ctO 叫 nterfacE纱, IRGHConnectlon +RGDIRe,ρortFo r(id .- int) .- RFDIReρort +ACTfOReportFor(customerlD .- int) : ACηORe,ρ。 rt I RGHConnectlon + RGHConnection(间内, name, passwan吗 刊。 nnect() +disconneCI() +AFDIAeportFor(id : int) : RFDIReport +ACTIORep。同 For(customerlD : int) ACTIOAep。同 relry() formPacket() : RFPacket 图 9-2 接口提取后 的 RGHConnection 至此,我们便可以创建一个轻便的 F akeConnection类,并使它能够提供我们所需的反馈信 息,然后将这个伪造的 U RGHConnect ion " 用在测试中: public class FakeConnection implements IRGHConnection { public RFD工 Report report ; public void connect ( ) { } public void disconnect () {} public RFDIReport RFD工 ReportFor{int id) ( return report i public ACTIOReport ACTIOReportFor (int customerID) ( re巳 urn null : 有了上面的类,便可以开始编写测试了,如下所示: void testNoSuccess () throws Exception { CreditMaster master new CreditMaster (Hcrm2 .mas H, true) i IRGHConnection connection new FakeConnection () i CreditValidator validator new CreditValidator( connection:, master, -a- ) i connection.report new RFDIReport(. .. ) i 第 9 章 无法将类放入测试用具中 93 一 certificate result validator.validateCustomer(new Customer( .. . ))i assertE申lals(Ce!tificate . VAL ID , result .getStatus()) ; FakeConnection类看起来有点古怪 :它的方法要么是空的要么就是简单地返回 null o 这种 情形并不常见 。更糟的是,它有一个任何人都可以看到并随意设置的公共变量 。 这样一个类似乎 违反了所有的良好准则。但你要看到,实际上并非如此。对于一个用来使得测试可行的类 ,规则 是有所不同的。 FakeConnection 中的代码并非产品代码。它永远也不属于最终投入运行的应用, 而只是为了测试用具而诞生的。 现在,既然我们已经可以创建一个 CreditValidator ,那么便可以开始编写它的 getValidationPercent 方法 了 。 在这之前我们先编写其测试 ,如下所示. void testAIIPassed100Percent{) throws Exception { CreditMaster master new CreditMaster (.crm2 .mas. , true) ; IRGHConnection connection new FakeConnection ( • admin. ,民 rii8ii9s"); CreditValidator validator new CreditValidator ( connection, master, "a") ; connection. report new RFDIReport( .. .): Certificate result validator.validateCustomer(new Customer ( .. . )) ; assertEquals(lOO . O, validator.getValidationPercent() , THRESHOLD) ; 测试代码vs产晶代码 测试代码并不需要具有产品代码那么高的品质 . 一般来说,如果将变量设为公有能够使测 试的编写史为容易的话,我是不会介意这么做的,尽管这会破坏封表.不过就算是测试代码也 应当是干净、易于理解和修改的. 让我们来看一看上面的 t estNoSuccess和 testAllPassedl00Percent 这两个测试 . 它 们之间有重复代码吗 9 是的,它们的头三行代码完全一样,应当提取出来放到一个公共方法中, 即所属类的 setUp ()方法. 以上测试检查CreditValidator对象在验证了 一个具有足够余额的客户之后其验证成功率 是否约为百分之百 。 这个测试工作起来没问题,但随着我们真正开始编写 ge tValidationPerc ent ,却发现一 些有趣的现象 . 似乎 getValidationPercent 并不需要用 到 CreditMaster. 既然这样 , 当我 们在测试用例中创建CreditValidator对象时为什么还要提供一个CreditMaster 呢?或许并 不-定要这么做。我们可以像这样来创建测试用的 Cred itVal 工 dator对象: CreditValidator validator new CreditValidator ( connec 巳 ion , null, "a")i 是不是很麻烦? 人们对于上述代码的反应往往很能体现出他们对付的是一个什么样的系统。比如一种反应是 回 这样的"酬,也就是说他将一个 null传递给了这个构造函数一-我们在系统中就经常这么做" 11101 94 第二部分修改代码的技术 这种人很可能对付的是一个相 当糟糕的系统 。 系统中可能到处都是针对 null 的检查,另外还有许 多条件代码用于判断某些引用到底引用的是什么以及能用来做什么 。 另 一种反应则是这样的 " 这 家伙到底怎么了?竟然在一个系统中把 null传来传去?到底有没有知识啊? "昵……如果你属 于后一种人(或者至少你读到这里还没有把书狠狠合上并扔回书店的书架上) , 请昕我一句善意 的辩解.别忘了我们只是在测试中才这么做。可能发生的最糟糕的事情就是某些代码试图 去使用 我们传递的 nulL 如果这真的发生的话, Java 运行时便会抛出 一个异常。又由于测试用 具 能够捕 获测试当中抛出的任何异常,所以最终我们很快就会发现参数是否被使用了 。 传 Null 在编写测试时,如果你发现某个对象需要的某参数难以构造,便可以考虑传递一个 null. 这样一来如果这个参数在测试运行的过程中被用到了的话,代码就会抛出一个异常 , 而测试用 具则会捕获这个异常.这时候,如果确实需要传递一个对象而不是null 的话,再去构造这个对 象并将它作为参数传递也不晚 . 传 Null手法在某些语言中是一项非常便利的技术。在 Java和 C# 中使用起来没什么问题 , 其 实只要是那些会对运行期使用空引用抛出异常的语言 , 这项技术都适用 . 但这也意味着在C和 C++ 中使用这项技术并不是个好主意,除非你知道运行时系统会检测出空指针的使用 . 如果系 统并不具备这样的能力,还是别用为妙,否则最后会发现你的测试神秘崩溃,而这还算是远气 好的结果,运气不好的话测试则会悄无声息地出问题,而你拿它们一点办法也没有;它们在运 行时会破坏内存 , 而你则被蒙在鼓里. 当我使用 Java语言来工作时,通常 开始编写 的测试是像下面这样的 , 然后根据实际情况需 要决定是否把其中的 null替换成有血有肉的对象。 public void t estCreate () C reditVal i dator 飞ralidator new CreditVali dator (null , null , "a " ) ; 关键要记住一点:不到万不得 己千万别在产品代码中传递 nulL 我知道现在有些库会期望你 在某些情况下传递 null ,这是无法控制的事情,但当编写 自 己 的代码时,请务必记住还有更好的 ITITl 选择 。 如果忍不住想在产品代码中使用 null ,那么建议你找出所有返回 nu川J! l 或传n皿null 虑采用另一种协议 。 比如 ,考虑使用空对象模式 CNull Object Pattem) 。 空对象模式 空对象模式用于避免在程序中传递 null . 例如,假设我们有一个方法,该方法根据调用者 提供的m返回相应雇员对象,那么,如果某个 ID是无效雇员 ID的话,应当返回什么呢 9 for (Iterator it i dList. i terator (); it . hasNext () ; ) EmployeeID id (Emp!oyeeID)it . next (); Employee e finder . get EmployeeForID (id ) ; e . pay() ; 有如下几个选择:一个选择是干脆抛出一个异常,这样就无需返回任何东西了,但同时也第 9 章无法将类放入测试用具中 95 一 就迫使调用方显式地去处理这个异常,另一个选择使是返回 null . 但这么一来调用方就必须显 式检查其返回位是否 null . 但其实我们还有第三个选择.回顾上面的示例代码,这段代码真的关心是否存在一个需要 支酬的雇员'马。另外它是否必须得关心这个呢?如果我们新添一个叫做NullEmployee 的类 怎么样? Nu llEmp l oyee 的对象相 当于一个既没有名字也没有地址的雇员,当你向他支酬时 (调用其pay() 方法).什么也不会发生. 空对象模式在这类情况下就可以派上用场了;它们可以免除让调用方进行显式错误检查的 烦恼.不过,虽说这个模式不错,但用的时候也得小心.例如,下面这段代码试图计算已被主 酬的雇员人数,而事实上这样做是非常糟糕的. i nt employeesPai d 0; for (Iterator i t i dList . itera t orO ; i t.hasNext {); ) ( EmployeeID id ( EmployeeI D)it .next (); Emp!oyee e f inder . getEmployeeFor I D (id) ; e .pay() ; mployeesPaid+ +i // bug ! 在上面的循环中,一旦获取到的是空雇员,最终的计算结果就会是错误的. 当调用方无须关心某个操作是否成功时,空对象模式尤其有用。而许多时候我们又都可以 通过对设计进行一些巧妙的处理从而使其符合这个前提 . 传 Null和接口提取 (285 页〉是两种可以用来解决恼人的参数的途径 . 但有时我们也可以使 用另 一个方案,如果一个参数类型 中的问题依赖并不是被硬编码在其构造函数中的话,就可以使 用于类化并重写方法 (S ubclas s and Override Method. 314页)来对付该依赖 。 比如在上面的例子 中这一方案就是可行的 。 如果 RGHC onnection的构造函数使用了它的 connec t () 方法来建立一 个连接的话,我们就可以通过在 RGHC onnection 的一个为测试而造的子类 中重写其 conne c t ( ) Ill21 方法来解开依赖 。 于类化并重写方法在有些场合下是非常有用的解依赖手段,但在使用的时候我 们得注意别把想要测试的行为给篡改了 。 9.2 隐藏侬赖 有些类是具有欺骗性的 。 比如我们发现它上面有一个构造函数是我们想要使用的,然后试图 去调用这个构造函数 。 结果,砰的一声 ! 我们撞上了 一块石头 。 而这块 u 石头"最常见的可能就 是隐藏依赖:比如构造函数中使用了 些我们在测试用 具中根本无法很好地访问到的资源 。 下面 就是一个杳关的例子,这是一个设计得很糟糕的 C++类,它负责管理一个邮件列表: class mailin9_1is t_dispatcher { public virtual mailin9_1ist_dispatcher ( ); -mailin9_1ist_dispatcher; 囚 96 第二部分修改代码的技术 void void send_message(const std: : string& message) i add_recipient(const mail_tXffi_ id id, const mail_address& address) ; private mail_service *service; l.nt status; } ; 下面是该类的构造函数的部分代码。它先是在构造函数的初始化列表中 new 了 一个 mail service对象。这是个差劲的编码风格,而且还不止这些。这个构造函数后面又用这个 mail serVlce对象做了很多细节的工作。此外它还使用了-个所谓的"魔数"一一 12. 这里 12 究竟代表什么意思? mailing_list_dispatcher : :mailing_list_dispatcher() service (new mail_servicel , s 巳 atus(MAIL_OKAY) const int client_type 12; service->connect() ; 主 f (service->get• status () MS_AVAILABLE) { service->register(this , client_type, MARK_MESSAGES_OFF); service->set-param(client_type, ML_NOBOUNCE I ML_REPEATOFF); else status MAIL_OFFLINE; 我们可以在测试中创建该类的对象,但或许这么做没什么好处。首先,需要连接到邮件库, 并配置邮件系统以便进行注册。而且倘若我们在测试中使用 send_rnessage 函数,就会真的给某 个人发邮件了 。 所以很难自动地对该功能进行测试,除非我们配置一个特殊的邮箱,然后不断重 复登录它,看看邮件消息是否己到达 。 如果我们要做的是一个整体的系统测试,那么这样做没问 题,但如果我们只是想往该类中添加一些经过测试的新功能的话,这么做就未免太小题大做了 。 我们只是想创建 个用于测试的简单对象,以便添加一些新的功能而己,如何才能达到这一目 的呢? 根本问题在于对 rnail service 的依赖被隐藏在了 rnailing_list_dispatcher 这个构造 函数中 。 如果有办法把那个mail service对象替换成一个伪对象的话,就可以通过这个伪对象 来进行感知,并在修改过程中获取一些反馈信息了。 这里可以采用的一个技术是参数化构造函数 (Parameterize Constructor. 297页) 。 运用该技 术,可以将一个藏在构造函数中的依赖"外在化气即让它以参数的形式从外面传进来。 下面就是对mailing_list_dispatcher 的构造函数运用参数化构造函数的结果 mailing_list_dispatcher : :mailing_list_dispatcher(mail_service *servicel status(MAIL_OKAY) CQnst int client_type 12 ; 第 9 章无法将类放入测试用具中 97 service- >connect() ; if (serv ice->get_status () MS_AVAILABLE ) { s er v ice->regist er (this , client_type, MARK_MESSAGES_OFF )i s ervice->set-param(cl ient_type . ML_NOBOUNCE I ML_REPEATOFF) ; else status MAIL_OFFLINE ; 实 际上这里面唯一 的 区 别就在于现在 rnail _service对象是在外面创建并以参数的形式传 递进来的了。看上去这可能算不上多大的改造,但它的确给 了 我们不可低估的优势 。 现在便可以 使用接口提取技术来给mail service提取一个接口了 。 该接口的实现之一是一个真正会发送邮 件的产 品类 ,而另 一个实现则可以是一个"伪造的" 类,它 的作用只是在测试时感知我们对它所 傲的动作,让我们确信这些事情都发生了 。 利用参数化构造函数技术,可以很方便地将构造函数中的依赖外在化,但人们却常常想不到 使用它 .一个原因是人们往往会认为随着某构造函数中的依赖被外在化,它的所有调用方都得进行 相应的改动, 以便传递新的参数 。 但实际上这种想法并不正确 。 我们可以解决这个 问题·首先将构 11141 造函数的函数体提取到一个新的方法 工 n itialize 中 . 跟大多数的方法提取有所不同,这一提取在 没有测试保护的情况下也是相当安全的 , 因为我们可以在提取时运用签名保持 (249 页 〉 技术 。 void mailin9_1ist_dispatcher: : initialize(mail_service .service) { status MAIL_OKAY i const int client_type 12 ; serv ice.connect (); if (serv ice->get_status () MS_AVAILABLE ) s ervice->register (this , client_type. MARK_MESSAGES_OFF) ; service->set-param(client_type, ML_NOBOUNCE I ML_REPEATOFF) ; else statu5 MA工 L_OFFLINE ; mail 工 ng_ li s t_dispatcher : :mailin9_1ist_dispatcher (mail_service . servi c e l { initialize(service) ; 现在,我们可以额外再提供一个跟原先的签名一模一样的构造函数 (如下所示 ) 。于是,在 测试时可 以调用具有mall_service参数的那个构造函数,而其他客户 代码则调用那个签名 l'!! 原 先一样的构造函数,这么一来客户代码便可以维持原样,无需关心我们作了哪些改动 。 mailing_lis t_dispatcher : : ma 工 l i ng_list_dispatcher () ( initialize(new mail_service) ; 98 第二部分修改代码的技术 这种重构在像C#和 Java这样的语言 中甚至更为容易,因为这些语言支持在一个构造函数中调 用另 一个构造函数 。 例如,假设我们要在c#中完成类似的工作 ,结果代码可能看起来像这样. public class MailingListDispatcher { public MailingListDispatcher() this(new MailService()) {} public MailingListDispa 巳 cher(MailService serviceJ 有多种技术可以用来克服隐藏在构造函数中的依赖.通常我们可以使用提取并重写获取方法 曰 C Ex!ract and Override Gett町, 278页〉、提取并重写工厂方法 CExtract and Override Factory Method, 276页〉以及替换实例变量 CSupersede Instance Variable, 317 页) ,但我比较倾向于尽可能地使用 参数化构造函数。 当一个构造函数在它的函数体中创建了 一个对象 ,并且该对象本身并没有任何 构造性依赖时,运用参数化构造函数就比较轻松了 。 9.3 构造块 参数化构造函数在解开构造函数中隐藏的依赖方面是一项易用的技术,而且它往往也是我第 -个诉诸的技术。然而遗憾的是,它并非总是最佳选择。如果一个构造函数中创建了大量的对象, 或者访问了大量的全局变量,采用这一技术可能最终会导致一个非常长的参数列 表。 最糟糕的是, 某个构造函数可能会先创建一些对象,然后使用它们来创建另一些对象 · class Watercolorpane public Watercolorpane(Form *border, WashBrush *brush. Pattern *backdrop) { anteriorpanel new Panel{border); ant eriorpanel->setBorderColor{brush->getForeColor{)) ; backgroundPanel new Panel (border, backdrop) i cursor new Focuswidget (brush, backgroundPanel); 如果想要通过 c ursor变量进行感知的话 , 我们就 ~itl 麻烦了 o cursor对象被嵌在一系列的 对象创建中 . 我们可以尝试将所有用于创建 cursor对象的代码都移至类外面,让客户代码去创 建 cu rsor并将它作为参数传递给该类 . 但这么做没有测试保护的话不是很安全 ,而且还可能会第 9 章无法将类放入测试用具中 99 给该类的客户代码带来不小的负担。 如果有一个能够安全地提取方法的重构工具,我们就可以对构造函数中的代码运用提取并重 写工厂方法 ( 276页 )手法了,然而这一做法也并非在所有语言 中都可行 . 在 Java和 c#中没问题, 但c++ 中就不同了 , 因为在C忡中,构造函数中对虚函数的调用是不会被决议到派生类的相应虚 函数上去 的 。 况且一般来讲这也并不是个好主意,因为派生类中的虚函数往往会认为它们可以使用 基类的成员变量,所以倘若在基类的构造函数完全结束之前,其派生类中的虚函数被调用起来并且 后者试图去访问基类中的某个成员变量的话,很可能它访问到的就会是一个未初始化的变量。 11161 还有一个选择,便是采用替换实例变量(3 17 页)手法。我们为这个类编写→个设置方法, 该方法允许我们在对象构造完毕之后替换掉其中的某个成员变盘 。 class WatercolorPane public WatercolorPane(Form *bor der, washBrush *brush, Pattern *backdrop) { anteriorPanel new Panel (border) ; anteriorPanel->setBorder Color (brush- >getForeColor()) ; backgroundPanel new Panel (border, backdrop) ; cursor new Focus widget (brush, backgroundPanell i void s u persedeCu rsor( F ocusW工 dg e t *newCursor) ( delete curs 。艺 , cursor newCursor ; 在c++中进行这类重构时得非常小心 。 在替换掉一个对象之前,得先将旧的对象处理掉 。 通 常这就意味着使用删除操作符来调用其析构函数并释放其内存 。 这么做的时候我们得清楚该析构 函数会做些什么 事情以及它是否会销毁某些当初传递给该对象的构造函数的东西如果我们在 清理内存的时候不小心的话,就可能会引入一些难以察觉的 bug . 而在大多数其他语言 中,替换实例交量手法则是相 当直观的 。 下面就是上例在 Java 中的版本 . 我们无需操心 cursor原先指向的对象,垃圾收集器会负责将其回收 。 但我们得非常注意的是别在 产品代码中使用这一技术,因为如果被替换的对象管理了某些资源的话,替换它们就可能会导致 一些严重的资源问题 。 void supersedeCursor (FocusWi dget newCursor) cursor newCursor ; .' l 比如上例中的 brush和 backgroundpanelo 一一译者注100 第二部分修改代码的技术 现在,既然我们已经有了一个替换方法 (supersedeCurs。ρ,便可以试着在类的外部创建 一个 FocusWìdget. 然后在该对象构造完毕之后通过这个替换方法把它传递进去。又因为我们 需要进行感知,所以可以对 FocusWidget 使用接口提取或实现提取,然后创建其伪对象传递进 [ilJ 去。显然,这个伪对象的创建比起前面的在构造函数中创建 FocusWidget 的过程要简单多了。 TEST(renderBorder , WatercolorPane) { TestingFocusWidget *widget new TestingFocusWidgeti WatercolorPane pane(form, border , backdrop) i pane.supersedeCursor(widget) ; LON电 S_EQUAL(Q , pane.getComponentCount()); 除非万不得已,否则我是不愿使用替换实例交量手法的。这一做法很可能带来一些资源管理 方面的问题.不过在C++ 中的有些场合下我还是会使用它的。由于我常常想要使用提取并重写工 厂方法,而在C++中这一技术又是无法用在构造函数中的 ,所以有时我只能改用替换实例交量作 为替代方案了. 9.4 恼人的全局依赖 多年来业界人们一直抱怨市面上没有更多的可复用组件。随着时间的推移,这种情况也在逐 渐好转,市面上已经出现了大量商业的和开源的框架,但总体上,它们中的许多其实并不是在被 我们使用,而是我们的代码在被它们"使用"着。框架通常会管理一个应用的生命周期,而我们 则是往框架的空档之中填塞代码。这一点在各种框架中都能看到,从ASP.NET到 Java Struts 。甚 至 xUnit这样的框架也如此,我们编写测试类,而 xUnit则负责调用这些类并显示结果。 框架解决了许多问题,而且它们也的确能在项目开始时助我们一臂之力,但这并非人们所真 正期盼的那种复用.旧风格的复用是这样的·我们发现一些类或一组类是我们想要用在项目中的, 于是便这么做了.将它们添加到项目中,并使用它们,就这么简单。如果这种情形能够成为日常 程序的话倒是不错,但坦白的说,通常我们连把一个类从它所在的项目中挖出来并在测试用具中 单独编译它都无法轻松做到,既然如此,也就根本不用奢望这种复用了。 有各种各样的依赖可能会令我们难以在测试框架中创建并使用一个类,其中最难对付的依赖 之一便是全局变量的使用。简单的情况下,我们可以使用参数化构造函数 (297 页)、参数化方法 ~ (3 01 页)以及提取并重写调用 (275 页)这三种技术来对付这些依赖,但有些时候对于全局变量 的依赖是如此的广泛,以致于从根本上解决问题倒成了较容易的途径。下面的例子就展示了这种 情况,这是一个 Java类,其作用是记录政府机构颁发的建筑许可。下面就是其中一个主要的类: public class Facility { private Permit basePermiti 第 9 章 无法将类放入测试用具中 101 public Facility(int facilityCode , String owner , PermitNotice notice) throws Permit Violation ( Permit associatedPermit PermitRepository . getlnstance() . findAssociatedPermit(noticel ; if (associatedPermit . isValid( ) && !notice . isVa lid( ) ) basePermit associatedPermit i else if ( ! notice . isValid{)) ( Permit permit new Permit (notice) i permit .validate ( ) j basePermit permit ; else throw new Permi tVi olation(permit) ; 我们的目的是要在测试用具中创建一个 Facility ,因此作如下尝试: public void testCreate () PermitNotice notice new PermitNotice{O , "a " ) j Fa c 工 lit y facility new Fac 工 li t y(Facilit y. RES 工 DENCE , "b" , noticeJ i 以上测试代码能够通过编译,但我们开始编写其他测试时便会发现一个问题 Fa c ility 的 构造函数使用 了-个名为 P e rmit Reposltory 的类,为了将我们的测试条件设置妥当 ,必须使用 一个或一组许可证 (pennit)来初始化这个全局的 PermltR e po s l t ory 。 下面就是 Fac ility构 造函数中的相关语句: Pe rmit associatedPermit PermitRepository . getInstance() . f 工 nctA ssociatedPermit { no t ic e ) ; 我们固然可以通过参数化构造函数来解决这儿的问题 , 但整个应用中并非只奋此处布这个问 题, 另 外还有 10个类中也有类似的代码 。 构造函数中有,一般方法中有,静态方法中也有。可想 而知,不花上一大把时间是没法处理代码基中的这些问题的 。 11191 如果你 学 过设计模 式 ,可能会 发 现这里用的正是羊件模式 Perrnit Repos i t o ry 的 g etln s tance 方法是 一 个静态方法,其职责是 返回 该 应用中 有 且仅允许有的那唯 一一个 问口nitRepos 工 tory 实例 。 持有该实例的变量也是静态的,是 Pe rrni t Rep o s itory 的一个静态 成员变量 。 在 Java 当中,单件模式是人们用于实现全局变量的机制之一。 通常, 全局变量不是个好做法, 叼 原 因有好几个方面, 其一就是不透明性。 当我们查看一段代码时,能够知道这段代码会产生什么 样的影响是件不错的事情 。 例如,在 Java 中 ,当我们想要理解某段代码会产生什么样的影响时 , 只需查看几个地方 Account example new Account () ; example . deposit(l) ; 102 第二部分修改代码的技术 int balance example . getBalance () ; 对于上面这段代码,我们知道一个 Account 对象可能会影响我们传递给它的构造函数的参 数,但在上面的代码中并没有传递任何参数给它的构造函数 o Account对象同样也可能会影响我 们传递给它的方法的实参对象,不过在这里我们并没奋传递任何可被改变的东西,只不过是一个 整型数。最后,我们将 g etBalance 的返回值赋给了 一个变量,这其实也应当就是这几行语句所 影响到的唯一 的变量了. 然而 , 一旦其中涉及全局变量的使用,情况就完全不同了。对于像Account 这样的类,光从 它的使用代码上可能怎么也看不出来它在背后是否访问或修改了在程序的其他地方声明的变量。 毫无疑 问 ,这使得我们对程序的理解变得更困难了。 测试过程中一个麻烦的部分就是我们得找出哪些全局变量正在被我们的类使用 , 并根据测试 需要将它们设置到适当的状态。而且,如果对于不同的测试,设置也不同的话,每个测试开始之 前就都得做一番设置工作.这个过程相当烦人,虽说我在好多个系统上都做过这类事情,但还是 觉得相当枯燥乏味。 回到我们原先讨论的线路上来。 PermitRepos 工 tory是一个单件。而正因为它是个单件,所以"仿造"它变得尤其困难。单 件模式的核心理念便是使人们无法在应用中创建一个以上单件类的实例。这在产品代码中或许是 件好事 ,然而到 了 测试中 可能就成了 一场灾难: 一套测试中的每个测试在某种程度上都应当被看 成是一个小型的应用,互相之间应当完全隔离开来。因此,要在测试用具中运行一段涉及了单件 11201 的代码,就得放松单件性约束。下面就来看看具体是怎么傲的。 第一步就是为单件类添加一个新的静态方法。该方法允许我们替换掉该单件类中的那个静态 实例。我们将这个方法命名为 setTestinglnstanceJ 如下所示: public class Pe 口nitRepository { private static PermitRepository instance nu11 ; private PermitRepository() {} public static void setTestinglnstance(permitRepository newlnstance) { instance newlnstance; public static PermitRepository getlnstance() { if ask + run() Sc hedullngτ>ask , , has no implementation of ,' 1 runO 图 9 - 3 SChedulingTask 就这个例子而言,应该说我们是比较幸运的,因为我们使用的是 Java而不是C忡。如果是c++ 就不能这样来处理了.因为 c++并不支持像 Java那样的接 口 o c++中的接口通常是由只包含纯虚 函数的抽象类来实现的.如果把上面这个例子移植到c++下面. SchedulingTask就应该变成一 个抽象类,因为它从 ISched飞llingTask 那里继承了一个纯虚函数。所以要想创建 SChedulingTask 的对象,就必须为它的纯虚成员函数 run ()提供一个定义体,该定义体负责把 任务转发给 SerialTask 的 run() 去傲。幸运的是这很容易做到。下面就是代码: class SerialTask public virtual void run () ; class ISchedulingTask public virtual void run () 0; } ; 回112 第二部分修改代码的技术 class SchedulingTask public SerialTask , public ISchedulingTask { puhl 工 C virtual void run() ( SerialTask: : run() i } 对于 门语言来说,只要能用它来创建接口,或者类似接口行为的类,我们就可以系统地使 固用它们来进行解依赖。 9.7 化名参数 当遇到构造函数参数 问题时,通常可以借助于接口提取或实现提取技术来克服。但有时候这 两种做法却是不实际的。让我们来看一看上节提到的关于建筑许可系统中的另 一个类 : public class rndustrialFacility extends Facility { Permit basePermiti public IndustrialFacility(int facilityCode , String owner , OriginationPermit permit) throws PermitViolation ( Perm工 t associatedPermit Perm主 tRep os itory .Ge tlnstance() findAssociatedFromOrigination(permitJ; if (associatedPermit . isValid() && !permi t . isValid()) basePermit associatedPermiti else if (! permit. isvalid () ) permit .validate() i basePermit permiti else throw new Permit Violation(permit); 我们想要在测试用具中实例化该类,但这里存在几个问题。其中之一便是,我们又一次遇到 了单件 Perm itRepos 工 t oryo 当然,可以借助于前面讨论全局依赖时提到的技术来解决这个问题。 但这之前我们首先得解决另一个问题,就是我们很难创建出→个Or 工 ginationPermit对象来传 给这个类的构造函数。 OriginationPermit上的依赖情况很严重。我第→时间想到的是"噢, 我可以对 Or iginationPermit 使用接口提取技术来解决这些依赖问题啊。"但事情并没那么简 1\33 1 单。请看图 9-4展示的这个 Perrni t 体系结构图 IndustrialFacility 的构造函数的参数之一便是Originat 工 onPerrnit ,该构造函数接着 从 Perrni tRepos 工 tory获取一个与之关联的许可 (Perrnit 对象):这是通过 PerrnitRepos 工 tory第 9 章 无法将类放入测试用具中 113 上的方法 findA ssociatedFromOr 吁 ination来实现的,该方法接受一个 OriginationPerm工 t 对象并返回与之关联的 Pe口nit 对象。如果找到了这个关联的许可, IndustrialFac 工 lity 的构造函 数就会将它保存到成员 basePermit 中。如果没有,则将那个作为构造函数参数的 。口 ginationPerrnit 对象保存到UbasePermit 中。我们可以为 OriginationPerm工 t 创建一个接 口,但这样做没什么好处,因为这么 一来当我们将这个参数赋给basePerrnit 时,它们的类型一 个是 IOriginationPermit. 而另 一个则是 Perrn 扰,这是行不通的。在 Java里面,接口不能从 类派生得来。最显而易见的解决方案就是顺着继承体系一路往下创建接口,并将basePermit 的 类型改为工 Permit 。图 9-5 显示了这种情形: 图 9-4 Permit 继承体系 图 9-5 接口提取后的 Perrni t 继承体系 这样做的工作量也太大了,而且我也不是特别喜欢代码最后的样子。接口的确是解依赖的利 器,但如果出现了接口与类几乎一一对应的情形,设计就变得混乱起来了。别误会,如果我们别 无退路,那么像上面这样的设计也是可以的,但如果还有其他选择的话,我们当然应该尝试一下。 幸运的是,的确存在其他方案 11341 接口提取只是对参数进行解依赖的途径之一。有时候问一问"为什么这个依赖是糟糕的"往 往是有好处的。例如,有时候对象创建是麻烦所在,而有时候参数则会具有糟糕的副作用,比如 跟一个文件系统或数据库进行通信;而还有些时候呢,也许只不过是创建起来太费时间了。当我 们使用接口提取时,可以克服所有这些问题,但代价是我们粗鲁地切断了与一个类之间的联系。 如果某个类只是有某几个地方有问题,则我们可以采取另一个方案,只切断与这些地方之间的联 系即可。 让我们更仔细地来考察 下。口 ginationPermit类。我们不想在测试中使用它,因为当我 们希望它进行自身验证时,它便会悄无声息地在幕后访问 一个数据库 public class Originat 工 onPermit extends FacilityPermit { public void val 工 date() { // form connection to database /1 query for validation information 回 114 第二部分修改代码的技术 // s et the valida t ion flag // c lose databa s e 我们可不希望在测试时发生这种事情 那样的话就不得不在数据库中放一些"伪造的" (测 试用)条目,搞得数据库管理员不得安宁 .一旦被他发现,我们就不得不请他吃顿饭还是什么的 来赔礼道歉,而且就算那样他还未必领情 。 他的工作本就已经难做了 。 可以采取的另一个策略就是于类化并重写方法 。 我们可以创建一个名为 Fak e Or i gination P ermit 的类 , 该类提供 一些方法使得外界可以很容易地改变其验证标志 。 接着,便可以在 F akeOrigina t i onPermit 的子类中 重写 val i date () 方法,按照测试所需来设置验证标志 。 下 面就是我们的第个有效的测试: public void testHasPermits () class AlwaysValidPer.mit extends FakeOriginationPer.mit f public void validate() { 11 set the validation flag becomeValid()i 1; Facility facility new IndustrialFacility (Facility . HT_l , . b~ , new AlwaysValidPer.mit() ) i assert Tr ue ( facility. hasPermits ()) i 在许多语言 当中,我们都可以像这样在一个方法当 中"即时"地创建一个新类 。 虽然我并不 喜欢经常在产品代码中这么做,但在测试当中这是个非常便利的特性 . 借助于它我们很容易就能 实现一些特殊的用例 。 子类化并重写方法能够帮助我们解开参数上的依赖,但有时类中的方法的分解方式并不十分 适合这个技术 。 就本例来说我们比较幸运,因为我们不希望看到的依赖被隔离在了那个validate ( ) 方法中 。 在情况最糟糕的时候,那些依赖可能会和我们所需要的逻辑混在一起,令我们不得不先 进行方法提取 。 这时如果我们手头有一个重构工具就会很好办,但如果没有,请参考第 22章中的 11361 一些技术,可能会有帮助 。第 10 章 无法在测试用具中运行方法 有时候将测试安置到位并不是件简单的事情。如果能够在测试用具中单独实例化你的类,那 你算是比较幸运的。许多人无法做到这一点。不过,如果你也在这上面遇到麻烦了,不妨尝试一 下第 9章描述的技术。 (在测试用具中)实例化一个类通常只是第一步。接下去便是为需要修改的方法编写测试。 有些时候我们无需实例化那个类便可以直接进入第二步。例如,假设待修改的方法并没有使用多 少实例变量,便可以使用暴露静态方法 (273 页)手法来访问该方法的代码。倘若该方法相当长 且难于对付,则可使用分解出方法对象 (261 页)手法来将其中的代码移到一个相对来讲更容易 实例化的类当中去。 幸运的是,大多数情况下,为 一个方法编写测试所需的必要工作量并不算太夸张。下面列出 了我们可能会遇到的 些问题: q 无法在测试中访问那个方法。比如说,它可能是私有的,或者有其他可访问性限制。 口无法轻易地调用那个方法,因为很难构建调用它所需的参数。 口那个方法可能会产生糟糕的副作用(如修改数据库、发射一枚巡航导弹,等等),因而无 法在测试用具中运行它。 口我们可能会需要通过该方法所使用的某些对象来进行感知。 本章接下来将会描述一系列的问题场景,展示了解决这些问题的不同方式,以及解决过程中 的权衡与折中。 11371 10.1 隐藏的方法 我们需要对一个类中的某方法作修改,但它是一个私有的方法,这时该怎么办呢? 第一个问题就是,能否通过一个公用的方法来进行我们的测试。如果能,则值得那么做,以 免我们得想方设法去访问那个私有方法。而且,这么做还有另一个好处,即当我们通过公用方法 来进行测试时,肯定是按照该方法被用在实际代码中的方式来测试它的 1 。这有助于稍微缩小我 们的工作范围。在遗留代码中,常常会出现一些质量有问题的方法。要想使一个私有方法对其每 l 因为公用的方法属于类的接口,而私有方法则般属于内部实现范畴.一一译者注116 第二部分修改代码的技术 个调用方都可用,可能需要进行相当量的重构工作才行。有些一般性很强的方法能够被许多调用 方调用,能这样固然不错,但实际上每个方法在功能上应当刚好足够满足它的调用者,在清晰程 度上 也应当足够清晰,以便于理解和修改 。在测试一个私有方法 时,倘若是通过 一 个使用了它的 公用方法来间接测试的话,则把这个私有方法做得一般化倒也没多大危险。如果有朝一日该方法 需要成为公用的,那么其外的第一个使用者应当编写一系列测试用例,准确说明该方法的用途以 及调用者该如何使用它。 以上这些都没问题,但杳时我们就是想要直接为一个私有方法编写测试用例(对该方法的调 用被深深埋藏在类中)。这么做的原因可能是因为我们想要获得一些具体的反馈,以及能够解释 该方法是怎样被使用的测试用例,或者,也有可能只是因为通过它的类上面的公用方法来测试它 太困难了,等等。 那么,如何为 一个私有方法编写测试 呢?这 肯定是在 测试中被问得最多的问题之 一 了 。幸运 的是,这个问题有 一 个非常直接的答案 z 如果需要测试 一 个私有方法,那么就应该将它设为公用 的 。 如果不大方便将其设为公用的,则大多数情况下便意味着我们的类做的事情太多了, 应该 进行适当调整。让我们来看看什么时候不大方便将 个私有方法置为公用的呢?有两个可能的 原因· (1 )该方法只是个工 具 方法:客户并不 会去关 心它 。 (2) 如果客户代码使用了该方法,那么他们可能 会反过来影响到该类上的其 他方法调用的 结果。 第 一 个原因并不算很严重 。类的 接口上多出 一 个公用方法并没什么大不了,不过我们还是应 该试试看将它放到另一个类当中会不会更好 一 些。而第二个原因要严重一点,不过幸运的是还有 补救措施·这个私有方法可以被转移到 一 个新类 当中去。我 们可以让它成为这个新类上的公用方 法,而我们原先的那个类则可以在内部创建该新类的实例。这么一来,这个方法也就变得可测试 11381 了,而同时我们的设计也得到了改善。 是的,我知道这个建议有点不中昕,但它带来的 一 些效果却是很积极的。无论如何这个事实 都不会改变好的设计应当是可测试的,不具可测试性的设计是糟糕的.遇到上面这类情况,应 对之策是尝试采用第 20 章中所描述的技术。不过,当并没有多少现有测试可用时,就不得不小心 行事,先做 一 些其他工作,然后再开始分解。 先来看 一 个真实的案例,看看我们是如何来解决上面提到的问题的 。 下面是 一 个 C++类声明 的 一部分· class CCAlmage { pr1vate void setSnapRegion(int x , int y , int dx , int dy); public void snap ( ) ; 第 10 章无法在测试用具中运行方法 117 CCAlmage是一个保安系统中的)个类,负责拍照功能。你可能想知道为什么一个图像类会 负责拍照,但别忘了,这是遗留代码。该类有一个 snap() 方法,该方法使用一个低层的 C API 来控制一个摄像头进行"拍"照,但是,它"拍"下来的是一种非常特殊的图像。 一次对 snap( ) 的调用会导致好几个不同的摄像头被调动起来,它们各自都会拍下一幅图片,这些图片被分别放 到 CCAlmage 内部的图像缓存中的不同部位.而决定每幅图片分别被安放在哪个部位的逻辑则是 动态的,取决于被拍对象的移动。根据其移动方式的不同, snap() 方法会重复调用 setSnapRegion ()来确定当前照片应当被放在缓存的哪个部位。然而遗憾的是,现在,摄像头 的 API改变了,于是我们需要对 setSnapRegion作相应的修改。那么,具体该怎样进行呢? 一种可能的做法就是简单地将该方法设为公用的。但可惜的是这么做会带来非常消极的影 响。 CCAlmage类中有一些变量负责记录拍照区域的当前位置。所以倘若产品代码不小心在 snap( ) 方法外部直接调用了 setSnapRegion 的话,便会给摄像头跟踪系统带来严重问题. 是的,问题就在这儿.不过,在开始寻找解决方案之前,让我们先来看一下当初是怎么踏进 这团泥沼中的.之所以无法测试这个图像类,真正的原因在于它负担的职责太多了。理想情况下 我们应当可以使用第 20章所讲的技术来将它分解为几个小类,那样的确不错,但首先得仔细考虑 一下眼前是否应该进行这么多的重构工作 。诚然,这么做是有极大好处的,不过能否这么做却取 决于当前我们在整个产品发布周期中所处的阶段,有无足够时间以及所有相关的风险。 11391 如果目前我们负担不起这个风险去将职责分离开来,那至少能为待修改方法编写测试吧?幸 运的是,答案是肯定的。下面便是具体做法: 第一步是将 setSnapRegion的访问权限从私有改为受保护的。 class CCAlmage { protected void setSnapRegi。η(int x , int y , int dx, int dy) ; public void snap () i 接下来,我们通过子类化CCAlmage来获得对 setSnapRegion 的访问权: class TestingCCAlmage public CCAlmage { public void setSnapRegion (int x , int y , in 巳 dx , int dy) { 1/ call the setSnapRegion of the superclass CCAlmage : : setSnapRegion(x, y , dx, dy) i 在大多数现代C++编译器下,我们还可以使用 uSlng声明来达到同样的目的.在 Testing­ CCAlmage 中通过uSlng声明来自动完成任务委托(元需像上面那样经过一层元谓的转发),如118 第二部分修改代码的技术 下所示. class TestingCCAlmage public CCAlmage { public // Expose all CCAlmage implementations of setSnapReg ion 1/ as part of my public interface. Delegate all calls , to CCAlmage using CCAlmage : :setSnapRegion i 在完成这些之后,便可以在测试中调用 CCAlma g e 上的 setSnapReg 工 on方法了(虽然是间接 调用) 。 然而,这样做是好主意吗?记得一开始的时候我们并不想将这个方法设为公用,但现在 我们通过其他途径达到了类似的效果。我们将方法设为受保护的,从而削弱了它的访问控制 。 坦白地说,我并不介意这么做。对我来说,这样做令我们能够编写测试,是一宗公平交易. 没锚,这样的改动的确破坏了封装,从而当我们在分析代码是如何工作的时候,就得把CCAlmage 的子类也能调用 setSnapReg ion这一事实也考虑进去,但是,这毕竟是一个相对来说比较次要 11401 的问题 . 或许后面当我们再次接触该类时, 当初那点小小的改动就足以引发我们对该类的彻底重 构 。届 时我们便可以将 CCAlrnage 中的职责分解到几个不同的类当中,并让后者成为可测试的 . 推翻访问保护 在许多比 C++晚的面向对象语言中,都可以使用反射( reflection )和特殊许可去(在运行 期)吁问私有变量 . 虽说这样的功能有些时候很好用,但有点"欺骗"的味道.诚然,当我们 想要解依赖时是非常有帮助的,但我可不喜欢将访问私有变量的测试代码留在项目中.因为这 种以"欺骗"手段访问私有变量的方式会蒙蔽团队的眼睛,使他们看不到代码变得有多糟糕 . 这样说或许有点残酷,但也别忘了,在一个遗留代码基中工作的痛苦经历反过来也能够成为我 们去进行修改的强大驱动力.当然,我们也可以用"旁门左道"来达到目的 ,但除非我们着眼 于解决根本问题(即责任过多的类和错综复杂的依赖),否则再怎么努力也只是暂时逃避问题, 最终,当每个人都发现代码变得糟糕时再去进行改善所花的代价就难以想象了 . 10.2 "有益的"语言特性 语言设计者经常会试图加入一些方便的语言特性,但这件事并不容易.他们得在易编程性与 安全性中进行折中。有些语言特性一开始看上去的确是"面面俱到"了,然而当我们想要测试使 用了这些持性的代码时,残酷的现实就显露出来了 。 下面这段C#代码负责从一个Web客户端接收一组上载的文件,然后遍历它们,从中挑出具有 特定特征的文件,然后返回一组与这些文件关联的流 。 public void IList getKSRStreams(HttpFileCollection files) ArrayList list new ArrayList () i foreach(string name in files) { HttpPostedF 工 le file files [name) i if (file . FileName . EndsWith (" . ksr") I I 第 10 章无法在测试用具中运行方法 119 (file . FileName . EndsWith(. . txt~) && file.ContentLength > MIN_LEN1) list . Add(file.lnputStream) ; return list; 现在,我们想要对以上代码作一些修改,或许再来一点重构,但问题是给它编写测试就不那 么容易了。我们想要创建 一 个 Ht tpFileCollection 容器对象并往它里面放入一组 1 141 1 HttpPostedFile对象,而这又是不可能的 。首先 , HttpPostedFile类并没有公用的构造函数。 其次,它是一个封闭的类.在C#中,这两点便意味着我们没法创建HttpPostedFi le 的实例,而 且我们也无法从它进行派生。 Ht tp Post edFile是 NET库的一部分。里面的其他一些类会在运行 期创建它的实例,但我们却没有对它的访问权。此外,打开 HttpFi l eCollection类的定义稍 微看一看就会发现, 它也有同样的问题: 无公用 构造函数, 无法创建其派生类。 . NET类库为什么要这么做呢?毕竟我们花钱买了它的许可证啊。说实话我倒并不认为这是 故意的,如果说微软是故意这么做的话,那 Sun也一样,因为这并不只是微软的语言的问题。 Sun 的语言也有阻止子类化的特性 。在 Java 中, final 关键字正是用来干这个的,当一个类在安全性 方面很敏感时 ,便可对它使用 final 。若是任何人都能创建 HttpPostedFile (或者甚至像 String这样的类) 的子类 的话,他们岂不就能编写出一些恶意的代码并将其用于那些使用了这 些类 的代码中了?这是非常危险的,不过 sealed和 final 关键字有时候对我们来说又显得过于 激进了,就拿刚才讨论的情况来说吧,它给我们带来了不小的麻烦。 那么,要想为 getKSRStreams 编写测试的话有什么办法呢?我们不能使用接口提取 (285 页) 或实现提取 (281 页),因为 H ttpPost edF ile和 HttpFileCollection并不由我们控制,它们是类 库里面的类,是不能随便去修改的.所以我们只能使用参数适配 (A也plParame'恼, 258页〉手法了。 不过,就这个例子来说我们还是挺幸运的,因为我们对那个HttpFi leC ollec t 工 on所作的 只不过是遍历而已 。 虽然 HttpFileCollection是个封闭类 ,但它却有一个非封闭的基类,叫 做 NarneObjectColl ectionBaseo 我们可以对后者进行子类化,并将所得子类的对象传给 getKSRStreams方法。借助于依靠编译器 (251 页〉技术,我们的修改既安全又容易。 public void LList getKSRStreams(OurHttpFileCollection files) ArrayList list new ArrayList () ; foreach(string name in files) HttppostedF 主 le file files [name) ; if (fi1e . FileN缸ne . EndsWith( " . ksr-) 11 (file.FileName.EndsWith(- . txt - ) && file . ContentLength > MAX_LEN1) list .Add(file . lnputStream) ; return list; 120 第二部分修改代码的技术 OurHttpFileCol1ection 是 NarneObjectCollectionBase 的子类,而 NarneObject 11421 CollectionBase则是-个抽象类,其功能是将字符串关联到对象. 这样一来我们就解决了其中一个问题。接下来的问题要稍困难一些:要在测试中运行 getKSRStreams ,我们需要一组 HttpPostedFiles. 而不幸的是我们偏偏又无法创建 HttpPostedFile 的实例。既然如此,不妨换个角度来思考·我们实际上需要用到的是 HttpPostedFile上的两个属性 FileNarne和 ContentLengtho 故而我们可以利用剥离并外覆 API ( 169 页)技术,解开代码与 HttpPostedFile 之间的稿合。为此 ,首先提取一个接口 IHttpPostedFile ,然后编写一个外覆类HttpPostedFileWrapper , public class HttpPostedFileWrapper : IHttpPostedFile { public HttpPostedFileWrapper(HttpPostedFile file) this.file filei public int ContentLength { get { return file . ContentLength; } 既然有了这么一个接口,也就可以利用它来创建一个测试用类了: public class FakeHttppostedFile IHttpPostedFile { public FakeHttppostedFile (int length, Stream stream, . . .) { •. . } public int ContentLength { get { return length; ) 现在,如果我们依靠编译器C2 51 页) .并对产品代码作 一点 修改的话,就可以通过 工 HttpPostedFile接口来使用 HttpPostedFileWrappe r 或 FakeHttpPostedFile对象,而无 需知道到底背后被使用的是哪一个了。 public IList getKSRStreams (OurHttpFileCollection) { ArrayList list new ArrayList () ; foreach(string name in files) { IHttppostedFile file files [namel ; if (file.FileName.EndsWith(".ksr") 11 (file.FileName.EndsW 工 th ( " . txt" ) ) && file.ContentLength > MruCLEN)) list .Add(file . lnputStream) ; return list; 第 10 章 无法在测试用具中运行方法 121 唯一麻烦的就是,我们必须得在产品代码中先遍历一遍原先的 HttpFileCollection容棒, 将其中的每个 HttpPostedFile对象都"打包"进一个相应的 HttpPostedFileWrapper对象中. 11431 然后将这些外覆对象放进一个新的容器 (OurHttPFileCollection) 中,并将后者传给 getKSRStreams方法 。 这就是安全性所要付出的代价 。 说真的,我们很容易相信 sealed Jlli! final 都是错误的特性,本就不应该加入到编程语言中. 然而实际上,真正的错误却出在我们自己身上,是我们自己选择直接依赖于不由我们控制的库的, 这一举动等于是在自寻烦恼。 将来的主流编程语言或许会给测试提供特殊访问权限,但现在,保守使用 sealed和l final 还是有好处的。当我们需要使用标记为 sealedl 且 nal 的类时,最好将它们隔离在一层外覆类后 面,这样以后对代码作修改时才能有一些回旋余地 。关于如何解决该问题的更多讨论和技术,请 参考第 14章 以及第 15 章。 10.3 无法探知的副作用 理论上,为一段功能编写测试不应该太难。实例化一个类,调用它的方法,然后检查结果, 就这样简单.那么,哪个环节可能 出问题呢? 实际上,如果我们欲创建的对象不跟其他任何对象 沟通的话,事情的确就像刚才描述的那样简单。甚至就算其他对象使用了该对象 ,但只要该对象 并不使用其他对象,我们的测试就可以像程序的其他部分那样使用它 。 然而遗憾的是,不使用 其 他对象的对象少之又少 . 程序是一个各部分互相协作的整体 。 常常会看到一些并不返回任何值的方法。我们调用这些 方法,它们完成各自的工作,而我们(指调用方代码)则根本不知道它背后都干了些什么。某对 象调用其他对象上的方法,而我们则根本无从知道结果 。 下面这个类暴露了上面所说的问题: public class AccountDetailFrame extends Frame 主 mplements ActionListener , windowListener pn飞late TextField display new TextField(10); public AccountDetailFrame ( . . . ) { ... } public void actionPerformed(ActionEvent event) String source (String) event. getActionCommand () ; if (source . equals("project activity")) detailDisplay 二 new DetailFrame(); detailDisplay.setDescription( getDetail Text () + " " + getproj ectionText () ) ; detailDisplay . show{); String accountDe scrip t 工。n detailDisplay.getAccountSymbol() ; accountDescription += ": "; display .setText(accountDescription); 回回 122 第二部分修改代码的技术 这个遗留的 Java'类什么都做。它创建GUI构件,并使用 actionPerformed事件处理函数从它 们那里接收通知消息,然后计算需要显示的内容并显示它们。不过,它做这些事情的方式非常奇 怪:首先建造一段详细文本,然后创建并显示另一个窗口。当该窗口完成它的工作时,该类再直 接从它获取信息,作一点处理,然后放入到一个文本框 i 中。 我们可以试着在一个测试用具中运行该方法,但这么做没有任何意义。它会创建一个窗口, 显示给我们,让我们输入数据,然后接着在另一个窗口显示一些东西。没有合适的地方可以感知 这段代码做了什么 那么,我们可以做些什么呢?首先,可以将依赖于 GUI的代码与不依赖于 GUI 的代码分离开 来。由于所用的编程语言是 Java ,所以我们可以选一个 Java 的重构工具来用。第一步工作就是执 行一组方法提取(3 25 页),将这个大方法中的工作分割成小块。 那么,具体从哪开始呢? 该方法本身主要是起到一个事件响应"钩子"的作用,负责响应窗口框架传递来的通知消息。 它所做的第一件事情便是从接受到的动作事件3 中获取命令的名字。所以,如果将该方法的整个 方法体都提取出来的话,也就可以完全脱离对ActionEvent类的依赖了,如下所示: public class AccountDetailFrame extends Frame implements ActionListener , windowListener private TextField d 工 splay new TextField (10) ; public AccountDetailFrame ( . . .) ( _ public void actionPerformed(ActionEvent event) String source (String) event . getActionCorrunand () ; performc。回阻 nd(source); public void performCommand(String source) if (source .equals ("project activityn)) detailDisplay new DetailFrame (); detailDisplay.setDescrip巳 ion( getDetailText () + 11 " + getProjectionText () ) ; detailDisplay.show(); String accountDescription detailDisplay.getAccauntSymbol(); accauntDescription += " : "; 1 成员变量 displaYø 一一译者注 2 副作用.一一译者注 3 参数 even t o 译者注第 10 章 无法在测试用具中运行方法 123 display.setText(accountDescription)i 然而要想让这些代码真正变成可测试的,这点工作还不够。下一步便是将访问另一个窗体的 代码提取成方法。为此, 一个有益的做法是将deta 工 lDisplay设成该类的一个实例变量,如下 所示 z public class AccountDetailFrame extends Frame implements ActionListener , WindowListener private TextField display new TextField(10) ; private DetailFrame detailDisplaYi public AccountDetailFrame (. . . ) .. } public void actionPerfòrmed(ActionEvent event) String source (String)event.getActionCommand(); performCommand(source) ; public vo 工 d performCommand(String source) 工 f (source . equals( "project activity")) detailDisplay new DetailFrame (); detailDisplay.setDescription( getDetail Text () + " " + getProj ectionText () ) ; deta 工 lDisplay . show{) ; Str 工 og accountDescription detailDisplay.getAccountSymbol() ; accountDescr 工 ption += ": "i display.setText(accountDescription) ; 有了这一步铺垫,我们便可以将使用 detailDisplay 窗体的代码提取成一组方法了。那么 该如何为这组方法命名呢?为此,我们从该类的角度来考虑每段代码都做了些什么,或者说它们 都为该类计算了些什么。此外,我们不应该使用与显示组件有关的名字,可以在提取出的代码中 使用显示组件,但其方法名却应当隐藏这一事实。有了这些考虑,我们便可以将提取 出的每块代 码做成一个命令式方法或查询式方法了。 命令/查询分离 命令/查询分离是最先由 Bertrand Meyer提出的设计准则.简而言之就是:一个方法要么是 因因 124 第二部分修改代码的技术 一个命令,要么是一个查询,但不能两者都是.命令式方法指那些会改变对象状态但并不返回 值的方法 . 而查询式方法则是指那些有返回值但不改变对象状态的方法. 那么,为什么说这是个重要的原则呢?有几个原因,其中最重要的就是它向用尸传达的信 息.例如,如果一个方法是查询式的,那么无需查看其方法体就知道可以连续多次使用它而不 用担心会带来什么副作用 . 在经过了一系列的方法提取之后, perforrnCommand方法看起来就像这样 z public class AccountDetailFrame extends Frame implements ActionListener, windowListener public void performCommand(String source) if (source . equals("project activity")) { setDescription(getDetailText() +四" + getprojectionText{)); String accountDescription getAccountSymbol () ; accountDescription += ~: "i display . setText(accountDescription) i void setDescription(String description) detailDisplay new DetailFra皿e(); detailDisplay.setDescription(description); detailDisplay.show(); String getAccountSymbol() { return detailDisplay.getAccountSymbol(); 既然我们已经将所有与 detailDisplay 窗体有关的代码都提取出来了,那么接下来就可以 找出并提取那些访问 AccountDetailFrame上的组件的代码了 。 public class AccountDetailFrame extends Frame implements ActionListener, WindowListener { public void performCommand(String source) if (source.equals("project activity" )) { setDescription (getDetail Text () + " " + getProjectionText () ) i S 巳 ring accountDescription detailDisplay.getAccountSymbol() ; accountDescription += ": "; setDisplayText(accountDescription)i 第 10 幸元法在测试用具中运行方法 125 void setDescription(String description) detailDisplay new DetailFrame () i detailDisplay.setDescription(descriptionJ ; detailDisplay.show() ; String getAccountSymhol() ret urn deta 工 lDisplay.getAccountSymbol() ; void setDisplayText(String description) display. setText(description); 在作了这一番提取之后,我们便可以运用一下子类化并重写方法技术,并对performCommand 中剩下来的代码进行测试了。例如,像下面这样子类化 了 AccountDetailFrame之后,我们便 可以测试当给出 " project activity " 命令时 disp1ay文本框能否得到正确的文本了: public class TestingAccountDetailFrame extends Account DetailFrame ( String displayText String accountSymbol void setDescript 工 on(String description) } String getAccountSymbol () return account Symbol ; void setD 工 splayText{String text) displayText text ; 下面这段代码就是用来测试perforrnCommand方法的 z public void 巳 estPerformCommand( } Tes 巳 ingAccountDetailFrame frame new TestingAccountDetailFrame() ; Erame. accountSyrnbol "SYM" ; Erame.performCommand("project activity") ; assertEquals ("SYM : basic account " , frame . displayText) ; 在像上面这样非常保守地通过 自动方法提取进行解依赖之后,得到 的代码可能会让我们心生 怯意。例 如, setDescription方法负责创建并显示 个窗体,这样的方法绝对是令人头大的 。 如果我们不小心两次调用了它会怎么样呢?得解决这个问题,以上一系列粗糙的方法提取是个不 因126 第二部分修改代码的技术 错的起点。接下来我们可以看看能否将这些窗体创建代码重新安置到一个更好的地方去。 我们来理 一 下目前的状况: 一 开始我们有一个类,该类上面有 一 个重要的方法: perforrnA ctiono 而现在的状况则可以用下图来显示(图1O-J) 。 虽然从m伍图中看不出来,但实际上 getAccountSymbol 和 setDescrlption 只使用了 detailDisplay成员变量。而 setDisplayText 也只是使用了 display成员变量而已。于是我 11491 们可以将它们看作互相独立的职责,从而最终得到如图 10-2所示的设计: AccountD唱tallFrame . display : TextField . detailDisplay : DetailFrame +阻rformAclion(ActionEvenl) +阳rformCo mmand(Slring) + getAccounlSymbol : 51rin9 + setDisplayTe xt(String) + setDescriplion(Slring) 图 10-1 AccountDetailFrame AccountDetailFrame display : TextField + seIDisplayText(Slring) SymbolSource -delailDisplay : DetailFrame + getAc∞ untSymbol : Slring + set De sc汗 iption币1 阳91 图 10- 2 AccountDetailFrarne粗略地重构之后 虽说这是极其粗糙的重构,但至少在某种程度上将儿个职责分离开来了.不过, AccountDetailFrarne (Frame 的子类〕仍 ~lWUI联系紧密,并且它里面仍然包含着业务逻辑。 进一步的重构可以解决这个问题,但至少现在我们可以在测试用例中运行那个原先包含了 业务逻 辑的方法了。这不能不说是一个积极的进展。 SymbolSource类是-个具体类,它代表着创建另一个窗体并从其获取信息的决策。然而, 我们之所以将它起名为 SyrnbolSource. 是因为从AccountDetailFrarne 的角度来说该类的工作 只是通过它所认为必要的方法获取到某些符号形式的信息。如果 SymbolSource演化成了 一个接 口我也肯定不会感到惊讶,只要背后获取信息的途径 1 改变了便可能会出现这一情况。 本例中我们采取的步骤是很常见的 。在有重构工具可用的情况下,很容易就可以对一个类进 行方法提取,然后将方法分组,以便可以放到新类中去。 一个好的重构工具能够判断你想要进行 的自动方法提取是不是安全的,如果不安全便不会予以进行 。然而 ,这只会令我们进行的其他修 改成为工作中最危险的部分。所以说,记住,如果目的是为了让测试能够安置到位的话,提取出 具有糟糕名字或糟糕结构的方法是可以接受的 。毕竟 ,安全才是第一位。在测试到位之后,就可 回 以放心着手让代码变得更清爽了 . l 如不再通过窗体+用户输入的方式,而是通过网络或数据库来获取,等等.一一译者注第 11 章 修改时应当测谎哪些方法 假设我们现在需要作一些代码修改,并且需要编写特征测试(1 53 页)来"固定"住已有的 行为,那么应当为哪些地方编写测试呢?最简单的答案就是为我们所要修改的每个方法都编写测 试。但这就够了吗?如果代码较为简单且易于理解的话的确是够了,但对于遗留代码来说,往往 并非如此。一个地方的改动可能会影响到其他地方的行为:除非有测试"坐镇"否则我们可能 永远也不知道自己的修改造成了什么影响。 当需要在特别错综复杂的遗留代码中作改动时,我通常会先花点时间考虑一下应当在哪儿编 写测试。这一过程包括考察将要进行的改动,看看它会带来哪些影响,看看被影响的东西又进而 会对哪些东西造成影响,后者又会造成哪些影响……。这种推理方式并不新鲜,人们在计算机的 启蒙时代就这么做了。 程序员们可能会因为各种各样的原因而坐下来对他们的程序进行推理。有意思的是,对此我 们谈论得并不多。我们只是假设每个人都知道怎么做,以及假设这是程序员的"份内之事"。然 而当我们面对的是错综复杂的、不易理清的代码时,光是嘴上说说是无济于事的。我们知道应该 对代码作一点重构来让它更易于理解,但前面遇到过的测试困境又出现了:如果没有测试在手, 我们又如何能知道正在进行的重构是正确的呢? 本章描述的技术填补了这个空白。看来,对于遗留代码,通常我们的确得花点功夫来推测一 下代码修改会产生哪些影响,以便找到编写测试的最佳地点。 11.1 推测代码修改所产生的影晌 虽说在业界我们就这个问题谈论得并不多,然而实际情况是,每对一个软件作 次功能上的 改动,都会带来一连串互相关联的影响。例如,假设我们将下面这段C#代码中的 3 改为 4. 就会影 [u!] 响到该函数的返回值,并进而影响到调用该函数的函数的返回值…·一路影响下去,直到遇到某 种系统边界为止。话虽如此,仍有许多代码的行为还是跟以前 一 样。由于并没有调用 getBalancePoint(). 所以它们给出的仍是原来的结果。 int getBalancePoint() const int SCALE_FACTOR 3; int result start 工 ngLoad + (LOAD_FACTOR * residual 会 SCALE_FACTORJ ; foreach(Load load in loads) 128 第二部分修改代码的技术 result += load . getPointWeight() 舍 SCALE_FACTOR; return result ; IDE对代码影晌分析的支持 有时候真希望有个IDE能帮我在遗留代码中"看到"代码修改所产生的影响.想象这样一 种情景 选中某块代码,敲下一个快捷键,于是IDE使给出了对该块代码作改动所可能影响到 的所有变量和方法的列表. 或许有一天人们会开发出这样的工具 . 但在那一天到来之前我们还是得学习如何在 J生有工 具的情况下仅凭大脑去椎测代码修改的影响.这个技能学起来不难,但我们 4拟住知道何时才算 正确掌握了它. 要想了解影响推测是个什么概念,最佳途径就是从实例入手.下面就是一个 Java类,该类所 属应用程序的功能是操纵C++代码 .这听起来似乎太专业,但其实在推测代码修改的影响时,有 没有相关的领域知识并不重要。 让我们来做一个小练 习 。下面是一个名为 CppClass 的类,列出其中所有能够在 CppClass 对象创建之后被改变,从而对其方法的返回值产生影响的东西。 public class CppClass { private String name; private List declarations; publ 工 c CppClass(String name , List declarationsl thiS . name name ; this . declarations declarations; public int getDeclarationCount() return declarations . size() ; 回 public String getName() return name; public Declaration getDeclaration (int index) return ((Declara 巳工。 n)declarations . get(indexll ; public String getInterface(String interfaceName , int [) indicesl String result "class" + interfaceName + "飞 npublic\n" ; for (int n 0; n < indices . length; n++ 1 Declaration virtualFunction (Declaration) (declarations . get(indices(n]ll ; result + 二 "\t" + virtualFunction . asAbstract (1 + ..\n" ; result += " ) ; \n" ; 第 11 章修改时应当测试哪些方法 129 一 return resulti 你的答案看起来应该像下面这样: (1)可以在 declarations 列表被传递给CppClass 的构造函数之后 1 再往它里面添加额外的 元素。由于该列表是按引用传递给CppClass 的构造函数并由 CppClass 的 declarat 工。ns 成员变 量按引用持有的,因此对它的改动会影响到 getInterface 、 getDeclaration 以及 getDeclarationcount 的结果。 (2) 可以改动或替换declarations 列表内的元素,同样还是影响到那几个方法。 有些人看到 getName ()可能会想,如果有人改动了成员变量name 的话,它的返回值使也 被改变了,然而实际上,在 Java 中, String对象是常性的.也就是说它们一旦被创建,值就 无法改变了.所以,在 CppClass 对象被创建出来之后,其 getName ()使总会返回同样的 str 工 ng值. 下面的 一幅图展示了对 declarations 的改动是怎样影响到Ugetdeclarationcount() 的 (图 11- 1 )。 getDeclarationCount 图 11-1 declarations影响 getDeclarationCount 从该图中我们可以看出,如果 declarat 工 ons 发生了某些改变,例如其中的元素数目改变了, 那么 getDeclarationCount() 的返回值也会随之改变。 同样,对于 getDeclaration() ,我们也可以画出 一张图来(图 11-2) • declaralions getbeclaration 图 11-2 declarations 以及它持有的元素对 getDeclaratio口的影响 1. I.I PCppClass对章已经被构造起来之后. 译者注 固130 第二部分修改代码的技术 如果有人改动了 declarations 或者其中的元素, !i1IJgetDeclaration (工 nt index) 的返回 值也会改变。 图 11-3 展示了 getlnterface被影响的情况。 getlnterface 图 11-3 影响 getlnterface的因素 11541 现在,我们可以将上面这几幅图结合起来,成为一张大图(图 11-4) 。 getDeclarationCount getlnterface getDeclaration 图 11-4 合并起来的影响图 这种图的规则并不复杂。我把它们称为影响草图 1 。作图的关键是 z 为每个可能会被影响到 的变量以及每个返回值可能改变的方法画一个单独的椭圆。这些变量可能来自同一个对象,也可 能来自不同的对象。究竟属于何者并不重要,我们只需为每个会改变的东西画上一个椭圆,并从 它们出发画一个箭头指向那些因它们的改变而在运行期改变的东西。 倘若你的代码结构良好,到 'J 其中的大多数方法的影响结构也会比较简单.实际上,衡量软 件好坏的标准之一使是,看看该软件对外部世界的相当复杂的影响能否由代码内的一组相对简 单得多的影响所构成.任何改动,只要能够使代码的影响结构图简单化,就能够使其支易理解 和维护. 1 后立也有称"影响结构图"、"影响结构示意图"的,意思样。一一译者注第 11 章修改时应当测试哪些方法 131 让我们把视野放远一点 ,看一下CppClass所处系统的影响 结构图 o CppClass对象是在一个 名 为 ClassReader 的类 中被创建出来的 。实际上 , 我们己经能够确定 ,它们仅在ClassReader 中被创建 . public class ClassReader { private boolean inPublicSection false ; private CppClass parsedClass i private List declarations new ArrayList () i private Reader reader; public ClassReader (Reade r reader) this . reader reader; public void parse () throws Exception { TokenReader source new TokenReader (reader) ; Token classToken source . readToken () ; Token className source . readToken () ; Token lbrace source. readToken ( ) ; matchBody(source) ; Token rbrace 二 source . readToken() ; Token semicolon source . readToken ( ) ; if (classToken . getType () Token. CLASS && className.g etτ'y pe () Token . IDENT && l b race .ge tτ'y pe ( ) Token . LBRACE && rbrace . getType () Token . RBRACE && semicolo n.g et τ'yp e ( ) Token . SE 哑 IC I ( parsedClass new CppCl ass (className. getText ( ) , declarations); 记得我们之前对 CppClass 有哪些了解吗? 当 时我们能否知道一个 CppClas s 对象在被创建 出来之后 , 它所持有 的 declaration s 列表会不会再改变呢 ? 这个问题是没法在 CppCl a ss ll~)L 找到答案 的,我们需要弄清楚decl arations列表是怎么被填充的 。 如果进一步考察上面这个类 , 便能看 出, ClassReader 中只有一处地方往 declarations列表中添加了 Declara tion对象 , 那就是在matchVirtualDeclaration方法当 中 .具体过程为 , parse ( ) 中调用了 matchBody ( ) • 后者进而调用了下面这个 mat c hVirt ua lDeclaration方法 z private void matchVirtualDeclaration (TokenReader source) throws IOExcept ion { if (! source . peekToken () . getType () Token . VIRTUAL ) returni List declarationTokens new ArrayList () i declarationTokens . add(source . readToken()) ; while(source . peekToken() . g e tτ'ype ( ) ! = Token . SEMIC) ( declarat 立。 nTokens . add{source . readToken() ) ; 回132 第二部分修改代码的技术 declarat 工。 nTokens.add(source.readToken()); if (inPublicSection) declaratioDs.add(new Declaration(declarationTokens))i 看上去,所有在这个列表上发生的事情都在CppClass对象被创建出来之前发生了。由于我 11561 frJ将一个元素存入declarations列表之后便不再保留其任何引用\所以该列表便没法再被更改了 。 回 再来考察一下 declarations 列表里面的元素 o TokenReader 的 readToken方法返回的是 一个Token对象,后者仅持有一个字符串和一个永不改变的整型数。毫不夸张地说,只需扫-眼 Declaration类的定义便可发现,一旦其对象被创建起来,就没有任何其他东西可以再改变它 的状态了,于是我们可以很放心地断言,一个CppClass对象被创建后,其中的 declarations 列表以及列表里的元素便不再变动了 。 以上这些分析对我们是有帮助的,如果我们从 CppClass对象那儿得到了 一些意外的结果, 那么我们知道只需从儿个地方下手来找原因就可以了 。一般来说,可以查看CppClass 的子对象 都是在哪些地方创建的,从而去弄清楚发生了什么。另外我们还可以给CppClass持有的某些引 用加上巳 nal 修饰,从而使它们成为筒'盘,这样代码便更加清晰了 。 对于写得糟糕的程序,我们往往会发现很难弄明白眼下发生的事情因何而起。当面对一个意 料之外的结果时,需要对付的其实是一个调试问题,我们得从问题的现象一路推到它的源头 。然 而倘若面对的是遗留代码,需要考虑的便是另外一个问题了,即一次特定的修改可能会对程序的 其余结果产生何种影响 . 这就要求我们从修改点一路向前推测影响。掌握了这种推理方式,也就初步掌握了寻找编写 测试的合适地点的技术 。 11.2 前向推测 在前面的例子中,我们试着从代码中某个特定地点的值的异常情况出发,推断出是哪些对象 对它产生影响。然而 , 在编写特征测试(1 53 页〉时,这个过程是反过来的。具体来说就是,面 对一组对象,试图搞清如果它们停止工作的话"下游"会发生什么情况。例如,下面这个类来自 一个内存中的文件系统。针对它目前还没有任何测试存在,不过我们想要对它作一些修改。 public class InMemoryDirectory ( private List elements new ArrayList () i public void addElement(Element newElement) { elements.add(newElement) ; 1. declarations . add(new Declaration (declarationTokens) )这行代码显示出,我们井没有保留被加入 到 dec la rations 列表中的元素的引用, ←个 Decla ration 且 new 出来便当即被盐I JlJde clara tions 中击了. 一一#者注第 11 章修改时应当测试哪些方法 133 publ ic void generatelndex ( ) { Element index new Element ( • index" ) ; for (Iterator it elements . iterator() i it.hasNext(); ) Element current (Element) it. next () ; index . addText(current . getName() + .\n") ; addElemen 巳(工 ndex) ; public in 巳 getElementCount() return elemen 巳 s . size() ; public Element getElement(String" name) for (Iterator it elements . iterator(); it . hasNext() ; ) Element current (Element) it. next () ; if (current . getN缸ne () . equals (name)) return current ; return nu11; InMemoryDirectory是一个不大的 Java类。我们可以创建一个工 nMemoryDirectory对象, 往其中添加元素 (addElement( 仆 , 生成索引 (generatelndex() ),并访问其中的元素。这 里的元素 (Element) 即内部存有文本的对象,就像文件一样。生成索引的过程是这样的创建 一个名为 Uindex " 的元素,然后将其他所有元素的名字添加到该元素内的文本区中(每个名 字 一行)。 I nMemoryDirectory有一个旧特性,就是generatelndex不能被调用两次,否则就会导致 存在两个不同的索引元素(第二个索引创建时实际上是把第一个索引元素当作自己的一般元素了 λ 幸运的是,我们的程序使用 I nMemoryDirectory 的方式是非常规矩 的 。 创建目录对象,用 元素填充它,调用它的 generatelndex方法,然后传递给需要访问它的元素的地方 . 目前为止 一切都好 , 然而问题是现在我们要进行一次修改,从而允许人们在目录对象的生命周期中的任何 时候都可以往里添加元素。 、 理想情况下,我们希望随着元素被添加进目 录 ,索引的创建和维护工作会自动完成。当第一 个元素添加进目录时,索引就应该自动建立起来,而且应当包含被添加元素的名字。下一次,当 又有新元素添加进这个目录时,同 IJ 才建立的那个索引应该自动被更新以包含这个新元素的名 字。 1 158 1 看起来给这个新功能编写测试 以及满足测试的代码是件再简单不过的事情,但问题是针对目前的 行为还没有任何测试存在。那么,我们如何知道将测试安置在哪呢? 本例中答案很清楚· 我们需要一系列的测试,它们以各种方式调用 addElernent ,然后生成 -个索引,最后获取这些元素看看它们是否正确 。 问题是,我们怎么知道应该使用这些而不是其 他方法呢?本例中问题 比较简单,测试只不过是对我们期望的使用目录的方式的描述。我们甚至134 第二部分修改代码的技术 用不着去看目录类的代码就可以写出这些测试来,因为我们对该类应该做什么早就有很好的把 握。不过遗憾的是,有些时候找出合适的测试地点并不是件简单的事。我本可以在这个例子中采 用 一个大而复杂的类,就像那种常常潜藏在遗留系统中的那些类。但那样一来你可能就会不耐烦 地把书给合上了。所以还是让我们假设这是一个棘手的类吧,看看通过考察代码怎样才能弄明白 应该测试哪些东西。而对于比这更棘手的问题,这里的推理方式同样适用。 本例中我们首先需要做的事情便是弄清楚该在哪些地方进行修改。答案是我们需要从 generatelndex ()中移除 些功能,并往 addElemen t ( )中添加一些功能。确定了这些修改点 之后,便可以开始勾勒影响结构示意图了。 首先来看 generatelndex ()。这个类当中没有任何其他方法调用它,唯一调用它的地方就 是客户代码了。那么,另 一方面, generatelndex() 当中创建了一个新的元素 」 索引元素)并 将该新元素添加进了目录中,它会对该目录类中的元素集合产生影响(见图 11-5) 。 图 11-5 generatelndex 影响了元素集合 现在我们便可以转而考虑元素集合会影响哪些东西了。那么,还有哪些地方使用了元素集合 呢? getElementCount似乎算一个, getElement 也是。另外, addEleme且也会用到这个元素 集合,不过我们可以不考虑它,因为不管元素集合怎么改变, addElement 的行为始终是不变的: 11591 也就是说,无论我们对元素集合做什么, addElement 的用户都不会受到影响(见图 11-6) 。 这幅图到这儿算是完成了吗?还没有,我们的修改点是在 generateIndex和 addElement 这两个方法中,因此还需要考察 addElement 是如何影响周围的系统的。首先,看起来 addElement 同样会对元素集合造成影响(见图 11 - 7) 。 图 11-6 generatelndex的改变进一步带来的影响 图 11-7 addElement 影响元素集合 我们可以进 一 步看看元素集合的改变会造成哪些影响,不过由于前面我们在分析 generatelndex的影响结构时已经这么做过了,所以不再重复。 i 现在可以画出整个的影响结构示意图了(见图 11 - 8) 。第 11 幸修改时应当测试哪些方法 135 囹 11-8 I nMem。巧 Direct。可类的影响结构示意图 InMernoryDirectory类的用户能够感知到影响的唯一途径便是通过getElemen tCount 和 getElernent 这两个方法.只要我们能够对这两个方法编写测试,似乎也就可以涵盖可能造成的 一切影响了。 不过,有没有可能漏掉了什么东西呢?我们考虑过它的基类和派生类吗?如果 I nMemory­ Directory 中 的某些数据是公有的、受保护的或者包作用域的,那么其派生类中的方法便能以我 们所不知道的方式来修改它们。不过,由于本例中 I nMemoryDirectory 中的实例变量是私有的, 因此我们无需担心这种情况。 在画影响结构图的时候,你得确保找到了所考察的类的所有客户端.如呆你的类有一个基 类或派生类,那么得注意一下它们里面是不是还有没有被注意到的客户代码. 到目前为止方方面面都考虑到了吗?还没有,事实上还有一件事情我们一直没有提及.我们 的目录类中的元素的类型为 Element ,但影响示意图中并未出现这个类.下面我们来仔细考察一 下它. generate 工口dex方法的工作方式是先创建一个 Element ,然后不断往它里面添加文本。现 在让我们来看一看 Element类的实现代码: public class Element { private String namei private String text public Element(String name) th主 s . name name i public String getName() return name i 回 回136 第二部分修改代码的技术 public void addText(String newText) text += ne队trext i public Str 工 ng getText () ( return text; 所幸的是 Elernent 类的定义还算简单。在下面的影响结构图中给generatelndex创建的新 元素画一个椭圆 (见图 11-9) 。 newElement.addTexl 图 11-9 通过Element类的影响 当新建的元素内部被填充了文本之后, generatelndex便会将它添加进元素集合中,所以, 因这个新元素影响了元素集合(见图 11-10) 。 elements 图 11~ 1O generatelndex影响了 elements集合 从我们前面的分析中得知, addText 方法会影响元素集合,后者又会进一步影响 getElement和 getElernentCount 方法的返回值。如果我们想要看看索引元素当中的文本是否第 11 章修改时应当测试哪些方法 137 正确,可以先用 getE l ement 获取它,然后调用其上的 getTex t 方法 . 以上便是所有需要编写测 试来侦测修改能带来影响的地方了 。 正如前面提到的,这虽是一个相当小的例子,然而却很能代表我们在估计对遗留代码的修改 所带来的影响时需要进行的那种推断 。 要找到安放测试的地点,第一步便是推断出哪儿可以探测 到我们的修改所带来的影响,即修改会带来哪些影响 . 知道了在哪儿能够探测到影响之后,在编 写测试的时候便可以在这些地方进行选择了 。 11.3 影晌的传播 代码修改所产生的影响的传播方式有的难以察觉,有的则明显一些。在上 一 节中的 InMemoryD i r e cto ry例子中,我们最后的任务是寻找返回值给调用方的方法。尽管一开始是从 修改点开始跟踪修改所产生的影响的,我通常还是会先注意到那些带有返回值的方法 。 除非它们 的返回值没有被使用,否则便会将影响传播到它们的调用者那儿。 11631 影响也可能会悄无声息地以不易觉察的方式传播 . 如果我们有一个对象,而该对象又以另 一 个对象为参数的话,前者便可能修改后者的状态,而这一修改则会在应用当中的其他地方体现 出来 。 关于方法的参数是如何被对待(传递)的,每门语言都有不同的规则 . 许多时候默认的做 法使是按佳传递对象的引用.这也正是Java和C#的默认做法.这种做法的关键在于我们并不是 将对象本身传递给一个方法,而是传递它的一个"句柄 (Hand le)" .这一事实带来的结呆便是, 任何方法都可以通过它们接受到的句柄来修改相应对象的状态.此外,有些语言也会提供一些 关键字来指出一个句柄只能用于读取而不能用于修改它所指向的对象的状态.例如 C++ 中的 const 关键字,当将它用 于方法形参声明中时,就能够起到上述作用 . 代码影响代码的最难以觉察的方式便是通过全局或静态数据了,如下所示 z public class Element { private String name; private String text . .; public Element (String name ) { this . name namei public Strλng get Name () { re 巳 urn name ; public void a ddText(St ring newText ) text += newText ; V生 ew.getCurrentDisplay().addText(newText); public String get Text ( ) { 138 第二部分修改代码的技术 retur n t ext ; 上面这个类跟我们在 I nMernoryDirectory 中用到的那个Elernent 类几乎一模一样,只不过 有一行代码不同 addText 中的第二行代码。光看 Elernent 的成员方法的签名根本无助于我们发 现元素的改变对视图产生的影响。信息隐藏是件好事,只不过,若是被隐藏的是我们需要知道的 回信息就不妙了。 影响在代码中的传递有三种基本途径: (1) 调用方使用被调用函数的返回值. (2) 修改传参 1 传进来的对象,且后者接下来会被使用到. (3) 修改后面会被用到的静态或全局数据. 不过,有些语言中也有其他途径.例如,在面向方面( aspect-oriented )的语言中,程序 员可以编写所谓的"方面"代码,后者能够影响革统中其他地方的代码行为. 我在寻找修改造成的影响时会使用如下的启发式方法· (1)确定一个将要修改的方法。 (2) 如果该方法有返回值,查看它的调用方. (3) 看看该方法是否修改了什么值.是则查看其他使用了这些值的方法,以及使用了汪些方 法的方法 . (4) 别忘了查看父类和子类,它们也可能使用了这些实例变量和方法. (5) 查看这些方法的参数,看看你要修改的代码是否使用了某参数对象或它的方法所返回的 对象 . (6) 找出到目前为止被你所找出的任何方法修改的全局变量和静态数据。 11.4 进行影晌推测的工具 我们最重要的筹码便是对编程语言的认识.每门语言当中都存在所谓的"防火墙"即能够 阻止影响继续传播的语言结构。若能知道这些"防火墙"分别是什么,我们便清楚什么时候不必 穿越它们去追溯影响的足迹了。 假设我们想要修改下面这个Coord i nate类的表现形式,想要将它泛化成一个能够表示三维 和四维坐标的坐标类,为此我们打算改用向量来存储各坐标分量。然而,如果这个类是像下面这 样来实现的话,在追踪修改所带来的影响时就无需考虑这个类的代码之外了。 public c lass Coordi nat e { private double x 0; priva te double y 0 ; l 通常是按引用或传地址.←一译者注第 11 章修改时应当测试哪些方法 public Coordinate () () public Coordinate(double x , double y) this.x Xi this.y Xi public double distance(Coordinate other) { return Math.sqrt( Math.pow{other.x - x , 2 . 0) + Math.pow(other.y - y. 2.0)); 而对于下面这个实现来说就不是这样了: public class Coordinate ( double x 0 ; double y 0: public Coordinate () {} public Coordinate(double x , double y) this . x x; this.y Xi } public double distance(Coordinate other) return Math.sqrt( Math. pow (other.x - x , 2 . 0) + Math . pow(other.y - y , 2 . 0)); 139 上面这两个实现的区别很细微。在第一个实现当中. x和y变量是私有的。而在第二个实现里 则是包作用域的.所以,对于第一个实现来说,我们对于 x和y变量的任何改动只能 ìili J:t di s tance ( ) 成员方法来对类的客户代码造成影响,不管其客户代码使用的是 Coordinate还是它的某个子 类。而在第二个实现中就不同了,与 Coo rdinate类位于 同 一个包内的代码可以直接访问它的x 和y成员变量。于是我们就得关心一下这些代码了,可以将这两个变量也设为私有,从而确保没 有客户代码能够直接访问到它们。此外, Coordinate 的子类也可能会使用到这两个成员变量, 因此我们同样不得不照顾到它的所有子类,看看其中有没有使用了 x或y的。 对所用语言的了解和把握是十分关键的,一些微妙的语言规则经常会把我们弄得晕头转向。 比如下面这个C++类: class PolarCoordinate public Coordinate ( public PolarCoordinate(); double getRho() const ; double getTheta() const; 在C++中,当 const修饰符跟在方法声明的后面时,被声明的方法便不能修改其所属对象的 因 实例变量。但真的是这样吗?假设 polarCoordinate 的父类看起来如下= 也到 class Coordinate { protected mutable double first . secondi 140 第二部分修改代码的技术 当 C++ 中的rnutable关键字用于修饰一个(变量)声明时,便意味着该变量可以被 cons t 方 法所修改。不可否认, rnutable 的这种用法非常古怪,然而当我们面对的是一个不甚了解的程序, 需要弄清哪些会改变而哪些不会改变的时候,不管用法多古怪,也得硬起头皮进行影响分析 。在 C++中不作认真检查而简单地把 co nst 当作真正的常性是危险的。像这类能够被"绕过去"的语 言特性都要加以注意。 了解你所用的语言. 11.5 从影响分析当中学习 建议你只要一有机会就去分析分析代码中的影响。有时候,在对一个代码基很熟悉了之后, 就会明臼在做影响分析的时候无需操心某些角落有这种感觉就意味着你已经发现了代码基所 具有的一些"基本品质飞最好的代码中是没有多少"陷阱 (gotcha) " 的,它们里面所包含的→ 些"规则" (不 管这些"规则"有没有被显式表达出来)使你在寻找可能的影响时不至于钻牛角 尖。找出这些"规则"的最佳方式便是,首先设想一个在软件的不同部分之间传递影响的途径, 这一影响传递途径必须是你从未在代码基中见到过的,然后对自己说"不,那样的话就太愚蠢 了。"如果代码基中有许多那样的规则的话,你就会发现在其中 工作起来要容易得多。在糟糕的 代码中人们不知道这些"规则"是什么,或者说即便有所谓"规则"也到处都是"例外气 上面所谓"规则"并不一定是指编码规范或编程风格(如"决不使用受保护的成员变童" ) 之类的东西,而通常是一些与实际场景相关的方面。比如在本章开头的 CppClass例子当中,我 们做了 一个小小的练习,试图搞清当一个 CppC lass对象被创建出来之后哪些动作会对其使用者 11671 产生影响。下面就是相关代码的摘录: public class CppClass { private St ring name i private List declarationSi public CppClass(String name , List declarationsl this . name name i this . declarations declarati ons i 我们知道这样一个事实,就是当 一个 dec larations 列表被传递给CppC la ss 的构造函数之 后,人们可能还会对该列表进行修改。"但那样就太愚蠢了"这正是刚才提到的规则的理想候选。 如果在最初查看CppC lass 的时候就知道接受到的 declarations Ji~表是不会再改变的话,接下 去 的影响推测便会容易得多了。 总的来说,我们对影响的扩大范围的限制越是严厉,编起程序来就越容易,从而减少理解一 l 如 mutab le 立于 const 。一一译者注第 11 章修改时应当测试哪些方法 141 段代码所需的前提知识。最极端的情况便是使用像 Scheme和 Haske ll这样的函数式编程语言来编 程。用这些语言编写的程序有时候真的很容易就能够理解不过这些语言被使用得并不广泛。 不管怎么说,在面向对象语言中,限制影响的作用范围可以令测试变得容易得多,而且并没什么 东西阻碍你这么做。 11.6 简化影晌结构示意图 这本书讲的是如何令遗留代码更易对付,所以列举的很多例子都有一种"泼出去的水"的感 觉,泼出去的水当然是收不回来的,同样,对于早已写成的遗留代码你也别指望能够从头来过了。 不过,我希望能够借此机会展示一些能够从影响示意图中看出来的非常有用的东西 。它能影响你 以后编写代码的方式。 还记得 CppClass 的影响示意图吗(见图 11 .11 ) ? getDeclarationCount gellnlerface getDeclaralion 图 1 1-11 CppClass 的影响结构示意图 看起来这幅影响结构图有点"散"。两块数据(一个是 declarations 集合,一个是单个 declaration) 对好几个不同 的方法都有影响。我们可以在这几个方法中选出 一个或几个来进 行测试。最佳选择就是 getlnterface. 因为它对declarations对象的使用比较全面一点。有 些东西我 们能够通过 ge口 nterface 方法轻易感知到的,却并不能同样容易地通 过 getDeclaration或 getDeclarationCount 感知到。如果要描述 CppClass 的话,我并不介意 只对 getlnterface 编写测试,但这么一来就很遗憾,不能覆盖到 getD eclaration 和 ge tDeclarationCount 了。然而,如果 getlnterface 的实现像下面这样呢? public String getlnterface(Str 工 og interfaceName , int [J indicesl String result "class + interfaceName + 町 {\npubl 工 C\n"; for (int n 0; n < indices . length; n++) Declaration virtualFunction getDeclaration(indices [n)) ; l 因为在这类语言中,所有操作都是没有副作用的,所有东西部像 Java的 Strlng那样是 immutable 的. 译者注 圃142 第二部分修改代码的技术 result +=\t- + virtualFunct 汪。 n. asAbstract () + .\n- i result += .};飞 n- ; return resul t; 这里的改动很小 getInterface在内部使用了 getDeclaration 。因而我们的影响结构图 11691 便从图 11 -12变成了图 11-13 0 getDeclarationCount getlnterface getOeclaration 图 11-12 CppClass 的影响结构示意图 getDeclarationCount getlnterface getDeclaration 图 II- I3 修改后的 CppClass 的影响结构示意图 这只是一个小小的改动,然而带来的影响却是显著的。 getInterface方法现在在内部使用 11701 了 getDeclaration方法,所以在测试get 工 nte rface 的同时也就连带测试了 getDe claration 。 在消除了 一点点的代码重复之后,我们往往能够得到一张"终点"更少的影响结构图 。 而后 者则往往能够令你的测试决策更容易。第 11 章修改时应当测试哪些方法 143 影晌和封装 关于面向对象,一个常常被人们挂在嘴边的好处便是封装.我在向人们展示本书中的一些 解依赖技术时,他们常常会指出许多解依赖技术都破坏了封装性.没错,的确如此 . 封装的重要性毋庸直疑,然而史重要的是它的重要性背后的原因.封装有助于我们对代码 进行椎测 • l.民封装不佳的代码相比,理解封装良好的代码所需要跟踪的路径史少 . 例如,假设 我们给一个构造函数新添一个参数来达到解依赖的目的(参数化构造函数),则在推测影响的 时候就可能需要多考虑一条珞径了.没错,打破封装会令代码中的影响推测变得支难,然而若 是最终我们能够给出具有很好的说明和阐释作用的测试,情况就恰恰相反了.因为一旦一个类 有了测试用例,就可以使用这些测试用例来支为直接地进行影响推测了 . 如采对代码的行为有 任何问题,都可以去编写新的测试. 实际上封装与测试覆盖也并不总是冲突的,只不过,当它们真的发生冲突时,我会倾向于 选择测试覆盖.通常它能够帮助我实现史多的封笨。 而且封装本身也并不是最终目的,而是帮助理解代码的工具 . 当需要确定在何处编写测试时,很重要的一 点就是耍弄清修改会带来哪些影响。为此我们得 进行影响推测.这种推测可以是非正式的,也可以稍微严密一些,借助一点草图来进行.但最重 要的就是要知道这些工夫花得都是值得的。在特别错综复杂的代码当中,要想将测试安置到位, 这是我们所能依赖的为数不多的儿项技能之一 。 囚第 12 章 在同一地进行多处修改, 是否应该将相关的睛类 都解依赖 阿川四 有些情况下,要开始给一个类编写测试还是比较容易的。不过要是遗留代码的话往往就不那 么简单了 . 可能会遇到一些难以解开的依赖。在痛下决心将一批类弄进测试用具从而让以后的日 子好过些之后,最令人窝火的事情莫过于却又发现要进行一堆挤在一块儿的修改。你要把一个新 特性加进系统,同时发现为此得修改三 四个紧密相关的类,其中每一个类都是不花上几个钟头就 没指望能放入测试之下的。你心里头当然清楚要是捏着鼻子干完这事儿的话代码肯定会变得容易 对付得多,然而,真的必须得一个一个的来解开所有这些依赖吗?那可不一定。 通常有一个办法值得一试,那就是所谓"退一层测试" "退后一层"从而找到一个地点能 够同时给多处修改编写测试。比如要对一系列私有方法进行修改,只需为某一个公有方法编写测 试就行了。或者,要对某个对象所持有的一组互相协作的对象进行测试,我们只需测试前者的接 口即可。采用这种方法不仅能够达到覆盖所作修改的目的,还能在代码重构方面提供更大的自由 度;在不违反测试所限定的行为的前提下,测试覆盖之下的代码无论怎么改都不要紧。 高层测试对代码重构也是比较有用的.与精细到类的测试相比,人们一般灵喜欢较为高层 的测试,因为他们觉得接口上如果有一大堆零零碎碎的测试的话改起来就要难一些了.然而实 际往往比人们想象得要简单,因为你可以先改测试再改代码,以安全的小步骤来一点一点地改 进代码的结构. 不过,高层测试虽说是个重要的工具,但并不能替代单元测试.而是为最终将单元测试安 直到位而进行的铺垫. 那么我们到底该如何才能将这些"覆盖测试"安置妥当呢?首先便是确定测试的地点。如果 还没有的话,建议你看 一看第 11 章。该章描述了所谓的"影响结构图 "影响结构图是一个强大 的工具,可以用它来推断出测试地点。而本章则描述了拦截点 C interception poinU 的概念, 并展 示了如何寻 找到拦截点.此外还描述了在代码中所能找到的最佳拦截点,即所谓的汇点 Cpincb point) 0 我会告诉你如何寻找到这些点,以及当你想要编写测试来覆盖将要修改的代码时,寻找第 12 章 在同一地进行多处修改,是否应该将相关的所有类都解依赖 145 到的这些点将会给你带来什么样的帮助。 12.1 拦截点 给定 处修改,在程序中存在某些点能够探测到该修改的影响,我们把这些点称为拦截点。 寻找拦截点的难易程度跟具体的应用程序是有关系的。 比如,假设一个应用程序中的各个部件都 搅和在一块,没多少自然接缝的话,在其中要想寻找到一个合适的拦截点就相当费工夫,不进行 一 点影响分析以及解开大量的依赖是别想达到目的的。 最佳切入点莫过于先确定需要进行修改的点,并从这些修改点开始一路向外追踪影响。每个 可以探测影响的点都是一个拦截点,但并非每个都是最佳拦截点,在整个过程中你都需要自己进 行判断。 12.1.1 简单的情形 现在,假想我们需要修改一个名为 Invoice 的 Java.类,目的是要更改该类计算费用的方式。 I 盯Q1Ce类上面的那个计算总计费用的方法叫做ge tValue ,如下所示. public class Invoice { public Money getValue () Money total i temsSum ( ) ; 工 f (b立 llingDate.after(Date . yearEnd(openingDate)) ) if (originator . getState() . equals("FL") 11 originator . getState () . equals ("NY " ) ) total . add(getLocalShipping()) ; else total . add(getDefaultSh 工 pping ()) i e lse total .add(getSpanningSh 工 pping ()) ; total . add(getTax()) ; return total i 我们需要修改发货到纽约的运输费用的计算方式,因为立法机关刚刚增加了 一项税,从而对 纽约那边的运输业务造成了影响,而且不幸的是,我们只得把这项费用算到客户头上。首先我们 把负责运输费用计算的代码提取到一个新类 ShippingPr 工 cer 当中。如下所示: public class Invoice { public Money getValue () Money total i temsSurn ( ) ; total . add(shippingPricer.getPrice()) ; total . add(getTax()) ; return total ; 回146 第二部分修改代码的技术 这么一来,原先 由 getValue完成的 工作现在就全部改 由 S hipp 工 ngPrìcer来完成了。 之后 我们还得对工 nVOlce 的构造函数作一点改动,在里面包 ~ 建一个知道开票日期的 Shipp 工吨 Pricer 对象。 要找出拦截点 ,就得从修改点起一路追踪影响 ge tValue方法将会返 回一个与原先不同的 值 。我们发现 工 nVOlce 中并没有 其他方法使用 getValue ,但 另一个类却使用了它,那就 是 11751 BillingState阳lt 类的 makeStaternent 方法。图 12 -1 展示了这一 点: 此外我们还要对 Invo 工 ce 的构造函数作改动,所以还得看一看哪些代码是依赖于它的 。本例 中要进行的改动是在 Invoice 的构造函数中创建一个 ShippingPricer对象。 该对象除了影响使 用了它的方法之外别无其他影响,而唯一一个使用了它的方法便是getValue 。图 12-2展示了这 -影响: gelValue BillingStatemen t. makeS川 atement 图 12- 1 getValue影响了 BillingStatement . makeSt atement 11761 我们可以将上面两幅图合并在一起,结果见图 12-3: creates creates 图 12-2 对getValue的影响 conslructor BillingStalement. makeSlatement 图 12-3 影响链 现在的问题是,我们的拦截点在哪里?其实我们可以将上图中的任意 个椭圆节点当作拦截 点,当然 , 前提是我们得对它们所对应的实体(方法/类/变量等)有访问权才行。我们可以尝试 通过 shippingPricer变量来进行测试,然而它是 Invo 工 ce类的一个私有变量 , 所以无法访 问 。第 12 章 在同一地进行多处修改,是否应该将相关的所有类都解依赖 147 实际上,就算在测试中可以访 问 shippingPricer变量,它也只能算是一个相当"窄"的拦截点, 通过它可 以感知到我们 对构造函数所作的改动(创建 shipp 工 ngPricer) .并可以确保 sh 工 ppingPricer 的行为如我们所预期的那样,但是却无法通过它来确保 getValue 的改动也是 良好的。 我们也可以为民 llingStatement 的makeStatement 方法编写测试,通过检查其返回值来 确保修改的正确性,但实际上还有更好的办法,那就是针对 Invoice 的 getValue编写测试:这 一方案甚至还更加省事。没错,能够将 BillingStatement 纳入测试之下固然是件好事,但在目 前的情况下其实并没这个必要。等到后面真要修改 Billi 叩 Statement 类时再将它纳入测试也 不迟。 但1日 一般来说拦截点离修改点越近越好.之所以这样说,有如下几方面的原因.首先是安全性. 从修改点至拦截点,途中的每一步都好比是逻辑论证过程中的一步.从根本上我们要表达的其 实就是这样的话"我们之所以可以在这儿测试,是因为这儿影响了这儿,后者进而影响了那 儿,那儿最终影响了我们所测试的这个东西 J 论证过程中的步骤越多,我们就越难判断论证 的正确性.有时候唯一能有信心的做法就是在拦截点编写测试,然后回到修改点对代码作一点 小小的改动,再观察测试失败了没有.的确,有些情况下你不得不退而采用该技术,但应该不 会总是需要这么做。拦截点的选择离修改点越近越好的另一个原因就是在离得较近的地方安置 测试通常比较容易一些。但这也并不是绝对的,具体还是要看实际的代码.从修改点至拦截点 的步骤越多,安直测试就越难.通常你得在大脑里面模拟代码运行来确定一个测试是否覆盖了 某块遥远的功能, 拿本例来说,我们想要对 Invoice进行修改,最佳的测试点或许就是getValue. 可以在 测试用具中创建一个 InVOlce对象,以各种方式来设置它,然后通过调用 getValue (并检查 其返回值)来固定位行为,以防被我们的修改所破坏. 12.1.2 高层拦截点 大多数情况下,对于一次修改来说,我们所能找到的最佳拦截点就是被修改类上的一个公共 方法。这类拦截点容易寻找,也容易使用,但有时候并非最佳的选择。关于这一点,只要我们把 Invoice例子稍微扩展一点就不难看出来。 假设除了修改工 nVOl ce 的运输费用计算方式之外,还得修改一个名为 Item的类,给它添加 一个成员变量来记录其运输方式。此外在Bi llingS taternent 中还需要给每个托运人配置一个单 一一 独的细目分类。图 12-4展示了目前的设计的UML图。l!l到 如果这几个类都没有测试的话,我们可以通过给每个类分别编写测试,并进行所需的修改开 始。这种做法当然是可行的,但并非最有效率的。更高效的做法是试着找出 一个能够用来刻画这 块代码的特征的高层拦截点。这么做的好处有两点.首先我们需要进行的解依赖可能减少了,另 外我们的"软件夹钳"所夹住的代码块也更大。有了能够刻画这组类的特征的测试,重构工作也 就得到了更多的守护。比如在更改 Invoice 和 Item 类的结构时,我们就可 以使用回 148 第二部分修改代码的技术 BillingStatement 的测试来作为不变式。下面就是为同时刻画 BillingStatement 、 Invoice 和 Item类而编写的一个启动测试: 图 12 -4扩展的开票系统 void testSimpleStatement() Invoice invoice new Invoice () ; invoice.addltem(new Item(O , new Money(10)); BillingS 巳 atement statement new BillingStatement () ; statement.addlnvoice(invoice) ; assertEquals (" H , statement .makeStatement ()) ; 如上面的代码,我们可以搞清 BillingStaternent 为只含一项货物的发货单生成的是什么说 明文本,并在测试中使用它.接下来,我们可以添加更多的测试,看看对于发货单和货物的不同 组合,说明文本的格式如何变化。对于那些将引入接缝的代码块,编写测试用例的时候要格外小 心。 那么,使得 B illingStatement成了 一个理想的拦截点的原因在于,在它这一个点上我们就 能够探测到一簇类的修改所造成的影响 。图 12-5 展示了即将进行的修改的影响结构图 。 creales ~~---_..'\- ----兰' 图 12 -5 账单系统的影响结构图 从上图中我们可以看到,一切影响都可以通过rnakeStatement 探测到。或许并不容易,但第 12 章 在同一地进行多处修改,是否应该将相关的所有类都解依赖 149 至少是可行的,而且别忘了这可是"在单一地点探视l 所有影响"。在设计中,我把这类地点称作 汇点 (pinch point!。汇点是影响结构图中的交通要冲,在这类地点编写的测试能够覆盖大量的修 改。若能在设计中找到汇点的话,你的工作就会轻松许多。 不过,关于汇点,有一个关键的地方是要记住的,那就是它们是由具体的修改点来决定的。 有时候就算一个类有多个客户,对它的一组修改也仍然存在着一个很好的汇点。为了说明这一点, 再次回顾一下我们的开票系统,这次我们把视野稍微拉远一点(见图 12 - 6) , BillingS幅幅 ment + makeStatementO : string InventoryControl +旧 n() 图 12-6 加入了详细目录的开票系统 有一点并没有注意到,那就是 Item还有一个叫做needsReorder的方法。每当需要了解是否需 要进行重新排序时, InventoryControl类便会调用这个方法。那么,就我们刚才所讨论的修改 而言,现在有了 needsReorder方法的加入,影响结构图会有什么样的变动呢?答案是没有丝毫 变动。往 Item类中添加 shippingCarrier成员变量根本不会影响到 needsReorder方法,所以 汇点还是原来那个汇点,即 BillingStatemento 让我们稍微改变 下场景。假设我们还需要作另 处改动。需要往 Item 中添加一个方法, 以便能够获取/设置货物 (Item) 的供应商。此外 InventoryControl 和 BillingStatement 这 两个类会使用供应商的名字。图 12 - 7显示了以上场景对影响结构图造成的影响。 现在事情看来似乎没刚才那么顺利了。我们的修改所产生的影响可以通过BillingStaternent 的makeStaternent 方法测得,也可以通过 InventoryControl 的 run方法所影响到的变量测得, 但问题是不再存在一个单一 的拦截点了。不过,合起来看的话, run和l makeStatement 这两个方 法是可以被看作汇点的:它们加起来也只是两个方法而己,比起我们要进行的修改(需触及 8 11801 个方法/变量) ,这还算是比较经济的。如果我们在这两个方法处编写测试,就能覆盖大量的修 改工作。 汇点 汇点是影响结构图中的隘口和交通要冲,在汇点处编写测试的好处就是,只需针对少数几 个方法编写测试,就能够达到探测大量其他方法的改动的目的.150 第二部分修改代码的技术 creales ‘::: ---…-- I\ ……圭' ,---;> , , ‘---~ 图 12-7 账单系统的全景图 对于某些代码基来说,在其中寻找一组修改的汇点是件相当容易的事,但也有很多时候儿乎 是不可能找到的。某个类或方法可能会直接影响 大堆东西,于是以它为中心延展出来的影响结 构图看起来可能就会相当复杂。这时候该怎么办呢?办法之一便是重新考量我们的修改点。问问 自己是不是太"贪"了,考虑能否一次仅为其中一两个修改点寻找汇点。最后,要是实在没法找 [ill] 出汇点的话,那就按就近原则直接给每个修改编写测试吧。 寻找汇点的另一个办法就是找出方法或类的被使用方式之间的共同之处(第 11 章中介绍了影 响结构图)。例如,某个方法或变量可能会有三个用户,但这并不就意味着这三个用户使用它的 方式各不相同。假设我们想对上例中的 Item类的 needsReorder方法作一点重构。我没有展示代 码,但只要画出影响结构图我们就能看出, InventoryControl 的 run和 BillingStatement 的 rnakeStatement 这两个方法构成 个汇点,但这个汇点已经无法再缩小了。然而一般而言,能 否只为这些类当中的一个编写测试呢?对此关键的-个问题就是"如果破坏该方法,在那个地 方能否感知到? "答案取决于该方法是怎样被使用的。如果它在一组对象上的使用方式都是一样 的,则只需在其中 一处测试即可。建议你跟你的同事一起试试这一分析过程吧。 12.2 通过汇点来判断设计的好坏 上一节我们讨论了汇点在测试中扮演的重要角色,但除了用在测试中之外汇点还有其他的用第 12 幸 在同一地进行多处修改,是否应该将相关的所有类都解依赖 151 处。其在影响结构图中的位置其实暗示了你如何才能让代码变得更好。 那么,到底什么是汇点呢?一个汇点其实就相当于一个自然封装边界。发现一个汇点就相当 于发现了 一个"漏斗口"一大块代码的影响都得从这个口经过。就拿我们这个例子来说,如果 BillingStatement.makeStaternent 方法是→堆发货单和货物的汇点的话,我们就知道当账单 上列出的内容跟预期不符的时候该到哪去找原因了·问题只可能出在BillingStatement类本身 或发货单和货物身上。同样,我们无需知道发货单和货物就可以调用 makeStaternent 。以上这 两点就基本体现了封装的精神 z 无需关心内部,而真的需要关心内部时,无需通过查看外部信息 来理解它。我在寻找汇点的时候常常注意到,可以通过在类之间转移职责来达到更佳的封装性。 11821 借助影晌结构图来发现潜在的类 假设你手头有一个庞大的类,那么就可以借助于影响结构图来发现如何将这个类分解成较 小的类.下面是一个 Java 中的例子,这个名为 Parser 的 Java类有一个叫做parseExpression 的公共方法. public class Parser { private Node root; pri vate in 巳 currentPosition; private String stringToParse; public void parseExpression{String expressionl { . . } private Token getToken() ( .. } private boolean hasMoreTokens() 倘若我们为这个类描绘一幅影响结构图的话,就会发现 parseExpression 依赖于 getToken和hasMoreTokens ,但并不直接依赖于 st 口 ngToParse或 currentPosition (而 getToken和hasMoreTokens 则是直接依赖于它俩的)。这儿我们看到了一个自然封装边界, 尽管这个边界并不十分狭窄(两个方法隐藏了两块信息).我们可以把上面提到的这些方法和 成员交量提取到一个名为 Tokenizer 的新类中,从而简化 Parser类. 当然,要分离类里面的职责并非只有这一个办法。有时候我们也可以从名字当中获得一些 线索,比如刚才这个例子中我们就看到有两个方法的名字中都具有 "Token" 这个单词。这可 以帮助你用另一种眼光来审视一个庞大的类,后者可能进而会启发你完成一些漂亮的提取。 作一个练习,请为一个庞大的类之中的修改勾勒一幅影响结构图,并故意不去管那些椭圆 节点的名字,而只是关注它们是怎样联革和聚集在一起的.在这样一种审视方式之下,看看图 中的自然封装边界,把目丸之位到这个边界内部的椭圆节点上,考虑给这纽方法/变量起个什 么样的名字,而这个名字就会成为你即将分离出来的新类的名字了.此外考虑是否需要适当修 改某个方法/交量的名字. 上面这个练习最好跟你的队友们一起完成.你们就命名问题进行探讨带来的好处不仅止于 目前正在做的工作.它们能够帮助你和你的团队形成一个关于"该系统是什么"以及"它能够 成为什么"的共识.152 第二部分修改代码的技术 要在程序的某部分完成←些侵入性的改动,理想的切入点便是在汇点编写测试。花点工夫将 一组类从系统中划分出来,对它们做一点修改,以便可以在测试用具中实例化它们 。之后 ,在你 完成了特征测试的编写之后,便可以肆无忌惮地进行修改了 。这时你已经在应用程序中制造出了 一个"小绿洲"在这块弹丸之地上你的工作变得相对容易不少。但是,请当心,这片"绿洲" 固有可能只是一个虚幻的海市酣! 12.3 汇点的陆阱 在编写单元测试的时候我们可能会遇到各式各样的麻烦。其一就是我们的单元测试可能会缓 慢但逐步地演化为"迷你型"的集成测试。 一般来说情况是这样的,开始的时候我们需要测试一 个类,所以实例化了好几个被它用到的类,并将实例化出来的对象传递给该类。我们检查某些值, 而且相信这组类互相"合作愉快飞但这种做法的缺点在于, 一旦它被过于频繁地使用,最终就 有可能演化成庞大而笨重的、不知何年何月才能运行完成的"单元测试"来.要想避免这一 点, 比如我们在给新代码编写单元测试的时候,就要尽可能单独而孤立地去测试它们 . 一旦意识到手 头的测试已经过于笨重了,就应该去分解被测试类,分解出较容易测试的独立小类来.另外我们 还不时需要去伪造被测试类所需要用到的某些对象,因为单元测试的任务并非检查一簇类是否能 够合作良好,而是检查单个的对象行为是否正确。通过伪造合作类对象我们就可以更容易地达到 这一 目的。 然而,以上只是对新编写的代码而言,在给既有代码编写测试时,情况就反过来了.往往比 较好的做法是把程序中的某一块切割下来,利用测试来坚固它。当这些测试都安置到位之后,我 们就可以更容易地为这块区域内的每个类编写更狭窄的单元测试了。然后,最终当这些单元测试 全都完成,原先在汇点编写的测试也就可以功成身退了。 在汇点编写测试就有点像走了几步进入一片森林,然后划一条线,说"这块地方现在是我 的了"然后,在拥有 了 这块(代码)区域之后,你就可以通过重构和编写更多的测试来改善它。 而随着时间的推移,你将可以扔掉原先在汇点编写的那些测试,而让每个类的单元测试来支持自 11841 己的开发工作。第 13 章 修改时应该怎样写测试 人们在谈论测试的时候所说的通常都是那些被用来寻找bug的测试,而这些测试通常又都是 手动测试。对于遗留代码来说,编写自动化的测试来寻找bug常常让人感觉还没有直接运行代码 来得高效。如果你有办法直接手动测试遗留代码的话,往往能很快找到bug o 缺点是伴随着一 次 次的代码修改,每次都得从头手动测试一泡。而且坦白地说,事实上人们并不采用这种办法。在 我所接触过的团队中,几乎每一个依赖于手动测试的团队最终都远远落在了后面,结果团队的信 心大受打击。 别误会,在遗留代码中寻找bug通常并不是问题。从策略上来说,把工夫花在这上面很可能 是将力气用错了地方.通常还不如把精力放在如何让你的团队始终能够编写出正确的代码上面。 一句话,正确的策略是关心如何才能从一开始就避免让bug进入代码 。 自动化测试是一个非常有用的工具,但这并非对寻找bug而言,至少并没有直接的关系。 一 般而言,自动化测试的任务是明确说明一个我们想要实现的目标,或者试图保持代码中某些既有 的行为 。在 自然的开发流程中,属于前者的测试逐渐就会变成后者当然 ,你会遇到 bug. 但通 常并非在某个测试第一次运行的时候,而是在不小心改变了不想改变的行为时,这时运行测试就 会指 出问题 。 那么对于遗留代码而言,这意味着什么呢?对于要在遗留代码中进行的修改,我们可能尚没 有任何针对性的测试,于是也就没有办法去验证在修改之后是否有什么行为被破坏了。所以,最 好就是把我们想要修改的那一块区域先用测试给罩起来,像安全网那样 。然后,在修改的过程中 我们会发现 bug. 并解决它们,但是对于大多数遗留代码来说,若是我们将寻找和修正所有 bug 11851 当成目标的话,则永远也不会有做完的一天。 13.1 特征测试 好吧,我们需要测试,但问题是如何编写它们呢?办法之一便是先搞清你的软件应当能做什 么,然后基于你所获得的认识去编写测试。我们可以把那些落了灰的需求文档和项目备忘录翻出 来,努力从中挖掘出我们想要的信息,之后便坐下来开始编写测试.这的确是个办法,但并不算 l 一旦某个测试所定义的开发目标实现了,该测试也就成为保持既再行为的测试了 . 一一译者注154 第二部分修改代码的技术 很好。因为对于几乎所有的遗留系统而言,更重要 的不是" 系统应该能够做些什么飞而是"系 统当前能够做些什么" 。 所以如果我们基于从文档当中发掘出来的关于"系统应该能够做些什么" 的假设来编写测试的话,就又回到了寻找bug的老路上了。寻找bug的确很重要,但我们当前的目 标是把测试安置到位,从而减少代码修改过程当中的不确定性 。 我把用于行为保持的测试称为特征测试 (Characterization test) 。 特征测试刻画了 一块代码的 实际行为。而不是"嗯……这块代码应该具有这一行为"或者"我想它会那样的 吧 飞特征测试 描述了系统当前的实际行为 。 以下是编写特征测试的几个步骤: (1) 在测试用具中使用目标代码块 . (2) 编写一个你知道会失败的断言。 (3) 从断言的失败中得知代码的行为 。 (4) 修改你的测试,让它预期目标代码的实际行为 。 (5) 重复上述步骤 . 在下面这个例子中,我相当确信一个 PageGener ator对象不会生成字符帘 'f red " ,所以我 写下一个断言: void testGenerator () { PageGenerator generator new PageGenerator () ; assertEquals(Rfred- , generator.generate()); 运行测试,看它是否失败 。一旦果真失败了,你也就明确地知道了代码当前在那种情况下的 实际行为。 例如在上面的代码中, 一个新建的 PageGenerator对象在它的 generate方法被调用 园时返回了 一个空串: F Time : 0 . 01 There was 1 failure 1) testGenerator(PageGeneratorTest) junit . framework . ComparisonFailure : expected: but was : <> at PageGeneratorTest.testGenerator (PageGeneratorTest.java:9) a t sun. reflect .NativeMethodAccessorlmpl.invokeO (Native Method) at sun. reflect .NativeMethodAccessorlmpl . invoke (NativeMethodAccessorlmpl.java : 39) at sun . reflect.Deleg a tingMe 巳 hodA ccessorlmpl . invoke (DelegatingMethodAccessorlmpl . java: 25) FAILURES! ! ! Tests run: 1 , Failures: 1 , Errors : 0 我们可以修改测试从而让它能够通过 2 void testGenerator() PageGenerator generator new PageGenerator () ; assertEquals(.. , generator . generate()) i 第 13 章修改时应该怎样写测试 155 现在测试通过了 。 而且,不仅是通过,它还起到了描述 PageGenerato r 的一个最基本行为 的作用,这个最基本的行为就是:如果我们创建一个 P ageGen erator并立即调用它的 gene rate 方法的话,就会得到一个空串 。 可以使用同样的技巧来查明当我们给 PageGenera tor提供其他数据的时候会生成什么 z void testGenerator () pageGenerator gene r ator new pageGenerator () ; generator . assoC (RowMappings . getRow( Page . BAS E_ROW)) i assertEquals (" f r ed" , generat or . generate{)); 对于 上 面的测试,测试用具给出的出错信息告诉我们结果串是 " l.l v ectrai " ,于是我们可以把这个串填入测试当中所期望的结果串那儿 。 void testGenerator() PageGenerator generator new PageGenerator ( ) i assertEquals( - 1 . 1 vectrai- , generator . gener ate()) i 不过,如果你已经习惯了把这些测试也当作测试的话,就会发现这种做法的一些很奇怪的地 方 。 比如,既然我们只是把代码产生的实际结果填入测试,那这些所谓的测试就没任何意义了 。 11871 要是代码本身就有bug自句话,那我们填入测试的那些期望值岂不很可能都是错的? 然而,如果我们换个角度的话,这个问题就消失了。我们不把它们看成软件必须遵循的黄 金准则,因为我们并不是为了寻找bug ,我们是想设置一个机制以便于以后寻找 bug 。注意,这 里所说的 b ug是指后面可能 出 现的、与当前系统行为不一致的行为。采用 了这一视角之后,我 们看待这类测试的方式也就相应发生了变化它们不再是一个个的准则,而是描述了系统各部 分的实际行为 。一旦我们知道系统某部分的实际行为,结合之前对于系统"应该具有的行为" 的认识,就可以明智地作出如何修改的决策 。 事实上,了解系统中某部分的实际行为是非常重 要的 。 我们通常可以通过与其他人交流或者通过计算知道哪些行为是需要添加的,但若是少了 (特征〉测试的话,便没法知道系统当前的实际行为是什么了,当然,除非你在每次需要判断 系统实际行为的时候都能够边读代码边在脑子里"运行"出结果来 . 对于后一种方式,有些人 比较拿手 , 而有些人则不是,关键是不管我们"运行 "得有多 快, 一遍遍地做这件事仍是相 当 乏味和浪费精力的 。 特征测试描述了一块代码的实际行为.在编写特征测试的时候如果发现某些结果与我们所 期望的不一敖,最好弄清它.因为我们遇到的可能是个 bug. 但这并不是说我们就不能把该测 试放进测试套装中,而是说我们应该将它标记为可疑的,并搞清修正它会带来哪些影响. 关于特征测试,目前我们所讲到的还只是很少一部分。比如在前面的 PageGenerator例子 当中,看起来当 时我们就好像只是很随便地扔一个值给测试对象,然后通过断言来查看得到什么 结果 。当然,如果我们对代码所应当具有的行为有一个良好的把握的话,是可以这么做的 。 有些156 第二部分修改代码的技术 情况下,像一开始我们对 PageGenerator所做的,只是创建一个对象,然后立即调用它的方法 查看结果,这一工作概念简单,而且也的确值得编写特征测试,但问题是,接下来我们该做什么 呢?还是拿 PageGenerator例子来说,像这样一个类,我们到底可以为它编写多少(特征)测 试呢?答案是,无穷多。花上十年八年也写不完。那么,什么时候应该停止呢?有什么办法可以 告诉我们其中哪些测试更重要呢? 要想解答这个问题,关键的一点就是要意识到我们并不是在编写黑盒测试。换句话说,我们 在编写特征测试的时候是可以去查看所要刻画的代码的。代码本身能够告诉我们它们的行为,而 如果看了代码还不能肯定的话,理想的办法就是编写测试去"询问"它们了。编写特征测试的第 一步就是让自己对目标代码的行为感到好奇,在这个阶段我们不断编写测试直到感到已经理解了 11881 代码。但这样我们的测试就能保证覆盖了代码的所有方面吗?还有第二步,就是设法弄清我们的 修改如果引入了 bug的话测试能否"感应"得到。如果存在可能的漏网之鱼,就要添加更多的测 试,直到无遗漏为止。而如果我们没有这么大的信心,安全一点的办法就是考虑换一种方式来修 改代码。或许我们可以再返回到第一步看看。 使用方法的规则 当准备在遗留革统中使用一个方法之前,请查看一下是否已有针对它的测试.没有的话就 自己写一个。始终保持这一习惯,你的测试就能起到信息传递媒介的作用.别人只要一看到你 的测试就能够知道对于某方法他们该期望什么而不该期望什么.试图使一个类变得可测试这一 行为本身往往能够改善代码的质量 B 于是人们能够发现什么是可行的,以及是如何可行的,他 们可以进行修改,史正 bug ,然后继续前进. 13.2 刻画类 假设现在有一个类,我们想要知道应该测试哪些东西。第一件事就是要从较高的层面上来领 会该类会做些什么。比如我们可以先针对能想到的最简单的行为来编写几个测试,然后把任务交 给我们的好奇心,让好奇心领着我们往前走。下面是几个有益的启发式方法. (1) 寻找代码中逻辑复杂的部分。如果你不理解某块代码,可以考虑引入感知变量 (239页) 来刻画它。利用感知变量来确保代码中的某些特定的区域被执行到了。 (2) 随着你不断发现类或方法的一个个职责,不时停下来把你认为可能出错的地方列一个单 子。看看能不能编写 出能够触发这些问题的测试。 (3) 考虑你在测试中提供的输入。如果故意把输入的值变得极端化会出现什么情况呢? (4) 对于某个类的对象,有没有某些条件在它的整个生命周期当中都是成立的?通常这被人 们称为不变式( invariant)。 尝试编写测试去验证它们。通常你可能需要重构才能够发现这些不变 式。但重构往往能够给你带来关于"代码应该怎样"的新认识。 我们编写出来的用于刻画代码的测试是非常重要的。它们描述了系统实际的行为。和编写任 何文档一样,你在编写特征测试的时候也得考虑哪些东西对于阅读它们的人来说是重要的。换位第 13 章修改时应该怎样写测试 157 思考,设想自己就是阅读它们的人,在面对一个以前从未见过的类的情况下,你想要知道关于它 的什么信息呢?你又想以什么样的顺序来获得这些信息?如果使用的是 xUnit框架,那么测试在 文件中的存在形式就是一个个的方法。可以通过调整它们的顺序来让别人更容易了解他们所面对 E画 的代码。 一开始可以放置一些简单的、用于说明目标类的主要意图的测试用例,然后再放置一些 突出该类与众不同的地方的用例。确保你所发现的那些重要的地方都以测试的形式呈现出来了。 后面当你开始修改代码时,你往往就会发现前面编写的测试恰恰非常有利于将要进行的工作。不 管是有意还是无意,事实就是我们将要进行的修改通常支配了我们的好奇心。 发现bug 时…... 在刻画遗留代码的整个过程中你都会不时发现一些 bug. 只要是遗留代码就免不了有 bug , 通常bug的量是跟它(遗留代码)被理解的程度成直接的比例关系的.那么,当发现bug时,又 该做些什么呢 9 答案要视具体情况而定.倘若革统尚未被部署, ß! 'J 答案很简单:修正 bug. 否则就需要调 查调查,看看是否已经有用户依赖于系统的当前行为(甚至在你看来是bug的行为)了.对于 后一种情况,通常得花上一点时间来分析如何才能在不导致连锁反应的前提下修正一个 bug. 我比较倾向于一旦发现bug就尽早修正它们.如果一个行为很明显就是错的,那就该进行 修正.如果你觉得某个行为有问题,则可以把相应的测试代码标成可疑的,并逐渐提升其优先 级。尽早查明它是否为 bug以及最好如何处置它。 13.3 目标测试 在编写了测试来理解一块代码之后,接下来的工作就是看看我们的测试是否真的覆盖了想要 修改的地方。下面就是一个例子, FuelShare 是一个 Java类,它上面有一个用于计算出租燃料罐 中燃料总价值的方法 : public class FuelShare { private long cost 0; private double corpBase 12 . 0; private ZonedHawthorneLease leasei public void addReading(int gallons , Date readingDate) { i f (lease.isMonthly()) if (gallons < Lease .CORP• MIN) cost += corpBaSei else cost += 1 .2 * priceForGallons(gallons); lease.postReading(readingDate, gallons); 回158 第二部分修改代码的技术 我们想要对 FuelShare类作一处非常直接的改动,甚至已经为它写好了相应的测试 . 我们想 要进行的修改是这样的·将顶层 if 语句提取到一个新的方法中,然后将这个新方法移到一个名叫 ZonedHawthorneLease 的类当中 。 FuelShare类里面的实例变量 lease便是该类的一个对象 。 我们可以先设想一下代码在重构之后的样子, 如下: public class FuelShare { public void addReading(int gallons , Date readingDate) ( cost += lease.coroputeValue(gallons , priceForGallons (gallons)) i lease .p ostRead工 ng(read ingDate , gallons); public class ZonedHawthorneLease extends Lease { public 100g computeValue(int gallons. 100g totalPricel ( 10ng cost 0 ; if (lease . isMonthly()) { if (gallons < Lease . CORP_MIN) cost += corpBase; else cost += 1.2 舍巳。 talPricei return cost ; 那么,要想确保正确进行这些重构,需要哪些测试呢?有一点毫无疑问,那就是我们肯定不 会去修改以下的逻辑· if (gallons < Lease .CORP_MIN) cost += corpBase; 因此我们可以用 一个测试来检查当加仑数小于 Lease .C ORP MIN时计算出的值,不过严格来 lIm 说这个测试也不是必须的 . 另一方面,下面的 else分支将会遭到修改= else valuelnCents += 1 . 2 舍 priceForGallons ( gallons) ; 以上代码到了新方法中就会变成这样: else valuelnCents += 1 . 2 * totalpricei 这个改动虽说不大,但仍然还是个改动.所以最好能够确保我们的测试能覆盖到这个 el se 分支 . 让我们再次回顾 下原来的那个方法: public class FuelShare { 第 13 章修改时应该怎样写测试 159 public void addReading(int gallons , Date readingDatel ( if (lease.isMonthly()) { if (gallons < CORP_MIN) cost += corpBaSei else cost += 1 . 2 * priceForGallons(gallons); lease . postReading (readingDate, gallons); 从原先的代码中可以看出,只要能够建立一个按月度的 FuelShare t 并 以大于 Lease.CORP_M 工 N的加仑数来调用 addReading 的话,就能触及那个 else分支了 ,以下就是测试 代码 public void testValueForGallonsMoreThanCorpMin{) StandardLease lease new StandardLease(Lease .MONTHL凹, FuelShare share new FuelShare(lease) ; share.addReading(FuelShare.CORP_MIN +1 , new Date()); assertEquals (12 , share . getCost () ) ; 在为代码分主编写测试时,应该考虑除了那个分支被执行之外是否还存在其他能令测试通 过的条件.如果不确定的话,可以使用一个感知交量或调试器来确定你的测试是否恰好命中 目丰t 像上面这样来刻画代码分支的时候有一件重要的事情必须弄清楚,那就是你所提供的输入会 不会反而导致本该失败的测试成功了。下面就是一个例子。假设代码中改用 double型变盘来表 示金额(原来是用 整型): 11921 public class FuelShare { pri vate double cost 0 . 0 ; public void addReading(int gallons , Date readingDate) ( if (lease.isMonthly()) { if (gallons < CORPJHN) cost += corpBase; else cost += 1.2 * priceForGallons{gallons); lease . postReading(readingDate, gallons) i 这下我们就可能会遇到严重的麻烦了。哦,别误会,这里所说的严重的麻烦并不是指程序中回 160 第二部分修改代码的技术 可能会到处发生的小数部分精度丧失(浮点数舍入误差所致) 。 而是指除非我们小心选择测试输 入数据,否则在提取方法的时候就可能会犯错,而且永远也不知道自己错在哪。比如, 一个可能 的错误就是我们提取出 一个方法并按整型而不是双精度型来接受参数。在 Java 以及许多其他语言 当 中都允许从双精度到整型的隐式转换:运行时会把值截断 1 。 所以除非我们想办法设计出能够 显式导致这些错误的测试数据来,否则错误就被隐藏起来了。 让我们来看一个具体的例子 。 假设 Lease . COPR MIN 的值是 10而 corpBase是 12. 0. 那么运 行下面的测试代码会出现什么结果呢? public void testValue () StandardLease lease new StandardLease(Lease .MONTHLY ); FuelShare share new FuelShare (lease) i share . addReading(l , new Date()) ; assertEquals (12 , share. getCost ()) ; 由于 l 小子 10. 所以 12.0被加到c。目的初始值。上面,结果是 12.0. 这一切都没有问题,但如 果我们像下面这样来提取方法,并将 cos t 变量的类型改为 long: public class ZonedHawthorneLease { public long computeValue (int gallons . long totalPrice) long cost 0 ; if (lease.isMonthly()) if (gallons < CORP_MINl cost += corpBase: else cost += 1.2 * t 。匕 alprice i return cost ; 这样改了之后,尽管返回的 cos t 值被截断了,但测试依然能够通过。代码中存在从double l! tl l 。吨的隐式转换,但这一点并没有被测试覆盖到。因为如果我们仅仅是把整型赋给整型,那 么隐式转换发生与否对结果是没有影响的。 重构的时候我们通常需要关心两件事情:一是目标行为在重构之后是否仍然存在,二是它 是否丘确"连接"在革统当中. 许多特征测试就像定心九一样.它们不去测试大量的特殊情,凡,而只是检验某些特定的行 为是否存在.在一番移动或提取代码的重构之后,只要看到这些行为仍然存在,我们就可以放 心地告诉自己.我们的重构保持了行为. 这个问题的解决有一些一般性的策略 。 其中之一便是孚动计算你期望从某段代码那儿得到的 l 这当然会首先以编译警告的形式表现出来. 译者注第 13 幸 修改时应该怎样写测试 161 值在每一个存在类型转换的地方留意是否可能存在截断问题。另 一种做法则是使用调试器,跟 踪赋值运算,从而知道某组特定的输入会导致什么转换。第三种做法是使用感知交量来确认某条 特定的代码路径被覆盖到了且目标转换也被测试到了。 最有价值的特征测试覆盖采条特定的代码且在径并检查这条路径上的每个转换. 实际上还有第四种做法。那就是刻画一块更小的代码。如果手头有一个重构工具能够帮我们 安全地提取方法的话,就可以将 cornputeValue方法切分开来,并分别为切分出来的小块编写测 试。然而遗憾的是并非所有的语言都有重构工具,而且有时候就算有也不一定会按照你设想的方 式来提取方法。 重构工具的怪癖 一个好的重构工具可以带来极大的帮助,然而即使有了这些工具人们还是不时求助于手动 重构.下面就是一种常见的情形.我们想要将A类的 b() 方法内的某些代码提取出来: public class A { int x 1; public void b () { int y 0 int c x + y 如果想要从b() 方法当中将 x+y表达式提取出来形成一个新的方法 add ,我们会发现市面 上至少有一种重构工具提取出的 add是单参的 add(y) 而不是双参的 add(x , y). 因为 x是个实 例交量,它对于我们提取出的每一个.方法都是可访问的. 13.4 编写特征测试的启发式方法 (1 )为准备修改的代码区域编写测试,尽量编写用例,直到觉得你已经理解了那块代码的 行为。 (2) 之后再开始考虑你所要进行的修改,并针对修改编写测试 。 (3) 如果想要提取或转移某些功能,那就编写测试来验证这些行为的存在性和一致性, 一种 情况-种情况地编写。确认你的测试覆盖到了将被转移的代码,确认这些代码被正确连接在系统 中。最后别忘了测试类型转换。 1 即不是让程序运行给出,而是自己看着代码去算.一一译者注 回 国国 第 14 章 棘手的库依赖问题 给开发带来实际帮助的技术之一就是代码复用。如果购买到一个能够替我们解决某些问题的 库(并了解如何使用它) .项目的耗时往往会大大缩短。这种做法唯- 的问题就是,很容易就会 变得对某个库过分依赖。如果在代码中不分青红皂白到处乱用一气的话,结果差不多铁定就是陷 进泥潭了。一些我接触过的团队的确曾被库依赖问题弄得焦头烂额。比如有这么 一种情况,库供 应商把版权税提得太高,以致于使用它的软件都无法盈利了。而另一方面,软件的团队又无法使用 其他供应商的库,因为要把代码中的所有那些对当前库的调用都分离出来还不如整个儿童写。 尽量避免在你的代码中到处出现对库的直接调用。你可能会觉得永远也不会需要去修改这 些调用,但最终可能只是自欺欺人. 在我写这本书的时候,开发者们的阵营皇 Java/ .NET两极分化之势。微软和 Sun都试图把它们 的平台尽量做大做宽,他们创建了数不清的库来吸引人们继续留在他们的平台上。从某种程度上 来说对于许多项目这都不是件坏事,但你仍旧可能会过分依赖于特定的库。每一处以硬编码方式 来直接使用类库的地方其实都可以以接缝的形式来实现。有些库在给其中的具体类定义接口方面 做得较好,而有些库则不仅做不到这点,还把具体类做成 fmal或 sealed 的,又或者是具有 些非 虚的关键函数,让你没法在测试中"伪造"它们。遇到这类情况,你也只能给这种类写一个对应 的外覆类了。而且别忘了发邮件给你的库供应商,抱怨他们的库给你们的开发带来了多大的麻烦。 借助于语言特性来施加设计约束的库设计者们往往是犯了一个错误。他们忘记了根本的一 条,那就是好的代码除了要能在产品环境中运行之外,还要能在测试环境中运行.然而针对产 品环境而施加在代码上的约束则常常会导致代码在测试环境中寸步难行。 实际上,在意图实现良好设计的语言特性与代码的易测试性之间有一条鸿沟。其中最普遍的一 个问题就是所谓的"一次性困境'气如果一个库假定某个类在系统中只会出现一个实例,则后面就 难对这个类使用伪对象手法了。像引入静态设置方法 092页)或其他许多原本可用来对付单件的解 依赖技术或许也派不上用场了。于是把那个单件用一个外覆类包装起来可能就成了你唯一的选择。 另一个有关的问题就是"重写限制困珑"。在有些面向对象语言当中,所有的类方法都是虚 方法。还有一些语言则让类方法在默认情况下是虚的,但同时也提供途径让你可以把一个方法设 置为非虚的 。其余那些语言 ,则是要求你必须显式指定某方法是虚的(否则它们就会默认为非虚)。第 14 章棘手的库依赖问题 163 从设计的角度来说,有时将一个方法做成非虚方法是有意义的 . 比如我们就不时会听到来自业界 的一些声音,建议最好尽可能把方法设成非虚的 . 有时候他们给出的理由也的确不错,但我们同 样也得承认,这么做使得往代码中引入感知和分离变得困难了,另外不可否认的是,使用 Smalltalk 的程序员也写出了很多很好的代码,而我们知道 Smalltalk中是没有非虚方法的。同样, Java虽然 提供了阻止重写的语言手段, @Java程序员通常不用它,可 Java程序员也写出了很多很好的代码 。 就连在C++里面也是,大量代码并没有遵循这个原则,但也都很好 。 事实上,你完全可以在产品 代码中把那些公有方法都"当成"是非虚的这么一来,你就可以有选择地在测试中重写它们, 实现测试和产品两不误。 有时候使用编码惯例并不比使用某种限制性的语言特性差.你得为自己的测试考虑考虑 11981 1 而实际上它们是虚 的 .一一译者注第 15 章 到处都是API 调用 要么买,要么借,要么就自己开发一个。这是每个软件开发者都需要面对的选择。很多时候 我们在做一个应用时会认为从其他地方买来一些库,或者利用开源库,或者甚至只使用平台 (J2EE. .NET等)自带库里面的代码,会节省一些时间和精力。而事实上,如果想去整合那些你 无权去更改的代码的话,有很多东西是需要加以考虑的。比如我们得知道它有多稳定,它是否能 满足我们的要求以及易用性如何,等等.最终当我们确定了要使用其他人编写 的代码时,还将面 临一个问题,那就是我们的应用看起来就好像除了到处调用其他人写 的库之外自己就没干什么 事 情。这样的代码我们该怎么修改? 你可能立即会想到,我们不需要什么测试 。 毕竟我们自己写的代码几乎没干什么重活,所有 的重要工作都丢给库去做了,我们的代码非常简单 。 这么简单的代码里面能有什么错误呢? 然而事实上,很多遗留项目正是从这样一种微不足道的阶段开始的。随着项目的修改,代码 也会不断增长,然后事情就会逐渐变得不那么简单了。一段时间之后,我们也许还能看到某些没 有调用库API的代码块,但它们就算存在,也是被包夹在重重根本不可测试的代码之间。于是每 次我们改点东西都不得不重新运行程序来确认它仍然还能运行,这也就意味着我们又回到了遗留 系统程序员所面对的主要困境:在不能确定是否会破坏什么行为的情况下修改,我们并没有编写 所有的代码, 但却得维护它。 从许多方面来讲,到处都是库调用的系统比完全自己编写的系统还难对付 。 其首要的原因就 是,对于这种系统你很难看 出 如何才能让代码的结构变得好起来,因为 一眼望过去,到处都是 API调用 。 看不到任何可以从中引出设计的东西 。 API密集的系统之所以难以对付的另一个原因 就是我们并不拥有那些API.如果那些API 归我们管的话,就可以通过重命名接口、类以及方法 11991 来让系统更清楚一些,还可以给类里面增加方法从而让它们在代码中的不同部分都可用 。 下面就是一个例子 . 这是一段写得非常糟糕的代码,我们甚至根本就不清楚它是否能运行: impor t j ava . io . IOException; import j ava. util . Properties i import j avax.mail 舍, import j avax. mail . in ter net 舍, public class Mail 主 ngLis t S er v er第 15 章到处都是 API 调用 165 public static final String SUBJECT_MARKER " [listl "; public stat ic final String LOOP_HEADER "X-LOOp"; public static void main (String [] args) if (args . length != 8) System.err.println ( "Usage : java MailingList " + " " + " " + ""); return; Hostlnformation host new Hostlnformation ( args [0] , args [1], args [2] , args [3) , args [4] , args [5]); String listAddress args[61; int interval new Integer (args [7]). intValue (); Roster roster 二 nul l; t ry { roster new FileRoster ("roster. txt") ; } catch (Excep 巳 ion e) { System. err.println ( "unable to open roster . tx 巳 "J ; return; try { do { try { Properties properties System.getProperties (); Session session Sess 工 on . getDefaultlnstance ( properties , null); Store store session.getStore ("pop3"); store . connect (host.pop3Host , ~l, host . pop3User, host . pop3Password) ; Folder defaultFolder 二 store . getDefaultFolder() ; if (defaultFolder 二三 null) { System . err.println{ 回 Unable to open default folder") ; return; Folder folder defaultFolder. getFolder (" INBOX") ; if (folder null) { System.err .println{"Unable to get + defaultFolderl; return; folder . open (Folder.READ_WRITEl i process (host , 1 工 stAddress , roster, session, store, folderl ; 1 catch (Exception e) System. err .println(e); System. err .println (" (retrying mail check) " ) i System.err . print (". ,,) i try { Thread.sleep (interval * 1000); } 因回 166 第二部分修改代码的技术 catch (InterruptedException e) {} } while (true) ; catch (Exception e) e . printStackTrace () i private static void process( Hostlnformation host , String listAddress , Roster roster, Session session, Store store, Folder folderJ throws MessagingException { try ( if (folder . getMessageCount() != 0) Message [1 messages folder . getMessages () ; dOMessage (host , l istAddress , roster, session, folder , messages) ; } catch (Except 工。n e) { System.err .println ("message handling error") ; e.printStackTrace (System. err) i finally ( folder.close (true) ; store. close () ; private stat ic void doMessage( Hostlnformation host , String listAddress , Roster roster, Session session , Folder folder , Message[] messages) t hrows MessagingException, AddressException, IOException, NoSuchProviderException { FetchProf 工 le fp new FetchProfile () ; fp . add (FetchProfile.ltem. ENVELOPE ); fp . add (FetchProfile . ltem. FLAGS ); fp . add ("X-Mailer" ) i folder . fetch (messages , fp) i for (工 nt i 0; i < messages . lengthi i++) Message message messages [il ; if (message.getFlags () . contains (Flags . Flag . DELETED)) continue; Syst em.out.println( Mmessage rece 工 ved : + message . ge 巳 Subject ()) i if (! roster . containsOneOf (message . getFrom ())) con t ~nue i M 工 meMessage forward new MimeMessage (session) i InternetAddress result nulli Address [1 fromAddress message . get From (); if (fromAddress != null && fromAddress.length > 0) 第 15 章到处都是 API 调用 167 result new InternetAddress (EromAddress [0] . toString ()); 工 nternetAddress from result ; forward.setFrom (from); forward.setReplyTo (new Address [ J new InternetAddress (listAddress) }); forward .addRecipients ( Message.Recip i entτype. TO , listAddress) i forward . addRecipients (Message . Recipientτ'ype. BCC , 主。 ster . getAddresses ()); String subject message. getSubject () ; if ( ~ l message.getSubject() . indexOf (SUBJECT_MARKER) ) subject SUBJECT_MARKER + " " + message .getSubject() i forward. setSubject (subjec 时, forward.setSentDate (message.getSentDate ()); Eorwa rd.addHeader (LOOP_HEADER , listAddressl; Object content message .getContent (); if (content instanceof Multipart) forward . setContent ((Multipart)content) i else forward.setText ((String)content) i Properties props new properties () j props . put ("mail . smtp . host~. host.smtpHost); Session smtpSession Session . ge 巳 Defaultlnstance (props. null) i Transport transport smtpSession. getTransport ( " smtp") ; transport. connect (host . smtpHost. host . smtpUser, host . smtpPassword); transport .sendMessage (forward, roster . getAddresses ()) ; -message.setFlag (Flags . Flag . DELETED , true) ; 这段代码算是相当短的了,然而非常不清晰。几乎没有一行代码没有调用剧。这段代码的 结构可以改善吗?其结构可以改善到能够令修改变得更容易吗? 是的,我可以做到。 第一步就是找出代码的计算核心:这段代码到底做了什么? 对于以上这个问题,最好给出 一个简洁的答案 · 这段代码从命令行读进自己直信息,并从一个文件中读进一组电子邮件地址.然后它会周期性 地检查邮件.如果收到新邮件就会把该邮件转发给刚才的那纽电子邮件地址(即转发给其中的每 一个邮件地址) • 这么看来这个程序似乎主要就是在输入和输出,但实际上除此之外还有一点遗漏掉了 2 代码 中启动了一个线程,这个线程会进入休眠并周期性地醒来检查邮件。此外这段代码并不仅仅是把 邮件收迸发出就算了,它会基于收到的邮件生成新的邮件,它得设置新邮件的所有字段:检查并 因168 第二部分修改代码的技术 修改邮件的标题,从而让它能够反映这封邮件是来自邮件列表这一事实。所以说这段代码并不是 个空壳子。 现在,如果我们想要把代码内的职责进行分离,就可能会得到这样的结果 2 (1)一个模块负责收邮件,并转给我们的系统。 (2) 一个模块负责发送邮件。 (3) 一个模块负责基于收到的邮件来生成发给邮件列表中的每个成员的邮件. (4) 一个模块负责周期性地醒来看有没有新邮件。 好,我们来看看上面这几个职责。里面有哪些职责JllUava Mail API结合得比较紧呢?职责 l 和 2t li!显然跟mail API是分不开的了。职责3 有点微妙:我们所需的邮件消息类是 mail API 的一部 分,但我们应该可以通过简单的邮件消息来单独测试该职责。职责4跟 mail一点关系也没有 2 只 需要一个被设置好的周期性醒来的线程就可以了。 因 图 15-1 展示了根据上面的职责分离进行的一点设计。 MallReceiver + ch 配 kForMail() processMessages(MessageD messages) <h_flushtime 11 buf->b_flusht 工 me > newt ime) buf->h flushtime newt 工 me ; } else ( buf->h_flushtime 0: 要测试这个函数,只需设置 jiffies变量的值:创建一个buffer_head并将其传给该函数, 然后在调用之后检查它的值。然而对于许多函数来说事情不是这么容易 的 。奋时候一个函数会调 用另 一个函数,后者又会调用第三个函数……最后调用了 一个麻烦的函数 比如一个1/0 函数, 或者一个来自某供应商的库函数。 我们的目的是想测试一下这个代码干 了什么, 然而答案往往是. "它的确干了些很酷的事情,但这些事只有在程序之外的某处地方才能知道,你没法知道。" 19.2 一个棘手的案例 12321 假设我们想要修改下面这个C 函数。首先想把它置于测试之下 ,再进行修改: #-i nclude "ksrlib. h " int scan-packets(struct rnode-packet *packet. int flag) { struct rnode-packet 舍 current packet ; int scan_result, err 0; while (current) scan_result loc_scan (current->body , flag) i if(scan_result & INVALID_PORTl ksr_notify(scan_result , current) ; current current->next i return err; 这段代码调用了 一个叫做ksr_no tify 的函数,后者具有一个很糟糕的副作用,它会把一个 通知写到一个第三方系统里 ,然而我们在测试的时候宁愿它没有这个副作用 。 一个办法就是使用连接期接缝(3 2页〉。如果我们想要使测试期间某个库里面的所有函数的副作 用都消失,可以自己做一个"伪库"来替代它"伪库"里面的函数名字跟"真库"里的对应,不同第 19 章对非面向对象的项目,如何安全地对它进行修改 189 的是伪函数什么也不做。比如在我们的这个例子中,可以写一个烦品的 ksr not 工句,如l 下所示 z void ksr_notify(int sca~c。巾, struct rnode-packet *packet) 我们可以把上面这个 ksr_notify放在一个库中,构建该库,然后把它连接到刚才那个程 序。 这么 一来, scan-packets 这个函数的行为基本完全没有改变, 只是它不会发出通知 了 。但后者 其实并不要紧,因为我们本就想要在修改 scan.--p ackets 函数之前将它的其他行为"钉牢飞 那么,该使用上面的这个策略吗?不一定,要看具体情况。如果 ksr库中有许多函数,并且 我们把这些函数看成系统主逻辑的"外国设施飞建立一个包含伪函数的库并连接到它是有意义 的做法。而另一方面,如果我们想要通过这些函数来进行感知,或者如果我们想要改变它们返回 的某些值的话,则使用连接期接缝并不是个好选择,甚至可以说是相当麻烦。由于函数的替换发 生在连接期,因此对于构建的每一个可执行文件,我们只能提供唯一一份函数定义 。 如果想要一 个伪造的 k sr_notify在测试中 具有一种行为而在另一个测试中具有另 一种行为,就得在测试中 设置一些条件,并在其函数体里面添加一些逻辑来判断该条件,从而调整函数的行为。总而言之 12331 挺麻烦。然而无奈的是,在许多过程式语言面前我们别无选择。 在C里面还有一个可行的方案。 C支持宏 ,因此我们可以利用它来简化我们给 scan.--p ackets 函数编写 的测试。比如在我们添加了测试代码之后,包含 scan-p ac}回国的文件如下 2 #include "ksrlib . h " 4导主 fdef TESTING 样 define ksr_ootify(code.packet) #endif int scan-packets(struct rnode-packet *packet , 工 nt flag) { struct rnode-packet *current packet ; int scan_result , err 0 ; while(current) scan result 工。 c_scan{current->body , flag) ; i f (scan_ resul t & INVALID_PORT) { ksr_notify(scan_result , current) ; current current->next ; return err; 4陆 ifdef TESTING #include int main () { struct rnode-packet packet; packet.body 190 第二部分修改代码的技术 int err scan---packets (&packet, DUP_SCAN); assert (err &工 NVALID_PORT) ; return 0; #endif 在上面的代码中,我们引入了条件编译,它的作用是当 TESTING被定义的时候将ksr_notify 替换为空。此外里面还提供了一小段测试代码。 将测试和源代码像这样混在同一个文件中显得不是十分清晰,往往会令代码难以浏览。二个 12341 替代方案就是使用文件包含,让测试和产品代码分别位于不同的文件中 样 include "ksrlib.h" #include 11 scannertestdefs. h" 1n巳 scan-p ackets(struct rnode-p acket 舍 packet , int flag) { struct rnode-packet *current packet i int scan_result, err 0; whi 1e (current) scan_result loc_scan(current->body , flag) i if(scan_result & INVALID_ PORT) ksr_notify(scan_result , current) i current current->next; return err; #include "testscanner. t s t " 这样修改了之后,代码看起来就跟没有测试时的样子差不多了。唯一的区别就是文件底部多 了 一个 #include语句 g 如果我们前导声明了被测试函数,就可以进一步把这个 testscanner tst 文件中的所有内容都放到顶上那个排工 nclude 的文件 (scannertestdefs.h) 中去。 要运行该测试,只需定义 TESTING宏,然后单独构建这个文件。当 TESTING宏被定义时, testscanner.tst 中的 main( )函数就会被编译并连接到最终的可执行文件中,从而负责运行起 测试来。 testscanner.tst 中的main() 函数只对 scan… packets 的扫描例程进行测试。实际上 我们还可以定义多个单独的测试函数,每个对应一个测试,然后一起调用它们,如下所示· #li fdef TESTING #include void test-port_invalid() struct rnode-packet packet; packet.body int err scan-packets (&packet , DUP _SCAN) i assert(err & INVALID_PORT) i 第 19 章对非面向对象的项目,如何安全地对它进行修改 191 void test_body_not_corrupt() ( void test_header() 得 endif 在另一个文件中,我们可以在 rnaln里面把它们全部调用起来: int main() ( test-port_invalid() ; test_body_not_corrupt(); test_header() ; return 0; 我们还可以进一步进行扩展,比如添加注册函数,使得测试的群组变得更容易。具体细节可 参考www .xprogramn飞 工 ng . com上 的C单元测试框架 。 尽管宏预处理很容易被误用,但在这儿的情况下它们倒是真的很有用的。文件包含和宏替换 可以帮助我们战胜哪怕最棘手的代码里面存在的依赖.实际上只要把这些丑陋的宏使用局限在测 试代码下,就无需过分担心我们对宏的误用会影响到产品代码。 C是主流编程语言里面为数不多的几个还支持宏预处理的。一般来说,对于其他过程式语言, 要想进行解依赖,只能使用连接期接缝,并努力将更大块的代码纳入测试之下。 19.3 添加新行为 对于过程式遗留代码而言,最好遵循一条原则,即宁可引入新的函数也不要把代码直接添加 到旧代码中。因为至少我们可以给我们引入的新函数编写测试。 那么,如何才能避免往过程式代码中引入依赖陷阱呢?一个办法(见第 8章〉就是使用测试 驱动开发 (TDD , 74页). TDD方法不仅适用于面向对象代码,也同样适用于过程式代码。在考 虑编写一段代码前先试图写出其测试来,这样的做法往往会引发我们对代码的设计进行改良。我 们只管先编写能够完成某些计算任务的函数,然后再把写好的函数集成到系统中去. 但为此我们通常得换个角度来审视将要编写的代码。比如,我们需要编写一个叫做 send command的函数。该函数会通过一个名 叫 mart_key_send自由函数发送一个D 、 一个名字、 以及一个命令字符串给另一个系统。可想而知,这样一个函数的代码应该是比较简单的.我们可 因 以想象得出,它的代码应该像这样 12361 v。且 d send_command(int id, char *name , char *command_s 巳 ring) { char *message, *header; if (id KEY_TRUM) { message ralloc (sizeof (int) + HEADER_LEN + 回 192 第二部分修改代码的技术 } else ( sprintf(message ,毛 s 老 s 宅 5" , header, command_string, footer) i mart_key_send(message); free (message) i 然而,对这个函数,我们如何给它编写测试呢?尤其是,要想弄清这个函数做了什么,唯 -一 的办法就是从rnart_key_send入手。稍微换一换思路如何? 我们可以把这个函数里面位于mart_key_send调用之前的逻辑取出来,作为另一个单独的 函数。为此我们编写测试如下: char *command form_command (1 , MMike Ratledge" , "56:78:cusp- :78") ; assert ( ! strcmp (" <-rsp-Mike Ratledge><-rspr>" , command)) ; 有了这一测试 , 我们便着手编写这个 form_command函数,该函数返回一个命令字符串 2 char * form_command(in 巳 id , char *name , char 舍 command_ string) { char *message, *headeri 工 f (工 d KEY_TRUM) { message ralloc(sizeof(int) + HEADER_ LEN + ) else { sprintf(message ,毛 s 毛 s 毛 5" , header, command_string, footer) i return message; 之后,我们再编写简单的 send_command函数· void send_coromand(int id , char *name , char *command_string) char *command form_command(id, name , command_string); mart_key_send(command) ; free(message) ; 很多时候这种简单的"变形"恰恰是我们所需要的。我们将所有非依赖逻辑放到一组函数中, 从而使它们远离问题依赖。这番工作之后,我们往往会得到一些像 send_command这样的薄薄的 外覆函数,它们的作用就是将刚才取出来的逻辑与依赖混合到一起。当然,以上做法并非十全十 美,但在依赖并不到处泛滥的情况下还是挺可行的。 而另 一些时候,我们需要编写的函数内到处都是外部调用。这类函数基本不做什么计算工作, 但它们对外部函数的调用顺序,则非常关键。例如我们要编写-个计算贷款利息的函数,直观的方第 19 章对非面向对象的项目,如何安全地对它进行修改 法可能看上去像这样· void calculate_loan_interest(struct temper_loan *loan. int calc_ type ) { db_retrieve(loan->id) ; db_ retrieve (loan->lender_ id) : db_update (loan->id, loan->record) i loan->in 巳 erest 二寸 遇到这类情况该怎么办呢?对于许多过程式语言而言 ,最佳选择其实就是干脆别 写测试, 尽 量直接把函数写对 。 也许我们可以在更高的层面测试该函数是否正确 。 但在C里面我们还有一个 选择 o C支持函数指针,我们可以利用该语言特性来设立另 一个接缝,如下所示 先创建一个包含一系列函数指针的结构体 struct database { v。工 d (*retrieve) (struct record_id idl ; void (*update) (struct r ecord_id id, struct record_set *record ) ; } ; 然后我们将它里面的函数指针初始化为指向相应的数据库访问函数 。 于是,以后如果要编写 需要访 问 数据库的函数,只需把这个结构体传给它即可。在产 品代码中,我们的结构体里面的函 数指针可以指向真正的数据库访问函数,而在测试的时候则可以将它们指向伪函数。 注意,对于比较老的编译器,我们可能需要采用旧的函数指针语法· extern struct datab ase db; (*db. update) (load->id, ! oan->r ecord) ; 不过,如果你的编译器比较新的话,你就可以用 一种非常自然的"面向对象"的风格来调用 匡到 这些函数了: extern struct database db; db. update (load->id, loan->recor d) ; 该技术并非仅适用于C 。 任何语言,只要支持函数指针,都支持该技术 . 19.4 利用面向对象的优势 对于面向对象语言 ,我们可以利用对象接缝 Cobject seam. 35 页) 。 对象接缝有一些很好的特质 · 口容易从代码中把它们认出来。 口可以用于将代码分解为更小、更易理解的块 。 口提供了更好的灵活性 。 为了测试而引入的接缝在你需要扩展系统时或许还能起到帮助 .因 194 第二部分修改代码的技术 然而遗憾的是,并非所有的系统都可以容易地迁移到面向对象,但有些系统迁移起来的确要 比其他系统容易得多。许多过程式语言已经进化成了面向对象语言。如微软的VB变成完全面向 对象语言只是最近的事, COBOL和 Fortran也有面向对象扩展,此外大多数C编译器也能够编译 C++代码。 如果你的语言允许你把代码向面向对象迁移的话,你手头就有了更多的选择。第一步通常是 使用封装全局引用 (Encapsulate Global Reference, 268 页)来将你打算修改的代码块置于测试之 下。还记得本章一开始的 scan-p ackets 所处的糟糕的依赖情况吗?利用该技术,我们便可以从 中解脱出来。回想一下,当时问题的源头是ksr_notify 函数 2 我们希望在测试期间,该函数不 会发送任何的通知消息。 工 nt scan-packets (struct rnode......packet *packet , int flag) { struct rnode--packet *current packet; int scan_result I err 0; while(current) scan_resul t loc_scan (current ->body , flag); 工 f(scan_ result & INV在LID_PORTl ( ksr_notify(scan_result , current); current current->next; return err; 首先,我们要将代码在C++模式下编译。这个改动可大可小,具体取决于我们怎么做。我们 可以主动出击,试着将整个项目在C++下重新编译:或者也可以一块一块的来,只是这样做要花 点时间。 一旦代码在 C++下编译成功,我们便可以搜索 ksr_notify 函数的声明并将它包装到一个类 当中: class ResultNo t if 工 er public: virtual void ksr_Dotify (工 nt scan_ result , struct rnode--packet *packetl; 我们还可以为该类引入 个新的源文件,并将其默认实现放在该文件里= extern "C~ void ksr_notify(int scan_result , struct rnode-packet *packet); void ResultNotifier: : ksr_Dotify(int scan_result , struct rnode-packet *packet) : :ksr_notify(scan_result, packet); 第 19 章对非面向对象的项目,如何安全地对它进行修改 195 注意 ,我们并没有修改函数 的名 字或签名。我们使用了签名保持 (249页〉技术来将出错 的儿率 降到最低。 接下来声明一个全局的 ResultNotifier对象,并将其放入一个源文件中: ResultNotifier globalResultNotifier; 现在我们便可以重新编译代码,让编译错误告诉我们哪儿需要改动。由于已经把 ksr_nofity 的声 明放入了一个类当中,所以编译器在全局作用 域内就找不到它的声明了。 以下是原来的函数: #include Mksrlib.h" int scan-packets(struct rnode-packet .packet, { struct rnode-packet 舍 current packet i 工 nt scan_resu!t. err 0; while(current) int flag) scan_result loc_scan (curren 巳 ->body , flag) ; if(scan_result & INVALID_PORTl ksr_notify(scan_result , current) ; current current->next ; return err; 要想让上面的代码再次通过编译,可以使用 一个外部声明来让 globalResultNotifier对 象对编译器可见,并给 ksr_notify调用加上globalResultNotifier前缀: 排 include -ksrlib.h" extern ResultNotifier globalResultNotifier; int scan-packets (struct rnode""'packet *packet , int flag) { struct rnode-packet *current packet; int scan_resul t. err 0 ; while(current) scan_resu!t !oc_scan(current->body , flag) ; if(scan_result & INVALID_PORT) globalResultNotifier . ksr_notify(scan_result , current) ; current current->next; return err; 因196 第二部分修改代码的技术 这样修改了之后,代码的行为还像原来那样 。 ResultNotifier上的 k sr_notify方法会将 任务转发给 (全局 的) : : ksr not 工 f y来完成 . 那么,以上这番工作究竟能给我们带来什么好处 呢 。 嗯…..目前还没有 . 下 一 步工作就是试图让我们能够在产 品代码中使用这个 Result­ Notifier对象 ,而同时能在测试代码中使用另-个对象 .实现这个目的有多种方法 ,但从长远 考虑的话,最好还是再一次运用封装全局引用 ( 268页 )来把 scan-p acke t s 也包进一个类之中, 臣E 我们姑且把这个类叫做 Scanner 吧,如下所示 z class Scanner public : int scanJ)ackets (struct rnodeJ)acket *packet , int flag); 这么 一来我们便可以运用参数化构造函数<2 97页〉手法来让 Scanne r 类使用我们提供的 Resul t- Notifier对象了: class Scanner private ResultNotifier& notifier; public Scanner() ; Scanner(ResultNotifier& n。巳 ifier ) ; int scanJ)ackets (st ruct rnodeJ)acket *packet . int flag) ; } ; // in the source file Scanner : :Scanner() notifier (globalRe sultNotifier) () Scanner : :Scanner(Result Notifier& notifier) notifier(notifier) () 这番修改之后 ,我们便可以找到那些调用 scan-packets 的地方,创建一个 Scanner实例 , 然后调用它上面的 scan-packet s 方法. 以上这些修改都相当安全,也相 当机械化 。 虽然它们并不是面向对象设计的良好案例,但作 为解依赖技术它们已经表现得足够好了,使我们在后续的工作中得以进行测试 。 19.5 一切都是面向对象 有些过程式程序员 喜欢抨击面向对象,他们认为面向对象是没必要的,或者认为它所带来的 复杂性并没换来什么实质性的好处 。 但如果你认真想一想的话,就会意识到,所有的过程式程序第 19 章对非面向对象的项目,如何安全地对它进行修改 197 其实都是面向对象的,只不过可惜的是它们中很多都只包含一个对象.为什么这么说呢?我们考一一 虑一个包含了大约 100个函数的程序,以下是这些函数的声明 12421 int db_find(char *id, unsigned int mnemon 主 c_id. struct db_rec **rec) i v。立 d process_run(struct gfh_task **tas ks , int task_cQuntl ; 假设我们可以将以上这些声明全都放进同一个文件当中,并用 一个类来将它们包起来· class program { public : int db_ find(char *id, unsigned int mnemonic_id, struct db_rec '"舍 rec) i void process_run(struct gfh_task **tasks. int task_cQuntl ; 然后,我们找到每个函数的定义,比如: int db_find(char *id, unsigned int struct db rec mnemonic_id, 舍会 re c ) ; 给它们的函数名加上刚才那个类名作为前缀: int program: : db_ find(char unsigned int struct db rec *id. mnemonic_id. 舍 * recl 经过以上这番改动之后,我们就得为程序编写一个新的 rnain ( )函数了· int main(int ac, char *舍av) program 巳 he-p rogram; return the-program.main (ac, av); 那么,这些改变系统的行为了吗?没有。这些只不过是一些机械的改动,程序的意义和行为 根本没有受到任何影响 . 所以说"老式"的C程序其实就是一个大对象 . 而我们在使用封装全局 因198 第二部分修改代码的技术 引用手法时,则是在创建新对象,将系统分成一个个的子部件,从而使其更容易对付. 如果一 门过程式语言支持面向对象扩展,那么我们就可以像上面这样对系统进行迁移 .这项 技术并不是什么高深的面向对象技术,我们只不过是利用一点点面向对象的知识来将程序分解从 而使其易于测试罢了 . 如果我们的语言支持面向对象,那么除了提取依赖性之外,还能用它来做些什么呢?其一就 是我们可以逐步将系统朝向更好的面向对象设计改进。 一般而言这就意味着你得将相关的函数群 组到一个个的类当中,提取出许许多多的函数以便将复杂的职责分解开来。更多这方面的建议可 以参考第 20章。 对于过程式代码,我们手头的选择要比面向对象代码少,但还是可以在过程式遗留代码中取 得进展的。不可否认的是过程式代码所特有的接缝形式严重地加大了我们工作的难度。因此如果 你的过程式语言支持面向对象扩展的话,我建议你对系统进行迁移。对象接缝 (35 页)除了将测 试安置到位之外还有其他很多好处。连接期接缝和预处理期接缝虽说也是很好的选择,但除了自B 12441 将代码置于测试之下之外,它们在改善设计方面并无多大贡献。第 20 章 处理大类 人们往系统中加入的特性其实有许多都是一些小调整。在添加它们的时候需要添加一点儿代 码,或许再加上几个方法。这时候你就会发现,把这些东西添加到一个既有的类身上是一个较具 诱惑力的选择。很可能你需要添加的代码必须用到某个既有类中的数据,因此最简单的做法便是 直接把代码塞到那个类当中。遗憾的是,这种省力的修改代码的方式可能会带来严重的麻烦。随 着我们一再地往既有类中添加代码,既有类的方法和类本身就会变得越来越庞大,我们的软件会 变成一个沼泽,然后你就得花上更长的时间才能搞清如何往里添加新特性,甚至连理解旧特性都 会花更多时间。 布一次我去协助一个团队,该团队做了 一个从纸上看来相当不错的架构设计。他们告诉我系 统中哪些类是主要类,以及通常情况下它们之间如何交互。接着他们给我看了一些展示架构的 UML图。然而,当我看到代码时却愣住了。系统中的每个类几乎都可以被分解为大约 10个类, 而这么做正是帮助他们摆脱目前面临的最紧迫问题的钥匙。 那么,庞大的类杳哪些问题呢?首先就是容易混淆。如果一个类有五六十个方法,那么要想 搞清哪些东西需要修改以及你的修改是否会影响到其他什么东西往往就困难了。最糟糕的情况就 是,一个庞大的类具有多得不可思议的成员变量,对于这样一个类,我们很难知道修改一个变量 会带来什么样的影响。另一个问题就是任务调度。如果一个类具有大约 20 个职责,那么很可能有 许多原因要修改它,多得你忙不过来。于是你可能会遇到在同一个迭代期中好几个程序员要对该 类进行不同的修改的情况。如果他们同时进行修改,就可能会导致一些严重的冲突,尤其是当考 虑到第三个问题的存在的时候,即庞大的类测试起来非常痛苦。封装是好事情,但可别对测试人 员这么说,他们可不这样想。过分庞大的类往往隐藏了过多的东西。当封装能够帮助我们推断代画画 码的行为,当我们知道某些特定的东西只有在特定的情况下才能被修改时,封装是好事情。然而, 封装 旦过了头,被封在里头的东西便会腐烂发臭了。比如你没法容易地感知到修改带来的影响, 于是只能退而采用编辑并祈祷的法子。而一旦事情到了这个地步,要么你的修改耗时漫长,要么 bug数目增长。反正你得为代码缺乏清晰性付出代价。 如果有一个庞大的类,那么第一个需要面对的问题就是:怎样修改才能不至于令已经糟糕的 状况雪上加霜?可以使用的两个关键技术是新生类 (54页)和新生方法 (52 页)。当需要修改代 码时,我们应该考虑将新添的代码放进一个新类或新方法中。新生类方法能够防止系统的状况变 得更糟。没错,当你将新加的代码放进新类中时,可能会需要从原类中调用这个新类,但至少没200 第二部分修改代码的技术 有令原类变得更大。新生方法也有帮助,不过比起新生类来,它起到的帮助就比较微妙了。把添 加的代码放到新方法中的确会导致系统中多出 一个方法来,但至少你给它所属类所做的另 一件 事情起了 一个名字,并在系统中把它标识出来,而且往往这些新生方法的名字能够暗示你如何 将一个类分解成更小的类。 对于庞大的类,一个关键的补救手段就是重构。将大类分解为一组小类是杳帮助的。然而最 大的问题在于要搞清楚这些小类应该是什么样子的。幸运的是,对此我们有一些指导原则。 单一职责原则 (SRP) 每个类应该仅承担一个职责:它在系统中的意图应当是单一的,且修改它的原因应该只有 一个 单一职责原则描述起来布点困难,因为"职责"这个概念有点含糊。如果我们以一种非常单 一的视角来看待这个概念的话,我们也许会说"哦,那是不是意味着每个类应该只有一个方法? " 呢……方法的确可以被看成职责。比如 Task类,它的 run 方法的职责是运行该 Task ,它的 taskC 。飞mt方法的职责是告诉我们它有多少个子任务,等等。但当我们关心的是主要意图时"职 匪E 责"这个词到底意味着什么就不是无关紧要的了。图 20-1 给出了 一个例子。 RuleParser - current : string - variables : HashMap - currenlPosi!ion : int + evaluate(string) : int - branchingExpression(Node left, Node right) : int - causalExpression(Node left, Node right) int - variableExpression(Node node) 晶 。 n t - valueExpression(Node node) : int nextTerm() : string - hasMoreTermsO : boolean + addVariable{string name, int value) 图 20-1 规则 > JobC。时roller ScheduledJob + run() I + addPred回国目 r( Schedu陷dJo时 +阳 use () I + addSu 回国目 r(Sc闸dul 盹Job) +陪 sumeO 1+ 9 创DurationO : int +isAunn呵。 1 + shOw() A 1 :叫叫陪耐f旬@ ,+<叩u叫) I +pau 臼 o 1+ resumeO ._----------------- --叫+ isRunningO +阳stMes四geO: void + ;sVisibleO : boolean + isModifiedO : boo!ean + perSls时 + acquireAesourcesO + releaseAesourcesO + getElapsed节 meO + getActiyitiesO 图 20-13 给ScheduledJob做一个客户相关的接口 国212 第二部分修改代码的技术 现在,那些只想对任务进行控制的客户就可以通过JobController接口来使用 Scheduled­ Job对象了。这种为 一组特定用户量身定做-个接口的设计手法能够保持你的设计符合接口隔 离 原则 CISP) 。 接口隔离原则 (ISP) 如果一个类体和、较大,那么很可能它的客户并不会使用其所有方法。通常我们会看到特定 用户使用特定的一组方法.如果我们给特定用户使用的那组方法创建一个接口,并让这个大类 实现该接口,那么用户使可以使用"属于它的"那个接口来访问我们的类了.这种做法有利于 信息隐藏,此外也减少了革统中存在的依赖.即当我们的大类发生改变的时候,其客尸代码便 不再需要重新编译了. 一旦为客户量身定做了接口之后,往往就可以开始将代码朝更好的方向改进了:原本是 个 回 大类,现在我们可以新建一个类,让这个类使用原来的大类,如图 20-14所示: HU叫) 创 interfac e>> JobController + pause() StandardJobController + StandardJobController(ScheduledJob) + runO + pauseO + resumeO + isRunningO 图 20-14 隔离 ScheduledJob的接口 这下我们不再是让 ScheduledJob把任务委托给 JobController. 而是反过来,让后者委 托前者。这样一来,不管什么时候,只要客户想要运行一个 ScheduledJ饨,它就可以创建一个 JobController ,并传递个 ScheduledJob对象给它,然后便可以利用这个JobController 来控制任务的执行。 不过真正做起来时,这利'重构几乎总是比它听起来要更困难。通常,为了完成这一重构,你 得在原类 CScheduledJob) 上暴露更多的公有方法,以便让新的"前端"类 CStandardJob Controller) 能够访问到足够多的功能来完成它的工作。这类修改往往需要相当大的工作量。 客户代码需要更改,这样它们才能从使用旧类转变为使用新类:而要想安全地完成这一更改,你 又得先把有关的客户代码用测试护住。不过这一重构最大的好处就是它使得你能够一 点点地对一第 20 章处理大类 213 个大类的接口进行削减 。 在我们的例子中可以看 到, 经过重构之后的 ScheduledJob 类上不再 具有JobController里的那些方法了 。 探索式方法 #6 ,当所有方法都行不通时,作一点草稿式重构 如呆实在才阳住看清一个类内部的职责,那么可以对它作一点草稿式重构. 草稿式重构 074页)是个强大的手段 。 但是,你得记住, 草稿式重构只能看作是一次"模 拟演习气你在草稿式重构时看到的代码并不一定是后面真正重构时得到的。 探索式方法 #7 关注当前工作 注意你目前手头正在做的事情.如采发现你自己正在为某件事情提供另一条解决方案,那 么可能使意味着这里面存在一个应该被提取并允许替代的职责 . 人们很容易就会被类里面数量庞大的职责吓住 . 别忘了,你正在进行的修改就等于是在告诉 你该软件可以按某种特定的方式改变 。 通常只要认识到这种改变的方式就足以将你编写 的新代码 看作一个独立 的职责 了 。 20.2 其他技术 上文介绍的探索式方法可以很好地帮助你深入了解并寻找出旧类里面的新抽象,但说到底它 们只是小技巧 . 要想真正很好地认识你的代码内的职责 ,还是只有多读,包括读关于设计模式的 书,更重要的还是读其他人编写的代码。花点时间来阅读开源项目的源代码,看看其他人是怎么 做的 。 读的时候注意别人是如何命名系统里面的类的,此外还有类名与方法名之间的对应关系 。 渐渐地,你就会在识别出隐藏职责方面更加得心应手, 并且在浏览不熟悉的代码的时候也能够发 现它们 。 20.3 继续前进 当你在一个大类中识别出了一系列不同的职责之后,就只剩下两个问题要解决了:战略和战 术 . 先来看看战略 . 20.3.1 战略 在识别出了所有独立的职责之后,我们该干些什么呢? 应该花上一周的时间把这些大类都重 因 整一遍吗?应该把它们都化整为零吗?如果你有这个时间,那再好不过 。 但实际上这种可能性很 12651 小,而且这一过程也有一定风险。就我所见过的有关案例中,几乎毫无例外的,当一个团队进行 一番大规模重构时,系统稳定性就会有一段时间的下跌,即便他们干得再仔细,一边编写 测试一 边重构,还是如此 。当然,如果你们仍处在发布周期的早期,愿意承担这样的风险,且有时间, 那么 一次大规模重构也并非不可 。 只要记得别让那些bug搞得你不再想去做其他重构就是了 。214 第二部分修改代码的技术 要分解大型的类,最佳步骤就是先确认出职责,并确保团队里的每个人都理解了这些职责, 然后在需要的时候再去对该类进行分解。这利 1 做法其实就是把修改的风险给减小了,从而让你能 够在这个过程中同时完成其他事情。 20.3.2 战术 对于大多数遗留系统而言, 一开始你所能奢望的也就是能够在实现层面运用单一职责原则 了:本质上这意味着从大类中提取出新类,然后把一些任务委托给这些新类。另一方面,在接口 层面引入S旧就要麻烦一些了。类的客户代码需要改动,所以你得先给它们编写测试。不过还算 好的是,实现层面 SRP 的引入有助于后面在接口层面的引入。让我们先来看一看实现层面的内容。 具体用什么技术来进行类提取取决于众多因素。其一就是要考虑给那些被影响到的方法编写 测试的难易程度。最好先看一看你的类,列出所有需要被移动的成员变量和方法。这么一来,对 于应该给哪些方法编写测试,你心里就应该有数了。就拿我们前面提到的那个 RuleParser类来 说,如果考虑分解出一个 TermTokenizer类,那么可能需要将 string类型的成员变量 current 、 int 型的成员变量 c urrentPos 工 tlon 、以及方法has MoreTerms和 nextTerrn移动到新类中。然 而,由于hasMoreTerrns和 nextTerm都是私有方法,因此我们无法直接为它们编写测试。当然, 我们可以先将它们改为公有的(反正它们要"搬家"了嘛)再进行测试的编写,但在测试用具里 头创建一个 RuleParser并通过它对一组字符串求值可能也同样容易。完成这些之后我们便有了 覆盖hasMoreTerms和 nextTerm的测试,从而能够安全地将它们转移到新类当中去。 遗憾的是,许多大型的类都很难在测试用具中实例化。关于这个问题,参考第 9章,里面有 一组可以用于解决这类问题的技术 。如果你已经能够在测试用具中实例化你的类,则仍可能需要 利用第 10章所讲的技术来帮助你将测试安置到位 。 一旦将测试安置到位,你便可以用 种非常直观的方式来提取类了, &p 利用 Martin F ow ler在 《重构·改善既有代码的设计》一书中提到的类提取重构。然而,如果无法将测试安置到位的话, 12661 仍然还是有办法的,尽管可能就要危险一些了。下面就是在测试无法安置到位的情况下可以采取 的步骤(注意,这是一个非常保守的策略,并且无论你手头是否有重构工具,该方案都可行)。 (1)确定出一个你想要分离到另 一个类当中的职责 。 (2) 弄清是否有成员变量需要被转移到新类当中 。有的话就将它们放到类体内的 个单独的 声明区段,跟其他成员变量区分开来。 (3) 如果一个方法需要整个儿被移至新类中,则将其函数体提取出来,放入新方法,别忘了新方 法的名字要跟旧方法一致,除了一点不同,即新方法的名字前加了个前缀,比如MOVING (全大写)。 如果你没有用重构工具,那么要记住在做方法提取的时候利用签名保持 (249页)手法。最后,每提 取一个方法就把它放入前面所说的那个单独的声明区段,即跟待转移的那些成员变量放在一起。 (4) 倘若一个方法只有一部分需要被转移,就将它们从"原住地"提取出来,同样利用 MOVING 前缀来标识它们栖身的新方法,并将后者放至那个单独的区段。 (5) 到此,类里面应该就有一个区段放的全是待转移的成员变量和方法了 。在当前类和当前 类的所杳派生类中作一次文本搜索,确保你要转移的成员变量里面不存在某个被要转移的方法集第 20 章处理大类 215 之外的方法使用到的变量。这一步不应依靠编译器 (25 1 页),恪守这一点很重要,因为在许多面 向对象语言中,一个派生类里面可以声明与基类里某个成员变量同名的成员变量。这通常被称为 名字隐藏。因此如果你的类里面有变量隐藏了其基类中的同名变量,同时(除了你要转移的方法 之外)还有一些方法是使用了该变量的,那么贸然将该变量转移走可能就会改变代码的行为 同样道理,如果你依靠编译器来试图发现那些使用了你即将移走的变量的方法,那么一旦你移走 的某个变量名字隐藏了基类中的某个变量,则你的编译器将不会帮你发现所有的这类方法 将 一个名字隐藏变量注释掉然后重编译只会让那些使用它的方法转而使用基类中同名的变盘而已 。 (6) 到此,你已经可以将前面分离好的成员变盘和方法一并转移到新类当中去了。然后在原 类中创建新类的对象,接着就依靠编译器来发现哪些地方应该调用新类上的那些方法吧。 12671 (7) 在完成"搬家"工作,并且代码也顺利编译了之后,便可以将新类上那些方法的 MOVING 前缀去掉了。最后,还是依靠编译器帮你找到那些需要相应修改名字的地方2 。 前面这番重构的步骤是相当复杂的,但如果你面对的本来就是一堆非常复杂的代码,则要想 不用测试就安全地将它们提取出来,这样复杂的步骤还是必要的 。 在没有测试的情况下进行类提取可能会遇到一些不测。其中最难觉察的就是可能会引入与继 承有关的bug o 将一个方法从←个类移至另 -个类是相当安全的,你可以依靠编译器的帮助,但 对于大多数语言来说,倘若你试图移走一个重写了另一方法的方法,情况就不妙了,原类中对这 个被移走方法的调用将会变成对基类中同名方法的调用 。对于成员变量也存在类似的现象。派生 类中的成员变量会隐藏基类中的同名变量。移走前者便会让后者暴露出来。 因此,为了解决这个问题,我们根本就不移走那个方法。而是通过提取原方法的函数体来创 建新的方法。其中给新方法加的前缀只是为了避免在移走它们之前与原方法产生名字冲突 。另一 方面,成员变量的情况则要微妙一些·我们得在移走它们之前手动搜寻哪些方法使用了它们 。这 一过程是有可能出错的,所以须得非常小心,建议跟你的伙伴一起完成这一工作。 20.4 类提取之后 对一个庞大的类进行类提取通常是个好的开始 。在实践当中我们发现,团队在做这项工作时 面临的放大危险便是野心过大。你可能已经对这个类做了 一些草稿式重构(1 74 页),或是已经对 该系统应该成什么样子建立起了一些其他的看法。但是请记住,你目前的系统结构在应用当中能 够工作,它提供了软件的功能,它可能还没有准备好向前迈进.有时候,你所能做到的也就是建 立起对一个大型类在重构之后的样子的"愿景气然后再把这个愿景给放一边去.这么做只是为 了去发现什么是可能的 。要想真正让代码向前推进,你得敏锐地关注代码中的现状,并小心地向 前改进(不一定要朝着你心目中理想的设计改进,但至少可以向更好的方向改进)。 l 因为那些原先使用该变量的方法将会转而使用基类中的同名变量了(如果可访问的话 λ 译者注 2 调用点等. 译者注 国第 21 章 需要修改大量相同的代码 在对付遗留系统时,也许没有比遇到下面的事情更令人沮丧的了 。 比如,你需要做一处修改, 看到了需要修改的地方,但接下来你却发现另 一处一模一样的地方也需要作相应的修改,然后又 是另 一处,另 一处……,因为你的系统里面其他很多地方的代码都与这个地方的儿乎一模一样 。 你可能会觉得,如果对系统做一些重构的话这样的问题可能就不会存在了,但谁又有这个时间 呢?于是也只能让这个问题在系统里面搁着,看着心烦却又无计可施。 如果你会重构的话,情况就会好一些 。 你会知道消除重复代码并不一定要像重新设计或重新 架构那样大张旗鼓,消耗大量精力,而是可以在工作的过程中小块小块地进行修改。这样系统的 状况就会逐步得到改善,当然,前提是别人不要跟在你后面继续往里猛塞重复代码才行 不过 就算这一情况不幸发生了,也仍然还是有办法解决的 ( 别玩真人PK就成。) ,但这就不是我们 这里应该讨论的问题了 。关键是,值不值得这么做 。 把一块代码里面的重复成分挤干对我们来说 到底有什么好处?我想答案会让你吃惊。先让我们来看一个例子。 现有一个不大的用 Java写 的网络系统,我们需要用它来发送命令到一个服务器端 。 有两个命 令类 ,分别叫 AddEmploy eeCmd和 LogonComr阳l d o 当需要发起一个命令时,我们就实例化相应 的命令类并传一个输出流给它的 写方法。 下面就是这两个命令类 的定义,从中你能看出有哪些重复代码吗? import java . io. OutputSt ream; public class AddEmployeeCmd { String name ; String address ; String city; String state ; 回 S tri 吨 yearlySal 町 , private static final byte{] header {( by 巳 e) Oxde , (byte) Oxad} ; priva t e s t atic final byte [1 corrunandChar (O x02 ) i private stati c final byte [ 1 footer { (byte) Oxbe, (by te) Oxe f} ; private static final int SIZE_LENGTH 1 ; private static final int CMD_BYTE_ LENGTH 1 ; private int getSize() return header . length + 第 21 章 需要修改大量相同的代码 217 SIZE_LENGTH + CMD_8YTE_LENGTH + footer _ length + name.get8ytes().length + 1 + address.getBytes() . length + 1 + city.getBytes() . length + 1 + state.getBytes().length + 1 + yearlySalary . getBytes() .length + 1 : public AddEmployeeCmd(String name , String address , String city , String state, int yearlySalary) ( thiS.name name i this. address address ; this . city city ; this. state state; this.yearlySalary Integer.toString(yearlySalary); public void write(OutputStream outputStream) throws Exception ( 。 utputStream . write(headerl; 。 utputStream . write(getSize{)) ; 。 utputStream . write(commandChar) ; 。 utputStream .write(name . getBytes()) ; 。 utputStream .write(OxÛO) ; 。 utputStream .writ e(add ress . getBytes()) ; 。 utputStream .write(O xOO); 。 utputStream .writ e(city . getBytes()) ; 。 utputStream.write(OxOO) ; 。 utputStream .write(s tate.g etBytes() ); outputStream.write(Ox 001 ; outputStream.write(yearlySalary . getBytes()) ; 。 utputStream . write(OxOO) i 。utputStream . write( 主。oter) ; import java.io. OutputStreami public class LoginCommand { private String userName ; private S 巳 ring passwd; prl. va 巳 e static final byte[] header {(byte) Oxde , (byte) Oxad} ; private static final byte[] commandChar {Ox01} ; private static final byte[] footer { (byte) Oxbe , (by 巳 e)Oxe f}; private static final int SIZE_LENGTH 1 ; private static final int CMD_BYTE_LENGTH 1 ; public LoginCommand(String userName, String passwd) this . userName userNamei this.passwd passwd; 因218 第二部分修改代码的技术 private int getSize () { return header . length + SIZE_LENGTH + CMD_BYTE_LENGTH + footer . length + uSerName . getBytes( ) . length + 1 + passwd . getBγ tes() . length + 1 ; public void write(OutputStrearn outputStream) throws Exception ( 。 utputStream . write(headerJ ; 。 utputStream .w rite ( get S i z e ()) ; 。 utputStream .w rite ( commandChar ); 。 utputStream . write(userName . getBytes()) ; 。 utputStream . write(OxOO) ; 。 utput Stream . write(pass wd . g etByte s ( ) ); 。 u t put Stream . wr ite ( OxOO ) ; 。 utpu t Stream . write(footer) ; 回 图 21-1 是相应的U皿 图。 AddEmployeeCmd name : Slring address : Slring cily : String stal : Slring yearlySalary : Slring . header : byte (] -∞ mmandChar : byte [J . fooler : b'阳 o - StZE LENGTH : inl -CMD BYTE LENGTH 咽 inl - gelSizeO : inl + AddEmployeeCmd(. ..) + write(OutputStream) LoglnCommand - getSize() : int + LoginCommand(. ..) + write(OulputStream) 图 21-1 AddEmploy eeCmd;f!lLoginCommand int 看起来重复成分还不少,但那又怎样呢? 反正代码量又不多 。 我们可以重构,切出重复成分 , 缩减代码量 。 但是,这么做是不是就让我们的工作变得容易些了呢?可能是,也可能不是:光是 就这样看上两眼脑袋里想一想是很难给出答案的。 那么就让我们试试看吧 。 首先找出那些重复的地方 ,删除它们,然后看看得到的代码是什么 样子 的 。 这样我们便可以判断我们的重复消除能否带来帮助了 。 开始之前,首先需要一组测试,以便在每次重构之后都能够运行它们来确保一切正常。不过 为了叙述的简洁,这里就不描述这组测试了,你只需记住它们在哪儿就可以了 .第 21 章 需要修改大量相同的代码 219 开始步骤 面对代码重复,我的第一反应便是"后退一步"好对它有一个更整体的认知。这么做肘,我 便开始思考最终得到的类会是什么样子的,以及抽取出来的重复代码成分看起来会是怎样的。然 后我意识到自己想得太多了。其实只需从删除小块重复代码开始,而且它使得我们以后更容易看 到更大块的重复成分。例如,在 LoginComrnand的写方法中,有如下的代码: outputStream.write(userName.getBytes() )i outputStrea工n.write(OxÛO) ; outputStrearn.write(passwd.getBytes()) ; outputStrearn.write(OxOO); 从以上代码可以看出,每写出一个字符串,结尾都会跟上一个结束符 (OxOO) 。于是我们可 以这样来提取这里的重复成分·创建一个名叫 wr iteFìe ld 的新方法,该方法接受 个字符串和 囚 一个输出流,然后负责将字符串写入流中,并写入个结束符。 vo id writeField(OutputStream QutputStream. String fieldl { 。utputStream.write(field . getBytes()) ; 。 utputStrearn.write(OxOO) ; 决定从哪开始 经过一系列的重构,终于消除了重复之后,最终得到的代码结构是什么样子的,还是得取 决于我们最初是从哪开始的.例如,假设我们有如下的方法: VQ 工 d c() ( a(); a(); bll; all; b(); b(); } 我们可以这样来分解它. void c () { aa (); b (); a (); bb (); } 或者也可以这样分解 void c () ( a () i ab (); ab (); b (); } 问题是,该选哪一个呢 9 事实上,两者结构并没有多大区别.两种分重且法都比原来的要好, 而且如采需要的话我们大可从其中的一种分组重构至另一种分组.以上并非最终决定.具体如 何决定妾看名字.如果我能给负责重复两次调用 a( )方法的新方法(上面的 aa () )找个好名字, 而且这个名字在其上下文中比我们能给ab() 找出的名字要史好的话,我们就会采用前者. 我使用的另一个启发式策略就是迈小步。如果有些很小的重复是可以消除的,那么我就先 把它们搞定,往往这能够使整个大图景变得明朗起来。 有了这个新方法之后,我们便可以将每个成对的"串/结束符"写入都替换成单个调用,同 时别忘了每次都要运行测试来确保一切正常。下面就是 LoginCorrunand的write方法修改之后的 样子: public void write(OutputStream outputStream) 220 第二部分修改代码的技术 throws Exception ( outputStream .wr 工 te(header) i out putStream.write(getSize()) ; 。 utputStream.write(commandChar) ; writeField(outputstream, username) i writeField(outputStream, passwdl; outputStream .wr 工 te (fo oter) ; 1273 1 这便解决了 LoginCornrnand类的问题,但还剩下 AddEmployeeCmd类 呢? AddEmployeeCmd 里面同样也存在重复的串/结束符写入序列。由于这两个类都是命令(Command)类,因此我们可以 考虑引入一个公共基类,比如就叫 Cornmand 。有了这个公共基类之后,便可以将writeF 二 eld放 在其中供这两个派生类使用。 Command # writeField(OutputStream , Slring) AddEmployeeCmd +W川 t e(口 ulpulStream) loginCommand + write(OutputStream) 图 21-2 命令类继承体系 现在我们可以回到 AddEmployeeCmd ,并将它里面的串/结束符写入序列也都替换为对 wrlteF 工 eld的调用了。修改之后 AddEmployeeCmd的写方法看起来像这样: public void write(OutputStream outputStream) throws Excep 巳 ion ( 。utputStream .wr ite(header) ; 。utputStream .write(getSize ()) i 。 utputStream .wri te(commandChar) ; writeField(autputStream, name) ; writeField(outputStream, address) ; writeField(outputStream, city) i writeField(outputStream, state) i writeField(outputStream, yearlySalary) ; outputStream.write(footer) ; LoginCorruuand 的写方法如下· public void write(OutputStream outputStream) throws Exception { 。 utputStream . write(header); outputStream.write(getSize() ); outputStream.write(commandChar) ; writeField(outputstream, userName) ; writeField(outputStream, passwd); outputStream.write(footer); 第 21 章 需要修改大量相同的代码 221 现在代码干净些 了 ,但还没有结束呢 。 AddEmp l oy e eCmd和 LoginCorrunand的 写方法形式 上其实是一样的 : 依次是写入命令的首部 C header) 、大小 C size) 以及命令符 C cornmand Char); 然后是写入一组字段 ; 最后 是写上尾部 C footer) 。 如果我们可以将它们之间 的差异成分抽取出 来 , 就能够得到如下写方法 ( 以 Lo g inComrnand的为例) 。 public void write(OutputStream out put Stream) throws Exception ( output Stream. writ e(header) ; 。utpu t Stream . wri t e(getSize() }; 。u t put Stream . write{commandChar) i writeBody(outputstream) ; 。utputStream . write(footer) ; 其中 wr 工 t eBody如下· private void writeBc 过 y(OutputStream outputStream) throws Exception ( writeField ( ou 巳 puts 巳 ream , userNamel ; writeField ( outputS 巳 ream , passwd) ; AddEmploye e Cmd 的写方法从形式上l'~ 上面的那个完全一样 ,只不过writeBody 的定义有所 不同 。 private void writeBody(OutputStream out put Stream) throws Exception ( writeFie ld(autputStream, name) ; writeField(outputSt ream, address) ; writeFie ld(outputSt ream, cit y) ; writeFie ld (outputSt ream, state) ; writeField(outputStream, yearlySal ary) ; 如果两个方法看上去大致相同,则可以抽取出它们之间的差异成分 . 通过这种做法 , 我们 往往能够令它们变得完全一样 , 从而消除掉其中一个 . 现在 ,这两个 类 的 写方法看上 去完全相同 了。 那么我们是否可以将这个 写方法提升到 Corrunand基类 中 了呢? 等等。就算两个方法" 看上去" 完全一样 了 , 但它们所使用 到的成员数据 仍然还是属于它们各 自 的类的 header 、 footer 以及 corrunandChar 。 如果想要实现单一 的写方 法,则这个写方法必须能够调用其派生类的特定方法来获取相应的数据成员 才行 。 因此就让我们 来考察一下 AddEmp l oy eeCmd和 L oginCorrunand 中的数据成员 吧 。 public class AddEmployeeCmd extends Comrnand { String name; String address ; String city; String state ; S 巳 ring yearlySalary; private static final byte[] header 因 因因 222 第二部分修改代码的技术 { (byte) Oxde , (byt e) Oxad} i private static final byte[) commandChar {Ox02} ; private static final byte[) footer { (bytel Oxbe, (byte) Oxef) ; priv ate static final int SIZE_LENGTH 1 ; private static final int CMD_BYTE_LENGTH 1 ; publ 工 c class Log inCommand e x tends Command { p r ivate String llserName; private String passwd; priv a t e static final by te [] header 二 {(bytelOxde , (byte) Oxad) ; pr 工 vate static final byte [) commandChar {O x Ol} ; pr 工 v a t e static final by te [] footer {(byte) Oxbe, (byte) Ox e f} ; private static f i nal int SIZE_LENGTH 1 ; private static final int CMD_BYTE_LENGTH 二 1 ; 看得 出 来,这两个类里面的公共数据还是挺多的,比如 head e r 、 footer 、 SIZE LENGTH 以及 CMD_BYT E_LENGTH . 由于它们的值对应相同,因此我们可以将它们全都提升至k orrunand类 中来 。 为了重新编译和测试的目的,我们先将它们设为受保护的 。 public class Command ( prot ected sta乞 ic f i nal byte() header {(by te)Oxde , (by te ) ûx ad} i prot ect ed static final byte [] footer 二 {(by te ) Oxbe, (by te ) Ox e f} i prot ect ed static fλnal int SIZE_LENGTH 1 ; protected static final int CMD_ BYTE_LENGTH 1 ; 现在,我们所关心的就只剩下两个派生类 中 的 corrunandC har变量了。 该变量在两个派生类 中 是不同的 。 解决这- 问题的办法之一是在Comrnand类上引入一个抽象的获取方法 。 public class Command { protected static final byte[J header {(by te) Ox de , (by te) Oxad} ; protected static final by 巳 e [J foo ter {(byte) Oxbe , (by te) Oxef} i protected static final int SIZE_LENGTH 1 ; prote cted static final int CMD.一_BYTE_L ENG TH 1 ; protected abst ract char [) getCommandChar 门 , 这样 一 来,我们只 需 在每个相 应 的派 生类 上 使用 重写 的 getC o rrunandChar 方 法 来表示7 第 21 章 需要修改大量相同的代码 223 commandChar变量即可 z { d n a 叩门FMd 飞r sa; dhH 叫 ncu edd tnb xa( em{ 时臼]ct[ ee eqdr ya 。 1J-n 、--『 tc p MEW Eae dhn dc zn dr seu stt ace 、4exct 。 cz lp l b m户 public class LoginCommand extends Command ( protected char [] getCommandChar() return new char [ 1 { OxO l} ; 没问题,好。是时候把写方法提升到 Comrnand基类中去了。完成之后我们的 Comrnand类看上 去像这样. public class Command { protected static final byte [J header {(byte) Oxde , (byte) Oxad}; protected static final byte [] footer { (byte) Oxbe, (byte) Oxef} ; protected static final int SIZE_LENGTH 1 ; protected stat 工 c final int CMD_BYTE_LENGTH 1; protected abstract char () getCommandChar(); pr。巳 ected abstract void writeBody(OutputStream outputStream); protected void writeField(OutputStrearn outputStream, String fieldl ( 。 utputStream .write(field . getBytes()) ; 。utputStream . write(OxOO) ; public void write(ÜutputStream outputStream) throws Exception ( 。 utputStream.write(header) i 。 utputStream.write(getSize()); 。 utputStream . write(commandChar) i writeBody(outputstream) i 。 utputStream . write(footer) ; 回 注意,我们还引入了一个抽象的 wr iteBody方法(见图 2 1-3 ) 。因 224 第二部分修改代码的技术 Command # writeField(OutputStream, String) # writeBody(OutpuIStre'am) {abslract} + write(OulputStream) AddEmployeeCmd + writeBody(OulpuIStream) loglnCommand + wrileBody(OulputStream) 图 21-3 提升writ e Fi e l d 将写方法提升至 Conunand基类之后 ,剩下唯一的任务就是每个派生类中 的 getSize方法 、 ge t ConunandChar方法,以及构造函数了 。 下面是我们的 LoginCommand类 : public class LoginCommand extends Command { private String liserName; private String passwd; publ 工 C LoginCommand(St ring userName , String passwd ) t his . userName userName ; this . passwd paSSwdi protected char () getCommandChar() re 巳 urn new char [1 { Ox Ol }; protec 巳 ed itlt getSi ze ( ) ( return header . lengt h + SIZE_LENGTH + CMD_ BYTE_LENGTH + footer . length + userName . getBytes () . length + 1 + passwd. getBytes() . length + 1 : 这 是 个相 当简 洁 清 爽的类。 AddEmploy eeCmd 也 与 此相似 :一个 getSize 方 法、 一 个 getCornrnandChar方法 ,儿乎就这些 。 现在让我们稍微仔细地看一下ge t Si z e 方法 。 这是 LoginCorruna时 的: protected int getSize () return header.length + SIZE_ LENGTH + CMD_BYTE_LENGTH + footer.length + userName . getBytes () . l ength + 1 + passwd. getBytes() . length + 1 ; 这是 AddEmploy eeCmd 的: pr 工 va t e int get Size () return header . length + SIZE_LENGTH + CMD_BYTE_ LENGTH + footer . lengt h + 第 21 章 需要修改大量相同的代码 225 name . getBytes() . length + 1 + address .getBytes() .length + 1 + city . getBytes() .length + 1 + state.get8ytes() .length + 1 + yearlySalary.getBytes() . length + 1; 这两个 getSize 的相同点有哪些,不同点又有哪些呢?看起来它们都将首部长度、 SIZE_LENGTH 、命令字节长度 以及尾部长度计算进去 了 。然后剩下的就是每个字段的长度 ,在 这一点上两者不同。那么,若是我们将这个不同点提取出来呢?把getBodySize ()作为计算各 字段长度总和的函数 ,于是: pr 主 vate int getSize () { return header . length + SIZE_LENGT旧 + CMD_BYTE_LENGTH + footer . length + ge 巳 BodySi ze ( ) ; 这么 来,两者的 getS 工 ze( )就又一样了。 getS 工 ze 的逻辑是所有的簿记 (bookkeeping) 数据的长度,加上命令消息体的长度(即所有字段的长度总和)。这么 一来我们便又可以将 getSize提升到iJc ommand基类当中去 了 ,而不 同的派生类只需实现不同的 getBodySize 即可(见 图 21-4) 。 I Command 11 writeField(OutputStream, String) 11 writeBody(OutputSlream) {abslracl} 11 getBodySizeO : int {abstract} + getSize() : int + write(OulputStream) i AddEmploy 时md I 国j:27|士土士 t 叫, 图 21 -4 提升 getSize 现在,停下来看看我们走到哪了 。且ddEmp loyeeCmd 的 getBodySize实现如下· protected int getBodySize() return name . getBytes() . length + 1 + address.getBytes() .length + 1 + city . getBytes( ) .length + 1 + s t a乞 e.ge t Bytes() . length + 1 + yearlySalary.getBytes() .length + 1 ; 看起来,我们还是漏掉了一些相当讨厌的重复代码。虽然数量不多,但就让我们再热心一 囚, 把它们完全消除掉看看。 回因 226 第二部分修改代码的技术 protected int getFieldSize(String field) return field . getBytes () . length + 1 i protected int getBodySize() return getFieldSize(name) + getFieldS 工 ze(address) + getFieldS 工 ze(cüy) + getFieldSize{state) + getFieldS 工 ze(yearlySalary) ; 只需将上面的 getFieldSize 方法提升到Jc ommand 基类中,便可以在 LoginComrnand 的 getBodySize方法中利用它了。 protected int getBodySize() return getFieldSize(name) + getFieldSize(password)i 还有什么重复的成分吗?实际上还有,但已经不多了。我们注意到 LoginCornmand 和 AddEmployeeCmd都是接受一组参数,获取它们的大小,然后将它们写出。除了 comrnandChar 变量之外,这两个类也就只剩这点差别了 : 我们能否通过一点泛化来解决掉这一重复呢?事实上, 通过在基类中引入一个链表,我们便可以在每个派生类中的构造函数里面这样做。 class Log 工 nCommand extends Command { public AddEmployeeCmd(String name , String password) { fields . add(name) ; fields.add(passwordl; 在派生类的构造函数填充完这个字段列表之后,我们便可以用同样的代码来获取消息体的大 小了。 int getBodySize () { int resul t 0; for (Iterator it fields. iterator (); it. hasNext (); ) String field (String) it . next (); result += getFieldSize(field); return result i 类似的, wr 工 teBody方法看起来可能像这样。 void writeBody(Outputstream outputstream) Eor (Iterator it Eields . iterator () i i t. hasNext () i ) String Eield (String) i t. next () i writeField(outputStream, field); 第 21 章 需要修改大量相同的代码 227 现在我们可以将这些方法统统都提升到kommand基类 中 了。这样便真正消除了所有的重复。 以下便是 Cornrnand类修改后 的样子。 注意 Corrunand 里面的那些不再被派生类访问的方法都 已经改 成私有的了. public class C。回land { private static final byte[] header {(byte) Oxde , (byte) Oxad}; private static final byte[) footer {{byte) Oxbe , (byte) Oxef} ; private stat 正 c final int SIZE_LENGTH 1 ; private static final int CMD_BYTE_LENGTH 1 ; protected List fields new ArrayList () ; protected abstract char [] getCommandChar 门 , private void writeBody(Outputstre缸胃。utputstream) for(Iterator it fields . iterator() ; it . hasNex 巳( 1; I ( String field (String) it.next (); writeField (outputStream, fieldl ; private int getFieldSize{String field) return field . getBytes() . length + 1; private int getBodySize() int resul t 0; for (工 terator it fields.iterator() ; 工 t . hasNext ( ) ; ) ( String field (Stringlit.next() ; result += getFieldS 工 ze (field) ; } return resulti private int getSize() { return header. length + SIZE_LENGTH + CMD_BYTE_LENGTH + fo。巳 er . length + getBodySize() i private v。主 d writeField(OutputStream outputStream. String field) ( 。 utputStream . write(field . getBytes()) ; 。 utputStream . write(OxOO) ; public void write(OutputStream outputStream) throws Exception ( 。utputStream . write(header) ; outputStream.write(getSize()) ; 。 utputStre缸n . write(commandChar) ; writeBody(outputstre缸时, 因圄 因 228 第二部分修改代码的技术 。 u tp 丛 t Stream . write(footer) i 再看看我们的 LoginCommand和 AddEmployeeC时,简直太简洁了 public class LoginCommand extends Command ( public Log 工 nCommand(String userName , Str 工 ng passwd) fields.add(username) ; fields.add(passwd) ; protected char [] getCommandChar() ret urn new char [] { OxOl} i public class AddEmployeeCmd extends Coru叽and ( public AddEmployeeCmd(String name, S 巳 ring address I String city, Str 工 ng state, int yearlySalary) fields.add(name) i f 工 elds.add(address) i fields.add(city) i fields.add(state) i fields.add(Integer . toString(yearlySalary)) ; protected char [] getCommandChar() return new char [] { Ox02 }; 图 21-5 是我们最终成果的UML图展示。 Command header : byt叫 l footer uyle 0 SIZE LENGTH : int - CMD BYTE LENGTH: int fields : List #wgreitteCFo可melmd(aOnudtCpuhtasrtoreabmyt, eSl]t {abstract} ring) - writeBody(OutputStream) - geIFieldSize(String)int - getBodySizeO : int . getSizeO : int + write(OutputStream) ? AddEmployeeCmd LoginCommand # getCommandCharO 问'.0 #9LeOtQClnocm。mmamndacndh{aro) b归叫 1+ AddEmployeeCommand(...) +Log 图 21-5 消除了重复代码之后的 Conunand继承体系图第 21 章 需要修改大量相同的代码 229 现在我们到哪了呢?消除拌了那么多的重复代码,以致于我们的两个命令类简直都成了 一层 空壳了。所有的功能性代码都转移到了公共基类Command 中。实际上,如果你开始怀疑究竟是否 要给这两种命令分别引入各自的类,你的怀疑是不无道理的。那么替代方案又是什么呢? 我们可以拿掉这两个派生类,并往 Comrnand类中加入一个静态方法,该方法允许我们发送一 个命令。 List arguments new ArrayList () ; arguments .add( MMikeM)i arguments .add("asdsad") i Command.send(stream, OxOl , argurnents) i 但这样一来对用户来说又显得太麻烦了 。有一件事是肯定的 z 我们肯定得发送两个不同的命 令字符,而且我们不想让用户来管理这些字符。 另一种方案就是为每种命令添加一个单独的静态方法。 Command.SendAddEmployee(stream, "Mike" I "122 Elm St 圃 "Miami 圃, 'FL' , 100001 ; Command. SendLogin (stream, "Mike" , "asdsad"); 但这又使得所有的客户代码都得作出相应改动。就目前来说,代码中创建AddEmployeeCmd 和 LoginConunandX才象的地方还真不少 。 或许最好的办法还是就让这两个类留在那儿。的确,它们小到不够资格,但那又如何呢?反 正它们又没有带来什么坏的影响,是吧。 完了吗?还没有。有一件事情我们还没做,实际上早该做了。那就是我们可以将 AddEmployeeCmd重命名为 AddE即 loyeeCommand 。这样两个命令类的名字就一致了 . 命名的 一致性可以减少出错的机会 。 缩写 类名和方法名缩写是问题的来源之一.缩写风格一致的话倒还好,但总的来说我不喜欢这 种做法. 我曾遇到过这样的一个团队,该团队试图在它们系统中的几乎每一个类的类在里面使用 manager和management l$... 两个字眼.这种命名习惯没带来好处,灵糟的是它们居然还用了好多 种不同的缩写法.例如,有些类被命名为 XXXXMgr. 而同时另一些类则被命名为XXXXMngr. 远就使得想要使用这些类的人几乎每次都要把类翻出来看看以确信自己使用了正确的类 . 而对 于我个人来说,要猜测某个特定的类用的应该是哪个后缀,有一半以上的时候猜错 12841 好了,现在我们已经将所有重复成分消除 . 剩下的问题就是检验它是否真的带来了好处。为 此让我们来设想几个场最。场景一 .当我们需要增加一个新命令时,我们该怎么办?我们可以从 Command派生出一个新类,创建该类的对象即可。而如果是在消除重复之前的系统里呢?我们便 需要创建一个新的命令类,然后从另一个命令类那儿剪切/粘贴一些代码,修改变量等等。而这 又会引入更多的重复,并让事情变得更糟:除此之外还更容易出错。比如可能会弄错了变盘什么230 第二部分修改代码的技术 的.而且在消除重复之前,这一过程绝对要花上更长一 点的时间。 那么 ,重复成分的消除是否导致我们丢掉了-部分灵活性呢 9 如果我们必须得发送非字符串 类的命令该怎么办呢?其实,从某种程度上说我们己经解决了 这个问题。 AddEmployeeCommand 本身就能接受一个整型参数,并将其转换为字符串然后作为命令发送。对于其他任何类型,我们 也可以用同样的做法 。 因为要想发送一个命令,反正总是要转换为字符串形式的 。我们可以在具 体派生类的构造函数中完成这一步骤。 另一个问题:如果我们需要另 一种形式的命令怎么办?比如说一个可以将其他命令嵌套进命 令体的命令。这个问题其实也可以轻易解决 。只需从 C ommand~生一个新类,重写其wr~teBody 方法: public class AggregateCommand extends COIT哑 nand { private List corrunands new ArrayList () i protected char [] getCommandChar() { return new char [) ( Qx03 ) ; publ ic void appendC 。即nand (Command newCOIT蓝nandl { commands . add(newCommandl ; protected void writeBody(OutputStream outl 。ut.write(commands.getSize()) ; for(Iterator it commands . iterator(); it.hasNext 门) { Comrnand innerCommand (Commandlit . next 门 , innerCommand .write(out); 剩下的就不用你操心了。 现在,想象一下倘若不是消除了重复,情况会怎样吧 。 这最后一个例子揭示出了一些非常重要的东西。即当消除类之间的重复成分时,会得到一些 很小,功能很集中的方法。每一个方法所做的事情都跟其他方法不间,这其实便意味着一个极大 圆的好处:正础。 正交性说白 了其实就是独立性(无关性〉。比如说你想要修改代码的既有行为,然后发现只 需到代码中的一处地方改一下就成了 。这就是正交性 。 如果把我们的应用比作外面嵌着一些旋钮 的盒子,那么具有正交性的应用就好比是一个行为只对应一个旋钮,调节起来很容易 。反之如果 重复情况很严重的话,就可能会 出现一个行为涉及多个旋钮的情况。就拿前面例子中的字段写出 来说,在一开始的设计中,如果我们想改用 Ox创作为结束符(原来是 OxOO) ,我们就得"遍历" 代码,将每一处使用 OxOO 的地方都改成 OxOl 。此外设想有人要求我们用两个 OxOO 作为结束符, 那么情况也会变得相当糟糕。一句话,没有单一切入点。另 一方面,在经过我们重构之后的代码 中,要想改变字段的写出行为,只需编辑或重写wri teField方法即可,对于一些像命令嵌套之 类的特殊任务则可以重写 writeBody方法。总之,一旦将行为局部化到了单个方法中,想要替换第 21 章 需要修改大量相同的代码 231 或是修改它就很容易了。 本例中我们做了很多事情:把一些方法和变量从一个类移到另一个类、将方法分解,等等。 但其中大部分事情都是机械的。我们只不过是注意到了代码中的重复情况然后将它们消除而已。 真正具有创造性的事情其实只有一件,那就是给新方法想出合适的名字。原来的代码并没有字段 或命令体的概念,但其实某种程度上这些概念是隐藏在代码中的。例如,有些变量是被特殊对待 的,我们将它们称为字段。最后我们得到了一个优雅得多的正交设计,但我们并不觉得是在进行 设计:而更多只是将一段代码改得更符合它的本意而已。 当你热情地去消除代码中的重复时,就会惊讶地发现,设计会自己浮现 出来 。是的,系统中 的大部分正交性都无需你刻意去实现,它们自然而然就会出现。但这还不够完美,例如,考虑 Command类上的如下方法 z public void write(OutputStream outputStream) throws Exception ( outputStream.write{header) ; outputStre田n.wr 工 te(getSize{)) ; outputStream.write(commandChar)i writeBody(outputstream) ; outputSt ream.write(footer) ; 假设该方法像下面这样 z public void write(OutputStream outputStreamJ throws Exception { writeHeader(outputStream) ; writeBody(outputstreaml; writeFooter(outputStream) ; 现在我们的代码中就多出了两个新的切入点。 一个是命令头的写 出,另一个是命令尾的写出 。 我们当然可以根据需要往代码中添加这样的切入点,然而毕竟看到它们自然而然地出现还是令人 欣喜的。 重复消除是锤炼设计的强大手段。它不仅能让设计变得更灵活,还能令代码修改更快更容易。 开放/封闭原则 开放j封闭原则是由 Bertrand Meyer首先提出的。其背后的理念是,代码对于扩展应该是开 放的而对于修改则应是封闭的。这就是说,对于一个好的设计,我们无需对代码作太多的修改 就可以添加新的特性。 那么,具体到本幸的例子,我们最后得到的代码具备这一要素吗?答案是肯定的.我们前 面已经考察了一系列的修改场景,许多情况下只需要对极少的方法稍加修改即可。还有一些情 况下则只需派生一个新类即可解决问题.当然,派生之后很重要的一点是要记得消除重复(见 关于差异式编程的描述,其中介绍了如何通过派生来添加新特性以及通过重构来整合它们)。 因 此外,消除了重复之后,我们的代码往往便会自然而然地往开放/封闭原则靠拢了 12871第 22 章 要修改一个巨型方法, 却没法为它编写测试 在对付遗留代码时最麻烦的事情莫过于遇到庞大的方法了 . 许多时候可以通过新生方法 (52 页) 和新生类 (54页〉手法来避免对长方法进行重构。但就算你逃掉了, 也应该为不得不如此感 到羞愧。长方法就像是代码基中的沼泽。不管什么时候,只要你试图去改变它,就得退一步先努 力把情况弄清楚,然后再去进行修改。通常,如果代码比较干净的话修改起来要省时省事得多。 代码基里有长方法是件痛苦的事,但 巨型方法就更恐怖了。 所谓"巨型"方法就是指那些庞 大复杂到你碰都不想去碰→下的方法.巨型方法可能包含成百上千行代码,其中还到处都是缩进 , 搞得你几乎无法浏览。所以,碰到这样的方法,你很可能会把它打印在一张长长的纸上,把纸摊 在走廊里以便和你的同事们-起把它读懂。 有一次,我去参加一个会议,在跟朋友走回旅馆的时候,他们其中有一个说"嗨,你一定 得来看看这个。"他跑进房间拿出笔记本,给我看一个方法,这个方法长达千行以上。朋友知道 我当时在研究重构,于是就说"遇到这种方法你怎么办? "于是我们就开始想办法。我们知道 测试是关键,但遇到这么巨型的方法,从哪里下手却成了问题。 12891 本章所讲的就是从那以后我在这个问题上积累 的经验。 22.1 巨型方法的种类 巨型方法不止一种。而且巨型方法的种类也并不都是能截然区分开来的. 一般来说,我们看 到的巨型方法都是几种不同特征的混合体(就像鸭嘴兽那样 λ 22.1.1 项目列表式方法 项目列表式方法就是指那种几乎毫无缩进的方法,你只能看到罗列下来的一串仿佛项目列表 似的代码块。其中也许有个别代码块中含有一定的缩进,但就整个函数范围来说是没什么缩进的。 一般来说这种方法一眼扫过去是这样的(如图 22-1 所示)。 图 22 -1 是最常见的一种项目列表式方法。如果运气好,可能会碰到写方法的人在各个代码区 段之间加上几个回车或者加点注释说明一下各部分是做什么的 . 理想情况下,你应该可以将每个第 22 章要修改一个巨型 方 法 , 却没法 为 它编写测试 233 代码区段提取为一个单独的方法,但通常代码并没有这么容易重构。看起来代码段之间是被一些 空行隔开 了, 然而这个表象实际上是有欺骗性的,往往在一个区段声 明的局部变量会在另一个区 段被用到。这就导致方法分解往往并不像你想象的那样只需把代码拷贝粘贴 出来就可以了。话虽 如此 , 项目列表式方法跟 巨型方法的其他" 品种 " 比起来还算是稍微好一点 的, 这主要是因为代 码中 没有那种疯狂 的缩进,后者往往会使阅读者找不着北 。 1 29 0 1 void Reservation:: extend (int additionalDays) { 主 nt status RIXlnterface: :checkAvailablettype, location , startingDa te); int identCookie -1 ; switch(sta巳 usl { case NQT AVAILABLE UPGRADE LUXURY identCook主 e RIX工 nterface: :holdReservation(Luxury, location, startingDate. additionalDays +dditionalDaysj"; break; case NOT_AVAILABLE_UPGRADE_SUV { int theDays additionalDays + additionalDays ; if (RIXlnterface: :getOpCode(customerID) !'" 0) theDays++; identCookie RIXlnterface: :holdReservat 工。n(SUV , location , startingDate , theDays); } break; case 如l OT-AVAILABLE_UPGRADE_VAN identCookie RIXlnterface : : holdReservation (Van , l ocation , $巳 arting Da巳 e , additionalDays + addit ionalDays) ; break; case AVAlLABLE deEault RIXlnterface: :holdReservation(type, location, startingDate); break; if (identCookie != -1 && state 工 nitiall { RIXlnterface : :waitlis 巳 Reservation(type , 1ocation , starting Da te); Customer c res_db.getCustomer(customerID) ; if (c.v i pProgramSta 巳 us VIP_DIAMOND) upgradeQuery true; if (!upgradeQuery) RIXlnterface : :extend(lastCookie, days + additionalDays); e1se ( RIXlnterface::waitlistReservation(type, location, startingDate); RIXlnterface: :extend(lastCookie, days + additionalDays +1 ) ; 图 22-1 项目 列表式方法 22.1.2 锯齿状方法 锯齿状方法就是指那些具有单个庞大的缩进块的方法.最简单的例子就是下面这样的,一个 具有一个庞大 的 u块的方法( 如 图 22-2所示 λ 回234 第二部分修改代码的技术 Reservation : :Rese玄vat 工。 n{VehicleType type, int customerIO, 10ng startingDate, int days, XLocation 1) type{type) , customerTD(customerID) , start 工 ngDate(startingDate) I days(days) , lastCookie{ - ll , state (Init 主 a1) , tempTotal(Q) { location 1; upgradeQuery Ealse; if (!R工 Xlnterface: :available()) ( R工 Xlnterface: :doEvents(100); PostLogMessage(O , 0 , "delay 00 reservation creation" ); int holdCookie -1; switch(statusJ { case NOT_AVAIL hB LE_UPGf之 ADE LUXURY holdCookie RIXlnterEace: :holdReservation(Luxury , l , startingDate}; if (holdCookie !~ -1) ( holdCookie I~ 9; } break; case NOT_AVAILABLE_UPGRADE_SUV holdCookie RIXlnterface: : holdReservation(SUV, l , startingDate); break; case NOTßVA工 LABLE_UPGRADE_VAN holdCookie RIX 工 nterface: :holdReservation(Van, l , startingDatel; break; case AVA工 LABLE default RIXlnterface: : holdReservation; state Held; break; 图 22-2 锯齿状方法一一一个简单的例子 但以上这个还算好的,至少跟上面的项目列表式方法差不多。下面展示的这个(见图 22-3) 才是真正令人头大的。 要想判断你手头的方法是否属于这一类,最好的办法就是试图把方法内的代码块都按照缩进 12921 格式格式化好。如果结果代码让你感到晕头转向,那么就是了。 大多数方法其实并不能严格归为上面所讲的两类,而更多的是属于两者的混合体。许多锯齿 状方法在它们的深层的缩进区块中也会含有大段的项目列表式代码,但由于这些代码被层层嵌套 12931 在了里面,因此很难给它们编写测试。对付锯齿状方法很具有挑战性。 在对长方法进行重构时,有没有重构工具会带来很大的区别。几乎每个重构工具都支持方法 提取,因为有很多地方都可以利用这 支持。如果一个工具能够为你安全地提取方法,你就不需 要自己编写测试去验证提取是否正确。工具代替了你的手工劳动,于是你可以专心致志地考虑如 何使用方法提取来将一个方法重构成体面的样子,从而让后续工作更容易。 如果不幸没有自动提取方法的重构工具支持,则整理巨型方法可就更具挑战性了。通常这时 候你的改动就得更保守一点,因为只有测试设置妥当了,相应的改动才能进行。第 22 章要修改一个巨型方法,却没法为它编写测试 235 Reservation : : Reservation(VehiCleType type, int customerID, 10n9 startingDate, int days, XLocation 1) typeltype) , customerID(customerID) , startingDate(startingDate) , days(days) , lastCookie(-11 , state(Initial) , tempTotal(OJ { location 1; upgradeQuery false; while(!RIXlnterface: :available()) ( RIXlnterface: :doEvents(100); PostLogMessage(O, 0 , "delay on reservation creati。凶.) , int holdCook 立 e 尊 -1 ; switch(status) { case NOT_AVArLABLE_uPGRADE~UXURY holdCookie R工 x 工 nterface: :holdReservation(Luxury, l , startingDate); if (holdCookie != -1) { if (1 GIG && customerID 45) { } break; 1/ Special 11222 while (RIXlnterface: ; notBusy()) { int code RIX 工 nter 主 ace :: getOpC ode(customerIDj ; if (code 1 II customerID> 0)) { PostLogMessage{1 , 0 , "QEX PID"); for (int n 0; n < 12; n++) { int total 2000 ; if (state lnitial 11 state Held) { total 令 getTotalByLocation{location) ; tempTotal total ; if (location GIG && days > 2) { if (state Held) totalφ30; } R工 X lnterface: :se rveIDCode(n , t。巳 a l) ; } } else { R工 x 工 nterface : :serveCode(customerID); case NOT AVAILABLE UPGRADE SUV holdCookie RIX工 nterface::holdReservation(SUV , l , starting Da te) break; case NOT-AVAILABLE_UPGRADE_VAN holdCookie RIXlnterface: :holdReservation{Van, l , startingDate); break; case AVAILABLE default RIXlnterface: :holdReservation{type.l , startingDatel; state Held; break; 图 22.3 恶劣的悄形236 第二部分修改代码的技术 22.2 利用自动重构支持来对付巨型方法 如果你的工具能进行方法提取,那么得首先弄清楚什么是它能替你做的,什么是它不能替你 做的。如今的大多数重构工具都能做简单的方法提取以及一些其他的重构,但人们在分解大型方 法的时候往往需要更多的辅助重构,这时候就不是随便某个重构工具能胜任的了 。举个例子,我 们常常喜欢对语句进行重排,分组,以便将它们提取出来。对于这一需求,目前就没有任何工具 能够进行必要的分析来判断给定的重排是否安全 。这是件很可惜的事情,因为它可能成为 bug的 来源。 要想针对大型方法有效地运用重构工具,最好仅用工具进行一系列的修改,并避免对代码进 行其他任何改动。这听起来似乎是用 一根小指头就能完成的事情,但它的确带来了好处,就是能 把那些已知为安全的修改和可能不安全的修改完全分离开来。在做这一阶段的重构时,你应当避 免像语句重排和表达式分解这类修改,就算它们很简单也不行。此外,如果你的工具支持变量重 命名,那固然很好,但如果它不支持,则你应该把这一工作推迟到后面再做 。 在没有测试的情况下进行自动重构时,一定要只用工具进行(其中不要参杂手工修改) . 而在一革列的自动重构完成之后,往往就可以将测试安直到位,并用这些测试来验证你所进行 的任何手动修改丁. 在做提取的时候,以下是你的主要目标: (1)将代码中的逻辑部分从尴尬的依赖中分离出来 . 12941 (2) 引入接缝,以后在重构时才能更容易地将测试安置到位 。 下面是一个例子: class CommoditySelectionPanel { public void update () if (commodities.size () > 0 && commodities . GetSource( ) . equals( Mlocal " )) listbox . clear () i for (It erator 工 t corrunodities. iterator () ; it . hasNext () i ) { Commodity current (Commodity) it . next () ; if (commodity . isTwi light() && !commodity.match(broker)) listbox . add(commodity.getView())i 以上方法中有很多地方都是可以清理一下的 。其 中最怪异的事情之一就是该方法所傲的"过第 22 章要修改一个巨型方法,却没法为它编写测试 237 滤"工作是发生在一个面板 (Panel) 类上,按理说一个叫做面板的类应该只管显示才对。要想 解开这段代码肯定是困难的。照现在的情况,要想编写测试的话,可以针对 listbox的状态来写, 但那样的话比原来的设计也好不了很多。 然而,如果有重构支持,情况就不一样了,我们可以在提升抽象层次的同时达到解开依赖的 目的。比如,下面就是代码经过了 一系列提取之后的样子: class Corr町、。 ditySelectionpanel { public v。工 d update() { if (c。皿moditiesAreReadyPorUpdate()) ( clearDisplay(); updateCommodities(); private boolean commoditiesAreReadyForUpdate() return commodities . size() > 0 && commodities.GetSource() .equals(-local"); private vo 工 d clearDisplay () ( listbox . clear () ; private void updateCommodit 主 es () { for (Iterator it commodities.iterator() i it.hasNext{) ; ) Commodity current (C。町~odity)it.next() ;) if (singleBrokercommodity(commodity)) ( displayCommodity(current.getv!ew()); private boolean singleBrokerCommod 工 ty (Cornmodity comrnodity) ( return c。炯~odity.is Tw ilight() && !commodity.match(brokerl; private void displayCommodity(CommodityView view) listbox . add(view) i 坦白 的说, 重构之后的代码从结构上看并没多大区别:仍然只不过是一个内含一些语句的 if 块。但不同的是,现在 if块内的工作被委托给其他方法来完成了。现在我们的 update方法就像 是原来的 update方法的骨架.但是,你会说,工具给自动起的那些方法名怎么办呢?它们看起 来总有点古怪。别介意,你可以把它们当成一个好的开始,至少它们让你的代码能够从一个更高 因因 238 第二部分修改代码的技术 的层面来传达语意了不是吗?而且它们还引入了接缝,这样的话后面我们就可以利用这些接缝来 进行解依赖了。例如,我们可以运用于类化并重写方法手法来通过 displayCommodity 和 clearDisplay进行感知。完成这些之后 ,我们可以考虑利用这些测试作后盾,创建一个新的显 示类并将这些方法移到该类中。不过在本例中更妥当的办法是看看能否将 update 乖l updateCornmodit 工 es 转移到另 一个类中,而将 clearDisplay和 displayCommodity 留下,因 为这样该类才能算是一个面板类,显示类.至于方法的重命名,则可以等到各个类都安放到位之 后再进行。再加上一些重构,最终我们的设计看起来可能就像图 22 -4所示的这样。 CommodilySelectionPanel + clearDispla例) +displ町 Commodily(CommodityView) CommodityFilter + update() . commoditiesAreRead向,u回 ate() updateCommodities() is$ingleBrokeιommodity(Commodily) 图 22-4 从CommoditySelectionPanel 中提取出来的逻辑类 在使用自动工具进行方法提取时,有一点认识很重要,那就是藉此你可以完成许多粗糙的工 作,之后,等其他测试安置到位了再去做那些细节的工作.在这个过程中,别太在意那些看上去跟 当前类格格不入的方法,因为这样通常意味着之后还要进行类提取。具体怎么做可参考第 20章。 22.3 手动重构的挑战 自动重构工具的支持可以让你无需做任何特殊准备就可以开始对大方法进行分解。好的重构 工具能够帮你检查试图进行的重构是否安全,如果不安全的话就不予执行。但如果没有重构工具, 就得靠自己来确保重构的正确性了,这时测试便成了最强的工具。 巨型方法使得测试、重构以及特性添加变得非常困难。但如果你能够在测试用具中实例化该 方法所在的类,那就可以试着写出 一组测试用例来保证你在分解该方法的过程中的安全.当然, 如果该方法中的逻辑特别复杂,编写测试用例也可能会变成噩梦 。但幸运的是,这种情况下我们 可以求助于一系列的技术。在学习这组技术之前,让我们先来看看在方法提取时可能会犯的一些 错误。 下面是一个简单的列表,虽然并不全面,但已经包含了最常见的错误。 (1)我们可能会忘记向提取出来的方法传递变盘 。 通常编译器会提醒我们这一错误(除非该 变量跟某个成员变量重名),但我们还是可能会错误地认为该变盘应当是一个局部变量,于是将 其声明在了新方法的方法体中. (2) 我们可能会给提取出来的方法起了 一个会覆盖或重写基类中某个同名方法的名字 。 (3) 我们可能会在传参或接受返回值的时候犯错。愚蠢的错误如返回了错误的值。更小一点 的错误如返回或接受了错误的类型 。 总之有很多地方可能会出错.本节的技术可以帮助你在没有测试的情况下更安全地进行方法 回酬。第 22 章要修改一个巨型方法,却没法为它编写测试 239 22.3.1 引入感知变量 我们可能不希望在对产品代码进行重构的时候往里面加入特性,但这并不意味着不能往里面 添加任何代码。比如,可能会想要往一个类里面添加一个变量并使用它来感知待重构方法内的条 件,而在完成了重构之后则可以将该变量删除,于是我们的代码就又回到了干净的状态。这一手 法叫做引入感知交量。下面就是一个例子。我们想要重构 Java类DOMBuilder上的一个方法。我 们想要将这个方法清理一下,但不幸的是手头没有重构工具。 public class DOMBuilder { void processNode(XDOMNSnippet root , List childNodesJ { if (root ! = null) if (childNodes ! = nulll 主 oot.addN ode(new XDOMNSnippet(childNodesl); 主。 ot . addChild(XDOMNSnippet . NullSnippet) ; List paraList new ArrayList () ; XDOMNSnippet snippet new XDOMNReSnippet ( ) ; snippet . setSource (m_statel ; for (工 terator it childNodes . iterat or () ; it.hasNext() ; } XDO担lNNode node (XOOMNNode) i t . next ( ) ; if (node . type() TF_G 11 node . type() TF_H 11 (node . type () TF _GL 己 T && node . isChild{))) { paraList.ad~ode(node); 本例中,似乎该方法中的许多工作都是围绕一个 XDOMNSmppet对象来进行的。这意味着我 们可以通过传递不同的值给该方法来编写想要的测试。而实际上在背后发生了许多无关的事情, 这些事情只可以通过非常间接的方式来感知。比如说本例,我们可以引入感知变量来辅助我们的 工作:可以引入一个实例变量来发现当一个变量具育恰当 的结点类型时即会被添加进paraL 工 st. public 出 S6 OOMBuilder 国 public boolean nodeAdded false; void processNode(XDOMNSnippet root , List childNodesl { if (root ! = nulll { if (childNodes 1= nulll 主。ot .addNode(new XDOMNSnippet(childNodes 门 , root . addChild(XDOMNSnippet .NullSnippet) ; 240 第二部分修改代码的技术 List paraList new ArrayList ( ) i XDOMNSnippet snippet new XDO~咽 eSnippet{) ; snippet . setSource ( m~state ) ; for (Iterator it chil dNodes 工 terator() ; i t.hasNext (); ) XOOMNNode node ( XDOM!咽 。 delit . next() ; if (node . type() TF_G 11 node.type() TF_H 11 (node . type () TF_GLOT && node . isChild() ) ) paraList . add(node ) ; nodeAdded true; 有了这个感知变量,我们仍需要设计一个能够产生这一条件的输入 。 然后便可以将这块逻辑 提取出 来 ,同时我们的测试仍能通过。 在下面这个测试中,我们添加一个类型为 TF_G的节 点 。 void tes 巳 Ad dNode OnBasicChild ( ) { DOMBuilder builder new DomBuilder () ; List children new ArrayList ( ) ; children . add (new X DOMNNOde ( XDO阳明 ode . TF_Gl) ; Builder.processNode (new XDOMNSnippet 门, childre时 , assertTrue(builder . nodeAddedl; 下面这个测试旨在验证 当一个节 点的类型不符时是不会被添加的 : void testNoAddNodeOnNonBasicChild( ) { DOMBuilder builder new DomBu 且 lder ( ) ; List children new ArrayList () ; children. add (new XD 哑。咽。 de ( XDOMNNode . TF_A)) i 国 Builder . processNode(new XDO四 Sni即 et ( ) I children); assertTrue( !builder . nodeAdded) ; 有 了这些测试的保护,我们在提取pro c ess Node () 中 决定一个节点是否被添加的条件的 时 候就放心一些 了 。 我们将整个条件表达式复制出来 , 测试表 明 当条件满足的时候节点被添加了 。 public class DOMBuilde r { void processNode(XDOMNSn i ppet root , List childNodes) { if (root != null) if (childNodes ! '" null) root . addNode {new XDOMNSnippet {childNodes) ); 第 22 章妾修改一个巨型方法,却没法为它编写测试 241 root . addChild(XDOMNSnippet.NullSnippetl i } List paraList new ArrayList () ; XOOMNSnippet snippet new XDOMNReSnippet 仆 , snippet . setSource(m_statel ; for (Iterator it childNodes . iterator() ; i t.hasNext () ; ) ( XOOMNNode node (XOOMNNode)it . next(); 主 f (isBasicChild{node)) { paraList . addNode{node) ; nodeAdded t rue; } private boolean isBasicChild(XDOMNNode node) ( return node.type() TF_G 11 node. type() "'= TP_H 11 node.type() TF_GLOT && node. isChild()); 以后不需要这个感知变量时,我们便可以将它删除 。 本例中我们使用的是一个布尔变量 . 使用该变量的目的是为 了 确定在提取条件表达式之后节 点是否仍被添加了 。 我相当确信自己能够毫不出错地将整个条件表达式提取出来,因此没有对其 内 部的各个分式进行测试 。 以上测试简单地确保了在代码提取之后我们所关心的条件表达式仍然是代 码路径的一部分。关于在方法提取时到底要做多少测试,可以参见第 13 章提到的目标测试Cl 57 页 ) 0 13001 使用感知交量时最好将变量放在被重构的类中,直到一系列的重构完成之后再将它们删除 。 我常常是这么做的,因为这样做有-个好处 , 就是能够看到自己为了 一系列提取所写 的所有测试, 万一想要更换提取方式的话就可以轻松地撤销这些测试 。 完成提取之后 ,我往往会删除这些测试, 或者将它们重构一下用来测试提取出来的新方法 。 感知变量是分解 巨型方法的利器。你可以用它们来对锯齿状方法 中内嵌层次很深的代码进行 重构,但你同样也可以用它们来逐步将一个锯齿状方法"反锯齿" 。 例如,如果一个方法内的大 部分代码都被深深地嵌在 了 一组条件语句内的话,便可以利用感知变量来提取其中的条件语句或 块 . 而对于提取出来的新方法,同样也可以使用感知变量。 直到整个代码被成功" 反锯齿" 。 22.3.2 只提取你所了解的 另 一个可以用来对付 巨型方法的策略就是一开始迈小步, 寻找那些我们可以不用测试也能放 心提取出来的小块代码,然后添加测试来覆盖它们 。 但是,等等,我得换个方式来表达,因为每 个人对"小"的定义是不一样的 。 当我说"小块代码"时,我指的是两到三行,最多五行的代码, -块你能够容易地给它想出 名字 的代码 。在进行这些小步提取时需要关心的关键的一个因素是相 合数 . 搞合数就是指传进传出你所提取的方法的值的总数 . 例如,如果我们从下面这段代码中提242 第二部分修改代码的技术 取出 一个max方法的话,它的稿合数就会是 3 。 void process (int a , int b , int c) int maximum; if ( a > b ) max~mum 二 a ; else maximum b ; 提取后 的代码像这样: void process(int a , int b , int c) int maximum max(a , b) i 该方法的搞合数就是 3 ;两个传进的值(参数), 一个传出的值 ( 返回值) 。一般来说最好提 13011 取那些糯合数小的方法,因为这样犯错的概率较小 。 因此, 当 你在选择提取哪些代码时,可以寻 找一段行数较小的代码,然后数一数进入这块代码以及从这块代码出去 的变量一共有多少 。 对成 员变量的访问不算,因为我们只是简单地将这块代码剪切复制出来而已;因此成员变量并不" 穿 过"我们所提取出来的方法的接口 。 方法提取过程中的一个主要危险是类型转换错误 。 因而如果我们只提取那些低锅合数的方 法,就能够更好地避免这类情况。在确定了 一个可能的提取之后,应该回头看看那些传进这块代 码的变量是在哪儿定义的,这么做是为了避免弄错方法的签名 . 如果说低祸合数的提取是安全的,那么是不是就意味着 。 祸合数的提取是&安全的呢?是的 。 实 际上,通过将一个巨型方法中的那些不接受任何参数也不返回任何值的代码块提取成方法,我 们就可以获得许多活动空间 。 这类方法实际上就是所谓的"命令式"方法 比如你命令一个对 象对它的状态做某些事情,或者更恶劣地对全局状态做某些事情。不管怎样,对于这类代码, 当 你试图给它们起名 字时,通常能够对该块代码获得更深刻的认识,比如关于它是做什么的,它会 怎样影响特定的对象等。而这种认识又进而能导致更多的认识,最终你将能够换一种更有效率的 视角来看待你的设计 。 当使用只提取你所了解的 (Extract What You Know) 手法时,记住别去选太大的代码块,如 果祸合数大于 0 ,那么通常使用 一个感知变盘是有好处的 。 提取完之后,别忘了给你的新方法写 几个测试 。 然而,当把这一技术用在小块小块的代码上时,你可能会觉得这对整个庞然大物的方法来说 有点杯水车薪的感觉 。 然而,实际上正如俗语所云 :积睦步以至千里 。 每次 当你回过头去提取出 又一小块代码时,就不知不觉间 又迈进了 一步,同时你的方法也更清晰了 一分 . 渐渐地,你会发 现对该方法有了更好的认识,同时也更清楚如何修改它了 。 当手头没有重构工具时,我通常 开始会提取 o 稿合数的方法,这一步只是为了能够对代码 的整体结构有一个认识.对后面进行测试以及其他工作是一个很好的准备。第 22 章 要修改一个巨型方法,却没法为它编写测试 243 如果你有一个项 目列表式方法,那么可能会觉得你将能够提取出许多 。 祸合数的方法,而且 每个都不错 。 是的,有些代码块的确如你所想,但通常许多代码块都会用到前面声明了的局部变量。 所以,有时候你必须得抛开所看到的代码块结构,而是从块内或者块间去寻找低祸合数的方法 1302 1 22.3.3 依赖收集 有时候一个 巨型方法里面会出现一些看起来跟该方法的主要意图不怎么有联系的代码 . 这些 方法也许是必要的,但并不十分复杂,而且如果你不小心破坏了它,很明显就能看出来。但尽管 这些都是实话,你仍然还是没法冒这个险。那么在这类情况下该怎么办呢?你可以使用一种叫做 依赖收集 Cgleaning dependencies) 的手法。首先你编 写测试来保护你要保护的逻辑。然后,你提 取出你的测试所没有辍盖到的部分。这么 一来,你至少可以确信你保护住了重要的行为 。 下面就 是一个简单的例子 : void addEntry (Entry entry) { if (view ! = null && OrSPLAY true) view. show(entry) i if (entry . category() . equals (.single- ) } 11 entry . c a tegory( - duaI-)) entries . add (entry) i view. showUpdate (entry , view. GREEN) i else { 如果我们把负责显示的代码搞糟了,则很快就能看到后果 。 然而,在添加 entry 的代码部分, 如果引入了 一个错误的话,就不是那么容易能找出来的了 . 在像这样的例子中,我们可以编写测 试来确保 ent ry 的添加总是在正确前提下发生的 。 然而, 一旦确信这些重要行为已经被保护起来 了,我们便可以将负责显示部分的代码提取出来,并同时确信我们的提取不会影响到 en t ry 的添加 。 某种意义上,这一手法就像驼鸟战术 。 你保护了 一组行为,而同时在无保护的情况下修改其 他代码 。 但在一个应用 当 中,并非所有的行为都是平等的 . 有些行为更重要,我们在修改代码时 能够将它们识别出来 . 依赖收集是一种强大的策略,尤其是当重要行为与其他行为纠缠在一起的时候 。一旦对重要 行为建立起了坚固的测试,便可以做许多编辑修改,虽然从技术上讲这些修改并没有全被测试辍 盖,但测试至少护住了那些关键的行为。 13031 22.3.4 分解出方法对象 感知交量是非常强大的工具 ,但有时候我们会注意到,方法里面本就己经有了可以直接被 用作感知变量 的变盘了,只不过它们也许是局部变量 。 要是成员变量的话,我们便可以在一个 方法被调用之后通过它们来进行感知了。而实际上,我们的确可以将一个局部变量变成成员变 量,只不过许多情况下这么做可能会带来一些混乱一一所提取出来的成员变量只对你的 巨型方244 第二部分修改代码的技术 法以及从该方法中提取出来的方法有意义。尽管每次你的巨型方法被调用起来的时候该变量都 会被重新初始化,但如果想要单独调用所提取出来的那些方法的话,就难以 弄清这些变量到底 持有什么值了。 一个替代方案是使用分解出方法对象 (Break Out Method Objectl 手法。该手法是由 Ward Cunningharu首先引入的,它是一种典型的人为抽象。当你分解出 一个方法对象时 , 实际上就是创 建了 一个类,其唯一职责是做原来的巨型方法所做的工作。原巨型方法的参数变成了该类的构造 函数的参数,原巨型方法中的代码则可以放到该类中的一个名叫 run () 或 execu t e ()的方法中 。 一旦代码被移到了新类中,重构起来就方便多 了 。我们可以将方法中的局部变量做成该类的成员 变量,并让它们充当 我们 的感知变量。 分解出方法对象是相 当激烈的改动,但与 引 入感知变量 (239 页)手法不同的是,前者的"感 知变量"同样也是产品代码要用到的变量。这就意味着你写出来的测试会-直可用。具体例子可 参见后面专门介绍这)手法的章节。 22.4 策略 本章介绍的技术能够帮助你将 巨型方法分解 , 从而利于后面的重构或特性添加。本节介绍的 一些原则会帮助你在做这项工作的时候在代码结构上作出权衡。 22.4.1 主干提取 如果摆在你面前的是一个条件语句,而你的任务是找出哪儿可 以提取出 个方法来,那么你 有两个选择: 一是将条件和分支一同提取出来, 二是分别提取。例如: if (marginalRate () > 2 && order . h a sLi m立 t 1)) ( 园 。 rder . r e adj 叫 ra teC叫 at o r . rat … oday()) ; order . r ecalculate () ; 如果你将代码中的条件和分支体分别提取到两个不同的方法中,那么后面想重组代码的逻辑 就会容易一些。 if (orderNeedsReca lcula tion(orde r )) r eca lcula teOrder(orde r , r ateCalcul ator ) ; 我将这一手法叫做主干提取 (Ske l e阳nize) ,因为你实质上是将代码的主干提取 出 来: 即控制 结构以及对其他方法的委托(调用)。 22.4.2 序列发现 假设你手里有一个条件语句,而你想要找到提取方法的地点。那么你有两个选择: 一 ,将条 件和分支体一 同提取出来: 二 ,分开来提取。下面是另一个例子: if (margi nalRate () > 2 && order .hasLimit ()) 第 22 幸妾修改一个巨型方法,却没法为它编写测试 245 order.readjust(rateCalculator.rateForToday()) i order.recalculate() i 如果你将条件和分支一同提取出来,好处就是容易从代码中识别出 一个操作序列 recalculateOrder(or der. rateCalculator); void recalculateOrder{Order order, RateCalculator rateCalculator) if (marginalRate( ) > 2 && order.hasLimit()) 。 rder.readjust(rateCalculator.rateForToday() ); 。 rder.recalculate(); 之所以这么说,是因为在这个长方法内的其他(除我们提取出来的这部分之外)代码可能只 是一系列的操作, 一个接一个:于是只要将这块条件代码也提取为单一 的操作(方法调用),就 能够对整个方法有一个更清晰的认识。 等一下,我是不是自相矛盾了。没错。实际上,我常常在主干提取跟序列发现这两者之间来 来回回。而且我打赌你也会。当我觉得某个控制结构在被澄清之后还需要被重构的话,就会采用 圃 主干提取。而另一方面,当我觉得呈现出代码中的序列结构能让代码变得更清晰的话,就会使用 序列发现。 面对项目列表式方法,我往往会使用序列发现,而锯齿状方法则是主干提取。然而到底选用 哪种策略其实还是取决于你在提取的时候对设计的洞察。 22.4.3 优先提取到当前类中 在从一个巨型方法中提取代码时,你可能会注意到其中有些代码块其实是应该属于其他类 的。对此-个很强的暗示就是你想给新方法起的名字。比如说你看到一块将要提取出来的代码, 然后很想用它用到的某个变量的名字来给这块代码命名,那么很可能这就意味着你提取出来的代 码应属于那个变量的类。比如下面这段代码: if (marginalRate() > 2 && order.hasLimit{)) { 。 rder.readjust(rateCalc~lator.rateForToday() ); 。 rder.recalculate() ; 看起来我们可以将这块代码叫做 recalculateOrdero 这是个不错的名字,但如果我们在名 字中用到了" order" 这个单词,那么或许这块代码应当转移到 Order类当中去,并起名叫做 recalculatec Order 已经有了一个名叫 recalculate 的方法,所以我们或许应该想想现在的 这个 recalculateß&Order上原有的那个到底有何区别,并将这一信息用在方法名上;或者我们 也可以重命名原来的那个 recalculate 。但不管怎样,看起来这块代码确实应该属于Order类。246 第二部分修改代码的技术 尽管将代码直接提取到另一个类中听起来很诱人,但实际上,别这么做。笨拙的名字可以先 用着。比如 recalcu lateOrder这个名字,笨重是笨重,但它让我们得以进行一些能够轻易撤销 的提取,从而试探我们的提取是否正确,是否能够继续往下提取。之后,当好的修改自然而然浮 现出来的时候,我们再将这些方法转移到别的类中去也不迟。而就目前来说,提取到当前类中能 够确保我们继续手头的工作,而且更不容易出错。 22.4.4 小块提取 我曾在上文提到这一手法,但这里我想再强调一遍 : 优先提取小块代码。虽说对于庞然大物 般的方法来说,提取一小块代码看起来是接蚁撼树,但假以时间,随着小块小块的代码不断被提 13061 取出来,你便会发现自己对该方法有了新的认识。比如,你可能会发现一个操作序列从代码结构 中清晰地浮现出来(原本被埋在一堆分支结构中),比如,你可能会发现一个更好的组织该方法 的方式。于是你可以朝着你所看到的方向前进。这样一种渐进式的方法比起一开始就想将巨型方 法大卸八块的做法要好得多。后者常常并不像它看上去那么容易:而且也不安全, 一不小心就会 忘记一些细节,而细节却是代码中必不可少的成分。 22.4.5 时刻准备重新提取 切一块蛋糕有很多种切法,同样,分解一个巨型方法也有很多办法。往往在做了一些提取之 后,你会发现还有能更容易地适应新特性的提取方式。对于这种情况,最佳做法有时便是撤销一 两个提取,并重新提取。这么做并不是说前面的提取都自做了,事实上它们带给了你一些非常重 13071 要的认识:对原有设计 以及更佳的改进方式的认识。第 23 章 降低修改的风膛 • 代码是→种奇怪的建筑材料。大多数能够用来做出东西的材料都会磨损或疲劳,如金属、木 材、塑料等,用久了便会坏掉。然而代码不同。你把一块代码丢在那儿,它怎么也不会坏,除非 有人去修改它(或者你的硬盘让宇宙射线给破坏了)。如果你使用 一台机器,那么用久了总会坏 的。但次次地运行一段代码却完全不会破坏它。 代码的这种性质给我们开发者带来了很大的负担。因为我们不仅是往软件(代码)里面引入 错误的人,而且实际上一不小心就会这么做。那么,修改代码的难易程度又如何呢?如果是指机 械地修改的话,答案是相当容易。每个人都可以打开编辑器,然后敲上一堆奇形怪状的代码。敲 首诗进去或许都能编译(见WWW. lOCCC.org上的模糊C代码大赛作品)。话说回来,在写代码时捅 出类子实在是太容易了。你有没有过这样的经历:在费尽周折抓住了 一个bug之后,却发现只是 因为当时不小心敲错了 一个键而导致的。比如在把书递给同事的时候书皮掉下来砸到了键盘。代 码真是相当脆弱的东西。 本章将会讨论一些帮助我们降低编码过程中的危险的方法。它们有些只是一些机械的方法, 问可 旧 ω| 有些则是心理方法,但关注它们是很重要的,尤其是当我们在遗留代码中解依赖时。 巴丑 23.1 超感编辑 你在编辑代码的时候都做些什么?我们的目标是什么?通常会有一个大的目标。我们想要添 力日→个特性或修正一个 bugo 知道目标是什么当然是件好事,但如何付诸行动呢? 这么想,我们现在坐在键盘前,每敲一次键盘会奋两种可能, 一种可能是我们的动作改变了 软件(代码)的行为,另 一种可能则是不改变。比如,往一段注释里面添加文字?不会改变行为。 往一段字符串里面添加文字?大多数时候会。除非该字符串位于一段不被调用到的代码中。但如 果之后我们又添加代码完成对包含该字符串的方法的调用呢,那是会改变行为的。所以严格来说, 就算只是敲敲空格键对代码做点格式化也算是某种意义上的重构。有时敲代码也是重构。不过, 修改一个表达式里面的数值不是重构,而是功能改变,分清这一点很重要。 这便是编程的有趣之处了,精确地了解我们的每一次敲击会带来什么影响。当然,这并非意 味着我们必须是万能的,而是说任何能够帮助我们了解(真正了解)我们敲入的字符会如何影响 系统行为的方法都能够帮助我们减少b吨。从这个意义上说,测试驱动开发(7 4页)是一 门强大248 第二部分修改代码的技术 的技术。只要你能将代码塞进测试用具并在一秒内运行完其测试,你就可以在任何必要的时候只 花上极短时间就了解到你的修改给系统带来了什么影响。 我相信不久(就算本书出版时还没有,我相信也不会太远)便会有人开发出这样一个 IDE: 它能够允许你指定一组测试在每次按键时都运行 . 这样一来反馈周期就几近于瞬时了. 我相信这样的 IDE迟早会有人开发出来的.因为这一需求看上去是如此的不可避免.目前 已经有 IDE能够在每次敲击时进行语法检查,并在发现代码中的错误时用加亮或下划线之类的 手段来提醒程序员.所以,很自然的"编辑时测试"就是下一步了 • • 和结对编程 样,测试也能够带来所谓的"超感"编程。但"超感"编程昕起来是不是挺费 神的? 1 没错,但任何事情过度了都是不行的。关键的一点是,这种"超感"编程并不令人沮丧, 它是一种流动状态,在这种状态下你能够隔绝外界一切影响,进入代码的世界,时刻感知它。实 际上"超感"编程是非常"提神"的。我个人的感觉是,如果我在编程的时候得不到任何反馈, 我就会感到非常疲劳:总是害怕自己是不是不小心犯下了什么破坏代码的错误。我需要在脑子里 记录和维护所有的状态,记住修改了什么和没有修改什么,并想着待会怎么才能说服自己所作的 回酬的确是当初计划的那些。 23.2 单一目标的编辑 我不清楚每个人对计算机行业的第一 印象是否都一样,但就我个人来说,我第一次想要当→ 个程序员肘,实在是被那些超级聪明的程序员的故事所迷住了。那些家伙能够将整个系统的状态 放在脑子里,在谈笑间就能写出漂亮的代码,并立即知晓某些修改是正确的还是错误的。我承认, 并非每个人都能像他们那样在脑子里记住那么多古怪的细节。我个人也只是在一定程度上能做 到。以前我曾经掌握了 c++语言里的许多晦涩的部分,而且有一 阵子我脑子里还记了许多关于 UML元模型的细节。直到有一天我发觉,作为一个程序员,记得关于UML的那么多细节其实根 本没用,而且简直有点可悲。 事实上,聪明也分很多种。在脑子里记住很多东西的这种聪明有时候是很有用的,但它并不 能帮助我们作出更好的决策。比如我自己吧,虽然现在的我对于所用语言的细节的掌握比以前要 少,但我觉得作为程序员我比以前要强了。判断力是一项关键的编程技能,如果我们非要试图表 现得像那些超级聪明的程序员那样的话,结果只会给自己带来麻烦。 以下场景曾经在你身上出现过吗:你在写代码,写着写着突然意识到"嗯……或许应该把 这块代码清理一下。"于是停下来开始重构,然而,你不由开始设想这块代码实际应该是什么样 子的,然后你就停住了。无论如何你正在做的这个特性还是要完成啊,所以你就回到刚才你编辑 代码的地方。你认为需要调用一个方法,于是跳转到那个方法的所在地,却发现你需要该方法做 一些其他事情,于是你又开始修改这个方法,把刚才的修改晾在那儿,这时你旁边的编程伙伴开 1 因为要时刻保持感知嘛. 译者注第 23 章 降低修改的风险 249 始冲你叫了"嘿!老兄!先把刚才的工作做完再来改这个吧 。 "于是你感觉自己像个拉磨的骤子 一样,而旁边的家伙偏偏还来添乱。 以上的确就是某些团队的现状.比如两个人结对编程,有了 一段有趣的编程体验,但其中有 四分之三的时间花在了修正前四分之一时间内破坏的代码上了 . 听起来很可怕是吗?没错,但有 时候这也是挺有趣的,你和你的伙伴得以从容不迫地枪下逃生 。 你们遇到了代码中的魔鬼并将其 杀死 。 你们是胜利者 。 问题是,是否值得?让我们来看一看另 一种方式。 你需要修改一个方法,并且已经将你的类弄进测试用具了,于是开始修改。但你不由开始想 了"我还需要修改一下那边一个方法"。于是你停下手头的修改,跳转到那个方法去了,后者看 [i] 起来一 团糟,所以你开始对它进行一点格式化,以便弄清楚它到底干了些什么。这时你的结对编 程伙伴发话了"你在干什么? "你回答道"哦,只是看看是否需要修改方法Xo" 于是他说"别, 还是同 一时间做一件事吧"他拿出 一张纸写下方法X的名字,放在电脑旁边,于是你回到原来 的代码继续未完的修改 .完成之后你运行了 一遍测试,发现全部通过 . 于是你再次跳到那个方法, 毫无疑问,你得对它作一些修改.首先你开始编写另一个测试 . 编了 一会程序之后,你运行编好 的测试,然后开始做集成 . 这时你和你的伙伴注意到桌子对面的另外两个程序员。其中一个正在 对着另 一个喊"嘿!老兄'先把刚才的工作做完再来改这个吧"他们已经在那个任务上耗了好 几个小时了,看起来筋疲力尽。如果时间可以倒流的话,他们会选择集成,并节省好几个小时 。 我在工作的时候时常用一句话来提醒自己"编程是关于同一时间只做一件事的艺术 J 如果 我是在和另 一个人结对编程,那么我会让我的伙伴监督我,在适当的时候提醒我"你在干嘛? " 如果我的答案包含了两件事情,那么我们就会在其中逃出 一件。同样,对我的伙伴我也会这么做。 坦臼的说,这种编程方式快多了。编程的时候一不小心就会掉入"贪心不足蛇吞象"的局面,结 果是不仅受到打击,而且落到只能通过尝试来让代码工作的境地,而不是胸有成竹。 23.3 签名保持 在编辑代码的时候有众多原因可能造成错误 。 比如打错字 、用错数据类型、用错变量……可 能性太多 了 . 尤其是重构,重构通常意味着极具侵入性的编辑.我们将代码复制来复和l 去,并建 立新类和新方法:从尺度上说这可比单单添加一两行代码大多了。 一般来说对付这种情形的手段是测试。 一旦测试在手,我们便能够捕获在修改代码的过程中 引入的许多错误 。然而可惜的是,对于许多系统而言 ,要想让它足够可测试,以便能够对其进一 步重构,就必须首先对它作一点重构。这种初始的重构(第 25 章列出的解依赖技术)注定要在没 有测试的情况下完成,而且它们必须得是很保守的重构。 在一开始使用这些技术的时候,我总是忍不住太贪心了 。当 需要提取某个方法的整个方法体 时,除了 复制粘贴之外我还做了其他清理工作 。例如,假设我要提取一个方法的方法体, 并让该 13121 方法成为静态的(暴露静态方法, 273页) ,如下所示 2 public void process (List orders , 回 250 第二部分修改代码的技术 int dailyTarget , double interestRate, int compensationPercent) /1 compli cated code here 如下进行提取,在过程中还顺带创建了一些辅助类: public void process(List orders, int dailyTarget , double interestRate, int compensationPercent) processOrders(new OrderBatch(orders) , new Compensat.ionTarget (dailyTarget I interestRate * 100 , compensationPercent)) ; 动机是好的。我是想在解依赖的过程中顺带改善设计,但事实并不像我想象的那样美好 。 我 在修改的过程中犯了 一些愚蠢的错误,而同时又没有任何测试能够帮我捕获它们,于是这些错误 常常过了很久才被发现。 在为了代码的可测试性而进行解依赖的过程中,你得格外小心。我的做法之→是尽可能地采 用签名保持手法。如果完全避免了签名的改动,就能够将方法的整个签名从一处剪切复制到另 处,并最小化引入错误的风险 。 在上面的例子 中,我本该这么做: public void proceSs(List orders , int dailyTarget , double interestRate, int compensationPercent) processOrder s(orders, dailyTarget, interestRate, compensationPercent) ; private static vo 工 d processOrders(List orders, int dailyTarget , double interestRate, int compensationPercent) 这样一来,只需花很少的精力就可以搞定新方法的参数。从根本上,只需如下几个步骤. (1) 将整个参数列表复制到剪切板中 z List orders , 工 nt da 工 lyTarget , douhle interestRate , int compensati onPercent (2) 声明新方法 :private void processOrders() } (3) 将刚才复制的参数列表粘贴到新方法这里: private void processOrders(List orders, int dailyTarget, double interestRate, 第 23 章降低修改的风险 251 int compensationPercent) (4) 调用新方法 : processOrders() ; (5) 仍然将复制的参数列表粘贴过来 processOrders (List orders, int dail yTarget, double interestRate, int compensationpercent) ; (6) 删除变量的类型: processOrders(orders, dailyTarget. interestRate, 。 ompensationPercent ) ; 一旦熟练了之后,这个过程就变得机械化,你也就对自己的修改越来越有信心了,从而在解 依赖时能够把精力 集中 在那些可能导致错误的顽固问题上 。 例如你的新方法是否隐藏住了基类中 某个同名 方法, 等等。 关于签名保持 , 还乎可另外一些场 景。 如,你可以利用该技术来声 明新方法,也可以用它来建 立一组成员变量 ( 具体细节见分解出方法对象手法 )0 13141 23.4 侬靠编译器 编译器的主要 目 的是将源代码转换成另 外一种形式,不过在静态类型 的语言 中,编译器还能 担当更多 的职责。 你可以利用 它 的类型检查机制来找出需要修改的地方 。 我把这种做法叫做依靠 编译器 ( Lean on the Compi1er) 。 以下是一个例 子: 假设在一个 C++程序 中 , 我们有一些全局变量 z d。 山 ble dome stic_exchange_rate; double foreign_exchange_rate; 与它们位于同一文件中的还有一些函数 , 后者使用了这些全局变量 。 我想将这些函数纳入测 试,于是使用了目录中列 出的封装全局引用 ( 268页 )技术。 为此我编写了 一个类来包住这两个变量,并声 明 了该类的一个对 象: class Exchange f 252 第二部分修改代码的技术 public } ; double domestic_exchange_rate; double fore 工 gn_exchange_rate; Exchange exchangei 然后编译代码,让编译器帮我找出所有引用了 domestic_exchange_rate 和 foreign_ exchange_rate 的地方,然后将这些地方统统改为通过 exchange对象来访问。下面是更改前后 的代码对比: total domestic_exchange_rate 舍 instrument_shares; 变成 z total exchange.domest ic_exchange_rate * instrument_shares; 该技术的最关键的一点就是让编译器帮助你找到需要修改的地点。注意,这并不代表你就不 需要思考该修改什么;而只是说在某些情况下可以让编译器帮你做搜集信息的工作。非常重要的 13151 一点是耍弄清什么是编译器能够帮你找到的,什么是它所不能的,这样才不至于陷入盲目的自信。 依靠编译器手法包含两步· (1)修改一处声明从而引发编译错误, (2) 转到编译出错的地点,修改。 在对代码结构进行修改时,可以采用依靠编译器手法,就像我们在封装全局引用例子中做的 那样。此外你还可以用它来发起类型修改。一个常见情形是将某个变量的类型从一个类改为一个 接口,并利用编译错误来发现哪些方法需要放在该接口上。 不过,这一手法也并非总是可行的。如果你的项目构建耗时很长,那么更实际的做法往往是 自己搜索那些需要修改的地方。第 7 章介绍了对付这一 问题的方法。不过话说回来,能依靠编译 器还是得依靠编译器,这是个有用的手法。只是要注意,盲目采用这一手法可能会引入一些微小 的错误。 比如当涉及到继承时,就得小心依靠编译器了,继承在这种场合下最容易带来问题,下面就 是一个例子。 假设我们有一个叫做ge tX ( )的实例方法,它位于一个Java类中。 public int getX () { return Xi 我们想要找到所杳调用它的地方。于是先将该方法注释掉。 /. public int getX () { return X; } ./ 然后重新编译。 猜怎么着 9 什么编译错误也没有。那这是不是就意味着 getX( )根本没有被任何地方调用第 23 章 降低修改的风险 253 呢?不一定。如果同样有一个getX( )被声明在了基类中,那么将现在这个(派生)类中的 getX( ) 注释掉只会让基类的那个暴露出来而已。成员变量也存在这个问题。 依靠编译器是一门强大的技术,但你得了解它的局限性在哪里;不了解的话就可能会遇到一 些严重的问题。 结对编程 很可能你已经昕说过结对编程 (Pair Programming) 这一概念了。如果在开发过程中运用了 极限编程 (XP) 的话,你很可能己经这么做了。很好,因为结对编程对于提高质量以及在团队 中传递知识都是很有好处的。 13161 如果你现在还未使用结对编程,我建议你试试。尤其建议当你在使用本书中描述的解依赖技 术时进行结对编程。 我们在编辑代码时很容易犯错误,并且自己还根本不知道已经破坏了代码。而多一双眼睛看 着当然是有好处的 。现实是,对付遗留代码就好比是动手术 ,而医生是从来不会一个人做手术的。 更多关于结对编程的介绍可参考 La旧ie Williams 和 Robert Kessler 的 Pair Programming Jlluminated (Addis四-Wesley 2002) ,并访问 www.pa叩吨ramming.como 13171 第 24 章 当你感到绝望时 对付遗留代码是件苦差事,这一点没什么好否认的。尽管各自情况不同,但有衡量工作价值 的方式是相同的(不管你是不是程序员):算算你能从中得到什么。对某些人来说也许是薪水 一这没什么不好意思的,毕竟人人都得生活。但我认为肯定还有其他什么原因让你成为 一个程序 员的。 有些幸运的读者也许是因为兴趣才加入这行业的。你怀着对计算机的迷恋开始编程,前面的 路充满无尽的可能,你可以通过编程实现各种各样很酷的东西。你学习并掌握很多知识,于是你 开始想"这似乎挺有意思的。如果我能够在这上面做得很好,或许我能把它当成一职业昵 1" 当然,并非每个人都是走的这条路,但就算不是这样的,也多多少少跟编程的乐趣有点关系。 如果你能够体会,并且你的同事们也能体会这种乐趣,那么所面对的是什么系统有什么类系呢? 即使是遗留系统也能在其中做出漂亮的东西出来。否则难道你想整天垂头丧气不成?那可没啥意 思。我们都不应该那样。 对付遗留系统的人们常常希望他们能去做全新的系统。从头开始构建一个系统固然有意思, 但坦白地说,全新的系统也有它们自己的问题。比如,我就不止一次看到如下场景 一个既有系 统,随着时间的推移,逐渐变得混乱,难以改动。每次修改都得花上很大的精力和时间,于是人 尸 1 叫们感到沮丧不堪。于是他们将队伍里面最好的程序员(有时候这些家伙才是问题的罪魁祸首。 出旦|放到一个新团队中,并交给他们任务:创建一个替代系统,要有更好的架构。一开始事情都还算 顺利。他们知道旧的架构问题在哪里,于是花了 一些时间做出一个新的设计。在他们做这些事情 的同时,其他开发者仍然奋战在老系统上。由于老系统仍在运行,因此他们会收到一些修正bug 的请求,间或还会需要添加新特性。业务人员冷静地审视这些新特性的请求,并决定是否要将它 们力u 入老系统中,或者客户能否等到新系统出来(提供这一特性)。由于许多时候客户都是等不 及的,所以不仅新系统要添加这特性,老系统同样也耍。这就意味着新系统开发团队面对的是 双重职责:他们要替换一个仍处于不断变化中的旧系统。儿个月过去了,事实越来越明显·他们 没法替换你正在维护的这个旧系统。压力还在增大。他们夜以继日地工作,甚至牺牲周末。然而 许多时候结果却是,公司发觉你在旧系统上做的工作才是最重要的,关于公司命脉和未来存亡。 看来新系统开发也不是那么好干的差事,不是么? 要想在对付遗留代码时保持积极向上的心态,关键是要找到动力。尽管有很多程序员的生活 圈子都较小,但工作时有→个良好的环境,有你所尊敬的并且知道如何快乐工作的人做同事仍然第 24 章 当你感到绝望时 255 是一个非常大的诱惑。比如,我的几个最好的朋友就是在工作中认识的。至今,当我在编程中遇 到一些有趣的问题或学到一些东西时还会跟他们交流。 另一个有用的做法就是跟社区中的广大程序员接触。在如今的网络时代,要想跟其他程序员 接触并从他们那儿学习或分享知识真是前所未有的容易。你可以订阅邮件列表、参加讨论会、利 用网络上可获取的一切资源、分享经验和技术,等等,并让自己时刻处在软件开发的最前沿 。 但就算项目组里面的所有成员都关心项目且希望能把事情做好,仍还是会有沮丧的情况发 生。比如有时候人们会觉得面对的代码基太大了 ,以致于他们觉得就算在上面做上十年也改进不 了百分之十。但这样就奋理由沮丧并放弃了吗?不。我就曾见过有的团队面对百万行遗留代码仍 然面不改色,每天迎接挑战并将其作为改进系统的契机乃至从中获得乐趣。我同样也见过一些团 队,有着相对好得多的代码基却仍然士气低迷.所以说态度很重要。 工作之余,自己练 练测试驱动开发,让自己仅仅为了兴趣而编一点程序。感受一下你自己 做的小项目跟实际工作中的大项目之间的差异。于是很可能,当你在工作中将项目置入快速测试 用具之下时,就会有与自己做小项目时同样的感觉了。 如果你的团队士气低迷,而且是由于代码质量太低而低迷的,那么有个办法可以一试一-从 项目中找出 一组最糟糕的类,将它们置入测试之下。 一旦你们作为一个团队共同克服了 一个最困 13201 难的问题,那么就会感觉一切都不过如此了 .这种情况我经历得太多了 。 一旦掌控住了代码基,你们便会开始在里面不断制造出 一块块良好的代码绿洲 .在其中工作 山2 川 便成了快乐的体验。‘ 匾翠噩噩噩 解依赖技术 第 25 章 解依赖技术第 25 章 解侬赖技术 本章介绍了 一系列的解依赖技术。当然这个列表并不全面,它包含了我与不同团队共事的过 程中,用于将类解依赖以使它们能被置于测试之下的技术。从技术上讲,这些是重构技术,因为 它们全都保持了代码的行为.但它们与业界目前所给出的那些重构技术不间,这些技术是要在没 有测试的情况下使用的,其目的是为了将测试安置到位.在大多数情况下,如果你小心按步骤行 事,那么出错的可能性是很小的 . 但这并不就意味着这些技术是完全安全的:错误仍可能发生, 因此你应该小心行事。在运用本章描述的重构技术之前,请先阅读第 23 章 . 该章所介绍的一些技 巧有助于你安全地运用这儿所讲的技术,从而将测试安置到位;而一旦测试到位了,仿也就可以 更放心地去做一些更为侵入性的改动了。 当然,这些技术并不能立竿见影地让设计变得更好。实际上,如果你有良好的设计感,这 里的某些技术甚至可能会令你退缩。但它们可以助你将方法、类以及类簇纳入测试,从而让你 的系统更具可维护性。 一旦代码被置入测试的保护之下,便可以使用测试支持的重构来改普你 的设计了. 日五1 本章提到的一些重构技术在 Martin Fowler 的著作《重构·改善既有代码的设计 p 1-'-1 (Addison-Wesley, 1999) 中也有描述.只是本章对它们的描述在步骤上有所不同,我将之适当 巴空j 剪裁以使得它们能够被安全地用在没有测试的情况下. 25.1 参数适配 在对方法作改动时常常会遇到一些令人头疼的依赖,这些依赖由方法的参数导致。比如有时 会发现难以创建所需的参数,又比如需要测试某方法对其某个参数的影响 . 许多时候我发现参数 的类型正是带来麻烦的根源.如果该类是我可以修改的,则可以使用接口提取来解开参数对该类 的依赖。在参数依赖问题上,接口提取 (285 页)往往是不二之选。 一般来说,我们都想通过一些简单的方法来解开那些妨碍我们安置测试的依赖,方法最好简 单到用的时候不会出错。而从这个意义上说接口提取有时并不是那么好的选择。比如参数类型的 抽象层次较低,或特定于某些实现技术的话,提取其接口就有可能达不到预期效果甚至根本就不 可行。第 25 章解依赖技术 259 当无法对一个参数的类型使用接口提取,或者当该参数难以"伪装"的时候,可采用参数 适自己手法. 下面是一个具体的例子: public class ARMDispa 巳 cher { public void populate(HttpServletRequest request) String [] values request . getParameterVa!ues(pageStateName) ; if (values ! = null && values.length > 0) { marketBindings.put(pageStateName + getDateStamp() , values [0]) ; 在该类中, populate方法接受一个 HttpServletRequest 参数。 HttpServletRequest 是 J2EE 中的 接口。就 populate 现 在的 这个样子 , 要想测试它的话就必须创建 一 个实现了 Ht tpServletReques . t 的类,而且该类须提供某种方法让 HttpServletRequest 能够在测试时 返回所需的参数值。 目前的 Java SDK文档显示HttpServletRequest 上声明了大约 23 个方法, 而且还不算派生自更高级接口内 的方法, 后者我们也需实现。要是能用接 口提取手法提取出一个 窄一些的、只含我们所需方法的接口就好了,只可惜我们无法从一个接 口提取出另一个接口。那 样便需要 H ttpServletRequest扩展我们提取出的那个接 口 ,然而我们又无法像这样修改一个 13261 标准接口。但所幸的是我们还有另 一个选择。 有 一些针对 J2EE 的仿对象 (mock 0均 ect)库 .若 下载 一 个,我们便可以为 HttpServ­ letRequest 创建一个仿对象并实现我们的测试 。 这么做可以节省不少时间:利用这种做法,我 们无需花时间手动" 伪造" 一个 servlel requesl类。 那么,这是不是说我们终于有 了解决方案 了呢? 未必。 在解依赖时我总是会尽量往前看,推测结果会如何 。然后确定自己能否忍受该结果.本例中 我们的产品代码基本也是一样,我们将要花上许多时间才能将 HttpServletRequest 这个API 接口保留在原位 。但有没有办法既能令代码看起来更好, 又能令解依赖更简单呢?实际上是有的 。 我们可以将参数类型外覆起来,从而完全解除对API接口的依赖。这么做之后的代码如下: public class ARMDispatcher public void populate( para皿eterSource Bource) String values source . ge 巳 ParameterForName(pageStatßName) ; if (value ! = nullJ { marketBindings.put(pageStateName + getDateStamp() , value) ; 回 ‘二 260 第二部分解依赖技术 我们对代码做了什么?嗯… ·我们引 入了一个新的接口一--ParameterSourceo 目前它上 面只有一个 ge tParameterForNarne方法 。 和 H tt pS ervl etRequ es t 的 g etParmeterValue 不 一样 , getParameterForName只返回一个串。之所以这样编写该方法是因为在当前的上下文中 我们只关心第一个参数 。 接口应传达职责而非实现细节 . 这样的接口令代码易于阅读和维护 . 以下是一个实现了 Pa rarneterSource 的"伪" 类。 可以将它用在我们的测试中: class FakeParameterSource implements ParameterSource { public String valuei public St ring getParameterForName(String name ) return valuei 而产品代码用的 ParameterSource实现则看起来像这样 class ServletParameterSource implement s ParameterSource { private HttpServletRequest request ; public ServletParameterSource(HttpServletRequest request) this . request request ; String getParameterValue(String name) String [] values request .getparameterValues(name) ; 工 f (values nul1 I I values . length < 1) return oul1 ; return values[Ol ; 从表面上看,这么做似乎仅仅是为了漂亮而漂亮,但遗留代码基中的一个普遍问题就是抽象 层次不够;系统中最重要的代码往往跟底层API调用精合在一起 。 我们当然已经看到了这会令测 试变得多难,但问题还不仅仅是测试.当代码中到处都是动辄包含大盘未被使用的方法的宽接口 时,代码便会变得难以理解。反之,若你能够创建窄一些并针对特定需求的接口,代码便会更好 地传达语义,而且其中也有了更佳的接缝 。 如果我们改用 P arameterS ource ,贝 Ij populate方法的逻辑就跟特定的参数源分离开来了 。 从而代码便不再依赖于特定的 J2EE接口。 参数适目己是一个违反签名保持(2 49 页)的例子 . 因此,用的时候请格外小心.第 25 章解依赖技术 261 有些时候参数适自己手法也可能会带来危险,比如你创建的简化接口 与原来参数的接 口 类型相 差太远 。 如果我们在修改的时候不小心,就可能会引入微小的 bug . 正如往常一样, 记住我们是 为了将测试安置到位而解开依赖 。 所以应该去做那些你更有信心的修改,而不是能导致最佳代码 结构的修改 。 一旦测试到位, 一切都好办了,那时你自然会得到最佳代码结构。例如,本例中我 们或许想要对 ParameterS ource作一点改动,从而使它的客户代码不用检查返回 null (详见空对 象模式. 94 页 ). 安全第一 . 一旦测试到位,你便可以史有信心地进行侵入性的改动了. 步骤 参数适自己手法的步骤如下: (1) 创建将被用于该方法的新接口,该接口越简单且能表达意图越好 。 但也要注意,该接口 不应导致需要对该方法的代码作大规模修改 。 (2) 为新接口创建一个用于产品代码的实现 . (3) 为新接口创建一个用于测试的"伪造"实现 。 (4) 编 写一个简单的测试用例,将伪对象传给该方法 。 (5) 对该方法作必要的修改以使其能使用新的参数 。 (6) 运行测试来确保你能使用伪对象来测试该方法 。 25.2 分解出方法对象 在许多应用中,长方法都是非常难对付的角色。但如果你能够实例化包含这种方法的类并将 其放入测试用具的话,往往就意味着下一步便可以开始编写测试了 。 有时候,为了让一个类能够 被独立地实例化,需要付出相当大的努力:甚至可能对于你要进行的修改来说显得有点得不偿失 了。如果你想要对付的方法规模较小,并且没有使用实例数据,那么可以使用暴露静态方法 (273 页)手法来将代码置入测试之下 。 另 一方面,倘若你的方法规模较大,或者使用了实例数据或其 他方法的话,则可以考虑使用分解出方法对象 (Break Out Method Object) 手法 。 简单说来,该 手法的核心理念就是将一个长方法移至一个新类中 。 后者的对象便被称为方法对象,因为它们只 含单个方法的代码。通常在运用了该手法之后你也就可以给新类编写测试了,这会比为旧方法编 写测试来得更容易一些 。 旧方法中的局部变量可以做成新类中的成员变量,这通常能令解依赖并 改普代码状况变得更容易 . 下面是一个 c++的例子(为简洁起见,大量的类和方法并没奋列出来), c lass GDIBrush { pub lic void draw ( ve ct。芷 & renderingRoots , ColorMatrix& colors , vec t or& selection) ; 因 回因 262 第二部分解依赖技术 private: void drawPoint (int x. int y , COLOR color) ; ) ; void GDIBrush : : draw(vector& renderingRoots , ColorMatrix& colors. vector& selectionl for(vector : : iterator it renderingRoots .begin(); it != renderingRoots .end(): ++it) { point p *it; drawPoint(p . x , p .y. colors[n) ); GDIBrush的 draw方法是一个长方法。我们没法轻易地为其编写测试,而且要想在测试用具 中创建GDIBrush 的实例也是很难的.因此,让我们来试试使用分解出方法对象技术将其移到一 个新类中看看。 第一步就是创建一个负责画图工作的新类。我们可以把它叫做 Renderero 创建该类之后, 再给它编写一个公有的构造函数。该构造函数的参数包括对原类的引用,以及原(旧)方法的所 在参数 。对于后者我们可以采用签名保持 (249 页〕手法: class Renderer public Renderer(GBIBrush *brush, vector& renderingRoots. ColorMatrix &colors, vect。艺 & selection); 创建了构造函数之后,我们便可以为它的每个参数设立一个成员变量,并初始化它们.为了 保持签名,这也是通过剪切/复制/粘贴来完成的。 class Renderer private GDIBrush *brush; vector& renderingRaots; Color皿atrix& colorsi vector& selection; public Renderer(GDIBrush 舍 b rush ,vector& renderingRoots , Col orMatrix& colors , vector& selection) 第 25 章 解依赖技术 263 brush(brush) , renderingRoots(renderingRoots) . co!ors(co!ors). selection(selection) () } ; 看到这里你可能会说" 怎么看上去我们要面对的状况跟原来一样呢?这个构造函数接受一 个GDIBru s h引用,而我们还是设法在测试用具中创建后者的对象!所以这番修改到底带给 了我 们什么好处呢? "稍安毋躁, 马上你就会发现情况完全不 同 了 。 !TIIJ 完成了构造函数之后,我们便可以接着往该类中添加另 一个方法了, 该方法负责原 draw() 方法的工作 。 同样可以将它起名为 draw 仆。 cla ss Renderer private GDIBrush *brush; vector& renderingRoots; ColorMatrix& colors ; vector& selection; publ i c Renderer (GDIBrush *brush , () vector& renderingRoots , ColorMatrix& colors, vector& selectionl brush(brush) , renderingRoots (renderingRoots) , colors (co!ors) , se lec 巳 ion ( sel ec tion ) void draw{); 现在我们将原来的 draw() 方法的方法体复制到 Rendere r 的 d r aw() 当 中,然后利用依靠编 译器技术告诉我们哪儿需要改动: void Renderer:: draw ( ) { for(vector : : iterator it renderingRoots . begin(); i t ! = renderingRoots . end() ; ++itl pOint p *ü ; drawPoint(p . x , p .y , colors[n)); 如果 Renderer上 的 draw () 用到了 GDIBru sh上的任何成员变量或方法的话,编译器就会替 我们找出 。 要让编译通过,我们可以简单地为 draw () 所依赖的那些成员变量分别引入一个获取 方法,并将它依赖的那 些方法设成公有的即可 。 本例 中 ,实际上 draw() 只依赖于一个叫做264 第二部分解依赖技术 drawPoint 的私有方法。因而我们只需把它改成公有的,便可以在 Renderer类 中直接访问 它了。 13321 OK. 现在我们可以在GDI Bru sh : : draw ( )中将任务委托给 Renderer 的 draw() 了· 回 void GDIBrush:ldraw(vector& renderingRoots , ColorMatrix &colors, vector& selection) Renderer rend~r 【 this , renderingRoots, colors, selection); renderer. draw(); 好,现在回到 Renderer 对 GDIBrush 的依赖问题上来 。若 我们 无法在测试用具中实例化 GDIBrush , 则仍可以使用接口提取 (285 页)来完全解除 Renderer对GD I Brush的依赖.关于接 口提取技术书中有详细介绍,简而言之,我们需建立一个空接口 , 然后让GD IBrush实现它.本例 中我们可以将该接口叫做 PointRenderer ,因为我们想要通过该接 口访问的GDIBrush方法其实只 是drawpointo 接下来,我们将Renderer 中持有的对GDIBrush的引用改为对 PointRenderer接 口的引用,再编译,然后让编译器告诉我们应该往接口上添加哪些方法。 最后的代码像这样= c lass PointRenderer public virtual void drawPoint(int x , int y , COLOR color) 0 ; class GDIBrush public PointRenderer public void drawPoint (int x , int y , COLQR color); ) ; class Renderer pr 工 vate PointRender *pointRendereri vector & renderingRootsi ColorHatrix& colors i vector& selection; public : Renderer(PointRenderer *renderer, vector& renderingRoots , ColorMatrix& colors , vector& selec 巳 ion) pointRenderer(pointRenderer) , renderingRoots(renderingRootsl , colors(colors) , selection(selectionl () • 第 25 章解依赖技术 265 vo id draw () ; void Rendere r :: draw ( ) { for (vec t or : : itera t or 工 t renderingRoots . begin () ; it != r enderingRoots. end () i ++it J ( point p *it i po 工 n tRende r er->drawPoint { p . x , p .y , colo r s(n ] ); 图 25-1 是UML图。 <' PolntRenderer + drawPoint(x : int, y : i 时,∞ lor :COLOR) 4 GDIBrush Renderer + draw(renderingAoots : vector&, + Renderer(PointRenderer .renderer, colors : ColorMatrix&, renderingRoots : vector&, selection : vector& j colors : ColorMatrix&, + drawPoinl帜.,时, Y:i时, color : COLOR) selection : vector&) + drawO 图 25-1 分解出方法对象之后的 GDIBrush J 这个结果看起来有点怪异。我们让一个类 (GDIBrush) 实现了一个新的接 口 (PointRenderer) , 然而该接口的唯一一个用户却是一个由该类创建出来的对象。你可能感觉不爽,因为我们为了实 现该技术,将原类 (GDIBrush) 中的某些原本是私有的东西给暴露(公有)出来了。比如GD I Brush 上的原本私有的 drawpoint方法现在就成了公有方法,完全暴露给了外界。但是别慌,因为这还 不是结局。 随着时间的推移,你会对没法将 GDIBrush放入测试用具感到越来越不满,于是就会开始尝 试对其进行解依赖。等你成功将其放入测试用具之后 , 就会开始思考其他设计方案。例如, PointRendere r 必须是接口吗?难道不能将它做成一个类并让它持有一个GDIBrush对象 1 如 果可以,那么或许你就可以基于这一新概念开始改进你的设计了。 以上还只是-4'简单的例子, 一旦GDIB rus h被测试覆盖,我们便可以做许许多多其他的事 情,所以最后的代码结构可能会大相径庭。 因266 第二部分解依赖技术 分解出方法对象技术有几个变种.最简单的如原方法不使用原类中任何实例成员的情况. 这种情,凡下我们元需传原类的引用给它. 另一些时候,目标方法只使用原类中的数据成员而不使用其方法.这时我们往往可以建立 一个新类,将被用到的数据成员放到该类中,然后传递该类的对象给分解出的方法对象. 本节中展示的情况其实是最糟的一种被分解出来的方法用到了原类上的方法 . 因此我们 用了接口提取,并在提取出的万法对象与原类之间建立起一定程度的抽象. 步骤 以下步骤用于在没有测试的情况下安全地分解出方法对象: (1)创建一个将包含目标方法的类 . (2) 为该类创建一个构造函数,并利用签名保持(249 页)手法来让它具有跟目标方法完全→ 样的参数列表。如果目标方法用到了原类中的成员变量或方法的话,再往该构造函数的参数列表 里面加上一个对原类的引用(添加为第一个参数 )0 (3) 对于构造函数参数列表里面的每个参数,创建一个相应的成员变量,类型分别与对应的 参数类型完全相同。这一步仍可以利用签名保持手法:将构造函数参数列表内的所有参数直接复 制到成员变量声明区段,并对格式作适当调整.在构造函数里面对刚才建立的所有成员变量赋值 或初始化. (4) 在新类中建立一个空的执行方法 .通常该方法可以叫做 runo 前面的例子中使用的是 draw () 。 (5) 将目标方法的方法体复制到刚才创建的执行方法中,然后编译,依靠编译器发现下一步 所要作的修改。 (6) 编译出错信息应当会告诉你该方法在哪儿使用了原类的方法或成员变量 。 作相应改动以 Iml 便令代码通过编译.通常可以通过改用原类的引用(指针〉来调用其成员方法来达到目的。或者 也有可能你需要将原类中的相应方法置为公有,如果是成员变量,或许还得为其引入获取方法函 数,以免将其直接暴露出来。 (7) 新类通过编译之后,回到原先的目标方法,对其进行修改,让它将工作全权委托给上面 创建出来的方法对象(只需创建新类的实例,然后调用其执行方法即可)。 13361 (8) 如果需要,使用接口提取 (285 页)来解开对原类的依赖 . 25.3 定义补全 有些语言允询你在一个地方声明类型然后在另 一个地方定义它。这一点体现得最明显的就是 在 c/c++中 。在c/c++ 中我们可以在一处地方声明一个函数/方法,然后在另 一处地方(通常是实 现文件中)定义它 。这一 能力可以用来帮助我们解依赖 。 下面就是一个例子: class CLateBindingDispatchDriver public CDispatchDriver 第 25 章解依赖技术 267 pub l 立 C CLateBindingDispatch~~iver (); virtual -CLateBindingDispatchD~.iver (); ROQTID GetROOTID (int id) const: v。工 d BindName (int id I OLECHAR FAR *name); private: CArray rootids; 以上是 一 个 C++ 类。用户创建 CLateBindingDispatchDrivers 的对象,然后调用其 BindN arne方法来将名字绑定到ID 。然而,我们希望在测试该类时能够以另一种方式来进行名字 绑定(而不是采用原来的 BindNarne 方法)。在 C++中这可以通过定义补全 CDefinition Completion) 技术来实现。 BindName 方法是被声明在该类的头文件中的,我们怎样才能在测试的时候替换掉 它的定义呢?这样,我们在测试文件中包含其头文件,然后给目标方法提供另一个定义,如下. #include ~LateBindingDispatchDriver . h" CLateplndlngDISPUdDHver CLateBlndlngDispatchDXlver(){} CLateBindingDispatchDriver: :-CLateBindingDispatchDriver() {} ROQTID GetROQTID (int id) const { return ROOTID(-l); } void BindNarne(int id, OLECHAR FAR *name) {} TEST(AddOrder , BOMTreeCtrl) { CLateBind 工 ngDispatchDriver driver; CBOMTreeCtrl ctrl(&driverl; ctrl.AddOrder(COrderFactory: :makeDefault()}; LONGS_EQUAL(l , ctrl.OrderCount()); 只需在测试中直接定义有关方法,我们便可以提供只用于测试的方法定义。我们可以给那些 我们在测试时并不关心的方法定义一个空的方法体,也可以定义可用于所有测试的感知方法。 在 CIC++中使用定义补会技术,这就意味着我们得为使用了定义补全的测试创建单独的可执 行文件了。因为如果不这么做的话,替换的定义就会 ß~ 原有的定义在连接期产生冲突。另 一个缺 点就是目标类中的方法现在有了两组定义, 一组位于测试源文件中,另一组位于产品代码源文件 中。这可能会给代码维护带来很大负担。而且如果你的调试环境没布设置妥当的话,甚至可能会 使调试器加载了错误的调试数据。因此,并不推荐使用该技术,除非你遇上了最糟糕的依赖情况。 而且即使如此,我也建议你只把该技术用在解开初始依赖上。一旦解除了初始依赖,你应该就能 回268 第二部分解依赖技术 快速地将类置入测试之下,这时重复的定义便可以删除了。 步骤 在C++ 中使用定义补全技术的步骤如下: (1)找出你想要对其成员函数实施定义替换的类。 (2) 确认该类的成员函数定义是在源文件而非头文件中。 (3) 将该头文件包含到待测试类的测试源文件中。 (4) 确保该类的源文件并不参与构建。 (5) 构建,找出没替换定义的成员函数。 巨型 (6) 往测试源文件中添加相应的成员函数定义,直到构建成功。 25.4 封装全局引用 在测试依赖于全局变量的代码时本质上有三个选择:想办法让它依赖的全局变量在测试期间具 有另一利 1 行为;利用连接器,连接到另一个全局变量定义;或将它封装起来,从而可以进一步进行 解祸。最后一个选择称作封装全局引用 C Encapsulate Global ReferencesJ 。下面是一个C++的例子: bool AGG230_activeframe(AGG230_SIZE]; bool AGG230_suspendedframe[AGG230_SIZE); vo 工 d AGGController : : suspend_frame() { fr缸ne_copy(AGG230_suspendedframe , AGG230_activeframe); clear(AGG230_activeframe) i flush_frame_buffers(); void AGGController: :flush_frame_buffers() { for (int n 0; n < AGG230_SIZE; ++n) { AGG230_ activeframe[n) false; AGG230• suspendedframe[n] false; 飞 以上代码用到了几个全局数组。 suspend_frarne 函数需要访问 AGG230_activeframe 和 AGG230_suspendedframe 这两个全局数组。初看起来似乎可以将这两个数组做成 AGGController类的成员变量,然而其实行不通,因为还有另外一些类(没有展示在代码中) 也要用到这两个数组。那怎么办呢? 你可能会立即想到:干嘛不使用参数化方法 001 页 J ,将它们作为参数传给 suspend_frame 函数呢。实际上这么做有一个问题:如果 suspend_frame调用了某个函数,而后者也使用了该全 局变量的话,我们就必须同样将该变量作为参数传递给它。如本例中的 flush_frame_buffer 。 另 一个选择是将两个全局数组传递给AGGControll er 的构造函数。这么做是可行的,但实第 25 章解依赖技术 269 际上还可以进一步检查一下这两个全局数组还在哪些地方被用到,如果发现它们每次都是被一起 用到的,则可以考虑把它们绑在起。 如果若干全局交量总是被一起使用一起修改,则它们应属同一个类. 应付这种状况的最佳办法就是建立一个"智能"的类(并给它想一个好名字)来持有这两个 13391 全局变量。有时候这并不像昕起来那么简单。我们需要考虑这些全局变量在设计中的意义以及它 们为什么在那里。如果为它们建立了 一个新类,那么我们迟早会往里面添加/转移方法的,而且 很可能这些方法的代码早已存在于某些使用这些变量的代码段中了。 命名一个类的时候,考虑最终会位于它里面的方法.当然我们应当给它起一个好名字,但 并不一定是完美的.另 1J 忘了,你总是可以重命名它的. 在上例中,我期望随着时间的推移, frarne_copy和 clear能被移至我们将要建立的新类中 。 那么,有哪些工作对于这两个全局变量来说是公共的呢? AGGController 的 suspend_frame 函数或许可以被移到新类中 , 只要后者包含那两个数组 (suspended_frame和 active_frame) 就行。我们可以管这个新类叫什么呢?可以叫 Frame ,并说明每个 Frarne包含一个活动 Cactive) 缓冲区和悬置 C suspended) 缓冲区。这一做法要求我们对系统中的概念作-些修改,并重命名几 个变量,但我们所得到的是一个智能的、隐藏了更多细节的类。 你想到的类名可能已经被用掉了.这时候可以考虑重命名那些使用了该名字的实体,从而 将该名字腾出来. 下面就是具体的步骤。 首先创建如下的类: class Frame public 1/ declare AGG230_SIZE as a constant enurn {AGG230_SIZE 256}; bool AGG230_activeframe[AGG230_SIZE) ; bool AGG230_suspendedframe[AGG230_SIZE); 我们故意保留了这两个数组原来的名字,这是为了简化后面的步骤。接下来声明 Frarne类的 →个全局对象· Frame frameForAGG230 i 接着,将两个全局数组原本的声明注释掉,并构建 z 11 bool AGG230_act 工 veframe[AGG230 _ SIZEJ; /1 bool AGG230_suspendedframe[AGG23 。一SIZE)i 因回 270 第二部分解依赖技术 这时,编译器会告诉我们 AGG activefrarne和 AGG230_suspendedframe不存在。如果你 的构建系统足够烂的话,它可能会徒劳地试图通过连接来寻找符号,结果给出长达 10页的连接错 误。但是别慌张,这些都是意料之中的。 要解决所有这些错误.我们可以顺着编译错误找到每一行出错代码,将里面对这两个全局数 组的引用加上 UframeForAGG230" 前缀,如下 2 void AGGController : : suspend_frame() { frame_copy( frameForAGG230.AGG230_suspendedframe, frameForAGG230 .AGG230_activeframel; clear( frameForAGG20.AGG230_activeframel ; flush_frame_buffer() ; 完成这些之后,你会发现代码更加丑陋了,但重要的是它能正确编译运行,所以说前面的步 骤是保持行为的。完成了这些工作之后,我们便可以通过AGGController类的构造函数来传递 Frame对象,并得到所需的分离了. 从引用一个简单的全局变量到引用一个类成员只是第一步.之后你还需要考虑是否应当使 用引入静态设置方法或参数化构造函数,又或者参数化方法 . 以上我们创建了一个新类,将两个全局变量添加到其中并设成公有的。为什么要这么做呢? 毕竟我们已经花了一些时间思考新类应该叫什么名字以及什么样的方法可以放到里面。我们本可 以创建一个伪 F rame对象,并在AGG_Controller 中将任务委托给它;然后我们可以把所有用到 了这两个变量的代码都移到一个真正的 Frame类中。没错,我们是可以这么做。但凡事不宜操之 过急。更何况手头还没有测试,而且我们要花尽量少的工作先将测试安置到位再说,这时候最好 能不碰就不去碰代码中的逻辑。我们应当尽量避免触动这些逻辑,并尝试往代码中引入接缝,以 便通过这些接缝来植入测试用的代码或数据。后面,当测试逐渐丰富的时候,便可以开始放心地 将行为从一个类转移到另一个类了。 一旦实现了将 Frame对象传递进 AGGController之后,我们便可以做一点小小的重命名, 让代码变得稍微清晰一些。以上重构之后的代码可能像这样: class Frame { public enum (BUFFER_SIZE 256}; bool activebuffer[BUFFER_S 工 ZE] ; bool suspendedbuffer[BUFFER_SIZE]; Frame frameForAGG230 ; void AGGController : :suspend_frame() { frame_copy(frame_suspendedbuffer , frame.activebufferl; clear(frame.activeframel; flush_frame_buffer() ; 第 25 幸解依赖技术 271 看起来改进不大,是不是?但实际上这是非常有价值的第一步。在将数据转移到一个类中之 后,我们便拥有了分离,并能够在接下来的工作中不紧不慢地将代码朝着良性的方向改进。我们 甚至看到 了创建一个 FrarneBuffer类的潜在可能性。 在使用封装全局引用手法时,从数据或小型方法开始着手.稍大一点的方法可以等测试到 位之后再移至新类中. 前面的例子展示了如何对全局数据作封装全局引用。实际上,不仅是全局数据,对于C++中 的非成员函数(也称自由函数),也可以做同样的事情。常常,当你面对的是CAPI时,会发现你 想要改进的代码里面随处可见对全局函数的调用,这时你唯一的接缝就是被调用函数的连接期接 缝。你可以使用连接替换 (296 页)手法来实现分离,但实际上还有更好的选择,如果你使用封 装全局引用来建立另 一种接缝的话,就能得到更佳的代码结构。下面就是一个例子· 在一块我们想要测试的代码中,有两个对全局函数的调用 GetOption{const string optionName) 和 SetOpt 工 on(string name, Option option) 。这两个函数是自由函数,没 有附着在任何类上,但代码中到处都用到了它们,比如下面这段: void ColumnModel: :update() ( alignRows{) ; Option resizeWidth : :GetOption("ResizeW 工 dth") ; if (resizeWidth.isTrue()) resize() ; else { resizeToDefault() ; 遇到这类情况,我们可以诉诸一些老技术,如参数化方法 001 页)和提取并重写获取方法 13421 (278 页),但如果这些调用跨越多个方法多个类,则使用封装全局引用就要干净一些了。做法如下, 首先创建一个新类: class OptionSource { public virtual -OptionSource () 0; virtual Option GetOption(const string& optionName) 0; virtual void SetOption(const string& optionName , const Option& newQption) 0; 该类包含了我们所需的每个自由函数的抽象成员版本。下一步,从OptionSource派生出 一 个伪类。比如说这儿我们可以让该伪类持有一个 vector或map ,内含测试期间用到的 Optlon对象。272 第二部分解依赖技术 我们可以给该伪类提供一个add成员函数 , 或者也可以直接让它的构造函数接受一个map ,哪种 方便就选哪种。完成伪类之后,再创建真正的 Op tionSource: class Produ ct工。 nOptionSource public OptionSource ( public } ; Option GetOpt ion(const string& optionName)i void SetOption(const string& optionName , const Op 巳 ion& newOption) Option ProductionOptionSource : : Ge 巳 Option( const string& opt 工 o nName) :GetOption(optionName) ; -void ProductionOptionSource : :SetOption( const string& optionName , const Qption& ne 飞ÑQption) SetOpt ion(0ptionName, newOption) ; 要封装对全局自由函数的引用,只需创建一个接口类,然后从它派生出伪类及产品类。产 品类中的代码什么都不用做,只需直接委托/调用相应的全局函数即可. 这一重构表现不错。我们引入了接缝,并最终将任务简单地委托给相应的全局API 函数完成。 之后我们便可以对目标类进行参数化,让它接受一个 QptionSou rc e 对象(通过指针或引用), 13431 然后我们便可以在测试时向它传递伪OptionSourc e对象,并在产品代码中传入真正的对象 。 在上例中,我们将函数放入类,并把它们做成虚的。但可不可以不这么做呢?可以。我们可 以建立一些自由函数,让它们委托给其他自由函数,或者将它们做成一个新类的静态函数,但这 两种方案都不能提供良好的接缝。根据它们的做法,我们就不得不使用连接期接缝 02页)或预 编译期接缝 (29 页)来替换函数实现了。然而,若是使用类1m 函数方案并辅以参数化类的话, 引入的接缝就既明显又易于掌控了 。 步骤 封装全局引用手法的步骤如下 z (1) 找出有待封装的全局变量/函数。 (2) 为它们创建一个类(你将通过该类来引用它们)。 (3) 将全局变量/函数复制到该类中。如果其中有些是变量,到I 忘了在类的构造函数中进行适 当的初始化 。 (4) 将全局变量/函数的原始声 明注释掉 。 (5) 声明新类的-个全局对象。第 25 幸解依赖技术 273 (6) 依靠编译器(2 51 页) 帮你找出所有用到了这些全局变量/函数的地方。 (7) 将所有对它们的引用加上刚建立的那个新类的全局对象为前缀。 。)在想要使用伪对象的地方,利用引入静态设直方法 (292 页)、参数化构造函数 (297页)、 参数化方法 001 页)或以获取方法替换全局引用 013页 )0 13441 25.5 暴露静态方法 对付那些没法在测试用具中实例化的类是件麻烦事。下面我就为你介绍一项我有时候会使用 的技术。假设你有一个方法,该方法不使用实例变量或其他方法,就可以将它设成静态的。而一 旦它成了静态的 , 你便无需实例化其类就可以将它置于测试之下了。下面是一个Java 的例子。 R SCWorkflow有一个叫做va l ida t e 的方法,现在我们需要添加一个新的验证条件。然而遗 憾的是,该方法所在的类很难实例化 。 这里就不把整个类列出来了,免得你头疼。仅列出需要修 改的方法. c lass RSCWorkflow public void va l idate (Packe t packet ) throws InvalidFlowException { i f (packet. getOrig inator () . equal s ( "MIA " ) II packet . getLength() > MAX_LENGTH I I ! packet . hasValidCheckSum () ) throw new InvalidFlowException (); 怎么才能将这个方法置于测试之下呢?仔细一看我们就会发现 .va l ida t e 方法用到了 Packet 上的许多方法。实际上,把val i date整个移到 p acket类上面倒的确是个不错的主意,但就目前来 说这么傲的风险还是大了点,比如首先我们就没法实施签名保持 (249页)。所以如果你没有方法转 移的自动化支持的话,通常最好还是先把测试安置到位再说。暴露静态方法 (Expose Static Me由od) 手法可以帮你做到这一 点。 一旦测试到位,就可以放心做所需的改动,并大胆改进代码了。 在没有测试的情况下解依赖时,尽可能对方法进行签名保持.对整个方法进行剪切/复制 可以降低引入错误的可能性 . va l idate 的代码并没有依赖于任何的实例变量或方法。所以,如果把validate设成公有静 态的会怎样呢?那样就可以在任何地方这样调用它 2 RSCWorkf low.validate(pa cket)i 很可能当有7RSCWorkflow的创建者根本没有想到会有这么 一天,它的validat e方法被做成 静态的,更不用说公有了。但这是不是说这么做就不对了呢?非也。封装对于类来说固然是件好 回274 第二部分解依赖技术 事,但类的静态部分其实并不属于该类。实际上,在某些语言中,它隶属于另一个类,有时候也 叫做元类 . 静态方法不会访问类的任何私有数据,它只是一个实用方法 。 如果把它设成公有的,就 可以编 写测试了.之后如果你想要将该方法转移到另 一个类中去,这些测试就会是你的强大 后盾。 实际上,静态方法和数据表现得就好像它们是属于另一个类的一样.比如静态数据的生命 周期是整个程序,而不是随着特定的实和l 生灭.此外静态成员元需实例便可以访问. 一个类的静态区段可以看作是"临时场池"用于存放不是十分隶属于该类的东西.如果 你看到某个方法没有使用任何实例数据,那么把它设成静态的是个好主意,这样可以让它变得 醒目,直到你弄清它应该属于哪个类 . 下面就是对 RSCWorkflow类提取静态方法之后的样子= public class RSCWorkflow { public void validate(Packet packet) throws InvalidFlowException ( validatePacket(packet); public static void validatePacket(Packet packet) throws InvalidFlowException { if (packet.getOriginator() "MIA" I I packet.getLength() <= MAX_LENGTH II packet.hasValidCheckSum()) throw new InvalidFlowException() i 在某些语言中其实还可以更简单一一只需直接把原来的方法设为静态的即可.如果该方法被 其他类用到了,则那些用它的地方仍然可以工作,也就是说可以通过实例来调用静态方法,如下· RSCWorkflow workflow 二 new RCSWorkflow()i // static call that looks like a non-static call 因 workflow.validatePacl叫 packet) i 不过在有些语言中这么做会招来编译警告。而如果没有编译警告当然是最好不过的了。 如果你担心之后又会有人来使用这个静态方法从而带来依赖问题,可以考虑使用非公有的访 问限制。比如在 Java幸口 C# 中有包内可见性或内部可见性,你可以用它们来限制别人对你的静态方 法的访问,或把它做成受保护的并通过一个测试基类来访问它.在 C++ 中也可以做类似的事情 z 可以把你的静态方法设为受保护的,或引入一个名字空间 .第 25 章解依赖、技术 275 步骤 暴露静态方法手法的步骤如下: (1)编 写一个测试,访问你打算设为公有静态的那个方法 。 (2) 将目标方法的方法体提取到一个静态方法中。记住实施签名保持(249 页)。给这个方法 起一个新的名字,看一看它的参数名,或许会有所启发。例如一个各叫 validate 的方法接受一 个 packet参数,那么就可 以提取出 一个叫做validatePacket 的静态方法。 (3) 编译 。 (4) 如果收到关于访 问实例变量或方法的编译错误,看一下那些被访问到的变量或方法,看 它们能否也能被设为静态的。如果可以,就将它们也一并设为静态的并通过编译。 [347[ 25.6 提取并重写调用 许多时候,在测试时遇到的依赖问题都是相当局部的。比如我们可能会遇到一个想要替换掉 的方法调用。于是若能解开对那个方法的依赖的话,就能够防止测试带来古怪的副作用,或可以 感知被传给该调用的值。 例如 - public class PageLayout { pri vat e int id 0 ; private List styl es i private StyleTemplate template; protected void rebindStyl es() styles StyleMaster . formStyles (template , id); PageLayout 调用 了 一个名叫 forrnStyles 的函数,后者位于个叫做 S tyleMaster 的类上。 该调用的返回值被赋给一个实例变量 : styles 。那么, 倘若我们想通过 forrnStyles来进行感知, 或者想解开对 StyleMaster 的依赖,该怎么办呢 9 有一个选择是,将该调用提取到一个新方法 中,并用一个测试子类来覆盖它。这一手法也被称为提取并重写调用 (Extract and Override Can) 。 提取之后的代码像这样: public class PageLayout { private int id 0 ; private List styles i private StyleTemplate templatei protected void rebindStyles() styles formStyles (te皿lplate , id); protected List formStyles(StyleTemplate template, int id) ( return StyleMaster.formStyles(template, id); 276 第二部分解依赖技术 因 一旦有了我们自己的 f orrnStyles方法,便可以通过重写它来解开依赖了。由于 styles 的值 和我们正在测试的东西没有任何关系,所以可以直接让重写的 formStyles返回一个空列表。 e t --、 al tp um 。 e 町 t 此丑四川山pi m se{ dT ne u-- 叫 xt est tsmh ued 飞 。 lt yys ati LSL emy qdra a。 X PEx gA n ←」 工 SMW +lvle 四 Ln Tdn ez stu set aee ltr c 。z ep --KHH U P 如果我们的测试需要各种各样的 styles ,则我们可以通过修改这个方法来配制所需的 styles .. 提取并重写调用是个非常有用的重构手法,我经常会用到它。如果你的目的是解开对全局 变量和静态方法的依赖,它是个理想的选择。 一般来说,如果对于同一个全局对象没有太多位 于不同地点的调用的话,我倾向于采用该手法:否则,我往往会采用以获取方法替换全局引用 的办法。 如果手头有自动重构工具,则该手法实施起来简直太容易了。你只需使用方法提取来对目标 方法进行提取即可。然而如果没有重构工具,则可以考虑如下步骤,遵循它们可以让你在 即使没 有测试的情况下也能安全地完成提取。 步骤 提取并重写调用手法的步骤如下: (1) 确定你想要提取的调用。找出它所调用的方法声明.复制其方法签名以便实施签名保持 (249页 ) 0 (2) 在当前类上创建一个新方法,用刚刚复制的方法签名来武装它。 13491 (3) 把对目标方法的调用复制到新方法中,然后在原来的地方改调用这个新方法。 25.7 提取并重写工厂方法 在你试图将一个类纳入测试时,可能会发现构造函数中的对象创建令你头疼不已。有时候你 并不想在测试的时候创建这些对象。还有些时候你或许只是想放置一个感知对象。然而现实是你 没法做到这一点,因为这些对象的创建被固定在构造函数体中了。 构造函数中固定了的初始化工作可能会给测试带来很大的麻烦.让我们来看一个具体的例子: public class WorkflowEngine { public WorkflowEngine () Reader reader new ModelReader( AppConfig.getDryConfiguration()); Persister persister 第 25 幸解依赖技术 277 new XMLStore ( AppConfiguration.getDryConfiguration() )i this.tm new TransactionManager(reader , persister); WorkflowEngine 的构造函数中创建了 一个 Transact 工。nManagero 如果这个对象是在其他 地方被创建的话,我们便能更容易地引入分离 。要实现这个条件,选择之一便是使用提取并重写 工厂方法 (Extract and Override F actory Method) 。 提取并重写工厂方法是个相当强大的手法,但它存在一些语言相关的问题.例如它在 C++ 里面是行不通的 .C++并不允许在构造函数中的虚函数调用被决议到派生类中去.而 Java及许 多其他语言则允许这一点.那么在 C++ 中怎么办呢?可以考虑用替换实例交量(3 17 页)和提 取并重写获取方法( 278 页)来代替,关于这个问题替换实例变量一节有示例及讨论. public class workflowEngine { public WorkflowEngine () this . tm makeTrans&ctionManager(); protected Transact!onManager makeTransactiOnManager() Reader reader new ModelReader ( AppConfiguration.getDryConfiguration()); Persister persister new XMLStore ( AppConfiguration.getDryConfiguration()); return new TransactionManager(reader. persister); 有了工厂方法之后,便可以将它子类化并重写了,我们可以在重写的工厂方法中返回我们想 要返回的东西: 国278 第三部分解依赖技术 public class TestWorkflowEngine extends WorkflowEng 工 ne { 步骤 protected TransactionManager makeTransactiOnManager() return new FakeTransactionManager()i 提取并重写工厂方法的步骤如下: (1) 找出构造函数中 的新建对象处。 (2) 将所有涉及新建该对象的代码通通转移到一个工厂方法中。 [illJ (3) 创建一个测试子类,重写刚才的那个工厂方法以避免测试期间对问题类型的依赖。 25.8 提取井重写获取方法 提取并重写工厂方法 (276页)在分离对于类型的依赖方面是个强大的手法,但它并不是万 能的。其最大的问题就是不适用于C忡。在C++里面你无法在基类构造函数中调用派生类的虚函 数。但所幸还是在解决办法的,如果你只在构造函数中创建新对象,并且并不用这个新对象做其 他任何事情的话,本节的手法是适用的。 该孚法的关键在于为你想要替换的成员变盘引入一个获取方法,以便可以通过该获取方法来 换入伪对象。引入了获取方法之后,将该类中所有使用该对象的地方改为通过获取方法来获取 。 这么 一来 ,你就可 以在派生类中通过子类化并重写该获取方法来换入测试用的对象了。 本例中, WorkflowEngine 的构造函数中创建的是一个 TransactionManager 。我们修改 的最终目标是要让该类能在产品环境下使用真正的 TransactionManager ,而在测试环挠下使 用测试用的 Tr ansactionM anager (比如一个只作感知用途的伪 TransactiOnManag er)o 代码一开始如下所示: 1/ WorkflowEngine . h class WorkflowEng 主 ne { private: TransactiOnManager *tm; public 例。 rkflowEngine (); 1/ WorkflowEngine.cpp WorkflowEngine: :workflowEngine() { Reader *reader new ModelReader( AppConfig .getDryConfiguration()) ; Persister *persister new XMLStore ( AppConfiguration.getDryConfiguration()) ; tm new TransactionManager{reader, persister) ; 最终的代码如下所示 · 1/ WorkflowEngine . h class WorkflowEngine { private: TransactiOnManager protected *tmi TransactionManager *getTransaction() const ; public WorkflowEngine (); // WorkflowEngine . cpp WorkflowEngine : :WorkflowEngine() tm (0) ( TransactionManager 舍 getTransactio nManager() const if (tm 0) { Reader *reader new ModelReader( AppConfig .g etDryConfigura 巳工。 n()) ; Persi ster *persister 第 25 章解依赖技术 279 new XMLStore ( AppConfiguration.getDryConfiguration() ); tm new TransactionManager (reader, persister) ; return tm; 我们所做的第一件事情就是引入一个迟求值的获取方法 ,该方法会在第一次被调用的时候创 建TransactionManager对象。然后我们将该类里面所有用到该对象的地方都改为通过调用这 个获取方法来获得它 。 回 因280 第二部分解依赖技术 一个 us求佳的获取方法在调用方看来 J~ 其他获取方法并没什么两样 . 但有一点关键的区 别,就是 us求值的获取方法在第一次被调用的时候才去创建被返回的对象.为此它们经常会包 含如下的逻辑(注意 thing是怎样被初始化的): Thing getThing () { if (thing nulll { thing new Thing ( ) i return thing; i丘求值的获取方法也用在单件模式中. 一旦有了这个获取方法,我们便可以对WorkflowEng 工 ne进行子类化,并重写该获取方法 , 以换入我们自己的测试用对象: class TestWorkflowEngine public WorkflowEngine { public TransactiOnManager *getTransactionManager() { return &transactionManager; FakeTransactionManager transactiOnManager; 使用提取并重写获取方法 (Extract and Override Getter) 手法时,你须得对对象的生命周 期格外小心,尤其是对于像C++这样的没有内建垃圾收集的语言.确保你释放测试用对象的方 式E在产品代码释放产品用对象的方式是一致的. 在测试中,只要我们需要,就可以很容易地访问伪的 TransactlonManager对象· TEST(transactionCount , WorkflowEngine) { auto-ptr engine(new TestWorkflowEngine) ; engine . run() ; LONGS_EQUAL(Q , engine . transactionManager.getTransactionCount() ); 提取并重写获取方法手法的缺点之一便是问题成员 变量可能在未被初始化之前就被不小心 13541 访问到。所以最好确保类里面的所有用到该成员变量的地方都是通过我们的获取方法来访问的。 其实我并不常用这一手法。如果我发现问题对象上只有唯一一个方法的话,使用提取并重写 调用 (275 页)会容易得 多. 但如果同一对象上有多个问题方法的话,提取并重写获取方法就是 更好的选择了。想想看,只需提取出一个获取方法然后重写它,问题就都解决了。这样的结局当 然是最好的。 步骤 提取并重写获取方法手法的步骤如下 ·第 25 章解依赖技术 281 (1) 找出需要为其引入获取方法的对象。 (2) 将创建该对象所需的所有逻辑都提取到一个获取方法中. (3) 将所有对该对象的使用都替换为通过该获取方法来获取,并在所有构造函数中将该对象 的引用初始化为 null (C++ 中则是指针初始化为 0) 。 (4) 在获取方法里面加入"首次调用时创建"功能,这样当成员引用为 null 时该获取方法就 会负责创建新对象。 (5) 子类化该类,重写这个获取方法并在其中提供你自己的测试用对象。 13551 25.9 实现提取 接口提取 (285 页)是个有用且用起来既方便又顺手的技术,但它也有其困难之处:命名。 我常常遇到这样的情况:我想要提取出一个接口,然而却发现我想给它取的名字已经被当前类给 占用了.这时如果我的 IDE支持重命名类或接口提取的话,情况就会很简单。但如果不幸不支待, 那么就只能退而求其次了, 一般来说有如下几个选择: 日起一个愚蠢的名字。 口看看你想要放到接口中的方法是不是全都是当前类上的公有方法,是的话或许能从它们 的名字中得到启发从而给你的接口想出个名字来。 通常我不赞成往当前类的名字前草草加上一个 "1" 前缀就了事的做法,除非这种做法已经 是你的代码基里面的命名惯例。想想看 z 一个代码基里面差不多一半的名字是带 "1" 前缀的而 另 一半则不带,而且你对这块代码又不戴卜一真是没比这更糟糕的事情了.当你使用 一个类型的 名字时,有一半的几率会用错。要么没加 "1" ,要么加了。 命名是设计的关键部分.好的名字有助于人们理解系统,并令系统灵易对付.反之,糟糕 的名字则会影响理解,并给你身后的程序员带来元尽烦恼. 如果一个类的名字恰恰适合用来作你的接口名,而且你手头又没有自动重构工具的话,可以 使用实现提取 (Extract Imp1emeter) 手法来获得所需的分离。提取一个类的实现时,只需从它派 生出一个新类,并将其中的所有具体方法都塞到这个派生类中,也就是说把它架空。 下面是一个C++的例子· /1 ModelNode.h class ModelNode { prl.vate list ID_interiorNodeSi list ffi_exteriorNodeSi double ffi_weight; void createSpanningLinks() i public: void addExteriorNode(ModelNode *newNodel; 282 第三部分解依赖技术 因 } ; void addlnternalNode(ModelNode *newNode) i void colorize() ; 第一步 是将 ModelNode 类的 声 明 复 制到另一个 头文 件中并将该剧 本 的 名字改 为 Product 工 onModelN。仇。 下面便是复制出 来 的类的-部分 : // ProductionModelNode . h class ProductionModeNode priva te list m_interiorNOdes i list m_exteriorNOdes i double m we 主 ght; void createSpanningLinks() ; public void addExteriorNode(ModelNode *newNode) ; void addlnternalNode(ModelNode *newNode) i void colorize(); 接着,回到ModelNode 的头文件中,删持Mod e lNode里面的所有非公有成员变量/函数的声 明 .然后将所有剩下来的成员函数设为 纯虚( 抽象)的 : 1/ ModelNode .h cla s s ModelNode pub l ic: virtual void addExteriorNode(ModelNode 会 newNode ) 0 ; virtual void addlnternalNode(ModelNode *newNode) 0 ; virtual void colorize () 0 ; 这时候Mode l Node就成了 一个纯粹 的接口类 。 它只包含抽象方法 。 由于是在C++里面,因此 别忘了加上纯虚析构函数,并在一个实现文件中定义它 / I ModelNode . h class ModelNode { public virtual -ModelNode () 0 ; virtual void addEx teriorNode(ModelNode *newNode) 0 ; v irtual void addlnternalNode(ModelNode *newNode) 0 ; v irtual void colorize () Oi 1 这是因 为 派生类的析构函数且是要调用基类的析构函数的, 不管基类是不是抽盈类. 译者注第 25 幸解依赖技术 283 1/ ModelNode . cpp ModelNode : : -ModelNode() 固 () 现在,再回到 productionM o delNode类 的头文件中,让它继承H。但 lNode这个抽象类: 路 include -ModelNode. h- class ProductionModelNode public ModelNode { private l ist m_interiorNodes ; list ffi_exteriorNOdes; double m_weight; void createSpanningLinks() ; public : void addExteriorNode(Mode lNode *newNodel ; void addlnternalNode(Mode lNode *newNodel ; void colorize() ; 如此一来, productionModelNode应当就能清清爽爽地通过编译了。但现在如果编译系 统的其余部分,你就会发现那些试图创建ModelNodes对象的代码现在不能通过编译了 . 于是 你可以将它们修改为创建 P roductionModelNodes对象 。 好,到目前为止我们的重构只是将 代码中创建某具体类的对象的地方改为创建另 一个具体类的对象,这对系统中的依赖情形并没 有任何改善。然而,由于有了接口类ModelNodes 的存在,现在我们便可以考察所有那些创建 productionModelNodes对象的地方,看看是否可以适当运用工厂方法来进一步减少依赖了。 25.9.1 步骤 实现提取的步骤如下: (1)将目标类的声明复制一份,给复制的类起一个新名字。这里最好建立一个命名习惯。比 如我通常使用的就是添加 "Production" 前缀以表明这是一个产品实现。 (2) 将目标类变成一个接口,这一步通过删除所有非公有方法和数据成员来实现, (3) 将所有剩下来的公有方法设为抽象方法 . 如果你的语言是C忡,则还需确保你设成抽象 的那些方法都是被虚方法重写的。 13581 (4) 删除该接口类文件当中的不必要的 import 或 include , 往往有许多都可以删掉 。 可以依靠 编译器 (251 页)来做这一步·挨个删除 irnp ortl include. 看编译出不出错,出错了就添回去,否 则就删掉。 (5) 让你的产品类实现该接口。 (6) 编译你的产品类以确保新接口中的所有方法都被实现了 。 (7) 编译系统的其余部分,找出那些创建原类的对象的地方,将它们修改为创建新的产品类 的对象 。284 第三部分解依赖技术 (8) 重编译并测试 25.9.2 一个更复杂的例子 如果目标类没有任何基类或派生类,则实现提取用起来还是相对简单的: 否则就需要聪明 ­ 点了。图 25.2再次展示了 ModelNode ,不过这次是用 Java写的,而且有一个基类和一个派生类: LlnkageNode 图 25-2 具有基类和派生类的ModelNode 在这个设计中, N。但、 ModelNode 以及 LinkageNode都是具体类 o ModelNode使用了 Node 13591 中的受保护方法。此外它自己也提供了供它的派生类 LinkageNode使用的方法 。实现提取需要 一个能被转换为接口的具体类。并且在完成提取之后你会得到一个接口和一个具体类。 因 那么,遇到这种情况我们该怎么办呢?我们可以对 N。但 类作实现提取, 并使 ProductionNode 派生自 Node 。此外还要修改继承关系, 让ModelNode继承 ProductionNode 而不是Nodeo 图 25-3 展示了修改后 的设计. LlnkageNode 图 25-3 在对Node作实现提取之后第 25 章解依赖技术 285 下一步,对ModelNode作实现提取。由于ModelNode 己经有了 一个派生类,因此我们可以 往 ModelNode 和 LinkageNode 之间引入一个 ProductionModelNc 切。之后我们便可以让 ModelNode接口扩展Node接口了,如图 25-4所示. "interface铺 ModelNode + addExteriorNode(Node) + add lnteriorNode(Node) -+ colorize() linageNode 图 25-4 对ModelNode实现提取 如果你发现自己将一个类像上面这样嵌入了继承体系,那么我建议你真的需要考虑一下是否 应当改用接口提取,并给你的接口选择其他名字了。接口提取比实现提取要直接得多。 回 25.10 接口提取 在许多语言中接口提取 (Extract Interface) 都是最安全的解依赖技术之一。如果某步出错了, 编译器就会立即告诉你,所以说用接口提取的时候,极少可能引入bugo 接口提取的关键在于为 你的类创建一个接口,该接口包含你想要在某些上下文中使用的所有方法。完成接口之后,就可 以让你的类实现它,从而通过该接口来感知或分离(比如传递一个伪对象给你想要测试的类)。 接口提取有三种方式,以及一些注意点。第一种方式是利用现成的自动重构工具(如果你的 IDE支持的话)。支持接口提取的工具往往会允许你选中一个类上的某些方法然后键入新接口的名 字。更好一些的则会询问你是否需要它帮你将代码中的某些地方自动改为使用新接口。这些工具 会帮你节省可观的工作量。 如果没有相应的工具,则可以采用第二种方式:即采用本节所讲的方法和步骤, 一步一步地 提取。 接口提取的第三种方式就是一次性从类垦面剪切/复制出多个方法,然后将它们放到一个新 的接口中。这种做法虽然没布前两种做法安全,但仍然还算是相当不错的:而且如果没有重构工 具支持并且构建耗时很长的话,该方式往往是唯一的选择。 下面我们重点描述第二种方式。在讨论的过程中会提及一些需要注意的地方。 我们需要提取 一 个接口来将 PaydayTransaction 类置于测试之下。图 25-5 展示了286 第二部分解依赖技术 13621 PaydayTransaction类的UML图,以及它所依赖的一个叫做TransactionLog 的类• PaydayTransaction + PaydayTra n国 ction(databa国 Payro l1 0atabase , I吨币 ansactionL,呻 ) + run{) • τ'ransactlonLog + saveTransaction(transactionτ,.n臼 ction) + recordError(code : int) 图 25-5 PaydayTransaction 依赖于TRansactionLog 我们的测试用例如下 void testPayday() { Transaction t new PaydayTransaction (getTestingDatabase () ) ; t . run (); assertEqua!s(getSampleCheck(12) , getTestingDatabase() . findCheck(12)) ; 要想让上面的测试用例通过编译,还需一个 transactionLog才行.那么就让我们假设一个 FakeTransactionLog 已经写好了,看看怎么用它. void testPayday() { FakeTransactionLog aLog new FakeTransactionLog () ; Transaction t new PaydayTransaction ( t. run() ; getTestingDatabase() , aL09) i assertEquals(getSampleCheck{12) , getTestingDatabase() . findCheck(12)) ; 要想让上面的代码通过编译,必须为 TransactionLog类提取一个接 口, 然后从该接口派生 出 - 个 FakeTransacti。此og. 最后修改一下 PaydayTransaction ,让 它能够接受 Fake 13631 TransactionLog 为参数. 但首要的是接口提取 。 为此我们创建一个新的空类,叫做TransactìonRecorder 。如果你 想知道这个名字是哪来的,看一看下面的注记。第 25 章解依赖技术 287 接口命名 接口,作为一种语言结构,算是比较新的事物. Jav a>Ä 及许多 NET语言都具备这一语言特 性.在c++ 中你得通过创建一个只含纯虚函数的类来模拟它. 接口这个概念最初被引入语言的时候,有些人发明了用它们所来自的类的类名加上 "1" 前级来命名的办法.例如,假设你有一个叫做Account 的类,并且想要一个接口,于是就可以 给这个接口起名叫 IAccounL 这种命名方式的好处就是不用动脑筋,只要加个前级就行了. 但也有它的坏处,那就是你最终会发现许多代码都不知道对付的究竟是一个接口还是一个类. 当然,理想情况下你的代码应该元需关心这些.同样,最后你的代码基中将既有带 "I" 前级 的名字又有不带"1"前级的名字.如果你想将一个带"I"前级的接口做成一个普通类的话就 会引发大规A莫的改动.而如果不改的话,那个名字又会作为一个微小的谎言躺在代码中. 在编写新类时,最简单的事情莫过于起个简单的类名了,即便是对于大型的抽象也是如此. 例如,如果我们正在编写一个account 包,则我们可以从一个就叫 Ac count 的类开始.然后开 始编写测试来添加新特性 . 随着系统的增长,迟早你会想要把Account做成一个接口.这时可 以在 Account 下创建一个派生类,并将 Account 中的所有数据和方法都转移到它里面,将 Account 架空成一个接口.这么做的好处是你无需将代码中所有引用 Account 的地方修改成引 用新的接口(因为 Account 本身就是那个接口 l. 在像 PaydayTransaction 这样的例于中,我们已经有了一个不错的候选名字 (TransactionLog ),这时同样可以采用上面的办法.但缺点是将所有数据成员和方法统统转 移到另一个类中需要不少步骤.不过只要风险足够小,我还是会时不时用这招的.这其实就是 所谓的实现提取. 如果我觉得缺少一些测试,于是想通过提取出一个接口好让灵乡的测试能够安直到位的 话,常常会给这个接口起一个新名字.有时候想这么个名字也不是件易事.如采你手头缺少能 够自动重命名类的工具的话,最好在使用该接口的代码还没有大量出现之前给它起个稳定的 名字. interface TransactionRecorder 现在,我们 回过头去,让TransactionLog实现以上接口。 public class TransactionLog implements TransactionRecorder { 下一步,创建一个空的 FakeTransact 工。nLog类: public class FakeTransactionLog implements TransactionRecorder 这几步之后,所有代码应当仍然完全能够通过编译,因为我们所做的只不过是引入了几个新 回固 288 第二部分解依赖技术 类 ,并让一个既有类实现了 一个空 的接口 。 但后面就要进行真正的重构了 。首先我们将想要使用 接 口的地方改为对接口的 引用 。 比如 paydayTransact i on ~本使用的是一个 Tr q. nsactionLog ; 现在我们需将其改为引用 Transact i onRecordero 然后我们编译代码 ,并从编译错误中 发现许 多地方调用了 TransactionRecorder上的方法 ,我们将相应方法加圭 JJ TransactionRecorder 接口中来解决这些编译错误 ,问时也将该方法的一个空 的实现加到 FakeTransactionLog 中。 下面是示例代码: public class PaydayTransaction extends Transaction ( public PaydayTransaction (PayrollDatabase db, TransactionRecorder 10g) super (db, 10g) ; public void run () ( for(Iterator it db. getEmployees() ; it .hasNext() ; ) Employee e (Emp loyee) i t . next ( ) ; if (e. isPayday (date) ) ( e.pay ( ) ; log. saveTransaction (thisl ; 本 例中, T日n s actionRecorder 上 唯 一被调用的 就 是 s aveTran sact 工。n方法。 而 由于 Transact 工 onReco r der接口现在还是空 的,所以会遇到编译错误 。 但我们只需将这个方法添加 到 TransactionRe c order上便可以了, 同 时也别忘 了往 FakeTransactionLog上添加一个空 的 实现: interface TransactionRecorder void saveTr ansaction (Transaction transact ion) ; -public class FakeTransactionLog implements TransactionRecorder { void saveTransaction(Transaction transaction) } 任务完成!现在我们无需在测试的时候创建真正的 TransactionLog对象 了 . 你可能会说"不是吧,我们还没有往接口以及伪类上添加 recordError方法呢" 没错, TransactionLog上的确有这个方法 。 如果需要提取出 Tran s actionLog 的整个接口,我们或许 的确会把 r e cordError放到它上面,但实际情况是 ,我们的测试并不需要这个方法. 尽管 ,把 一个类的所有公有方法都放到接口上是个不错的做法,但如果顺着这条路走下去,就有可能要做 许多不必要的工作才能将代码最终纳入测试了 。 如果你觉得代码的设计走向的确要求接口拥有相第 25 章解依赖技术 289 应类上的所有公有方法的话,不妨考虑递增式地扩充该接口.许多时候,在得到足够的测试覆盖 之前,最好避免大规模的改动。 提取接口时并不一定要提取类上的所有公有方法.你可以依靠编译器来帮你发现哪些方法 需要加到接口上去. 该手法唯一的困难之处在于当面对非虚方法的时候。这里所谓的非虚方法在 Java里面可能是 静态方法,而在 C#或C++里面则可能是非虚成员函数。对于这类情况请参考下文的附注。 步骤 接口提取的步骤如下: (1) 创建一个新接口,给它起一个好名字。暂时不要往里面添加任何方法。 (2) 令你提取接口的目标类实现该接口。这一步不会破坏任何东西,因为接口上还没有任何 方法。但你也可以编译确认一下。 (3) 将你想要使用伪对象的地方从引用原类改为引用你新建的接口。 (4) 编译系统。如果编译器汇报接口上缺少某某方法,则添加相应的方法(同时也往伪类上 面添加一个空的实现),直到编译通过。 13661 接口提取与非虚函蚊 如果你的代码当中有像 "bondRegistry.newFixedYield(client)" 这样的调用,则 对于许多语言而言,光看这行调用是没法分辨出 newFixedYield到底是静态方法、非虚实例 方法,还是虚实例方法的.在允许非虚实例方法的语言中,如果提取接口并将目标类里面的某 个非虚方法添加到其上的话,你就遇到麻烦了.一般而言,如果你的类没有派生类,则可以将 目标方法设为盛的,并照常进行接口提取.一切都没问题.但如果你的类有派生类,那么把它 的非虚函数设成盛的并放到接口中就会破坏玩有代码的行为了 . 下面就是一个 c++ 的例于­ BondRegistry有一个非虚方法 class BondRegistry { public Bond *newFixedYield(Client *clientl { ... } ) ; 而且BondRegistry有一个派生类,该类里面有一个同名同签名的方法 class PremiumRegistry public BondRegistry { public: Bond 舍 newFixedYield(Client *clientl { ... } 现在,假设我们从BondRegistry提取出一个接口 class Bondprovider 290 第二部分解依赖技术 public virtual Bond 舍 newFixedvield (Client *clientl 0 ; ) ; 并令BondRegistry 实现它· 固 class Bon曲 egi 叫 public B叫 现在考虑如下的代码: voi d disperse(BondRegistry *registry) { Bond *bond registry ->new FixedYield ( exlst 工 ngClient) ; 如果我们传递给也 sperse一个 PremiumRegistry对象,那么,根据原先的 BondR egistry 类定义,被调用的应该是非虚的那个 BondRegistry : : newFixedYield , 因为对于非虚方法 调用采用的是静态决议.然而经我们提取接口之后,被调用的就成了 Premi umRegistry 的 newFixedYieldG 在 C++ 中,只要基类中的方法是虚的,那么派生类中的相应方法,不管有 没有加 virtua l ,都会自动变成虚的.值得在意的是,在 Java或 C# 中并没有这个问题 . Java 中所 有的实例方法都是虚的.而 C# 中的情况则要安全一点,因为添加一个接口并不会影响到目前 对非虚方法的调用 . 一般而言, C++ 中,在派生类中创建一个与基类中某个非虚方法同名且同签名的方法是不 好的做法,因为这样做可能会带来误解.如果你真的需妥通过一个接口来访问某个类上的非虚 方法,并且这个类有派生类的话,最好的做法就是添加一个新的虚方法.后者可以委托调用一 个非虚甚至静态的方法.而你只要确保该方法对于你提取接口的那个类的所有派生类都做了正 13681 确的事情即可. 25.11 引入实例委托 人们会因为各种各样的原因使用静态方法.其中最常见的原因便是为了实现单件模式 ( 293 页〕。而另 一个常见的原因是使用静态方法来创建实用类。 实用类在许多设计中都是一眼就能看 出来的 .它们一般没有任何实例变量和实例方法。而是 全部由静态方法和静态常量组成。 同样,人们也会 出于各种各样的原因而创建实用类。大多数时候是因为无法为一组方法寻找 出合适的公共抽象。比如 JDK中的 Math类. Math 内有计算三角函数的静态方法 (cos , sin, tan), 但除此之外还有许多其他方法。当语言设计者从对象开始一路往下构建他们的语言时,应确保基 本的数值对象知道如何进行这些操作。如,你应 当 能够对(l l" 这个对象调用 s in ( )方法,并获 得正确的正弦值 。在本书写作的时候, Java 尚不支持在基本类型上调用数学方法,因此实用类是 个正当的解决方案,但它同时也是个特例 。在几乎所有的情况下,你也可以使用传统的带有实例 数据和方法的类来完成工作.第 25 章解依赖、技术 291 如果你的项目中有静态方法,则很可能它们并不会给你带来麻烦,除非其中包含了 一些你没 法或不想在测试的时候依赖的东西。(这个的技术术语叫做"静态粘着 " 0 )遇到这类情况,你可 能会想"唉,要是有对象接缝 05页)就好了,那样的话就可以利用它来嵌入测试用的行为了 。 " 那么 , 到底怎么办呢? 方案之一便是往目标类上引入委托实例方法。 这时候,你需要想办法将对那些静态方法的调 用 替换为对目标类的实例方法 的调用 。 例如: public class BankingServices { publ i c sta t ic void updateAccountBalance (i nt u s erID, Money arnoun 巳 I ( BankingServices 仅包含静态方法 。 这里为了简化起见只 写 出了 一个 。 我们可以往它上面 添加一个实例方法(如下) ,并让后者委托那个静态方法· public class Bank ingServices ( public static v。主 d updateAccountBalance(int userID, Money amount) public void updateBalance{int userI 口 , Money amount) updateAccountBalance(userID , amount ); 我们添加的那个 实 例方法叫 updateBalance ,它只是简单地将调用转发 至 静态方法 u p dateAccountBalanceo 这么 一来,我们便可以将对upda t eAccountBalance 的调用: public class SomeClass { public void someMethod() BankingServices.updateAccountBalance(id, su皿 I ; 替换为: public class SomeC!ass { public void someMethod(BankingServices services) services.updateBalance{id, sum); 回292 第三部分解依赖、技术 注意,只有当我们有办法在外部创建一个BankingServices对象从而能通过该对象来调用 someMethod时 ,该做法才能成功 。这就意味着我们需要做一些额外的重构,不过,在静态类型 的语言里,可以依靠编译器 (251 页)来帮我们将对象放置到位。 对于许多静态方法来说,该技术还算是相当直观的。然而当遇到实用类的时候,你可能就 会感觉不自然了。一个具有 5 到 10 个静态方法,却只有一两个实例方法的类看起来的确挺怪异 的。而如果这仅有的两个实例方法还只是一层空壳,其功能只是将任务简单地转发给相应的静 态方法,那就更加怪异了。但好处是,该技术引入了对象接缝,后者使你能够在测试时替换进 另 一种行为。随着时间的推移,你可能就会发现每个对该类的方法的调用都通过实例方法进行 转发了,这时候你便可以将那些静态方法的方法体转移到相应的实例方法中,并删除所有静态 四方法了。 步骤 引入实例委托(Introduce Instance Delegator) 手法的步骤如下: (1) 找出会在测试中带来问题的那个静态方法。 (2) 在它所属类上新建一个实例方法(记得用签名保持手法〕 。让该实例方法委托那个静态 方法. (3) 找出你想要纳入测试的类中有哪些地方使用了那个静态方法。使用参数化方法 (301 页) 或其他解依赖技术来提供一个实例给代码中想要调用那个静态方法的地方(即改为从该实例间接 回调用那个静态方法) • 25.12 引入静态设置方法 或许我是个纯粹主义者,但我确实不喜欢全局变量。在协助团队的过程中我常常发现,阻挠 我们将一块代码放进测试用具的最大敌人就是它们。比如你想把一组类放入测试用具,却发现其 中有些需要被设置成某些特定状态才能使用。于是在你将测试用具架起来了之后,还得查看所有 的全局变量,确保它们的状态/值满足你进行测试所需的条件。看来"超距作用"并非盘子物理 学家率先发现的,在软件界这一效应由来已久了。 对全局变量的抱怨暂且放一边,事实是,许多系统里面都免不了有全局变盘。有些系统中全 局变量的存在形式是直接的 只是由于某个程序员在全局范围内定义了 一个变量。而另一些系 统中它们则可能以严格遵循单件模式的单件形式存在。不管哪种情况,引入一个伪对象并用它来 进行感知是个非常直接的做法。如果你的全局变量就是个赤裸裸的全局变量,那么可以干脆替换 它本身。如果对它的引用是 const 或 final 的,你可能就需要将这些修饰符去掉了(同时在代码 里留下注释说明你这么做只是为了方便测试,在产品代码中不应利用这个漏洞 λ第 25 章解依赖技术 293 单{牛设计模式 羊件模式被许多人用来确保某个特定的类在整个程序中只可能有唯一一个实例 . 大多数羊 件实现都有以下三个共性 (1)单件类的构造函数通常被设为私有. (2) 单件类具有一个静态成员,该成员持有该类的唯一一个实例. (3) 羊件类具有一个静态方法,用来提供对单件实例的访问.通常该方法名 ø~ instanceo 虽说羊件模式能够防止人们在产品代码中创建不只一个目标类的实例,但它同样阻止了人 们在测试用具中创建第二个实例.这是一把双刃剑. 替换单件需要多花点工夫才行.首先是往单件类上添加一个静态的设置方法以便用它来替换 单件实例,然后将构造函数设为受保护的.之后就可以对单件类进行子类化,创建一个全新的对 象并将它传递给那个静态的设置方法了. 这种做法可能会令你心里感到不安,因为你觉得单件类的保护给打破了,但是,别忘了,访 问限制的目的在于防止错误,而我们编写测试的目的同样也是防止错误。而为了在这种情况下引 13721 入测试,我们不得己才用了强硬一点的手段 . 下面的例子展示了如何在 C++中引入静态设置方法 CIntroduce Static Setter): void MessageRouter: : route(Message *messageJ Dispatcher *dispatcher ExternalRouter : : instance()->getDispatcher() ; if (d工 spatcher ! = NULL) dispatcher->sendMessage(message) i 在 Me ssageRouter类 中,许多地方都使用了单件来获取 Dispatcher 对 象( getDispa­ tcher())oExternalRouter类就是其中的一个单件类,它有一个静态方法 instance ()来提供 对全局唯一 的 ExternalRouter对象的访问。此外 Exte rnalRouter上有一个ge tDispatcher () 方法用于获取民 spatche r 。要想换入我们自己的测试用 Dispatcher ,我们可以把提供该 Dispatcher 的 ExternalRouter对象替换掉。 在引入静态设置方法之前, ExternalRouter类看起来像这样: class ExternalRouter private static ExternalRouter 舍 ~nstance; public static ExternalRouter *instance(); ExternalRouter *ExternalRouter : :_instance 0 i ExternalRouter *ExternalRouter: : instance () 回 294 第二部分解依赖技术 i f (_instance 0) instance new External Router ; return _instance; 注意. Ex ternalRout er单件对象是在 instan c e ( ) 方法被第一次调用的时候创建出来的 . 要换入我们自己的路由对象,就必须想办法修改 i n st ance ( )的返回值 。 为此,我们首先引入→ 个用于替换该实例的方法: void Externa!Router : : set Testinglnstance(Ex ternalRouter * n ew工 nst ance ) { delete _instancei instance newlnstance; 当然,这一做法有一个假定的前提,那就是我们能够创建一个新的实例 . 人们在使用单件模 式的时候一般都是通过将构造函数设为私有来防止外界创建多个实例的。如果我们将构造函数的 访问权限改设为受保护的,就可以通过子类化该单件类来实现感知和分离,并将新的实例传给 s et T est i ng lnstance 方法。在上例中,我们可以创建 External Route r 的 子 类,比如叫 Testi ngEx ternalRoute r. 然后重写其 getD ispa tcher方法,让它返回我们想让它返回的东 西,即 一个伪对象: class TestingExternalRout er public Ex ternalRouter { publ ic virtual void Dispat cher *get Dispatche r () const { return new FakeDispatcher; 仅仅为了换入一个新对象就大费周章地如此折腾一气,看起来似乎太夸张了点 . 最终创建的 一个新的 Ex ternalRouter派生类仅仅只是为了换入我们的测试用对象 。 当然,捷径还是有的, 但每条捷径都有它们自己的缺点。 比如我们可以往 Ext ernal Rou t er里面添加一个布尔变量,然 后根据该变量的值来决定返回产品还是测试用对象。在C++或C# 中我们也可以使用条件编译来切 换对象。以上这两个替代手法都可行,但它们的侵入性太强,而且如果在整个代码基中普遍采用的 话就会变得比较笨拙。一般而言我喜欢把产品代码和测试代码分得清清楚楚,互相井水不犯河水 。 在单件上使用设置方法和受保护的构造函数侵入性不强,却能帮助你将测试安置到位 。 但你 或许会发出疑问"人们会不会错用我们为测试而留的后门,在产 品代码中创建出 多于一个的 4 单 件'出来呢? "答案是可能的。但我觉得 , 如果某个实例在系统中的唯一性是如此重要的话,最 好的办法就是确保团队的每个成员都意识到这一重要性 。 除了降低构造函数访问限制级别并利用子类化之外,还有一个替代手法就是利用接口提 取,在单件类上提取出一个接口并在该接口上提供一个能接受实现了该接口的类对象的设置方第 25 章解依赖、技术 295 法.但该做法也有它的缺点,那就是你必须修改用来引用单件的引用类型以及工 ns tance ()方 法的返回类型.这些修改可能会很棘手,而且这些改动的方向并不好.我们认为"灵好的方向" 是减少对羊件的全局引用,最终使羊件类可以成为一个普通类. 在上面的例子中,我们利用了 一个静态设置方法来替换单件对象 . 而我们的单件对象的任务 其实只是负责提供一个 Dispatcher对象.偶尔我们也会在有些系统中发现另一种全局变量 一个全局工厂。它们并非持有唯一一个对象,而是在每次静态方法被调用的时候提供全新的对象。 13741 对于这类情况,要想换入我们自己的对象就有点难度了,但通常你都可以通过让这个工厂委托另 一个工厂来达到你的目的。比如,让我们来看一个 Java 的例子: public class RouterFactory { static Router makeRouter() return new EWNRouter () ; RouterFactory是一个很直观的全局工广.就它现在的这个样子,我们是没法换入测试用 路由对象的,但可以对它作如下修改: interface RouterServer Router makeRouter() i public class RouterFactory implements RouterServer ( s t atic Router makeRouter() return server . makeRouter() ; static setServer(RouterSe rver server) this . server server; static RouterServer server new RouterServer () public RouterServer makeRouter() return new EWNRouter 川, 这样,在测试中我们便可以这么做: protected void setUp () RouterServer . setSer v er ( 口 ew RauterServer( ) public RouterServer makeRauter() return new FakeRouter() ; }} ; 296 第三部分解依赖技术 但有一点需要注意,在所有这些关于寻|入静态设置方法的手法里,你对程序状态的修改对于 所有测试来说都是可见的。如果你使用的是 xUnit测试框架,则可以使用里面的 tea rDown方法来 将状态复位,以便后续的测试在一个己知的状态环境下执行。 一般来说,仅当错误的状态用于后 续的测试会引起误解时我才会使用这种方法.假设我在每个测试中都是替换进 一 个伪的 13751 MailSender对象,那么再弄一个伪 MailSender对象出来似乎没多大意义。但另 一方面,如果 是用全局变量来保存某些状态并且这些状态会影响到系统结果的话,通常我就会在 setUp 和 tearDown方法里面做同样的事情一一保证系统处于一个干净的状态,如下. protected void setUp () { Node . count 0; protected void tearDown {) Node . count 0 ; 我猜你看到这儿肯定在想"不就为了把这个测试安置到位吗,用得着这么大动干戈的嘛? " 你说得没错,这些模式的确会明显丑化系统 .但别忘了,手术也从来都不是漂亮的,尤其是开始 的时候 。 那么我们怎么才能让系统重新回到体面的状态呢? 需要考虑的问题之一是参数传递。考察一下需要访问你的全局变量的类,看看能否给它们一 个公共基类。如果可以,就在创建它们的时候将全局对象传递给它们,并逐渐往消除全局变量的 方向靠拢。人们常常会害怕系统中每个类都会需要全局对象,但结果往往会令你大吃一惊.比如 我曾经遇到过一个嵌入式系统,该系统将内存管理和错误汇报机制都封装在了类中,将一个内存 对象或错误汇报对象传给任何想要它的代码。随着时间的推移,在需要这些服务的类与不需要它 们的类之间就形成了 一道清晰的隔离。需要它们的类都具有一个共同的基类。在系统中被传来传 去的对象在程序一开始就被创建出来,你几乎觉察不到。 步骤 引入静态设置方法的步骤如下· (1) 降低构造函数的保护权限,这样你才能够通过子类化单件类来创建伪类及伪对象。 (2) 往单件类上添加一个静态设置方法 。 后者的参数类型是对该单件类的引用.确保该设置 方法在设置新的单件对象之前将旧的对象销毁。 (3) 如果你需要访问单件类里面的受保护或私有方法才能将其设置妥当的话,可以考虑对单 13761 件类子类化,也可以对其提取接口并改用该接口的引用来持有单件。 25.13 连接替换 面向对象方法学给了我们很多替换对象的契机 。 比如只要两个类实现了共同的接口,或具有第 25 章解依赖技术 297 共同的基类,那么我们便可以轻松地将其中一个的对象替换为另 一个的对象。但遗憾的是,像C 这样的过程式语言的程序员没这个福气。比如下面这个函数,如果不用预处理手段,就完全没法 在编译期将其替换为另 一个函数. void account_deposit(int amountl ; 还有其他办法吗?有。可以使用连接替换(Link Substitution) 手法来将它替换为另一个函数。 实施该手法时,你创建一个哑元库,该库皇面包含一个跟以上函数签名完全一样的函数 。如果你 的目的是感知,那么 需要在该函数里面设置一些机制来保存通知消息并对它们进行查找。可以使 用文件, 全局变量,或其他办法,只要你觉得方便就行 。 下面就是我们的测试用 account_deposit void account_deposit(int amount) { struct Call *call (struct Call 食 )calloc( l, sizeof (struct Calll 1; call->type ACC_DEPOSIT; call->argO amounti append(g_calls , calll; 从以上代码中可以看出,我们感兴趣的是感知,所以创建了 一个全局列表,该列表里面包含 了每次该函数被调用时的有关信息。在测试时,我们可以在测试完一组对象之后检查该列表来确 认该函数是否按照正确的顺序被调用了。 我个人从未对C++类用过这一手法,但我想道理是一样的。当然,我相信 C++编译器的名字 粉碎机制肯定会带来一些困难,但如果调用的是C 函数,则该手法是非常可行的。其最大的用处 就是用来伪造外部库内的函数。而最好伪造的又属那些纯数据汇 1 的库 对于这类库,你只是调 用其中的函数,而通常并不关心其返回值。图形库就是这样的一个例子. 连接替换手法也可以用在 Java 中。做法是创建一个同名同方法集的类,然后修改类路径,让 调用被决议到你的新类上,从而避开原类上的糟糕依赖 13771 步骤 连接替换的步骤如下: (1)找出你想要伪造的函数或类。 (2) 为它们编写另 一份定义。 (3) 修改你的构建参数,让你的伪造品能够代替真品被连接到项目中。 13781 25.14 参数化构造函数 如果你用构造函数创建了 一个对象,那么通常解除对该对象的依赖的最佳办法就是将它的创 1. s尬,也称"宿"、"池飞一一译者注298 第二部分解依赖技术 建过程外部化,即在该类外创建该对象,然后让该类的客户代码将这个对象传给该类的构造函数。 下面就是一个例子 。 一开始的代码如下所示· public class MailChecker { public Mailchecker (int checkPeriodSeconds) this . receiver new MailReceiver() ; thiS . checkPeriodSeconds checkPeriodSeconds i 然后我们引入一个新的参数,如下: public class MailChecker { public MailChecker (MailReceiver receiver, int cheç k Per 玉。 dSeconds) this . receiver receiver ; this . çheckPeriodSeconds checkPeriodSecondsi 人们之所以并不常常想到该技术,是因为他们觉得这种做法等于强迫客户代码传递额外参数 给该类的构造函数。然而别忘了,你还可以另外再写一个方便的构造函数,它具有跟原来的构造 函数相同的签名,如下 z public class Ma 工 lChecker { public MailChecker (int checkPeriodSecondsl ( this(new MailReceiver() , checkPeriodSeconds); public MailChecker (MailReceiver receiver, int checkPeriodSeconds) { this . receiver receiver; this . checkPeriodSeconds checkPeriodSeconds; 这么一来你就既保证了测试代码能够换入必要的测试用对象,又丝毫不干扰产品代码 - 13791 让我们一步一步来,下面是原始代码 public class MailChecker ( public MailChecker (int checkPeriodSeconds) this . receiver new MailReceiver () i this . checkPeriodSeconds checkPeriodSecondsi 首先我们将其构造函数复制一份· public class MailChecker { 第 25 章解依赖技术 299 public MailChecker (int checkPeriodSeconds) this . receiver new MailReceiver ( ) ; this . checkPeriodSeconds checkPeriodSeconds; public Ma ilChecke r (int che ckPeriodSeconds) { t his . r e ce i v er new MailRe ce iver ( ) ; this.checkPeriodSeconds checkPer!odSe conds; 然后给其中一个构造函数添加一个MailReceiver型的参数: public class Mailchecker { public MailChecker (int checkPeriodSecondsl this . receiver new MailReceiver () i this . checkPeriodSeconds checkPeriodSeconds; public MailChecker (MailReceiver rec eiver, int checkPeriodSecondsl this . receiver new MailReceiver () ; this . checkPeriodSeconds checkPeriodSeconds i 接着 , 我们将这个参数赋给相应 的实例变量 , 删持原来的 new表达式: public class MailChecker { public MailChecker (int checkPeriodSeconds) { this . receiver new MailReceiver () ; this . checkperiodSeconds checkPeriodSeconds i public MailChecker (MailReceiver receiver , int chec kPeriodSeconds) this . receiver receiver; this . checkPeriodSeconds checkPeriodSeconds i 现在 , 转到另 一个构造函数 , 删除其函数体 , 代 以对刚才那个构造函数的调用 。 别忘了,调 用的时候new一个MailReceiver对象出来传给它 。 圃300 第三部分解依赖技术 public class MailChecker { public MailChecker (int checkperiodSeconds) this (new Ma 工 lReceiver() , checkPeriodSeconds); public MailChecker (MailReceiver receiver, int checkPeriodSeconds) this.rece 工 ver reCe~Veri this.checkPeriodSeconds checkPeriodSecondsi 该技术有什么缺点吗?有的。当我们往一个构造函数上添加一个新的参数时,可能会导致进 一步对该参数的类型的依赖。该类的用户可能会在产品代码中使用这个新的构造函数,从而增加 系统内的依赖。不过一般来说这个问题不要紧。参数化构造函数 (ParameterizeConstructor) 是 个非常简单的重构手法,我喜欢用它。 在支持默认参数的语言中实施参数化构造函数还有史简单的办法一一只需简单地给现有 医E 构造函数的目标参数加一个默认实参即可: 下面是一个 C++类的示例 class Assemblypoint { public AssemblyPoint(EquipmentDispatcher *dispatcher new EquipmentDispatcher); 在 C++ 中这么做只有一个缺点 该类所在的头文件必需包含E 明工pmentDispatcher的 头文件.如果不是因为这个默认参数,我们只需前向声明 Equ 工 pmentDispatcher就够了.而 也正是因为这个原因,一般我并不使用默认实参, 步骤 参数化构造函数的步骤如下: (1) 找出你想要参数化的构造函数,并将它复制一份。 (2) 给其中的一份复制增加一个参数,该参数用来传入你想要替换的对象。将该构造函数体 中的相应的对象创建语句删掉,改为使用新增的那个参数(如果需要赋值的话就将那个参数赋给 相应的实例变量)。 例如果你的语言支持委托构造函数那么删掉另一份构造函数的函数体,代以对刚才那个 构造函数的调用,别忘了调用的时候要 new一个相应对象出来。如果你的语言不支持委托构造函 1 即从 个构造函数中调用另一个构造函数的能力.一一译者注第 25 章解依赖技术 301 数,则可能需要将构造函数中的共同成分提取到一个比如叫 cornrnon_init 的方法中。 25.15 参数化方法 假设你有一个方法,该方法在内部创建了某个对象,而你想要通过替换该对象来实现感知或 分离。往往最简单的办法就是从外面将你的对象传进来。下面是一个 C++的例子: void TestCase: : run ( ) delete ffi_resulti ffi_Tesult new TestResulti try { setUp() i runTest(m_result); catch (exception& e) resul 巳 ->addFailure(e ,巳 his) ; tearDown ( ) i run ()中创建了 一个 TestResult对象。如果我们想要通过该对象来进行感知或分离的话, 可以将它作为一个参数传进去,如下 : void TestCase : : run(TestResult *result) delete ffi_result ; ffi_result result; try ( setUp() ; runTest(m_result); catch (exception& e) result->addFailure(e , this); tearDown ( ) ; 借助于一点函数转发技巧,我们就可以完全保留原来的那个函数签名 : void TestCase:: run () run(new TestResult); C++ 、 Java 、 C#以及许多其他语言都允许同一个类上有多个同名万法,前提是只要它们的 签名各不相同.在上例中,我们利用了该便利,使原方法和参数化之后的方法具有同样的名字. 尽管这省了点事,但有时也会带来混乱。一个替代方案是将新参数的类型名嵌入万法名中.例 如,在上面的例于中,我们可以保留 run( )作为原方法名,同时将加了参数的那个 run 叫做 runW 工 thTestResult(TestResult). 国 因302 第二部分解依赖技术 与参数化构造函数手法一样,参数化方法 (p缸amete田eMe血。d) 也可能会导致客户代码依赖于 新出现的那个参数的类型。如果你觉得这的确会带来问题的话,可以考虑改用提取并重写工厂方法。 步骤 参数化方法的步骤如下: (1) 找出目标方法,将它复制一份。 (2) 给其中 一份增加一个参数,并将方法体中相应的对象创建语句去掉,改为使用刚增加的 这个参数。 (3) 将另一份复制的方法体删掉,代以对被参数化了的那个版本的调用,记得创建相应的对 回象作参数, 25.16 朴素化参数 -般来说,修改一个类的最佳途径就是在测试用具中创建它的实例,为你想要进行的修改编 写相应的测试,然后作出修改来满足该测试。然而有时候为了将一个类纳入测试需要花的工夫太 大了。我就曾经遇到这样的一个团队,他们接手的一个遗留系统里面的那些领域相关的类几乎直 接依赖了系统内的其他所有类。然后,就像情况还嫌不够糟似的,这些类居然还统统被绑进了 一 个持久化框架 。当然 ,把里面的一个类纳入测试框架仍然还是可行的,但如果时间都花在跟那些 领域类纠缠上面的话,做正事的时间就被占用掉了 。在那种情况下,为了获得一些必要的分离, 我们使用了本节所讲的技术。为了保护应有的权利,下面给出的例子作了必要的修改。 在一个音乐合成工具中,一条音轨包含了多个音乐事件序列。而我们则需要找出每个序列中 的"死时间,,,这样才能往这些地方加进 一 些小的重复音乐模式。我们需要一个 叫做 bool Sequence: : hasGapFor (Sequence& pattern) const 的方法。该方法的返回值表示一段音乐 模式能否放进一个序列 . 理想情况下,该方法应该放在一个叫做 Sequence 的类上,但不幸的是 Sequence类正是那种 想要把整个世界都吸进测试用具中去的"黑洞"类。在开始编写这个方法之前,我们得先想想怎 么给它编写测试。幸运的是,序列 (Sequence)对象的内部表示可以简化,这就使得我们编写测 试成为了可能。每个序列对象都包含一组事件.但仍然不幸的是,事件类的依赖情况并不比序列 类好:它们都有相当严重的依赖,都会给构建带来麻烦。然而,再一次,幸运的是,要计算一段 模式能否放进一个序列,我们其实只需要每个事件的持续时间.于是我们可以编写另 一个基于整 型数来进行计算的方法。有了该方法之后,便可以编写 hasGapFor并让它将实质工作委托给基于 整型计算的那个方法来完成。 让我们从编写第一个方法开始,下面是对它的测试: TEST (hasGapFor , Sequence) { 1. dead time. 原为色谱分析来语 . 译者注vector baseSequencei baseSequence .push_hack(l) ; baseSequence .push_hack(Q) ; baseSequence . push_back(Q) ; vector pat terni pattern .push_back(l) ; pattern .push_back(2) ; CHECK (SequenceHasGapFor {baseSequence, pattern)) ; 第 25 章 解依赖技术 303 SequenceHasGap F or是一个自由函数:它并不属于任何类,但关键的一点是,它所操作的 对象是 一 个 基 于 基 本 类 型的序列 表示 (这里 是 unsigned int ) 。 如果我们能 编写 出 S e quenc e HasGapFor . 就能进而在 S equence类上添加一个非常简单的 ha s GapF o r 函数 ,它只 要将实质性的工作全部委托给 Sequ e nc e HasGap For来完成就行 了: bool Seql且 ence : :hasGapFor (Sequence& pat tern) const { vector baseRepresentation getDurat ionsCopy() ; vect。主 patternRepresentation pattern . getDura 巳工。 nsCopy () ; return SequenceHasGapFor(baseRepresentation, patternRepresentation) ; 该函数需要借助另一个函数来获取持续时间的数组,我们来编写这个函数 · vec tor Sequence : :getDurationsCopy() const { vector result ; for (vect 。主 : : i t erator it events .begin ( ) ; it ! = events . end() ; ++it) result . push_back (it ->dur ation) ; return result; 到目前为止 , 已经可以 实现我们想要的特性了,但做法是非常E陋的 。下面就列出其 中 的 问题: (1) 暴露了 Se qu e nce类的内部表示。 (2) 令 Sequenc e 类的实现更难理解 ,因为我们将其实现的一部分推到 了一个自由函数中 。 (3) 写 了一些没杳测试覆盖的代码(实际上我们是没法给 getDurations Copy(} 编写测试) 。 但) 重复数据 。 (5) 拖延了问题 。 我们并未解开领域类与基础架构之间的依赖(这一 点会给后面的工作带来 因 很大 的影响) 。 巴坐j304 第二部分解依赖技术 尽管有这许多缺点,我们终究还是得以把一个有测试覆盖的特性加进去了 。 我并不喜欢这类 重构,但如果已经没有其他选择的话,也只能这么办了。通常它是新生类 (54页)手法的一个不 错的准备。你可以设想将 SequenceHasGapFor 包覆在一个G apFinder类中的情形 。 朴素化参数( Primitivize Parameter) 手法对代码的状况并无多大改善.总的来说灵好的办 法是将新代码加到原类上,或使用新生类手法来建立新抽象,充当后续工作的基础 . 我使用朴 素化参数手法的唯一一次是当我觉得有信心在后面能腾出时间来把我的类纳入测试时;到那时 候 , 便可以把我的方法放到这个类上了. 步骤 朴素化参数手法的步骤如下: (1)编 写一个自由函数来实现你想要对目标类做的事情。同时建立一个中间表示 ,以便你的 自由函数进行处理. (2) 往目标类上添加一个函数来构造这一中间表示,并将实际任务转发给上一步创建的那个 固自由函数。 25.17 特性提升 有时候,比如说,你要对付一个类上面的一簇方法,而阻止你在测试用具中实例化这个类的 依赖却又是跟这簇方法毫无瓜葛的。这里所谓"毫无瓜葛"是指这些方法既没有直接也没有间接 引用或触碰到那些问题依赖。当然,这时你可以通过重复采用暴露静态方法 (275 页)或分解出 方法对象 (261 页)来"解决"问题,但那样做未必就是解决这个问题的最直接办法 。 面对这种情况,你可以将这簇方法(即所说的"特性")提取出来,提升到一个抽象基类中。 然后再对这个抽象基类进行子类化,并在测试中创建这个子类的实例 。 例如: public class Scheduler { private List items ; public void updateScheduleltem(Schedule I tem item) throws SchedulingException { try ( validate (ite m) ; catch (ConflictException e) throw new SchedulingException(e); private void validate (ScheduleItem item) throws ConflictException { /1 make calls to a database 第 25 章解依赖技术 305 public int getDeadtime() int result 0; for (Iterator it items 工 terator () i it . hasNext (); ) Scheduleltem item (Schedulelteml it. next () ; if (工 tem.getτ'ype () ! = Scheduleltem. TRANS 工 ENT && notShared(item)) result += item.getSetupTime() + clockTime() ; if (item. getType() ! = Scheduleltem.TRANSIENT) result += item. finishingTime () ; else ( result += getStandardFinish(iteml ; return result; 假设我们要修改 getDeadTirne ,但并不关心updateScheduleltemo 如果不用应付对数据 库的依赖的话,情况会好很多。为此我们可以尝试使用暴露静态方法,但getDeadTirne 里面用 到了 Schedu ler上的许多实例变量。也可以试试分解出方法对象,但这又是个很小很小的方法, 似乎不值得这么做,况且它对于其他实例变量和方法的依赖会使得我们被许多根本不想看到的麻 烦缠住 毕竟我们只是想把这么个小小的方法放入测试而已。 另 一个做法就是将问题方法提升到一个基类中。我们可以将问题依赖留在原来的类中,免得 它们阻挠测试。特性提升之后的 Scheduler类大致像这样: public class Scheduler extends SchedulingServices { public v。工 d updateScheduleltem(Scheduleltem item) throws SchedulingException ( private void validate(Scheduleltem item) throws Confl 工 ctExcept 工。n ( 1/ make calls to the database 我们已经将 getDeadtime (我们想要测试的特性)以及它所用到的所有特性都提升到了 一 个抽象类 Schedu lingSer 飞Tl ces 中了: public abstract class SchedulingServices ( protected List items; 因306 第二部分解依赖技术 protected boolean notShared(Scheduleltem item) protected int getClockTime() { 国 protected int getStandardFinish(Scheduleltem item) public int getDeadtime() { int result 0 ; 歪。 r (工 terator it items . iterator(); .it . hasNext(); Scheduleltem item (Schedule工 t础。 it.next(); if (item. getType() 1= Scheduleltem. TRANSIENT && notShared(item)) ( result += item.getSetupTime() + clockTime(); if (item.getType() != Schedule 工 tem.TRANSIENT) ( result += item. finishingTime(); else ( result += getStandardFinish(item) ; return result; 于是现在便可以从 SchedulingSer飞Tl ces 派生 出 一个测试子类来,这样我们便可以从容地 在测试用具中访问这些方法 了· public class TestingSchedulingServices extends SchedulingServices { public TestingSchedulingServices() } public void addltem(Scheduleltem item) items . add(item) ; import junit . framework . * ; class SchedulingSer 飞r icesTest extends TestCase { public void testGetDeadTime() ,, ) " {、 a s mH se( ecm le lvt vzI ree e s­ sgu nd 51e 自 1h cue -JUS 町如 w eke CMMHn 34( 川一山跚止 S 引 dhtd eqd hya c u" s e s gre ne ---l tv sz ee TS 第 25 章解依赖技术 307 10 , 20 , Schedule 工 tem . BAS 工 CII ; assertEquals(2 , services.getDeadtime()) ; 回顾一下,我们做了什么呢?首先将想要测试的方法提升到一个抽象基类中,然后创建该抽 象基类的一个具体派生类,后者便是我们可以用在测试中的类 . 那么,这么做是不是件好事呢?从 13901 设计的角度来说,这种做法还不算理想。我们令一组特性跨越了两个类,这么做的原因只不过是为 了容易测试 。 如果这两个类的特性之间的关系并不密切的话,这种特性跨越就可能会带来混乱。而 这儿的情况正是如此:我们有 一个类叫 Scheduler ,其职责是更新计划项 目,还有一个类叫 Schedul 工 ngServices ,但这个类的职责就广了,包括获取计划项目的默认时间、计算死时间 等。 另~种好一点的重构方式是让 Scheduler委托某个validator对象,将访问数据库的任务放在后 者身上,但如果这一步目 前看上去还太危险,或者说还有其他糟糕的依赖存在的话,那么特性提升 是个不错的开始。使用特性提升手法肘,你如果实施签名保持 ( 249页),并且依靠编译器(2 51 页) 的话,风险就会小得多 . 而当测试安置到位之后,我们可以再去考虑是否转向委托的模式 。 步骤 特性提升 ( Pull Up Feature) 手法的步骤如下: ( 1 ) 找出你想要提升到抽象基类中去的方法 。 (2) 为它们创建一个抽象基类。 (3) 将这些方法转移到该抽象基类中,再编译 。 (4) 编译错误会告诉你这些方法引用到的其他实例成员,将它们也转移到基类中。记住这么 做的时候要保持签名,以尽量减少出错的机会 。 (5) 当两个类都成功编译之后,为那个抽象基类创建一个测试子类,并往其中添加你觉得需 要用于设置测试环境的方法。 你可能会想干嘛非要让那个基类成为抽象的呢? "实际上,是为了让代码史易理解. 设想你看到一块代码基,那么肯定会期望看到每个具体类都被用到了,如果有一个具体类没 有被任何代码直接用到(实例化)的话,你肯定会感到困惑,因为在你看来它们无异于"死 代码 .. 25.18 侬赖下推 有些类里面的问题依赖并不多。如果这些依赖被包含在少数几个方法中的话,你可以采用于 类化并重写<3 14 页)手法来将它们解决掉。但如果依赖猖獗,则这条路可能就行不通了,这时 你可能就需要动用接口提取技术,重复运用接口提取来解除对某些特定类型的依赖。依赖下椎 (Push Down Dependency) 则是另 一个选择 。 该手法能够将目标类其他部分的问题依赖分离出来, 使你能够更容易地在测试用具中将它实例化 。 回308 第二部分解依赖技术 在使用依赖下推技术时,首先把目标类设为抽象类,然后创建一个它的子类,后者便是你的 新的产品类了。接着将所有的问题依赖都"下推"到这个子类中。到这一步,你便可以通过子类 化原类来让它的有关方法接受测试了。下面是一个 C++的例子 z class OEfMarketTradeVal 工 dator public TradeValidator private Trade& trade; bool flag ; void showMessage() publ 工 C int status AfxMessageBox (makeMessage () , MB_ABQRTRETRYIGNORE) i if (status IDRETRY) else SubmitDialog dlg(this , "Press okay if this is a valid trade" ) i dlg.DoModal() ; 工 f (dlg . wasSubrnitted()) { 9_dispatcher.undoLastSubmission() ; flag true; if (s tatus 二 IDABORT) { flag false; OffMarketTradeValidator(Trade& trade) trade(tradel , flag(false) {} 圄 b叫时叫() cons 巳{ if (工 nRange(trade . getDate()) && validDestination(trade . destinationl && inHours (trade) flag true; showMessage() ; return flag ; 对于上面这个类,如果想要修改它里面的 isVal 工 d() 的验证逻辑的话就会遇到麻烦了,我们 可不希望将UI相关的函数和类牵扯到测试用具中来。这时依赖下推手法便有用武之地了。 对上面的类作依赖下推之后的情形如下: class OfEMarketTradeValidator public TradeValidator protected Trade& trade; bool flagi virtual void showMessage ( ) 0 ; public OffMarketTradeValidator(Trade& trade) trade (trade) , flag{false) {} bool isValid() const ( 工 f (inRange(trade . getDate()) 第 25 章解依赖技术 309 && validDestination(trade . destination) && inHours (trade) flag true; showMessage() ; return flag; class WindowsOffMarketTradeValidator public OffMarketTradeValidator protected: virtual void ShOwMessage{) int status AfxMessageBox(makeMessage() , MB_ABORTRETRYIGNORE) ; if (status IDRETRY) { else SuhmitDialog dlg(this , " Press o kay 工 f this is a val i d trade" ) ; dlg . DoModal() ; if (dlg . wasSuhmitted()) ( 9_dispatcher . undoLastSuhmission() ; flag true; if (status IDABORT) flag false i 一旦u相关的 工作被下推到了 一个新的子类 (WindowsOffMarketValidator) 中, 我们 便可 以创建另一个测试用的子类了 ,后者只需实现一个空的 showMessage ( ) 方法即 可 : class Tes tingOffMarketTradeVali dator public OffMarketTradeValidator protected virtual void showMessage ( ) { } ) ; 回310 第二部分解依赖技术 如此一来我们便有了一个可测试但同时又不依赖于任何U相关事物的类 。那么 ,在这个例子 中使用继承是否为理想的方案呢?不是 的,但它最大的好处就是能帮助我们将目 标类的一部分逻 辑纳入测试。而一旦有了对 OffMarke tTradeValidator 的测试,便可以开始清理 由。wMessage ()里面的重试逻辑,并将其从W indow s OffMark e tTradeVal 工 dator提升到基类 中来。然后,当W 工 ndowsOf fMarke t Trade飞lalidator最终被掏空得只剩UI相关的调用时,我们 便可以转向委托式的设计了,即将UI相关调用委托给一个UI依赖的新类。 步骤 依赖下榕的步骤如下: (1 )在测试用具中构建目标类 . (2) 找出哪些依赖是导致构建问题的依赖. (3) 创建目标类的子类,子类的名字须反映上一步找出的依赖的特征 (4) 将目标类中的依赖变量和方法全部复制到新建的子类中,注意保持签名;将目标类中的 回相应方法设为受保护及抽象的:将目标类设为抽象的。 (5) 创建 目标类的一个测试子类,修改你的测试,实例化该测试子类。 13951 (6) 创建测试来验证你的确能实例化这个新的测试子类。 25.19 换函数为函数指针 在过程式语言中解依赖可没有在面向对象语言中那么多选择。比如你没法使用封装全局引用 (268页)或是子类化并重写方法(3 14 页)。所有这些面向对象的解依赖手法都行不通。当然,你 还是可以使用连接替换 (296 页)或定义补全 (266 页),但它们对于解开一些小的依赖又显得有 点杀鸡用牛刀了 。 对于支持函数指针的语言,换函数为函数指针 (Replace Function wi也 Function Pointer) 手法是一个可边的方案。众所周知的支持函数指针的语言就属 C 了 。 不同的团队对函数指针有着不同的看法。有些团队认为函数指针极度不安全,因为指针的内 容可能会被破坏,从而导致被调用的是一块随机内存。而有些团队则把它当成有用的工具,当然, 小心使用是前提 。实际上 ,如果你站在后者的阵营 ,便可以借助于这一工具来解开用其他办法很 难或者无法解开的依赖 。 首先让我们来看一看函数指针的自然用法.下面这个例子是C写的,其中我们声明了一些函 数指针并通过它们来发起调用: S 巳 ruct base_operations { double (*projectl (double , doublel ; douhle ("'max 工 mize) (double, double); double default-projection(double first , double second) 1 比如是对wi ndows UI的依赖,那么便可以加上 "Windows" 前缀 . 译者注return SeCOndi double maximize{double first , double secondl return first + SeCOndi void init_ops(struct base_operations *operationsl 。perations->project default-p roject 玉。 n; operations->maximize defaul t_rnaximize ; void run tess e!at 工。n(struct node *base, 第 25 幸解依赖技术 311 struct base_operations *operationsl double value operations• >project(base.first , base.secondl; 函数指针可以实现一些非常初级的基于对象的编程,然而在解依赖方面它们又有多大用处 呢?考虑下面这个场景。 有一个 网络应用,包信息被保存在一个在线数据库 中 。你通过如下的调用来与该数据库 交互: void db_store ( struct receive_record 舍 record , S 巳 ruct time_stamp receive_ timel ; struct receive_record * db_retrieve(time_stamp search_time); 我们可以使用连接替换 (296页)来将这些函数连接到新的函数体,但连接替换有时会给构 建过程带来较大麻烦 . 比如我们可能会需要将问题函数从它所在的库中分离出来以便替换它们 。 更重要的是,在产品代码中根本没法使用连接替换所获得的接缝来调节行为。如果你想要将代码 纳入测试同时又能提供产品实现的灵活性 ( 比如切换你的代码所访问的数据库的类型 〉 的话 ,换 函数为函数指针是个好主意。下面就是具体的步骤。 首先我们找出想要替换为函数指针的函数 1/ db.h void db_store(struct receive_record *record, struct time_stamp receive_timel ; 声明一个跟它同名的函数指针: // db.h void db_store(struct receive_record *record, struct time_stamp receive_time); void (舍 db_storel (struct receive_record *record, struct time_stamp receive_timel i 重命名原函数: // db.h void db_store-production(struct receive_record *record, struct time_stamp receive_timel ; 因回 312 第三部分解依赖抗术 void ( 舍 db_store) (struc 巳 receive_record *record, struct time_ stamp receive_time) ; 在一个C源文件中初始化这个函数指针 . / / main . c extern void db_store--p roduction ( struct receive_record *record, struct time_stamp receive_time) ; VO 工 d initi alizeEnvironment() db_store db_store--production; int main(int ac, char **av) initializeEnvironment() ; 找到 db_s t ore 函数的定义 , 将其重命名为 db_ s tore-production: / / db. c void db_store--product ion( struct rece ive_re cord *record, struct time_st amp receive_ time) 现在便可以编译和测试了 。 奋 了 这个函数指针,测试文件便可以替换进 , 从而达到感知或分离的目的 。 换函数为函数指针手法是解依赖的好办法 . 它的好处之一使是发生在编译期(而非连接 期) , 因此对你的构建系统几乎没有影响 . 然而,如果你是在C里面运用这项技术的 , 那么请 考虑转移到 C忡,以便利用 c++提供的各种各样的接缝.在本书写作时,许多 C编译器都提供 了编译开关来允许你进行c/c++混合编译 。 利用这个性能你使可以将C项目逐步往 c++迁移 , 每次只需对你关心的那些文件进行解依赖 . 步骤 换函数为函数指针手法的步骤如下· (1) 找到你想要替换的函数的声明 。 (2) 在每个找到的函数之前创建一个同名的函数指针 。 (3) 重命名原函数的声明 , 以避免跟刚才声明的函数指针重名 。 (4) 在一个C文件中初始化这些函数指针 , 将它们指向相应 的函数 。 因 (5) 构建,通过构建错误来找 出原函 数的函数体 。 将它们改为新的函 数名。第 25 章解依赖技术 313 25.20 以获取方法替换全局引用 当你想单独对付某块代码时,可能常常会发现全局变量挡在你的路上。关于全局变量的害处 这里就不再次说了,因为我曾经在引入静态设直方法 (292页)一节作了相当完整的叙述。 要想解开一个类里面对全局变量的依赖,方法之一是在该类中为每个相应的全局变量引入一 个获取方法.有了这些获取方法,便可以通过于类化并重写方法C3 14页)来令它们返回测试用 对象了.不过也有些情况下你可能会需要动用接口提取 (285 页 )0 下面是一个 Java 的例子: public class RegisterSale { public void addltem{Barcode code) { Item newltem Inventory.get 工 nventory() . itemFor8arcode(code) ; items .add(newltem) ; 在上丽的代码中, Inventory类是被作为一个全局变量来访问的J‘什么? "你可能会嚷起 来"这不明明是个静态方法调用吗? "没错。但从我们的意图(测试)来说,它其实就相当于 一个全局变量 。在 Java 中,该类本身就是一个全局对象,而且似乎它还需要引用 一些状态才能完 成它的工作(基于给定的 barcode (条形码〉返回相应的 item (商品》。那么,我们能否利用以获 取方法替换全局引用 (Replace Global Reference with Getter) 手法来对付这个问题呢?试试看吧 . 首先编写获取方法。注意,我们将该方法设为受保护的,这样才能在测试子类中重写它 public class RegisterSale { public void addltem{Barcode code) { Item newltem Inventory . getlnventory() . itemForBarcode(code); items . add(newltem) ; protected Inventory getlnventory () { return Inventory.getlnventory(); 然后将每一处对全局对象的引用替换为对该获取方法的调用· public class RegisterSale ( public void addItem(Barcode codel Item newltem getlnventory () . itemForBarcode (code) ; items . add(newltem); pr。巳 ected Inventory getlnventory () 回314 第三部分解依赖技术 return Inventory.getlnventory() i 接着我们创建工 nvent 。町 的测试用伪类。 由于 Invento ry 是个单件类,因此需要先降低其 构造函数的访问权限为受保护的,然后再像下面这样, 从它派生出 一个 Fakelnventory ,并在里 面放置我们需要 的逻辑· public class Fakelnventory extends Inventory { public Item itemForBarcode(Barcode code) 最后编 写 Reg isterSa le 的测试用子类 · class TestingRegisterSale extends RegisterSale { 工 nventory inventory new FakeInventory ( ) ; 步骤 protected Inventory getlnventory() return ~nventory ; 以获取方法替换全局引用的步骤如下. (l)找出你想要替换的全局引用。 (2) 给它编 写一个相应的获取方法 . 确保该获取方法的访问权限允许你在派生类中重写它 。 (3) 将对全局对象的引用替换为对该获取方法的调用。 [400[ (4) 创建测试子类并重写获取方法。 25.21 子类化并重写方法 于类化并重写方法 (Subclass and Override Method) 是面向对象程序中解依赖的核心技术 。 实际上本章所讲的许多其他的解依赖手法都是该手法的变种。 该手法的核心理念就是你可以在测试环境下利用继承来将并不关心的行为架空或访问到你 所关心的行为 。 让我们来看一看一个小型应用里面的一个方法: class MessageForwarder { private Message createForwardMessage(Session session. Message message) 第 25 章解依赖技术 315 throws MessagingException, IOException { MimeMessage 歪。 rward new MimeMessage (session); forward.setFrom (getFromAddress (message)); forward . setReplyTo ( new Address (1 new InternetAddress (listAddress) }) ; forward . addRecipients (Message. Recipien tτ'ype.TO , listAddress) i forward . addRecipients (Message.RecipientType.BCC, getMailListAddresses ()); forward . setSubj ect ( transformedSubject (rnessage.getSubject ())); forward.setSentDate (message.getSentDate ()): forwa rd. addHeader (LOOP_HEADER , listAddress); buildForwardContent(message, forward); return forward : MessageForwarder类上还有其他一些方法没有显示出来。其中某个公有的方法调用了 上面 给出的这个私有方法 createForwardMessage来创建一条新的消息。现在,假设我们不想在测 试的时候依赖于MlrneMessage类。因为 MlmeMessage用到了 一个叫做 sess 工 on (会话 ) 的变量, 而在测试的时候是没法构造出 一个真正的 session 出来的。如果我们想要将对MimeMessage 的 依赖分离出来,便可以将 createForwardMessage设为受保护的,并在一个测试用子类中重写 它3 如下: class TestingMessageForwarder extends MessageForwarder { protected Message createForwardMessage(Session session, Message message) Message forward new FakeMessage (rnessage); return forward; 在这个新建的测试子类中,我们可以做想做的事情,得到想要的分离和感知。在这个特定的 例子中,我们可以完全架空 createForwar dMessage 的大部分行为,但由于在测试的时候并不 需要用到 createForwar dMessage原来的那些行为,所以一切都没问题。 在产品代码中,我们实例化的是 MessageForwardersi 而在测试代码中,实例化的则是 TestingMessageForwarders 。你看,我们以最少的修改换来了所需要的分离。实际上只不过 是将 createForwardMessage 的访 问 权限从私有换成了受保护的。 通常, 一个类当中的功能分解的好坏决定了你使用该手法来分离依赖时的难易程度。好的情 况下,你想要分离出的依赖会被隔离在一个小小的方法当中。而在糟糕的情况下,你可能就需要 重写一个较大的方法才能分离出依赖了。 回316 第二部分解依赖技术 于类化并重写方法是个强大的手段,但用的时候也需小心。比如在前面的例子中我可以返回 一个没有主题、发件人地址等信息的空消息对象,但这么做是有特定前提的,比如我正在测试的 是能否将一个消息从系统中的一个地方传到另 一个地方,而并不关心消息的具体内容和地址,这 时候这么做才是安全的。 对我来说编程活动大多数时候是可视化的。我在工作的时候脑袋里会设想各种各样的情景, 这有助于我在不同的方案之间进行取舍。可惜的是我设想的这些图景没有一个是 illv怔图,不过 它们还是帮了我不少忙。 比如我经常设想的一种场景就是我所谓的"叠纸视图"。我看着一个方法,然后在脑海里设 想所有可用于将其语句和表达式分组的方式。对于一个方法中我可以确定出来的几乎任何细小的 代码片段,我猜想能否将其提取到一个方法中,进而在测试中将其替换为另一个方法。这就好像 我将一层半透明的纸放在代码之上,在这层半透明的纸上我可以放置用于替换目标代码片段的代 码。这叠纸就是我的测试对象,而从上往下看到的方法便是会在测试中被执行到的方法。图 25-6 匹E 展示了我所设想的场景。 public class Acount { public void deposit(int value) { balance += value log.newLogMessagE耳'date, ν'afue); log.flushO publiC class TeslingAccount exlends Account 」一一一一→ { protected void logDeposil(Date date, int value) { } 图 25 - 6 TestingAccount 浮于Account之上 叠纸视图法能够帮助我看到什么是可能的,但当我真正开始使用于类化并重写方法手法时, 仍然还是尽量去重写既有的方法。毕竟我们的目的是将测试安置到位,而在没有测试的情况下提 取方法常常是危险的。 步骤 子类化并重写方法的步骤如下: (1 )找出你想要分离出来的依赖,或者想要进行感知的地点。找出尽量少的一组方法来完成 你的目标。 (2) 确定了重写哪些方法之后,还得确保它们都是可重写的。这一步根据语言的不同有所不 同。 C++中首先要将它们设为虚函数。 Java 中它们必须是非 final方法。而在许多 NET语言中,你 还得明确地将这些方法设为可重写的。 (3) 在某些语言中,你需要调整这些方法的访问权限才能在子类中重写它们。比如在 Java和第 25 章解依赖技术 317 c#中,必须至少是受保护的方法才能被子类中的方法重写。而在c++中私有虚函数仍然是可以在 子类中重写的。 (4) 创建一个子类并在其中重写这些方法。确保你的确能够在测试用例中构建该类。 14031 25.22 替换实例变量 构造函数中的对象创建可能会带来依赖问题,尤其是当测试很难依赖这些对象的时候。大多 数情况下我们可以使用提取并重写工厂方法 (276 页)手法来对付这个问题。但对于那些不支持 在构造函数中调用虚函数的语 言, 则必须另觅他法。而办法之一便是本节所要讲的替换实例变量 CSupersede Instance Variable) 。 下面这个例子演示 了 c++ 中的虚函数调用问题: class Pager { public pager () reset () i EormConnection() ; virtual v。工 d formConnection() asse rt(state READY) ; / / nas 巳 y code that talks to hardware here void sendMessage(const std ; ; string& address , const std: :string& message) formConnection( ) ; 我们发现 pager 的构造函数中调用 iO 了 forrnConnection方法。本来,把工作委托给其他函 数来完成是无可非议的 , 但这儿的代码有点令人误解。由于 forrnConnec tion是个虚函数,所以 人们可能会想当然的以为只要对它进行于类化并重写方法(3 1 4 页)就够了。但是别着急 , 没有 调查就没有发言权,我们来简单地验证一下: class TestingPager public Pager { public : virtual void forrnConnect 工 on() ( } TEST(messaging, Pager) { TestingPager pager; pager.sendMessage{ "5551212 " , 回318 第三部分解依赖技术 -Hey , wanna go to a party? XXXOOO.); LONGS_EQUAL(OKAY , pager .getStatus()) ; 在C++中重写虚函数时,基类相应的行为会被派生类中的行为替换,这一点跟我们预期的 样,然而有一点例外,就是当你在构造函数中调用一个虚函数时,调用并不会被分发到派生类 中 的相应虚函数上去。在本例中,当 sendM essage被调用时, TestingPager : : forrnConnection 固然会被调用起来,这很好,因为我们并不想发送这条搞怪的消息给信息操作员,然而遗憾的是 结果并不如你所想。这个TestingPager对象在被构造起来的时候,基类 pager 的构造函数被调 用,后者调用了 formConnection . 而又由于在构造函数中虚函数机制是被禁止的,因此那一次 的 formConnection调用被决议到了 Pager :: formConnect 工 on上 ! C++之所以有这个规则是因为在构造函数中允许虚函 数调用可能会导致危险。设想下面这种 情况 class A puhlic A() ( someMethod() ; virtual void someMethod() } class B public A C * Ci public 8() ( C new C; • virtual void someMethod () c . doSomething ( ) ; B: : someMethod重写了 A: : sorneMethod 。当一个B对象被构造的时候,先是A的构造函数 被调用起来(这时B 的构造函数还没有进入).而A的构造函数调用了 someMethod . 如果这时允 许虚函数转发机制,也就是说让这个 s orneMethod调用转发到B 中 去的话, " c . doSomething() ;" 语句就会被试图执行,然而问题是,既然还没轮到B 的构造函数,那么就是说 c 根本就还没被初 14051 始化,结果可想而知 。 这便是C++在构造函数中禁止虚函数转发机制的原因.一些其他语言在这个问题上则要放松 一些,比如 Java 中允许这么做,但我不建议你在产品代码中这么干。 然而 .C++的这个保护机制却阻碍了我们替换构造函数中的行为 。 所幸的是还有一些替代方第 25 章解依赖技术 319 案。 如果你想要替换的对象并没有在构造函数中被使用(而只是创建的话),就可以采用提取并 重写获取方法来解开依赖 . 而如果构造函数中使用了该对象并且你需要确保在另 一个方法被调用 之前将该对象替换掉的话,就可以采用替换实例变量手法了。例如 Blend 工 ngpen : : Blendingpen () { setName(MBlendingPenM) ; ffi-param ParameterFactory : : createParameter ( .cm" , " Fade " , "Aspect Alter" ) ; ffi-param->addChoice("blend" ); ffi-param->addChoice("add") ; ffi-pararn->addChoice("filter" ) ; setParamByNarne (.cm" , "blend 门, BlendingP凹的构造函数通过一个工厂来创建 Pararneter对象。我们可 以使用引入静态设 置方法 (202 页〉手法来控制该工厂产出的对象,但这么改动的话就太具侵入性了 。 如果不介意 往 Blendingpen 上添加 一 个方法的话,我们便可以替换掉构造函数中创建出来的那个 Parameter对象,为此引入一个 supe rsedeParameter方法 : void Blendingpen: : supersedeParameter(Parameter *newParameter) { delete ffi-param; ffi-param newParameter; 在测试时,我们可以根据需要创建 B lendingPen对象,并在需要放入感知对象的时候调用 它的 supersedePararneter方法。 从表面上来说,替换实例交量看起来是个挺糟糕的放置感知变盘的手法,但在 C++ 中, 当 参数化构造函数 (297页〉手法由于构造函数中纠缠的逻辅而变得难以使用时,替换实例交 量就成了最好的选择。不过,在允许构造函数中调用虚函数的语言 中,提取并重写工厂方法 (2 76页)通常是更好的选择 。 匹E 一般而言,提供设置方法来允许外界修改被用到的于对象属于不良实践.这些设置方法允 许客户代码彻底改变一个对象在其生命周期当中的行为.在旁人可以调用这些设立方法时,你 就必须得了解目标对象的历史状态方能知道对它的方法调用会带来什么后果.而当没有设置方 法时,代码使史易于理解 . 使用" supersede" 作为这类方法的方法名前缀的一个好处就是这个单词比较奇异且不常见, 于是如果你担心别人会在产品代码中使用这个方法的话,只需搜索一下 II supersede" 就能知道结 果了 。320 第二部分解依赖技术 步骤 替换实例变量的步骤如下: (1)找出你想要替换的实例变量。 (2) 创建一个名为 supers edeXXX 的方法 ,其中 xxx是你想要替换的变量的名 字. (3) 在该方法中销毁原先被创建出来的那个对象,换入你新建出来的对象 。 如果持有该对象 的实例成员是一个引用,则需要确保该类中没有其他成员引用了原先创建出来的那个对象 。 如果 有的话,你可能就需要在 supersedeXXX方法里面多做一点工作,来确保能够安全地换入你的新 14071 对象并且确保达到正确的效果。 25.23 模板重定义 本章提到的许多解依赖技术都依赖于面向对象的核心机制,如接口以及实现继承 . 而有些新 的语言特性则提供了另外的选择。例如,如果你的语言支持泛型以及类型别名,则可以使用叫做 模板重定义 (Template Redefmition) 的手法来解依赖。下面是一个以 c++给 出 的例子· /1 AsyncReceptionPort . h class AsyncReceptionPort { prl.vate CSocket ffi_socket ; Packet ffi-packet ; int m_segmentSize; public AsyncReceptionPort () ; void Run() ; 1 / AsynchReceptionPort.cpp void AsyncReceptionPort : :Run() for(int n 0 ; n < ffi_segmentSize; ++0) int bufferSize ffi_bufferMax; if (0 rn_segmentSize - 1) bufferSize m_remainingSize; m_socket . receive(m_ receiveBuffer, buffe rSize) ; m斗packet . mark () ; 风-p acket .app end(m_r e c ei v eBuffer , buf f e r S ize ) ; 风-p a cket . pack () ; ffi-packet . finalize( ); 第 25 章解依赖技术 321 对于以上代码,如果我们想修改 run ()函数中的逻辑,就会发现要想在测试用具中执行该方 法,就必需通过一个套接字发送东西。在 c++中我们可以通过把AsyncReceptionPort 做成一个 类模板来完全避免这个问题。下面是代码修改之后的样子: 1/ AsynchReceptionPort . h template class AsyncReceptionPortlrnpl { private : SOCKET rn_socket ; Packet ffi-packet ; int m~segmentSizei public AsyncReceptionPortlmpl() ; void Run{) ; template void AsyncReceptionPortlmpl: : Run() for(int n 0 ; n < ffi_segmentSizei ++n ) int bufferSize rn_bufferMax; i f (n ffi_ segmentSize - 1) buffersize ffi_rernainingSize; ffi_socket . receive(m_ receiveBuffer, bufferSize) ; m-packet .mark() ; ffi-packet.append(m_receiveBuffer, bufferSize) i ffi-packet . pack() ; ffi-packet . finalize() ; typedef AsyncReceptionPortlmpl AsyncReceptionPort; 有了这一步作铺垫,便可以在测试文件中用一个FakeSocket 来实例化该类模板了· 1/ TestAsynchReceptionPort.cpp #include "Asy口 cReceptionPort.h~ class FakeSocket public void receive (char * int size) { . • . } } ; TEST(Run, AsyncReceptionPort) { AsyncReceptionportlmpl porti 回 回322 第二部分解依赖技术 该技术最漂亮之处就在于我们可以通过一个 typedef来避免在代码基中到处修改对该类的使 用。如果没有 typedef的话,就需要将每处对 AsyncReceptionPort 的使用换成 AsyncRece ptionPort 。这就意味着大量无聊 的工作,但难倒是不难 ,我们可以依靠编译器 (251 页)来确保修改了每一处地方。在支持泛型但不支持类型别名机制(如 typede f)的语言中,你只 能依靠编译器。 在 c++ 中你甚至可以利用该技术来替换方法的定义,只不过这么做就有点不够优雅了。 c++ 的语言规则要求你必须提供一个模板参数,所以可以选择一个成员变量并将其类型泛化为模板参 数或引入一个新的成员变量以便能够基于某个类型来参数化你的类 1 一一但我非到万不得已是不 会采取这种做法的。我会先非常谨慎地考察是否能使用基于继承的技术。 c++ 中的模板重定义手法有一个主要的缺点,即当你参数化一个类之后,它的实现代码就 必须转移到头文件中来.这会增加系统中的依赖.每次修改类模板的代码之后,使用该类的代 码都必须重编译. 一般来说我仍然倾向于采用基于继承的手法在 c++ 中解依赖.然而如果想要解开的依赖本 就处于模板代码中的话,该手法就可以用了,例如 template class CollaborationManager { Con 巳 actManager ID_contactManageri 这儿,如果我们想要解开对ffi_contactManager 的类型的依赖,考虑到在这里模板的使 用方式使得接口提取比较困难.我们可以换种方式来参数化COllaborationManager ,问题 就迎刃而解了· template class CollaborationManager { 回) ; 步骤 ArcContactManager m…contactManageri 以下是在 c++中运用模板重定义手法的步骤。在其他支持泛型的语言中步骤或许有所不同, 但原则-样: (1)在待测试类中找出你想要替换的特性。 (2) 将该类做成→个类模板,根据你想要替换的变量对它进行参数化,将方法体转移到头文 l 这里作者的叙述似乎有问题,实际上在 C++中一个类模板的模板参数井不-定要在该类模板里面使用到 . 译者注第 25 章解依赖技术 323 件中 (3) 给该类模板另起一个名字。可以将原类名后面加上 "Impl" 0 (4) 在类模板定义之后加上一行 typedef. 如 typedef XXXlmpl XXX; (其中 XXX是原 类名. T为参数化类型的具体类型,如 CSocked 。 (5) 在测试文件中包含该类模板的定义,用新的测试用类型来实例化,如 :XXXlmp lo @ITI 25.24 文本重定义 一些新的解释型语言提供了非常好的解依赖途径。在被解释的时候,方法可以被及时重定义。 比如下面是个Ruby的例子: # Account. rb class Account end def report_deposit(value) end def deposit(value) @balance += value report_deposit(value) end def withdraw(value) @balance value end 如果我们不想看到 report_depos 工 t 在测试中被执行,则只需在测试文件中重定义,并将测 试代码放在新的定义之后,如下· 得 AccountTest.rb require • runit/ 巳 estcase- require -Account- class Account end def report_deposit(value) end # tests start here class AccountTest < RUNIT : :TestCase end 有 一点 非常值得注意. ~p 我们并没有重定义整个的 Account 类 ,而只是重定义了它的 report_deposit 方法。 Ruby解释器将 ruby 代码文件的每行都看作可执行语句。而" class l. C++编译器普遍不支持模板的分离编译.一一译者注324 第二部分解依赖技术 Account" 语句就相 当于打开了 Account类 的定义,从而新的方法定义可以被添加进去 . 而 " def report_deposit(value) " 语句则开始往打开的类 中添加新方法 。 Ruby解释器并不关心该方法 14121 是否己有定义体存在,如果有的话就替换掉。 Ruby 中的文本重定义 (Text Redefinition) 也有它的缺点 新方法对旧方法的替换会一直 有效,直到程序结束 . 所以如果你后面忘记了某方法已被重定义了的话就可能会遇到麻烦 . 其实我们在 C/C++ 中也可以进行文本重定义一--{史用预处理关于这个的一个例于可以参 考第 4章描述预处理期接缝 (29 页)一节中的例子 . 步骤 在Ruby中使用文本重定义的步骤如下 g (1)找出你想要替换定义的方法所在的类 . (2) 在测试源文件开头添加一行requlre 引入包含目标类的模块 。 (勿在测试源文件的开头给每一个你想要替换的方法提供新的定义 。 四 、 l 一般叫文本替换. 译者注附景 重构 重构是改善代码的核心技术。关于重构的标准参考读物是 Martin F owler的著作《重构 z 改善 既奋代码的使用>> I 更多关于在有测试情况下的重构,我建议你去阅读 Martin 的大作。 本附录的意图是描述一个关键的重构手法:方法提取 (Ex田ctMe也od) 。从而给你一些关于 在有测试情况下的重构机制的认识。 方法提取 在所有的重构手法中,方法提取大约是最有用的一种了.方法提取手法背后的理念是我们可 以系统地将一个大的方法分解为一些小的 。这会令代码更易理解.此外我们常常还能在系统的其 他部分复用分解出来的代码块并避免逻辑重复。 在维护得很糟糕的代码基中,方法会倾向于越"长"越大.人们会不断往既有代码中添加 逻辑,于是这些方法就会不断膨胀.并且,随着这一过程的发生,一个方法使会最终承担起两 到三个不同的职责.极端情况下一个方法甚至会做十件乃至上百件事情.而方法提取正是这些 病症的解药. 当想要提取一个方法时,首要的事情便是要有一组测试。如果你有一组测试能够完全覆盖目 标方法,则可以采用下述步骤对它进行方法提取. (1)找出你想要提取出来的代码,将其注释掉。 (2) 创建-个新的空方法,给它起一个名字. (3) 在原方法中调用这个新方法. (4) 将你想要提取的代码放到新方法中. (5) 依靠编译器 (251 页)帮你发现新方法要接受哪些参数以及返回什么值. (6) 相应调整新方法的声明,声明参数列表和返回类型。 (7) 运行你的测试. l 此书英文注释版即将自人民邮电出版社出版 . 另外. 10sb皿 Kerievsky 的 4 重构与模式 ) (人民邮电出版社〉是重 构方面的另气部重要著作 , 体现了近年来重构领域的成就和发展,井充分阐述了重构与模式的深刻关系 . 编者注 国回 326 附录重 构 (8) 删掉第一步注释掉的代码。 下面是一个简单的 Java例子 2 public class Reservation { public int calculateHandlingFee (int amount) { int result 0; if (amount < 100) result += getBaseFee(amount); else { result += (amount * PREMlmCRATE-.ADJ) + SURCHARGE; return result; 以上代码中的 else语句 负责计算premlurn预定的手续费.考虑到系统中还有其他地方也需 要用到这块逻辑,所以我们可以将它提取到一个新方法中并在其他地方复用它 . 下面是第一步 public class Reservation { public int calculateHandlingFee(int amount) int result 0 ; if (amount < 100) result += getBaseFee (amount) ; else { 11 result +'" (amount * PREHIUM-.RATE_ADJ) + SU丑CHARGE; return result ; 我们想要调用新方法getPremi urnF ee ,于是创建该方法并将对它的调用加到那个 e lse子句中= public class Reservation { public int calculateHandlingFee(int amount) int resul t 0 i if (amount < 100) result += getBaseFee(amount) ; else { 11 result += (a皿。unt * PREMIUM_RATE_ADJ) + SURCHAl王.GE; result += getpremiu皿.F ee();return result; int getPremiumFee() } 附录重 下一步,将else子句中原来的代码复制到getPremiurnFee ()中,编译: public class Reservation { public int calculateHandlingFee (int amount) int result 0 : if (amount < 100) result += getBaseFee(amount)i else { 11 result += (amount * PREM工 UM_RATE_ADJ) + SURCHARGE; result +g getpremiumFee(); return result ; int getPremi 山nFee() { result += (amount * PRBMIUM_RATE_ADJ) + SURCRARGE; 构 327 正如预料的那样,编译通不过。因为被复制到 getPrerniumFee ()中的代码使用了名为 resu lt 和 amount 的两个变量,而这两个变量在 getPremiumFee() 中并未声 明 。 由 于我们计算 的 只是结果 的一部分,所以可 以直接返回计 算的结果. 此外, amount 变量可以通过为 get- 产「 1411吁 PremiumFee ()增加一个相应的参数来获得= public class Reservat 立。n { public int calculateHandlingFee (int amountl int result 0; if (amount < 100) { result += getBaseFee(amount); else { 11 result += (amount * PREMIUM_RATE_ADJ) + SURCHARGE; result += getpremi umP ee(a皿。unt); return result ; int getPremiumFee(int 缸nount) { return (amount * PRBMIUM_RATE_ADJ) + SUl是.CHARGE;328 附录重 构 现在便可以运行测试来检验我们的成果了 . 如果代码通过测试,则就可以将原来注释掉的代 码完全删除了 public class Reservation { public int calculateHa ndlingFee (int amount) { int result 0 i if (amount < 100) result += getBaseFee (amount) ; else ( result += ge t PremiumF ee ( 面noun t ) ; return result ; int getPremiumFee (int amount ) r eturn ( amount 金 PREMrUM_RATE~DJ) + SURCHARGE ; 我喜欢先将待提取的代码注释掉而不是直接删掉(你不一定要这么做),这样一来,如呆 后面的步骤出了错的话就可以容易地还』草原来的代码,并重新尝试. 上面的例子只是展示 了方法提取的一种方式 。 在有测试的情况下,方法提取是一个相对简单 14181 和安全的操作 。 而如果你有重构工具的话那就更简单了一一只需选择某函数中的一块代码,然后 选择一个菜单即可 . 工具会帮你检查那块代码能否被提取成一个新方法并让你输入新方法的名字。 方法提取是对付遗留代码的核心手段 。 你可以用它来提取重复代码、分离职责 ,以及分解长 14 191 方法 。术语表 修改点 (change point) 需要往代码中引入修改的点。 特征测试 (characterization test) 描述软件某部分的当前行为的测试,当你修改代码时能够用 来保持行为。 精合戴 (coupling count) 当一个方法被调用时传给它以及从它传出来的值的数目 。 如果该方 法没有返回值,则稿合数就是它的参数数目。否则就是参数数目加 1 0 如果你想要在没有测 试的情况下提取出 一个小方法的话,计算一下它的稿合数是很有意义的。 影响草图 (e忏'e ct sketch) 一张不大的手画草图,其作用是展示出哪些变量和方法返回值会被 某个特定的修改所影响 . 在你试图 寻找合适的测试地点时影响草图可以帮上忙。 伪对象 (fake object) 在测试中伪装成一个类的合作者的对象 . 特性草固 (featu 阻 sketch) 一张不大的手画革图,展示了 一个类中的方法是如何使用其他实 例方法和变量的 。 在你试图决定如何分解一个大类时特性革图可以帮上忙。 自由函蚊 (free function) 一个不属于任何类的函数。在C和其他过程式语言中,自由函数被简 单地称为函数。而在C++ 中它们被称为非成员函数。 Java和 C# 中 没有自 由函数 。 拦截点 (interception point) 可以编写测试来感知某些条件的地点。 连接期接缝 (link seam) 在连接期接缝处,你可以通过连接到另 一个库来替换行为.在编译 型语言中,可替换的东西包括产品库、 DLL、程序集或JAR文件。其目的是为了解除依赖 , 或感知某些在测试期间可能会发生的条件 。 仿对象( mock object) 在 内 部对条件进行断言 的伪对象。 对象接缝 (object seam) 在对象接缝处你可以通过替换一个对象为另 一个对象来"更换"行 为 。 在面向对象语言 中,我们通常通过子类化产品代码中的类并重写其方法来实现这一 点。 汇点 (pinch point) 汇点是影响结构图中的隘口和交通要冲,在汇点编写测试的好处就是,只 需针对少数几个方法编写测试,就能达到探测大量其他方法的改动的 目的 。 差异式编程 (programming by difference) 一种使用继承来往面向对象系统中添加特性的编程 方式,常常可以用于将一个新特性快速添加进系统中。而编写用来驱动新特性的测试则可以 在之后被用于将系统重构至更好的状态。 接缝 (seam) 接缝,顾名思义,就是指程序中的一些特殊的点,在这些点上你无需作任何修 改就可 以达到改动程序行为的目的 。 例如,对一个对象上的多态函数的调用就是一个接缝, 因为你可以通过子类化该对象的类来让该调用具有另 一种行为 .330 术语表 测试驱动开发 (Iesl-driven developmenl , TDD) 测试驱动开发是一种开发过程,它包括编写 失败测试用例,并一次一个地满足它们。在这么做的过程中,你通过重构来使代码尽量保持 简单。使用 TDD方法编写出来的代码默认就是测试覆盖的。 测试用具 (Iesl harness) 支持单元测试的软件。 测试子类 (Iesling subclass) 为了访问被测试类而派生出来的子类。 单元测试 (unil lesl) 单元测试的两个特点: 一、运行时间小子十分之一秒, 二 、足够小,从 而当失败的时候能够帮你锁定问题。索引 索引中页码为英文原书页码,与本书中页边标泣的页码一致 . 嗣 nclude directiv田 (#include 指令) , 129 A abbreviations (缩写), 284 access protection, subverting (推翻访问保护), 141 Account (账户), 120, 364 ActionEvent class (Act i o nEve nt 类), 145 ACTIOReportFor, 108 Adapt Parameter (参数适配) , 142, 326-329 adapting paramete附 (参数适配) , 326-329 addElement. 160 AddEmployeeCmd. 279 getBody, 280 write method (写方法), 274 adding fea阳res (添加特性),见 featur白, adding AGGController. 339-341 algorithtru; for changing legacy code (修改遗留代码的算 法), 18 breaking dependencies (解依赖) , 19 finding test points (寻找测试点), 19 identifying change poin臼 (确定修改点), 18 refactoring (置构), 20 writing tes饱 (编写测试) , 19 aHased parameters (参数别名) , getting classes into (将英 放进) t 田 t hamesses (测试用具) , 133-136 analyziog effects (影响分析) , 167-168 API calls (AP I 调用),另见 librari 目 restructuring (篮构), 199-201,203-207 s kinning 皿d wrapping (剥离井外覆), 205-207 applîcatîon arcbit田阳陀, preserving (保持应用架构), 215-216 conversatlOn ∞n田阳〈会话概念), 224 NakedCRC (椒 C RC) , 220-223 telling story of system (讲述系统的故事), 216-220 architecture of system, pr田 erving (保持系统架构), 2 1 5-216 con versatlOn ∞ ncepts (舍话概念), 224 Naked CRC, 220-223 telling s t。可 ofsystem (讲述系统的故事), 216-220 automated refactoring (自动重构) monster methods (巨型方法), 294-296 t 田 ts (测试), 46-47 automated tests (自动测试), 185-186 B characterization tests (特征测试), 186- 189 forcl国ses (对类的特征测试 ), 189-1 90 heuristic for writing (编写特征测试的启发式方法) 195 targeted testing (目标测试), 190-194 8eck. Kent . 岖,220 behavior (行为), 5 preserving (保持) , 7 behavior of code (代码行为),见 ch aracterization tests (测试) 188 8indName method (8 indName 方法), 337 8 ondRegistry. 367 8rant, John. 45 8rcak Out Method Object (分解出方法对草) , 137, 330-336 monster mcthods (巨型方法) , 304 breaking (解〉 dependencie5 (依赖), 19-25, 79-85, 135 Interception Points (拦截点) , 174-1 82 bre哇jng up classes (分解类), 183 bug finding (寻找 bug) versus characterization te由{与特征测试), 188 when to fix bugs (何时修正 bug) , 190 bugs, fixing in 50食ware (软件中的 bug 与修正), 4-6 build dependencies (构建依赖), breaking (解开) , 80-85 buildMartShcet. 42 332 索 51 bu lJ eted methods (项目列表式方法), 290 c C macro preprocessor (C 的宏预处理). testing procedur剖 {澜试过程) code (代码), 234-236 C++ , 127 compilers (编译器), 127 e ffe c t reasoning t ∞ 1 , (用于影响推测的工具), 166 Template Redefinition (摸极重定义) , 410 calls (词用), 348-349 CCAlma ge , 139-140 ce lJ .Recalculat e. 40 c ban ge points. identifying (确定修改点), 18 chang in g 皿 ftware (修改软件) , 且 so ftw缸仓. cbanging characterization tests (特征测试), 151 , 157, 186-189 for c l asses (类的特征测试), 189-190 heuristi c for writing (编写特征测试 的 启发式方法), 195 阳电 eted te s ting (目标涮试), 190- 194 characters. writing null (写 null 字符) cbaracters (字符), 272 cl 剖S田〈类〉 Account , 364 A c tio nE vent. 14 5 Ad dE mployeeCmd. 279 AGGControllcr. 339 big c1 ass国(大类), 247 extracl ing c lasses from (从大类中提取类) , 268 problems with (大类 的问题), 245 refactoring (大类的重构), 246 responsibilities (职 责) ,见 responsibiliti臼 breaking up (分解), 183 CCAImage. 139-140 characte ri za tion tesls (特征测试), 189-190 C la ssReader. 155 Co mmand , 281-282 Coord inate , 165-166 Cp pCI 醋, 15 6 ExtemalRouter. 373 extracting ( 提取), 268 to curτent c1 ass first rnonster methods (先至 当前类 , 巨型方法) , 306 fakeConnection , 110 gett ing into tesl hamesses (放入测试用具〉 alîased parameters (参数别名), 133-136 global dependency (圭周依赖) , 11 8- 126 hidden dependency (隐藏依赖), 1 日- 116 huge parameter li sts (超长参数列表), 116 - 118 include dependencies (包含依赖) , 127-130 parameters (参数), 106 - 11 3 , 130-132 lndustrialFacility. 135 instances (实例) , 122 interfaces (接 口 ) , ex tracting (提取), 80 LoginCommand. 见 LoginCommand ModelNode. 357 nammg conventi ons (命名惯例), 227-228 once dil emma (" 次性"困境), 198 Originatio nP e盯nit. 134 ~ 135 Packet. 345 PaydayTransaction. 362 ProductionModelNode. 358 RulePars 町, 250 Schedu ler. 128 SymbolSource. 150 test harnes ses (测试用具) . parameters (参数) , 1!3 tesl in g s ubcl 坦 ses (测试于类), 227 , 390 C lassReader. 155 code (代码〉 editing (编辑),见时 iting code effect propagation (影响传播), 164 - 165 modularity (模块性) , 29 preparing for changes (修改前的准晶), 15 7- 163 test code versus production c 叫时测试代码与产品代码), 110 ∞de reuse (代码复用) avoid in g library dependen c i 田 (避免库依赖) , 197-19 8 restructuring API calls (重构 API 调用), 19 9 -207 collaborating fake s (伪造告作者) . mock 。 bjects (仿对fj:!.) , 27-28 Command c1 ass (Co mmand 英), 28 1- 282 write m e thod (w口 te 方法), 277 writeBody me曲。d (wri teBody 方法), 285 Co nunaodlQu e可 Se pa 阳 tion (命令/查询分离), 147-14 9 commandChar variable (commandChar 变量), 276-277 Com皿。d i tySe l ectio nP ane l . 296 compilers (编译器) C++ , 127 叫 iting code (编辑代码), 3 15-3 16 compi liñ g Sch edul e r (编译调度), 129 completing definitions (定义补圭), 337-338 Composed Method (testing changes) (告成方法(测试修 改)), 69 concrete c1ass dependencies versus interface dependencies (具体类依赖与接口依赖), 84 CQnst keyword ( const 关键宇), 164 ∞ nstructors (构造函数), Parametcrize Constructor (参数 化构造函数), 379-382 conventions (惯例), c1ass naming conventions (类命名惯 制。), 227-228 Coordinate c1ass (Coor世 inate 类), 165-166 coordinates, 165 coupling c。四川锅台数), 301-302 Cover and Modify (覆盖井修改) , 9 Coverage (覆盖), 13 CppClass , 156 CppUnitLite, 50-52 CRC (Class, Responsibi1ity, and Collaboration吨, Naked CRC (CRC (类、职责与合作),幌 CRC) , 220-223 CreditMaster, 107-108 CreditVal由阳, 107 Cwmingham, Ward, 220 cursors (游标) , \1 6 D data lype conversion errors (数据类型转换错误), 193-194 db_update, 36 debugging (调试),见 bug finding decisions (决定), looking for (寻找), 25 1 declarations (声明), 154 decorator pattem (装饰模式), 72-73 Definition Completion (定义补全), 337-338 definitions (定义), completing (补全), 337-338 dejection (沮丧), overcoming (战胜), 319-321 delegating instance metbods (委托实例方法), 369-376 deleting unused code (删除不被使用的代码), 213 dependencies (依赖), 16, 18, 21 avoiding (避免), 197-198 breaking (解开),见 brea k.i ng; depend四cy-breaking techniqu田(解依赖技术) getting c1 asses into test hamesses (将类放入测试用具), \1 3- \1 6 gleaning from monster methods (从巨型方法中拾取), 303 global dependencies (全局依赖) , ge剧ng classes ioto test 索 号| 333 hamesses (将类放入测试用具), \1 8-126 include dependenci田(包含依赖). getting c1出S目 into test harness田(将樊放入测试用具), 127-130 m prc阳 dural code (在过程式代码中). avoiding (避免), 236-239 Pusb Down Dependency (依赖下推), 392-395 ,e咄咄阳ring API calls (重构 AP I 调用), 199-207 dependency-breaking techniques (解依赖技术〉 Adapt Parameter (参数适配), 326-329 Break Out Method Object (分解出方法对聋), 330-336 Definition Completion (定义补圭), 337-338 Encapsulate Global Refcrcnces (封装全局引用), 339-344 Expose Static Method (事露静态方法) , 345-347 Ex国.ct and Override Call (提取井重写调用), 348-349 Extract 皿d Override Factory Me由od( 提取并重写工厂方法), 350-35 1 Extract and Override Gettcr (提取并重写获取方法), 352-355 Extract Implementer (实现提取), 356-361 Extract Interface (接口提取) , 362-368 Introduce Instance Delegator (引入实例委托), 369-371 』忧。duce Static Setter (引入静态设置方法), 372-376 Link Substitution (连接鲁换), 377-378 Parameterize Constructor (参数化构造函数), 379-382 Parameterize Methods (~数化方法), 383-384 Primitivize Parameter ( 朴素化参数) , 385-387 Pull Up Feature (提升特性) , 388-391 Push Down Dependency (依赖下推), 392-395 Replace Function with Function Pointer (替换函数为函数 指针), 396-398 Replace Global Reference with Getter (替换全局引用为 获取方法), 399-400 Subc1ass and Override Method (于类化并重写方法), 401 -403 Supersede Iostance Variable (瞥换实例变量), 4例- 407 Template Redefinition (模板重定义), 408-4\1 Text Redefmition (主本重定义), 412-4 13 design (设计), improving software design (改善软件的) , 见 refactoring dir回tories , locations for t田 t code, 228-229 drawO, Renderer, 332 duplication (重复), 269-271 removing (消除), 93-94, 272-287 renaming c1 asses (m命名类), 284 334 索 号| E Edit and Pray (编辑井祈祷) , 9 Edit and Pray programming ("编辑井祈祷"式编程), 246 editing code (编辑代码〉 compilers (编译器), 315-3 16 hyperaw町 edi ting (超感编辑), 310 Pair Prograrnming (结对编程), 316 preserving signatures (签名保持), 312-314 single-goal ed阳、 g (单一 目的编辑), 311-312 effect analysis (影响分析〉 IDE support for (的 IDE 支持), 152 leaming from (从"影响分析"获得认识), 167-168 eff,国 t propagation (影响传播), 163-165 preventing (阻止), 165 effi国H四son i ng (影响推测) , 152-1 57 tool5 for (的工具), 165-167 effect sketches (罪响草图), 155,254 pinch points (汇点), 108-184 effect sketcbes (影响革图), simplifying (简化) , 168-171 effects (影响) , encapsulation (封装), 171 effeclS of change (修改的影响) , understanding (理解), 212 Elements, 158 elemcnts addElement, 160 generatelndex, 159 enabling points (使能点), 36 Encapsulate Global References (封装圭局引用), 239 , 3 1 ι 316, 339-344 encapsulating global references (封装圭局引 用 ), 339-344 encapsulatioo (封装). effects (影响) , 171 encapsulation boundaries (封装边界), pinch points as (汇 点作为封装边界), 182-183 crrQr 1∞ali血仇。n (错误定位), 12 errors (错误〕 changing 50企ware (修改), 14-18 type conversion (类型转换), 193-194 evaluate method (求 ilî方法) , 248 exceptions (异常), throwing (抛出) , 89 execution time (执行时间), 12 Expose Static Mcthod (暴露静态方法), 137, 330, 345-347 exposing static metbods (暴露静态方法) , 345-347 ExtemalRouter (ExtemaIRouter), 373 Extnlct and Override Call (提取井重写调用) , 348-349 Extnlct and Override Factory Method (提取井重写工厂方 法), 116, 350-35 1 Ex田 ct and Override Getter (提取并重写获取方法), 352, 354-355 Extnlct lmplementer (实现提取), 71 , 74 , 80-82,侣, 117 , \3 1,356-361 Extract Interface (接口提取) , 17, 71 , 74, 80, 85, 11 2-1\4, 1\7, 131, 135, 326, 333, 362-368 Extnlct Metbod (refactoring) (方法提取(重构川, 415-4 19 extracting (挺取〉 calls (调用 ), 348-349 classes (类) , 268 to current class first (先至当前类) , monster methods (巨 型方法), 306 facto叩 method (工厂方法), 350-351 getters (获取方法) , 352-355 implementers (实现), 356-361 interfaces (接口), 362-368 monster mcthods (巨型方法) , 301-302 small pi ec白川、片), monster methods (巨型方法), 306 extnlcting interfaces (接口提取), 80 extnlcting methods (方法提取) , 212 refactoring tool8 (重构工具) , 195 I Responsibility-Based Extnlction (基于职责的提取), 206-207 targeted tcsting (目标测试) , 190-194 extnlctions (提取), redoing in monster methods (举行方法中的重构 ), 307 F factory method (工厂方法) , 350-351 failing test case⑤(失败测试用例), writing (编写), 88-91 fake objects (伪对草), 23-27 distilling fakes (精炼伪对草) , 27 阳 ts (测试), 26 FakeConnection class ( FakeCo皿ection 类), 110 6届四 (伪造〉 col1aborating mock objects (合作者仿对象), 27-28 distilling (精炼), 27 fake objects (伪对鼻),见 fake objccts f,a阳盹 skctches (特性草图) , 252-254 features (特性), adding (添加), 87 with programming by difference (差异式编程), 94-104 with test-driven development (TDD) (测试驱动开发), 88-94 FeeCalculatof, 259 feedback (反馈), 11 testìng (测试〉 也edback lag time (反馈延迟). effect 00 length of time for changes (对修改造成的影响 ), 78-79 file inc1 usion (立件包吉), testing procedural code (测试过 程式代码), 234-236 finding ( 寻找〉 sequences( 序列) . monster methods (巨型方法), 305 -306 test points (测试点), 19 FIT (Framework for Integration) (FIT (集成框架川, 53 日t. Fixture, 37 fi t. Parse, 37 Fitnesse, 53 fixing bugs in software (修正软件中的 bug) , 3-4 fonnConnection m创 hod (fonnConnection 方法), 404 formStyles method (fonnStyles 方法), 349 Fowl町., Martin (Martin Fowler), 325 Framework for Integration Tt田ts (集成测试框架) (FIT), 53 Frameworks (框架), 118 globaldependency (全局依赖), 118-126 function pointe囚(函数指针〉 replacing (替换), 396-398 testing procedural code (测试过程式代码), 238-239 臼nctional changes (功能性改动), 310 functions (函数) G PostReceiveErr肘, 31 replacing with function pointers (替换为函数指针), 396-398 runO, 132 send message (发送消息), 114 SequenceHasGapFor (S呵uenceHasGapFo叶, 386 substituting (替换), 377-378 Gamma, Erich (Erich Gamma), 48 GDillrush, 333-334 Generatelndex, 158-162 elements, 159 generating indexes (生成索寻 1) , 158 getBalance, 120 getBa1ancePointO, 152 getBody, AddEmployeeCmd, 280 getDeadTime, 389 getDec1 arationCountO, 153 getElement, 160, 163 getElementCount, 160, 163 索 sl getlnstance me由。d (ge山 stance 方法), 120 getlnterface, 154 getKSRStreams, 142 getLastLineO, 27 getName, 153 ge配rs (获取方法〉 extracting (提取), 352-355 1azy ge阳阳(惰性获取方法), 354 overriding ( 重 写), 352-355 335 J replacing global references (替换全局引用), 399-400 getValidationPercent, 106, 110 G1eaning Dependenci田(依赖拾取), monster methods ( 巨 型方法), 303 global dependency (全局依赖), getting c1asses into test hame巧 ses (将类放入测试用具), 118-126 global references (全局引用〉 encapsu1ating (封装), 339-344 replacing with getters (替换为获取方法), 399-400 伊 phics Ii brari 田(图库), link seams (连接期接缝), 39 grouping methods (方法分组), 249 H hidden metbods (隐藏方法), 250 getting methods into test hamesses (将方法放入测试用 具), 138-141 hierarchi田, pennits (层次,允许), 134 higher-Ievel t白ting (高层测试), 14, 173-174 Interception Points (拦截点), 174-182 H忧pFi怆Collection , 141 H叩 PostedF ile 0均 ects (H叩 PostedFi1e 对象), 141 HttpServletRequest, 327 hyperawa四 editing (超感编程), 310 IDE, support for effe只ct analysis (影响分析的 IDE 支持), 152 identifYing change points (确定修改点), 18 implementers (实现), extracting (提取), 356-361 inc1ude dependencies (包吉依赖), getting c1 asses into te&J harnesses (将类放入测试用具), 127-130 ind叩 endence (独立 ). removing duplication (消除重复), 285 indexes (索引), generating (生成), 158 336 索 ~I lndustrialFacility, 135 1 由 eritance (继承), programming by di 仔erence (差异式编 程), 94-104 lnMemoryDirectory, 158, 161 instances (实例〉 class四(类), 122 Introduce Instance Delegator (引人实例委托), 369-376 Supersede lnstance Variable (瞥代实例变量), 404-407 testing (测试) , 123 PennitReposit。可', 1 21 lnterception Points (拦截点), 174-182 Interface Segregation Principle (lSP) (接口隔离原则 OS肘, 263 interfaces (接口), 132 dependencies versus concrete c1 ass dependencies (接口依赖与具体类依赖), 84 回国 cting (提取), 80, 362-368 na皿ng (命名), 364 ParameterSource, 327 segregating (隔离), 264 intemal relationships (内在联系). looking for (寻找), 251 lntroduce Instance Delegator (引入实例委任) , 369-371 lntroduce Sensiog Variable (引入感知变量), 298-301 Introduce Static Setter (引入静态设置方法), 122, 126,341 ,372-376 ISP (Interface Segrcgation Principle) C ISP( 接口隔离原则川, 263 J Jeffries, Ron , 221 JUnit, 49-50, 217 k keywords (关键宇〉 const, l64 mutable, 167 knobs (旋钮), 287 L lag time (延迟时间), effect 00 length of time for chang田 (对悻改时间产生的影响), 78-79 language features (语言特性), ge仙 ng methods into test hamesses (将方法放进测试用具), 141-144 k可 getters (惰性获取方法), 354 Lean 00 the Compiler (依靠编译器), 125, 143, 315 legacy code (遗留代码), changing algorithms (修改算法), 18 breaking dependencies (解依赖), 19 日nding test points (寻找测试点), 19 identifying change points (确定修改点), 18 refactoring (重构), 20 writing tests (编写测试), 19 legacy systems versus well-maintained systems (遗留系统与 良好维护的系统). understanding of code (了解代码), 77 length of time for changes (修改 1m 时), 77 breaking dependenci因 〈解依赖), 79-85 rea回ns for (原因), 77-79 test hamess usage (测试用具的使用), 57-59 Sprout Class (新生类), 63-67 Sprout Method (新生方法), 59-63 Wrap Class (外覆类), 71-76 WrapMe出时(外覆方法), 67-70 libraries (库),又见 AP I cal1s dependencies. avoiding. 197-198 graphics Iibraries (图库). link S回阳(连接期接缝), 39 mock object librari田(仿对革库), 47 Link Seam (连接期接缝). testing procedural code (测试过 程式代码), 233-234 link seams (连接期接缝), 36-40 Link Substitution (连接期管换), 342, 377-378 Liskov substinttion principle (LSP) violatlon (违反 Liskov 兽换原则 (LSP 川, 101 listing markup for understanding code (用清单标记手法辅 助理解代码), 211-212 LoginConunand. 278 write me曲。d (wri te 方法), 272-273 LSP (Liskov substi阳tion principle) violation (违反Liskov 替换原则 (LSP 川, 101 M macro preproc田 sor (宏预处理). testing procedural code (测试过程式代码), 234-236 mail 四川 ce (邮件服务), 113-114 manual refactoring (手动重构) . monster mcthods (巨型方 法), 297 Break Out Method Obj出现(分解出方法对革), 304 extracting (提取), 301-302 Gleaning Dependencies (依赖拾取), 303 Introduce Sensing Variable (引 λ感知变量) , 298-301 mark.ing up listings for understanding code( 借助清单标记手 法来理解代码), 211-212 MessageForwarder, 401 method objects (方法对盘), breaking out (分解出) , 330-336 台。m monster methods (从巨型方法中), 304 method use ru1e (方法使用规则), 189 methods (方法〉 ACTIOReportFor, 108 BindName, 337 drawO, Renderer, 332 effl肌 ts of change (修改的草响), understanding (理解), 212 eva1uate, 248 Extract Method (refactoring) (方法提取(重构川, 415 -4 19 回回cting (提取), 212 formConnection me曲。d ( formConnection 方法 ), 404 fonnSty 1 田 .349 ge由 .lan四 PointO.152 getBody, AddEmployeeCmd, 280 getDeclarationCountO, 153 getE1ement, 160, 163 getElementCount, 160, 163 getlnstance, 120 gctInterface, 154 getKSRStreams, 142 gctting into test hamesses (放进测试用具) hidden methods (隐藏的方法), 138-141 language features (语言特性), 141 -144 side effects (剧作用), 144-150 getValidationPercent, 110 grouping methods (方法分组), 249 hidden methods (隐藏的方法), 138-141,250 1.可 getters (惰性获取方法), 354 monster methods (巨型方法),见 monster methods non-Vl由础 1 methods (非虚方法), 367 Parameterize Method (参数化方法), 383-384 perfonnCotruDand, 147-149 populate, 326 private methods (私有方法), testing for (的测试), 138 public methods (公有方法), 138 readToken, 157 recalculate, 306 recordError, 366 resetForTestingQ, 122 索 51 337 Responsibility-Based Ex tTaction (基于职责的提取), 206-207 白地tricted override di1emma (受限的重写困境) , 198 RFDIReportFor, 108 scanQ, 23-25 setUp, 50 showLine, 25 snapQ, 139 Sprout Method (新生方法), 246 static mcthods (静态方法). cxposing (暴露), 345-347 Subclass and Override Method (于类化井重写方法), 401 -403 suspend frame (延迟帧), 339 targeted tcsting (目标测试), 190-194 tearDown, 375 testEmpty, 49 understanding struc阳回 of (理解方法的结构), 211 update, 296 updateBalance, 370 validate, 136, 345 writc, 273-275 AddEmployα~md . 274 Conunand c l 国5 (Command 类), 277 LoginCommand, 272-273 writeBody, 281 Command class (Conunand 类), 285 啊iting te白白or (为方法编写测试), 137 migrating to object orientation (迁移至面向对直), 239-244 Mike HiII (Mike HiIl) , 51 mock objects (仿对盘), 27-28, 47 ModclNode c1 ass (ModelNode 类), 357 modularity (模块性), 29 monster methods (巨型方法), 289 automatcd refactoring (自动重构), 294-296 bulleted mc由呻(项目列表式方法), 290 回国阳IgJllpteces( 提取小块代盼, 306 ex田cting to current class first (先提取至当前类), 306 finding sequenc田{寻找序列), 305-306 manual refactoring (手动重构),见 manua l refactoring redoing extractions (重新提取), 307 ske1etonize methods (方法主干提取), 304-305 snarled me地hods (锯齿状方法), 292-294 mora1e (士气), inc陀剧 ng (增长), 319-321 mutable, 167 338 索 51 N NakedCRC (棵 CRC) , 220-223 naming (命名), 356 interfaces (接口), 364 naming conventions (命名惯例) abbreviations (缩写), 284 c1 asses (类), 227-228 ncw cons阳 ctors (新构造函数), 381 non-virtual methods (非虚方法), 367 normalized h阳a时ly (规范化层次结构), 103 null characters ( null 字符), 272 Null Object Pattem (空对靠模式), 112 NullEmployee, 112 nulls,I1 I-112 NUnit, 52 o object orientation (面向对盘). migrating to (迁移至), 239-244 object seams (对聋接缝), 33, 40-44, 239, 369 objects (对盘〉 creating (创建), 130 fake objects (伪对靠), 23-27 distilling (精炼), 27 tests (测试), 26 HttpFileCollection, 141 HttpPostedFile, 141 mail service (邮件服务), 113-114 mock objects (仿对靠), 27-28, 47 once dilemma (" 次性"困境), 198 00 languages (00 语言), C忡, 127 Opdyke, Bi1I. 45 openlclosed principle (开放/封闭原因U) , 287 optimization (优化), changing 50企ware (修改软件), 6 OriginationPennit, 134-135 Orthogonality, 285 ovcrriding (重写〉 calls (调用), 348-349 factory method (工厂方法), 350-351 getters, 352-355 overwhelming feelings (难以承受的感觉), overcoming (克 n~) , 319-321 P Packct class (Packet 类), 345 PageLayout, 348 Pair Progrannning (结对编程), 316 papcr vicw (paper 视图),咽 2 parameter lists (参数列表). getting class田 into test hamesses (将类放入测试用具), 1 1 6 -118 Parameterize Constructor (参数化构造函数), 114-116, 126, 171 ,242, 341 ,379-382 Parameterize Method (参数化方法), 341 ,383-384 parameters (参数) adapting (适配), 326-329 aliased parameters (参数别名), 133-136 ge饥 ing c1田S田 into test ham自由s (将类放入测试用具), 106-113, 130-132 Parameterize Constructor (参数化构造函数), 379-382 Parameterize Method (参数化方法), 383-384 PrimitivÎze Parameter (朴素化参数), 385-387 ParameterSource, 327 Pass Nul1 (传 nul1), 62, 111-112, 131 passing nul1s ({~ nul1) 11 2 patterns (模式) Null Object Pattem (空对盘模式), 112 Singleton Design Pattem (单件设计模式), 372 PaydayTr拥 saction class (PaydayTransaction 类), 362 performCommand, 147-149 Permit, hierarchies (继草体系), 134 PermitRe有posit。可, 120-125 pinch poin恼(汇点), 80 as encapsulation boundaries (作为封装边界), 182-183 testing with (辅助测试), 180-184 pointers (指针),见 function pointers populate method (populate 方法), 326 PostReceiveError, 31 , 44 preparing for changes to code( 为代码修改做准备), 157-163 preprocessing seams (预处理期接缝), 33-36, 130 Preserve Si伊阳 res (签名保持), 70, 240, 312-314,331 preserving (保持) behavior (行为), 7 signatures (签名), 312-314 preventing effect propagation (阻止影响传播), 165 pnma可 responsibilities (主要职责), looking for (寻找), 260 Primitivize Parameter (朴素化参数), 17, 385-387 princîples (原则), openlclosed principle (开放/封闭原因0 , 287 private methods (私有方法), testing for (测试), 138 problems w恤 b i g c1 asses (大类的问题), 245 pr∞edura l code (过程式代码), testing (测试), 231-232 with C macro preproce田or (利用 C 的宏预处理机制), 234-36 with file inclusion (利用文件包吉), 234-236 funCtiOD pointers (函数指针), 238-239 with Link Seam (利用连接期接缝), 233-234 migrating to object orientation (迁移至面向对草), 239-244 Test-Driven Development σDD) (测试驱动开发 (TDD)), 236-238 production code versus test code (产品代码与测试代码), 110 ProductionModelNode, 358 programming (编程). redi scovering 臼n in (寻找其中的乐 趣), 319-321 programming by difference (差异式编程), 94-104 propagating effects (影响传播),见 effect propagation public methods (公西方法), 138 Pull Up Feature (提到特性), 388-391 Push Down Dependency (依赖下推), 392-395 R readToken method ( readToken 方法 J , 157 reasoning (推断) effect reasoning (影响推断), 152-157 tools for (的工具), 165-167 reasoning forward (前向推断), 157-163 reasoning forward (前向推断), 157-163 Recalculate, 40-42 recalculate method (recalculate 方法), 306 recordError, 366 redefining (重定义〉 templates (模板), 408-4 11 text (立本), 412-413 redoing extractions (重新提取), monster methods (巨型方法), 307 refactoring (重构 J , 5 , 20 ,晤, 415 automated refactoring (自动重构〉 monster methods (巨型方法), 294-296 and te由(和测试), 46-47 big classes (大类), 246 Extract Method (方法提取), 415-4 19 manual refactoring (手动室构). monster methods (巨型方法), 297-301 索 scratch refactoring (草稿式重构), 264 refactoring t∞Is (重构工具), 45-46, 195 scratch refactoring for understanding 51 339 code (利用草稿式重构来理解代码), 212-213 R呐ctoring: Improving the Design 01 Exisling Code (Fowler) (重构改善既有代码的设计 (Fow l er 川, 415 references (引用). En国:psulate Global References (封装圭 局引用), 339-344 regression testing (回归测试), 10-11 relationships (联系). looking for intemal relationships (寻找内部联系), 251 removing duplication (消除重复), 93-94, 272-287 renaming c1目S 田(重命名类), 284 render币 r. drawO. 332 Replace Function with Function Pointer (替换函数为函数指 针), 396-398 Replace Global Reference with GeUer (替换全局引用为获 取方法), 399-400 replacing (替换) functions with function pointers (函数为函数指针), 396-398 global references with geUers (圭局引用为获取方法), 399-400 Reservation (保留), 256-257 resetForTestingO, 122 responsibilities (职责), 249 decisions (决定), looking for d四 Is10ns 也就 can change (寻找可以改变的决定), 251 grouping methods (方法分组 J , 249 hidden methods (隐藏的方法), 250 intemal relationships (内部联系), 251-253 ISP (Interface Segregation Principl呻(I SP (接口隔离原 贝 IJ)), 263 looking for prima叩 responsibility (寻找主要职责 J , 260 pnmary respons 刷 lities (主要职责), 260 scratch factoring (草稿式重构), 264 seg田gating interfaces (接口隔离), 264 separating (分离 J , 211 白 ategy for dealing with (应付的战略), 265 tactics for dealing with (应付的战术), 266-268 Responsibility-Based Extmction (基于职责的提取), 206-207 restricted override di1emma (受限的重写困境), 198 340 索 ~I retum values (返回值). effc回 t propagation (影响传播), 163 RFDIReportFor, 108 RGHConnections, 107-109 risks of changing 50食ware (修改软件的风险), 7-8 Roberts, Doo. 45 RuleParser c1ass (RuleParser 类), 250 础。, 132 s safety nets (安全网), 9 scanQ.23-25 Scheduler, 128-129, 391 compiling (编译), 129 SchedulerDisplay, 130 SchedulingT:臼k, \3 1- \32 scratch refactoring (草稿式重构), 2臼 for understanding code (理解代码), 212-2 \3 seams (接缝), 30-33 enabling points (便能点), 36 link seams (连接期接缝), 36-40 。bject seams (对革接缝), 33, 40-44 preprocessing seams (预处理期接缝), 33-36 segregating interfaces (接口隔离), 264 send message functioD (发送消息的函数), 114 sensing (感知 ), 21 -22 sensing variables (感知变量), 301 ,304 scparating responsibilities (职贸分离), 211 S叩aration 分离), 21-22 Sequen臼H田GapFo飞 386 S呵uenc田(序列). finding in monster methods (在巨型方 法中寻找), 305-306 sctSnapRegion, 140 setTestinglnstance, 121-123 setUp method (setUp 方法), 50 showLine, 25 side effects (副作用). getting metbods into test harnesses (将方法放进测试用具), 144-150 signatures, preservÎng (签名保持), 312-3 14 simplifying (简化) effect sketches (影响草图), 168-171 system architecture (系统架构), 216-220 single responsibility principlc (SRP) (单一职责原则 (SRP)), 99, 246-248,260-262 single-goal editing (单一 目标的编辑), 311-312 Singleton Dcsign Pattem (单件设计模式), 120, 372 skeletonize methods (抽取方法主干), 304-305 skctches (草图〉 effect sketches (影响草图), simplifying (简化), 168-171 for understanding code (理解代码), 210-211 Reservation (预留), 255 skinning and wrapping API cal1s (剥离井外覆 APl 调用), 205-207 Smalltalk.45 snapQ, 139 snarled methods (锯齿状方法), 292-294 '0岱ware (软件〉 behavior (行为), 5 cbanging (修改), 3-8 risks of (之风险), 7-8 test coverings (测试覆盖), 14-1 8 阳台W缸"e vise 软件夹钳), 10 Sprout Class (testing cbanges) (新生类(测试修改川, 63-67 Sprout Method (testing changes) (新生方法(测试修改)) 59-63, 246 SRP (single responsibil町 principle) (单一职责原则 (SRP)), 246-248,260-262 static c1 ing (静态粘着), 369 static methods (静态方法) , 346 exposing (暴露), 345-347 strategies (战略〉 for dealing with responsibi1ities (对付职责), 265 for monster mcthods (对付巨型方法〉 extracting small pic由理(提取小块代码), 306 ex缸acting to current class first (先提取至 当前类), 306 finding s叫uences (寻找序列), 305-306 redoing extractions (重新提取) , 307 skeletonize methods (方法主干提取), 304-305 Subclass and Override Me出od( 于类化并重写方法), 112, 125, \36, 401 -403 Subc1ass to Override (于类化井重写), 148 subclass田 (于类化) Subclass and Override Method (于类化井重写方法), 401 -403 testing subclasses (测试于类), 390 subclassing (于类化). programming by di fTercnce (差异式 编程), 95-96 substituting functions (替代函数), 377-378 subverting acc田 s prot田 lion 推翻访问保护), 141 Supercede Instance 飞{ariable (替换实例变量 ), 11 7-1\8 404-407 suspend frame mcthod (suspend frame 方法), 339 SymbolSource, 150 system architec阳,e( 系统架构), prese凹 ing( 保持), 215 - 216 conversation concepts (会话概念), 224 NakedCRC (裸 CRC) , 220-223 telling story of system (讲述系统的故事), 216-220 T tactics for dealing wÎth responsibilities (对付依赖的战术), 266-268 targeted testing (目标测试), 190-194 TDD (Test-Driven Development) (TDD (测试驱动的开 发)), 20, 88-94, 236-238 tearDown method (tearDown 方法) 375 techniques (技术), dependency-breaking techniqu四(解依赖技术),见 d巳pendency-breaki ng techniques Template Redefinition (模板重定义), 408-4 11 templa t四(模板). redefining (重定义), 408-4 11 temporal coupling (暂时性祸合), 67 te8t code versus production code (测试代码与产品代码), 110 te业 hamesses (测试用具), 12 adding fca阳 res (添加特性), 87 and length oftime for changes (修改时间), 57-59 Sprout Class (新生类), 63-67 Sprout Method (新生方法), 59-63 Wrap Class (外覆类), 71 -76 WrapMe曲。d (外覆方法), 67-70 breaking dependencies (解依赖), 79-85 FIT, 53 Fitnesse, 53 getting c1ass田 ioto (将类放入〉 aliased parameters (参数别名), 133-136 global dependency (全局依赖), 118-126 hidden dependency (隐藏依赖), 113-116 huge paramet町 li sts (超长参数列表), 116-118 inc1 ude dependenci田(包吉依赖), 127-130 parameters (参数), 106-ll2, 130-132 getting methods into (将方法放进〉 hidden metho由(隐藏的方法), 138-141 language fi四阳r臼(语言特性), 141 -144 side effects (副作用), 144-150 test points (测试点), finding (寻找) 19 索 51 341 Test.Driveo Development (TDD) (测试驱动开发
还剩359页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 8 金币 [ 分享pdf获得金币 ] 3 人已下载

下载pdf

pdf贡献者

yc8b

贡献于2012-10-30

下载需要 8 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!