Refactor

Improving the design of existing code

Posted on 2018-10-09

代码的坏味道

  • Duplicated code
    如果在一个以上的地点看到相同的程序结构 -> Extract Method

    • 一个类中的两个函数有相同的表达式, 提取函数,然后原先的两个func都call这个新的
    • 不同的子类有相同的程序结构,提取method, 放入super class中
    • 不相关类,将重复代码提炼到一个独立类中,然后在另一个类中使用这个新类。(比如util pkg)
  • Long Method
    小型函数有着更好的解释能力、共享能力、选择能力.
    我们遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。
    如何确定该提炼哪一段代码昵? 一个很好的技巧是:寻找注释。它们通常是指出「代码用途和实现手法间的语义距离」的信号。如果代码前方有一行注释,就是在提醒 你:可以将这段代码替换成一个函数,而且可以在注释的基础上给这个函数命名。就算只有一行代码,如果它需要以注释来说明,那也值得将它提炼到独立函数去。
    条件式和循环常常也是提炼的信号。你可以使用Decompose Conditional 处理条件式。至于循环,你应该将循环和其内的代码提炼到一个独立函数中。

  • Large Class
    将相同类型的函数放入同一个类。并且Extract Subclass往往比较简单。
    有时候class并非在所有时刻都使用所有instance变量。果真如此,你或许可以多次使用Extract Class或Extract Subclass。
    先确定客户端如何使用它们,然后运用Extract Interface为每一种使用方式提炼出一个接口。这或许可以帮助你看清楚如何分解这个class。

  • Long Parameter List
    有了对象,你就不必把函数需要的所有东西都以参数传递给它了,你只需传给它足够的东西、让函数能从中获得自己需要的所有东西就行了。

  • Divergent Change
    如果某个class经常因为不同的原因在不同的方向上发生变化,Divergent Change就出现了。当你看着一个class说:『呃,如果新加入一个数据库,我必须修改这三个函数;如果新出现一种金融工具,我必须修改这四个函数』,那么此时也许将这个对象分成两个会更好,这么一来每个对象就可以只因一种变化而需要修改。当然,往往只有在加入新数据库或新金融工具后,你才能发现这一点。针对某一外界 变化的所有相应修改,都只应该发生在单一class中,而这个新class内的所有内容都应该反应该外界变化。为此,你应该找出因着某特定原因而造成的所有变化,然后运用Extract Class 将它们提炼到另一个class中。

测试体系

  • 自我测试代码的价值
    编写代码其实只占非常小的一部分。有些时间用来决定下一步干什么,另一些时间花在设计上面,最多的时间则是用来调试(debug)。
    实际上,撰写测试代码的最有用时机是在开始编程之前。当你需要添加特性的时候,先写相应测试代码。听起来离经叛道,其实不然。编写测试代码其实就是在问自己:添加这个功能需要做些什么。编写测试代码还能使你把注意力集中于接口而非实现上头(这永远是件好事)。预先写好的测试代码也为你的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
    Java之中的测试惯用手法是“ testing main”,意思是每个class都应该有一个用于测试的main()。这是一个合理的习惯(尽管并不那么值得称许),但可能不好操控。这种作法的问题是很难轻松运行多个测试。另一种作法是:建立一个独立用 于测试,并在一个框架(framework)中运行它,使测试工作更轻松。

  • JUnit测试框架
    test-suite(测试套件)、test-case(测试用例)和test-fixture(测试装备),期能直接对应图4.1的JUnit结构组件,并有助于阅读JUnit文档。

    这个框架运用Composite 模式[Gang of Four],允许你将测试代码聚集到suites(套件)中,如图4.1。这些套件可以包含未加工的test-cases(测试用例),或其他test-suits(测试套件)。如此一来我就可以轻松地将一系列庞大的test-suits结合在一起,并自动运行它们。

定义test case:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class FileReaderTester extends TestCase{
public FileReaderTester (String name) {
super(name);
}
protected void setUp() {
try {
_input = new FileReader("data.txt");
} catch (FileNotFoundException e) {
throw new RuntimeException ("unable to open test file");
}
}
protected void tearDown() {
try {
_input.close();
} catch (IOException e) {
throw new RuntimeException ("error on closing test file");
}
}

public void testRead() throws IOException {
char ch = '&';
for (int i=0; i < 4; i++)
ch = (char) _input.read();
assert('d' == ch);
}

public void testReadAtEnd() throws IOException {
int ch = -1234;
for (int i = 0; i < 141; i++)
ch = _input.read();
assertEquals(-1, ch);
}
}

