關於Java Lambda表達式看這一篇就夠了

Java Lambda表達式的一個重要用法是簡化某些匿名內部類Anonymous Classes)的寫法。實際上Lambda表達式並不僅僅是匿名內部類的語法糖,JVM內部是通過invokedynamic指令來實現Lambda表達式的。具體原理放到下一篇。本篇我們首先感受一下使用Lambda表達式帶來的便利之處。

Lambda and Anonymous Classes(I)

本節將介紹如何使用Lambda表達式簡化匿名內部類的書寫,但Lambda表達式並不能取代所有的匿名內部類,只能用來取代函數接口(Functional Interface)的簡寫。先別在乎細節,看幾個例子再說。

例子1:無參函數的簡寫

如果需要新建一個線程,一種常見的寫法是這樣:

// JDK7 匿名內部類寫法new Thread(new Runnable(){// 接口名
	@Override
	public void run(){// 方法名
		System.out.println("Thread run()");
	}}).start();

上述代碼給Tread類傳遞了一個匿名的Runnable對象,重載Runnable接口的run()方法來實現相應邏輯。這是JDK7以及之前的常見寫法。匿名內部類省去了爲類起名字的煩惱,但還是不夠簡化,在Java 8中可以簡化爲如下形式:

// JDK8 Lambda表達式寫法new Thread(
		() -> System.out.println("Thread run()")// 省略接口名和方法名).start();

上述代碼跟匿名內部類的作用是一樣的,但比匿名內部類更進一步。這裏連接口名和函數名都一同省掉了,寫起來更加神清氣爽。如果函數體有多行,可以用大括號括起來,就像這樣:

// JDK8 Lambda表達式代碼塊寫法new Thread(
        () -> {
            System.out.print("Hello");
            System.out.println(" Hoolee");
        }).start();

例子2:帶參函數的簡寫

如果要給一個字符串列表通過自定義比較器,按照字符串長度進行排序,Java 7的書寫形式如下:

// JDK7 匿名內部類寫法List<String> list = Arrays.asList("I", "love", "you", "too");Collections.sort(list, new Comparator<String>(){// 接口名
    @Override
    public int compare(String s1, String s2){// 方法名
        if(s1 == null)
            return -1;
        if(s2 == null)
            return 1;
        return s1.length()-s2.length();
    }});

上述代碼通過內部類重載了Comparator接口的compare()方法,實現比較邏輯。採用Lambda表達式可簡寫如下:

// JDK8 Lambda表達式寫法List<String> list = Arrays.asList("I", "love", "you", "too");Collections.sort(list, (s1, s2) ->{// 省略參數表的類型
    if(s1 == null)
        return -1;
    if(s2 == null)
        return 1;
    return s1.length()-s2.length();});

上述代碼跟匿名內部類的作用是一樣的。除了省略了接口名和方法名,代碼中把參數表的類型也省略了。這得益於javac類型推斷機制,編譯器能夠根據上下文信息推斷出參數的類型,當然也有推斷失敗的時候,這時就需要手動指明參數類型了。注意,Java是強類型語言,每個變量和對象都必需有明確的類型。

簡寫的依據

也許你已經想到了,能夠使用Lambda的依據是必須有相應的函數接口(函數接口,是指內部只有一個抽象方法的接口)。這一點跟Java是強類型語言吻合,也就是說你並不能在代碼的任何地方任性的寫Lambda表達式。實際上Lambda的類型就是對應函數接口的類型Lambda表達式另一個依據是類型推斷機制,在上下文信息足夠的情況下,編譯器可以推斷出參數表的類型,而不需要顯式指名。Lambda表達更多合法的書寫形式如下:

// Lambda表達式的書寫形式Runnable run = () -> System.out.println("Hello World");// 1ActionListener listener = event -> System.out.println("button clicked");// 2Runnable multiLine = () -> {// 3 代碼塊
    System.out.print("Hello");
    System.out.println(" Hoolee");};BinaryOperator<Long> add = (Long x, Long y) -> x + y;// 4BinaryOperator<Long> addImplicit = (x, y) -> x + y;// 5 類型推斷

上述代碼中,1展示了無參函數的簡寫;2處展示了有參函數的簡寫,以及類型推斷機制;3是代碼塊的寫法;4和5再次展示了類型推斷機制。

自定義函數接口

自定義函數接口很容易,只需要編寫一個只有一個抽象方法的接口即可。

// 自定義函數接口@FunctionalInterfacepublic interface ConsumerInterface<T>{
	void accept(T t);}

上面代碼中的@FunctionalInterface是可選的,但加上該標註編譯器會幫你檢查接口是否符合函數接口規範。就像加入@Override標註會檢查是否重載了函數一樣。

有了上述接口定義,就可以寫出類似如下的代碼:

ConsumerInterface<String> consumer = str -> System.out.println(str);

進一步的,還可以這樣使用:

class MyStream<T>{
	private List<T> list;
    ...
	public void myForEach(ConsumerInterface<T> consumer){// 1
		for(T t : list){
			consumer.accept(t);
		}
	}}MyStream<String> stream = new MyStream<String>();stream.myForEach(str -> System.out.println(str));// 使用自定義函數接口書寫Lambda表達式

Lambda and Anonymous Classes(II)

讀過上一篇之後,相信對Lambda表達式的語法以及基本原理有了一定了解。對於編寫代碼,有這些知識已經夠用。本文將進一步區分Lambda表達式和匿名內部類在JVM層面的區別,如果對這一部分不感興趣,可以跳過

經過第一篇的的介紹,我們看到Lambda表達式似乎只是爲了簡化匿名內部類書寫,這看起來僅僅通過語法糖在編譯階段把所有的Lambda表達式替換成匿名內部類就可以了。但實時並非如此。在JVM層面,Lambda表達式和匿名內部類有着明顯的差別。

匿名內部類實現

匿名內部類仍然是一個類,只是不需要程序員顯示指定類名,編譯器會自動爲該類取名。因此如果有如下形式的代碼,編譯之後將會產生兩個class文件:

public class MainAnonymousClass {
	public static void main(String[] args) {
		new Thread(new Runnable(){
			@Override
			public void run(){
				System.out.println("Anonymous Class Thread run()");
			}
		}).start();;
	}}

編譯之後文件分佈如下,兩個class文件分別是主類和匿名內部類產生的:

2-AnonymousClass.png

進一步分析主類MainAnonymousClass.class的字節碼,可發現其創建了匿名內部類的對象:

// javap -c MainAnonymousClass.classpublic class MainAnonymousClass {
  ...
  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/lang/Thread
       3: dup
       4: new           #3                  // class MainAnonymousClass$1 /*創建內部類對象*/
       7: dup
       8: invokespecial #4                  // Method MainAnonymousClass$1."<init>":()V
      11: invokespecial #5                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
      14: invokevirtual #6                  // Method java/lang/Thread.start:()V
      17: return}

Lambda表達式實現

Lambda表達式通過invokedynamic指令實現,書寫Lambda表達式不會產生新的類。如果有如下代碼,編譯之後只有一個class文件:

public class MainLambda {
	public static void main(String[] args) {
		new Thread(
				() -> System.out.println("Lambda Thread run()")
			).start();;
	}}

編譯之後的結果:

2-Lambda

通過javap反編譯命名,我們更能看出Lambda表達式內部表示的不同:

// javap -c -p MainLambda.classpublic class MainLambda {
  ...
  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/lang/Thread
       3: dup
       4: invokedynamic #3,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable; /*使用invokedynamic指令調用*/
       9: invokespecial #4                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
      12: invokevirtual #5                  // Method java/lang/Thread.start:()V
      15: return

  private static void lambda$main$0();  /*Lambda表達式被封裝成主類的私有方法*/
    Code:
       0: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #7                  // String Lambda Thread run()
       5: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return}

