讀古今文學網 > Java 8實戰 > 第8章 重構、測試和調試 >

第8章 重構、測試和調試

本章內容

  • 如何使用Lambda表達式重構代碼

  • Lambda表達式對面向對象的設計模式的影響

  • Lambda表達式的測試

  • 如何調試使用Lambda表達式和Stream API的代碼

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

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

8.1 為改善可讀性和靈活性重構代碼

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

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

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

8.1.1 改善代碼的可讀性

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

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

  • 使用Java 8,你可以減少冗長的代碼,讓代碼更易於理解

  • 通過方法引用和Stream API,你的代碼會變得更直觀

這裡我們會介紹三種簡單的重構,利用Lambda表達式、方法引用以及Stream改善程序代碼的可讀性: * 重構代碼,用Lambda表達式取代匿名類

  • 用方法引用重構Lambda表達式

  • 用Stream API重構命令式的數據處理

8.1.2 從匿名類到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表達式可能是一個比較複雜的過程1。 首先,匿名類和Lambda表達式中的thissuper的含義是不同的。在匿名類中,this代表的是類自身,但是在Lambda中,它代表的是包含類。其次,匿名類可以屏蔽包含類的變量,而Lambda表達式不能(它們會導致編譯錯誤),譬如下面這段代碼:

1這篇文章對轉換的整個過程進行了深入細緻的描述,值得一讀:http://dig.cs.illinois.edu/papers/lambda-Refactoring.pdf。

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表達式時,就導致了一種晦澀的方法調用,因為RunnableTask都是合法的目標類型:

doSomething( -> System.out.println("Danger danger!!"));    ←─麻煩來了: doSomething(Runnable) 和doSomething(Task)都匹配該類型

  

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

doSomething((Task) -> System.out.println("Danger danger!!"));

  

但是不要因此而放棄對Lambda的嘗試。好消息是,目前大多數的集成開發環境,比如NetBeans和IntelliJ都支持這種重構,它們能自動地幫你檢查,避免發生這些問題。

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

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

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

  

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

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
    menu.stream.collect(groupingBy(Dish::getCaloricLevel));    ←─將Lambda 表達式抽取到一個方法內

  

為了實現這個方案,你還需要在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;
    }
}

  

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

inventory.sort(
    (Apple a1, Apple a2) -> a1.getWeight.compareTo(a2.getWeight));    ←─你需要考慮如何實現比較算法

inventory.sort(comparing(Apple::getWeight));    ←─讀起來就像問題描述,非常清晰

  

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

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

  

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

int totalCalories = menu.stream.collect(summingInt(Dish::getCalories));

  

8.1.4 從命令式的數據處理切換到Stream

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

比如,下面的命令式代碼使用了兩種模式:篩選和抽取,這兩種模式被混在了一起,這樣的代碼結構迫使程序員必須徹底搞清楚程序的每個細節才能理解代碼的功能。此外,實現需要並行運行的程序所面對的困難也多得多(具體細節可以參考7.2節的分支/合併框架):

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的形式是個困難的任務,因為你需要考慮控制流語句,比如breakcontinuereturn,並選擇使用恰當的流操作。好消息是已經有一些工具可以幫助我們完成這個任務2。

2請參考http://refactoring.info/tools/LambdaFicator/。

8.1.5 增加代碼的靈活性

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

1. 採用函數接口

首先,你必須意識到,沒有函數接口,你就無法使用Lambda表達式。因此,你需要在代碼中引入函數接口。聽起來很合理,但是在什麼情況下使用它們呢?這裡我們介紹兩種通用的模式,你可以依照這兩種模式重構代碼,利用Lambda表達式帶來的靈活性,它們分別是:有條件的延遲執行和環繞執行。除此之外,在下一節,我們還將介紹一些基於面向對象的設計模式,比如策略模式或者模板方法,這些在使用Lambda表達式重寫後會更簡潔。

2. 有條件的延遲執行

我們經常看到這樣的代碼,控制語句被混雜在業務邏輯代碼之中。典型的情況包括進行安全性檢查以及日誌輸出。比如,下面的這段代碼,它使用了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表達式
    }
}

  

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

3. 環繞執行

第3章中,我們介紹過另一種值得考慮的模式,那就是環繞執行。如果你發現雖然你的業務代碼千差萬別,但是它們擁有同樣的準備和清理階段,這時,你完全可以將這部分代碼用Lambda實現。這種方式的好處是可以重用準備和清理階段的邏輯,減少重複冗余的代碼。下面這段代碼你在第3章中已經看過,我們再回顧一次。它在打開和關閉文件時使用了同樣的邏輯,但在處理文件時可以使用不同的Lambda進行參數化。

