《Java8實戰》-第八章筆記(重構、測試和除錯)

雷俠發表於2018-10-14

重構、測試和除錯

通過本書的前七章,我們瞭解了Lambda和Stream API的強大威力。你可能主要在新專案的程式碼中使用這些特性。如果你建立的是全新的Java專案,這是極好的時機,你可以輕裝上陣,迅速地將新特性應用到專案中。然而不幸的是,大多數情況下你沒有機會從頭開始一個全新的專案。很多時候,你不得不面對的是用老版Java介面編寫的遺留程式碼。

這些就是本章要討論的內容。我們會介紹幾種方法,幫助你重構程式碼,以適配使用Lambda表示式,讓你維護的程式碼具備更好的可讀性和靈活性。除此之外,我們還會討論目前比較流行的幾種物件導向的設計模式,包括策略模式、模板方法模式、觀察者模式、責任鏈模式,以及工廠模式,在結合Lambda表示式之後變得更簡潔的情況。最後,我們會介紹如何測試和除錯使用Lambda表示式和Stream API的程式碼。

為改善可讀性和靈活性重構程式碼

從本書的開篇我們就一直在強調,利用Lambda表示式,你可以寫出更簡潔、更靈活的程式碼。用“更簡潔”來描述Lambda表示式是因為相較於匿名類,Lambda表示式可以幫助我們用更緊湊的方式描述程式的行為。第3章中我們也提到,如果你希望將一個既有的方法作為引數傳遞給另一個方法,那麼方法引用無疑是我們推薦的方法,利用這種方式我們能寫出非常簡潔的程式碼。

採用Lambda表示式之後,你的程式碼會變得更加靈活,因為Lambda表示式鼓勵大家使用第2章中介紹過的行為引數化的方式。在這種方式下,應對需求的變化時,你的程式碼可以依據傳入的引數動態選擇和執行相應的行為。

這一節,我們會將所有這些綜合在一起,通過例子展示如何運用前幾章介紹的Lambda表示式、方法引用以及Stream介面等特性重構遺留程式碼,改善程式的可讀性和靈活性。

改善程式碼的可讀性

改善程式碼的可讀性到底意味著什麼?我們很難定義什麼是好的可讀性,因為這可能非常主觀。通常的理解是,“別人理解這段程式碼的難易程度”。改善可讀性意味著你要確保你的程式碼能非常容易地被包括自己在內的所有人理解和維護。為了確保你的程式碼能被其他人理解,有幾個步驟可以嘗試,比如確保你的程式碼附有良好的文件,並嚴格遵守程式設計規範。

跟之前的版本相比較,Java 8的新特性也可以幫助提升程式碼的可讀性:

  • 使用Java 8,你可以減少冗長的程式碼,讓程式碼更易於理解
  • 通過方法引用和Stream API,你的程式碼會變得更直觀

這裡我們會介紹三種簡單的重構,利用Lambda表示式、方法引用以及Stream改善程式程式碼的可讀性:

  • 重構程式碼,用Lambda表示式取代匿名類
  • 用方法引用重構Lambda表示式
  • 用Stream API重構命令式的資料處理

從匿名類到 Lambda 表示式的轉換

你值得嘗試的第一種重構,也是簡單的方式,是將實現單一抽象方法的匿名類轉為Lambda表示式。為什麼呢?前面幾章的介紹應該足以說服你,因為匿名類是極其繁瑣且容易出錯的。採用Lambda表示式之後,你的程式碼會更簡潔,可讀性更好。還記得第3章的例子就是一個建立Runnable 物件的匿名類,這段程式碼及其對應的Lambda表示式實現如下:

Runnable r1 = new Runnable(){
    public void run() {
        System.out.println("Hello");
    }
};
Runnable r2 = () -> System.out.println("Hello");
複製程式碼

但是某些情況下,將匿名類轉換為Lambda表示式可能是一個比較複雜的過程。 首先,匿名類和Lambda表示式中的 this 和 super 的含義是不同的。在匿名類中, this 代表的是類自身,但是在Lambda中,它代表的是包含類。其次,匿名類可以遮蔽包含類的變數,而Lambda表示式不能(它們會導致編譯錯誤),譬如下面這段程式碼:

int a = 10;
Runnable r1 = () -> {
    // 編譯錯誤
    int a = 2;
    System.out.println(a);
};

Runnable r2 = new Runnable(){
    public void run(){
        // 一切正常
        int a = 2;
        System.out.println(a);
    }
};
複製程式碼

最後,在涉及過載的上下文裡,將匿名類轉換為Lambda表示式可能導致最終的程式碼更加晦澀。實際上,匿名類的型別是在初始化時確定的,而Lambda的型別取決於它的上下文。通過下面這個例子,我們可以瞭解問題是如何發生的。我們假設你用與 Runnable 同樣的簽名宣告瞭一個函式介面,我們稱之為 Task (你希望採用與你的業務模型更貼切的介面名時,就可能做這樣的變更):

interface Task {
    public void execute();
}

public static void doSomething(Runnable r) { r.run(); }
public static void doSomething(Task a) { a.execute(); }
複製程式碼