反編譯之後我們發現Lambda表達式被封裝成了主類的一個私有方法,並通過invokedynamic指令進行調用。

推論,this引用的意義

既然Lambda表達式不是內部類的簡寫,那麼Lambda內部的this引用也就跟內部類對象沒什麼關係了。在Lambda表達式中this的意義跟在表達式外部完全一樣。因此下列代碼將輸出兩遍Hello Hoolee,而不是兩個引用地址。

public class Hello {
	Runnable r1 = () -> { System.out.println(this); };
	Runnable r2 = () -> { System.out.println(toString()); };
	public static void main(String[] args) {
		new Hello().r1.run();
		new Hello().r2.run();
	}
	public String toString() { return "Hello Hoolee"; }}

Lambda and Collections

我們先從最熟悉的Java集合框架(Java Collections Framework, JCF)開始說起。

爲引入Lambda表達式,Java8新增了java.util.funcion包,裏面包含常用的函數接口,這是Lambda表達式的基礎,Java集合框架也新增部分接口,以便與Lambda表達式對接。

首先回顧一下Java集合框架的接口繼承結構:

JCF_Collection_Interfaces

上圖中綠色標註的接口類,表示在Java8中加入了新的接口方法,當然由於繼承關係,他們相應的子類也都會繼承這些新方法。下表詳細列舉了這些方法。

接口名Java8新加入的方法
CollectionremoveIf() spliterator() stream() parallelStream() forEach()
ListreplaceAll() sort()
MapgetOrDefault() forEach() replaceAll() putIfAbsent() remove() replace() computeIfAbsent() computeIfPresent() compute() merge()

這些新加入的方法大部分要用到java.util.function包下的接口,這意味着這些方法大部分都跟Lambda表達式相關。我們將逐一學習這些方法。

Collection中的新方法

如上所示,接口CollectionList新加入了一些方法,我們以是List的子類ArrayList爲例來說明。瞭解Java7ArrayList實現原理,將有助於理解下文。

forEach()

該方法的簽名爲void forEach(Consumer<? super E> action),作用是對容器中的每個元素執行action指定的動作,其中Consumer是個函數接口,裏面只有一個待實現方法void accept(T t)(後面我們會看到,這個方法叫什麼根本不重要,你甚至不需要記憶它的名字)。

需求:假設有一個字符串列表,需要打印出其中所有長度大於3的字符串.

Java7及以前我們可以用增強的for循環實現:

// 使用曾強for循環迭代ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));for(String str : list){
    if(str.length()>3)
        System.out.println(str);}

現在使用forEach()方法結合匿名內部類,可以這樣實現:

// 使用forEach()結合匿名內部類迭代ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));list.forEach(new Consumer<String>(){
    @Override
    public void accept(String str){
        if(str.length()>3)
            System.out.println(str);
    }});

上述代碼調用forEach()方法,並使用匿名內部類實現Comsumer接口。到目前爲止我們沒看到這種設計有什麼好處,但是不要忘記Lambda表達式,使用Lambda表達式實現如下:

// 使用forEach()結合Lambda表達式迭代ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));list.forEach( str -> {
        if(str.length()>3)
            System.out.println(str);
    });

上述代碼給forEach()方法傳入一個Lambda表達式,我們不需要知道accept()方法,也不需要知道Consumer接口,類型推導幫我們做了一切。

removeIf()

該方法簽名爲boolean removeIf(Predicate<? super E> filter),作用是刪除容器中所有滿足filter指定條件的元素,其中Predicate是一個函數接口,裏面只有一個待實現方法boolean test(T t),同樣的這個方法的名字根本不重要,因爲用的時候不需要書寫這個名字。

需求:假設有一個字符串列表,需要刪除其中所有長度大於3的字符串。

我們知道如果需要在迭代過程衝對容器進行刪除操作必須使用迭代器,否則會拋出ConcurrentModificationException,所以上述任務傳統的寫法是:

// 使用迭代器刪除列表元素ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));Iterator<String> it = list.iterator();while(it.hasNext()){
    if(it.next().length()>3) // 刪除長度大於3的元素
        it.remove();}

現在使用removeIf()方法結合匿名內部類,我們可是這樣實現:

// 使用removeIf()結合匿名名內部類實現ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));list.removeIf(new Predicate<String>(){ // 刪除長度大於3的元素
    @Override
    public boolean test(String str){
        return str.length()>3;
    }});

上述代碼使用removeIf()方法,並使用匿名內部類實現Precicate接口。相信你已經想到用Lambda表達式該怎麼寫了:

// 使用removeIf()結合Lambda表達式實現ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));list.removeIf(str -> str.length()>3); // 刪除長度大於3的元素

使用Lambda表達式不需要記憶Predicate接口名,也不需要記憶test()方法名,只需要知道此處需要一個返回布爾類型的Lambda表達式就行了。

replaceAll()

該方法簽名爲void replaceAll(UnaryOperator<E> operator),作用是對每個元素執行operator指定的操作,並用操作結果來替換原來的元素。其中UnaryOperator是一個函數接口,裏面只有一個待實現函數T apply(T t)

需求:假設有一個字符串列表,將其中所有長度大於3的元素轉換成大寫,其餘元素不變。

Java7及之前似乎沒有優雅的辦法:

// 使用下標實現元素替換ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));for(int i=0; i<list.size(); i++){
    String str = list.get(i);
    if(str.length()>3)
        list.set(i, str.toUpperCase());}

使用replaceAll()方法結合匿名內部類可以實現如下:

// 使用匿名內部類實現ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));list.replaceAll(new UnaryOperator<String>(){
    @Override
    public String apply(String str){
        if(str.length()>3)
            return str.toUpperCase();
        return str;
    }});

上述代碼調用replaceAll()方法,並使用匿名內部類實現UnaryOperator接口。我們知道可以用更爲簡潔的Lambda表達式實現:

// 使用Lambda表達式實現ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));list.replaceAll(str -> {
    if(str.length()>3)
        return str.toUpperCase();
    return str;});

sort()

該方法定義在List接口中,方法簽名爲void sort(Comparator<? super E> c),該方法根據c指定的比較規則對容器元素進行排序Comparator接口我們並不陌生,其中有一個方法int compare(T o1, T o2)需要實現,顯然該接口是個函數接口。

需求:假設有一個字符串列表,按照字符串長度增序對元素排序。

由於Java7以及之前sort()方法在Collections工具類中,所以代碼要這樣寫:

// Collections.sort()方法ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));Collections.sort(list, new Comparator<String>(){
    @Override
    public int compare(String str1, String str2){
        return str1.length()-str2.length();
    }});

現在可以直接使用List.sort()方法,結合Lambda表達式,可以這樣寫:

// List.sort()方法結合Lambda表達式ArrayList<String> list = new ArrayList<>(Arrays.asList("I", "love", "you", "too"));list.sort((str1, str2) -> str1.length()-str2.length());

spliterator()

方法簽名爲Spliterator<E> spliterator(),該方法返回容器的可拆分迭代器。從名字來看該方法跟iterator()方法有點像,我們知道Iterator是用來迭代容器的,Spliterator也有類似作用,但二者有如下不同:

  1. Spliterator既可以像Iterator那樣逐個迭代,也可以批量迭代。批量迭代可以降低迭代的開銷。

  2. Spliterator是可拆分的,一個Spliterator可以通過調用Spliterator<T> trySplit()方法來嘗試分成兩個。一個是this,另一個是新返回的那個,這兩個迭代器代表的元素沒有重疊。

可通過(多次)調用Spliterator.trySplit()方法來分解負載,以便多線程處理。

stream()和parallelStream()

