《修改代碼的藝術(shù)》讀書筆記

遺留代碼
  • 其他人那兒得來的代碼;
  • 錯綜復(fù)雜,難以理清的結(jié)構(gòu),需要改變?nèi)欢鴮嶋H上又根本不能理解的代碼;
  • 沒有編寫相應(yīng)測試的代碼;

沒有編寫測試的代碼是糟糕的代碼。不管我們有多細(xì)心地去編寫它們,不管它們有多漂亮,面向?qū)ο蠡蛘叻庋b良好,只要沒有編寫測試,我們實際上就不知道修改后的代碼是變得更好了還是更糟了。反之,有了測試,我們就能夠迅速,可驗證地修改代碼的行為。

第一部分 修改機(jī)理

第 1 章 修改軟件

為什么要修改軟件:

  • 添加新特性
  • 修正bug
  • 改善設(shè)計
  • 優(yōu)化資源使用

修改是需要考慮:

  • 我們要進(jìn)行哪些修改?
  • 我們?nèi)绾蔚弥呀?jīng)正確地完成了修改?
  • 我們?nèi)绾蔚弥獩]有破壞任何(既有的)東西?

第 2 章 帶著反饋工作

改動系統(tǒng)的兩種主要方式:

  • 編輯并祈禱(edit and pray)
  • 覆蓋并修改 (cover and modify)

好的單元測試:

  • 運(yùn)行快
  • 能幫助我們定位問題所在

以下測試不叫單元測試:

  • 跟數(shù)據(jù)庫有交互
  • 進(jìn)行了網(wǎng)絡(luò)間通信
  • 調(diào)用了文件系統(tǒng)
  • 需要你對環(huán)境作特定的準(zhǔn)備(如編輯配置文件)才能運(yùn)行的
2.3 測試覆蓋

依賴性是軟件開發(fā)中最為關(guān)鍵的問題之一。在處理遺留代碼的過程中很大一部分工作都是圍繞著“解除依賴性以便改動變得更容易”這個目標(biāo)來進(jìn)行的。

遺留代碼的困境

我們在修改代碼時,應(yīng)當(dāng)有測試保護(hù),而為了將這些測試安置妥當(dāng),往往又得先去修改代碼。

image.png
image.png

上述的用于解開InvoiceUpdateResponder 對 InvoiceUpdateServlet 和對 DBConnection 的依賴的兩種重構(gòu)手法分別稱為樸素化參數(shù)(Primitivize Parameter)和接口提?。‥xtract Interface)。

2.4 遺留代碼修改算法

以下算法可以用于對遺留代碼基進(jìn)行修改:

  • 確定改動點
  • 找出測試點
  • 解依賴
  • 編寫測試
  • 修改,重構(gòu)
2.4.3 解依賴

依賴性是進(jìn)行測試的障礙,表現(xiàn)在兩個方面:

  • 難以在測試用具中實例化目標(biāo)對象
  • 難以在測試用具中(調(diào)用)運(yùn)行方法

第 3 章 感知和分離

  • 感知: 當(dāng)我們無法訪問到代碼計算出的值時,就需要通過解依賴來“感知”這些值。
  • 分離: 當(dāng)我們無法將哪怕一小塊代碼放入到測試用具中去運(yùn)行時,就需要通過解依賴將這塊代碼“分離”出來。
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方法是直接調(diào)用顯示器上面,在我們Unit Test 里面,沒有辦法知道showLine里面做的事情,所以通過先把對顯示器刷新的代碼提取出來,然后再提取一個接口,通過一個Fake實現(xiàn)類去假設(shè)showLine做的事情,最后再用我們的假設(shè)去測試Sale是否會將正確的文本送到顯示器上。其實就是獲取到Sale調(diào)用showLine的參數(shù),來驗證其正確性。這個參數(shù)在Sale里面可能是一個臨時變量,我們沒辦法在Unit Test 直接拿到值。這樣沒辦法測試顯示器是否有問題,但是可以測試我們系統(tǒng)代碼是否有問題。

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

偽對象是偽裝成目標(biāo)對象,仿對象目的在于盡量模仿真實的目標(biāo)對象的行為,被測試者可以(從行為上)把它看作一個真正的目標(biāo)對象來使用。

第 4 章 接縫模型

最終得到易于測試的程序的兩條路:

  • 邊開發(fā)邊編寫測試;
  • 在先期花點時間試著將以測試性納入整體的設(shè)計考量。
接縫(seam)

指程序中的一些特殊的點,在這些點上你無需作任何修改就可以達(dá)到改動程序行為的目的。

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 預(yù)處理期接縫
激活點

每個接縫都有一個激活點,在這些點上你可以決定使用哪種行為。

4.3.2 連接期接縫

使用連接期接縫時,請確保測試和產(chǎn)品環(huán)境之間的差別是顯而易見的。

4.3.3 對象接縫

上面講接縫的時候有一個具體的例子。

第 5 章 工具