現在,你再傳遞一個匿名類實現的 Task ,不會碰到任何問題:

doSomething(new Task() {
    public void execute() {
        System.out.println("Danger danger!!");
    }
});
複製程式碼

但是將這種匿名類轉換為Lambda表示式時,就導致了一種晦澀的方法呼叫,因為 Runnable和 Task 都是合法的目標型別:

// 麻煩來了: doSomething(Runnable) 和doSomething(Task)都匹配該型別
doSomething(() -> System.out.println("Danger danger!!"));
複製程式碼

你可以對 Task 嘗試使用顯式的型別轉換來解決這種模稜兩可的情況:

doSomething((Task)() -> System.out.println("Danger danger!!"));
複製程式碼

但是不要因此而放棄對Lambda的嘗試。

從 Lambda 表示式到方法引用的轉換

Lambda表示式非常適用於需要傳遞程式碼片段的場景。不過,為了改善程式碼的可讀性,也請儘量使用方法引用。因為方法名往往能更直觀地表達程式碼的意圖。比如,第6章中我們曾經展示過下面這段程式碼,它的功能是按照食物的熱量級別對菜餚進行分類:

Map<Dish.CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
            groupingBy(dish -> {
                if (dish.getCalories() <= 400) {
                    return Dish.CaloricLevel.DIET;
                } else if (dish.getCalories() <= 700) {
                    return Dish.CaloricLevel.NORMAL;
                } else {
                    return Dish.CaloricLevel.FAT;
                }
            }));
複製程式碼

你可以將Lambda表示式的內容抽取到一個單獨的方法中,將其作為引數傳遞給 groupingBy方法。變換之後,程式碼變得更加簡潔,程式的意圖也更加清晰了:

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(groupingBy(Dish::getCaloricLevel));
複製程式碼

為了實現這個方案,你還需要在 Dish 類中新增 getCaloricLevel 方法:

public class Dish{
    …
    public CaloricLevel getCaloricLevel(){
        if (this.getCalories() <= 400) {
            return CaloricLevel.DIET;
        } else if (this.getCalories() <= 700) {
            return CaloricLevel.NORMAL;
        } else {
            return CaloricLevel.FAT;
        }
    }
}
複製程式碼

除此之外,我們還應該儘量考慮使用靜態輔助方法,比如 comparing 、 maxBy 。這些方法設計之初就考慮了會結合方法引用一起使用。通過示例,我們看到相對於第3章中的對應程式碼,優化過的程式碼更清晰地表達了它的設計意圖:

inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
inventory.sort(comparing(Apple::getWeight));
複製程式碼

此外,很多通用的歸約操作,比如 sum 、 maximum ,都有內建的輔助方法可以和方法引用結合使用。比如,在我們的示例程式碼中,使用 Collectors 介面可以輕鬆得到和或者最大值,與採用Lambada表示式和底層的歸約操作比起來,這種方式要直觀得多。與其編寫:

int totalCalories = menu.stream().map(Dish::getCalories).reduce(0, (c1, c2) -> c1 + c2);
複製程式碼

不如嘗試使用內建的集合類,它能更清晰地表達問題陳述是什麼。下面的程式碼中,我們使用了集合類 summingInt (方法的名詞很直觀地解釋了它的功能):

int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
複製程式碼

從命令式的資料處理切換到 Stream

我們建議你將所有使用迭代器這種資料處理模式處理集合的程式碼都轉換成Stream API的方式。為什麼呢?Stream API能更清晰地表達資料處理管道的意圖。除此之外,通過短路和延遲載入以及利用第7章介紹的現代計算機的多核架構,我們可以對Stream進行優化。

比如,下面的命令式程式碼使用了兩種模式:篩選和抽取,這兩種模式被混在了一起,這樣的程式碼結構迫使程式設計師必須徹底搞清楚程式的每個細節才能理解程式碼的功能。此外,實現需要並行執行的程式所面對的困難也多得多:

List<String> dishNames = new ArrayList<>();
for(Dish dish: menu){
    if(dish.getCalories() > 300){
        dishNames.add(dish.getName());
    }
}
複製程式碼

替代方案使用Stream API,採用這種方式編寫的程式碼讀起來更像是問題陳述,並行化也非常容易:

menu.parallelStream()
    .filter(d -> d.getCalories() > 300)
    .map(Dish::getName)
    .collect(toList());
複製程式碼

不幸的是,將命令式的程式碼結構轉換為Stream API的形式是個困難的任務,因為你需要考慮控制流語句,比如 break 、 continue 、 return ,並選擇使用恰當的流操作。

增加程式碼的靈活性

第2章和第3章中,我們曾經介紹過Lambda表示式有利於行為引數化。你可以使用不同的Lambda表示不同的行為,並將它們作為引數傳遞給函式去處理執行。這種方式可以幫助我們淡定從容地面對需求的變化。比如,我們可以用多種方式為 Predicate 建立篩選條件,或者使用Comparator 對多種物件進行比較。現在,我們來看看哪些模式可以馬上應用到你的程式碼中,讓你享受Lambda表示式帶來的便利。

  • 採用函式介面