stream()parallelStream()分別返回該容器的Stream視圖表示,不同之處在於parallelStream()返回並行的StreamStream是Java函數式編程的核心類,我們會在後面章節中學習。

Map中的新方法

相比CollectionMap中加入了更多的方法,我們以HashMap爲例來逐一探祕。瞭解Java7HashMap實現原理,將有助於理解下文。

forEach()

該方法簽名爲void forEach(BiConsumer<? super K,? super V> action),作用是Map中的每個映射執行action指定的操作,其中BiConsumer是一個函數接口,裏面有一個待實現方法void accept(T t, U u)BinConsumer接口名字和accept()方法名字都不重要,請不要記憶他們。

需求:假設有一個數字到對應英文單詞的Map,請輸出Map中的所有映射關係.

Java7以及之前經典的代碼如下:

// Java7以及之前迭代MapHashMap<Integer, String> map = new HashMap<>();map.put(1, "one");map.put(2, "two");map.put(3, "three");for(Map.Entry<Integer, String> entry : map.entrySet()){
    System.out.println(entry.getKey() + "=" + entry.getValue());}

使用Map.forEach()方法,結合匿名內部類,代碼如下:

// 使用forEach()結合匿名內部類迭代MapHashMap<Integer, String> map = new HashMap<>();map.put(1, "one");map.put(2, "two");map.put(3, "three");map.forEach(new BiConsumer<Integer, String>(){
    @Override
    public void accept(Integer k, String v){
        System.out.println(k + "=" + v);
    }});

上述代碼調用forEach()方法,並使用匿名內部類實現BiConsumer接口。當然,實際場景中沒人使用匿名內部類寫法,因爲有Lambda表達式:

// 使用forEach()結合Lambda表達式迭代MapHashMap<Integer, String> map = new HashMap<>();map.put(1, "one");map.put(2, "two");map.put(3, "three");map.forEach((k, v) -> System.out.println(k + "=" + v));}

getOrDefault()

該方法跟Lambda表達式沒關係,但是很有用。方法簽名爲V getOrDefault(Object key, V defaultValue),作用是按照給定的key查詢Map中對應的value,如果沒有找到則返回defaultValue。使用該方法程序員可以省去查詢指定鍵值是否存在的麻煩.

需求;假設有一個數字到對應英文單詞的Map,輸出4對應的英文單詞,如果不存在則輸出NoValue

// 查詢Map中指定的值,不存在時使用默認值HashMap<Integer, String> map = new HashMap<>();map.put(1, "one");map.put(2, "two");map.put(3, "three");// Java7以及之前做法if(map.containsKey(4)){ // 1
    System.out.println(map.get(4));}else{
    System.out.println("NoValue");}// Java8使用Map.getOrDefault()System.out.println(map.getOrDefault(4, "NoValue")); // 2

putIfAbsent()

該方法跟Lambda表達式沒關係,但是很有用。方法簽名爲V putIfAbsent(K key, V value),作用是只有在不存在key值的映射或映射值爲null,纔將value指定的值放入到Map中,否則不對Map做更改.該方法將條件判斷和賦值合二爲一,使用起來更加方便.

remove()

我們都知道Map中有一個remove(Object key)方法,來根據指定key值刪除Map中的映射關係;Java8新增了remove(Object key, Object value)方法,只有在當前Mapkey正好映射到value才刪除該映射,否則什麼也不做.

replace()

在Java7及以前,要想替換Map中的映射關係可通過put(K key, V value)方法實現,該方法總是會用新值替換原來的值.爲了更精確的控制替換行爲,Java8在Map中加入了兩個replace()方法,分別如下:

  • replace(K key, V value),只有在當前Mapkey的映射存在時才用value去替換原來的值,否則什麼也不做.

  • replace(K key, V oldValue, V newValue),只有在當前Mapkey的映射存在且等於oldValue才用newValue去替換原來的值,否則什麼也不做.

replaceAll()

該方法簽名爲replaceAll(BiFunction<? super K,? super V,? extends V> function),作用是對Map中的每個映射執行function指定的操作,並用function的執行結果替換原來的value,其中BiFunction是一個函數接口,裏面有一個待實現方法R apply(T t, U u).不要被如此多的函數接口嚇到,因爲使用的時候根本不需要知道他們的名字.

需求:假設有一個數字到對應英文單詞的Map,請將原來映射關係中的單詞都轉換成大寫.

Java7以及之前經典的代碼如下:

// Java7以及之前替換所有Map中所有映射關係HashMap<Integer, String> map = new HashMap<>();map.put(1, "one");map.put(2, "two");map.put(3, "three");for(Map.Entry<Integer, String> entry : map.entrySet()){
    entry.setValue(entry.getValue().toUpperCase());}

使用replaceAll()方法結合匿名內部類,實現如下:

// 使用replaceAll()結合匿名內部類實現HashMap<Integer, String> map = new HashMap<>();map.put(1, "one");map.put(2, "two");map.put(3, "three");map.replaceAll(new BiFunction<Integer, String, String>(){
    @Override
    public String apply(Integer k, String v){
        return v.toUpperCase();
    }});

上述代碼調用replaceAll()方法,並使用匿名內部類實現BiFunction接口。更進一步的,使用Lambda表達式實現如下:

// 使用replaceAll()結合Lambda表達式實現HashMap<Integer, String> map = new HashMap<>();map.put(1, "one");map.put(2, "two");map.put(3, "three");map.replaceAll((k, v) -> v.toUpperCase());

簡潔到讓人難以置信.

merge()

該方法簽名爲merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction),作用是:

  1. 如果Mapkey對應的映射不存在或者爲null,則將value(不能是null)關聯到key上;

  2. 否則執行remappingFunction,如果執行結果非null則用該結果跟key關聯,否則在Map中刪除key的映射.

參數中BiFunction函數接口前面已經介紹過,裏面有一個待實現方法R apply(T t, U u)

merge()方法雖然語義有些複雜,但該方法的用方式很明確,一個比較常見的場景是將新的錯誤信息拼接到原來的信息上,比如:

map.merge(key, newMsg, (v1, v2) -> v1+v2);

compute()

該方法簽名爲compute(K key, BiFunction<? super K,? super V,? extends V> remappingFunction),作用是把remappingFunction的計算結果關聯到key上,如果計算結果爲null,則在Map中刪除key的映射.

要實現上述merge()方法中錯誤信息拼接的例子,使用compute()代碼如下:

map.compute(key, (k,v) -> v==null ? newMsg : v.concat(newMsg));

computeIfAbsent()

該方法簽名爲V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction),作用是:只有在當前Map不存在key值的映射或映射值爲null,才調用mappingFunction,並在mappingFunction執行結果非null時,將結果跟key關聯.

Function是一個函數接口,裏面有一個待實現方法R apply(T t)

computeIfAbsent()常用來對Map的某個key值建立初始化映射.比如我們要實現一個多值映射,Map的定義可能是Map<K,Set<V>>,要向Map中放入新值,可通過如下代碼實現:

Map<Integer, Set<String>> map = new HashMap<>();// Java7及以前的實現方式if(map.containsKey(1)){
    map.get(1).add("one");}else{
    Set<String> valueSet = new HashSet<String>();
    valueSet.add("one");
    map.put(1, valueSet);}// Java8的實現方式map.computeIfAbsent(1, v -> new HashSet<String>()).add("yi");

使用computeIfAbsent()將條件判斷和添加操作合二爲一,使代碼更加簡潔.

computeIfPresent()

該方法簽名爲V computeIfPresent(K key, BiFunction<? super K,? super V,? extends V> remappingFunction),作用跟computeIfAbsent()相反,即,只有在當前Map存在key值的映射且非null,才調用remappingFunction,如果remappingFunction執行結果爲null,則刪除key的映射,否則使用該結果替換key原來的映射.

