第6章 修改代码的技术
- 新生方法:
- 将新增代码形成一个新的方法,并在修改点对其进行调用。
- 类的依赖关系太恶劣时,可考虑将新方法作为公有静态方法,并将this作为参数传递(便于测试)
- 新生类:
- 确定修改点后在修改点生成新的类,需要将原来的局部变量传递时则将其传递给构造函数,同时有需要返回结果则构建对应的方法并进行调用来获得结果。
- 优点在于进行侵入型较强的修改时能有更大的自信继续进行,而且不用改变现有的头文件,不会增加原类的编译负担。
- 缺点是会使系统中的概念复杂化。
- 时间耦合:
- 两段代码仅仅是需要同时执行(执行了一个就必须也要执行另一个),可以使用新生类(当它们不再需要同时执行时方便拆分),但是外覆方法可能更好。
- 外覆方法:
- 生成同名的新方法,并在新方法中调用改名后的旧方法。(然后执行新的代码)
- 将旧方法与新增代码包装形成一个新的接口。
- 缺点
- 逻辑无法与原方法交融,必有先后(这应该不是坏事)
- 函数取名可能会比较糟糕
- 优点是完全独立与现有的方法
- 新生方法和外覆方法的区别在于是否会修改原方法内部(新生会插入一个调用而外覆不会)
- 外覆类:
- 进行实现提速或接口提取,并让外覆类实现该接口。(可以使用公有继承或类组合,取决于需要大范围更改还是个别地方的更改,同上)
- 在设计模式中称为装饰模式。用一个类覆盖另一个类(被外覆类),并传递其对象,同时实现同样的接口,这样外覆类可通过派生子类来解决被外覆类同一个接口上不同的时间耦合问题,甚至可以将外覆类的子类们实现为层层嵌套(当然套太多了还是不好)
- 使用情形:
- 新的操作与原类完全独立
- 原类已经足够大了
第8章 添加特性
- 依赖倒置原则:
- 让代码依赖于改动不频繁的接口而不是依赖于具体的类。
- 为新类解依赖的同时提供新类的接口防止新类的客户代码对其依赖。
- 测试驱动开发:
- (将想要修改的旧类置于测试之下)
- 编写失败用例
- 让用例通过编译
- 让测试通过
- 消除重复
- 重复以上步骤
- 测试驱动开发能避免同时进行编码与重构
- 差异式编程:
- 通过继承添加新特性,随后(在测试的帮助下)进行重构
- 注意不要违反Liskov置换原则(LSP): 子类对象应当能够替换代码中出现的它们的父类对象,无论后者出现在什么地方。有个一般性原则就是不要重写父类的非虚方法。
第9章 无法将类放入测试用具中
- 测试时旧方法(类)有很多麻烦的参数:
- 接口提取,传 null(测试类中),子类化并重写
- 空对象模式可让调用方免于进行显式错误检查,这个在调用方不关心是否成功是尤其有用(因为为无效对象进行一次调用不会产生副作用)
- 隐藏依赖:
- 参数化构造函数,让一个隐藏在构造函数中的依赖由外部传递,进而使用接口提取。
- 其他方法:提取并重写获取方法,提取并重写工厂方法,替换实例变量。
- 替换实例变量: 在外面构造对象后,通过替换方法将其传递进去(但是替换是可能会产生一些资源相关的问题)
- 全局依赖/单例模式:
- 放松单例的约束(也称为引入静态设置方法)
- 添加新的静态方法,以替换单例中原先的静态实例
- 构造新的实例,然后进行替换。(这里因为很多单例的构造函数不是公开的,所以可以派生一个子类然后提取接口或者实现),允许打破单例的情况下也可直接将构造函数公开)
- 洋葱参数(构造函数中不断传递其他类的对象):
- 参数传null,或用接口提取、实现提取简化
- 化名参数
- 有些情况下使用接口提取不太妥当(会使得接口与类一一对应),这时可以使用子类化并重写的方法解开依赖
- 无法访问类中的私有方法:
- 在子类中使用 using 暴露基类的保护接口
- “有益”的语言特性(封闭的类,sealed, final)
- 通过非封闭的基类(如果有)进行参数适配(和接口提取类似,但是接口提取需要修改原始类,而这里的类是库中的,无法修改)
- 剥离并外覆API
第11章 修改时应该测试哪些方法
寻找影响的传播:
- 确定修改的函数
- 如果有返回值,查看调用方
- 如果修改了一些值,则查看使用这些值的方法,以及调用这些方法的方法
- 查看父类和子类,可能会使用这些实例变量和方法
- 查看参数是否被修改
- 全局变量和静态数据
- 影响和封装:
- 封装本身并不是目的,而是帮助理解代码的工具
第13章 修改时应该怎样写测试
- 特征测试:
- 用于刻画当前代码的实际行为(而不是要寻找bug),不断调整测试用例使其与当前代码的行为一致(而不一定是保持正确),如果有与期望不一致的行为,也要弄清楚原因
- 刻画类:
- 寻找代码中逻辑复杂的部分
- 为你认为可能出错的地方编写失败用例
- 使用极端值/临界值作为测试输入
- 寻找不变式
- 目标测试:
- 确认测试的覆盖率(为代码分支编写测试时,应该考虑除了分支被执行之外的使测试通过的条件)
- 确认输入是否会使原来不通过的测试通过了(例如数据类型不匹配造成错误),可以手动计算期望得到的值,留意每个存在类型转换的地方,或者利用调试器确认类型转换,或使用感知变量确认路径覆盖。
第15章 到处都是API调用
- 剥离并外覆API,同时使用签名保持以减少出错的概率。(适用于API规模较小/想完全解开API的依赖/没有测试的情况下)
- 基于职责的提取,将API连同一部分代码一起提取为一个低级的接口,供上层使用。(API规模大/能安全地完成重构)
第16章 对代码的理解不足
- 理解旧代码:
- 使用草图/注记
- 打印代码(方法)后进行标注
- 删除完全没有被使用到的代码
第18章 测试代码碍手碍脚
测试代码命名约定:
- 测试类: 后面加Test后缀
- 测试子类: 子类化后的测试类可加上Testing前缀
- 伪类: 加上Fake前缀
测试代码安置
- 与产品代码放在同一目录里
- 放在同一地点,并用条件编译等隔离开
- 最好不要将两者分离到不同项目中
第19章 修改非面向对象代码
- 用条件编译+宏来替换函数调用
- 使用文件包含将产品和测试代码分离到不同文件中
- 引入新函数而不是直接添加代码到旧函数中
- 利用C的函数指针设立接缝
第21章 修改大量重复代码
- 逐步提取公共部分以放到基类中,并对差异的部分使用虚函数进行特化,隐藏到接口之后。
- 同时考虑: 增加新类(新特性)是否方便、修改当前所有类的行为是否方便,是否丧失了一些灵活性等。
- 正交性: 即无关性,修改代码时有单一切入点
- 开放/封闭原则: 代码对于扩展是开放的而对于修改是封闭的,好的设计无需对代码进行太多修改就可以添加新特性。
第22章 要修改巨型方法,却没法写用例
分类:
- 项目列表式:(几乎)毫无缩进,一串罗列下来仿佛列表般的代码,理想情况下可以将每个区段提取为一个方法
- 锯齿状方法: 具有单个庞大的缩进块(嵌套if)
尽量利用自动重构(工具)。没有测试的情况下,一定要只用工具进行,不要手工修改。重构、测试安置到位后再来手工修改。这个时候要做的是分离逻辑依赖和引入接缝(方便安置测试)
手动重构
- 引入感知变量: 借此来简化/间接判断一些操作(或代码分支)是否执行
- 只提取你所了解的: 提取小块代码。(耦合数: 传进传出所提取出方法的参数数)耦合数越少越好,数量大于0时可以使用感知变量。提取完后写几个测试。
- 依赖收集: 写测试来保护需要保护(不修改)的逻辑,然后提取测试没有覆盖的部分,通过这样来保护关键的行为。
- 分解出方法对象: 分解大方法时相当于创建了一个类: 原来的参数作为构造函数的参数,逻辑主体在Run()中完成。
一些策略
- 提取if等分支时,可以将判断和执行分开提取,也可以合并提取。分开的话便于重组,合并的话则逻辑会比较清晰。
- 优先提取到当前类中:就算有些方法可以(或应该)并入其他类中,也先不要这么做,这样可以使当前的修改操作易于进行,不易出错。可以之后再分类合并。
小块提取代码:一次提取太多容易忽略细节引入bug
时刻准备重新提取:可能会有新的认识,更好的提取方法
第23章 降低修改的风险
- 单一目标的编辑: 一次只做一件事
- 签名保持(其实同上)
- 依靠编译器: 但要注意一些隐式类型转换、继承、重载等编译器无法识别的情况
- 结对编程
第25章 解依赖技术
参数适配
- 创建被用于方法的新接口,接口越简单越好,同时也不应该造成对代码的大范围修改。
- 为接口创建产品和测试的实现。
- 编写用例将伪对象传给该方法,并适当进行修改以便能顺利测试。
分解方法对象
- 创建一个将要包含目标方法的类。
- 创建与原方法相同签名的构造函数,并创建所有参数对应的类成员变量。
- 有需要用到原类中的成员变量时,在参数列表中添加一个原类的引用,可能还要暴露一些额外的方法、或是为成员变量增加获取函数。
- 在新类中创建一个空的执行方法(如 run() 等),将原方法的操作移至此中,并通过编译。
- 在原方法中将工作委托给新类的方法完成。
- 有需要的话再使用接口提取解开对原类的依赖。
定义补全(C/C++)
- 为测试创建对应函数体的实现
- 在链接时根据需要链接至不同的实现代码
- (维护比较麻烦,不推荐使用)
封装全局引用
- 创建类来引用全局变量/函数。
- 在想要使用伪对象的地方可以利用 引入静态设置方法/参数化构造函数/参数化方法/获取方法替代全局引用。
暴露静态方法
- 将成员变量设置为静态的。
提取并重写调用
- 在当前类创建一个新方法,将对目标方法的调用移动到新方法中,然后调用这个新方法。
- 可以解开如全局变量/其他类的依赖。
提取并重写工厂方法
- (构造函数中固定了的初始化工作可能会为测试带来很大麻烦)单独的init和clear方法
- (C++中在构造/析构函数中虚函数的调用不会被重新决议,因此C++中该方法不适用,可用替换实例变量和提取并重写获取方法代替。其实把初始化从函数函数里移出来就行了。)
- 在构造函数的新建对象移到一个工厂函数中并调用。
- 在子类中重写工厂函数。
提取并重写获取方法
- 为想要替换的成员引入获取方法,以便换入伪对象。
- 获取方法中使用迟求值的方法: if (!obj) { obj=new XXX; } return obj;
- 缺点是容易被直接访问变量绕过
- 如果引用只有一处的话可用上面的 提取并重写调用。
实现提取
- (主要用来处理接口提取时面临的命名接口困难的问题)
- (接口提取是新增一个接口,当前类作为一个派生;实现提取是自己架空成一个接口,用新增类代替自己原先的使用,也达到了新增接口的目的)
- 如果当前类已经有了基类和派生类:B->C->D,转换后可能形如:IB->B->C->D + IB->IC->C->D 这样一个菱形结构。
接口提取
- 根据需要为当前类提取一个接口,并在代码中以对接口的引用来代替对原类的引用。
- 注意与非虚函数同名的情况,或者提取时将非虚函数意外转变为虚函数的可能,这会引发运行时多态从而导致调用了错误的函数。
引入实例委托
- 在测试中有碰到会带来麻烦的静态方法时,可在静态类中添加一个非静态方法,并在其中调用静态方法,借此来引入对象接缝。
引入静态设置方法
- 在全局变量或单例的类中引入一个设置方法,以便(调用它们的Get时)能在产品和测试中返回不同实例。
- 要求其构造函数至少是保护的,这样才能另外创建出对象后再调用设置方法替换。
- 或者采用接口提取对其进行子类化。
连接替换
- 同定义补全。
参数化构造函数
- 复制一份想要参数化的构造函数。
- 增加一个用来传递想要替换对象的参数,并修改对应逻辑。
- 复制后的方法调用原方法(注意构造函数间不能显示调用!)
- (或者用默认参数)
朴素化参数
- 为了解开依赖,编写一个自由函数来实现想要做的事。
- 通过一个中间表示在自由函数和原类方法之间传递信息。
特性提升
- 为了解开依赖将类中的一部分代码转移到抽象基类中去。
- 通过编译之后为抽象基类创建一个测试子类。
依赖下推
- 在测试用具中创建目标类并找出依赖。
- 将依赖复制到子类,并将目标类中相应方法设为抽象。
- 创建并实例化测试子类。
把函数换成函数指针
- 与定义补全、连接替换不同,这个是发生在编译期。
以获取方法代替全局引用
- 和引入静态设置方法不同的是,这里是在调用的类里添加一个获取的方法。
子类化并重写方法
- (面向对象程序中解依赖的核心技术)
替换实例变量
- C++不支持构造函数中转发虚函数,故不支持“提取并重写工厂方法”
- 改用单独写一个函数来销毁原先创建出的变量并直接换入新的变量。
void Blendingpen::supersedeParameter(Parameter *newParameter) { delete m_param; m_param = newParameter; }
模板重定义
- 在测试类中找出想替换的特性
- 将类改为模板,并参数化想替换的变量为模板参数
- 给模板换一个名字,如加上Impl
- 在模板定义后面加上typdef XXXImpl
XXX,以不影响原来的代码 - 实例化并测试
- (C++编译器普遍不支持模板的分离编译)
- (不推荐这种改法)
文本重定义(Ruby,C/C++可以使用预处理)
- 找出想要替换定义的方法所在的类
- 在测试源文件开头添加require引入包含目标类的模块
- 在测试源文件开头给每个要替换的方法提供新的定义