5.1 自動化重構(gòu)工具
重構(gòu)

名詞。對軟件內(nèi)部結(jié)構(gòu)的一種調(diào)整,目的是在不改變軟件的外在行為的前提下,提高其可理解性,降低其修改成本。

我在重構(gòu)代碼的時候沒有用過自動化重構(gòu)工具,自動化重構(gòu)之后難以保證程序的行為沒有發(fā)生改變。

5.2 仿對象

在面向?qū)ο笳Z言的代碼中,可以用仿對象(mock object)對付遺留代碼中的依賴問題。

5.3 單元測試用具

xUnit 關(guān)鍵特性:

  • 允許使用開發(fā)語言編寫測試
  • 所有測試互不干擾獨立運(yùn)行
  • 一組測試可以集合起來成為一個測試套件(suite),根據(jù)需要不斷運(yùn)行

第二部分 修改代碼的技術(shù)

第 6 章 時間緊迫,但必須修改

雖說不管是解依賴還是為所要進(jìn)行的修改編寫測試都要花上一些時間,但大部分情況下最終還是節(jié)省了時間,同時也避免了一次又一次的沮喪感。

作者在和團(tuán)隊合作過程中,有這樣一個實驗。在一個迭代期,試著堅持不要在沒有測試覆蓋的情況下去改動代碼。如果某個人覺得他們無法編寫某個測試,就得召集一個臨時會議,詢問整個團(tuán)隊是否可能編寫該測試。這樣一個迭代期在開始的時候是糟糕的。人們覺得他們做了無用功。但是慢慢地,他們就開始發(fā)現(xiàn)當(dāng)重訪代碼時看到的時更好的代碼。并且代碼的修改也變得越來越容易,這時他們就會打心底里覺得這么做時值得的。

新生方法
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中的對象在日期被發(fā)送并添加到transactionBundle中去之前是否已經(jīng)存在了。

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)實際需要采取的步驟:

  • 確定修改點。
  • 如果你的修改可以在一個方法中的一處地方以單塊連續(xù)的語句序列出現(xiàn),那么在修改點插入一個方法調(diào)用,而被調(diào)用的就是我們下面要編寫的,用于完成有關(guān)工作的新方法。然后我們將這一調(diào)用先注釋掉。
  • 確定你需要原方法中的哪些局部變量,并將它們作為實參傳給新方法調(diào)用。
  • 確定新方法是否需要返回什么值給原方法。如果需要的話就得相應(yīng)的修改對它的調(diào)用,使用一個變量來接受其返回值。
  • 使用測試驅(qū)動的開發(fā)方式來開發(fā)新的方法。
  • 使原方法中被注釋的調(diào)用重新生效。

缺點

  • 放棄修改原方法

優(yōu)點

  • 新舊代碼清晰隔離
6.2 新生類

使用新生類(Sprout Class)的兩種情況:

  • 所要進(jìn)行的修改迫使你為某個類添加一個全新的職責(zé)。(避免職責(zé)混淆)
  • 要添加的只是一點小小的功能,可以將它放入現(xiàn)有的類中,但問題時無法將這個類放入測試用具,或者說要花非常多的工作才能加進(jìn)去。(無法或難以加入測試用具)

步驟:

  • 確定修改點。
  • 如果你的修改可以在一個方法中的一處地方以單塊連續(xù)的語句序列出現(xiàn),那么用一個類來完成這些工作,并為這個類起一個恰當(dāng)?shù)拿?。然后,在修改點插入代碼創(chuàng)建該類的對象,調(diào)用其上的方法。
  • 確定你需要原方法的哪些局部變量,并將它們作為參數(shù)傳遞給新類的構(gòu)造函數(shù)。
  • 確定新生類是否需要返回什么值給原方法。
  • 使用測試驅(qū)動開發(fā)的方式來開發(fā)這個新類。

缺點

  • 可能使系統(tǒng)中的概念復(fù)雜化

優(yōu)點

  • 更大的自信
  • 更安全的修改
  • 容易寫單元測試
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。創(chuàng)建一個新的pay()方法,調(diào)用dispatchPayment() 和 logPayment()。 客戶不必知道這次改動,也不用做任何改動。

這是外覆方法的運(yùn)用形式之一:
創(chuàng)建一個與原方法同名的新方法,在新方法中調(diào)用更名后的原方法。

外覆方法的另一種形式,顯示暴露日志記錄:

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

    public void pay() {
        ...
    }

    private void logPayment() {
        ...
    }
}

用戶可以根據(jù)自己需要自由選擇。

外覆方法第一種形式步驟:

  • 確定待修改的方法。
  • 將待修改的方法重命名,并使用原方法名一個名字和簽名創(chuàng)建一個新方法。
  • 在新方法中調(diào)用重命名后的原方法。
  • 為欲添加的新特性加一個方法。

第二種形式步驟:

  • 和第一種方式類似,只是創(chuàng)建一個新的函數(shù)調(diào)用新舊兩個方法。