String oneLine =
    processFile((BufferedReader b) -> b.readLine);    ←─傳入一個Lambda表達式
String twoLines =
    processFile((BufferedReader b) -> b.readLine + b.readLine);    ←─傳入另一個Lambda表達式

public static String processFile(BufferedReaderProcessor p) throws
     IOException {
    try(BufferedReader br = new BufferedReader(new FileReader("java8inaction/
     chap8/data.txt"))){
        return p.process(br);    ←─將BufferedReaderProcessor 作為執行參數傳入
    }
}

public interface BufferedReaderProcessor{    ←─使用Lambda表達式的函數接口,該接口能夠拋出一個IOException
    String process(BufferedReader b) throws IOException;
}

  

這一優化是憑借函數式接口BufferedReaderProcessor達成的,通過這個接口,你可以傳遞各種Lamba表達式對BufferedReader對像進行處理。

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

8.2 使用Lambda重構面向對象的設計模式

新的語言特性常常讓現存的編程模式或設計黯然失色。比如, Java 5中引入了for-each循環,由於它的穩健性和簡潔性,已經替代了很多顯式使用迭代器的情形。Java 7中推出的菱形操作符(<>)讓大家在創建實例時無需顯式使用泛型,一定程度上推動了Java程序員們採用類型接口(type interface)進行程序設計。

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

3參見http://c2.com/cgi/wiki?GangOfFour。

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

  • 策略模式

  • 模板方法

  • 觀察者模式

  • 責任鏈模式

  • 工廠模式

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

8.2.1 策略模式

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

策略模式包含三部分內容,如圖8-1所示。

  • 一個代表某個算法的接口(它是策略模式的接口)。

  • 一個或多個該接口的具體實現,它們代表了算法的多種實現(比如,實體類ConcreteStrategyA或者ConcreteStrategyB)。

  • 一個或多個使用策略對象的客戶。

圖 8-1 策略模式

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

public interface ValidationStrategy {
    boolean execute(String s);
}

  

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

public class IsAllLowerCase implements ValidationStrategy {
    public boolean execute(String s){
        return s.matches("[a-z]+");
    }
}

public class IsNumeric implements ValidationStrategy {
    public boolean execute(String s){
        return s.matches("\\d+");
    }
}

  

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

public class Validator{
    private final ValidationStrategy strategy;

    public Validator(ValidationStrategy v){
        this.strategy = v;
    }

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

Validator numericValidator = new Validator(new IsNumeric);
boolean b1 = numericValidator.validate("aaaa");    ←─返回false
Validator lowerCaseValidator = new Validator(new IsAllLowerCase );
boolean b2 = lowerCaseValidator.validate("bbbb");    ←─返回true

  

使用Lambda表達式

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

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

8.2.2 模板方法

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

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

abstract class OnlineBanking {

    public void processCustomer(int id){
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy(c);
    }

    abstract void makeCustomerHappy(Customer c);
}

  

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

使用Lambda表達式

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

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

public void processCustomer(int id, Consumer<Customer> makeCustomerHappy){
    Customer c = Database.getCustomerWithId(id);
    makeCustomerHappy.accept(c);
}

  

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

new OnlineBankingLambda.processCustomer(1337, (Customer c) ->
    System.out.println("Hello " + c.getName);

  

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

8.2.3 觀察者模式

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

圖 8-2 觀察者設計模式

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

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

interface Observer {
    void notify(String tweet);
}

  

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

class NYTimes implements Observer{
    public void notify(String tweet) {
        if(tweet != null && tweet.contains("money")){
            System.out.println("Breaking news in NY! " + tweet);
        }
    }
}
class Guardian implements Observer{
    public void notify(String tweet) {
        if(tweet != null && tweet.contains("queen")){
            System.out.println("Yet another news in London... " + tweet);
        }
    }
}
class LeMonde implements Observer{
    public void notify(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 notifyObservers(String tweet);
}

  

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

class Feed implements Subject{

    private final List<Observer> observers = new ArrayList<>;

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

    public void notifyObservers(String tweet) {
        observers.forEach(o -> o.notify(tweet));
    }
}

  

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

Feed f = new Feed;
f.registerObserver(new NYTimes);
f.registerObserver(new Guardian);
f.registerObserver(new LeMonde);
f.notifyObservers("The queen said her favourite book is Java 8 in Action!");

  

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

使用Lambda表達式

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

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

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

  

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

8.2.4 責任鏈模式

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

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

public abstract class ProcessingObject<T> {

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

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

    abstract protected T handleWork(T input);
}

  

圖8-3以UML的方式闡釋了責任鏈模式。

圖 8-3 責任鏈設計模式

可能你已經注意到,這就是8.2.2節介紹的模板方法設計模式。handle方法提供了如何進行工作處理的框架。不同的處理對象可以通過繼承ProcessingObject類,提供handleWork方法來進行創建。

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

public class HeaderTextProcessing extends ProcessingObject<String> {
    public String handleWork(String text){
        return "From Raoul, Mario and Alan: " + text;
    }
}

public class SpellCheckerProcessing extends ProcessingObject<String> {
    public String handleWork(String text){
        return text.replaceAll("labda", "lambda");    ←─糟糕,我們漏掉了Lambda中的m字符
    }
}

  

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

ProcessingObject<String> p1 = new HeaderTextProcessing;
ProcessingObject<String> p2 = new SpellCheckerProcessing;

p1.setSuccessor(p2);                               ←─將兩個處理對像鏈接起來

String result = p1.handle("Aren't labdas really sexy?!!");
System.out.println(result);                        ←─打印輸出“From Raoul, Marioand Alan: Aren't lambdas reallysexy?!!”

  

使用Lambda表達式

稍等!這個模式看起來像是在鏈接(也即是構造) 函數。第3章中我們探討過如何構造Lambda表達式。你可以將處理對像作為函數的一個實例,或者更確切地說作為UnaryOperator<String>的一個實例。為了鏈接這些函數,你需要使用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 result = pipeline.apply("Aren't labdas really sexy?!!");

  

8.2.5 工廠模式

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

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

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

  

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

Product p = ProductFactory.createProduct("loan");

  

使用Lambda表達式

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

Supplier<Product> loanSupplier = Loan::new;
Loan loan = loanSupplier.get;

  

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

final static 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 createProduct(String name){
    Supplier<Product> p = map.get(name);
    if(p != null) return p.get;
    throw new IllegalArgumentException("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表達式編寫和重構代碼。接下來,我們會介紹如何確保新編寫代碼的正確性。

8.3 測試Lambda表達式

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

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

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

    private 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方法的行為是否與預期一致:

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

    assertEquals(15, p2.getX);
    assertEquals(5, p2.getY);
}

  

8.3.1 測試可見Lambda函數的行為

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

有些時候,你可以借助某個字段訪問Lambda函數,這種情況,你可以利用這些字段,通過它們對封裝在Lambda函數內的邏輯進行測試。比如,我們假設你在Point類中添加了靜態字段compareByXAndThenY,通過該字段,使用方法引用你可以訪問Comparator對像:

public class Point{
    public final static Comparator<Point> compareByXAndThenY =
        comparing(Point::getX).thenComparing(Point::getY);
    …
}

  

還記得嗎,Lambda表達式會生成函數接口的一個實例。由此,你可以測試該實例的行為。這個例子中,我們可以使用不同的參數,對Comparator對像類型實例compareByXAndThenYcompare方法進行調用,驗證它們的行為是否符合預期:

@Test
public void testComparingTwoPoints throws Exception {
    Point p1 = new Point(10, 15);
    Point p2 = new Point(10, 20);
    int result = Point.compareByXAndThenY.compare(p1 , p2);
    assertEquals(-1, result);
}

  

8.3.2 測試使用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 throws Exception{
    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);
    assertEquals(expectedPoints, newPoints);
}

  

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

8.3.3 將複雜的Lambda表達式分到不同的方法

可能你會碰到非常複雜的Lambda表達式,包含大量的業務邏輯,比如需要處理複雜情況的定價算法。你無法在測試程序中引用Lambda表達式,這種情況該如何處理呢?一種策略是將Lambda表達式轉換為方法引用(這時你往往需要聲明一個新的常規方法),我們在8.1.3節詳細討論過這種情況。這之後,你可以用常規的方式對新的方法進行測試。

8.3.4 高階函數的測試

接受函數作為參數的方法或者返回一個函數的方法(所謂的“高階函數”,higher-order function,我們在第14章會深入展開介紹)更難測試。如果一個方法接受Lambda表達式作為參數,你可以採用的一個方案是使用不同的Lambda表達式對它進行測試。比如,你可以使用不同的謂詞對第2章中創建的filter方法進行測試。

@Test
public void testFilter throws Exception{
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
    List<Integer> even = filter(numbers, i -> i % 2 == 0);
    List<Integer> smallerThanThree = filter(numbers, i -> i < 3);
    assertEquals(Arrays.asList(2, 4), even);
    assertEquals(Arrays.asList(1, 2), smallerThanThree);
}

  

如果被測試方法的返回值是另一個方法,該如何處理呢?你可以仿照我們之前處理Comparator的方法,把它當成一個函數接口,對它的功能進行測試。

然而,事情可能不會一帆風順,你的測試可能會返回錯誤,報告說你使用Lambda表達式的方式不對。因此,我們現在進入調試的環節。

8.4 調試

調試有問題的代碼時,程序員的兵器庫裡有兩大老式武器,分別是:

  • 查看棧跟蹤

  • 輸出日誌

8.4.1 查看棧跟蹤

你的程序突然停止運行(比如突然拋出一個異常),這時你首先要調查程序在什麼地方發生了異常以及為什麼會發生該異常。這時棧幀就非常有用。程序的每次方法調用都會產生相應的調用信息,包括程序中方法調用的位置、該方法調用使用的參數、被調用方法的本地變量。這些信息被保存在棧幀上。

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

Lambda表達式和棧跟蹤

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

import java.util.*;

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

  

運行這段代碼會產生下面的棧跟蹤:

Exception in thread "main" java.lang.NullPointerException
    at Debugging.lambda$main$0(Debugging.java:6)    ←─這行中的$0是什麼意思?
    at Debugging$$Lambda$5/284720968.apply(Unknown Source)
    at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
      .java:193)
    at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators
      .java:948)
…

  

討厭!發生了什麼?這段程序當然會失敗,因為Points列表的第二個元素是空(null)。這時你的程序實際是在試圖處理一個空引用。由於Stream流水線發生了錯誤,構成Stream流水線的整個方法調用序列都暴露在你面前了。不過,你留意到了嗎?棧跟蹤中還包含下面這樣類似加密的內容:

at Debugging.lambda$main$0(Debugging.java:6)
    at Debugging$$Lambda$5/284720968.apply(Unknown Source)

  

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

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

points.stream.map(Point::getX).forEach(System.out::println);

Exception in thread "main" java.lang.NullPointerException
    at Debugging$$Lambda$5/284720968.apply(Unknown Source)    ←─這一行表示什麼呢?
    at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
       .java:193)
…

  

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

import java.util.*;

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

    public static int pideByZero(int n){
        return n / 0;
    }
}

  

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

Exception in thread "main" java.lang.ArithmeticException: / by zero
    at Debugging.pideByZero(Debugging.java:10)    ←─pideByZero正確地輸出到棧跟蹤中
    at Debugging$$Lambda$1/999966131.apply(Unknown Source)
    at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline
       .java:193)