這個函數的功能跟如下代碼是等效的:

// Java7及以前跟computeIfPresent()等效的代碼if (map.get(key) != null) {
    V oldValue = map.get(key);
    V newValue = remappingFunction.apply(key, oldValue);
    if (newValue != null)
        map.put(key, newValue);
    else
        map.remove(key);
    return newValue;}return null;
  1. Java8爲容器新增一些有用的方法,這些方法有些是爲完善原有功能,有些是爲引入函數式編程,學習和使用這些方法有助於我們寫出更加簡潔有效的代碼.

  2. 函數接口雖然很多,但絕大多數時候我們根本不需要知道它們的名字,書寫Lambda表達式時類型推斷幫我們做了一切.

Streams API(I)

你可能沒意識到Java對函數式編程的重視程度,看看Java 8加入函數式編程擴充多少功能就清楚了。Java 8之所以費這麼大功夫引入函數式編程,原因有二:

  1. 代碼簡潔函數式編程寫出的代碼簡潔且意圖明確,使用stream接口讓你從此告別for循環。

  2. 多核友好,Java函數式編程使得編寫並行程序從未如此簡單,你需要的全部就是調用一下parallel()方法。

這一節我們學習stream,也就是Java函數式編程的主角。對於Java 7來說stream完全是個陌生東西,stream並不是某種數據結構,它只是數據源的一種視圖。這裏的數據源可以是一個數組,Java容器或I/O channel等。正因如此要得到一個stream通常不會手動創建,而是調用對應的工具方法,比如:

  • 調用Collection.stream()或者Collection.parallelStream()方法

  • 調用Arrays.stream(T[] array)方法

常見的stream接口繼承關係如圖:

圖中4種stream接口繼承自BaseStream,其中IntStream, LongStream, DoubleStream對應三種基本類型(int, long, double,注意不是包裝類型),Stream對應所有剩餘類型的stream視圖。爲不同數據類型設置不同stream接口,可以1.提高性能,2.增加特定接口函數。

你可能會奇怪爲什麼不把IntStream等設計成Stream的子接口?畢竟這接口中的方法名大部分是一樣的。答案是這些方法的名字雖然相同,但是返回類型不同,如果設計成父子接口關係,這些方法將不能共存,因爲Java不允許只有返回類型不同的方法重載。

雖然大部分情況下stream是容器調用Collection.stream()方法得到的,但streamcollections有以下不同:

  • 無存儲stream不是一種數據結構,它只是某種數據源的一個視圖,數據源可以是一個數組,Java容器或I/O channel等。

  • 爲函數式編程而生。對stream的任何修改都不會修改背後的數據源,比如對stream執行過濾操作並不會刪除被過濾的元素,而是會產生一個不包含被過濾元素的新stream

  • 惰式執行stream上的操作並不會立即執行,只有等到用戶真正需要結果的時候纔會執行。

  • 可消費性stream只能被“消費”一次,一旦遍歷過就會失效,就像容器的迭代器那樣,想要再次遍歷必須重新生成。

stream的操作分爲爲兩類,中間操作(intermediate operations)和結束操作(terminal operations),二者特點是:

  1. 中間操作總是會惰式執行,調用中間操作只會生成一個標記了該操作的新stream,僅此而已。

  2. 結束操作會觸發實際計算,計算髮生時會把所有中間操作積攢的操作以pipeline的方式執行,這樣可以減少迭代次數。計算完成之後stream就會失效。

如果你熟悉Apache Spark RDD,對stream的這個特點應該不陌生。

下表彙總了Stream接口的部分常見方法:

操作類型接口方法
中間操作concat() distinct() filter() flatMap() limit() map() peek()
skip() sorted() parallel() sequential() unordered()
結束操作allMatch() anyMatch() collect() count() findAny() findFirst()
forEach() forEachOrdered() max() min() noneMatch() reduce() toArray()

區分中間操作和結束操作最簡單的方法,就是看方法的返回值,返回值爲stream的大都是中間操作,否則是結束操作。

stream方法使用

stream跟函數接口關係非常緊密,沒有函數接口stream就無法工作。回顧一下:函數接口是指內部只有一個抽象方法的接口。通常函數接口出現的地方都可以使用Lambda表達式,所以不必記憶函數接口的名字。

forEach()

我們對forEach()方法並不陌生,在Collection中我們已經見過。方法簽名爲void forEach(Consumer<? super E> action),作用是對容器中的每個元素執行action指定的動作,也就是對元素進行遍歷。

// 使用Stream.forEach()迭代Stream<String> stream = Stream.of("I", "love", "you", "too");stream.forEach(str -> System.out.println(str));

由於forEach()是結束方法,上述代碼會立即執行,輸出所有字符串。

filter()

函數原型爲Stream<T> filter(Predicate<? super T> predicate),作用是返回一個只包含滿足predicate條件元素的Stream

// 保留長度等於3的字符串Stream<String> stream= Stream.of("I", "love", "you", "too");stream.filter(str -> str.length()==3)
    .forEach(str -> System.out.println(str));

上述代碼將輸出爲長度等於3的字符串youtoo。注意,由於filter()是個中間操作,如果只調用filter()不會有實際計算,因此也不會輸出任何信息。

distinct()

函數原型爲Stream<T> distinct(),作用是返回一個去除重複元素之後的Stream

Stream<String> stream= Stream.of("I", "love", "you", "too", "too");stream.distinct()
    .forEach(str -> System.out.println(str));

上述代碼會輸出去掉一個too之後的其餘字符串。

sorted()

排序函數有兩個,一個是用自然順序排序,一個是使用自定義比較器排序,函數原型分別爲Stream<T> sorted()Stream<T> sorted(Comparator<? super T> comparator)

Stream<String> stream= Stream.of("I", "love", "you", "too");stream.sorted((str1, str2) -> str1.length()-str2.length())
    .forEach(str -> System.out.println(str));

上述代碼將輸出按照長度升序排序後的字符串,結果完全在預料之中。

map()

函數原型爲<R> Stream<R> map(Function<? super T,? extends R> mapper),作用是返回一個對當前所有元素執行執行mapper之後的結果組成的Stream。直觀的說,就是對每個元素按照某種操作進行轉換,轉換前後Stream中元素的個數不會改變,但元素的類型取決於轉換之後的類型。

Stream<String> stream = Stream.of("I", "love", "you", "too");stream.map(str -> str.toUpperCase())
    .forEach(str -> System.out.println(str));

上述代碼將輸出原字符串的大寫形式。

flatMap()

函數原型爲<R> Stream<R> flatMap(Function<? super T,? extends Stream<? extends R>> mapper),作用是對每個元素執行mapper指定的操作,並用所有mapper返回的Stream中的元素組成一個新的Stream作爲最終返回結果。說起來太拗口,通俗的講flatMap()的作用就相當於把原stream中的所有元素都”攤平”之後組成的Stream,轉換前後元素的個數和類型都可能會改變。

Stream<List<Integer>> stream = Stream.of(Arrays.asList(1,2), Arrays.asList(3, 4, 5));stream.flatMap(list -> list.stream())
    .forEach(i -> System.out.println(i));

上述代碼中,原來的stream中有兩個元素,分別是兩個List<Integer>,執行flatMap()之後,將每個List都“攤平”成了一個個的數字,所以會新產生一個由5個數字組成的Stream。所以最終將輸出1~5這5個數字。

截止到目前我們感覺良好,已介紹Stream接口函數理解起來並不費勁兒。如果你就此以爲函數式編程不過如此,恐怕是高興地太早了。下一節對Stream規約操作的介紹將刷新你現在的認識。

Streams API(II)

