后端技术EA

《修改代码的艺术》读书笔记

2017-11-30  本文已影响1060人  贾尼
遗留代码

没有编写测试的代码是糟糕的代码。不管我们有多细心地去编写它们,不管它们有多漂亮,面向对象或者封装良好,只要没有编写测试,我们实际上就不知道修改后的代码是变得更好了还是更糟了。反之,有了测试,我们就能够迅速,可验证地修改代码的行为。

第一部分 修改机理

第 1 章 修改软件

为什么要修改软件:

修改是需要考虑:

第 2 章 带着反馈工作

改动系统的两种主要方式:

好的单元测试:

以下测试不叫单元测试:

2.3 测试覆盖

依赖性是软件开发中最为关键的问题之一。在处理遗留代码的过程中很大一部分工作都是围绕着“解除依赖性以便改动变得更容易”这个目标来进行的。

遗留代码的困境

我们在修改代码时,应当有测试保护,而为了将这些测试安置妥当,往往又得先去修改代码。

image.png image.png

上述的用于解开InvoiceUpdateResponder 对 InvoiceUpdateServlet 和对 DBConnection 的依赖的两种重构手法分别称为朴素化参数(Primitivize Parameter)和接口提取(Extract Interface)。

2.4 遗留代码修改算法

以下算法可以用于对遗留代码基进行修改:

2.4.3 解依赖

依赖性是进行测试的障碍,表现在两个方面:

第 3 章 感知和分离

3.1 伪装成合作者
伪对象(fake object)
image.png

找到Sale中对显示器刷新的那部分代码,抽出来:

image.png

提取接口:

image.png
public interface Display {
    void showLine(String line);
}
public class Sale {
    private Display display;

    public Sale(Display display) {
        this.display = display;
    }

    public void scan(String barcode) {
        ...
        String itemLine = item.name() + " " + item.price.asDisplayText();
        display.showLine(itemLine);
    }
}
import junit.framework.*;

public class SaleTest extends TestCae {
    public void testDisplayAnItem() {
        FakeDisplay display = new FakeDisplay();
        Sale sale = new Sale(display);
        sale.scan("1");
        assertEquals("Milk $3.99", display.getLastLine);
    }
}
public class FakeDisplay implements Display {
    private String lastLine = "";
    public void showLine(String line) {
        lastLine = line;
    }
    public String getLastLine() {
        return lastLine;
    }
}

上面的例子中,因为showLine方法是直接调用显示器上面,在我们Unit Test 里面,没有办法知道showLine里面做的事情,所以通过先把对显示器刷新的代码提取出来,然后再提取一个接口,通过一个Fake实现类去假设showLine做的事情,最后再用我们的假设去测试Sale是否会将正确的文本送到显示器上。其实就是获取到Sale调用showLine的参数,来验证其正确性。这个参数在Sale里面可能是一个临时变量,我们没办法在Unit Test 直接拿到值。这样没办法测试显示器是否有问题,但是可以测试我们系统代码是否有问题。

3.1.4 仿对象
import junit.framework.*

public class SaleTest extends TestCase {
    public void testDisplayAnItem() {
        MockDisplay display = new MockDisplay();
        display.setExpectation("showLine", "Milk $3.99");
        Sale sale = new Sale(display);
        sale.scan("1");
        display.verify();
    }
}

伪对象是伪装成目标对象,仿对象目的在于尽量模仿真实的目标对象的行为,被测试者可以(从行为上)把它看作一个真正的目标对象来使用。

第 4 章 接缝模型

最终得到易于测试的程序的两条路:

接缝(seam)

指程序中的一些特殊的点,在这些点上你无需作任何修改就可以达到改动程序行为的目的。

public class Sale {

    public void scan(String barcode) {
        ...
        String itemLine = item.name() + " " + item.price.asDisplayText();
        showLine(itemLine);
        ...
    }
}

假如我测试scan的时候不想测试showLine(itemLine)这行代码,我可以用建一个subclass:

public class TestingSale extends Sale {
   showLine(String itemLine) {
   
   }
}