缺點

  • 添加的新特性無法跟舊特性的邏輯“交融”在一起。
  • 得為原方法中的舊代碼起一個新名字。

優(yōu)點

  • 將新的經(jīng)過測試的功能添加進(jìn)去,比較安全。
  • 不會增加現(xiàn)有方法的體積。
  • 顯式地使新功能獨立于既有功能,不會跟另一意圖的代碼互相糾纏在一起。
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() {
        ...
    }
    ...
}

上面的技術(shù)在設(shè)計模式里面被稱為裝飾模式

簡單理解就是把原方法包起來,在子類中加入其他行為,然后再調(diào)用父類的方法。

外覆類手法步驟:

  • 確定修改點
  • 新建一個類,該類的構(gòu)造函數(shù)接受需要被外覆的類的對象為參數(shù)。如無法在測試用具中創(chuàng)建外覆類實例,可以先對被外覆類使用實現(xiàn)提取或接口提取技術(shù),以便能夠?qū)嵗飧差悺?/li>
  • TDD方式為外覆類編寫方法實現(xiàn)新功能,然后再加一個方法,實現(xiàn)新功能和調(diào)用原方法。
  • 在系統(tǒng)中需要使用新行為的地方創(chuàng)建并使用外覆類的對象。

使用外覆類的兩種情況:

  • 欲添加的行為是完全獨立的,并且我們不希望讓低層或者不相關(guān)的行為污染現(xiàn)有類。
  • 原類已經(jīng)夠大了,不想一直在上面加功能。

第 7 章 漫長的修改

第 8 章 添加特性

8.1 測試驅(qū)動開發(fā)

測試驅(qū)動開發(fā)與遺留代碼

測試驅(qū)動開發(fā)的最有價值的一個方面是它使得我們可以在同一時間只關(guān)注于一件事情。要么在編碼,要么在重構(gòu)。
這一好處對付遺留代碼顯得尤其有價值,它使得我們能夠獨立地編寫新代碼。
在編寫完新代碼之后,可以通過重構(gòu)來消除新舊代碼之間的任何重復(fù)。

遺留代碼中,測試驅(qū)動開發(fā):

  • 將想要修改的類置于測試之下。
  • 編寫一個失敗測試用例。
  • 讓它通過編譯。
  • 讓測試通過(盡量不要改動既有代碼)
  • 消除重復(fù)
  • 重復(fù)上述步驟
8.2 差異式編程(programming by difference)

借助于類的繼承,我們可以在不直接改動一個類的前提下引入新的特性。

差異式編程能夠快速做出改動,事后還可以再靠測試的幫助來換成更干凈的設(shè)計。但要小心別違反了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);

結(jié)果應(yīng)該是12,這樣就不是正方形了,假如去重寫setWidth 和 setHeight方法,結(jié)果變成9或者16都會造成違反期望的結(jié)果。

子類對象應(yīng)當(dāng)能夠用于替換代碼中出現(xiàn)的它們父類的對象,不管后者被用在什么地方。如果不能,代碼中就有可能出現(xiàn)了一些錯誤。

一般規(guī)則:

  • 盡可能避免重寫具體方法(接口上的方法不屬于具體方法)。
  • 重寫了某個具體方法,看看能否在重寫方法中調(diào)用被重寫的那個方法。

如果想要保留繼承,可以將父類做成一個抽象類,讓子類各自去提供具體的實現(xiàn)。

賈尼.png

第 9 章 無法將類放入測試用具中

四種最為常見的問題:

  • 無法輕易創(chuàng)建該類的對象
  • 當(dāng)該類位于測試用具中時,測試用具無法輕易通過編譯構(gòu)建。
  • 我們需要用到的構(gòu)造函數(shù)具有副作用
  • 構(gòu)造函數(shù)中有一些要緊的工作,我們需要感知到它們。
9.1 令人惱火的參數(shù)

無法輕易構(gòu)建該類的對象時,通過提取接口,然后用一個偽裝類來實現(xiàn)接口,從而構(gòu)造參數(shù),和前面講的的偽對象一樣。

9.2 隱藏依賴

使用提取并重寫獲取方法(Extract and Override Getter)、提取并重寫工作方法(Extract and Override Factory Method)以及替換實例變量(Supersede Instance Variable),盡可能使用參數(shù)化構(gòu)造函數(shù)。當(dāng)一個構(gòu)造函數(shù)在它的函數(shù)體中創(chuàng)建了一個對象,并且該對象本身并沒有任何構(gòu)造依賴時,運(yùn)用參數(shù)化構(gòu)造函數(shù)就比較輕松了。

9.3 構(gòu)造塊
9.4 惱人的全局依賴

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

  • 我們建模的是現(xiàn)實世界,在現(xiàn)實世界中這種東西只有一個。
  • 創(chuàng)建某個類的兩個(或多個)對象可能會導(dǎo)致嚴(yán)重的問題。
  • 創(chuàng)建某個類的兩個(或多個)對象可能會使用過多的資源。