上一節介紹了部分Stream常見接口方法,理解起來並不困難,但Stream的用法不止於此,本節我們將仍然以Stream爲例,介紹流的規約操作。

規約操作(reduction operation)又被稱作摺疊操作(fold),是通過某個連接動作將所有元素彙總成一個彙總結果的過程。元素求和、求最大值或最小值、求出元素總個數、將所有元素轉換成一個列表或集合,都屬於規約操作。Stream類庫有兩個通用的規約操作reduce()collect(),也有一些爲簡化書寫而設計的專用規約操作,比如sum()max()min()count()等。

最大或最小值這類規約操作很好理解(至少方法語義上是這樣),我們着重介紹reduce()collect(),這是比較有魔法的地方。

多面手reduce()

reduce操作可以實現從一組元素中生成一個值,sum()max()min()count()等都是reduce操作,將他們單獨設爲函數只是因爲常用。reduce()的方法定義有三種重寫形式:

  • Optional<T> reduce(BinaryOperator<T> accumulator)

  • T reduce(T identity, BinaryOperator<T> accumulator)

  • <U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

雖然函數定義越來越長,但語義不曾改變,多的參數只是爲了指明初始值(參數identity),或者是指定並行執行時多個部分結果的合併方式(參數combiner)。reduce()最常用的場景就是從一堆值中生成一個值。用這麼複雜的函數去求一個最大或最小值,你是不是覺得設計者有病。其實不然,因爲“大”和“小”或者“求和”有時會有不同的語義。

需求:從一組單詞中找出最長的單詞。這裏“大”的含義就是“長”。

// 找出最長的單詞Stream<String> stream = Stream.of("I", "love", "you", "too");Optional<String> longest = stream.reduce((s1, s2) -> s1.length()>=s2.length() ? s1 : s2);//Optional<String> longest = stream.max((s1, s2) -> s1.length()-s2.length());System.out.println(longest.get());

上述代碼會選出最長的單詞love,其中Optional是(一個)值的容器,使用它可以避免null值的麻煩。當然可以使用Stream.max(Comparator<? super T> comparator)方法來達到同等效果,但reduce()自有其存在的理由。

需求:求出一組單詞的長度之和。這是個“求和”操作,操作對象輸入類型是String,而結果類型是Integer

// 求單詞長度之和Stream<String> stream = Stream.of("I", "love", "you", "too");Integer lengthSum = stream.reduce(0, // 初始值 // (1)
        (sum, str) -> sum+str.length(), // 累加器 // (2)
        (a, b) -> a+b); // 部分和拼接器,並行執行時纔會用到 // (3)// int lengthSum = stream.mapToInt(str -> str.length()).sum();System.out.println(lengthSum);

上述代碼標號(2)處將i. 字符串映射成長度,ii. 並和當前累加和相加。這顯然是兩步操作,使用reduce()函數將這兩步合二爲一,更有助於提升性能。如果想要使用map()sum()組合來達到上述目的,也是可以的。

reduce()擅長的是生成一個值,如果想要從Stream生成一個集合或者Map等複雜的對象該怎麼辦呢?終極武器collect()橫空出世!

終極武器collect()

不誇張的講,如果你發現某個功能在Stream接口中沒找到,十有八九可以通過collect()方法實現。collect()Stream接口方法中最靈活的一個,學會它纔算真正入門Java函數式編程。先看幾個熱身的小例子:

// 將Stream轉換成容器或MapStream<String> stream = Stream.of("I", "love", "you", "too");List<String> list = stream.collect(Collectors.toList()); // (1)// Set<String> set = stream.collect(Collectors.toSet()); // (2)// Map<String, Integer> map = stream.collect(Collectors.toMap(Function.identity(), String::length)); // (3)

上述代碼分別列舉了如何將Stream轉換成ListSetMap。雖然代碼語義很明確,可是我們仍然會有幾個疑問:

  1. Function.identity()是幹什麼的?

  2. String::length是什麼意思?

  3. Collectors是個什麼東西?

接口的靜態方法和默認方法

Function是一個接口,那麼Function.identity()是什麼意思呢?這要從兩方面解釋:

  1. Java 8允許在接口中加入具體方法。接口中的具體方法有兩種,default方法和static方法,identity()就是Function接口的一個靜態方法。

  2. Function.identity()返回一個輸出跟輸入一樣的Lambda表達式對象,等價於形如t -> t形式的Lambda表達式。

上面的解釋是不是讓你疑問更多?不要問我爲什麼接口中可以有具體方法,也不要告訴我你覺得t -> tidentity()方法更直觀。我會告訴你接口中的default方法是一個無奈之舉,在Java 7及之前要想在定義好的接口中加入新的抽象方法是很困難甚至不可能的,因爲所有實現了該接口的類都要重新實現。試想在Collection接口中加入一個stream()抽象方法會怎樣?default方法就是用來解決這個尷尬問題的,直接在接口中實現新加入的方法。既然已經引入了default方法,爲何不再加入static方法來避免專門的工具類呢!

方法引用

諸如String::length的語法形式叫做方法引用(method references),這種語法用來替代某些特定形式Lambda表達式。如果Lambda表達式的全部內容就是調用一個已有的方法,那麼可以用方法引用來替代Lambda表達式。方法引用可以細分爲四類:

方法引用類別舉例
引用靜態方法Integer::sum
引用某個對象的方法list::add
引用某個類的方法String::length
引用構造方法HashMap::new

我們會在後面的例子中使用方法引用。

收集器

相信前面繁瑣的內容已徹底打消了你學習Java函數式編程的熱情,不過很遺憾,下面的內容更繁瑣。但這不能怪Stream類庫,因爲要實現的功能本身很複雜。

收集器(Collector)是爲Stream.collect()方法量身打造的工具接口(類)。考慮一下將一個Stream轉換成一個容器(或者Map)需要做哪些工作?我們至少需要兩樣東西:

  1. 目標容器是什麼?是ArrayList還是HashSet,或者是個TreeMap

  2. 新元素如何添加到容器中?是List.add()還是Map.put()

如果並行的進行規約,還需要告訴collect() 3. 多個部分結果如何合併成一個。

結合以上分析,collect()方法定義爲<R> R collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner),三個參數依次對應上述三條分析。不過每次調用collect()都要傳入這三個參數太麻煩,收集器Collector就是對這三個參數的簡單封裝,所以collect()的另一定義爲<R,A> R collect(Collector<? super T,A,R> collector)Collectors工具類可通過靜態方法生成各種常用的Collector。舉例來說,如果要將Stream規約成List可以通過如下兩種方式實現:

// 將Stream規約成ListStream<String> stream = Stream.of("I", "love", "you", "too");List<String> list = stream.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);// 方式1//List<String> list = stream.collect(Collectors.toList());// 方式2System.out.println(list);

通常情況下我們不需要手動指定collect()的三個參數,而是調用collect(Collector<? super T,A,R> collector)方法,並且參數中的Collector對象大都是直接通過Collectors工具類獲得。實際上傳入的收集器的行爲決定了collect()的行爲

使用collect()生成Collection

前面已經提到通過collect()方法將Stream轉換成容器的方法,這裏再彙總一下。將Stream轉換成ListSet是比較常見的操作,所以Collectors工具已經爲我們提供了對應的收集器,通過如下代碼即可完成:

// 將Stream轉換成List或SetStream<String> stream = Stream.of("I", "love", "you", "too");List<String> list = stream.collect(Collectors.toList()); // (1)Set<String> set = stream.collect(Collectors.toSet()); // (2)

上述代碼能夠滿足大部分需求,但由於返回結果是接口類型,我們並不知道類庫實際選擇的容器類型是什麼,有時候我們可能會想要人爲指定容器的實際類型,這個需求可通過Collectors.toCollection(Supplier<C> collectionFactory)方法完成。