定义test suite:

1
2
3
4
5
6
class FileReaderTester...
public static Test suite() {
TestSuite suite= new TestSuite();
suite.addTest(new FileReaderTester("testRead"));
return suite;
}

这个测试套件只含一个test-case(测试用例)对象,那个是FileReaderTester实体。创建test-case对象时,我传给其构造函数一个字符串,这正是待测函数的名称。这会创建出一个对象,用以测试被指定的函数。这个测试系通过Java反射机制(reflection)和对象系结在一起。你可以自由下载JUnit源码,看看它究竟如何做到。至于我,我只把它当作一种魔法。
要将整个测试运行起来,还需要一个独立的TestRunner class。TestRunner 有两个版本,其中一个有漂亮的图形用户界面(GUI),另一个采用文字界面。我可以在main函数中调用「文字界面」版:

1
2
3
4
class FileReaderTester...
public static void main (String[] args) {
junit.textui.TestRunner.run (suite());
}

这段代码创建出一个TestRunner,并要它去测试FileReaderTester class。

  • 添加更多的测试
1
2
3
4
5
6
public static Test suite() {
TestSuite suite= new TestSuite();
suite.addTest(new FileReaderTester("testRead"));
suite.addTest(new FileReaderTester("testReadAtEnd"));
return suite;
}

当test suit (测试套件)运行起来,它会告诉我它的每个FileReaderTester——也就是这两个test cases (测试用例)——的运行情况。每个用例都会调用tearDown(),然后执行测试代码,最终调用tearDown()。每次测试都调用setUp()和tearDown()是很重要的,因为这样才能保证测试之间彼此隔离。也就是说我们可以按任意顺序运行它们,不会对它们的结果造成任何影响。

老要记住将test cases添加到suite(),实在是件痛苦的事。幸运的是Erich Gamma和Kent Beck和我一样懒,所以他们提供了一条途径来避免这种痛苦。TestSuite class有个特殊构造函数,接受一个class为参数,创建出来的test suite会将该class内所有以“test”起头的函数都当作test cases包含进来。如果遵循这一命名习惯, 就可以把我的main()改为这样

1
2
3
public static void main (String[] args) {
junit.textui.TestRunner.run (new TestSuite(FileReaderTester.class));
}

这样,我写的每一个测试函数便都被自动添加到test suit 中。
测试的一项重要技巧就是 「寻找边界条件」 。对read()而言,边界条件应该是第一个字符、最后一个字符、倒数第二个字符:

注意,我在这里扮演「程序公敌」的角色。我积极思考如何破坏代码。我发现这种思维能够提高生产力,并且很有趣。它纵容了我心智中比较促狭的那一部分。
测试时,别忘了检查预期的错误是否如期出现。如果你尝试在stream被关闭后再读 取它,就应该得到一个IOException异常,这也应该被测试出来

随着tester classes愈来愈多,你可以产生另一个class,专门用来包含「由其他tester classes所形成」的测试套件(test suite)。这很容易做到,因为一个测试套件本来就可以包含其他测试套件。这样,你就可以拥有一个「主控的」(master)test class:

1
2
3
4
5
6
7
8
9
10
11
12
class MasterTester extends TestCase {
public static void main (String[] args) {
junit.textui.TestRunner.run (suite());
}
public static Test suite() {
TestSuite result = new TestSuite();
result.addTest(new TestSuite(FileReaderTester.class));
result.addTest(new TestSuite(FileWriterTester.class));
// and so on...
return result;
}
}

重构catalog

  1. 重构格式
  • 名称(name)。建造一个重构词汇表,名称是很重要的。这个名称也就是我将在本书其他地方使用的名称。
  • 名称之后是一个简短概要。(summary),简单介绍此一重构手法的适用情景,以及它所做的事情。这部分可以帮助你更快找到你所需要的重构手法。
  • 动机(motivation),为你介绍「为什么需要这个重构』和「什么情况下不该使用这个重构」。
  • 作法(mechanics),简明扼要地一步一步介绍如何进行此一重构。
  • 范例(examples),以一个十分简单的例子说明此重构手法如何运作。