采用子類并重寫方法,創(chuàng)建一個派生類讓測試更容易。

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
    ...
}

為避免跟數(shù)據(jù)庫通信,可以如下子類化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 “洋蔥”參數(shù)

通過提取接口的方法解依賴。

對于一門語言來說,只要能用它來創(chuàng)建接口,或者類似接口行為的類,我們就可以系統(tǒng)地使用它們來進(jìn)行解依賴。

9.7 化名參數(shù)

當(dāng)遇到構(gòu)造函數(shù)參數(shù)問題時,通??梢越柚诮涌谔崛』?qū)崿F(xiàn)提取技術(shù)來克服。但有時候不實際,因為需要提取的接口太多了。

賈尼.png

可以采取另一個方案,只切斷某些地方之間的聯(lián)系。

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

可以采用子類化并重寫方法。創(chuàng)建一個名為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 章 無法在測試用具中運(yùn)行方法

為一個方法編寫測試可能會遇到的一些問題:

  • 無法在測試中訪問那個方法。比如說,它可能時私有的,或者有其他可訪問性限制。
  • 無法輕易地調(diào)用那個方法,因為很難構(gòu)建調(diào)用它所需的參數(shù)。
  • 那個方法可能會產(chǎn)生糟糕的副作用(如修改數(shù)據(jù)庫、發(fā)射一枚巡航導(dǎo)彈等等),因而無法在測試用具中運(yùn)行它。
  • 可能會需要通過該方法所使用的某些對象來進(jìn)行感知。
10.1 隱藏的方法
  • 改成公有方法
  • 提取到新類中改成公有方法
10.2 “有益的”語言特性

每種語言都有自己的特性,有些特性導(dǎo)致需要測試的類沒辦法抽取接口或者實例化。

10.3 無法探知的副作用

常常會看到一些并不返回任何值的方法。調(diào)用這些方法,它們完成各自的工作,調(diào)用方代碼根本不知道它背后做了什么。我們無從知道結(jié)果

  • 先用提取函數(shù)的方法把方法的職責(zé)分離開。

命令/查詢分離

一個方法要么是一個命令,要么是一個查詢;但不能兩者都是,命令式方法指那些會改變對象狀態(tài)但并不返回值的方法。而查詢式方法則是指那些有返回值但不改變對象狀態(tài)的方法。
為什么說這是一個重要的原則呢?其中最重要的原因就是它向用戶傳達(dá)的信息。例如,如果一個方法是查詢式的,那么無需查看其方法體就知道可以連續(xù)多次使用它而不用擔(dān)心會帶來副作用。

一番方法提取之后,就可以用類似前面提到的掃描機(jī)一樣運(yùn)用子類化并重寫方法技術(shù)來寫測試了。

第 11 章 修改時應(yīng)當(dāng)測試哪些方法

賈尼.png

把類中值改變產(chǎn)生的影響畫一張影響草圖,我們可以從修改點一路向前推測影響,然后在會被影響的地方加上測試保護(hù)。

11.2 向前推測
image.png
image.png
image.png
image.png

image.png

要找到安放測試的地點,第一步便是推斷出哪兒可以探測到我們修改所帶來的影響,即修改會帶來哪些影響。知道在哪兒能夠探測到影響之后,在編寫測試的時候便可以在這些地方進(jìn)行選擇了。

11.3 影響的傳播

代碼修改所產(chǎn)生的影響可能會悄無聲息地以不易察覺的方式傳播。

影響在代碼中的傳遞有三種基本途徑:

  • 調(diào)用方使用被調(diào)用函數(shù)的返回值
  • 修改傳參傳進(jìn)來的對象,且后者接下來會被使用到。
  • 修改后面會被用到的靜態(tài)或全局?jǐn)?shù)據(jù)。

在尋找修改造成的影響時會使用如下的啟發(fā)式方法:

  • 確定一個將要修改的方法。
  • 如果該方法有返回值,查看它的調(diào)用方。
  • 看看該方法是否修改了什么值。如果是則查看其他使用了這些值的方法,以及使用了這些方法的方法。
  • 查看父類和子類,它們也可能使用了這些實例變量和方法。
  • 查看方法的參數(shù),看看你要修改的代碼是否使用了某參數(shù)對象或它的方法所返回的對象。
  • 找出到目前為止被你所找到的任何方法修改的全局變量和靜態(tài)數(shù)據(jù)。

第 12 章 在同一地進(jìn)行多處修改,是否應(yīng)該將相關(guān)的所有類都解依賴

12.1 攔截點

給定一處修改,在程序中存在某些點能夠探測到該修改的影響,這些點稱為攔截點。

一般來說攔截點離修改點越緊越好。

  • 安全性更高
  • 離得較近的地方安置測試通常比較容易一些。
12.1.2 高層攔截點
賈尼.png