首先,你必須意識到,沒有函式介面,你就無法使用Lambda表示式。因此,你需要在程式碼中引入函式介面。聽起來很合理,但是在什麼情況下使用它們呢?這裡我們介紹兩種通用的模式,你可以依照這兩種模式重構程式碼,利用Lambda表示式帶來的靈活性,它們分別是:有條件的延遲執行和環繞執行。

  • 有條件的延遲執行

我們經常看到這樣的程式碼,控制語句被混雜在業務邏輯程式碼之中。典型的情況包括進行安全性檢查以及日誌輸出。比如,下面的這段程式碼,它使用了Java語言內建的 Logger 類:

if (logger.isLoggable(Log.FINER)){
    logger.finer("Problem: " + generateDiagnostic());
}
複製程式碼

這段程式碼有什麼問題嗎?其實問題不少。

    • 日誌器的狀態(它支援哪些日誌等級)通過 isLoggable 方法暴露給了客戶端程式碼。
    • 為什麼要在每次輸出一條日誌之前都去查詢日誌器物件的狀態?這隻能搞砸你的程式碼。更好的方案是使用 log 方法,該方法在輸出日誌訊息之前,會在內部檢查日誌物件是否已經設定為恰當的日誌等級:
logger.log(Level.FINER, "Problem: " + generateDiagnostic());
複製程式碼

這種方式更好的原因是你不再需要在程式碼中插入那些條件判斷,與此同時日誌器的狀態也不再被暴露出去。不過,這段程式碼依舊存在一個問題。日誌訊息的輸出與否每次都需要判斷,即使你已經傳遞了引數,不開啟日誌。

這就是Lambda表示式可以施展拳腳的地方。你需要做的僅僅是延遲訊息構造,如此一來,日誌就只會在某些特定的情況下才開啟(以此為例,當日志器的級別設定為 FINER 時)。顯然,Java 8的API設計者們已經意識到這個問題,並由此引入了一個對 log 方法的過載版本,這個版本的 log 方法接受一個 Supplier 作為引數。這個替代版本的 log 方法的函式簽名如下:

public void log(Level level, Supplier<String> msgSupplier)
複製程式碼

你可以通過下面的方式對它進行呼叫:

logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());
複製程式碼

如果日誌器的級別設定恰當, log 方法會在內部執行作為引數傳遞進來的Lambda表示式。這裡介紹的 Log 方法的內部實現如下:

public void log(Level level, Supplier<String> msgSupplier) {
    if(logger.isLoggable(level)){
        log(level, msgSupplier.get());
    }
}
複製程式碼

從這個故事裡我們學到了什麼呢?如果你發現你需要頻繁地從客戶端程式碼去查詢一個物件的狀態(比如前文例子中的日誌器的狀態),只是為了傳遞引數、呼叫該物件的一個方法(比如輸出一條日誌),那麼可以考慮實現一個新的方法,以Lambda或者方法表示式作為引數,新方法在檢查完該物件的狀態之後才呼叫原來的方法。你的程式碼會因此而變得更易讀(結構更清晰),封裝性更好(物件的狀態也不會暴露給客戶端程式碼了)。

通過這一節,你已經瞭解瞭如何通過不同方式來改善程式碼的可讀性和靈活性。接下來,你會了解Lambada表示式如何避免常規物件導向設計中的僵化的模板程式碼。

使用 Lambda 重構物件導向的設計模式

新的語言特性常常讓現存的程式設計模式或設計黯然失色。比如, Java 5中引入了 foreach 迴圈,由於它的穩健性和簡潔性,已經替代了很多顯式使用迭代器的情形。Java 7中推出的菱形操作符( <> )讓大家在建立例項時無需顯式使用泛型,一定程度上推動了Java程式設計師們採用型別介面(type interface)進行程式設計。

對設計經驗的歸納總結被稱為設計模式。設計軟體時,如果你願意,可以複用這些方式方法來解決一些常見問題。這看起來像傳統建築工程師的工作方式,對典型的場景(比如懸掛橋、拱橋等)都定義有可重用的解決方案。例如,訪問者模式常用於分離程式的演算法和它的操作物件。單例模式一般用於限制類的例項化,僅生成一份物件

Lambda表示式為程式設計師的工具箱又新添了一件利器。它們為解決傳統設計模式所面對的問題提供了新的解決方案,不但如此,採用這些方案往往更高效、更簡單。使用Lambda表示式後,很多現存的略顯臃腫的物件導向設計模式能夠用更精簡的方式實現了。這一節中,我們會針對五個設計模式展開討論,它們分別是:

  • 策略模式
  • 模板方法
  • 觀察者模式
  • 責任鏈模式
  • 工廠模式

我們會展示Lambda表示式是如何另闢蹊徑解決設計模式原來試圖解決的問題的。

策略模式

策略模式代表瞭解決一類演算法的通用解決方案,你可以在執行時選擇使用哪種方案。在第2章中你已經簡略地瞭解過這種模式了,當時我們介紹瞭如何使用不同的條件(比如蘋果的重量,或者顏色)來篩選庫存中的蘋果。你可以將這一模式應用到更廣泛的領域,比如使用不同的標準來驗證輸入的有效性,使用不同的方式來分析或者格式化輸入。