这样就有效地将showLine方法的行为屏蔽掉了。

上面讨论的这类接缝称之为对象接缝(object seam)

4.3 接缝类型
4.3.1 预处理期接缝
激活点

每个接缝都有一个激活点,在这些点上你可以决定使用哪种行为。

4.3.2 连接期接缝

使用连接期接缝时,请确保测试和产品环境之间的差别是显而易见的。

4.3.3 对象接缝

上面讲接缝的时候有一个具体的例子。

第 5 章 工具

5.1 自动化重构工具
重构

名词。对软件内部结构的一种调整,目的是在不改变软件的外在行为的前提下,提高其可理解性,降低其修改成本。

我在重构代码的时候没有用过自动化重构工具,自动化重构之后难以保证程序的行为没有发生改变。

5.2 仿对象

在面向对象语言的代码中,可以用仿对象(mock object)对付遗留代码中的依赖问题。

5.3 单元测试用具

xUnit 关键特性:

第二部分 修改代码的技术

第 6 章 时间紧迫,但必须修改

虽说不管是解依赖还是为所要进行的修改编写测试都要花上一些时间,但大部分情况下最终还是节省了时间,同时也避免了一次又一次的沮丧感。

作者在和团队合作过程中,有这样一个实验。在一个迭代期,试着坚持不要在没有测试覆盖的情况下去改动代码。如果某个人觉得他们无法编写某个测试,就得召集一个临时会议,询问整个团队是否可能编写该测试。这样一个迭代期在开始的时候是糟糕的。人们觉得他们做了无用功。但是慢慢地,他们就开始发现当重访代码时看到的时更好的代码。并且代码的修改也变得越来越容易,这时他们就会打心底里觉得这么做时值得的。

新生方法
public class TransactionGate {
    public void postEntries(List entries) {
        for(Iterator it = entries.iterator(); it.hasNext();) {
            Entry entry = (Entry) it.next();
            entry.postDate();
        }
        transactionBundle.getListManager().add(entries);
     }
}

对于上面的类,需要添加代码检查entries中的对象在日期被发送并添加到transactionBundle中去之前是否已经存在了。

public class TransactionGate {
    public void postEntries(List entries) {
        List entriesToAdd = uniqueEntries(entries);//新生代码
        for(Iterator it = entries.iterator(); it.hasNext();) {
            Entry entry = (Entry) it.next();
            entry.postDate();
        }
        transactionBundle.getListManager().add(entries);
     }
    List uniqueEntries(List entries) {
        List result = new ArrayList();
        for (Iterator it = entries.iterator(); it.hasNext();) {
            Entry entry = (Entry)it.next();
            if (!transactionBundle.getListManager().hasEntry(entry)) {
                result.add(entry);
            }
        }
        return result;
    }
}

uniqueEntries方法很容易通过一个test case driven出来。
新生方法(Sprout Method)实际需要采取的步骤:

缺点

优点

6.2 新生类

使用新生类(Sprout Class)的两种情况:

步骤:

缺点

优点

6.3 外覆方法
public class Employee {
    ...
    public void pay() {
        Money amout = new Money();
        for(...) {
            ...
        }
        payDispatcher.pay(this, date, amount);
    }
}

新需求:每次给一个雇员支付薪水时都得做一个日志记录。

public class Employee {
    ...
    private void dispatchPayment() {
        Money amout = new Money();
        for(...) {
            ...
        }
        payDispatcher.pay(this, date, amount);
    }

    public void pay() {
        logPayment();
        dispatchPayment();
    }

    private void logPayment() {
        ...
    }
}

将pay() 重命名为dispatchPayment() 并改为private。创建一个新的pay()方法,调用dispatchPayment() 和 logPayment()。 客户不必知道这次改动,也不用做任何改动。

这是外覆方法的运用形式之一:
创建一个与原方法同名的新方法,在新方法中调用更名后的原方法。

外覆方法的另一种形式,显示暴露日志记录:

public class Employee {
    ...
    public void makeLoggedPayment() {
        logPayment();
        pay();
    }

    public void pay() {
        ...
    }