在擴(kuò)展的開票系統(tǒng)中,我們可以對其中每個類單獨進(jìn)行測試,更好的做法是找出一個能夠刻畫這塊代碼的特征的高層攔截點:

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

這樣做的好處有兩點:

  • 需要進(jìn)行的解依賴可能減少了;
  • 我們的“軟件夾鉗”所夾住的代碼塊也更大。


    賈尼.png

使得BillingStatement成立一個理想攔截點的原因在于,在這個點上,能夠探測到一簇類的修改所造成的影響。在設(shè)計中,把這類地點稱作匯點(pinch point)

匯點

匯點是影響結(jié)構(gòu)圖中的隘口和交通要沖,在匯點處編寫測試的好處是只需針對少數(shù)幾個方法編寫測試,就能夠達(dá)到探測大量其他方法的改動的目的。

第 13 章 修改時應(yīng)該怎樣寫測試

13.1 特征測試

把用于行為保持的測試稱為特征測試(Characterization Test)。特征測試刻畫了一塊代碼的實際行為。

編寫特征測試的幾個步驟:

  • 在測試用具中使用目標(biāo)代碼塊;
  • 編寫一個你知道會失敗的斷言;
  • 從斷言的失敗中得知代碼的行為;
  • 修改你的測試,讓它預(yù)期目標(biāo)代碼的實際行為;
  • 重復(fù)上述步驟。
void testGenerator() {
    PageGenerator generator = new PageGenerator();
    
    assertEquals("fred", generator.generate());
}
賈尼.png

通過一個失敗的測試,得知代碼當(dāng)前情況下的實際行為,然后再修改測試。通過反復(fù)加測試的方法來理解當(dāng)前系統(tǒng)的行為。

編寫測試去“詢問”它們。

  • 讓自己對目標(biāo)代碼的行為感到好奇,這個階段我們不斷編寫測試直到感到已經(jīng)理解了代碼。
  • 設(shè)法弄清我們的修改如果引入了bug的話測試能否“感應(yīng)”得到。如果存在可能的漏網(wǎng)之魚,就要添加更多的測試,直到無遺漏為止。
13.2 刻畫類

先針對能想到的最簡單的行為編寫測試,然后把任務(wù)交給我們的好奇心。下面是幾個啟發(fā)式方法:

  • 尋找代碼中邏輯復(fù)雜的部分。
  • 隨著你不斷發(fā)現(xiàn)類或方法的一個個職責(zé),不時停下來把你認(rèn)為可能出錯的地方列一個單子??纯茨懿荒芫帉懗瞿軌蛴|發(fā)這些問題的測試。
  • 考慮你在測試中提供的輸入。
  • 對于某個類的對象,有沒有某些條件在它的整個生命周期當(dāng)中都是成立的?稱為不變式(invariant)。嘗試編寫測試去驗證它們。
13.3 目標(biāo)測試

重構(gòu)的時候我們通常需要關(guān)心兩件事情:

  • 目標(biāo)行為在重構(gòu)之后是否仍然存在
  • 是否正確“連續(xù)”在系統(tǒng)當(dāng)中

最有價值的特征測試覆蓋某條特定的代碼路徑并檢查這條路徑上的每個轉(zhuǎn)換。

13.4 編寫特征測試的啟發(fā)式方法
  • 為準(zhǔn)備修改的代碼區(qū)域編寫測試,盡量編寫用例,直到覺得你已經(jīng)理解了那塊代碼的行為。
  • 之后再開始考慮你所要進(jìn)行的修改,并針對修改編寫測試。
  • 如果想要提取或轉(zhuǎn)移某些功能,那就編寫測試來驗證這些行為的存在性和一致性,一種情況一種情況地編寫。確認(rèn)你的測試覆蓋到了將被轉(zhuǎn)移的代碼,確認(rèn)這些代碼被正確連接在系統(tǒng)中。

第 14 章 棘手的庫依賴問題

意圖實現(xiàn)良好設(shè)計的語言特性與代碼的易測試之間有一條鴻溝。

一次性困境:如果一個庫假定某個類在系統(tǒng)中只會出現(xiàn)一個實例,則后面就難對這個類使用偽對象手法。

重寫限制困境

第 15 章 到處都是API調(diào)用

  • 剝離并外覆API (Skin and Wrap the API)
  • 基于職責(zé)的提取

剝離并外覆API在以下場合表現(xiàn)良好:

  • API 規(guī)模相對較小
  • 你想要完全分離出對第三方庫的依賴
  • 沒有現(xiàn)有測試,而且沒法編寫

基于職責(zé)的提取在以下場合比較合適:

  • API 較為復(fù)雜
  • 能安全的提取方法

第 16 章 對代碼的理解不足

16.1 注記/草圖
16.2 清單標(biāo)注
  • 職責(zé)分離
  • 理解方法結(jié)構(gòu)
  • 方法提取
  • 理解你的修改產(chǎn)生的影響
16.3 草稿式重構(gòu)
16.4 刪除不用的代碼