…

  

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

8.4.2 使用日誌調試

假設你試圖對流操作中的流水線進行調試,該從何入手呢?你可以像下面的例子那樣,使用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流水線中的每個操作(比如mapfilterlimit)產生的輸出?

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

List<Integer> result =
  numbers.stream
         .peek(x -> System.out.println("from stream: " + x))    ←─輸出來自數據源的當前元素值
         .map(x -> x + 17)
         .peek(x -> System.out.println("after map: " + x))    ←─輸出map 操作的結果
         .filter(x -> x % 2 == 0)
         .peek(x -> System.out.println("after filter: " + x))    ←─輸出經過filter操作之後,剩下的元素個數
         .limit(3)
         .peek(x -> System.out.println("after limit: " + x))    ←─輸出經過limit操作之後,剩下的元素個數
         .collect(toList);

  

圖 8-4 使用peek查看Stream流水線中的數據流的值

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

from stream: 2
after map: 19
from stream: 3
after map: 20
after filter: 20
after limit: 20
from stream: 4
after map: 21
from stream: 5
after map: 22
after filter: 22
after limit: 22

  

8.5 小結

下面回顧一下這一章的主要內容。

  • Lambda表達式能提升代碼的可讀性和靈活性。

  • 如果你的代碼中使用了匿名類,盡量用Lambda表達式替換它們,但是要注意二者間語義的微妙差別,比如關鍵字this,以及變量隱藏。

  • 跟Lambda表達式比起來,方法引用的可讀性更好 。

  • 盡量使用Stream API替換迭代式的集合處理。

  • Lambda表達式有助於避免使用面向對像設計模式時容易出現的僵化的模板代碼,典型的比如策略模式、模板方法、觀察者模式、責任鏈模式,以及工廠模式。

  • 即使採用了Lambda表達式,也同樣可以進行單元測試,但是通常你應該關注使用了Lambda表達式的方法的行為。

  • 盡量將複雜的Lambda表達式抽像到普通方法中。

  • Lambda表達式會讓棧跟蹤的分析變得更為複雜。

  • 流提供的peek方法在分析Stream流水線時,能將中間變量的值輸出到日誌中,是非常有用的工具。