    private void logPayment() {
        ...
    }
}

用户可以根据自己需要自由选择。

外覆方法第一种形式步骤:

第二种形式步骤:

缺点

优点

6.5 外覆类 (Wrap Class)

外覆方法的类版本就是外覆类,两者概念几乎一样。

在上例中的Employee中,可以将Employee类变成一个接口,新建一个LoggingEmployee的新类。

Class LoggingEmployee extends Employee {
    public LoggingEmployee (Employee e) {
        empoyee = e;
    }

    public void pay() {
        logPayment();
        employee.pay();
    }
    
    private void loyPayment() {
        ...
    }
    ...
}

上面的技术在设计模式里面被称为装饰模式

简单理解就是把原方法包起来,在子类中加入其他行为,然后再调用父类的方法。

外覆类手法步骤:

使用外覆类的两种情况:

第 7 章 漫长的修改

第 8 章 添加特性

8.1 测试驱动开发

测试驱动开发与遗留代码

测试驱动开发的最有价值的一个方面是它使得我们可以在同一时间只关注于一件事情。要么在编码,要么在重构。
这一好处对付遗留代码显得尤其有价值,它使得我们能够独立地编写新代码。
在编写完新代码之后,可以通过重构来消除新旧代码之间的任何重复。

遗留代码中,测试驱动开发:

8.2 差异式编程(programming by difference)

借助于类的继承,我们可以在不直接改动一个类的前提下引入新的特性。

差异式编程能够快速做出改动,事后还可以再靠测试的帮助来换成更干净的设计。但要小心别违反了Liskov 置换原则(LSP)

Liskov 置换原则

public class Rectangle {
    ...
    public Rectangle(int x, int y, int width, int height) {  ...  }
    public void setWidth(int width) {  ...  }
    public void setHeight(int height) {  ...  }
    public int getArea() {  ...  }
}

假如派生一个名叫Square的子类

public class Square extends Rectangle {
    ...
    public Square(int x, int y, int width) {  ...  }
    ...
}

考虑下面的代码, 它的面积是多少呢?

Rectangle r = new Square();
r.setWidth(3);
r.setHeight(4);

结果应该是12,这样就不是正方形了,假如去重写setWidth 和 setHeight方法,结果变成9或者16都会造成违反期望的结果。

子类对象应当能够用于替换代码中出现的它们父类的对象,不管后者被用在什么地方。如果不能,代码中就有可能出现了一些错误。

一般规则:

如果想要保留继承,可以将父类做成一个抽象类,让子类各自去提供具体的实现。

贾尼.png

第 9 章 无法将类放入测试用具中

四种最为常见的问题:

9.1 令人恼火的参数

无法轻易构建该类的对象时,通过提取接口,然后用一个伪装类来实现接口,从而构造参数,和前面讲的的伪对象一样。

9.2 隐藏依赖

使用提取并重写获取方法(Extract and Override Getter)提取并重写工作方法(Extract and Override Factory Method)以及替换实例变量(Supersede Instance Variable),尽可能使用参数化构造函数。当一个构造函数在它的函数体中创建了一个对象,并且该对象本身并没有任何构造依赖时,运用参数化构造函数就比较轻松了。

9.3 构造块
9.4 恼人的全局依赖

要求实例唯一性的主要原因

采用子类并重写方法,创建一个派生类让测试更容易。

public class PermitRepository {
    ...
    public Permit findAssociatedPermit(PermitNotice notice) {
        // open permit database
        ...
        // select using values in notice
        ...
    }
    // verify we have only one matching permit, if not report error 
    ...
    
    // return the matching permit
    ...
}

为避免跟数据库通信,可以如下子类化PermitRepository:

public class TestingPermitRepository extends PermitRepository {
    private Map permits = new HashMap();
    
    public void addAssociatedPermit(PermitNotice notice, Permit permit) {
        permits.put(motice, permit);
    }

    public Permit findAssociatedPermit(PermitNotice notice) {
        return (Permit) permits.get(notice);
    }
}

