美文网首页后端技术EA
《修改代码的艺术》读书笔记

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

作者: 贾尼 | 来源:发表于2017-11-30 23:58 被阅读1060次
    遗留代码
    • 其他人那儿得来的代码;
    • 错综复杂,难以理清的结构,需要改变然而实际上又根本不能理解的代码;
    • 没有编写相应测试的代码;

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

    第一部分 修改机理

    第 1 章 修改软件

    为什么要修改软件:

    • 添加新特性
    • 修正bug
    • 改善设计
    • 优化资源使用

    修改是需要考虑:

    • 我们要进行哪些修改?
    • 我们如何得知已经正确地完成了修改?
    • 我们如何得知没有破坏任何(既有的)东西?

    第 2 章 带着反馈工作

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

    • 编辑并祈祷(edit and pray)
    • 覆盖并修改 (cover and modify)

    好的单元测试:

    • 运行快
    • 能帮助我们定位问题所在

    以下测试不叫单元测试:

    • 跟数据库有交互
    • 进行了网络间通信
    • 调用了文件系统
    • 需要你对环境作特定的准备(如编辑配置文件)才能运行的
    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 关键特性:

    • 允许使用开发语言编写测试
    • 所有测试互不干扰独立运行
    • 一组测试可以集合起来成为一个测试套件(suite),根据需要不断运行

    第二部分 修改代码的技术

    第 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() {
            ...
        }
        ...
    }
    

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

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

    外覆类手法步骤:

    • 确定修改点
    • 新建一个类,该类的构造函数接受需要被外覆的类的对象为参数。如无法在测试用具中创建外覆类实例,可以先对被外覆类使用实现提取或接口提取技术,以便能够实例化外覆类。
    • TDD方式为外覆类编写方法实现新功能,然后再加一个方法,实现新功能和调用原方法。
    • 在系统中需要使用新行为的地方创建并使用外覆类的对象。

    使用外覆类的两种情况:

    • 欲添加的行为是完全独立的,并且我们不希望让低层或者不相关的行为污染现有类。
    • 原类已经够大了,不想一直在上面加功能。

    第 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());
    }
    

    这样做的好处有两点:

    • 需要进行的解依赖可能减少了;
    • 我们的“软件夹钳”所夹住的代码块也更大。


      贾尼.png

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

    汇点

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

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

    13.1 特征测试

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

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

    • 在测试用具中使用目标代码块;
    • 编写一个你知道会失败的断言;
    • 从断言的失败中得知代码的行为;
    • 修改你的测试,让它预期目标代码的实际行为;
    • 重复上述步骤。
    void testGenerator() {
        PageGenerator generator = new PageGenerator();
        
        assertEquals("fred", generator.generate());
    }
    
    贾尼.png

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

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

    • 让自己对目标代码的行为感到好奇,这个阶段我们不断编写测试直到感到已经理解了代码。
    • 设法弄清我们的修改如果引入了bug的话测试能否“感应”得到。如果存在可能的漏网之鱼,就要添加更多的测试,直到无遗漏为止。
    13.2 刻画类

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

    • 寻找代码中逻辑复杂的部分。
    • 随着你不断发现类或方法的一个个职责,不时停下来把你认为可能出错的地方列一个单子。看看能不能编写出能够触发这些问题的测试。
    • 考虑你在测试中提供的输入。
    • 对于某个类的对象,有没有某些条件在它的整个生命周期当中都是成立的?称为不变式(invariant)。尝试编写测试去验证它们。
    13.3 目标测试

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

    • 目标行为在重构之后是否仍然存在
    • 是否正确“连续”在系统当中

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

    13.4 编写特征测试的启发式方法
    • 为准备修改的代码区域编写测试,尽量编写用例,直到觉得你已经理解了那块代码的行为。
    • 之后再开始考虑你所要进行的修改,并针对修改编写测试。
    • 如果想要提取或转移某些功能,那就编写测试来验证这些行为的存在性和一致性,一种情况一种情况地编写。确认你的测试覆盖到了将被转移的代码,确认这些代码被正确连接在系统中。

    第 14 章 棘手的库依赖问题

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

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

    重写限制困境

    第 15 章 到处都是API调用

    • 剥离并外覆API (Skin and Wrap the API)
    • 基于职责的提取

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

    • API 规模相对较小
    • 你想要完全分离出对第三方库的依赖
    • 没有现有测试,而且没法编写

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

    • API 较为复杂
    • 能安全的提取方法

    第 16 章 对代码的理解不足

    16.1 注记/草图
    16.2 清单标注
    • 职责分离
    • 理解方法结构
    • 方法提取
    • 理解你的修改产生的影响
    16.3 草稿式重构
    16.4 删除不用的代码

    第 17 章 应用毫无结构可言

    17.1 讲述系统的故事
    17.2 Naked CRC

    CRC

    • 类(Class)
    • 职责(responsibility)
    • 协作(Collaboration)

    Naked CRC 原则:

    • 卡片代表实例,而非在类
    • 用叠在一起的卡片来表示“一组实例”
    反省你们的交流或讨论

    第 18 章 测试代码碍手碍脚

    • 类命名约定
    • 测试代码放在哪里

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

    19.3 添加新行为

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

    • 使用TDD开发

    第 20 章 处理大类

    庞大的类有哪些问题:

    • 容易混淆
    • 任务调度

    使用新生类和新生方法

    单一职责原则(SRP)

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

    20.1 职责识别
    • 探索性方法: 方法分组

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

    • 探索是方法:观察隐藏方法

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

    • 探索式方法: 寻找可以更改的决定

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

    • 探索式方法:寻找内部关系

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

    • 为每个成员变量画一个圈
    • 观察每个方法,各自画一个圈
    • 任以方法与该方法用到的任何成员变量或方法之间画一个带箭头的线。
    Jani.png 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 策略
    • 主干提取(Skeletonize)
    • 序列发现
    • 优先提取到当前类中
    • 小块提取

    第 23 章 降低修改的风险

    23.1 超感编辑

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

    23.2 单一目标的编辑

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

    第三部分 解依赖技术

    第 25 章 解依赖技术

    参数适配

    参数适配手法的步骤:

    • 创建将被用于该方法的新接口,该接口越简单且能表达意图越好。但也要注意,该接口不应导致需要对该方法的代码作大规模修改。
    • 为新接口创建一个用于产品代码的实现。
    • 为新接口创建一个用于测试的“伪造”实现
    • 编写一个简单的测试用例,将伪对象传给该方法。
    • 对该方法作必要的修改以使其能使用新的参数
    • 运行测试来确保你能使用伪对象来测试该方法。
    分解出方法对象 (Break Out Method Object)

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

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

    • 创建一个将包含目标方法的类
    • 为该类创建一个构造函数,并利用签名保持手法来让它具有跟目标方法完全一样的参数列表。如果目标方法用到了原类中的成员变量或方法的话,再往该构造函数的参数列表里面加上一个对原类的引用(添加为第一个参数)
    • 对于构造函数参数列表里面的每个参数,创建一个相应的成员变量,类型分别与对应的参数类型完全相同。
    • 在新类中建立一个空的执行方法。通常该方法可以叫做run
    25.8 提取并重写获取方法

    步骤:

    • 找出需要为其引入获取方法的对象
    • 将创建该对象所需的所有逻辑都提取到一个获取方法中
    • 将所有对该对象的使用都替换为通过该获取方法来获取,并在所有构造函数中将该对象的引用初始化为null
    • 在获取方法里面加入“首次调用时创建”功能,这样当成员引用为null时该获取方法就会负责创建新对象
    • 子类化该类,重写这个获取方法并在其中提供你自己的测试用对象
    25.9 实现提取

    步骤如下:

    • 将目标类的声明复制一份,给复制的类起一个新名字。这里最好建立一个命名习惯。
    • 将目标类变成一个接口,这一步通过删除所有非公有方法和数据成员来实现
    • 将所有剩下来的公有方法设为抽象方法。
    • 删除该接口类文件当中的不必要的import或include
    • 让你的产品类实现该接口
    • 编译你的产品类以确保新接口中的所有方法都实现了
    • 编译系统的其余部分,找出那些创建原类的对象的地方,将它们修改为创建新的产品类的对象。
    • 重编译并测试

    一个复杂的例子:


    jani.png image.png image.png

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

    25.10 接口提取

    步骤:

    • 创建一个新接口,给它起一个好名字。
    • 令你提取接口的目标类实现该接口。
    • 将你想要使用伪对象的地方从引用原类改为引用你新建的接口
    • 编译系统
    25.11 引入实例委托

    Introduce Instance Delegator
    步骤:

    • 找出会在测试中带来问题的那个静态方法
    • 在它所属类上新建一个实例方法。让该实例方法委托那个静态方法
    • 找出你想要纳入测试的类中有哪些地方使用了那个静态方法。使用参数化方法或其他解依赖技术来提供一个实例给代码中想要调用那个静态方法的地方。

    单件设计模式

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

    • 单件类的构造函数通常被设为私有
    • 单件类具有一个静态成员,该成员持有该类的唯一一个实例
    • 单件类具有一个静态方法,用来提供对单件实例的访问。通常该方法名叫instance
    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;
        }
    }
    

    参数化构造函数的步骤:

    • 找出你想要参数化的构造函数,并将它复制一份。
    • 给其中的一份复制增加一个参数,该参数用来传入你想要替换的对象。将该构造函数体中的相应的对象创建语句删掉,改为使用新增的那个参数。
    • 如果你的语言支持委托构造函数,那么删掉另一份构造函数的函数体,代以对刚才那个函数的调用,别忘了调用的时候要new一个相应的对象出来。如果你的语言不支持委托构造函数,则可能需要将构造函数中的共同成分提取到一个比如叫common_init 的方法中。
    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 替换实例变量

    步骤:

    • 找出你想要替换的实例变量
    • 创建一个名为supersedeXXX的方法,其中XXX是你想要替换的变量的名字
    • 在该方法中撤销原先被创建出来的那个对象,换入你新建出来的对象。如果持有该对象的实例成员是一个引用,则需要确保该类中没有其他成员引用了原先创建出来的那个对象。如果有的话,你可能就需要在supersedexxx方法里面多做一点工作,来确保能够安全地换入你的新对象并且确保达到正确的效果。

    相关文章

      网友评论

        本文标题:《修改代码的艺术》读书笔记

        本文链接:https://www.haomeiwen.com/subject/xsutbxtx.html