// 使用toCollection()指定規約容器的類型ArrayList<String> arrayList = stream.collect(Collectors.toCollection(ArrayList::new));// (3)HashSet<String> hashSet = stream.collect(Collectors.toCollection(HashSet::new));// (4)

上述代碼(3)處指定規約結果是ArrayList,而(4)處指定規約結果爲HashSet。一切如你所願。

使用collect()生成Map

前面已經說過Stream背後依賴於某種數據源,數據源可以是數組、容器等,但不能是Map。反過來從Stream生成Map是可以的,但我們要想清楚Mapkeyvalue分別代表什麼,根本原因是我們要想清楚要幹什麼。通常在三種情況下collect()的結果會是Map

  1. 使用Collectors.toMap()生成的收集器,用戶需要指定如何生成Mapkeyvalue

  2. 使用Collectors.partitioningBy()生成的收集器,對元素進行二分區操作時用到。

  3. 使用Collectors.groupingBy()生成的收集器,對元素做group操作時用到。

情況1:使用toMap()生成的收集器,這種情況是最直接的,前面例子中已提到,這是和Collectors.toCollection()並列的方法。如下代碼展示將學生列表轉換成由<學生,GPA>組成的Map。非常直觀,無需多言。

// 使用toMap()統計學生GPAMap<Student, Double> studentToGPA =
     students.stream().collect(Collectors.toMap(Function.identity(),// 如何生成key
                                     student -> computeGPA(student)));// 如何生成value

情況2:使用partitioningBy()生成的收集器,這種情況適用於將Stream中的元素依據某個二值邏輯(滿足條件,或不滿足)分成互補相交的兩部分,比如男女性別、成績及格與否等。下列代碼展示將學生分成成績及格或不及格的兩部分。

// Partition students into passing and failingMap<Boolean, List<Student>> passingFailing = students.stream()
         .collect(Collectors.partitioningBy(s -> s.getGrade() >= PASS_THRESHOLD));

情況3:使用groupingBy()生成的收集器,這是比較靈活的一種情況。跟SQL中的group by語句類似,這裏的groupingBy()也是按照某個屬性對數據進行分組,屬性相同的元素會被對應到Map的同一個key上。下列代碼展示將員工按照部門進行分組:

// Group employees by departmentMap<Department, List<Employee>> byDept = employees.stream()
            .collect(Collectors.groupingBy(Employee::getDepartment));

以上只是分組的最基本用法,有些時候僅僅分組是不夠的。在SQL中使用group by是爲了協助其他查詢,比如1. 先將員工按照部門分組,2. 然後統計每個部門員工的人數。Java類庫設計者也考慮到了這種情況,增強版的groupingBy()能夠滿足這種需求。增強版的groupingBy()允許我們對元素分組之後再執行某種運算,比如求和、計數、平均值、類型轉換等。這種先將元素分組的收集器叫做上游收集器,之後執行其他運算的收集器叫做下游收集器(downstream Collector)。

// 使用下游收集器統計每個部門的人數Map<Department, Integer> totalByDept = employees.stream()
                    .collect(Collectors.groupingBy(Employee::getDepartment,
                                                   Collectors.counting()));// 下游收集器

上面代碼的邏輯是不是越看越像SQL?高度非結構化。還有更狠的,下游收集器還可以包含更下游的收集器,這絕不是爲了炫技而增加的把戲,而是實際場景需要。考慮將員工按照部門分組的場景,如果我們想得到每個員工的名字(字符串),而不是一個個Employee對象,可通過如下方式做到:

// 按照部門對員工分佈組,並只保留員工的名字Map<Department, List<String>> byDept = employees.stream()
                .collect(Collectors.groupingBy(Employee::getDepartment,
                        Collectors.mapping(Employee::getName,// 下游收集器
                                Collectors.toList())));// 更下游的收集器

如果看到這裏你還沒有對Java函數式編程失去信心,恭喜你,你已經順利成爲Java函數式編程大師了。

使用collect()做字符串join

這個肯定是大家喜聞樂見的功能,字符串拼接時使用Collectors.joining()生成的收集器,從此告別for循環。Collectors.joining()方法有三種重寫形式,分別對應三種不同的拼接方式。無需多言,代碼過目難忘。

// 使用Collectors.joining()拼接字符串Stream<String> stream = Stream.of("I", "love", "you");//String joined = stream.collect(Collectors.joining());// "Iloveyou"//String joined = stream.collect(Collectors.joining(","));// "I,love,you"String joined = stream.collect(Collectors.joining(",", "{", "}"));// "{I,love,you}"

collect()還可以做更多

除了可以使用Collectors工具類已經封裝好的收集器,我們還可以自定義收集器,或者直接調用collect(Supplier<R> supplier, BiConsumer<R,? super T> accumulator, BiConsumer<R,R> combiner)方法,收集任何形式你想要的信息。不過Collectors工具類應該能滿足我們的絕大部分需求,手動實現之間請先看看文檔。

Stream Pipelines

前面我們已經學會如何使用Stream API,用起來真的很爽,但簡潔的方法下面似乎隱藏着無盡的祕密,如此強大的API是如何實現的呢?比如Pipeline是怎麼執行的,每次方法調用都會導致一次迭代嗎?自動並行又是怎麼做到的,線程個數是多少?本節我們學習Stream流水線的原理,這是Stream實現的關鍵所在。

首先回顧一下容器執行Lambda表達式的方式,以ArrayList.forEach()方法爲例,具體代碼如下:

// ArrayList.forEach()public void forEach(Consumer<? super E> action) {
    ...
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        action.accept(elementData[i]);// 回調方法
    }
    ...}

我們看到ArrayList.forEach()方法的主要邏輯就是一個for循環,在該for循環裏不斷調用action.accept()回調方法完成對元素的遍歷。這完全沒有什麼新奇之處,回調方法在Java GUI的監聽器中廣泛使用。Lambda表達式的作用就是相當於一個回調方法,這很好理解。

Stream API中大量使用Lambda表達式作爲回調方法,但這並不是關鍵。理解Stream我們更關心的是另外兩個問題:流水線和自動並行。使用Stream或許很容易寫入如下形式的代碼:

int longestStringLengthStartingWithA
        = strings.stream()
              .filter(s -> s.startsWith("A"))
              .mapToInt(String::length)
              .max();

上述代碼求出以字母A開頭的字符串的最大長度,一種直白的方式是爲每一次函數調用都執一次迭代,這樣做能夠實現功能,但效率上肯定是無法接受的。類庫的實現着使用流水線(Pipeline)的方式巧妙的避免了多次迭代,其基本思想是在一次迭代中儘可能多的執行用戶指定的操作。爲講解方便我們彙總了Stream的所有操作。

Stream操作分類
中間操作(Intermediate operations)無狀態(Stateless)unordered() filter() map() mapToInt() mapToLong() mapToDouble() flatMap() flatMapToInt() flatMapToLong() flatMapToDouble() peek()
有狀態(Stateful)distinct() sorted() sorted() limit() skip()
結束操作(Terminal operations)非短路操作forEach() forEachOrdered() toArray() reduce() collect() max() min() count()
短路操作(short-circuiting)anyMatch() allMatch() noneMatch() findFirst() findAny()

Stream上的所有操作分爲兩類:中間操作和結束操作,中間操作只是一種標記,只有結束操作纔會觸發實際計算。中間操作又可以分爲無狀態的(Stateless)和有狀態的(Stateful),無狀態中間操作是指元素的處理不受前面元素的影響,而有狀態的中間操作必須等到所有元素處理之後才知道最終結果,比如排序是有狀態操作,在讀取所有元素之前並不能確定排序結果;結束操作又可以分爲短路操作和非短路操作,短路操作是指不用處理全部元素就可以返回結果,比如找到第一個滿足條件的元素。之所以要進行如此精細的劃分,是因爲底層對每一種情況的處理方式不同。