这样保留住部分单件性,我们使用的是PermitRepository的一个子类而不是PermitRepository 本身。

9.5 可怕的包含依赖
9.6 “洋葱”参数

通过提取接口的方法解依赖。

对于一门语言来说,只要能用它来创建接口,或者类似接口行为的类,我们就可以系统地使用它们来进行解依赖。

9.7 化名参数

当遇到构造函数参数问题时,通常可以借助于接口提取或实现提取技术来克服。但有时候不实际,因为需要提取的接口太多了。

贾尼.png

可以采取另一个方案,只切断某些地方之间的联系。

public class OriginationPermit extends FacilityPermit {
    ...
    public void validate() {
        // form connection to database
        ...
        // query for validation information
        ...
        // set the validation flag
        ...
        // close database
        ...
    }
}

可以采用子类化并重写方法。创建一个名为FakeOriginationPermit 类,在它的子类中重写validate() 方法。

public void testHasPermits() {
    class AlwaysValidPermit extends FakeOriginationPermit {
        public void validate() {
            // set the validation flag
            becomeValid();
        }
    };
    Facility facility = new IndustrialFacility(Facility.HT_1, "b", new AlwaysValidPermit());
    
    assertTrue(facility.hasPermits());
}

第 10 章 无法在测试用具中运行方法

为一个方法编写测试可能会遇到的一些问题:

10.1 隐藏的方法
10.2 “有益的”语言特性

每种语言都有自己的特性,有些特性导致需要测试的类没办法抽取接口或者实例化。

10.3 无法探知的副作用

常常会看到一些并不返回任何值的方法。调用这些方法,它们完成各自的工作,调用方代码根本不知道它背后做了什么。我们无从知道结果

命令/查询分离

一个方法要么是一个命令,要么是一个查询;但不能两者都是,命令式方法指那些会改变对象状态但并不返回值的方法。而查询式方法则是指那些有返回值但不改变对象状态的方法。
为什么说这是一个重要的原则呢?其中最重要的原因就是它向用户传达的信息。例如,如果一个方法是查询式的,那么无需查看其方法体就知道可以连续多次使用它而不用担心会带来副作用。

一番方法提取之后,就可以用类似前面提到的扫描机一样运用子类化并重写方法技术来写测试了。

第 11 章 修改时应当测试哪些方法

贾尼.png

把类中值改变产生的影响画一张影响草图,我们可以从修改点一路向前推测影响,然后在会被影响的地方加上测试保护。

11.2 向前推测
image.png image.png image.png image.png
image.png

要找到安放测试的地点,第一步便是推断出哪儿可以探测到我们修改所带来的影响,即修改会带来哪些影响。知道在哪儿能够探测到影响之后,在编写测试的时候便可以在这些地方进行选择了。

11.3 影响的传播

代码修改所产生的影响可能会悄无声息地以不易察觉的方式传播。

影响在代码中的传递有三种基本途径:

在寻找修改造成的影响时会使用如下的启发式方法:

第 12 章 在同一地进行多处修改,是否应该将相关的所有类都解依赖

12.1 拦截点

给定一处修改,在程序中存在某些点能够探测到该修改的影响,这些点称为拦截点。

一般来说拦截点离修改点越紧越好。

12.1.2 高层拦截点
贾尼.png

在扩展的开票系统中,我们可以对其中每个类单独进行测试,更好的做法是找出一个能够刻画这块代码的特征的高层拦截点:

void testSimpleStatement() {
    Invoice invoice = new Invoice();
    invoice.addItem(new Item(0, new Money(10)));
    BillingStatement statement = new BillingStatement();
    statement.addInvoice(invoice);
    assertEquals(" ", statement.makeStatement());
}

这样做的好处有两点:

使得BillingStatement成立一个理想拦截点的原因在于,在这个点上,能够探测到一簇类的修改所造成的影响。在设计中,把这类地点称作汇点(pinch point)

汇点

汇点是影响结构图中的隘口和交通要冲,在汇点处编写测试的好处是只需针对少数几个方法编写测试,就能够达到探测大量其他方法的改动的目的。