策略模式包含三部分內容:

  • 一個代表某個演算法的介面(它是策略模式的介面)。
  • 一個或多個該介面的具體實現,它們代表了演算法的多種實現(比如,實體類 ConcreteStrategyA 或者 ConcreteStrategyB )。
  • 一個或多個使用策略物件的客戶。

我們假設你希望驗證輸入的內容是否根據標準進行了恰當的格式化(比如只包含小寫字母或數字)。你可以從定義一個驗證文字(以 String 的形式表示)的介面入手:

interface ValidationStrategy {
    boolean execute(String s);
}
複製程式碼

其次,你定義了該介面的一個或多個具體實現:

static class IsAllLowerCase implements ValidationStrategy {

    @Override
    public boolean execute(String s) {
        return s.matches("[a-z]+");
    }
}

static class IsNumeric implements ValidationStrategy {

    @Override
    public boolean execute(String s) {
        return s.matches("\\d+");
    }
}
複製程式碼

之後,你就可以在你的程式中使用這些略有差異的驗證策略了:

private static class Validator {
    private final ValidationStrategy validationStrategy;

    public Validator(ValidationStrategy validationStrategy) {
        this.validationStrategy = validationStrategy;
    }

    public boolean validate(String s) {
        return validationStrategy.execute(s);
    }
}

Validator v1 = new Validator(new IsNumeric());
// false
System.out.println(v1.validate("aaaa"));
Validator v2 = new Validator(new IsAllLowerCase());
// true
System.out.println(v2.validate("bbbb"));
複製程式碼

使用Lambda表示式 到現在為止,你應該已經意識到 ValidationStrategy 是一個函式介面了(除此之外,它還與 Predicate 具有同樣的函式描述)。這意味著我們不需要宣告新的類來實現不同的策略,通過直接傳遞Lambda表示式就能達到同樣的目的,並且還更簡潔:

Validator v3 = new Validator((String s) -> s.matches("\\d+"));
System.out.println(v3.validate("aaaa"));
Validator v4 = new Validator((String s) -> s.matches("[a-z]+"));
System.out.println(v4.validate("bbbb"));
複製程式碼

正如你看到的,Lambda表示式避免了採用策略設計模式時僵化的模板程式碼。如果你仔細分析一下箇中緣由,可能會發現,Lambda表示式實際已經對部分程式碼(或策略)進行了封裝,而這就是建立策略設計模式的初衷。因此,我們強烈建議對類似的問題,你應該儘量使用Lambda表示式來解決。

模板方法

如果你需要採用某個演算法的框架,同時又希望有一定的靈活度,能對它的某些部分進行改進,那麼採用模板方法設計模式是比較通用的方案。好吧,這樣講聽起來有些抽象。換句話說,模板方法模式在你“希望使用這個演算法,但是需要對其中的某些行進行改進,才能達到希望的效果”時是非常有用的。

讓我們從一個例子著手,看看這個模式是如何工作的。假設你需要編寫一個簡單的線上銀行應用。通常,使用者需要輸入一個使用者賬戶,之後應用才能從銀行的資料庫中得到使用者的詳細資訊,最終完成一些讓使用者滿意的操作。不同分行的線上銀行應用讓客戶滿意的方式可能還略有不同,比如給客戶的賬戶發放紅利,或者僅僅是少傳送一些推廣檔案。你可能通過下面的抽象類方式來實現線上銀行應用:

public abstract class AbstractOnlineBanking {
    public void processCustomer(int id) {
        Customer customer = Database.getCustomerWithId(id);
        makeCustomerHappy(customer);
    }

    /**
     * 讓客戶滿意
     *
     * @param customer
     */
    abstract void makeCustomerHappy(Customer customer);

    private static class Customer {}

    private static class Database {
        static Customer getCustomerWithId(int id) {
            return new Customer();
        }
    }
}
複製程式碼

processCustomer 方法搭建了線上銀行演算法的框架:獲取客戶提供的ID,然後提供服務讓使用者滿意。不同的支行可以通過繼承 AbstractOnlineBanking 類,對該方法提供差異化的實現。

使用Lambda表示式 使用你偏愛的Lambda表示式同樣也可以解決這些問題(建立演算法框架,讓具體的實現插入某些部分)。你想要插入的不同演算法元件可以通過Lambda表示式或者方法引用的方式實現。

這裡我們向 processCustomer 方法引入了第二個引數,它是一個 Consumer 型別的引數,與前文定義的 makeCustomerHappy 的特徵保持一致:

public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
    Customer customer = Database.getCustomerWithId(id);
    makeCustomerHappy.accept(customer);
}
複製程式碼

現在,你可以很方便地通過傳遞Lambda表示式,直接插入不同的行為,不再需要繼承AbstractOnlineBanking 類了:

public static void main(String[] args) {
        new AbstractOnlineBankingLambda().processCustomer(1337, (
            AbstractOnlineBankingLambda.Customer c) -> System.out.println("Hello!"));
    }