第 17 章 應(yīng)用毫無結(jié)構(gòu)可言

17.1 講述系統(tǒng)的故事
17.2 Naked CRC

CRC

  • 類(Class)
  • 職責(zé)(responsibility)
  • 協(xié)作(Collaboration)

Naked CRC 原則:

  • 卡片代表實例,而非在類
  • 用疊在一起的卡片來表示“一組實例”
反省你們的交流或討論

第 18 章 測試代碼礙手礙腳

  • 類命名約定
  • 測試代碼放在哪里

第 19 章 對非面向?qū)ο蟮捻椖浚绾伟踩貙λM(jìn)行修改

19.3 添加新行為

寧可引入新的函數(shù)也不要把代碼直接添加到代碼中。

  • 使用TDD開發(fā)

第 20 章 處理大類

龐大的類有哪些問題:

  • 容易混淆
  • 任務(wù)調(diào)度

使用新生類和新生方法

單一職責(zé)原則(SRP)

每個類應(yīng)該僅承擔(dān)一個職責(zé): 它在系統(tǒng)中的意圖應(yīng)當(dāng)是單一的,且修改它的原因應(yīng)該只有一個。

20.1 職責(zé)識別
  • 探索性方法: 方法分組

尋找相似的方法名。將一個類上的所有方法列出來,找出哪些看起來是一伙的。

  • 探索是方法:觀察隱藏方法

注意那些私有或受保護(hù)的方法。大量私有或收保護(hù)的方法往往意味著一個類內(nèi)部有另外一個急迫想要獨立出來。

  • 探索式方法: 尋找可以更改的決定

尋找代碼中的決定——指已經(jīng)作出的決定。比如代碼中有什么地方(與數(shù)據(jù)庫交互、與另一組對象交互等)采用了硬編碼嗎?

  • 探索式方法:尋找內(nèi)部關(guān)系

尋找成員變量和方法之間的關(guān)系。 “這個變量只被這些方法使用嗎?”

  • 為每個成員變量畫一個圈
  • 觀察每個方法,各自畫一個圈
  • 任以方法與該方法用到的任何成員變量或方法之間畫一個帶箭頭的線。
Jani.png
Jani.png
  • 從草圖中看出存在聚集的現(xiàn)象


    Jani.png
Jani.png
Jani.png
Jani.png
  • 探索式方法: 尋找主要職責(zé)

嘗試僅用一句話來描述該類的職責(zé)。

Jani.png
Jani.png

上面把ScheduledJob類將一系列的職責(zé)委托給另外幾個類來完成。

接口隔離原則(ISP)

如果一個類體積較大,那么很可能它的客戶并不會使用其所有方法,通常我們會看到特定用戶使用特定的一組方法。如果我們給特定用戶使用的那組方法創(chuàng)建一個接口,并讓這個大類實現(xiàn)該接口,那么用戶便可以使用“屬于它的”那個接口來訪問我們的類了。這種做法有利于信息隱藏,此外也減少了系統(tǒng)中存在的依賴。即當(dāng)我們的大類發(fā)送改變的時候,其客戶代碼便不再需要重新編譯了。

  • 探索式方法: 當(dāng)所有方法都行不通時,作一點草稿式重構(gòu)

  • 探索式方法: 關(guān)注當(dāng)前工作

注意你目前手頭正在做的事情,如果發(fā)現(xiàn)你自己正在為某件事情提供另一條解決方案,可能意味著這里面存在一個應(yīng)該被提取并允許替代的職責(zé)。

在測試無法安置到位的情況下可以采取以下步驟:

  • 確定出一個你想要分離到另一個類當(dāng)中的職責(zé)。
  • 弄清是否有成員變量需要被轉(zhuǎn)移到新類中。有就將它們放到類體內(nèi)的一個單獨的聲明區(qū)段,跟其他成員變量區(qū)分開來。
  • 如果一個方法需要整個兒被移至新類中,則將其函數(shù)體提取出來,放入新方法。
  • 倘若一個方法只有一部分需要被轉(zhuǎn)移,就將它們提取出來。

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

決定從哪開始

我使用的另一個啟發(fā)式策略就是邁小步。如果有些很小的重復(fù)是可以消除的,那么我就先把它們搞定,往往這能夠使整個大圖景變得明朗起來。

如果兩個方法看上去大致相同,則可以抽取出它們之間的差異成分。通過這種做法,我們往往能夠令它們變得完全一樣,從而消除掉其中一個。

縮寫

類名和方法名縮寫是問題來源之一。

開放/封閉原則

開放/封閉原則是由Bertrand Meyer 首先提出的。其背后的理念是,代碼對應(yīng)擴(kuò)展應(yīng)該是開發(fā)的而對于修改則應(yīng)是封閉的。這就是說,對于一個好的設(shè)計,我們無需對代碼作太多的修改就可以添加新的特性。

第 22 章 要修改一個巨型方法,卻沒法為它編寫測試

22.1 巨型方法的種類
  • 項目列表式方法

  • 鋸齒狀方法

