关于重构
Martin Fowler:“任何笨蛋都可以写出编译器理解的了的代码,但难的是写出其他程序员理解的了的代码。”
这章的大部分内容来自Martin Fowler的书《Refactoring, Improving the Design of Existing Code》
重构就是在不修改程序行为的前提下修改程序结构。
软件的两个价值
软件系统有两个内在价值:明显的那个是软件做什么,不那么明显的但却最关键的那个是代码结构。
软件必须是“软”件;必须容于修改。易于修改有巨大的商业价值。
世界总是不断在变的。期望不改需求是不可能成功的。所以不要想着“如果需求不变更,我就能做出完美的设计”
通过重构,我们欢迎改变。通过保持代码整洁,增加了软件的那个关键但不明显的价值。
重构的三个关键技能
成功重构的三个关键技能:
- 开发者要有个发现代码味道的好鼻子:如果能够分别出一种特定的代码味道,就更有可能消除这个臭味。
- 能够预见一个更好的设计:预见更好的结构和解耦方式需要很长时间来掌握,要求学习和经验。而其他一些就较好掌握了,比如选择更好的名字以及提取代码段。
- 能转变设计同时在整个过程中保证测试不断通过:如果没有能力转变设计,你就承受着巨大的风险。
一旦你开始编码并更好的理解细节,你很可能最后发现改完的设计和你预见的那个的工作方式不一样。
一些代码的味道
重复的代码
冗余的代码是大部分代码味道的根源。
差的命名
好的命名使得代码更好理解;差的命名则使得代码含糊。
作者的命名建议是让回顾或第一次读的时候更简单:
- 让名字可读,避免缩写。
- 在函数名中揭示想要的输出,而不是内部实现。(对于非库而是特定应用领域的函数)
糟糕的面团
意大利面代码:特别杂乱的代码,圈复杂度很高。
“馄饨代码”:包含许多小的自包含包,使用“酱汁”松散偶合起来。这是好代码。
可能的问题是如果馄饨太小了,你可能包含太多包,就很难搞清楚发生了什么。但这问题一般可以忽略不计。
“千层饼代码”是分层架构,层内高内聚,层间低耦合。层内也可能像意大利面,但起码比全是意大利面好。
“意大利面加肉团代码”:模块化的代码依赖于混乱的代码。
消除意大利面的过程常很费时。先从大函数中提取小函数,分组相关数据。提取多轮后,函数依赖的数据子集就很清晰了。分组数据和操作它们的函数就可以被提取为新模块或新层。然后“馄饨”和“千层饼”越来越多,代码结构就越来越好了。
长函数
大部分C代码味道的产物是长函数。
如果一个函数没法立刻装进你的脑袋里的话,那就太长了。
有证据表明当代码超过一个屏幕的时候不利于我们快速理解。
图:代码审查中对眼睛焦点的追踪
单一职责的函数更易于理解。
高阶函数作为(相对)低阶函数的代理。
一个好的揭示意图的函数名比在长的C函数中找杂碎的细节好。
解决长函数的方法就是将其拆分为一堆短函数。
当准备为一块代码加注释的时候,可以试着移走那块代码然后创建一个描述性名字的函数。——提取方法(在C中可能得叫做提取函数)
长函数违背了单一职责原则。其含糊了潜在地有用主意,隐藏了冗余。
Abstraction Distraction
每个函数都应该有一致级别的抽象。C函数很容易出现“基本类型偏执”,高阶的想法很容易丢失在由基本类型和操作产生的噪声中。抽象的跃进另人分神。
令人困惑的布尔语句
一长串判断语句。可以提取判断语句为一个帮助函数,这样一眼就可以看出这个判断语句在干嘛。
如改
if(!(day == EVERYDAY || day == today
||(day == WEEKEND && (SATURDAY == today
|| SUNDAY == today)) || (day == WEEKDAY
&& today >= MONDAY && today <= FRIDAY)))
return;
为:
if(!matchesToday(day))
return;
Switch Case Disgrace
带有swith/case语句的函数应该遵从单一职责原则,它只负责确定分支,然后做一些简单的事情,或者让一些代理做事情。
重复的Switch Case
如果出现了重复的switch/case,就要考虑应用开闭原则,如使用(预类型)动态接口进行替换。
多层嵌套
多层嵌套的代码难以理解。考虑在每一嵌套层使用一个辅助函数。
依恋情结
一个对象抓取另一个对象的数据,进行操作,然后放回去,或产生一些输出。C中,就是指被传来传去或全局访问的一个数据结构。
依恋情结很容易带来冗余。因为每个操作这个结构体的函数可能都要做相似的事情。
可以通过多例模型来提升模块化,增加偶合,降低冗余;即使用面向对象概念与数据抽象。
长的参数列表
当多个函数签名中出现了重复的参数,那可能就需要新的数据结构了。
为了解决长参数表问题,需要一个新模块。重复的参数表成为新模块的核心。之前调用长参数表的调用者之一成为模块的初始化函数。其他函数则将长参数替换成了一个指向新定义结构体的指针。
随意初始化
当你开始往遗留代码中添加测试时就会嗅查到“随意初始化”。因为在测试运行前需要手动初始化数据。根源很可能是因为对应数据结构没有专门的初始化函数。
于是需要把相关的初始化代码收集到一个地方,让初始化过程明显。
随意访问的全局数据
包括全局变量和数据结构。没有明确的数据拥有者,任何函数都可以访问任何全局数据。全局数据常有“随意初始化”问题。它们是一个强耦合力。对使用全局变量的代码进行测试是很困难的,因为测试用例间容易相互影响。
文件作用域变量也有类似的可测试性问题。当然这问题对单例模型不存在,因为初始化函数能对文件作用域变量进行初始化。
考虑把全局变量封装到受保护的函数调用中。其中一个函数用来初始化全局数据。如果全局数据是一个结构体,考虑转换为ADT。
注释
注释有时是必要的,但大部分时候是缺点。重构的目标是让结构良好的代码为自己说话。只在实在没办法时使用注释。
贬低注释是因为注释易于过时、失修,然后就成了谎话。
结构良好的代码并不需要许多注释。
当代码没法通过好的命名和结构来为自己说话时才使用注释。如解决一个有问题的API;或者一个为了优化而不清晰的代码;描述为什么选了某个实现而不用另一个;在模块等级建立上下文和描述职责。
对现存的注释,根据其为指引来重构代码,使用提取的良好命名的函数来替换注释,删除无用的注释。
当模块有单一职责、良好的命名、有揭示意图的函数时,你的代码就更容易为自己说话了。
注释掉的代码
被注释掉的代码会造成混乱。解决方法就是直接全删掉,反正你可以中源代码仓库中恢复。
条件编译
充斥着条件编译的代码很难理解。应该把它当做跨平台的最后一个手段。
作者更喜欢使用链接器或函数指针来隔离平台独立性问题。
重构代码实践
除了重命名外。函数提取是最常用的重构技术。其揭示了长函数的工作方式,提高它的抽象等级。
预想你希望有的代码
预想一个更好的代码结构时,在插入你希望有的代码前先加入注释会很有帮助。
选择一个从调用者角度揭示函数意图的名字。
评估函数签名
当你要进行提取一个函数时,评估需要的参数和返回值。
Don’t Burn Bridges原则
然后拷贝,而不是剪切对应代码来写一个新函数。
提出了函数后先编译通过。再删掉旧代码并调用新函数。
然后运行测试。如果测试通过,就没问题了。如果失败,那撤销就能解决问题。
Avoid Abstraction Distraction
可以进一步把(冗余的)条件判断语句提取进表明其意义的辅助函数。
移除重复
找到冗余的代码,将冗余部分提出来。
分离想法
即使没有冗余的代码,也可以对现有的函数进行进一步抽象,按照功能把函数再分为几个命名良好的辅助函数。
解决令人困惑的布尔语句
对于很长很复杂的布尔语句。使用一个辅助函数提出来。然后进行简化。
快速切换技术
快速切换使用条件编译来切换重构前后的代码。有助于快速发现问题。
#if 1
……
#else
……
#endif
使用快速切换后要记得清理代码,没必要以防万一,这只会在将来使接盘侠困惑。
移动函数
如果提取出来的函数逻辑上应该属于另一个模块,那就移过去,这样有助于降低冗余,因为其他使用这个模块的调用者也很可能会使用一样的功能。
为移动的函数添加测试
虽然原来模块的测试也间接保证了被移动的函数的正确性,但是长期来看,它应该有自己的测试。
为移动的函数去冗余
很有可能在代码的其他地方也有用到刚移动的函数的地方,找到并递增地去掉冗余。
删掉不再需要的测试用例。
封装数据结构
找到可以封装的数据结构,隐藏起来。
性能和代码量问题
对于重构带来的大量额外函数和函数调用导致的可能的速度和内存问题,作者的建议是:首先让代码有清晰的结构,然后只有在度量结果支持某种优化方式的情况下才进行优化。
Joseph M. Newcomer:没人能在没有数据的情况下预测或分析性能瓶颈,不管你认为哪里吃掉了时间,最终你都会惊讶的发现时间其实跑到了另一个地方。
事实上,优化一流的编译器常会自动内联短代码段,并且在现代电脑中,函数调用的开销几乎为0。
如果有对时间有要求的代码段,试着独立出它,这样你就可以测量它。可以写一个当函数的时间开销过大时就会失败的测试。
TEST(Performance, PostEventDeadline){
Voltage v;
unsigned long start = get_tic();
for (int i = 0; i < 1000; i ++)
QueueVoltageReading(v);
unsigned long end = get_tic();
CHECK(DEADLINE * 1000 >= end - start);
}
为遗留代码添加测试
Michael Feathers说:没有测试的代码就是差代码。不管它写的多好;不管它多么优美还是面向对象还是封装的多好。只要有测试,我们就可以快速且可验证地修改代码的行为。如果没有的话,我真不知道代码会改得更好还是更差。
遗留代码修改政策
采用TDD的团队基于遗留代码进行开发的政策:
- 测试驱动新的代码。
- 在修改遗留代码前先添加测试。
- 测试驱动对遗留代码的改变。
sprouting:当需要修改一些遗留代码时,看看你是否可以提取出一个新函数或模块来做这个新行为。测试驱动它,并从遗留代码中调用它。
当提取代码没有影响到调用者的控制流程的时候,Sprouting十分安全。当返回值用于条件判断,或者修改了数据结构时,可能你还需要为遗留代码添加一些行为保留测试。
Boy Scouts原则
Boy Scouts原则:每次修改后代码都得更干净。
这不是说得立刻清理所有垃圾,但你不能让它变得更垃圾,并且得让它起码好一点。
往长函数加东西
提取一些东西;多的是机会提出一些想法并命名它。记得添加测试来保留应有的行为。
往复杂的条件判断加东西
提取条件语句到一个良好命名的辅助函数中。同时为它写一个测试。当然也得注意这个条件语句会不会实际上属于另一个模块,如果是,移动辅助函数到对应模块去。
复制/黏贴/修改
除非是在验证假设,不要做复制/黏贴/修改代码这种事。在修改前后,提取共同的代码到一个辅助函数中。同样,为被提取的代码写测试。
神秘的本地变量名
一旦搞懂某个莫名其妙的变量名是做什么用的,重命名它。
深嵌套
对于深度嵌套,改成多个辅助函数。使用守卫语句来解决嵌套语句的条件判断问题。
遗留代码修改算法
遗留代码的修改算法:
- 找到修改点
- 找到测试点:想清楚代码中发生了什么,代码从哪里得到其输入。测试点最常出现在由函数调用形成的接缝处。但是也可能是全局变量或我们传递的数据结构等。
- 打开(或不打开)依赖:为了整合遗留代码到测试用具中或得到一些测试点,我们不得不打开依赖。为了打破对全局数据的依赖,可以把对一个有问题的全局函数的访问封装进存取器函数。然后在测试中,你可以复写存取器以更好地控制全局变量。
- 写测试:找到测试点后,写一些测试来高亮并帮助保留遗留代码的行为。
- 进行修改和重构
测试点
我们需要测试点来证明我们确实知道代码干了什么。
接缝
函数调用形成了不同代码段间最好的接缝。这些接缝是最好的测试点。接缝让我们看到及影响被测代码的行为。
接缝(seam):一个你可以在那地方改变程序行为却不用编辑那个地方的地方。
在接缝处,我们可以使用测试替身来监视传递给协作者的数据,使得测试用例能够确保被测代码给予了协作者正确的指令。函数调用接缝可以被测试替身用来通过返回值给被测代码提供间接输入。
在庞大的函数中创建接缝可能很危险;也许添加个感知变量(sensing variable)更安全。
全局变量
全局变量可以当做测试点,也可以作为一种使得特定测试值进入被测代码的机制。一旦代码被测试,你就可以开始封装全局变量了。
感知变量
感知变量有助于访问一个长函数中难以访问的数据或者中间结果。
修改巨长的函数时有很大的风险,添加几个感知变量是种降低风险的方法。
可以用感知变量来监视一串计算中的一个中间变量的值、一个状态变量的值、或可能一个循环次数计数器。
感知变量是测试可以监视的全局变量。测试可以修改对被测代码的输入,然后看其对感知变量的影响。
感知变量是在测试遗留代码时的一种妥协,处理遗留代码时,不用急着消除所有的全局变量。
调试输出感知点
后调试编程中到处分布的调试输出函数可以用作调试输出感知点。
内联监视器
往代码中插入一个特别的函数调用来报告给测试用例任何信息。调试输出感知点是内联监视器的一种特例。它做的活更像是Mock对象。
分两步的结构体初始化
依赖于公共数据结构的代码有他自己独特的初始化问题。
static DvRecorder recorderData = {
4,
{
{"Rocky and Bullwinkle", REPEAT, 2, 8, 30, 30, HIGH_PRIORITY, ALL_EPISODES},
{"Bugs Bunny", REPEAT, 9, 8, 30, 30, HIGH_PRIORITY, ALL_EPISODES},
{"Dr. Who", REPEAT, 11, 23, 0, 90, HIGH_PRIORITY, REPEATED_EPISODES},
{"Law and Order", REPEAT, 5, 21, 0, 60, HIGH_PRIORITY, ALL_EPISODES},
{ 0 }
}
};
TEST_GROUP(DvRecorder){
DvRecorder recorder;
void setup(){
memcpy(&recorder, &recorderData, sizeof(recorder));
DvrRecorder_Create();
DvRecorder_RestorePrograms(&recorder);
}
void teardown(){
DvRecorder_Destroy();
}
};
Crash to Pass算法
在复杂的C代码中,遗留函数使用的数据可能十分杂乱,连决定初始化哪个以及怎么初始化都很困难。
这种情况可以使用crash-to-pass算法来搞定。
算法的C语言表示:
void addNewLegacyCtest(){
makeItCompile();
makeItLink();
while (runCrashes()){
findRuntimeDependency();
fixRuntimeDependency();
}
addMoreLegacyCtests();
}
使编译通过
先随便提供给遗留函数其想要的数据结构和参数,NULL或简单的文本数据值什么的都无所谓。
最开始可能甚至无法编译,尝试加#include等让他编译通过,一个个error解决。
捷径方法是从调用目标函数的产品代码文件中直接复制includes,这样可以编译通过,但是依赖十分混乱。测试编译成功后,试着删减includes列表。
使链接成功
:通过链接部分产品代码或通过提供测试替身来解决未解析的内部需求。
运行崩溃时
解决所有链接错误后,可执行测试运行器最可能会直接崩溃。这是由于之前未初始化或不合适地初始化的参数。崩溃会将你领向运行时依赖。
然后只要还是崩溃的,你就得不断循环地找到并修复运行时依赖。崩溃问题解决将是个重大突破。
发现运行时依赖
发现运行时依赖:打开调试器并单步调试崩溃点。也可以观测输入数据来找到明显有问题的初始化。
操作系统和硬件对非法内存访问的支持有助于你更快地发现运行时依赖。
修复运行时依赖
专注于刚找到的依赖问题,想出接下来初始化什么然后如需要,进行一系列的1、2步。
完成后继续回到上一步,最终程序不再崩溃,然后就可以进行下一步了。
为遗留代码添加更多测试
算法如下:
void addMoreLegacyCtests(){ //take two
while (!testsAreSufficientForCurrentNeeds()){
copyPasteTweakTheLastTest();
while (!testDifferencesAreEvident()){
if (setupStepsAreSimilar())
extractAndParameterizeCommonSetup();
if (checkStepsAreSimilar())
extractAndParameterizeCommonAssertions();
else
considerStartingANewTestGroup();
}
}
}
A. 决定是否测试足够保证当前行为稳定了,如果足够了,就可以退出了。考虑:
- 输入要怎么变化
- 需要检查什么来验证被测代码的运行
- 还需要什么测试
- 有需要测试的边界条件和特例么
B.复制黏贴修改上一个测试。然后就要准备去冗余了。
C.测试间是否明显不同?如是则回到A。一般最开始肯定为否。为是只可能在经过了重构并提取了共享测试数据和辅助函数后。
D.配置步骤是否相似?如是则到E,否则到F。
E.提取并参数化通用的配置。提取通用配置进共享的测试用例变量和辅助函数中。修改的数据值常可以用作提取的初始化函数的参数。而测试间不变的参数可以放进setup()中。
F.验证步骤是否相似?如是,则到G,否则到H
G.提取并参数化通用的断言。
H.如果测试的配置和验证阶段很不同,可能得考虑新开个TEST_GROUP了。
I.成功
表征测试
表征测试(Characterization Test):描述现存软件的实际行为,通过自动化测试来保护遗留代码的现存行为不被无意的修改改变。
表征测试有助于理解被修改的代码。如果你足够地理解代码并可以为之写测试,那很可能你也足够修改它,反之亦然。
mock对象在表征测试中可能很有用。
当搞定了所有的表征测试,你就可以安全地重构遗留代码,或者开始添加新功能了。
为第三方代码写学习测试
对于第三方代码,我们默认这肯定是测试过的,我们也不负责为其写测试。但是写一些测试同样能对我们有所帮助。
作者建议在使用第三方代码前先按照你准备使用它的方式写些测试。测试可以是受控的实验,使你能够准确的发现代码做了什么。当你学会了这软件包后,将其运用到你的产品中。
这种方式的一个好的副作用是你可以很有信心的接受这个工具包的新版本。测试会反映出(你用到的地方在)新版本与旧版本不兼容的地方。
测试驱动的Bug修复
修复bug也需要测试,定位bug后,先写一个揭示bug的单元测试,不要急着立刻修复它。这样你就能确保它真被修复了,同时别人也知道了这个bug。
添加策略性测试
一个基于遗留代码的产品团队应该考虑前瞻性地添加测试以发现现存的bug并保护关键功能。
前瞻性同样有助于降低打破重要特性的风险。考虑添加覆盖系统主要使用方式的测试,即这个系统的首要目的。在错误的路径之前先测试正确的那条。添加有助于保留你产品价值的测试。添加降低安全风险或财产损失的测试。
测试模式和反模式
TDD新手写单元测试时趋于重复一些错误。这些常见,但很反生产力的模式,称为反模式。
大部分反模式的根源是你忽略了四步测试模式以及测试中的冗余。
“漫游”测试 反模式
“漫游(Ramble-on)”测试就是不知道什么时候结束。本质就是作者不知道或者不遵从四步测试模式。
复制黏贴修改重复 反模式
“复制黏贴修改重复”可以很快的产生大量测试,带给你成就感,但这不是可持续的实践,如果没有不断重构的话,会导致混乱。
Sore Thumb测试用例 反模式
Sore Thumb Test Cases:突然出现了与其他测试用例的配置和清理步骤不同的测试用例。
解决方法是为它们专门开一个测试组。
测试组间重复 反模式
有多个测试组的话很容易存在组间冗余。
建议将不同的测试组放入独立的文件,以及一个放通用辅助函数的文件。将组间冗余重构进通用辅助函数。
然后,由于辅助函数无法轻松访问测试组变量了,可能辅助函数需要更多参数或存取函数。
不尊重测试 反模式
团队中有人不认可TDD和单元测试,不写自动化测试或将未通过的测试转为忽略。这会使得测试发挥不出作用。
在采用、评估和实验TDD的早期,应该让整个团队都同意并尊重测试。改变人通常比改变技术更难。
行为驱动开发BDD 测试模式
在行为驱动开发(Behavior-Driven Development,BDD)风格中,更加强调规范而不是测试。BDD风格的测试遵从以下形式:
- 假定一些预条件
- 当某些事情发生
- 然后某些依赖于1和2的东西应该为真。
下面代码趋向传统TDD。很明显的四步测试模式:
TEST(LightScheduler, ScheduleWeekEndItsSaturday){
LightScheduler_ScheduleTurnOn(3, WEEKEND, 1200);
setTimeTo(SATURDAY, 1200);
LightScheduler_Wakeup();
checkLightState(3, LIGHT_ON);
}
按下面这样改后则变成了BDD风格。
TEST(LightScheduler, ScheduleOffWeekendAndItsSaturdayAndItsTime){
LightScheduler_ScheduleTurnOff(lightNumber, WEEKEND, scheduledMinute);
setTimeTo(SATURDAY, scheduledMinute);
LightScheduler_WakeUp();
checkLightState(lightNumber, LIGHT_OFF);
}