複製程式碼

這是又一個例子,佐證了Lamba表示式能幫助你解決設計模式與生俱來的設計僵化問題。

觀察者模式

觀察者模式是一種比較常見的方案,某些事件發生時(比如狀態轉變),如果一個物件(通常我們稱之為主題)需要自動地通知其他多個物件(稱為觀察者),就會採用該方案。建立圖形使用者介面(GUI)程式時,你經常會使用該設計模式。這種情況下,你會在圖形使用者介面元件(比如按鈕)上註冊一系列的觀察者。如果點選按鈕,觀察者就會收到通知,並隨即執行某個特定的行為。 但是觀察者模式並不侷限於圖形使用者介面。比如,觀察者設計模式也適用於股票交易的情形,多個券商可能都希望對某一支股票價格(主題)的變動做出響應。

讓我們寫點兒程式碼來看看觀察者模式在實際中多麼有用。你需要為Twitter這樣的應用設計並實現一個定製化的通知系統。想法很簡單:好幾家報紙機構,比如《紐約時報》《衛報》以及《世界報》都訂閱了新聞,他們希望當接收的新聞中包含他們感興趣的關鍵字時,能得到特別通知。

首先,你需要一個觀察者介面,它將不同的觀察者聚合在一起。它僅有一個名為 notify 的方法,一旦接收到一條新的新聞,該方法就會被呼叫:

interface Observer{
    void inform(String tweet);
}
複製程式碼

現在,你可以宣告不同的觀察者(比如,這裡是三家不同的報紙機構),依據新聞中不同的關鍵字分別定義不同的行為:

private static class NYTimes implements Observer {

    @Override
    public void inform(String tweet) {
        if (tweet != null && tweet.contains("money")) {
            System.out.println("Breaking news in NY!" + tweet);
        }
    }
}

private static class Guardian implements Observer {

    @Override
    public void inform(String tweet) {
        if (tweet != null && tweet.contains("queen")) {
            System.out.println("Yet another news in London... " + tweet);
        }
    }
}

private static class LeMonde implements Observer {

    @Override
    public void inform(String tweet) {
        if(tweet != null && tweet.contains("wine")){
            System.out.println("Today cheese, wine and news! " + tweet);
        }
    }
}
複製程式碼

你還遺漏了最重要的部分: Subject !讓我們為它定義一個介面:

interface Subject {
    void registerObserver(Observer o);

    void notifyObserver(String tweet);
}
複製程式碼

Subject 使用 registerObserver 方法可以註冊一個新的觀察者,使用 notifyObservers方法通知它的觀察者一個新聞的到來。讓我們更進一步,實現 Feed 類:

private static class Feed implements Subject {
    private final List<Observer> observers = new ArrayList<>();

    @Override
    public void registerObserver(Observer o) {
        observers.add(o);
    }

    @Override
    public void notifyObserver(String tweet) {
        observers.forEach(o -> o.inform(tweet));
    }
}
複製程式碼

這是一個非常直觀的實現: Feed 類在內部維護了一個觀察者列表,一條新聞到達時,它就進行通知。

毫不意外,《衛報》會特別關注這條新聞!

使用Lambda表示式 你可能會疑惑Lambda表示式在觀察者設計模式中如何發揮它的作用。不知道你有沒有注意到, Observer 介面的所有實現類都提供了一個方法: inform 。新聞到達時,它們都只是對同一段程式碼封裝執行。Lambda表示式的設計初衷就是要消除這樣的僵化程式碼。使用Lambda表示式後,你無需顯式地例項化三個觀察者物件,直接傳遞Lambda表示式表示需要執行的行為即可:

Feed feedLambda = new Feed();
feedLambda.registerObserver((String tweet) -> {
    if (tweet != null && tweet.contains("money")) {
        System.out.println("Breaking news in NY!" + tweet);
    }
});

feedLambda.registerObserver((String tweet) -> {
    if (tweet != null && tweet.contains("queen")) {
        System.out.println("Yet another news in London... " + tweet);
    }
});

feedLambda.notifyObserver("Money money money, give me money!");
複製程式碼

那麼,是否我們隨時隨地都可以使用Lambda表示式呢?答案是否定的!我們前文介紹的例子中,Lambda適配得很好,那是因為需要執行的動作都很簡單,因此才能很方便地消除僵化程式碼。但是,觀察者的邏輯有可能十分複雜,它們可能還持有狀態,抑或定義了多個方法,諸如此類。在這些情形下,你還是應該繼續使用類的方式。

責任鏈模式

責任鏈模式是一種建立處理物件序列(比如操作序列)的通用方案。一個處理物件可能需要在完成一些工作之後,將結果傳遞給另一個物件,這個物件接著做一些工作,再轉交給下一個處理物件,以此類推。

通常,這種模式是通過定義一個代表處理物件的抽象類來實現的,在抽象類中會定義一個欄位來記錄後續物件。一旦物件完成它的工作,處理物件就會將它的工作轉交給它的後繼。程式碼中,這段邏輯看起來是下面這樣:

private static abstract class AbstractProcessingObject<T> {
    protected AbstractProcessingObject<T> successor;

    public void setSuccessor(AbstractProcessingObject<T> successor) {
        this.successor = successor;
    }

    public T handle(T input) {
        T r = handleWork(input);
        if (successor != null) {
            return successor.handle(r);
        }
        return r;
    }

    protected abstract T handleWork(T input);
}
複製程式碼

下面讓我們看看如何使用該設計模式。你可以建立兩個處理物件,它們的功能是進行一些文字處理工作。

private static class HeaderTextProcessing extends AbstractProcessingObject<String> {

    @Override
    protected String handleWork(String text) {
        return "From Raoul, Mario and Alan: " + text;
    }
}

private static class SpellCheckerProcessing extends AbstractProcessingObject<String> {

    @Override
    protected String handleWork(String text) {
        return text.replaceAll("labda", "lambda");
    }
}
複製程式碼

現在你就可以將這兩個處理物件結合起來,構造一個操作序列!

AbstractProcessingObject<String> p1 = new HeaderTextProcessing();
AbstractProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSuccessor(p2);
String result = p1.handle("Aren't labdas really sexy?!!");
System.out.println(result);
複製程式碼

使用Lambda表示式 稍等!這個模式看起來像是在連結(也即是構造) 函式。第3章中我們探討過如何構造Lambda表示式。你可以將處理物件作為函式的一個例項,或者更確切地說作為 UnaryOperator 的一個例項。為了連結這些函式,你需要使用 andThen 方法對其進行構造。

UnaryOperator<String> headerProcessing =
            (String text) -> "From Raoul, Mario and Alan: " + text;

UnaryOperator<String> spellCheckerProcessing =
        (String text) -> text.replaceAll("labda", "lambda");

Function<String, String> pipeline = headerProcessing.andThen(spellCheckerProcessing);

String result2 = pipeline.apply("Aren't labdas really sexy?!!");
System.out.println(result2);
複製程式碼

工廠模式

使用工廠模式,你無需向客戶暴露例項化的邏輯就能完成物件的建立。比如,我們假定你為一家銀行工作,他們需要一種方式建立不同的金融產品:貸款、期權、股票,等等。

通常,你會建立一個工廠類,它包含一個負責實現不同物件的方法,如下所示:

private interface Product {
}

private static class ProductFactory {
    public static Product createProduct(String name) {
        switch (name) {
            case "loan":
                return new Loan();
            case "stock":
                return new Stock();
            case "bond":
                return new Bond();
            default:
                throw new RuntimeException("No such product " + name);
        }
    }
}

static private class Loan implements Product {
}

static private class Stock implements Product {
}

static private class Bond implements Product {
}
複製程式碼

這裡貸款( Loan )、股票( Stock )和債券( Bond )都是產品( Product )的子類。createProduct 方法可以通過附加的邏輯來設定每個建立的產品。但是帶來的好處也顯而易見,你在建立物件時不用再擔心會將建構函式或者配置暴露給客戶,這使得客戶建立產品時更加簡單:

Product p1 = ProductFactory.createProduct("loan");
複製程式碼

使用Lambda表示式 第3章中,我們已經知道可以像引用方法一樣引用建構函式。比如,下面就是一個引用貸款( Loan )建構函式的示例:

Supplier<Product> loanSupplier = Loan::new;
Product p2 = loanSupplier.get();
複製程式碼

通過這種方式,你可以重構之前的程式碼,建立一個 Map ,將產品名對映到對應的建構函式:

final static private Map<String, Supplier<Product>> map = new HashMap<>();

static {
    map.put("loan", Loan::new);
    map.put("stock", Stock::new);
    map.put("bond", Bond::new);
}
複製程式碼

現在,你可以像之前使用工廠設計模式那樣,利用這個 Map 來例項化不同的產品。

public static Product createProductLambda(String name) {
    Supplier<Product> p = map.get(name);
    if (p != null) {
        return p.get();
    }
    throw new RuntimeException("No such product " + name);
}
複製程式碼

這是個全新的嘗試,它使用Java 8中的新特性達到了傳統工廠模式同樣的效果。但是,如果工廠方法 createProduct 需要接收多個傳遞給產品構造方法的引數,這種方式的擴充套件性不是很好。你不得不提供不同的函式介面,無法採用之前統一使用一個簡單介面的方式。

比如,我們假設你希望儲存具有三個引數(兩個引數為 Integer 型別,一個引數為 String型別)的建構函式;為了完成這個任務,你需要建立一個特殊的函式介面 TriFunction 。最終的結果是 Map 變得更加複雜。

public interface TriFunction<T, U, V, R>{
    R apply(T t, U u, V v);
}
Map<String, TriFunction<Integer, Integer, String, Product>> map = new HashMap<>();
複製程式碼

你已經瞭解瞭如何使用Lambda表示式編寫和重構程式碼。接下來,我們會介紹如何確保新編 寫程式碼的正確性。

測試 Lambda 表示式