「作法」(mechanics)出自我自己的笔记。这些笔记是为了让我在一段时间不做某项重构之后还能记得怎么做。它们也颇为简洁,通常不会解释「为什么要这么做那么做」。我会在「范例」(examples)给出更多解释。这么一来「作法」就成了简短的笔记。如果你知道该使用哪个重构,但记不清具体步骤,可以参考「作法」部分(至少我是这么使用它们的);如果你初次使用某个重构,可能「作法」对你还不够,你还需要阅读「范例」。
撰写「作法」的时候,我尽量将重构的每个步骤都写得简短。我强调安全的重构方式,所以应该采用非常小的步骤,并且在每个步骤之后进行测试。真正工作时我通常会采用比这里介绍的「婴儿学步」稍大些的步骤,然而一旦遇上臭虫,我就会撤销上一步,换用比较小的步骤。这些步骤还包含一些特定状况的参考,所以它们也有检验表(checklist)的作用;我自己经常忘掉这些该做的事情。
「范例」(examples)像是简单而有趣的教科书。我使用这些范例是为了帮助解释重构的基本要素,最大限度地避免其他枝节,所以我希望你能原谅其中的简化工作(它们当然不是优秀商用对象设计的适当例子)。不过我敢肯定你一定能在你手上那些更复杂的情况中使用它们。某些十分简单的重构干脆没有范例,因为我觉得为它们加上一个范例不会有多大意义。
更明确地说,加上「范例」仅仅是为了阐释当时讨论的重构手法。通常那些代码最终仍有其他问题,但修正那些问题需要用到其他重构手法。某些情况下数个重构经常被一并运用,这时候我会把某个范例拿到另一个重构中继续使用。大部分时候,一个范例只为一项重构而设计,这么做是为了让每一项重构手法自给自足(self-contained),因为这份重构名录的首要目的还是作为参考工具。

重新组织你的函数

  1. Extract Method
  • 动机(Motivation)
    Extract Method是我最常用的重构手法之一。当我看见一个过长的函数或者一段需要注释才能让人理解用途的代码,我就会将这段代码放进一个独立函数中。
    有数个原因造成我喜欢简短而有良好命名的函数。首先,如果每个函数的粒度都很小(finely grained),那么函数之间彼此复用的机会就更大;其次,这会使高层函数码读起来就像一系列注释;再者,如果函数都是细粒度,那么函数的覆写(overridden)也会更容易些。
    的确,如果你习惯看大型函数,恐怕需要一段时间才能适应这种新风格。而且只有当你能给小型函数很好地命名时,它们才能真正起作用,所以你需要在函数名称下点功夫。人们有时会问我,一个函数多长才算合适?在我看来,长度不是问题,关键在于函数名称和函数本体之间的语义距离(semantic distance )。如果提炼动作 (extracting )可以强化代码的清晰度,那就去做,就算函数名称比提炼出来的代码 还长也无所谓。

  • 作法(Mechanics)
    创造一个新函数,根据这个函数的意图来给它命名(以它「做什么」来命名, 而不是以它「怎样做」命名)。
    即使你想要提炼(extract )的代码非常简单,例如只是一条消息或一个函数调用,只要新函数的名称能够以更好方式昭示代码意图,你也应该提炼它。但如果你想不出一个更有意义的名称,就别动。
    将提炼出的代码从源函数(source)拷贝到新建的目标函数(target)中。
    仔细检查extract method,看看其中是否引用了「作用域(scope)限于源函数」的变量(包括局部变量和源函数参数)。
    检查是否有「仅用于被extract method」的临时变量(temporary variables )。如果有,在目标函数中将它们声明为临时变量。
    检查被extract method,看看是否有任何局部变量(local-scope variables )的值被它改变。如果一个临时变量值被修改了,看看是否可以将被提炼码处理为一个查询(query),并将结果赋值给相关变量。如果很难这样做,或如果被修改的 变量不止一个,你就不能仅仅将这段代码原封不动地离炼出来。你可能需要先使用 Split Temporary Variable,然后再尝试提炼。也可以使用Replace Temp with Query 将临时变量消灭掉(请看「范例」中的讨论)。
    将被extract method中需要读取的局部变量,当作参数传给目标函数。
    处理完所有局部变量之后,进行编译。
    在源函数中,将extract method替换为「对目标函数的调用」。
    如果你将任何临时变量移到目标函数中,请检查它们原本的声明式是否在被extract method的外围。如果是,现在你可以删除这些声明式了。
    编译,测试。