Digest of Working Effectively with Legacy Code
Part I - The Mechanics of Change
Chapter 1 - 修改代码的 4 个理由
修改代码的 4 个理由以及其侧重点:
理由/侧重点 | Code Structure | New Behavior | Old Behavior | Resource Usage |
---|---|---|---|---|
添加 feature | Change | Add | - | - |
修正 bug | Change | - | Change | - |
refactoring | Change | - | - | - |
optimizing | - | - | - | Change |
Chapter 2 - 修改代码的 2 种方式
修改代码的 2 种方式:
- Edit-and-Pray
- Cover-and-Modify
那第一种也不是说不行,但肯定不如第二种保险。
如何 Edit-and-Pray
请阅读 Chapter 6
如何 Cover-and-Modify: 要 Unit Test 而不是 Regression Test
Unit Test 和 Regression Test 虽然都是 test,但是它俩不是一个维度的概念。Unit Test 强调的是形式、是组成单位,Regression Test 强调的是性质。简单说,所有检测 “引入的新代码后,旧的 behavior 有没有改变” 的测试都是 Regression Test,它可以是 Unit Test、Integration Test、UI Test、etc.,或是它们的组合。
一般来说 Regression Test 会比较费时,对频繁的修改不够效率。Unit Test 颗粒明显要小很多,效率更高。如何判断一个 Unit Test 快不快?作者的理论是:耗时超过 0.1 秒的都不算快!考虑到 Java 的 application 可能有上千个 class,这个标准也说得通。
严格来说,Unit Test 有这么些要求:
- 要跑得快
- 不应该与 DB 交互
- 不应该有 network 通信
- 不应该调用 File System
- 不应该依赖特定的运行环境 (比如配置文件)
但实际情况往往无法做到这么严格。
依赖 (Dependency) 是 Unit Test 的障碍
表现在:
- 难以在测试中初始化 target 对象
- 难以在测试中调用 target 方法
如何 Cover-and-Modify: 流程
The Legacy-Code-Change algorithm:
- Identify change points. (确定改动点)
- Find test points. (找出测试点)
- Break dependencies. (解依赖)
- Write tests. (写测试)
- Make changes and refactor. (修改代码、重构)
Chapter 3 - 解依赖的两个目的:Sensing and Separation
这一章是作者模糊表达的重灾区!写得弯弯绕绕的,真的有那么复杂吗?!
啥叫 sensing?给翻译翻译!
结合后面作者的用词,“to sense through object/method” 的意思其实就是 “搞清楚 object/method 具体干了啥”,具体说来就是:
- “搞清楚 object/method 计算了哪些 value” (这是书上的意思)
- “搞清楚 object/method 产生了哪些 side effect” (这是我个人的扩展)
所以简单来说,“sensing” 就是 “搞清楚” 的意思 (MD,搞 OO 的这帮人一天到晚在这嚼词汇也不是一天两天了)。
那我们说要 “sense the effect of change” 的意思就是 “搞清楚 change 对 target object/method 的 computed value/side effect 的影响”。
那我们为啥需要 sensing?这不明显着嘛:test 需要知道这些 computed value/side effect 啊,不然它咋测试?
那 sensing 和 break dependency 有啥关系?我个人的理解是:不需要 break dependency 也可以 sensing 啊,你读代码算不算 sensing?也算啊,对不对?所以这里我认为更准确的陈述是:“break dependency 是 sensing 的一种辅助手段”,因为有时候 dependency 部分的代码不太好 sense (或者说:target object/method 调用 dependency 产生的 computed value/side effect 不太好 sense) (这里涉及到一个 “你到底是 sense through dependency 还是 sense through target object/method” 的问题)
那什么是 fake collaborator?我个人的理解是:“fake collaborator 是一个具体的 break dependency 的技术,用来辅助 sensing”。
Separation
Separation 比较简单,它主要就是解决前面说的两个问题:
- 难以在测试中初始化 target 对象
- 难以在测试中调用 target 方法
If dependency makes it impossible to set up a test harness, we need to separate the code from that dependency.
本书的大部分章节其实都是在解决 separation 的问题。
Fake Collaborator
这一小节从技术角度好理解,注意 mock objects 是 “能够对 internal behavior 做 assertion 的” 高级 fake object 就可以了。
Chapter 4 - Seam
Seam 本意是 “接缝”,比如两块布的连接处、两片金属的焊接处,这里指:
A seam is a place where you can alter behavior in your program without editing in that place.
注意它定义的落脚点是 “place”,后面的 “seam types” 其实就是在介绍 “有哪些 places”:
- Preprocessing Seam: 比如用
#ifdef
来控制 behavior (的地方) - Link Seam: 通过控制 linking 的 library 来控制 behavior (的地方)
- Object Seam: 简单来说就是尽可能地利用多态,方便传入 fake/mock objects (的地方)
Preprocessing Seam 举例
可以看 Chapter 19
Link Seam 举例
用 python 举个例子。假设你的 prod 环境只有 orjson
,你的 test 环境只有 json
(或者在 prod/test 上都安装 orjson
和 json
,但是在启动脚本里提供不同的 classpath),你可以用:
try:
import orjson
except Exception:
import json
来动态 import,从而控制 caller 的 hehavior。
Object Seam 举例
用 java 举个例子:
public foo() {
Bar b = new Bar();
b.doSomething();
}
// 改写成:
public foo(Bar b) {
b.doSomething();
}
后面一种写法就能利用 Bar
的多态,使得我们能传入一个 Bar b = new MockBar()
来方便测试。
Chapter 5 - Tools (略)
这一章没啥用,略过。
Part III - Dependency-Breaking Techniques
Part III 就一章,我也是醉了。对的,我就是要先写 Chapter 25,因为这些技能在 Part II 没有被完全 cover 到,等把 Part II 写完再写 Part III 就显得支离破碎的,不如先写了。
Chapter 25 - Dependency-Breaking Techniques
技能树
灾难的 Chapter 25!一共 24 个技能,作者发了疯按技能名字的 alphabet 排序!然后这些名字还是作者自己起的,所以这个顺序本质上是随机的!大哥你好歹分一下类吧?有的技巧是修改测试目标类、有的是修改 dependency,大部分是 object seam 的范畴、小部分是 link seam/preprocessing seam。你就大喇喇地 24 条一起扔那儿,这样好吗?这样不好!
我大概分了一下:
- Link Seam / Preprocessing Seam: 我基本用不上,放一边
- 边缘辅助技能:这三个超简单,但又不好分类,姑且叫它边缘辅助
- “逃避可耻但有用”:逃离 dependency 但无法彻底消灭 dependency
- “我成替身了”:通过 substitution (包括 fake/mock) 消灭 dependency;分三步:
- 把 dependency 纳入 object seam 里,否则没法做替换
- 如果 dependency 的替身不好做,我们就虚化、弱化 dependency class 的结构
- 给 dependency 做替身,然后在 object seam 里替换 dependency
$\dagger$ Link Seam / Preprocessing Seam
$\Diamond \texttt{[03]Definition Completion}$ (C/C++)
简单说或就是用 #include <dependency.h>
中的 function declaration,但是测试用 #include <fake.c>
中的 function definition。
$\Diamond \texttt{[13]Link Substitution}$
通过 linking 替换 library。
$\Diamond \texttt{[23]Template Redefinition}$ (C++)
将 Target Class 改写成 template,然后 template 的 parameter 就是 seam。
- 感觉像是个 Strategy Pattern
$\Diamond \texttt{[24]Text Redefinition}$ (Interpreted Language)
有的 Interpreted Language 允许 function/class definition 的覆盖 (类似 REPL 中用一个新的 def foo()
覆盖原有的 def foo()
),所以可以用 fake definition 来测试。
$\dagger$ 边缘辅助
$\Diamond \texttt{[04]Encapsulate Global References}$
封装成组的 global 的 variables/function,感觉像是减小依赖的 “散度” (从 “依赖多个 global variables/functions” 减小到 “依赖于一个 global object”)。
后续还可以接一个 $\texttt{[20]Replace Global Reference with Getter}$
$\Diamond \texttt{[05]Expose Static Method}$
如果你依赖的 dependency call 本质是个 static,那我们就可以省去实例化 dependency class
$\Diamond \texttt{[11]Introduce Instance Delegator}$
如果你依赖的 dependency call 已经是个 static,但是很笨重,我想要替换掉它,该怎么办?
// Target Class + Target Method
class Application {
public void doSomething() {
Service.foo(); // 笨重
}
}
// Dependency class
class Service {
public static void foo() {
// pass
}
}
注意 Java 的 static method 不能被 override,所以直接 fake/mock 是没法用的。可以考虑原地 tp,在 dependency class 里加一个 member method 来 delegate 这个 static method,然后再用 “我成替身了” 系技能来做替换。比如:
// 改写成:
class Application {
public void doSomething() {
this.service.foobar(); // 后续替换掉 service instance
}
}
class Service {
public static void foo() {
// pass
}
public void foobar() {
foo(); // 原地 tp
}
}
$\dagger$ “逃避可耻但有用”
这几个技能适用的场景是:Target Class 的 constructor 有 dependency,但 Target Method 并不依赖那些 dependency。我们的目的就是 “把 Target Method 从 Target Class 挪到一个新的、不需要 dependency 的新 class 中”,然后测试这个新的 class。但是这个技能系的问题是:原来的 Target Class 中仍然有 dependency,你要全局测试的话还是逃不脱。
$\Diamond \texttt{[02]Break Out Method Object}$
简单说就是 M2C (Method to Class),做法是:
- 把 Target Method 整体平移到一个 New Class 里
- Target Method 参数列表不变
- Target Method 原来用到的 Target Class 的 member 平移成 New Class 的 member
- 现在只需要实例化 New Class 就能测试了
额外优点:
- 对冗长的 Target Method 是一次 refactor 的机会,比如可以考虑 “要不要再细分计算步骤”、”要不要对用到的 local variable 建 class” 之类的
- New Class 可以考虑转型成 Strategy 或者 Visitor Pattern
$\Diamond \texttt{[06]Extract and Override Call}$
比如说的 Target Method 有 10 行是有 dependency 的,我想甩掉这个 dependency,可以把这 10 行 extract 成一个新的 method,然后再做一个 Target Class 的 subclass 替换掉这个有 dependency 的 class,然后测试 subclass。举个例子:
// 原代码:
class TargetClass {
public void targetMethod() {
int i = dependency.call(); // 笨重
this.bussinessLogic(i);
}
}
// 改写成:
class TargetClass {
public void targetMethod() {
int i = this.callDependency();
this.bussinessLogic(i);
}
public int callDependency() {
return dependency.call(); // 笨重
}
}
// 真正被测试的类:
class FakeTargetClass extends TargetClass {
@Override
public int callDependency() {
return fakeValue;
}
}
$\Diamond \texttt{[17]Pull Up Feature}$ / $\Diamond \texttt{[18]Push Down Dependency}$
和 $\texttt{[02]Break Out Method Object}$ 很像,本质就是从 Target Class 引申出一个 Abstract Target Class,把不依赖 dependency 的 Target Method 挪到 Abstract Target Class,然后再生成一个 Abstract Target Class 的子类来测试。
这两个技能本质是一样的,区别在于你是准备用 Target Class 的名字当实现类的名字、还是抽象类的名字,和 $\texttt{[09]Extract Implementer}$ / $\texttt{[10]Extract Interface}$ 的关系是类似的。
$\dagger$ “我成替身了” $\rhd$ 将 dependency 纳入 object seam
$\Diamond \texttt{[07]Extract and Override Factory Method}$
用 factory method 作为 object seam:
// 原代码:
class Foo {
public Foo(Baz baz) {
this.bar = new Bar();
this.baz = baz;
}
}
// 改写成:
class Foo {
public Foo(Baz baz) {
this.bar = makeBar();
this.baz = baz;
}
public static Bar makeBar() {
return new Bar()
}
}
有了这个 Bar
的 factory method 之后,原代码的逻辑不变,但现在我们可以 subclass Foo
然后 override 这个 factory method 来替换掉 new Bar()
这个 dependency (比如用 Bar
的 fake/mock;甚至直接用 null,如果它不影响测试的话)。
虽然我们并没有把 Bar bar
放到参数列表里,但是这个 makeBar()
相当于就是个 object seam。
$\Diamond \texttt{[08]Extract and Override Getter}$
用 lazy getter 作为 object seam:
// 原代码:
class Foo {
public Foo(Baz baz) {
this.bar = new Bar();
this.baz = baz;
}
}
// 改写成:
class Foo {
public Foo(Baz baz) {
this.bar = null;
this.baz = baz;
}
public Bar getBar() {
if (this.bar == null) {
this.bar = new Bar();
}
return this.bar
}
}
有了这个 lazy getter 之后,原代码的逻辑不变,但现在我们可以 subclass Foo
然后 override 这个 lazy getter 来替换掉 new Bar()
这个 dependency (比如用 Bar
的 fake/mock;甚至直接用 null,如果它不影响测试的话)。
虽然我们并没有把 Bar bar
放到参数列表里,但是这个 getBar()
相当于就是个 object seam。
$\Diamond \texttt{[14]Parameterize Constructor}$ / $\Diamond \texttt{[15]Parameterize Method}$
最直接的 object seam 就是参数列表。
更地道一点的话,可以保留原 signature 的 constructor/method (for back compatibility),然后新加一个 parameterized constructor/method。举例:
// 原代码:
class Foo {
public Foo(Baz baz) {
this.bar = new Bar();
this.baz = baz;
}
}
// 改写成:
class Foo {
public Foo(Baz baz) {
Bar bar = new Bar();
this(bar, baz);
}
public Foo(Bar bar, Baz baz) {
this.bar = bar;
this.baz = baz;
}
}
$\Diamond \texttt{[19]Replace Function with Function Pointer}$
如果 language 有 first-class function 的话,或者有 callable object 的话,我们可以把 dependency call 的函数体通过参数列表传给 parameterized constructor 或者 parameterized method。
$\Diamond \texttt{[20]Replace Global Reference with Getter}$
我们对 global reference (包括 singleton instance) 都是直接 access,没有经过 object seam,如果这个 global reference 是个 dependency,我们就需要把它放到 seam 里然后替换掉。我们可以用一个 getter 来做这个 seam:
Global G = new Global();
// 原代码:
import static com.global.G;
class Foo {
public foo() {
G.doSomething();
}
}
// 改写成:
import static com.global.G;
class Foo {
public foo() {
this.getGlobal().doSomething();
}
public Global getGlobal()) {
return G;
}
}
$\Diamond \texttt{[22]Supersede Instance Variable}$
“supersede” 可以简单理解成 “replace”,但这个 “super-“ 前缀暗含了一层 “A supersedes B because A is superior” 的意思,有一点中文 “取代” 的意思。
这是个万不得已才使用的技能:
- 如果我的 dependency class 无法 subclass 怎么办?(比如
final class
) - 如果我的 dependency method 无法 override 怎么办?(比如有的 language 不允许 override 被 constructor 调用的 virtual function)
迫不得已的话只能:
- 在 Target Class 内忍痛做 dependency class 的实例化 (不可避)
- 但是我可以给 Target Class 加一个 setter 在 dependency class 的实例化之后替换掉 dependency instance
这个 setter 就是 dependency instance 的 object seam。
$\dagger$ “我成替身了” $\rhd$ 虚化、弱化 dependency class
虚化、弱化 dependency class 是为了方便我们更好地做替身 (fake/mock):
- 虚化就是提取 dependency 的 interface/abstract class,然后通过 implementation/subclass 来做替身
- 弱化是用一个新的 simple dependency class 来代替原来的 complex dependency class,降低做替身的工作量
$\Diamond \texttt{[09]Extract Implementer}$ / $\Diamond \texttt{[10]Extract Interface}$
针对很难实例化的 parameter 提取一个 interface 便于我们创建 fake/mock…… (这么简单的技术这需要一个新建一个概念吗?)
至于这两个技能的区别,举个例子:
- $\texttt{Extract Interface}$ 是从
class Bar
出发,得到interface IBar
+class Bar
- $\texttt{Extract Inplementer}$ 是从
class Bar
出发,得到interface Bar
+class BarImpl
- 决定是使用哪个技能完全取决于你起始的这个
Bar
是适合做类名还是接口名
吐槽:
- 就这么个简单的破玩意儿还用得着专门写好几页?!
- 我就姑且认为这俩是 interchangeable 了
$\Diamond \texttt{[01]Adapt Parameter}$
书上的例子:
// 原代码:
public class Dispatcher {
public void populate(HttpServletRequest request) {
String [] values = request.getParameterValues(this.pageStateName);
if (values != null && values.length > 0) {
String value = values[0];
}
// pass
}
}
假设 HttpServletRequest
是一个烦人的 dependency,且假设我们只使用了这么一小段,那么我们可以给 HttpServletRequest
写一个 adapter,使得 Target Class Dispatcher
只依赖于这个 adapter 而不直接依赖 HttpServletRequest
:
// 新添代码:
interface ParameterSource {
String getByName(String name)
}
class FakeParameterSource implements ParameterSource {
private String value;
public String getByName(String name) {
return value;
}
}
class ServletParameterSource implements ParameterSource {
private HttpServletRequest request;
public String getByName(String name) {
String [] values = request.getParameterValues(name);
if (values != null && values.length > 0) {
return values[0];
}
return null;
}
}
注意要修改原来的方法签名:
// 改写成:
public class Dispatcher {
public void populate(ParameterSource source) {
String value = source.getByName(this.pageStateName)
// pass
}
}
这个改动就是把 HttpServletRequest
这个 dependency 弱化成了 ParameterSource
。
$\Diamond \texttt{[16]Primitivize Parameter}$
中文版在 P14 的脚注特别强调了一下翻译,”primitivize” 不是说 “要转换成 primitive type” 的意思。译者翻译成了 “朴素化”,因为他觉得 “简化” 这个词又太模糊了,我表示同意。
那啥叫 “朴素化”?比方说 parameter 是一个复杂的 class,有 10 个 attributes,但其实我 Target Method 只用了其中 3 个,那我可以就用这 3 个 attributes 做一个新的中间层 class,然后让 Target Method 改成依赖这个中间层 class。代码举例:
// 原代码:
class TargetClass {
public void targetMethod(Parameter p) {
// uses p.a, p.b, p.c
}
}
class Parameter {
// pass
}
// 新添代码:
class PrimitivizedParameter {
public PrimitivizedParameter(Parameter p) {
this.a = p.a;
this.b = p.b;
this.c = p.c;
}
}
// 改写成:
class TargetClass {
public void targetMethod(Parameter p) {
PrimitivizedParameter pp = p.primitivize();
targetMethod(pp);
}
public void targetMethod(PrimitivizedParameter pp) {
// uses pp.a, pp.b, pp.c
}
}
class Parameter {
public PrimitivizedParameter primitivize() {
return new PrimitivizedParameter(this);
}
}
这个改动就是把 Parameter
这个 dependency 弱化成了 PrimitivizedParameter
。
普通技 $\texttt{Do You Really Need this Parameter?}$ 是本技能的极端情况。
$\dagger$ “我成替身了” $\rhd$ 在 object seam 里替换 dependency
$\Diamond \texttt{[21]Subclass and Override Method}$
可以宽泛地认为 fake/mock 用的就是这个技能。
对 dependency 做 subclass 然后 override 复杂的部分,用这个 subclass 作为 dependency 的替身。
如果有对 dependency 做 $\texttt{[10]Extract Interface}$ 的话,我觉得做一个 dependency 的 sibling class 也应该归并到这个技能内。
$\Diamond \texttt{[12]Introduce Static Setter}$
注意这个技能和 $\texttt{[22]Supersede Instance Variable}$ 的不同:
- $\texttt{[22]Supersede Instance Variable}$ 是在 Target Class 中加 setter
- $\texttt{[12]Introduce Static Setter}$ 是在 singleton dependency class 中加 static setter
这个技能起源于一个想法:在 singleton dependency class 中用 static setter 直接替换掉 singleton instance。比如:
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
// 原始想法:
// 新添加的代码:
public static void setTestingInstance(Singleton newInstance) {
instance = newInstance;
}
}
但这个想法有个问题:你这个 newInstance
应该是什么类型?
- 首先这里是个 singleton,是不应该有多个 instance 的,所以
newInstance
不可能是Singleton
类型 - 那
newInstance
就只能是Singleton
的子类,但这又引入了新的问题:- 如果
Singleton
不允许继承呢? - 如果
Singleton
允许继承,那我为什么不直接用 $\texttt{[21]Subclass and Override Method}$ 呢?
- 如果
为了继续这个想法,我们可以再结合一个 $\texttt{[10]Extract Interface}$,写成这样:
// 原代码:
public class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
// 改写成:
public class Singleton implements ISingleton {
private static Singleton instance = null;
private Singleton() {}
public static ISingleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
// 新添加的代码:
public static void setTestingInstance(ISingleton newInstance) {
instance = newInstance;
}
}
然后我就可以这样来做 fake/mock:
class FakeSingleton implements ISingleton {
// pass
}
Singleton.setTestingInstance(new FakeSingleton());
Part II - Changing Software
Chapter 6 - 旧代码没有 test,我也没时间写,但现在我需要修改它
假设只针对 OO 的情况。你有一个 TargetClass
,需要修改其中的 targetMethod()
,添加一段新的代码逻辑:
TargetClass
没有 test case,我们当前对原来的 method 也不打算测试- 但是我们对新添加的代码逻辑想做测试
$\Diamond \texttt{Sprout Method}$ (新生方法)
将新逻辑添加到一个全新的 TargetClass.newMethod()
里,只在 TargetClass.targetMethod()
里添加一个调用,最大程度减小 intrusion (侵入量)。后续 TargetClass.targetMethod()
和 TargetClass.newMethod()
可以独立测试:
class TargetClass {
public void targetMethod() {
// original logic
}
}
// 改写成:
class TargetClass {
public void targetMethod() {
this.newMethod();
// original logic
}
}
$\Diamond \texttt{Sprout Class}$ (新生类)
如果我们用了 $\texttt{Sprout Method}$,我们就需要测试 TargetClass.newMethod()
,但是问题是:如果 TargetClass
很难创建 instance 该怎么办?(我们也不打算用 mock 啥的)
我们就把代码 wrap 到 NewClass.targetMethod()
里,然后只测试 NewClass
就好了,就这么简单 (TargetClass
保持不动)。
class TargetClass implements ITarget {
public void targetMethod() {
// original logic
}
}
// 改写成:
class TargetClass implements ITarget {
public void targetMethod() {
// original logic
}
}
class NewClass implements ITarget {
public void targetMethod() {
// new logic
}
}
改完后,我们还需要修改原来 TargetClass
的 caller,可能的情况有:
- 用
NewClass
完全代替TargetClass
(即将NewClass
看做TargetClass
的 decorator; 参见 Middleman Patterns: Adapter / Proxy / Decorator) - 持有一个
ITarget
的 collection
然后作者又啰哩吧嗦说了一大段,无非是说这种做法可以引发你对 design 的思考:新的代码逻辑是不是应该独立于 TargetClass
?有两个类是不是更好?要不要提取公共 interface?
如果逻辑上没有必要设计成两个类,那 Sprout Class 这个方法就会带来它最大的缺点:对 Class Hierarchy 的严重破坏。
另外还有一个问题我很好奇:你 TargetClass
的 caller 改动了,如何测试 caller?书上可没说……
$\Diamond \texttt{Wrap Method}$ (外覆方法)
将 TargetClass.targetMethod()
的逻辑转移到 TargetClass.foo()
,然后添加新的逻辑到 sprout method TargetClass.bar()
,重新构建 TargetClass.targetMethod()
:
class TargetClass {
private void foo() {
// original logic
}
private void bar() {
// new logic
}
public void targetMethod() {
this.foo()
this.bar()
}
}
- I usually use $\texttt{Sprout Method}$ when the code I have in the existing method communicates a clear algorithm to the reader.
- I move toward $\texttt{Wrap Method}$ when I think that the new feature I’m adding is as important as the work that was there before.
- In that case, after I’ve wrapped, I often end up with a new high-level algorithm (instead of a long text of instructions).
$\Diamond \texttt{Wrap Class}$ (外覆类)
和 $\Diamond \texttt{Sprout Class}$ 很像,但是 NewClass
是 extends TargetClass
或者组合一个 TargetClass
instance,然后 overwrite targetMethod()
。本质还是一个 decorator pattern。
Chapter 7 - It Takes Forever to Make a Change (略)
我完全不知道为什么要有这么一章?为什么是这个标题?为什么要放到这个位置?我就简单把本章看做是后续章节的一个指路环节好了。略。
Chapter 8 - How Do I Add a Feature? (可以略)
TDD
8.1 是一个 TDD 的示例 walk-through
Programming by Difference
8.2 给了一个 Programming by Difference 的示例 walk-through,但是又没有说啥叫 Programming by Difference…… (MD,我发现这种写文章的鬼手法,OO 界也是重灾区,就是喜欢搞描述性的概念,w/o definition at all)
所谓 Programming by Difference 就是:
In defining derived classes, we only need to specify what’s different about them from their base classes.
在本章的 context 下,它意味着将新 feature 完全反映在子类与父类的差异上。(这不是理所当然的吗?还需要新建个概念?)
然后书上提了一句注意考虑 LSP,这一点倒是很 reasonable。
Chapter 9 - 实例化 Target Class 会遇到的困难
Case 1: constructor 需要的 parameter 很难实例化
可以使用的技能:
- null枪打鸟
- 试试 null 值?
- 试试 null object?(参考 PPP chapter 17. Null Object 模式)
- $\dagger$ “我成替身了” 技能系都可以用
Case 2: constructor 中直接 new 了一个很难实例化的 object (书上称之为 Hidden Dependency)
其实最常见的 Hidden Dependency 就是就是在 constructor 中直接 new 其他的 object,而不是把这个 object 创建好再传入 constructor。
所以首先应该把这个 dependency object 放到 Target Class 的 object seam 里,再考虑替换。
Case 3: constructor 中直接 new 了一个 “onion object”
所谓 “onion object” 是指多层 “object inside object” 的情况,就像一个洋葱,可以一直剥一直剥。
基本可以看做是 Case 2 但不方便使用 $\texttt{[14]Parameterize Constructor}$ 的特殊情况,举例:
class Foo {
public Foo() {
this.bar = new Bar();
this.baz = new Baz(this.bar);
this.qux = new Qux(this.baz);
this.quux = new Quux(this.qux); // 笨重
}
}
这里 quux
就是个 onion-object。考虑 quux
很不好实例化的情况,你会发现:
- 只 parameterize
bar
和baz
并不能解决问题 - 全员 parameterize
bar
、baz
、qux
和quux
的话:- 参数列表过长
- construction 的难度增大
此时只能:
- 要么 $\texttt{[07]Extract and Override Factory Method}$
- 要么 $\texttt{[22]Supersede Instance Variable}$
Case 4: constructors、member methods、static methods 都依赖于一个笨重的 Singleton Instance
注意这里的 scenario:
- 书上有用 “global variable” 这个词,但这里肯定不是简单的 int、string 全局变量,它们很好处理;书上这里指的是一个笨重的 global instance,最常见的就是 singleton
- 然后这个 “笨重” 体现在:a) 要么它很难实例化;b) 要么它的 behavior 很耗时 (比如访问 DB 或是网络通信)
- 书上还用了 “extensive” 这个词,但这里不是说我会依赖很多个 global instances,而是说 constructors、member methods、static methods 都会依赖某一个 global instance
“extensive” 带来的问题:
- 如果只是 constructors 或 member methods 依赖这个 global instance,我们可以 $\texttt{[14]Parameterize Constructor}$
- 如果只有 static methods 依赖这个 global instance,我们可以 $\texttt{[15]Parameterize Method}$
- 但现在是 constructors、member methods、static methods 全都依赖这个 global instance,你总不能把它们全都 parameterize 了吧?(影响可读性、可维护性、封装 etc)
举个例子就知道 parameterize 技术对这个 scenario 的不足:
// 原代码:
class Foo {
public Foo() {
// ...
Singleton.getInstance().initialize(); // 笨重
}
public void bar(int i) {
// ...
Singleton.getInstance().doSomething(i); // 笨重
}
public static void baz(int j) {
// ...
Singleton.getInstance().doSomethingElse(j); // 笨重
}
}
// 不适合的 parameterize:
class Foo {
public Foo(ISingleton singleton) { // parameterize constructor
// ...
this.singleton = singleton
}
public void bar(int i) {
// ...
this.singleton.doSomething(i);
}
public static void baz(int j, ISingleton singleton) { // parameterize method
// ...
singleton.doSomethingElse(j);
}
}
解决方法:
- 如果 Singleton 允许继承,用 $\texttt{[21]Subclass and Override Method}$
- 否则就用 $\texttt{[12]Introduce Static Setter}$
Case 5: Horrible #include
Dependency (略)
Case 6: constructor 有一个 “onion-parameter”
注意和 Case 3 区分。现在的 scenario 是:
class Foo {
public Foo(Bar bar) {
// pass
}
}
class Bar {
public Bar(Baz baz) {
// pass
}
}
class Baz {
public Baz(Qux qux) {
// pass
}
}
class Qux {
public Qux(Quux quux) {
// pass
}
}
// pass
我现在要测试 Foo
,然后发现 quux
、Qux
、Baz
、Bar
全都要 new 一个,其中任何一个不方便实例化的话,Foo
就不方便实例化。
解决方法:
- null枪打鸟
- $\texttt{[10]Extract Interface}$
Case 7: constructor 的 parameter 不适合用 $\texttt{[10]Extract Interface}$
主要出现在 parameter 位于 class hierarchy 底端时。比如 constructor 需要一个 Bar4
,但是它的 hierarchy 是这样的:
Bar <- Bar1 <- Bar2 <- Bar3 <- Bar4
若是为 Bar4
做一个 interface,最终的效果可能是:
IBar
^
Bar <- Bar1 <- Bar2 <- Bar3 <- Bar4
或者:
IBar <- IBar1 <- IBar2 <- IBar3 <- IBar4
^ ^ ^ ^ ^
Bar <- Bar1 <- Bar2 <- Bar3 <- Bar4
波及的范围有点大……所以此时还是直接用 $\texttt{[21]Subclass and Override Method}$ 为上策。
Chapter 10 - 测试 Target Method 会遇到的困难
Quest 0: 不实例化 Target Class 来测试 Target Method
- $\texttt{[05]Expose Static Method}$
- $\texttt{[02]Break Out Method Object}$
Quest 1: 如何测试 private method?
首先考虑改成 public 是否合适 (是个人就能想到;但一般我是不会这么搞的)。不行的话只能改成 protected 或 package 权限,然后考虑用 $\texttt{[21]Subclass and Override Method}$,但其实也可以不 override,原地 delegate 一下也行。
我个人的意见是:private method 应该是开发人员自己 unit test 要保证的内容。
Quest 2: Target Method 需要的 parameter 很难初始化
其实 Chapter 9 - Case 1 的技术都能用。
这里讨论一种额外的 scenario,即 parameter 的类型是一个 framework 的 class:
- 可能有容器负责了它的实例化,所以它自己干脆没有 constructor
- 它已经有 interface 了,但你 fake/mock 这个 interface 会很麻烦 (比如说它是一个非常多方法的 interface)
此时常用的技术是有下面两个。
$\Diamond \texttt{Do You Really Need this Parameter?}$
如果你只是需要这个 parameter 的两个 field,我们完全可以 overload 这个 Target Method,比如:
// 原代码:
class Foo {
public void foo(Bar bar) {
// uses bar.a and bar.b
}
}
// 改写成:
class Foo {
public void foo(Bar bar) {
this.foo(bar.getA(), bar.getB());
}
public void foo(int a, int b) {
// uses a and b
}
}
$\Diamond \texttt{去找一个针对这个 framework 的 mock object library}$ (略)
Quest 3: 如何测试 Side Effect?
$\Diamond \texttt{是谁引入的 Side Effect?}$
这个问题其实很重要。因为:
- 如果是 Target Method 自己 直接 造成的 Side Effect,那作为开发者你自己应该清楚如何测试
- 如果我 Target Class 是一个 JDBC,Target Method 是 insert 了一条 record,我作为 JDBC 的开发者我肯定知道这个要怎么测
- 如果是 Target Method 新建了一个 local variable,然后是这个 local variable 造成的 Side Effect,那么我应该考虑 把这个 local variable 提升为 Target Class 的 member
- 比如我 Target Class 是一个 DAO,Target Method 自己创建了一个 JDBC connection,然后 insert 了一条 record
- 那么 DAO 的 Unit Test 是无法 access 这个 JDBC connection 的,也就无法测试它
- 如果你 DAO 是持有这个 JDBC connection,别的不说,DAO 的 Unit Test 至少能 fake/mock 这个 JDBC connection
- 题外话:我觉得这应该叫 Testability-Oriented Design,隶属 defensive programming 的范畴
$\Diamond \texttt{Command/Query Separation (CQS)}$
Command/Query Separation is a design principle first described by Bertrand Meyer.
- A $\texttt{command}$ is a method that can modify the state of the object but that doesn’t return a value.
- A $\texttt{query}$ is a method that returns a value but that does not modify the object.
- $\texttt{query}$ 应该是 idempotent 的
CQS: A method should be a $\texttt{command}$ or a $\texttt{query}$, but not both.
这个 principle 没法做法绝对哈,比如我有一个 lookup 的方法:
class DAO {
privaite DBConnection conn;
private RecordCache cache;
public Record lookup(Table table, Field field, int i) {
// this is a command
this.updateLookupCounter(table, field, i);
// this is a query
Record r = this.cache.get(table, field, i);
if (r != null) {
return r;
}
// this is a query
Record r = this.conn.select(table, field, i);
// this is a command
this.cache.put(table, field, i, r);
return r;
}
}
你无论怎么 separation,这个 lookup method 始终是一个 $\texttt{command}$ 和 $\texttt{query}$ 的混合体。我觉得 CQS 的重点在于:我们在面对一个复杂的、既有 $\texttt{command}$ 又有 $\texttt{query}$ 的方法时,要依据 CQS 尽量将 $\texttt{command}$ 和 $\texttt{query}$ 区分 (指划入不同的小方法)。
- 这么做也能保持你 extract 出来的 $\texttt{command}$ 和 $\texttt{query}$ methods 处于相同的抽象层次
细分之后,有助于我们确定 是谁引入的 Side Effect?,然后我们就能用上一小节的方法来测试。
Chapter 11 - 修改时应该测试哪些 methods? (可以略)
还能有啥?这值得专门写一章?
你修改了一个 method,那么这个 method 的所有 caller 你都要检查;如果你这个 method 是父类的一个 method,那么所有的子类你都要检查。复杂的情况就画调用图呗,画 class hierarchy 呗,还能有啥招数?
Chapter 12 - 续•修改时应该测试哪些 methods?
这一章比上一章有用。
Interception Point / Pinch Point
假设现在我做了一个修改,不管我是画了调用图还是 class hierarchy,这个图都可以看做 “change’s effect” 传播的一个路径图,书上叫 “effect sketch”。
- change point 就是你做出修改的地方,相当于是 effect sketch 的 source node
- interception point 就是指 effect sketch 上的一个 node, where 你决定要测试这个 effect 的正确性
- 有的时候并不是你修改了哪个 method 就能测试哪个 method 的,比方说你修改了一个 private method,此时离 change point 最近的一个 interception point 应该是 class 内调用了这个 private method 的一个 public method
有时候不见得离 change point 最近的 interception point 就是最优的选择,比如说我现在要修改 5 个离散的 classes 的 5 个 public methods,然后我又没有时间去处理这 5 个 classes 各自的 dependency,但是我发现这 5 个 public method 有一个共同的 caller,那我可以选择把 interception point (从 change point 前线) 战术撤退到这个 caller。
- pinch point 相当于是 effect sketch 中的 critical point,在 pinch point 拦截可以一次测试多个改动
- 但是同时要注意,太后方的 pinch point 的测试可能会很笨重
Chapter 13 - How to Write Tests (略)
Chapter 14 - Dependencies on Libraries (略)
这一章,是一章哦,只有 1.5 pages,我都不知为作者为什么要这么写。然后这 1.5 pages 我还没看懂他要表达啥意思……
Chapter 15 - 如何重构充斥着 API Calls 的代码 (可以略)
其实也没啥,说的都是业界 best practice。
啥 “Wrap the API”,不就是写 DAO 嘛,通过 DAO 暴露限定的 high-level API,隐藏 library 的 low-level API。
啥 “Responsibility-Based Extraction”,不就是 “模块化”、”封装” 嘛。
我都懒得吐槽了。
Chapter 16 - 如何读代码 (没啥内容)
- 画图
- 代码段 (比如一个很长的方法) tagging
- 草稿式重构
- 删掉无用代码
Chapter 17 - 如何读懂架构 (没啥内容)
Chapter 18 - 测试类咋命名 / 测试代码放哪儿 (略)
这一章为什么不和 Chapter 13 放一起?
Chapter 19 - 如何修改 non-OO 的代码 (略)
一个利用 Chapter 4 的 Preprocessing Seam 和 Link Seam 的 C/C++ 例子的 walk-through
Chapter 20 - 如何重构一个 big class (可以略)
Chapter 21 - 如何重构大量重复的代码 (可以略)
Chapter 22 - 如何重构一个 monster method
extract 小方法时注意:
- 尽量将隔离出的局部逻辑与 monster method 整体的 dependency 分离
- 引入 seam 以方便测试
Coupling Count
代码段的逻辑复杂程度的一个度量:coupling count (耦合数),即传入值的数量加上传出值的数量。比如:
int maximum = max(a, b); // coupling count == 3
Record r = db.select(table, field, i); // coupling count == 4
db.insert(r); // cooupling count == 1
- coupling count 越小的代码段,extraction 越安全
- 如果一段代码的 coupling count 是 0,那么它就是一个不接收参数的 $\texttt{command}$
Comments