現在你的程式碼中已經充溢著Lambda表示式,看起來不錯,也很簡潔。但是,大多數時候,我們受僱進行的程式開發工作的要求並不是編寫優美的程式碼,而是編寫正確的程式碼。

通常而言,好的軟體工程實踐一定少不了單元測試,藉此保證程式的行為與預期一致。你編寫測試用例,通過這些測試用例確保你程式碼中的每個組成部分都實現預期的結果。比如,圖形應用的一個簡單的 Point 類,可以定義如下:

public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    public Point moveRightBy(int x) {
        return new Point(this.x + x, this.y);
    }
}
複製程式碼

下面的單元測試會檢查 moveRightBy 方法的行為是否與預期一致:

public class PointTest {

    @Test
    public void testMoveRightBy() {
        Point p1 = new Point(5, 5);
        Point p2 = p1.moveRightBy(10);

        Assert.assertEquals(15, p2.getX());
        Assert.assertEquals(5, p2.getY());
    }
}
複製程式碼

測試可見 Lambda 函式的行為

由於 moveRightBy 方法宣告為public,測試工作變得相對容易。你可以在用例內部完成測試。但是Lambda並無函式名(畢竟它們都是匿名函式),因此要對你程式碼中的Lambda函式進行測試實際上比較困難,因為你無法通過函式名的方式呼叫它們。

有些時候,你可以藉助某個欄位訪問Lambda函式,這種情況,你可以利用這些欄位,通過它們對封裝在Lambda函式內的邏輯進行測試。比如,我們假設你在 Point 類中新增了靜態欄位compareByXAndThenY ,通過該欄位,使用方法引用你可以訪問 Comparator 物件:

public class Point {
    public final static Comparator<Point> COMPARE_BY_X_AND_THEN_Y =
            comparing(Point::getX).thenComparing(Point::getY);
    ...
}
複製程式碼

還記得嗎,Lambda表示式會生成函式介面的一個例項。由此,你可以測試該例項的行為。這個例子中,我們可以使用不同的引數,對 Comparator 物件型別例項 compareByXAndThenY的 compare 方法進行呼叫,驗證它們的行為是否符合預期:

@Test
public void testComparingTwoPoints() {
    Point p1 = new Point(10, 15);
    Point p2 = new Point(10, 20);
    int result = Point.COMPARE_BY_X_AND_THEN_Y.compare(p1 , p2);
    Assert.assertEquals(-1, result);
}
複製程式碼

測試使用 Lambda 的方法的行為

但是Lambda的初衷是將一部分邏輯封裝起來給另一個方法使用。從這個角度出發,你不應該將Lambda表示式宣告為public,它們僅是具體的實現細節。相反,我們需要對使用Lambda表示式的方法進行測試。比如下面這個方法 moveAllPointsRightBy :

public static List<Point> moveAllPointsRightBy(List<Point> points, int x) {
    return points.stream()
            .map(p -> new Point(p.getX() + x, p.getY()))
            .collect(toList());
}
複製程式碼

我們沒必要對Lambda表示式 p -> new Point(p.getX() + x,p.getY()) 進行測試,它只是 moveAllPointsRightBy 內部的實現細節。我們更應該關注的是方法 moveAllPointsRightBy 的行為:

@Test
public void testMoveAllPointsRightBy() {
    List<Point> points =
            Arrays.asList(new Point(5, 5), new Point(10, 5));
    List<Point> expectedPoints =
            Arrays.asList(new Point(15, 5), new Point(20, 5));
    List<Point> newPoints = Point.moveAllPointsRightBy(points, 10);
    Assert.assertEquals(expectedPoints, newPoints);
}
複製程式碼

注意,上面的單元測試中, Point 類恰當地實現 equals 方法非常重要,否則該測試的結果就取決於 Object 類的預設實現。

除錯

除錯有問題的程式碼時,程式設計師的兵器庫裡有兩大老式武器,分別是:

  • 檢視棧跟蹤
  • 輸出日誌

檢視棧跟蹤

你的程式突然停止執行(比如突然丟擲一個異常),這時你首先要調查程式在什麼地方發生了異常以及為什麼會發生該異常。這時棧幀就非常有用。程式的每次方法呼叫都會產生相應的呼叫資訊,包括程式中方法呼叫的位置、該方法呼叫使用的引數、被呼叫方法的本地變數。這些資訊被儲存在棧幀上。

程式失敗時,你會得到它的棧跟蹤,通過一個又一個棧幀,你可以瞭解程式失敗時的概略資訊。換句話說,通過這些你能得到程式失敗時的方法呼叫列表。這些方法呼叫列表最終會幫助你發現問題出現的原因。

Lambda表示式和棧跟蹤 不幸的是,由於Lambda表示式沒有名字,它的棧跟蹤可能很難分析。在下面這段簡單的程式碼中,我們刻意地引入了一些錯誤:

public class Debugging {
    public static void main(String[] args) {
        List<Point> points = Arrays.asList(new Point(12, 2), null);
        points.stream().map(p -> p.getX()).forEach(System.out::println);
    }
}
複製程式碼

執行這段程式碼會產生下面的棧跟蹤:

12
Exception in thread "main" java.lang.NullPointerException
    // 這行中的 $0 是什麼意思?
	at xin.codedream.java8.chap8.Debugging.lambda$main$0(Debugging.java:15)
	at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
	at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
	...
複製程式碼

這個程式出現了NPE(空指標異常)異常,因為 Points 列表的第二個元素是空( null )。

這時你的程式實際是在試圖處理一個空引用。由於Stream流水線發生了錯誤,構成Stream流水線的整個方法呼叫序列都暴露在你面前了。不過,你留意到了嗎?棧跟蹤中還包含下面這樣類似加密的內容:

at xin.codedream.java8.chap8.Debugging.lambda$main$0(Debugging.java:15)
	at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
複製程式碼

這些表示錯誤發生在Lambda表示式內部。由於Lambda表示式沒有名字,所以編譯器只能為它們指定一個名字。這個例子中,它的名字是 lambda$main$0 ,看起來非常不直觀。如果你使用了大量的類,其中又包含多個Lambda表示式,這就成了一個非常頭痛的問題。

即使你使用了方法引用,還是有可能出現棧無法顯示你使用的方法名的情況。將之前的Lambda表示式 p-> p.getX() 替換為方法引用 reference Point::getX 也會產生難於分析的棧跟蹤:

at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
複製程式碼

注意,如果方法引用指向的是同一個類中宣告的方法,那麼它的名稱是可以在棧跟蹤中顯示的。比如,下面這個例子:

public class Debugging {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3);
        numbers.stream().map(Debugging::divideByZero).forEach(System
                .out::println);
    }

    public static int divideByZero(int n) {
        return n / 0;
    }
}
複製程式碼

方法 divideByZero 在棧跟蹤中就正確地顯示了:

Exception in thread "main" java.lang.ArithmeticException: / by zero
    // divideByZero正確地輸出到棧跟蹤中
	at xin.codedream.java8.chap8.Debugging.divideByZero(Debugging.java:20)
	at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
	...
複製程式碼

總的來說,我們需要特別注意,涉及Lambda表示式的棧跟蹤可能非常難理解。這是Java編譯器未來版本可以改進的一個方面。

使用日誌除錯

假設你試圖對流操作中的流水線進行除錯,該從何入手呢?你可以像下面的例子那樣,使用forEach 將流操作的結果日誌輸出到螢幕上或者記錄到日誌檔案中:

List<Integer> numbers = Arrays.asList(2, 3, 4, 5);
numbers.stream()
            .map(x -> x + 17)
            .filter(x -> x % 2 == 0)
            .limit(3)
            .forEach(System.out::println);
複製程式碼

這段程式碼的輸出如下:

20
22
複製程式碼

不幸的是,一旦呼叫 forEach ,整個流就會恢復執行。到底哪種方式能更有效地幫助我們理解Stream流水線中的每個操作(比如 map 、 filter 、 limit )產生的輸出?

這就是流操作方法 peek 大顯身手的時候。 peek 的設計初衷就是在流的每個元素恢復執行之前,插入執行一個動作。但是它不像 forEach 那樣恢復整個流的執行,而是在一個元素上完操作之後,它只會將操作順承到流水線中的下一個操作。下面的這段程式碼中,我們使用 peek 輸出了Stream流水線操作之前和操作之後的中間值:

List<Integer> result = Stream.of(2, 3, 4, 5)
                .peek(x -> System.out.println("taking from stream: " + x)).map(x -> x + 17)
                .peek(x -> System.out.println("after map: " + x)).filter(x -> x % 2 == 0)
                .peek(x -> System.out.println("after filter: " + x)).limit(3)
                .peek(x -> System.out.println("after limit: " + x)).collect(toList());
複製程式碼

通過 peek 操作我們能清楚地瞭解流水線操作中每一步的輸出結果:

taking from stream: 2
after map: 19
taking from stream: 3
after map: 20
after filter: 20
after limit: 20
taking from stream: 4
after map: 21
taking from stream: 5
after map: 22
after filter: 22
after limit: 22
複製程式碼

小結

  • Lambda表示式能提升程式碼的可讀性和靈活性。
  • 如果你的程式碼中使用了匿名類,儘量用Lambda表示式替換它們,但是要注意二者間語義的微妙差別,比如關鍵字 this ,以及變數隱藏。
  • 跟Lambda表示式比起來,方法引用的可讀性更好 。
  • 儘量使用Stream API替換迭代式的集合處理。
  • Lambda表示式有助於避免使用物件導向設計模式時容易出現的僵化的模板程式碼,典型的比如策略模式、模板方法、觀察者模式、責任鏈模式,以及工廠模式。
  • 即使採用了Lambda表示式,也同樣可以進行單元測試,但是通常你應該關注使用了Lambda表示式的方法的行為。
  • 儘量將複雜的Lambda表示式抽象到普通方法中。
  • Lambda表示式會讓棧跟蹤的分析變得更為複雜。
  • 流提供的 peek 方法在分析Stream流水線時,能將中間變數的值輸出到日誌中,是非常有用的工具。

程式碼

Github:chap8

Gitee:chap8

相關文章