读书笔记 - 修改代码的艺术
- 第 1 章 修改软件
- 第 2 章 带着反馈工作
- 第 3 章 感知和分离
- 第 4 章 接缝模型
- 第 5 章 工具
- 第 6 章 时间紧迫,但必须修改
- 第 7 章 永远都无法完成的修改
- 第 8 章 如何添加新特性
- 第 9 章 无法将类放入测试用具中
- 第 10 章 无法在测试用具中运行方法
- 第 11 章 我需要修改代码,应该测试哪些方法
- 第 12 章 在同一地进行多处修改,是否应该将相关的所有类都解依赖
- 第 14 章 棘手的库依赖问题
- 第 15 章 到处都是API调用
- 第 16 章 对代码的理解不足
- 第 17 章 应用毫无结构可言
- 第 18 章 测试代码挡路了
- 第 19 章 对非面向对象的项目,如何安全地对它进行修改
- 第 20 章 类太大了,我不想让它继续膨胀
- 第 21 章 需要修改大量相同的代码
- 第 22 章 要修改一个巨型方法,却没法为它编写测试
- 第 23 章 如何知道没有造成任何破坏
- 第24章 我要崩溃了,它不会再有任何改进
- 第 25 章 解依赖技术
缺少测试的代码就是很差的代码。不管它编写得有多好;也不管它的面向对象以及封装做得有多好。有了测试,我们可以快速且有保证地修改代码的行为。相反,没有测试,我们就不知道代码是变得更好还是更糟糕。那么整洁的代码呢?如果代码非常整洁、结构良好,那就足够了吗?好吧,不要错怪我。我喜欢整洁的代码,甚至比大多数我认识的人还要喜欢,但是尽管整洁的代码非常棒,那还不够。
遗留代码,比如错综复杂,难以理清的结构,需要改变然而实际上又根本不能理解的代码,当处理遗留代码的时候,很多不同的技术和实践都非常有用,很难独立开来说明。如果你能够找到接缝、创建伪对象、使用一些技术打破依赖关系,那么最简单的修改会变得更加容易。
第 1 章 修改软件
修改软件的四大原因
为简单起见,让我们看一下修改软件的四种主要原因。
- 增加特性,行为对软件来说至关重要。它是用户所依赖的内容。当我们增加行为的时候,用户会很喜欢(前提是他们真的需要)
- 修正缺陷,我们改变或者删除他们所依赖的特性(引入缺陷),他们就会对我们失去信任。
- 改善设计,改善设计而不改变其行为的动作叫做重构。
- 优化对资源的利用,是程序所使用的某种资源,通常是时间或者内存。
第 2 章 带着反馈工作
系统变更方式
我们能够以两种主要方式来实现系统中的变更。我喜欢把它们称为“编辑并祈祷”和“覆盖并修改”。
“编辑并祈祷”方式很大程度上已经成为了业界的标准。当使用“编辑并祈祷”方式时,你会仔细地计划所要做的修改,确保理解将要修改的代码,然后开始实施修改。完成后,你会运行系统,看修改是否已经生效,然后你会到处点点,确保没有破坏任何功能。“到处点点”这项操作是必不可少的。做出修改的时候,你希望并祈祷能够正确完成,完成后,你会花费额外的时间确保达到了目的。
“覆盖并修改”是另一种代码修改方式。它背后的理念是,修改软件的时候,可以由安全网来保护我们。要修改的代码上的类似斗篷的东西,可以确保很差的修改不会漏出来,从而污染到软件的其他部分。覆盖软件用测试实现。当一段代码有一系列很好的测试的时候,我们就可以做出修改,并很快发现造成的影响是好还是坏。我们还会同样小心,但是有了反馈,我们可以更小心地做出修改。
反馈方式
回归测试,传统开发流程中,我们会在开发之后编写并执行测试。一组程序员编写代码,另一组测试人员在其之后运行测试,看它与说明是否符合。在一些非常传统的开发作坊中,这就是开发软件的方式。团队能够获得反馈,但是反馈的周期会非常长。可能在工作了几周甚至几个月之后,另一组的人才会告诉你的代码的执行是否正确。
单元测试,从概念上讲是针对单独软件组件的隔离测试。在业界,花费0.1秒运行的单元测试就是很慢的单元测试了。其他类型的测试经常会伪装成单元测试。如果出现以下情况,那么就不是单元测试:1. 测试会访问数据库;2. 测试会通过网络通信;3. 测试会访问文件系统;4. 你需要做特定的工作配置环境(像编辑配置文件)来运行测试。
高层次测试,单元测试非常棒,但是高层次测试也有自己的作用,它会覆盖应用程序中的场景和交互。使用高层次测试,我们可以一次确认一组类的行为。通常,能够做到这一点你就可以更轻松地为单独的类编写测试
测试覆盖,对真实数据库的连接。那么我们在测试中要如何处理呢?我们需要为测试设置数据库吗?那样做的工作量很大。通过数据库来测试会很慢吗?不管怎样,我们现在不需要特别关心数据库,所有这些问题都是依赖的问题。当类直接依赖于难于在测试中使用的东西时,它们就很难修改,也很难使用。
遗留代码修改方法
当你需要在遗留代码库中做修改的时候,以下是你能够使用的方法。
- 确定变更点,你需要做变更的地方在很大程度上取决于你的架构。
- 找到测试点,在某些情况下,找到编写测试的地方很容易,但是在遗留代码中通常会比较困难。
- 打破依赖关系,依赖关系通常是测试最大的阻碍。这一阻碍的两种表现形式是,在测试用具中难以初始化对象和在测试用具中难以运行方法。
- 编写测试,我发现自己在遗留代码中编写的测试和针对新代码所编写的测试有些不同。
- 做出修改并重构,我提倡在遗留代码中使用测试驱动开发(Test DrivenDevelopment,TDD)来增加特性。
在遗留代码相关工作中日常的目标就是做修改,但不是任何修改。我们想要做能够带来价值的功能性修改,同时让系统更多的部分能够拥有测试。在每个编程阶段的末尾,我们都应该能够做到,不仅指出提供新特性的代码,还指出它的测试在哪里。
第 3 章 感知和分离
一般来说,当我们想要准备测试的时候,会出于两种原因打破依赖关系:感知和分离。
- 感知:当无法访问我们的代码所计算的值时,我们就会打破依赖以感知。感知只有一种主要的技术,那就是伪协作程序。
- 分离:当我们无法把一段代码放到测试用具中执行的时候,我们就会打破依赖以分离。分离软件有很多种方式。
伪协作程序
我们在遗留代码工作中面临的一个大问题是依赖。如果我们想要只执行一段代码来看它的功能,通常我们需要打破它与其他代码之间的依赖关系。但是这实际上并不那么简单。通常其他代码只是我们能够轻易感知到活动影响力的唯一地方。如果能够用某些其他代码放在该处并彻底测试,那么我们就能够编写测试了。在面向对象的方法中,这些“其他代码片段”通常被叫做伪对象。
模拟对象
伪对象很容易编写,而且对于检测是非常有价值的工具。如果你需要编写大量伪对象,那么你可能就需要考虑使用一种更高级的伪对象,即模拟对象(Mock object)。模拟对象是在内部执行断言的伪对象。
第 4 章 接缝模型
接缝的定义,接缝是你可以在程序中变更行为而不需要编辑的地方。
接缝
当你开始试图为单元测试抽取出单独的类时,通常需要打破大量依赖。有趣的是,你通常会有大量的工作要做,不管设计有多“好”。为了测试从现有的项目中抽取类会从根本上改变你对“好的”设计的看法。它还会让你以截然不同的方式来思考软件。
每个接缝都有启用点,在那里你可以决定使用哪个行为。
public class CustomSpreadsheet extends Spreadsheet{
// 不是接缝
public Spreadsheet buildMartSheet() {
Cell cell = new FormulaCell(this, "A1", "=A2+A3");
}
// 是接缝
public Spreadsheet buildMartSheet(Cell cell) {
cell.Recalculate();
}
}
第 5 章 工具
自动化重构工具
其实手动重构也不错,但是,有一种工具能够帮你做一些重构工作会节省很多时间。在20世纪90年代,Bill Opdyke开始创建C++重构工具,将其作为他在重构方面论文工作的一部分。尽管它最终也没有提供商业版本,但据我所知,他的工作激发了其他语言的很多工作。其中最重要的就是John Brant和DonRoberts在伊利诺斯大学开发的Smalltalk重构管理器。Smalltalk重构管理器支持大量重构方法,很长时间以来一直是代表自动化重构技术发展水平的实例。从那开始,有很多人都尝试为各种广泛使用的语言添加重构支持。在我写这本书的时候,已经有很多Java的重构工具,大多数都集成在IDE之中,也有少数没有集成。
让我们再了解一下什么是重构,以下是MartinFowler在《重构:改善既有代码的设计》。
重构(名词):对软件内部结构的变更,使其更易于理解,并且修改成本更低,而不会改变既有的行为。
单元测试用具
我遇到的最有效的测试工具是免费的。xUnit测试框架就是其中之一。它最初是由Kent Beck使用Smalltalk编写的,然后由Kent Beck和Erich Gamma迁移到Java中,xUnit是一种小型、设计强大的单元测试框架。
以下是它的关键特性:
1·它让程序员在他们的开发用语言中编写测试。
2·所有测试都在隔离的状态下运行。
3·测试可以分组为套件(suite),从而根据需要运行和重新运行。
在Java中,JUnit是我最喜欢的xUnit用具,它看起来和大多数其他xUnit工具类似。在C++中,我通常会使用自己编写的名为CppUnitLite的测试用具。它看起来有些不同,我会在本章加以描述。顺便提下,我使用CppUnitLite,并不是轻视CppUnit的原作者。
集成测试框架(Framework for Integrated Test,FIT)
FIT是一种简洁而优雅的测试框架,由Ward Cunningham开发。FIT背后的理念简单且强大。如果你可以编写关于系统的文档,并在其中嵌入表格,描述系统的输入和输出,而且这些文档可以保存为HTML格式,那么FIT框架就可以把它们作为测试运行。
FIT最强大的一点就在于,它能够建立编写软件的人员和做详细设计人员之间的沟通。详细设计人员可以编写文档,并在其中嵌入真正的测试。测试会运行,但是不会通过。稍后开发人员可以添加特性,然后测试就会通过。用户和开发者对系统功能的认识相同,时刻更新。
第 6 章 时间紧迫,但必须修改
实际上,你为了打破依赖以及为变更编写测试的工作会花费一些时间,但在大多数情况下,最终还是会节省时间,而且避免了很多令人郁闷的情况。在某些情况下,你可能会为需要修改的代码编写测试,那会花费你两个小时。而你之后所做的修改可能只需要15分钟。你不知道如果没有编写测试你会花费多少时间。你也不知道,如果犯了错误,要花费多长时间来调试,也就不知道因为准备好测试节省了多少时间。我说的不仅仅是在测试捕获到错误时可以节省的时间,还包括你试着寻找错的时候,测试帮你节省的时间。有测试来保护代码,把功能性问题挖掘出来通常会更容易一些。
当处于压力之下的时候,决定是否编写测试是最困难的事情,特别是在你不知道添加新特性需要花费多长时间的情况下。在遗留代码中,估计出合理的时间尤其困难。
新生方法(Sprout Method)
有时候,你想要使用新生方法,但类中的依赖关系如此复杂,你无法为其创建实例,除非模拟大量构造函数的参数。一种解决方法是使用“传递空值”。当这样做无效的时候,你可以考虑把新生方法设置为公有的静态方法。你可能需要把源类的实例变量作为参数传入,但是那会让你能够做出修改。
以下是我们所采取的步骤:
- 确定你需要修改代码的地方。
- 如果修改可以是方法中某个地方的一系列声明,那么就为新方法编写一个调用,完成相关的工作,然后把它注释掉(我喜欢在编写方法之前就先做这一步,这样我就能够知道方法在上下文之中会是什么样子)。
- 确定在源方法中你需要什么样的局部变量,然后把它们做成调用的参数。
- 确定是否需要新生方法来向源方法返回值。如果需要,就修改调用,让它可以返回赋给变量的值。
- 使用测试驱动开发的方法开发新生方法。
- 删除源方法中的注释,启用调用。
新生类(Sprout Class)
新生类的主要优点是,如果你在做重大修改,那么它让你可以更有信心进行工作。在C++中,新生类还有更多优点,你不需要修改任何现存的头文件,就能够准备好你的修改。你可以为新类把头文件包含在源类的实现文件中。此外,向你的项目添加新的头文件是件好事。随着时间推移,你会把声明放到新的头文件中,而不是放在源类的头文件中。这会降低源类的编译负载。你至少会知道,你没有让已经很差的形势变得更糟糕。一段时间之后,你可能能够重新访问源类,并为其编写测试。
新生类的主要缺点是概念复杂。当程序员了解新的代码库时,他们会对关键类如何协同工作产生一种印象。当你使用新生类的时候,就会开始破坏抽象,并在其他类中做大量工作。有时,这是完全正确的。而还有些时候,你这样做只是因为已经别无选择。理想状况下本应该在一个类中的内容最终会分布在新生类中,只是为了让我们可以做出安全的变更。
以下是创建新生类的步骤:
- 确定你需要在哪里修改代码。
- 如果变更无法规划为一个方法中一个地方的一系列声明,那么为能够完成工作的类想一个好名字。然后,在那个地方编写能够创建那个类的对象的代码,并在其中调用能够完成所需工作的方法,然后把这些行代码注释掉。
- 确定在源方法中你需要哪些局部变量,并让它们成为类的构造函数的参数。
- 确定新生类是否需要向源方法返回值。如果需要,那么就在类中提供能够提供那些值的方法,并在源方法中添加调用,以接受那些值。
- 首先开发出新生类的测试(参见测试驱动开发,8.1节)。
- 在源方法中删除注释,启用对方法的创建和调用。
包装方法
方法增加行为很容易,但是通常并不正确。当你首次创建方法的时候,它通常只为客户端做一件事。任何你后来增加的代码都值得怀疑。你之所以增加它,可能只是因为它需要和你添加前的代码一起执行。回到编程的早期,这叫做时间耦合(temporal coupling),而且当你做得太多时,这是一件很不好的东西。只因为同时发生就把一些代码归为一组,它们之间的关系并不是非常紧密,稍后你就可能发现,当你需要做其中的一件事,而不需要同时执行其他内容的时候,它们已经紧密结合到一起了。没有接缝,分离它们会是一件很辛苦的工作。
如果需要增加行为,可以用一种没那么复杂的方式来做。你能够使用的方法包含新生方法,还有一种非常有用的方法,我把它叫做包装方法(Wrap Method)。其实就是旧代码方法的提取,并埋入新方法。
包装类
与包装方法对应的类级别上的方法是包装类(WrapClass)。包装类使用了完全相同的概念。如果需要在系统中增加行为,我们可以把它添加到已存在的方法中,但是我们还可以把它添加到使用那个方法的其他事物中。在包装类中,“其他事物”就是另一个类。
装饰器模式
装饰器模式很适合解决这种问题。当你使用装饰器的时候,你会创建一个抽象类,定义需要支持的一系列操作。然后你会从那个抽象类创建继承它的子类,在构造函数中接受类的实例,并为每个方法提供主体。
abstract class ToolControllerDecorator extends ToolController{
public void lower() { controller.lower(); }
public void step() { controller.step(); }
public void on() { controller.on(); }
public void off() { controller.off(); }
}
以下是包装类的步骤:
- 确定你需要做出变更的方法。
- 如果变更能够规划为一个地方的一系列声明,那么就创建一个类,将你要包装的类作为其构造函数的参数。如果你很难在测试用具中创建包装了原始类的类,那么你可能需要对被包装的类使用提取实现或者提取接口,从而实例化你的包装器。
- 在那个类中创建一个方法来完成新的工作,此时可以使用测试驱动开发的方法。在被包装的类中编写另一个方法,它会调用新方法和旧方法。
- 在你的代码中需要启用新行为的地方实例化包装器类。
第 7 章 永远都无法完成的修改
修改一段代码要花费多长时间呢?对于这个问题的答案会根据具体情况而截然不同。在代码乱得可怕的项目中,很多修改都会花费很长时间。我们需要仔细研究代码,理解修改的所有分支,然后再做出修改。在代码清晰的部分,这可能会很快,但是在复杂的部分,会花费很长时间。有些团队所拥有的代码要比其他团队更差一些。
1、理解,随着项目中的代码量不断增加,它会变得越来越难以理解。指出要修改什么所要花费的时间也会随之增加。
2、延迟时间,变更之所以花费很长时间,可能是因为另一个非常常见的原因:延迟时间。延迟时间指的是,从你做出变更到获得变更所导致的反馈之间的时间。
3、打破依赖关系,依赖关系会导致很多问题,但幸运的是,我们可以打破它们。
4、构建依赖关系,在面向对象的系统中,如果你拥有一堆类,想要更快地构建它们,那么需要确定的第一件事就是哪些依赖关系是障碍。一般来说,这很简单:你只需要试着在测试用具中使用类。你遇到的所有问题几乎都是由于你应该打破的依赖关系所导致的。类在测试用具中运行之后,还会有一些依赖关系会影响编译时间。查看一下你所能够实例化的类所依赖的所有内容,会对你很有帮助。当你重新构建系统的时候,那些内容也会重新编译。你如何才能够使由此而来的影响最小化呢?
5、依赖反转原则,当你的代码依赖于接口的时候,依赖关系通常非常细微而不引人注目。除非接口发生了改变,否则你就不需要做出变更,而接口一般远远不及后端的代码变更频繁。当你拥有接口的时候,就可以编辑实现那个接口的类,或者增加新的实现接口的类,所有这些工作都不会影响使用接口的代码。由于这个原因,依赖于接口或者抽象类要比依赖于具体类更好一些。当你依赖于更加稳定的东西时,就最大程度减少了触发大规模重新编译的变更。
第 8 章 如何添加新特性
测试驱动开发
我所知道的最强大的增加特性的技术就是测试驱动开发(Test Driven Development,TDD)。简而言之,它的工作原理是:我们想出一个方法,能够帮我们解决部分问题,然后我们为其编写一个会失败的测试。此时那个方法还不存在,但是如果我们能够为其编写测试,那么我们就能理解将要编写的代码要做什么。
测试驱动开发使用了下面这样的步骤:
1)编写失败的测试案例。
2)对其进行编译,我们想让测试失败。
3)使其通过,编写了使其通过的代码。
4)去除重复的内容,在此我们有重复的内容吗?没有。那么我们可以继续进行下一个步骤。。
5)重复以上步骤。
TDD和遗留代码
TDD最有价值的是,它让我们每次只集中处理一件事情。我们或者编写代码,或者重构,我们不会同时做这两件事。
在遗留代码中,这种分离尤其重要,因为它让我们可以在代码彼此独立的情况下编写新代码。
在已经编写了一些新代码之后,我们可以对其进行重构,以移除它和旧代码之间重复的内容。
对于遗留代码,我们能够以这种方式来扩展TDD方法:
- 为你想要修改的类编写好测试。
- 编写失败的测试案例。
- 对其进行编译。
- 使其通过(试着在这样做的时候不要修改现存代码)。
- 去除重复的内容。
- 重复以上步骤。
根据差异编程
测试驱动开发并非只能用于面向对象的情况。在增加了特性之后,我们可以确切地指出想要如何整合该特性。
这样做的关键技术是根据差异编程(programming bydifference)。这是一种相当早期的技术,曾经在20世纪80年代被广泛讨论和使用过,但在20世纪90年代,面向对象社区中的很多人发现,如果过度使用继承,就会造成问题,因此该技术就受到了冷落。但我们在开始的时候使用继承,并不意味着会永远保留它。有了测试的帮助,如果继承出现问题,我们可以轻松地向其他结构转换。
最近一些年间,测试驱动开发越来越流行。我要特别推荐Kent Beck的《Test-Driven Development by Example》(Addison-Wesley,2002)和Dave Astel的《Test-DrivenDevelopment:A Practical Guide》(Prentice HallProfessional Technical Reference,2003)。
第 9 章 无法将类放入测试用具中
以下是我们最经常遇到的四种问题:
- 无法轻松地创建类的对象。
- 测试用具无法和其中的类一起轻松构建。
- 我们需要使用的构造函数有严重的副作用。
- 构造函数包含大量工作,我们需要对其进行检测。
恼人的参数
想要知道在测试用具中初始化一个类是否会有问题,最好的方式就是试着做一下。编写一个测试案例,并试着在其中创建一个对象。编译器会告诉你需要做什么才能让它真正工作起来。
测试代码不需要遵守与生产代码相同的标准。总的来说,如果能够让编写测试更简单,我不介意把变量设置为公有,尽管那会破坏封装。然而,测试代码应该是整洁的。它应该易于理解和修改。
传递空值
重要的是要知道,不要在生产代码中传递空值,除非你别无选择。我知道有些类库期望你那样做,但是当你编写新代码的时候,会有更好的替换方案。如果你想要在生产代码中使用空值,那么就找到返回空值和传递空值的地方,并考虑不同的协议。或者考虑使用空值对象模式来替代。
传递空值和提取接口是访问恼人的参数的两种方式。
空值对象模式
空值对象模式是一种在程序中避免使用空值的方法。例如,找不到数据就返回NullEmployee的自定义空对象。
具有隐藏依赖的情况
某些类是有迷惑性的。我们查看它们,找到想要使用的构造函数,然后试图调用它。然后,嘭!我们遇到了地雷。最常见的“地雷”就是隐藏的依赖,构造函数使用了某些我们在测试用具中无法正常访问的资源。
最根本的问题在于对mail_service的依赖,而它隐藏在mailing_list_dispatcher中。如果有某种方式可以使用伪对象来替换mail_service对象,那么我们就可以通过伪对象来感知,并在修改类的时候得到一些反馈。
可怕的Include依赖
在Java和C#中,如果一个文件中的类需要使用另一个文件中的类,那么我们会使用import或者using语句让它的定义可用。编译器会寻找那个类,并检查看它是否已经被编译完毕。如果没有的话,就会编译它。如果已经编译了,那么编译器就会从已编译的文件读取简要的信息片段,只获得它所需要的信息,以确保原来的类所需要的方法都在那个类中存在。
C++编译器一般来说没有经过这样的优化。在C++中,如果一个类需要了解另一个类,在需要使用它的文件中会以文本的形式包含对那个类(位于另一个文件中)的声明。这个过程会慢得多。编译器每次看到那个声明的时候都需要重新解析,并构建内部的表现。更坏的是,include机制容易被滥用。一个文件可以包含另一个包含了文件的文件,依此类推。在人们没有避免这种情况的项目中,经常会发现很小的文件,最终以include的方式包含了成千上万行代码。人们奇怪为什么他们的构建会花费那么长时间,而系统中到处都是include声明,很难找到一个特定的文件,并了解为什么编译花费了那么长时间。
洋葱皮参数
在很多情况下,这意味着我们需要为其提供设置良好的对象。那些对象可能需要其他对象,而那些对象可能也需要设置,这样我们最后需要为了创建对象而创建对象,然后再创建对象,从而为我们想要测试的类的构造函数创建参数。对象位于其他对象之中。这看起来就像一个大洋葱。
唯一能够防止我们陷入绝望的就是,至少有一个类不需要另一个类的对象作为参数。如果没有的话,那么系统可能都无法成功编译。
第 10 章 无法在测试用具中运行方法
准备好测试然后再修改代码可能会有一点问题。如果你能够在测试用具中分别实例化你的类,那么就可以庆幸一下了。很多人做不到那一点。
隐藏方法的情况,我们需要修改类中的一个方法,但它是私有方法。我们应该怎么做呢?第一步是把私有的方法变成受保护的方法。在大多数先进的C++编译器中,我们还可以在测试的子类中使用using语句,以自动执行委托。
第 11 章 我需要修改代码,应该测试哪些方法
我们需要做出一些变更,并且需要编写鉴定测试,以防止改变既有的行为。我们应该在哪里编写呢?最简单的答案是为每个修改的方法都编写测试。但那样就足够了吗?如果代码简单,而且易于理解,那么那样就足够了,但在遗留代码中,那通常会让我们全盘皆输。在一个地方的修改会影响其他地方的行为——除非我们已经让测试就位,否则就可能永远都不会知道这一点。
推断影响,在业界,我们不会经常谈论这个问题,但是对于软件中的每次功能性修改,都会有一条相关的影响链条。例如,如果我在以下C#代码中把3修改为4,被调用的时候,它就会修改方法的结果。它还会改变调用那个方法的方法的结果,依此类推,一直到达系统的某个边界。尽管如此,代码的很多部分不会有不同的行为。它们不会产出不同的结果,因为它们不会直接或间接地调用这个方法。
正向推理,在上一个例子中,我们试着推断一系列影响了代码中特定值的对象。当我们编写鉴定测试的时候,这个过程就会倒转。我们会查看一系列对象,并试图确定,如果它们停止工作,下游的哪些内容会发生改变。
影响传播的某些方式比较容易注意到。在上个部分的InMemoryDirectory例子中,我们最后找到了向调用程序返回值的方法。即便我开始时从变更点——也就是我们做出变更的地方——跟踪影响,我通常还是会首先注意带有返回值的方法。除非它们的返回值没有被使用,否则就会把影响传播到调用它们的代码中。影响也会以无声、隐蔽的方式传播。如果我们有一个对象,它会接受某些对象作为参数,那么它就会修改它的状态,而变更就会反映到应用程序的其他部分中。
从影响分析中学习,只要一有机会,你就要试着分析代码中的影响。有时你会发现,随着你对代码库越来越熟悉,会感觉不需要查找特定的内容。当你有那种感觉的时候,就意味着找到了代码库中的某种“基本的精华”。最好的代码中不会有很多“陷阱”。某些“规则”就存在于代码库中,不管它们是否显式地声明,都可以防止你在查找可能的影响时过分偏执。找到这些规则最好的方式是,考虑一种软件能够对其他软件产生影响的方式,那是一种永远都不会在代码库中看到的方式。然后和自己说:“但是,不,那会很愚蠢。”代码库拥有大量那样的规则,会更好处理。在很差的代码中,人们不知道“规则”是什么,或者“规则”是例外情况。
第 12 章 在同一地进行多处修改,是否应该将相关的所有类都解依赖
在重构过程中,高层次测试很有用。相比每个类上的细粒度测试,通常人们要更喜欢它们,因为他们认为,当针对需要变更的接口编写大量小测试的时候,变更会更难一些。事实上,变更通常要比你想象的更简单,因为你可以先修改测试,然后修改代码,以小步前进的方式逐渐修改结构。
拦截点只是你程序中的一个地方,在那里你可以检测到特定变更的影响。在某些应用程序中,找到它们要比在其他应用程序中更难一些。如果你有个应用程序,它的各个代码片段都是黏合在一起的,没有很多天然的接缝,那么找到合适的拦截点就很难,通常需要某些对影响的推理工作,并需要打破大量依赖。我们如何开始呢?最好的方式就是确定你需要做出变更的地方,然后从那些变更点开始跟踪影响。你能够检测影响的每个地方都是一个拦截点,但可能不是最佳的拦截点。你需要在整个过程中自己进行判断。
夹点是影响草图中变窄的地方,也就是针对几个方法测试,就能检测到很多方法中变更的地方。在一些软件中,为一系列变更找到夹点非常容易,但是在很多情况下,这几乎是不可能完成的任务。单独一个类或方法可能会直接影响几十个地方,而根据它绘制的影响草图可能看起来像一棵复杂的树。那样我们能做什么呢?我们可以做的一件事是重新回顾我们的变更点。可能我们一次试图做了太多工作。考虑一次只为一个或两个变更寻找夹点。如果你根本无法找到夹点,那么就试着为单独的变更尽可能近地编写测试。
夹点陷阱,当我们编写单元测试的时候,会以多种方式陷入麻烦之中。一种方式就是让单元测试慢慢增长成为小型的集成测试。我们需要测试一个类,所以实例化了几个协作类,并把它们传递给那个类。我们会检查一些值,然后可以确信整个一组对象可以很好地一起工作。缺点在于,如果我们这样做得太多,最后就会得到一个庞大的单元测试,可能永远都运行不完。
第 13 章 我需要修改代码,但不知道要编写哪些测试
自动化测试是一种非常重要的工具,但并不是用来寻找缺陷的。至少,不能直接使用。一般来说,自动化测试应该指定一个我们想要达到达到目标,或者是已经存在、我们需要保持的行为。
鉴定测试
我们想要保持行为不变时所需要的测试叫做鉴定测试。鉴定测试是描述一段代码的实际行为特征的测试。其中没有“好吧,它应该这么做”或者“我想它会那么做”之类的说法。测试会记录系统当前实际的行为。以下是用来编写鉴定测试的小技巧:
- 使用测试用具中的一段代码
- 编写你知道将会失败的断言
- 让失败告诉你行为应该是怎样的
- 修改测试,从而让它符合代码实现的行为
- 重复以上步骤
如果我们就把软件生成的值放到测试中,那么我们的测试真正测试了什么吗?如果软件有缺陷会怎么样呢?
如果我们以不同的方式来考虑测试,问题就会迎刃而解。它们并不是软件必须将其作为金科玉律,必须依赖于它才能够生存的测试。我们在此并不是要试着找到缺陷,而是在试着放置一种机制,可以在稍后找到缺陷,那些缺陷会与系统当前的行为显示出区别。当我们采用这种视角的时候,对测试的看法就不一样了:它们没有任何道德上的权威性。它们就是在那里记录系统的一部分到底做了什么。我们看到那部分所做的工作,就可以和我们所知道的关于系统应该做什么的知识结合在一起,做出变更。
鉴定类
我们拥有一个类,并想要确定要测试什么。那么,我们应该怎么做呢?要做的第一件事就是从高层次上确定类做了什么。我们可以为我们想象中它所做的最简单的事情编写测试,然后让我们的好奇心带领我们继续。以下是可能有帮助的启发式方法:
- 寻找复杂的逻辑片段。如果你不理解一块代码,那么就考虑引入检测变量来描述它的特征。使用检测变量确保你执行了特定区域的代码。
- 当你发现了类或者方法的职责之后,停下来,列出可能出现错误的地方。看你是否能够编写触发它们的测试。
- 考虑你在测试中提供的输入。在极限值的情况下会发生什么?
- 在类的生命周期内,所有条件都应该一直是真吗?如果是,这些条件就叫做不变量。试着编写测试来验证它们。通常你可能需要重构,以发现这些条件。如果你那样做,那么重构通常会让你对代码应该是什么样子有新的看法。
定向测试(Targeted Testing)
在编写测试以理解一段代码之后,我们需要查看我们想要修改的内容,并看我们的测试是否已经将其覆盖。当你为分支编写测试的时候,问下自己,除了执行那个分支之外,是否有另外的方式能够让测试通过。如果不确定,那么就使用检测变量或者调试器来找出测试是否执行了它。
第 14 章 棘手的库依赖问题
我们应该避免在代码中对库中的类直接调用。你可能认为永远都不会修改它们,但那会变成自我实现的预言。
如果库假设在系统中只有类的一个实例,我们就很难使用伪对象。那样我们可能无法使用引入静态设置方法或者能够用来处理单例的其他打破依赖的技术的方式。有时包装单例是你唯一的选择。
第 15 章 到处都是API调用
系统中到处散落着对类库的调用,这比自己编写的系统要更难以处理,在各方面都是那样。第一个原因在于,很难看出如何让结构更优,因为你所能够看到的就是API调用。所有应该是设计提示的东西都不在那里。API集中的系统难以处理的第二个原因是我们并不拥有那些API。如果拥有的话,我们可以对接口、类和方法进行重命名,让我们更清晰地了解,或者我们可以向类增加方法,使得它们能够供代码的不同部分使用。
代码的结构能够优化吗?是否能够把结构调整为另一种形式,从而让变更更容易呢?
主要有两种方法:
包装API,当我们包装API的时候,会创建接口,尽可能接近地镜像API,然后对库中的类创建包装器。为了尽可能减少犯错的几率,我们可以在处理的时候保留签名。
基于职责提取,职责是什么呢?就是代码的提供的能力或归类。
API进行包装在这些情况下比较适合:
·API相对小。
·你想要完全分离出第三方类库中的依赖。
·你没有测试,也无法编写,因为你无法对API进行测试。基于职责提取适合于以下情况:
·API更加复杂。
·你拥有工具,支持安全地提取方法,或者你有自信能够安全地手动完成提取。
第 16 章 对代码的理解不足
接触不熟悉的代码,特别是遗留代码,会让我们如履薄冰。随着时间推移,一些人会对这种恐惧相对免疫。他们从一次又一次面对并杀死代码中的恶魔的过程中获得了信心,但想要不再害怕还是很困难。每个人都会一次次遇到无法杀死的恶魔。如果你在开始查看代码之前对其进行讨论,那会让情况更糟糕。你永远都不知道,变更会很简单,还是会让你在几周内不停地拔头发,让你开始诅咒系统、你的情况,甚至是身边的一切。
做笔记,画草图
当阅读让人迷惑的代码时,首先画些草图、做些笔记会很有用。写下你看到的重要内容的名称,然后记录下一个重要内容的名称。如果你看到二者之间的联系,那么就画条线。这些草图不需要是使用某种特定标记法的完整UML图或者功能调用图,如果情况变得让人困惑,你可能会整理一下,让图更加正式或者整洁,从而整理你的思路。对内容画草图通常会帮助我们以不同的方式来看待它们。当我们试着理解特别复杂的东西时,这也是一种保持头脑清醒的很好方式。
列表标记,绘制草图并不是唯一对理解有帮助的东西。我经常使用的另一种技术是列表标记。
标记列表的方式依赖于你想要理解的内容。第一步是打印你想要处理的代码。然后,你可以在采取每一步行动的时候使用列表标记。
分离职责,如果你想要分离职责,那么使用标记来对内容分组。如果几件事物属于一类,那么就在每个后面放置特殊的符号,以便识别。如果可以的话,使用多种颜色。
理解方法结构,如果你想要理解一个大型方法,那么就用线把它分为代码块。通常长方法中的缩进使得它无法阅读。
提取方法,如果你想要分解大型方法,那么就把想要提取的代码圈起来。使用耦合数来注释
理解变更的影响,如果你想要理解你所做变更的影响,不要绘制影响草图,而应该在你将要修改代码的旁边做下标记。然后在会因为那个变更值而发生改变的变量旁,以及可能会受到影响的方法调用旁边做标记。接下来,在会受到你刚刚标记的内容影响的变量和方法旁边做标记。尽可能在你需要的地方都这么做,看修改会造成多大的影响。当你那样做的时候,你就会更好地了解需要测试什么。
临时重构,用于了解代码最佳的技术之一就是重构。查看一下,然后把代码移动一下,让代码更清晰一些。唯一的问题是,如果你没有测试,那会是很有风险的动作。
删除没有用的代码,如果你对查看的代码很迷惑,而你可以确定其中一些并没有被用到,那么就删除它。它不会对你有任何帮助,除了挡你的路之外。
第 17 章 应用毫无结构可言
当团队对架构不了解的时候,可能就会对其降级。那么是什么让我们无法了解架构呢?
·系统可能非常复杂,以至于了解全局需要花费很长时间。
·系统可能非常复杂,根本没有全局的概念。
·团队处于非常被动的情况,处理一个又一个紧急事件,从而无法了解全局情况。
传统情况下,很多组织都会让架构师来解决这些问题。架构师通常会负责了解全局的任务,并为团队做出决定,保留全局的概念。这会有效,但我在此警告,架构师需要在团队之中,每天都和团队成员在一起工作,否则代码就会与全局偏离。残酷的现实是,架构师太重要了,所以不能给少数人专用。拥有一位架构师很好,但保证架构师工作有效的关键方式是,确保团队中的每个人都知道那是什么,并一直坚持。
我们怎样才能获得大型系统的全局情况呢?有很多方式能够做到这一点。Serge Demeyer、Stephane Ducasse和OscarM. Nierstrasz著的《面向对象重构模式(Object-OrientedReengineering Patterns)》(Morgan Kaufmann出版社,2002)中包含了处理这种问题的技术列表。
JUnit的架构是什么样子?JUnit有两个主要的类。一个叫做Test,另一个叫做TestResult。用户会创建并运行测试,然后传递给它们一个TestResult。当测试失败的时候,TestResult就会被告知。然后人们就可以向TestResult请求所有发生的故障。
让我们来列举一下简化后的情况。
- 在JUnit中有很多其他类。我说Test和TestResult是最主要的两个,只是因为我是那么认为的。对我来说,它们之间的交互就是系统中最核心的交互。其他人可能会对架构有不同的正确观点。
- 用户不会创建测试对象。测试对象是从测试用例类通过反射机制创建的。
- Test并不是一个类,而是一个接口。在JUnit中运行的测试通常会编写在名为TestCase类的子类中,而TestCase则实现了Test。
- 人们一般不会向TestResult请求获得故障信息。TestResult会注册监听程序,当TestResult从测试接受信息的时候,它会收到通知。
- 测试不仅仅会报告故障。它们还会报告运行的测试数量以及错误的数量(错误是发生在测试中,没有显式检查的问题。故障是失败了的检查)。
第 18 章 测试代码挡路了
当你第一次开始编写单元测试的时候,可能会感觉不太自然。人们通常遇到的就是,感觉他们的测试挡路了。他们四处浏览项目,有时会忘记他们查看的是测试代码还是生产代码。即便你从始至终查看了大量测试代码,也不会有太大帮助。如果不开始确立一些规范,你最终就会不知所措。
类命名规范
需要确立的第一件事就是类命名规范。一般来说,你会对处理的每个类拥有至少一个单元测试类,所以把单元测试类的名称设置为类名的一种变体会比较合理。我们使用了几种规范。最常见的就是使用Test作为类名的前缀或后缀。那么,如果我们有一个名为DBEngine的类,那么就可以把测试类叫做TestDBEngine或者DBEngineTest。
第 19 章 对非面向对象的项目,如何安全地对它进行修改
即便面向对象已经在业界非常普遍,但还是有很多其他语言以及其他编程方式。有基于规则的语言、函数式编程语言、基于约束的编程语言等。但所有这些,都没有像平凡而古老的过程化语言那样应用广泛,如C、COBOL、FORTRAN、Pascal以及BASIC。
在C语言中,还有另一种选择。C语言拥有宏预处理器,我们可以使用它,让编写要测试的函数的测试更简单。
C语言是少许几种拥有宏预处理器的主流语言之一。一般来说,为了在其他过程化语言中打破依赖,我们需要使用链接接缝,并试图让大段代码拥有测试。
增加新行为,在过程化的遗留代码中,倾向于引入新的函数,而不是向旧的函数中增加代码,那会带来不少好处。至少,我们可以为自己编写的新函数编写测试。
我们如何避免在过程化代码中引入依赖陷阱呢?一种方式是使用测试驱动开发。TDD在面向对象和过程化的代码中都有效。通常如果你试图为每段想要编写的代码都编写测试,那么就会让我们以很好的方式来改变它的设计。我们会专注于编写那些完成计算工作的函数,然后把它们集成到应用程序的其他部分中。
第 20 章 类太大了,我不想让它继续膨胀
人们向系统增加的很多特性都是小改动,需要增加少许代码,可能还需要几个方法。我们很可能会把这些修改放到现存的类中。然而,你需要增加的代码有可能必须使用来自某些现存类的数据,而最简单的做法就是向其中增加代码。遗憾的是,这种简单的修改方式会导致某些严重的问题。我们一直向现存的类中增加代码,最后就会得到很长的方法和很大的类。我们的软件成了一个大沼泽,了解如何增加新的特性,甚至是了解旧的特性如何工作,都会花费更多时间。
大类会有什么问题呢?
首先是困惑。如果你在一个类中有50或60个方法,通常就难以发现你需要修改什么,以及它是否会影响其他功能。在最坏的情况下,大类会有超多的实例变量,我们很难知道修改一个变量会造成什么样的影响。
另一个问题关乎为任务安排计划。当一个类有20多个职责的时候,你就有可能有很多原因要修改它。在同一次迭代中,你可能有多位程序员,他们需要对那个类做不同的事情。如果他们同时工作,就会导致严重的冲突,
第三个问题最为突出:大类是测试永远的痛。封装是件好事,是吧?然而,不要向测试人员打听封装,他们可能会咬你一口。太大的类通常会隐藏太多东西。当封装能够帮助我们了解代码,并且当我们知道在特定的情况下可以修改特定的内容时,封装才是好的。
对于大类,关键的补救措施就是重构。它有助于把类分解成一系列更小的类。
单一职责原则(Single Responsibility Principle,SRP)
每个类都应该拥有单一职责:它应该在系统中拥有单一的目的,修改它应该只有一个原因。单一职责原则很难描述,因为职责本身就是模糊不清的。如果我们以一种非常朴素的视角来看,我们可能会说:“哦,那意味着每个类都应该只有一个方法,对吗?”是的,方法可以被认为是职责。负责使用run方法来运行的任务、负责使用taskCount方法告诉我们有多少子任务的任务等。但我们所说的职责在讨论主要目的时才会作为关注点。
启示1:对方法分组
对方法分组这项技术是很好的工作开端,特别是对于大型的类。重要的是要知道,你不需要把所有名称都分组到新的类中。你只需要查看你是否能够找到看起来是通用职责的部分。如果你能够确定其中的一些职责,而它们并不是类的主要职责,那么随着时间的推移,你就可以找到代码的方向。
启示2:查看隐藏方法
注意私有和受保护的方法。如果一个类有很多这样的方法,通常意味着这个类可以分离出另一个类。大型类会隐藏很多内容。这个问题对于刚开始进行单元测试的人来说,会一次次出现:“我如何测试私有方法?”很多人花费了大量时间,试图找到解决这个问题的方法,但是,正如我在前一章所提到的,真正的答案是,如果你强烈需要测试一个私有方法,那么该方法就不应该是私有的;如果把方法变成公有的会对你造成困扰,那么可能就是因为它是分离职责的一部分。它应该在另一个类中。
启示3:寻找能够改变的决定
寻找决定——并不是你要在代码中做出的决定,而是你已经做出的决定。是否有通过硬编码做事情的某种方式(与数据库对话、与另外一组对象对话,诸如此类)?你能想象到它的变更吗?
当你试图分解一个大类的时候,可能会把很多注意力放在方法的名称上。
因此,在真正开始提取类之前,做一些小型的提取方法重构会很有用。我们应该提取哪些方法呢?我会通过寻找决定来回答这个问题。在代码中应该有多少东西?调用方法的代码来自于特定的API吗?是否假设它总会访问同一数据库?如果代码做了这些事情,那么提取一个能够在高层次上反映出你所要做工作的方法,是个好主意。如果你从数据库获取特定的信息,那么就提取一个方法,并根据你所获得的信息来命名。当你做这些提取工作时,你会有更多方法,但是你也可能会发现,对方法分组会更容易。比那更好的是,你可能会发现,你把某些资源完全封装在一系列方法之后。当你为其提取类的时候,你将需要在低层次的细节上打破某些依赖。
启示4:寻找内部关系
寻找实例变量和方法之间的关系。特定的实例变量是由某些方法而不是由其他方法使用的吗?
很难找到这样的一个类,其中所有方法使用了所有实例变量。通常在一个类中会有某种“组”。两三个方法可能是唯一使用一组三个变量的方法。通常名称会帮助你看到这一点。
对于在类中找到分离的职责来说,特性草图是非常棒的工具。我们可以试着对特性分组,并确定基于名称我们能够提取哪些类。但是,除了帮助我们找到职责之外,特性草图还让我们可以看到类中的依赖结构,当我们决定要提取什么的时候,那通常和职责一样重要。
启示5:寻找主要职责
试着用一句话描述类的职责。
单一职责原则告诉我们,类应该拥有单一的职责。如果是那样的话,那么类的职责就可以很容易地用一句话写下来。
单一职责原则有两种违反方式。它可能会在接口层或者实现层违反。当类呈现出一个接口,使其看起来负责大量事情,那么就会在接口层违反单一职责原则。单一职责原则还是在接口层违反了,但在实现层,情况好一些。
接口隔离原则(Interface Segregation Principle,ISP)
当一个类很大的时候,所有客户端使用所有方法的情况极少。通常我们可以看到特定客户端使用的不同组的方法。如果我们为每一组都创建一个接口,并让大型类实现那些接口,那么各个客户端就可以通过特定的接口看到这个大型类。这帮助我们隐藏信息,并且减少系统中的依赖。当大型类重新编译的时候,客户端不再需要重新编译。
启示6:如果所有其他都失败了,那么就做一些小型重构
如果在查看类中的职责时你遇到了很大困难,那么就做一些简略重构。
启示7:专注于当前的工作
把注意力放在你现在需要做的工作上。对任何工作提供不同方式之前,你很可能要确定需要提取的职责,然后允许取代它。
我们很容易被在一个类中确定的独特职责的数量弄得不知所措。记住你当前所做的变更,这些变更告诉你软件可以变更的特定方式。通常通过识别出变更的方式就足以看到把你编写的新代码视为分离的职责。
第 21 章 需要修改大量相同的代码
遗留代码的系统中有几十处都是同样的代码。你可能会感觉,如果你重新设计或者重新构造你的系统,可能就没有这个问题了,但谁又有时间做那件事呢?所以你又遇到了系统中让人恼火的地方,这些加起来通常会让你很郁闷。
第一步,当我面对重复的时候,第一个反应就是后退一步,了解一下全局的情况。当我那么做的时候,首先会考虑最终会得到什么类型的类,以及提取出来的重复部分会是什么样子。然后我意识到,我其实考虑过多了。删除小段重复会有帮助,那使得稍后查看大块重复更容易。
移除重复是精炼设计的一种强大方式。它不仅会让设计更灵活,而且还会让变更更快、更容易。
开放/闭合原则
开放/闭合原则是由Bertrand Meyer最先提出的一种原则。它背后的观点就是代码应该对扩展开放,而对修改关闭。那意味着什么呢?那意味着当我们拥有良好设计的时候,不需要对代码大做修改就能增加新特性。
第 22 章 要修改一个巨型方法,却没法为它编写测试
遗留代码工作中最困难的就是处理大型方法。在很多情况下,你可以使用新生方法和新生类技术来避免对长的方法进行重构。但是,即便你能够避免,避免本身也会让你感觉不舒服。长方法是代码库中的沼泽。当你需要修改它们的时候,就需要追溯,并试着再次理解它们,然后你需要做出变更。通常那样比代码整洁的情况花费更长的时间。
巨兽方法有很多种。它们不一定都是单一类型。那些方法就像鸭嘴兽一样,看起来是多种类型的组合。
无序方法(bullted method)是没有任何缩进的方法。它只是一系列代码块,告诉你这是个无序列表。块中的某些代码可能会缩进,但是方法本身没有太多缩进。
缠结的方法是一种具有单个大型缩进部分的方法。最简单的例子就是只有一个大型条件语句的方法,大量if或者case when。但这种缠结方法和无序方法的性质差不多,如果你感觉晕头转向,那么就是陷入在缠结方法种了。
引入检测变量,重构的时候,我们可能不想向生产代码中增加特性,但是那并不意味着我们不能增加任何代码。有时候,向类中增加一个变量,并使用它来检查方法中我们想要重构的条件,会很有帮助。完成了需要做的重构,我们就可以删除那个变量,我们的代码会变成整洁的状态。这叫做引入检查变量。
收集依赖,有时在巨兽方法中有些代码并不实现方法的主要目的。它是必要的,但并不是特别复杂,如果你意外地破坏了那段代码,就会显现出问题。但是,尽管以上都属实,但你肯定不能破坏方法的主逻辑。在这样的情况下,你可以使用一种名为收集依赖(gleaning dependencies)的技术。你会为需要保留的逻辑编写测试。然后,你要提取测试没有覆盖的内容。当你这样做的时候,就可以至少确信已经保留重要的行为。
第 23 章 如何知道没有造成任何破坏
超感编辑(Hyperaware Editing),编辑代码的时候,我们到底是在做什么呢?我们想要完成什么呢?我们通常会有远大的目标。想要增加特性或者修正缺陷。知道那些目标是什么非常棒,但我们如何把它们转换为行动呢?
单一目标编辑,开始处理一段代码,然后想:“嗯,可能我应该清理一下。”所以你暂停,开始做些重构,但是你开始考虑代码看起来应该是什么样子,然后暂停。你正在编写的特性仍然需要完成,所以你回到开始时编辑代码的位置。你决定调用一个方法,然后你跳到代码所在的地方,但你发现方法需要做一些其他工作,所以你开始修改它,而最初的修改被延迟了,而你结对的伙伴坐在旁边,喊到:“太好了!修正它,然后我们就会做到。”你感觉像是赛马场上的一匹马,而你的搭档并没有起到什么帮助。他就像是一位骑手,甚至更糟糕,像是看台上的一名赌徒。
保留签名,当我们编辑代码的时候,会犯很多种错。我们会拼写错误,会使用错误的数据类型,会输入一个变量,而指的是另一个。这个列表无穷无尽。重构尤其容易造成错误。通常它会涉及非常有侵略性的编辑。我们会复制一些代码,创建新类和方法,规模要比只添加一行新代码大得多。
结对编程,你有可能已经听说过结对编程。如果你使用极限编程(XP)作为你的过程,那么就可能已经在这么做了。很好。这对于提升质量以及在团队中传播知识是非常好的方式。
如果你现在还没有结对编程,那么我建议你尝试一下。我特别建议你在使用我在本书中描述的打破依赖的技术时进行结对。
那很容易犯错,并且不知道你已经破坏了软件。另一双眼睛肯定会很有帮助。让我们面对它,处理遗留代码就像是外科手术,医生永远不会独自开刀。
第24章 我要崩溃了,它不会再有任何改进
处理遗留代码很困难。任何人都无法否认。尽管每种情况都有所不同,但有一件事会决定作为程序员,一件工作是否值得你做:对你而言遗留代码中有什么。对于某些人来说,只有薪水,那也没什么错,毕竟我们都需要养家糊口。但对于为什么你要编程,应该还有其他原因。
第 25 章 解依赖技术
调整参数
当我修改方法的时候,通常会遇到方法参数所导致的依赖问题,那让我很是头疼。有时我发现创建需要的参数非常困难,还有些时候,我需要测试方法对参数的影响。在很多情况下,参数的类并没有让这变得容易。如果是我能够修改的类,那么我可以使用提取接口来打破依赖。当我们要打破参数依赖的时候,提取接口是最佳选择。
在J2EE中提供了一些模拟对象(mock object)库。如果我们下载一个,就可以使用HttpServletRequest的模拟对象来完成我们需要的测试。这真的会帮我们节省大量时间,如果我们沿着这个思路,就不需要花费时间手动模拟虚拟的servlet请求了。这样,看上去我们已经有了解决方案。确实如此吗?当我打破依赖的时候,我总是试图向前看。
想要使用调整参数,我们需要执行以下步骤:
- 创建你将会在方法中使用的新接口。使其尽可能简单并可通信,但试着不要创建需要在方法中大做修改的接口。
- 为新接口创建生产实现。
- 为新接口创建伪实现。
- 编写简单的测试案例,把伪对象传递给方法。
- 在方法中做出你需要的修改,以使用新参数。
- 运行你的测试,确认你可以使用伪对象来测试方法。
分解方法对象
在很多应用程序中,长方法都很难处理。通常,如果你可以实例化包含它们的类,并把它们包含在测试用具中,那么你就可以开始编写测试了。
你可以使用以下这些步骤,在没有测试的情况下安全完成分离方法对象的操作:
- 创建一个类,在其中将会存放方法的代码。
- 为类创建一个构造函数,并保留签名,赋予其方法所使用的参数的完整副本。如果方法使用了原始类中的实例数据或方法,那么就添加对原始类的引用,作为构造函数的第一个参数。
- 对于构造函数中的每一个参数,声明一个实例变量,并把它的类型设置为与变量一样。直接把所有参数复制到类中来保留签名,并把它们格式化为实例变量的声明。在构造函数中把所有参数赋予实例变量。
- 在新类中创建一个空的执行方法。通常这个方法叫做run()。我们在例子中使用的名称是draw。
- 把旧方法中的代码复制到执行方法中,编译,然后依赖于编译器。
- 编译器所显示的错误信息会表明仍然使用旧类中的方法或者变量的方法。在这些方法中,做所需的工作,使方法通过编译。在某些情况下,这非常简单,只需要修改使用对原始类引用的调用。在其他情况下,你可能需要把原始类中的方法设置为公有,或者引入getter方法,从而不需要创建公有的实例变量。
- 新类通过编译之后,你需要回到原来的方法并对其进行修改,从而使它创建新类的实例,并把它的工作委托给它。
- 如果需要,使用提取接口来打破原始类上的依赖。
完善定义
在某些语言中,我们可以在一个地方声明一种类型,然后在另一个地方定义它。这种特性最明显的语言就是C和C++。在这两种语言中,我们可以在一个地方声明一个函数或方法,而在其他地方对其进行定义,通常那会是在实现文件中。当语言有这种能力的时候,我们就可以使用它来打破依赖。
为了在C++中使用完善定义,你可以遵循以下步骤:
- 确定一个类,其中有你想要替换的定义。
- 确认方法定义位于源代码文件中,而不是头文件中,如果方法定义在头文件中,就将它移至源代码文件中。
- 在你要测试的类的测试源文件中包含头文件。
- 确认类的源文件并不是构建的一部分。
- 构建,找到遗漏的方法。
- 在测试源文件中添加方法定义,直到构建完全成功。
封装全局引用
当你想要测试一些代码,它们有一些对全局的有问题的依赖,那么你只有三种选择。你可以让全局变量在测试中有所不同,你可以连接到不同的全局变量,或者你可以封装全局变量,从而进一步解耦。最后一种方法叫做封装全局引用。
想要封装全局引用,我们可以遵循以下步骤:
- 确定你想要封装的全局变量。
- 创建一个类,在其中你会引用它们。
- 把全局变量复制到类中。如果其中一些是变量,那么就让它们在类中初始化。
- 注释掉原来的全局成员的声明。
- 声明新类的全局实例。
- 依赖于编译器找到所有对旧的全局成员无法解析的引用。
- 在每个无法解析的引用前面增加新类的全局实例的名称。
- 在你想要使用伪对象的地方,使用引入静态设置器、参数化构造函数、参数化方法或者使用getter方法替换全局引用。
暴露静态方法
在测试用具中使用无法实例化的类有些技巧。以下就是我在某些情况下使用的技术。如果你有一个方法,没有使用实例数据或者方法,那么就可以把它转换成静态方法。当它是静态的时候,你就可以为其编写测试,而不需要对类进行实例化。下面是一个Java的例子:
想要暴露静态方法,你需要遵循以下步骤:
- 编写一个测试,它可以访问类中你想要暴露为公有静态方法的那个方法。
- 把方法中的代码提取到静态方法中。记得要保留签名。你可能需要对方法使用一个不同的名称。通常你可以使用参数的名称来帮助你得到新的方法名称。例如,如果方法的名称是validate,并接受一个Packet作为参数,你可以把其中的代码提取为名为validatePacket的静态方法。
- 编译。
- 如果有任何与访问实例数据和方法有关的错误,那么查看一下那些特性,看是否也可以变成静态的。如果可以,那么就把它们设置为静态的,从而让系统编译通过。
提取并重写调用
有些时候,在测试过程中阻碍我们的依赖都是局部的。我们可能有需要替换的单独方法调用。如果可以在方法调用上打破依赖,那么我们就可以避免在测试中造成奇怪的副作用,或者检查传递给调用的值。
想要提取并重写调用,你需要遵循以下步骤:
- 确定你想要提取的调用,找到它的方法的声明,复制它的方法签名,从而保留签名。
- 在当前类中创建一个新方法,赋予它你复制的签名。
- 把调用复制到新方法上,并使用对新方法的调用来替换那个调用。
- 引入测试子类,重写新方法。
提取并重写工厂方法
当你想要为类编写测试的时候,在构造函数中创建对象会很烦人。有时在那些对象中所做的工作不应该发生在测试用具中。其他时候,你只是想要设置感知对象,但是做不到,因为对象的创建是在构造函数中通过硬编码实现的。
想要提取并重写工厂方法,你需要遵循以下步骤:
- 识别构造函数中的创建对象操作。
- 把与创建相关的所有工作都提取到一个工厂方法中。
- 创建一个测试子类并重写其中的工厂方法,以避免测试中对有问题的类型的依赖。
提取并重写getter方法
提取并重写工厂方法(25.7节)是一种强大的方法,它可以分离类型上的依赖,但并不适用于所有情况。最大的问题就是,它无法在C++中使用。在C++中,你无法从基类的构造函数中调用继承类中的虚拟函数。幸运的是,对于这种情况有一种变通方案,使你只需要在构造函数中创建对象,而不需要做其他附加的工作。
想要提取并重写getter方法,你需要遵循以下步骤:
- 确定你需要为其创建getter方法的对象。
- 把创建对象所需要的所有逻辑都提取到一个getter方法中。
- 使用对getter方法的调用替换所有使用对象的地方,并在所有构造函数中把持有对象的引用都初始化为空。
- 向getter方法中增加第一次调用单独逻辑,从而在引用是空的时候能够构造对象,并赋给引用。
- 创建子类并重写getter方法,从而为测试提供另一个对象。
提取实现器
提取接口(25.10节)是一种非常方便的技术,但其中有一部分很困难:命名。我经常会遇到这样的情况,我想要提取接口,但是想要使用的名称已经是类的名称了。如果我是在一种支持重命名类以及提取接口的IDE环境下工作,那么这就非常容易处理。
命名是设计的关键部分。如果你选择好名称,那么就可以加强对系统的理解,并让与系统相关的工作更容易。如果你选择了很差的名称,那么就会对理解造成破坏,并让追随你的程序员们的命运很悲惨。
想要提取实现器,你需要遵循以下步骤:
- 创建源类的声明的副本。给它起个不同的名字。让你提取的类拥有命名规则会很有用。我通常会使用前缀Production,意味着新类在生产代码中是一个接口的实现器。
- 删除所有非公有的方法和所有变量,从而把源类转换成接口。
- 把所有重命名的公有方法设置为是抽象的。如果你在使用C++,那么就要确保你设置为抽象的方法没有被非虚方法重写。
- 检查接口文件中的所有要导入和包含的文件,看它们是否都是必需的。通常你可以删除其中很多文件。你可以依赖于编译器(23.4节)来进行检测。依次删除,然后重新编译,看是否需要它。
- 让你的生产类实现新的接口。
- 编译生产类,确保接口中的所有方法签名都得到了实现。
- 编译系统的其他部分,找到所有创建了源类实例的地方。把那些地方替换为创建新的生产类。
- 编译并测试。
提取接口
在很多语言中,提取接口都是最安全的一种打破依赖的技术。如果某个步骤出现了错误,编译器就会马上告诉你,所以引入缺陷的几率非常小。该方法的要点在于,某个类中有在某种情境下你想要使用的所有方法的声明,而你要为其创建一个接口。当你完成的时候,就可以通过实现接口来感知或者分离,把伪对象传递到你想要测试的类中。
提取接口有三种方法,还有一些需要注意的小问题。
第一种方式是,如果你足够幸运,在开发环境中有自动化重构支持,那么就使用它。为你提供支持的工具通常会提供某种方式来选择类中的方法,并输入在新接口中的名称。真正好的工具会问你,是否想要让它在代码中搜索,并找到需要修改引用从而使用新接口的地方。这样的工具可以节省大量工作。
第二种方式来提取方法:你可以使用我在这个部分概述的步骤逐步提取,人工总结提取。
第三种方式是把多个方法从一个类剪切(复制)并粘贴到接口中声明它们的地方。这没有前两种方法那样安全,但也非常安全,通常,当你没有自动化支持,并且构建需要花费很长时间的时候,这是唯一可行的提取接口方式。
想要提取接口,你需要遵循以下步骤:
- 使用你喜欢使用的名称来创建一个新接口,暂时不要向其中添加任何方法。
- 让提取接口被提取前所在的类实现那个接口。这不会破坏任何东西,因为接口没有任何方法,但最好编译并运行测试来验证一下。
- 修改你想要使用对象的地方,让它使用接口而不是原来的类。
- 编译系统,并在接口上为每个编译器报告为错误的方法使用地方引入新的方法声明。
提取接口和非虚函数
也许你在代码中有这样的调用:bondRegistry.newFixedYield(client)。在很多语言中,仅仅通过查看,我们很难确定方法是静态方法,还是虚方法,还是非虚的实例方法。在允许非虚实例方法存在的语言中,如果你提取接口,并把类中非虚方法的签名添加到其中哪个,那么就会出现问题。一般来说,如果你的类没有任何子类,那么你可以把方法设置为虚方法,然后提取接口。
引入实例委托器
人们会因多种原因在类中使用静态方法。最常见的一种原因就是要实现单例设计模式。另一种常见的使用静态方法的原因就是要创建工具类。
在很多设计中我们都很容易发现工具类。它们是那些没有任何实例变量或者实例方法的类。它们只是包含一系列静态方法和常量。
人们会因为多种原因创建工具类。大多数时候,当对于一系列方法很难找到通用的抽象时,就会创建工具类。Java JDK中的Math类就是典型的例子。
想要引入实例委托器,你需要遵循以下步骤:
- 确定一个在测试中使用有问题的静态方法。
- 在类中为那个方法创建一个实例方法。记得要保留签名(23.3节)。让实例方法委托给静态方法。
- 找到在类中使用具有测试的静态方法的地方。使用参数化方法(25.15节)或者其他打破依赖的技术,向调用静态方法的地方提供实例。
- 调用第3步中引入实例的委托,替换掉对原来的静态方法有问题的调用。
引入静态设置器
尽管全局内容会造成很多痛苦,但还是在很多系统中存在。在某些系统中,有人会直接使用,且不知道这会带来什么问题;有人只是在某个地方声明了一个变量。在其他系统中,它们会装扮成单例,严格地坚持单例设计模式。不管是哪种情况,创建伪对象来进行感知都非常简单。如果变量是一个很明显的全局变量,就位于类之外,或者作为公有的静态变量存在,那么你就可以替换掉那个对象。如果引用前有final或const修饰符,你可能就需要去掉那种保护。在代码中写下注释,说明你是为测试而那样做,那样其他人就不会在生产代码中那样访问了。
单例设计模式是一种很多人用于确保在程序中对于特定的类只有一个实例的模式。大多数单例都有三种共同的属性:
- 单例类的构造函数通常都是私有的。
- 类的一个静态成员会持有类在程序中创建的唯一实例。
- 我们会使用一个静态方法来提供对实例的访问。通常这个方法会被命名为instance或getinstance。
想要引入静态设置器,你需要遵循以下步骤:
- 降低对构造函数的保护,从而通过创建单例的子类来创建伪对象。
- 为单例类增加静态setter方法。setter方法应该接受对单例类的引用。确保setter方法会在设置新对象之前,以合适的方式销毁单例实例。
- 如果你需要访问单例中的私有或者受保护的方法,从而为测试而设置它们,那么考虑创建它的子类,或者提取一个接口,并让单例以引用的形式持有它的实例,而它的类型就是接口的类型。
链接替换
面向对象让我们可以拥有很好的机会,能够用一种对象替换另一种。如果两个类实现了相同的接口,或者拥有同一个超类,那么你就可以很容易地用一个替换另一个。不幸的是,使用类似于C语言之类的过程化语言的人们无法做到。当你拥有类似于这样的函数时,就没有办法在编译时用一个函数替换另一个,因为无法使用预处理程序。
是否有其他解决办法呢?是的,你可以用链接替换来用一个函数替换另一个。
要使用链接替换,你需要遵循以下步骤:
- 确定你想要虚拟的函数或者类。
- 为它们创建另一种定义。
- 调整你的构建,从而包含另一种定义而不是生产上的版本。
参数化构造函数
如果你是在构造函数中创建对象,那么,通常替换它的最简单的方式就是把它的创建过程放在外边,在类之外创建对象,然后让客户端把它作为参数传递给构造函数。
想要参数化构造函数,你需要遵循以下步骤:
- 确定你想要参数化的构造函数,并创建它的副本。
- 为你想要替换创建过程的对象向构造函数增加参数。删除对象创建的代码,并添加从参数到对象的实例变量的赋值。
- 如果你能够在语言中从构造函数中调用构造函数,那么就删除旧构造函数中的代码,替换成对旧构造函数的调用。在旧构造函数中增加新的表达式以调用新构造函数。如果你无法在语言中从另一个构造函数中调用构造函数,那么可能就需要把构造函数中所有重复的部分提取到一个新方法中。
参数化方法
你有一个在内部创建对象的方法,你想要替换那个对象,从而进行感知或者分离。通常做到这一点的最简单方式就是从外部传递对象。
想要参数化方法,你需要遵循以下步骤:
- 确定你想要参数化的方法,并创建它的副本。
- 为你想要替换创建过程的对象向方法增加参数。删除对象创建的代码,并添加从参数到持有对象的变量的赋值。
- 删除复制的方法中的代码,并调用参数化的方法,为原始的对象使用创建对象的表达式。
原始化参数(Primitivize Parameter)
一般来说,修改一个类的最佳方式就是在测试用具中创建一个实例,为你想要做出的变更编写测试,然后做出满足测试的修改。但有时为了让类可以测试所需要做的工作量非常大。我曾经访问过的一个团队继承了一个遗留系统,系统中的领域类以递归的方式几乎全部彼此依赖。好像这还不够糟,它们还全都绑定在一个持久化框架上。他们倒是可以让这些类中的一个处于测试框架之下,但是,如果他们花费太长时间处理领域类,那么在一段时间内就无法取得特性上的进展。为了获得一些分离,我们使用了这种策略。例子已经被改变,能够保护无辜的代码。
想要原始化参数,你需要遵循以下步骤:
- 编写一个自由函数,能够完成你需要在类中实现的工作。在过程中,编写你可以用来完成这项工作的中间表现。
- 向构建表现的类增加函数,并把它委托给新的函数
上推特性
有时,你需要使用类中的一组方法,而让你无法实例化类的依赖与这些方法无关。我这里所说的“无关”指的是,你想要使用的方法不会直接或间接引用任何不良依赖。你可以重复暴露静态方法(25.5节)和分解方法对象(25.2节)操作,但那并不是处理依赖最直接的方式。
想要上推特性,你需要遵循以下步骤:
- 确定你想要上推的方法。
- 为包含方法的类创建一个抽象超类。
- 把方法复制到超类并编译。
- 把编译器提出警告的遗漏的引用复制到新的超类中。记得在同时保留签名,以降低出现错误的几率。
- 当两个类都成功编译之后,为抽象类创建一个子类,并把你需要能够在测试中设置的方法都添加到其中。
下推依赖
某些类只拥有少许有问题的依赖。如果依赖只包含在少许方法调用之中,那么在编写测试的时候,你就可以使用创建子类并重写方法(25.21节)来解决它们。但是,如果依赖到处都是,那么创建子类并重写方法可能就无效了。你可能需要多次使用提取接口来移除对于特定类型的依赖。下推依赖是另一种可选的方法。这种技术会帮助你把有问题的依赖和类的其他部分分离开来,使得它在测试用具中更易于处理。
当你使用下推依赖的时候,要把当前类设置为抽象类。然后你要创建子类,它会作为你新的生产类,然后你会把有问题的依赖下推到那个类中。那时,你可以创建原始类的子类,并让它的方法可供测试。
想要下推依赖,你需要遵循以下步骤:
- 试着构建在测试用具中有依赖问题的类。
- 确定在构建中是哪些依赖产生了问题。
- 创建新的子类,名称要能够与那些依赖的特定环境通信。
- 把包含很差依赖的实例变量和方法复制到新的子类中,注意要保留签名。在原始的类中,把方法设置为受保护和抽象的,并把原始类设置为抽象的。
- 创建测试子类并修改你的测试,从而设置对其进行实例化。
- 构建你的测试,以确认你可以实例化新类。
使用函数指针替换函数
当你需要在过程化语言中打破依赖的时候,就没有像在面向对象语言中那么多的选择。你无法使用封装全局引用(25.4节)和创建子类并重写方法(25.21节)。所有那些选项都不可用。你可以使用链接替换(25.13节)和定义完善(25.3节),但通常对于很小的打破依赖操作,它们有些大材小用了。使用函数指针替换函数是支持函数指针的语言中的一种可选方法。带有这种支持的最众所周知的语言就是C。
不同的团队对函数指针有不同的看法。在某些团队中,函数指针被视为极为不安全的事物,因为它可能会破坏内容,最终通过内存的某些随机区域调用。在另一些团队中,它被视为非常有用的工具,但要小心使用。如果你更倾向于“小心使用”阵营,那么就可以分离一些依赖,如果不使用函数指针这些依赖的分离就很难或者不可能做到。
想要使用函数指针替换函数,你需要遵循以下步骤:
- 找到你想要替换的函数的声明。
- 在每个函数声明之前,创建名称相同的函数指针。
- 重命名原始的函数声明,使它们的名称和你刚刚声明的函数指针不同。
- 在C文件中把指针初始化为旧函数的地址。
- 运行构建,以找到旧函数的函数体。把它们重命名为新的函数名称。
使用getter方法替换全局引用
当你想要独立地处理一段代码时,全局变量是个大麻烦。那也正是我在此想要说的。在引入静态设置器(25.12节)部分的介绍中,我对全局变量抱怨了很多。在此就不再重复,以节约你的时间。
在类中解决全局变量的依赖问题的一种方法是,在类中为每个全局变量引入getter方法。当你拥有getter方法之后,就可以创建子类并重写方法(25.21节),让getter方法返回合适的内容。在某些情况下,你可能会进一步使用提取接口(25.10节)来打破类对全局变量的依赖。
想要使用getter方法替换全局引用,你需要遵循以下步骤:
- 确定你想要替换的全局引用。
- 为全局引用编写getter方法。确保方法的访问保护足够松弛,可以让你在子类中重写getter方法。
- 使用对getter方法的调用来替换对全局变量的引用。
- 创建测试子类,并重写getter方法。
创建子类并重写方法
创建子类并重写方法是在面向对象的语言中打破依赖的核心技术。事实上,本章中很多其他打破依赖的技术都是它的变体。
创建子类并重写方法的核心思想是,你可以在测试的情境下使用继承,把你不关心的行为置为空,或者访问你确实关心的行为。
想要创建子类并重写方法,你需要遵循以下步骤:
- 确定你想要分离的依赖,或者你想要感知的地方。试着找到最小的一组方法,你可以重写它们来达到目标。
- 让每个方法都可重写。做到这一点的方式会因为编程语言的不同而不同。在C++中,如果方法还不是虚方法,就需要进行设置。在Java中,方法需要被设置为非最终的(non-final)。在很多.NET语言中,你需要显式地把方法设置为可重写。
- 如果你的语言需要,那么就调整你将要重写的方法的可视性,让它们可以在子类中重写。在Java和C#中,想要在子类中重写,方法至少需要拥有受保护的可视性。在C++中,方法保持私有,也可以在子类中重写。
- 创建重写方法的子类。确认你能够在测试用具中构建它。
替代实例变量
在构造函数中创建对象会导致问题,特别是在测试中很难依赖于那些对象的时候。在大多数情况下,我们可以使用提取并重写工厂方法(25.7节)来解决这个问题。然而,在一些语言中不允许在构造函数中重写虚函数,那样我们就需要寻找其他办法。其中之一就是替代实例变量。
想要替换实例变量,你需要遵循以下步骤:
- 确定你想要替代的实例变量。
- 创建名为supersedeXXX的方法,其中XXX是你想要替代的变量的名称。
- 在方法中,编写你需要的代码,从而销毁之前的变量的实例,并把它设置为新的值。如果变量是一个引用,那么就验证在类中没有任何对它所指向的对象的其他引用。如果有的话,你可能就需要在替代的方法中做更多工作,以确保替换对象是安全的,并会产生正确的影响。
模板重定义
本章中很多打破依赖的技术都依赖于核心的面向对象机制,像接口和实现继承。某些更新的语言特性提供了更多选项。例如,如果一种语言提供了泛型和为类型创建别名的方式,那么你就可以使用叫做模板重定义的技术来打破依赖。
以下是在C++中重定义模板的描述。在其他支持泛型的语言中,步骤可能会有所不同,但这个描述对这种技术做了简单说明:
- 确定你想要测试的,在类中替换的特性。
- 把类转换成模板,用你需要替换的变量对其进行参数化,并把方法体复制到头文件中。
- 为模板起个另外的名称。一种机械的方式就是在原来的名字前加上Impl。
- 在模板定义之后增加typedef语句,使用原始的类名和参数来定义模板。
- 在测试文件中,把模板定义包含进来,并在新类型上实例化模板,那种类型会替换你需要为测试而替换的类型。
文本重定义
一些更新的解释型语言给了你一种非常好的打破依赖的方式。在解释的时候,方法可以在运行的过程中重定义。
为了在Ruby中使用文本重定义,你可以遵循以下步骤:
- 确定一个类,其中有你想要替换的定义。
- 增加带有模块名称的require从句,那个模块会在测试源文件的上面包含那个类。
- 在测试源文件前面为你想要替换的每个方法提供另一种定义。