22.2 利用自動重構(gòu)支持來對付巨型方法

做提取的主要目標(biāo):

  • 將代碼中的邏輯部分從尷尬的依賴中分離出來
  • 引入接縫,以后在重構(gòu)時才能更容易地測試安置到位。

22.3 手動重構(gòu)的挑戰(zhàn)

只提取你所了解的

Extract What You Know

耦合數(shù):傳進(jìn)傳出你所提取的方法的值的總數(shù)。

22.4 策略
  • 主干提?。⊿keletonize)
  • 序列發(fā)現(xiàn)
  • 優(yōu)先提取到當(dāng)前類中
  • 小塊提取

第 23 章 降低修改的風(fēng)險

23.1 超感編輯

嚴(yán)格來說,就算只是敲敲空格鍵對代碼做點格式化也算是某種意義上的重構(gòu)。不過修改一個表達(dá)式里面的數(shù)值不是重構(gòu),而是功能改變。

23.2 單一目標(biāo)的編輯

編程是關(guān)于同一時間只做一件事的藝術(shù)。

第三部分 解依賴技術(shù)

第 25 章 解依賴技術(shù)

參數(shù)適配

參數(shù)適配手法的步驟:

  • 創(chuàng)建將被用于該方法的新接口,該接口越簡單且能表達(dá)意圖越好。但也要注意,該接口不應(yīng)導(dǎo)致需要對該方法的代碼作大規(guī)模修改。
  • 為新接口創(chuàng)建一個用于產(chǎn)品代碼的實現(xiàn)。
  • 為新接口創(chuàng)建一個用于測試的“偽造”實現(xiàn)
  • 編寫一個簡單的測試用例,將偽對象傳給該方法。
  • 對該方法作必要的修改以使其能使用新的參數(shù)
  • 運(yùn)行測試來確保你能使用偽對象來測試該方法。
分解出方法對象 (Break Out Method Object)

該手法的核心理念就是將一個長方法移至一個新類中。后者的對象便被稱為方法對象,因為它們只含單個方法的代碼。

在沒有測試的情況下安全地分解出方法對象的步驟:

  • 創(chuàng)建一個將包含目標(biāo)方法的類
  • 為該類創(chuàng)建一個構(gòu)造函數(shù),并利用簽名保持手法來讓它具有跟目標(biāo)方法完全一樣的參數(shù)列表。如果目標(biāo)方法用到了原類中的成員變量或方法的話,再往該構(gòu)造函數(shù)的參數(shù)列表里面加上一個對原類的引用(添加為第一個參數(shù))
  • 對于構(gòu)造函數(shù)參數(shù)列表里面的每個參數(shù),創(chuàng)建一個相應(yīng)的成員變量,類型分別與對應(yīng)的參數(shù)類型完全相同。
  • 在新類中建立一個空的執(zhí)行方法。通常該方法可以叫做run
25.8 提取并重寫獲取方法

步驟:

  • 找出需要為其引入獲取方法的對象
  • 將創(chuàng)建該對象所需的所有邏輯都提取到一個獲取方法中
  • 將所有對該對象的使用都替換為通過該獲取方法來獲取,并在所有構(gòu)造函數(shù)中將該對象的引用初始化為null
  • 在獲取方法里面加入“首次調(diào)用時創(chuàng)建”功能,這樣當(dāng)成員引用為null時該獲取方法就會負(fù)責(zé)創(chuàng)建新對象
  • 子類化該類,重寫這個獲取方法并在其中提供你自己的測試用對象
25.9 實現(xiàn)提取

步驟如下:

  • 將目標(biāo)類的聲明復(fù)制一份,給復(fù)制的類起一個新名字。這里最好建立一個命名習(xí)慣。
  • 將目標(biāo)類變成一個接口,這一步通過刪除所有非公有方法和數(shù)據(jù)成員來實現(xiàn)
  • 將所有剩下來的公有方法設(shè)為抽象方法。
  • 刪除該接口類文件當(dāng)中的不必要的import或include
  • 讓你的產(chǎn)品類實現(xiàn)該接口
  • 編譯你的產(chǎn)品類以確保新接口中的所有方法都實現(xiàn)了
  • 編譯系統(tǒng)的其余部分,找出那些創(chuàng)建原類的對象的地方,將它們修改為創(chuàng)建新的產(chǎn)品類的對象。
  • 重編譯并測試

一個復(fù)雜的例子:


jani.png
image.png
image.png

如果你發(fā)現(xiàn)自己將一個類像上面這樣嵌入了繼承體系,那么建議考慮是否應(yīng)當(dāng)改成接口提取,并給你的接口選擇其他名字。接口提取比實現(xiàn)提取要直接得多。

25.10 接口提取

步驟:

  • 創(chuàng)建一個新接口,給它起一個好名字。
  • 令你提取接口的目標(biāo)類實現(xiàn)該接口。
  • 將你想要使用偽對象的地方從引用原類改為引用你新建的接口
  • 編譯系統(tǒng)