第 13 章 修改时应该怎样写测试

13.1 特征测试

把用于行为保持的测试称为特征测试(Characterization Test)。特征测试刻画了一块代码的实际行为。

编写特征测试的几个步骤:

void testGenerator() {
    PageGenerator generator = new PageGenerator();
    
    assertEquals("fred", generator.generate());
}
贾尼.png

通过一个失败的测试,得知代码当前情况下的实际行为,然后再修改测试。通过反复加测试的方法来理解当前系统的行为。

编写测试去“询问”它们。

13.2 刻画类

先针对能想到的最简单的行为编写测试,然后把任务交给我们的好奇心。下面是几个启发式方法:

13.3 目标测试

重构的时候我们通常需要关心两件事情:

最有价值的特征测试覆盖某条特定的代码路径并检查这条路径上的每个转换。

13.4 编写特征测试的启发式方法

第 14 章 棘手的库依赖问题

意图实现良好设计的语言特性与代码的易测试之间有一条鸿沟。

一次性困境:如果一个库假定某个类在系统中只会出现一个实例,则后面就难对这个类使用伪对象手法。

重写限制困境

第 15 章 到处都是API调用

剥离并外覆API在以下场合表现良好:

基于职责的提取在以下场合比较合适:

第 16 章 对代码的理解不足

16.1 注记/草图
16.2 清单标注
16.3 草稿式重构
16.4 删除不用的代码

第 17 章 应用毫无结构可言

17.1 讲述系统的故事
17.2 Naked CRC

CRC

Naked CRC 原则:

反省你们的交流或讨论

第 18 章 测试代码碍手碍脚

第 19 章 对非面向对象的项目,如何安全地对它进行修改

19.3 添加新行为

宁可引入新的函数也不要把代码直接添加到代码中。

第 20 章 处理大类

庞大的类有哪些问题:

使用新生类和新生方法

单一职责原则(SRP)

每个类应该仅承担一个职责: 它在系统中的意图应当是单一的,且修改它的原因应该只有一个。

20.1 职责识别

寻找相似的方法名。将一个类上的所有方法列出来,找出哪些看起来是一伙的。

注意那些私有或受保护的方法。大量私有或收保护的方法往往意味着一个类内部有另外一个急迫想要独立出来。

寻找代码中的决定——指已经作出的决定。比如代码中有什么地方(与数据库交互、与另一组对象交互等)采用了硬编码吗?

寻找成员变量和方法之间的关系。 “这个变量只被这些方法使用吗?”

Jani.png Jani.png Jani.png Jani.png Jani.png

尝试仅用一句话来描述该类的职责。

Jani.png Jani.png

上面把ScheduledJob类将一系列的职责委托给另外几个类来完成。

接口隔离原则(ISP)

如果一个类体积较大,那么很可能它的客户并不会使用其所有方法,通常我们会看到特定用户使用特定的一组方法。如果我们给特定用户使用的那组方法创建一个接口,并让这个大类实现该接口,那么用户便可以使用“属于它的”那个接口来访问我们的类了。这种做法有利于信息隐藏,此外也减少了系统中存在的依赖。即当我们的大类发送改变的时候,其客户代码便不再需要重新编译了。

注意你目前手头正在做的事情,如果发现你自己正在为某件事情提供另一条解决方案,可能意味着这里面存在一个应该被提取并允许替代的职责。

在测试无法安置到位的情况下可以采取以下步骤:

第 21 章 需要修改大量相同的代码

决定从哪开始

我使用的另一个启发式策略就是迈小步。如果有些很小的重复是可以消除的,那么我就先把它们搞定,往往这能够使整个大图景变得明朗起来。

如果两个方法看上去大致相同,则可以抽取出它们之间的差异成分。通过这种做法,我们往往能够令它们变得完全一样,从而消除掉其中一个。

缩写

类名和方法名缩写是问题来源之一。

开放/封闭原则

开放/封闭原则是由Bertrand Meyer 首先提出的。其背后的理念是,代码对应扩展应该是开发的而对于修改则应是封闭的。这就是说,对于一个好的设计,我们无需对代码作太多的修改就可以添加新的特性。