一種直白的實現方式

仍然考慮上述求最長字符串的程序,一種直白的流水線實現方式是爲每一次函數調用都執一次迭代,並將處理中間結果放到某種數據結構中(比如數組,容器等)。具體說來,就是調用filter()方法後立即執行,選出所有以A開頭的字符串並放到一個列表list1中,之後讓list1傳遞給mapToInt()方法並立即執行,生成的結果放到list2中,最後遍歷list2找出最大的數字作爲最終結果。程序的執行流程如如所示:

這樣做實現起來非常簡單直觀,但有兩個明顯的弊端:

  1. 迭代次數多。迭代次數跟函數調用的次數相等。

  2. 頻繁產生中間結果。每次函數調用都產生一次中間結果,存儲開銷無法接受。

這些弊端使得效率底下,根本無法接受。如果不使用Stream API我們都知道上述代碼該如何在一次迭代中完成,大致是如下形式:

int longest = 0;for(String str : strings){
    if(str.startsWith("A")){// 1. filter(), 保留以A開頭的字符串
        int len = str.length();// 2. mapToInt(), 轉換成長度
        longest = Math.max(len, longest);// 3. max(), 保留最長的長度
    }}

採用這種方式我們不但減少了迭代次數,也避免了存儲中間結果,顯然這就是流水線,因爲我們把三個操作放在了一次迭代當中。只要我們事先知道用戶意圖,總是能夠採用上述方式實現跟Stream API等價的功能,但問題是Stream類庫的設計者並不知道用戶的意圖是什麼。如何在無法假設用戶行爲的前提下實現流水線,是類庫的設計者要考慮的問題。

Stream流水線解決方案

我們大致能夠想到,應該採用某種方式記錄用戶每一步的操作,當用戶調用結束操作時將之前記錄的操作疊加到一起在一次迭代中全部執行掉。沿着這個思路,有幾個問題需要解決:

  1. 用戶的操作如何記錄?

  2. 操作如何疊加?

  3. 疊加之後的操作如何執行?

  4. 執行後的結果(如果有)在哪裏?

操作如何記錄?

注意這裏使用的是“操作(operation)”一詞,指的是“Stream中間操作”的操作,很多Stream操作會需要一個回調函數(Lambda表達式),因此一個完整的操作是<數據來源,操作,回調函數>構成的三元組。Stream中使用Stage的概念來描述一個完整的操作,並用某種實例化後的PipelineHelper來代表Stage,將具有先後順序的各個Stage連到一起,就構成了整個流水線。跟Stream相關類和接口的繼承關係圖示。

還有IntPipeline, LongPipeline, DoublePipeline沒在圖中畫出,這三個類專門爲三種基本類型(不是包裝類型)而定製的,跟ReferencePipeline是並列關係。圖中Head用於表示第一個Stage,即調用調用諸如Collection.stream()方法產生的Stage,很顯然這個Stage裏不包含任何操作;StatelessOpStatefulOp分別表示無狀態和有狀態的Stage,對應於無狀態和有狀態的中間操作。

Stream流水線組織結構示意圖如下:

圖中通過Collection.stream()方法得到Head也就是stage0,緊接着調用一系列的中間操作,不斷產生新的Stream。這些Stream對象以雙向鏈表的形式組織在一起,構成整個流水線,由於每個Stage都記錄了前一個Stage和本次的操作以及回調函數,依靠這種結構就能建立起對數據源的所有操作。這就是Stream記錄操作的方式。

操作如何疊加?

以上只是解決了操作記錄的問題,要想讓流水線起到應有的作用我們需要一種將所有操作疊加到一起的方案。你可能會覺得這很簡單,只需要從流水線的head開始依次執行每一步的操作(包括回調函數)就行了。這聽起來似乎是可行的,但是你忽略了前面的Stage並不知道後面Stage到底執行了哪種操作,以及回調函數是哪種形式。換句話說,只有當前Stage本身才知道該如何執行自己包含的動作。這就需要有某種協議來協調相鄰Stage之間的調用關係。

這種協議由Sink接口完成,Sink接口包含的方法如下表所示:

方法名作用
void begin(long size)開始遍歷元素之前調用該方法,通知Sink做好準備。
void end()所有元素遍歷完成之後調用,通知Sink沒有更多的元素了。
boolean cancellationRequested()是否可以結束操作,可以讓短路操作儘早結束。
void accept(T t)遍歷元素時調用,接受一個待處理元素,並對元素進行處理。Stage把自己包含的操作和回調方法封裝到該方法裏,前一個Stage只需要調用當前Stage.accept(T t)方法就行了。

有了上面的協議,相鄰Stage之間調用就很方便了,每個Stage都會將自己的操作封裝到一個Sink裏,前一個Stage只需調用後一個Stage的accept()方法即可,並不需要知道其內部是如何處理的。當然對於有狀態的操作,Sink的begin()end()方法也是必須實現的。比如Stream.sorted()是一個有狀態的中間操作,其對應的Sink.begin()方法可能創建一個乘放結果的容器,而accept()方法負責將元素添加到該容器,最後end()負責對容器進行排序。對於短路操作,Sink.cancellationRequested()也是必須實現的,比如Stream.findFirst()是短路操作,只要找到一個元素,cancellationRequested()就應該返回true,以便調用者儘快結束查找。Sink的四個接口方法常常相互協作,共同完成計算任務。實際上Stream API內部實現的的本質,就是如何重載Sink的這四個接口方法

有了Sink對操作的包裝,Stage之間的調用問題就解決了,執行時只需要從流水線的head開始對數據源依次調用每個Stage對應的Sink.{begin(), accept(), cancellationRequested(), end()}方法就可以了。一種可能的Sink.accept()方法流程是這樣的:

void accept(U u){
    1. 使用當前Sink包裝的回調函數處理u
    2. 將處理結果傳遞給流水線下游的Sink}

Sink接口的其他幾個方法也是按照這種[處理->轉發]的模型實現。下面我們結合具體例子看看Stream的中間操作是如何將自身的操作包裝成Sink以及Sink是如何將處理結果轉發給下一個Sink的。先看Stream.map()方法:

// Stream.map(),調用該方法將產生一個新的Streampublic final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
    ...
    return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
                                 StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
        @Override /*opWripSink()方法返回由回調函數包裝而成Sink*/
        Sink<P_OUT> opWrapSink(int flags, Sink<R> downstream) {
            return new Sink.ChainedReference<P_OUT, R>(downstream) {
                @Override
                public void accept(P_OUT u) {
                    R r = mapper.apply(u);// 1. 使用當前Sink包裝的回調函數mapper處理u
                    downstream.accept(r);// 2. 將處理結果傳遞給流水線下游的Sink
                }
            };
        }
    };}

上述代碼看似複雜,其實邏輯很簡單,就是將回調函數mapper包裝到一個Sink當中。由於Stream.map()是一個無狀態的中間操作,所以map()方法返回了一個StatelessOp內部類對象(一個新的Stream),調用這個新Stream的opWripSink()方法將得到一個包裝了當前回調函數的Sink。

再來看一個複雜一點的例子。Stream.sorted()方法將對Stream中的元素進行排序,顯然這是一個有狀態的中間操作,因爲讀取所有元素之前是沒法得到最終順序的。拋開模板代碼直接進入問題本質,sorted()方法是如何將操作封裝成Sink的呢?sorted()一種可能封裝的Sink代碼如下:

// Stream.sort()方法用到的Sink實現class RefSortingSink<T> extends AbstractRefSortingSink<T> {
    private ArrayList<T> list;// 存放用於排序的元素
    RefSortingSink(Sink<? super T> downstream, Comparator<? super T> comparator) {
        super(downstream, comparator);
    }
    @Override
    public void begin(long size) {
        ...
        // 創建一個存放排序元素的列表
        list = (size >= 0) ? new ArrayList<T>((int) size) : new ArrayList<T>();
    }
    @Override
    public void end() {
        list.sort(comparator);// 只有元素全部接收之後才能開始排序
        downstream.begin(list.size());
        if (!cancellationWasRequested) {// 下游Sink不包含短路操作
            list.forEach(downstream::accept);// 2. 將處理結果傳遞給流水線下游的Sink
        }
        else {// 下游Sink包含短路操作
            for (T t : list) {// 每次都調用cancellationRequested()詢問是否可以結束處理。
                if (downstream.cancellationRequested()) break;
                downstream.accept(t);// 2. 將處理結果傳遞給流水線下游的Sink
            }
        }
        downstream.end();
        list = null;
    }
    @Override
    public void accept(T t) {
        list.add(t);// 1. 使用當前Sink包裝動作處理t,只是簡單的將元素添加到中間列表當中
    }}

上述代碼完美的展現了Sink的四個接口方法是如何協同工作的:

  1. 首先beging()方法告訴Sink參與排序的元素個數,方便確定中間結果容器的的大小;

  2. 之後通過accept()方法將元素添加到中間結果當中,最終執行時調用者會不斷調用該方法,直到遍歷所有元素;

  3. 最後end()方法告訴Sink所有元素遍歷完畢,啓動排序步驟,排序完成後將結果傳遞給下游的Sink;

  4. 如果下游的Sink是短路操作,將結果傳遞給下游時不斷詢問下游cancellationRequested()是否可以結束處理。

疊加之後的操作如何執行?

Sink完美封裝了Stream每一步操作,並給出了[處理->轉發]的模式來疊加操作。這一連串的齒輪已經咬合,就差最後一步撥動齒輪啓動執行。是什麼啓動這一連串的操作呢?也許你已經想到了啓動的原始動力就是結束操作(Terminal Operation),一旦調用某個結束操作,就會觸發整個流水線的執行。

結束操作之後不能再有別的操作,所以結束操作不會創建新的流水線階段(Stage),直觀的說就是流水線的鏈表不會在往後延伸了。結束操作會創建一個包裝了自己操作的Sink,這也是流水線中最後一個Sink,這個Sink只需要處理數據而不需要將結果傳遞給下游的Sink(因爲沒有下游)。對於Sink的[處理->轉發]模型,結束操作的Sink就是調用鏈的出口。

我們再來考察一下上游的Sink是如何找到下游Sink的。一種可選的方案是在PipelineHelper中設置一個Sink字段,在流水線中找到下游Stage並訪問Sink字段即可。但Stream類庫的設計者沒有這麼做,而是設置了一個Sink AbstractPipeline.opWrapSink(int flags, Sink downstream)方法來得到Sink,該方法的作用是返回一個新的包含了當前Stage代表的操作以及能夠將結果傳遞給downstream的Sink對象。爲什麼要產生一個新對象而不是返回一個Sink字段?這是因爲使用opWrapSink()可以將當前操作與下游Sink(上文中的downstream參數)結合成新Sink。試想只要從流水線的最後一個Stage開始,不斷調用上一個Stage的opWrapSink()方法直到最開始(不包括stage0,因爲stage0代表數據源,不包含操作),就可以得到一個代表了流水線上所有操作的Sink,用代碼表示就是這樣:

// AbstractPipeline.wrapSink()// 從下游向上遊不斷包裝Sink。如果最初傳入的sink代表結束操作,// 函數返回時就可以得到一個代表了流水線上所有操作的Sink。final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
    ...
    for (AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
        sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
    }
    return (Sink<P_IN>) sink;}

現在流水線上從開始到結束的所有的操作都被包裝到了一個Sink裏,執行這個Sink就相當於執行整個流水線,執行Sink的代碼如下:

// AbstractPipeline.copyInto(), 對spliterator代表的數據執行wrappedSink代表的操作。final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
    ...
    if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
        wrappedSink.begin(spliterator.getExactSizeIfKnown());// 通知開始遍歷
        spliterator.forEachRemaining(wrappedSink);// 迭代
        wrappedSink.end();// 通知遍歷結束
    }
    ...}

上述代碼首先調用wrappedSink.begin()方法告訴Sink數據即將到來,然後調用spliterator.forEachRemaining()方法對數據進行迭代(Spliterator是容器的一種迭代器,參閱),最後調用wrappedSink.end()方法通知Sink數據處理結束。邏輯如此清晰。

執行後的結果在哪裏?

最後一個問題是流水線上所有操作都執行後,用戶所需要的結果(如果有)在哪裏?首先要說明的是不是所有的Stream結束操作都需要返回結果,有些操作只是爲了使用其副作用(Side-effects),比如使用Stream.forEach()方法將結果打印出來就是常見的使用副作用的場景(事實上,除了打印之外其他場景都應避免使用副作用),對於真正需要返回結果的結束操作結果存在哪裏呢?

特別說明:副作用不應該被濫用,也許你會覺得在Stream.forEach()裏進行元素收集是個不錯的選擇,就像下面代碼中那樣,但遺憾的是這樣使用的正確性和效率都無法保證,因爲Stream可能會並行執行。大多數使用副作用的地方都可以使用歸約操作更安全和有效的完成。

// 錯誤的收集方式ArrayList<String> results = new ArrayList<>();stream.filter(s -> pattern.matcher(s).matches())
      .forEach(s -> results.add(s));  // Unnecessary use of side-effects!// 正確的收集方式List<String>results =
     stream.filter(s -> pattern.matcher(s).matches())
             .collect(Collectors.toList());  // No side-effects!

回到流水線執行結果的問題上來,需要返回結果的流水線結果存在哪裏呢?這要分不同的情況討論,下表給出了各種有返回結果的Stream結束操作。

返回類型對應的結束操作
booleananyMatch() allMatch() noneMatch()
OptionalfindFirst() findAny()
歸約結果reduce() collect()
數組toArray()
  1. 對於表中返回boolean或者Optional的操作(Optional是存放 一個 值的容器)的操作,由於值返回一個值,只需要在對應的Sink中記錄這個值,等到執行結束時返回就可以了。

  2. 對於歸約操作,最終結果放在用戶調用時指定的容器中(容器類型通過收集器指定)。collect(), reduce(), max(), min()都是歸約操作,雖然max()和min()也是返回一個Optional,但事實上底層是通過調用reduce()方法實現的。

  3. 對於返回是數組的情況,毫無疑問的結果會放在數組當中。這麼說當然是對的,但在最終返回數組之前,結果其實是存儲在一種叫做Node的數據結構中的。Node是一種多叉樹結構,元素存儲在樹的葉子當中,並且一個葉子節點可以存放多個元素。這樣做是爲了並行執行方便。關於Node的具體結構,我們會在下一節探究Stream如何並行執行時給出詳細說明。

本文詳細介紹了Stream流水線的組織方式和執行過程,學習本文將有助於理解原理並寫出正確的Stream代碼,同時打消你對Stream API效率方面的顧慮。如你所見,Stream API實現如此巧妙,即使我們使用外部迭代手動編寫等價代碼,也未必更加高效。

注:留下本文所用的JDK版本,以便有考究癖的人考證:

$ java -versionjava version "1.8.0_101"Java(TM) SE Runtime Environment (build 1.8.0_101-b13)Java HotSpot(TM) Server VM (build 25.101-b13, mixed mode)

文章來源:https://objcoding.com/2019/03/04/lambda/#top

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章