25.11 引入實例委托

Introduce Instance Delegator
步驟:

  • 找出會在測試中帶來問題的那個靜態(tài)方法
  • 在它所屬類上新建一個實例方法。讓該實例方法委托那個靜態(tài)方法
  • 找出你想要納入測試的類中有哪些地方使用了那個靜態(tài)方法。使用參數(shù)化方法或其他解依賴技術(shù)來提供一個實例給代碼中想要調(diào)用那個靜態(tài)方法的地方。

單件設(shè)計模式

單件模式被許多人用來確保某個特定的類在整個程序中只可能有唯一一個實例。大多數(shù)單件實現(xiàn)都有以下三個共性:

  • 單件類的構(gòu)造函數(shù)通常被設(shè)為私有
  • 單件類具有一個靜態(tài)成員,該成員持有該類的唯一一個實例
  • 單件類具有一個靜態(tài)方法,用來提供對單件實例的訪問。通常該方法名叫instance
public class RouterFactory {
    static Router makeRouter() {
        return new EWNRouter();
    }
}

RouterFactory 是一個很直觀的全局工廠?,F(xiàn)在這樣我們是沒法換入測試用路由對象的,但可以對它作如下修改:

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

引入靜態(tài)設(shè)置方法的步驟如下:

  • 降低構(gòu)造函數(shù)的保護(hù)權(quán)限,這樣你才能夠通過子類化單件類來創(chuàng)建偽類及偽對象
  • 往單件類上添加一個靜態(tài)設(shè)置方法。后者的參數(shù)類型是對該單件類的引用。確保該設(shè)置方法在設(shè)置新的單件對象之前將舊的對象銷毀。
  • 如果你需要訪問單件類里面的受保護(hù)或私有方法才能將起設(shè)置妥當(dāng)?shù)脑?,可以考慮對單件子類化,也可以對其提取接口并改用該接口的引用來持有單件。
25.14 參數(shù)化構(gòu)造函數(shù)(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;
    }
}

參數(shù)化構(gòu)造函數(shù)的步驟:

  • 找出你想要參數(shù)化的構(gòu)造函數(shù),并將它復(fù)制一份。
  • 給其中的一份復(fù)制增加一個參數(shù),該參數(shù)用來傳入你想要替換的對象。將該構(gòu)造函數(shù)體中的相應(yīng)的對象創(chuàng)建語句刪掉,改為使用新增的那個參數(shù)。
  • 如果你的語言支持委托構(gòu)造函數(shù),那么刪掉另一份構(gòu)造函數(shù)的函數(shù)體,代以對剛才那個函數(shù)的調(diào)用,別忘了調(diào)用的時候要new一個相應(yīng)的對象出來。如果你的語言不支持委托構(gòu)造函數(shù),則可能需要將構(gòu)造函數(shù)中的共同成分提取到一個比如叫common_init 的方法中。
25.15 參數(shù)化方法

參數(shù)化方法的步驟如下:

  • 找出目標(biāo)方法,將它復(fù)制一份
  • 給其中一份增加一個參數(shù),并將方法體中相應(yīng)的對象創(chuàng)建語句去掉,改為使用剛增加的這個參數(shù)。
  • 將另一份復(fù)制的方法體刪掉,代以對被參數(shù)化了的那個版本的調(diào)用,記得創(chuàng)建相應(yīng)的對象作參數(shù)。
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;
    } 
}

步驟:

  • 找出你想要替換的全局引用
  • 給它編寫一個相應(yīng)的獲取方法。確保該獲取方法的訪問權(quán)限允許你在派生類中重寫它。
  • 將對全局對象的引用替換為對該獲取方法的調(diào)用
  • 創(chuàng)建測試子類并重寫獲取方法。
25.21 子類化并重寫方法(Subclass and Override Method)

步驟:

  • 找出你想要分離出來的依賴,或者想要進(jìn)行感知的地點。找出盡量少的一組方法來完成你的目標(biāo)。
  • 確定了重寫哪些方法之后,還得確保它們都是可重寫的。
  • 在某些語言中,你需要調(diào)整這些方法的訪問權(quán)限才能在子類中重寫它們。
  • 創(chuàng)建一個子類并在其中重寫這些方法。確保你的確能夠在測試用例中構(gòu)建該類。
25.22 替換實例變量

步驟:

  • 找出你想要替換的實例變量
  • 創(chuàng)建一個名為supersedeXXX的方法,其中XXX是你想要替換的變量的名字
  • 在該方法中撤銷原先被創(chuàng)建出來的那個對象,換入你新建出來的對象。如果持有該對象的實例成員是一個引用,則需要確保該類中沒有其他成員引用了原先創(chuàng)建出來的那個對象。如果有的話,你可能就需要在supersedexxx方法里面多做一點工作,來確保能夠安全地?fù)Q入你的新對象并且確保達(dá)到正確的效果。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容