第 22 章 要修改一个巨型方法,却没法为它编写测试

22.1 巨型方法的种类
22.2 利用自动重构支持来对付巨型方法

做提取的主要目标:

22.3 手动重构的挑战

只提取你所了解的

Extract What You Know

耦合数:传进传出你所提取的方法的值的总数。

22.4 策略

第 23 章 降低修改的风险

23.1 超感编辑

严格来说,就算只是敲敲空格键对代码做点格式化也算是某种意义上的重构。不过修改一个表达式里面的数值不是重构,而是功能改变。

23.2 单一目标的编辑

编程是关于同一时间只做一件事的艺术。

第三部分 解依赖技术

第 25 章 解依赖技术

参数适配

参数适配手法的步骤:

分解出方法对象 (Break Out Method Object)

该手法的核心理念就是将一个长方法移至一个新类中。后者的对象便被称为方法对象,因为它们只含单个方法的代码。

在没有测试的情况下安全地分解出方法对象的步骤:

25.8 提取并重写获取方法

步骤:

25.9 实现提取

步骤如下:

一个复杂的例子:


jani.png image.png image.png

如果你发现自己将一个类像上面这样嵌入了继承体系,那么建议考虑是否应当改成接口提取,并给你的接口选择其他名字。接口提取比实现提取要直接得多。

25.10 接口提取

步骤:

25.11 引入实例委托

Introduce Instance Delegator
步骤:

单件设计模式

单件模式被许多人用来确保某个特定的类在整个程序中只可能有唯一一个实例。大多数单件实现都有以下三个共性:

public class RouterFactory {
    static Router makeRouter() {
        return new EWNRouter();
    }
}

RouterFactory 是一个很直观的全局工厂。现在这样我们是没法换入测试用路由对象的,但可以对它作如下修改:

interface RouterServer {
    Router makeRouter();
}

public class RouterFactory implements RouterServer {
    static RouterServer server = new RouterServer() {
        public RouterServer makeRouter() {
            return new EWNRouter();
        }
    }

    static Router makeRouter() {
        return server.makeRouter();
    }

    static setServer(RouterServer server) {
        this.server = server;
    }
}

在测试中可以这样:

protected void setUp() {
    RouterServer.setServer(new RouterServer() {
        public RouterServer makeRouter() {
            return new FakeRouter();
        }
    });
}

引入静态设置方法的步骤如下:

25.14 参数化构造函数(Parameterize Constructor)
public class MailChecker {
    public MailChecker (int checkPeriodSeconds) {
        this.receiver = new MailReceiver()
        this,checkPeriodSeconds = checkPeriodSeconds;
    }
    ...
}
public class MailChecker {
    public MailChecker (int checkPeriodSeconds) {
        this(new MailReceiver(), checkPeriodSeconds);
    }

    public MailChecker (MailReceiver receiver, int checkPeriodSeconds) {
        this.receiver = recevier;
        this.checkPeriodSeconds = checkPeriodSeconds;
    }
}

参数化构造函数的步骤:

25.15 参数化方法

参数化方法的步骤如下:

25.20 以获取方法替换全局引用
public class RegisterSale {
    public void addItem (Barcode code) {
        Item newItem = Inventory.getInventory().itemForBarcode(code);
        items.add(newItem);
    }
}
public class RegisterSale {
    public void addItem (Barcode code) {
        Item newItem = getInventory().itemForBarcode(code);
        item.add(newItem);
    }

    protected Inventory getInventory() {
        return Inventory.getInventory();
    }
    ...
}
public class FakeInventory extends Inventory {
    public Item itemForBarcode (Barcode code) {
        ...
    }
    ...
}
class TestingRegisterSale extends RegisterSale {
    Inventory inventory = new FakeInventory();
    
    protected Inventory getInventory () {
        return inventory;
    } 
}

步骤:

25.21 子类化并重写方法(Subclass and Override Method)

步骤:

25.22 替换实例变量

步骤:

上一篇 下一篇

猜你喜欢

热点阅读