42.Lambda 優先于匿名類
??在之前的做法中(Historically),使用單個抽象方法的接口(或很少的抽象類【只有一個抽象方法的抽象類數量比較少】)被用作函數類型。它們的實例稱為函數對象,代表一個函數或一種行為。自 JDK 1.1 于 1997 年發布以來,創建函數對象的主要方法是匿名類(第 24 項)。下面的這個代碼片段,用于按長度順序對字符串列表進行排序,使用匿名類創建排序的比較函數(強制排序順序):
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
??匿名類適用于需要經典功能的面向對象的設計模式,特別是策略模式[Gamma95]。Comparator 接口表示用于排序的抽象策略; 上面的匿名類是排序字符串的具體策略。然而,匿名類的冗長使得 Java 中的函數式編程成為一個沒有吸引力的前景。
??在 Java 8 中,該語言正式成為這樣一種概念,即使用單一抽象方法的接口是特殊的,值得特別對待。這些接口現在稱為功能接口,該語言允許你使用 lambda 表達式或簡稱 lambdas 創建這些接口的實例。Lambdas 在功能上與匿名類相似,但更加簡潔。以下是上面的代碼片段如何將匿名類替換為 lambda。樣板消失了,行為很明顯:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
??請注意,lambda(Comparator <String>
)的類型,其參數(s1 和 s2,兩個 String)及其返回值(int)的類型不在代碼中。編譯器使用稱為類型推斷的過程從上下文中推導出這些類型。在某些情況下,編譯器將無法確定類型,你必須指定它們。
類型推斷的規則很復雜:它們占據了 JLS 的整個章節 [JLS,18]。很少有程序員詳細了解這些規則,但這沒關系。
省略所有 lambda 參數的類型,除非它們的存在使您的程序更清晰。
如果編譯器生成錯誤,告訴你無法推斷 lambda 參數的類型,請指定它。有時你可能必須轉換返回值或整個 lambda 表達式,但這種情況很少見。
??關于類型推斷,應該添加一個警告。第 26 項告訴你不要使用原始類型,第 29 項告訴你支持泛型類型,第 30 項告訴你支持泛型方法。當你使用 lambdas 時,這個建議是非常重要的,因為編譯器獲得了從泛型的執行類型推斷出的大多數類型信息。如果你不提供此信息,編譯器將無法進行類型推斷,你必須在 lambdas 中手動指定類型,這將大大增加它們的詳細程度【也就是代碼量】。舉例來說,如果變量詞被聲明為原始類型 List 而不是參數化類型 List <String>,那么上面的代碼片段將無法編譯。
??順便提一下,如果使用比較器構造方法代替 lambda,則片段中的比較器可以更簡潔(第 14. 43 項):
Collections.sort(words, comparingInt(String::length));
??實際上,通過利用 Java 8 中添加到 List 接口的 sort 方法,可以使代碼段更短:
words.sort(comparingInt(String::length));
??將 lambda 添加到語言中使得使用函數對象變得切實可行。例如,請考慮第 34 項中的 Operation 枚舉類型。因為每個枚舉對其 apply 方法需要不同的行為,所以我們使用特定于常量的類主體并覆蓋每個枚舉常量中的 apply 方法。為了讓你有清晰的記憶,這里是代碼:
// Enum type with constant-specific class bodies & data (Item 34)
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
@Override
public String toString() { return symbol; }
public abstract double apply(double x, double y);
}
??第 34 項說 enum 實例字段比特定于常量的類體更可取。使用前者而不是后者,Lambdas 可以輕松實現特定于常量的行為。只需將實現每個枚舉常量行為的 lambda 傳遞給它的構造函數。構造函數將 lambda 存儲在實例字段中,apply 方法將調用轉發給 lambda。生成的代碼比原始版本更簡單,更清晰:
// Enum with function object fields & constant-specific behavior
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override
public String toString() { return symbol; }
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
??請注意,我們使用 DoubleBinaryOperator 接口來表示枚舉常量行為的 lambdas。這是 java.util.function(第 44 項)中許多預定義的功能接口之一。它表示一個函數,它接受兩個 double 參數并返回一個 double 結果。
??查看基于 lambda 的 Operation 枚舉,您可能會認為特定于常量的方法體已經過時了,但事實并非如此。跟類和方法不一樣,lambdas 缺乏名稱和文檔; 如果一個運算過程不能自我解釋【代碼就是最好的文檔】,或超過幾行,請不要將它放在 lambda 中。一行【代碼】對于 lambda 是理想的,三行【代碼】是合理的最大值。如果違反此規則,可能會嚴重損害程序的可讀性。如果 lambda 很長或難以閱讀,要么找到簡化它的方法,要么重構你的程序來取代 lambda。此外,傳遞給枚舉構造函數的參數在靜態上下文中進行運算。因此,枚舉構造函數中的 lambdas 無法訪問枚舉的實例成員。如果枚舉類型具有難以理解的特定于常量的行為,無法在幾行【代碼】中實現,或者需要訪問實例字段或方法,則仍然可以使用特定于常量的類主體。
??同樣,你可能會認為匿名類在 lambdas 時代已經過時了。這很接近事實,但是你可以用匿名類做一些你無法用 lambdas 做的事情。Lambdas 僅限于函數接口。如果要創建抽象類的實例,可以使用匿名類,但不能使用 lambda。同樣,你可以使用匿名類來創建具有多個抽象方法的接口實例。最后,lambda 無法獲得對自身的引用。在 lambda 中,this 關鍵字引用封閉的實例,這通常是你想要的。在匿名類中,this 關鍵字引用匿名類實例。如果需要從其體內【類內部】訪問函數對象,則必須使用匿名類。【在 lambda 表達式中使用 this 關鍵字,獲得的引用是 lambda 所在的實例的引用,在匿名類中使用 this 關鍵字,獲得的是當前匿名類的實例的引用】
??Lambdas 與匿名類都具有無法在實現中可靠地序列化和反序列化它們的屬性【lambda 和匿名類都無法被序列化和反序列化】。因此,你應該很少(如果有的話)序列化 lambda(或匿名類實例)。如果您有一個要進行序列化的函數對象,例如 Comparator,請使用私有靜態嵌套類的實例(第 24 項)。
??總之,從 Java 8 開始,lambda 是迄今為止表示小函數對象的最佳方式。除非必須創建非功能接口類型的實例,否則不要對函數對象使用匿名類。另外,請記住,lambda 使得通過使用對象來代表小函數變得如此容易,以至于它打開了以前在 Java 中不實用的函數式編程技術的大門。
43.方法引用優先于 Lambda
??lambda 優于匿名類的主要優點是它們更簡潔。Java 提供了一種生成函數對象的方法,它比 lambda 更簡潔:方法引用。這是一個程序的代碼片段,它維護從任意 key 到 Integer 值的映射。如果該值被解釋為 key 實例數的計數,則該程序是多集實現。代碼段的功能是將數字 1 與 key 相關聯(如果它不在映射中),并在 key 已存在時增加相關值:
map.merge(key, 1, (count, incr) -> count + incr);
??請注意,此代碼使用 merge 方法,該方法已添加到 Java 8 中的 Map 接口。如果給定鍵 key 沒有映射,則該方法只是插入給定的值; 如果已存在映射,則 merge 將給定的函數應用于當前值和給定值,并使用結果覆蓋當前值。這段代碼表示 merge 方法的典型用例。
??代碼讀起來很 nice,但仍然有一些樣板【代碼】。參數 count 和 incr 不會增加太多值,并且占用相當大的空間。實際上,所有 lambda 告訴你的是該函數返回其兩個參數的總和。從 Java 8 開始,Integer(以及所有其他包裝的數字基本類型)提供了一個完全相同的靜態方法 sum。我們可以簡單地傳遞對此方法的引用,獲得相同的結果,并且【代碼】看起來不會那么亂:
map.merge(key, 1, Integer::sum);
??方法具有的參數越多,使用方法引用可以消除的樣板【代碼】就越多。但是,在某些 lambda 中,你選擇的參數名稱提供了有用的文檔,使得 lambda 比方法引用更易讀和可維護,即使 lambda 更長。
??對于一個你不能用 lambda 做的方法引用,你無能為力(有一個模糊的例外 - 如果你很好奇,請參閱 JLS,9.9-2)。也就是說,方法引用通常會導致更短,更清晰的代碼。如果 lambda 變得太長或太復雜,它們也會給你一個方向(out):你可以將 lambda 中的代碼提取到一個新方法中,并用對該方法的引用替換 lambda。你可以為該方法提供一個好名稱,并將其記錄在核心的內容中。
??如果你使用 IDE 進行編程,如果可以的話,它就會提供方法引用替換 lambda。你要經常(并不總是)接受 IDE 提供的建議。有時候,lambda 將比方法引用更簡潔。當方法與 lambda 屬于同一類時,這種情況最常發生。例如,考慮這個片段,假定它出現在名為 GoshThisClassNameIsHumongous 的類中:
service.execute(GoshThisClassNameIsHumongous::action);
??使用 lambda 看起來像這樣:
service.execute(() -> action());
??使用方法引用的代碼段既不比使用 lambda 的代碼段更短也更清晰,所以更喜歡后者。類似地,Function 接口提供了一個通用的靜態工廠方法來返回 Identity 函數 Function.identity()。它通常更短更清潔,不使用此方法,而是編寫等效的 lambda 內聯:x -> x。
??許多方法引用會引用靜態方法,但有四種方法引用不會引用靜態方法。其中兩個是綁定和未綁定的實例方法引用。在綁定引用中,接收對象在方法引用中指定。綁定引用在本質上類似于靜態引用:函數對象采用與引用方法相同的參數。在未綁定的引用中,在應用函數對象時,通過方法聲明的參數之前的附加參數指定接收對象。未綁定引用通常用作流管道(stream pipelines)(第 45 項)中的映射和過濾功能。最后,對于類和數組,有兩種構造函數引用。構造函數引用充當工廠對象。所有五種方法參考總結在下表中:
Method Ref Type | Example | Lambda Equivalent |
---|---|---|
Static | Integer::parseInt | str -> Integer.parseInt(str) |
Bound | Integer::parseIntr | Instant then = Instant.now(); t -> then.isAfter(t) |
Unbound | String::toLowerCase | str -> str.toLowerCase() |
Class Constructor | TreeMap<K, V>::new | () -> new TreeMap<K, V> |
Array Constructor | int[]::new | len -> new int[len] |
??總之,方法引用通常提供一種更簡潔的 lambda 替代方案。在使用方法引用可以更簡短更清晰的地方,就使用方法引用,如果無法使代碼更簡短更清晰的地方就堅持使用 lambda。(Where method references are shorter and clearer, use them; where they aren’t, stick with lambdas.)
44.堅持使用標準的函數接口
??既然 Java 有 lambda,那么編寫 API 的最佳實踐已經發生了很大變化。例如,模板方法模式[Gamma95],其中子類重寫基本方法進而具體化其超類的行為,遠沒那么有吸引力。現在的替代方案是提供一個靜態工廠或構造函數,它接受一個函數對象來實現相同的效果。更一般地說,你將編寫更多以函數對象作為參數的構造函數和方法。需要謹慎地選擇正確的功能參數類型。
??考慮 LinkedHashMap。你可以通過重寫其受保護的 removeEldestEntry 方法將此類用作緩存,該方法每次將新 key 添加到 map 時都會調用。當此方法返回 true 時,map 將刪除其最舊的 entry,該 entry 將傳遞給該方法。 以下覆蓋允許 map 增長到一百個 entry,然后在每次添加新 key 時刪除最舊的 entry,保留最近的一百個 entry:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}
??這種技術【實現方式】很好,但你可以用 lambda 做得更好。如果現在編寫 LinkedHashMap,它將有一個帶有函數對象的靜態工廠或構造函數。查看 removeEldestEntry 的聲明,你可能會認為函數對象應該采用 Map.Entry <K,V>并返回一個布爾值,但是不會這樣做:removeEldestEntry 方法調用 size()來獲取 map 中 entry 的數目,因為 removeEldestEntry 是 map 的實例方法。傳遞給構造函數的函數對象不是 map 上的實例方法,并且無法捕獲它,因為在調用其工廠或構造函數時 map 尚不存在。因此,map 必須將自身傳遞給函數對象,因此函數對象必須在輸入的地方獲得 map,就像獲取最老的 entry【方式】一樣【函數的形參需要傳入 map 本身以及最老的 entry】。如果你要聲明這樣一個功能性接口,它看起來像這樣:
// Unnecessary functional interface; use a standard one instead.
@FunctionalInterface
interface EldestEntryRemovalFunction<K,V>{
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
??此接口可以正常工作,但您不應該使用它,因為你不需要為了這個目的聲明新接口。java.util.function 包提供了大量標準功能性接口供您使用。如果其中一個標準功能接口完成了這項工作,您通常應該優先使用它,而不是專門構建的功能接口。這將使您的 API 學習起來更容易,通過減少其概念表面積(by reducing its conceptual surface area),并將提供重要的互操作性優勢(and will provide significant interoperability benefits),因為許多標準功能性接口提供有用的默認方法。例如,Predicate 接口提供了結合斷言(combine predicates)的方法。對于 LinkedHashMap 示例,應優先使用標準 BiPredicate <Map <K,V>,Map.Entry <K,V >>接口,而不是自定義 EldestEntryRemovalFunction 接口。java.util.Function 中有 43 個接口。不指望你記住它們,但如果你記得 6 個基本接口,你可以在需要時得到其余的接口。基本接口對對象引用類型進行操作。Operator 接口表示結果和參數類型相同的函數。Predicate 接口表示一個接收一個參數并返回布爾值的函數。Function 接口表示其參數和返回類型不同的函數。Supplier 接口表示不帶參數并返回(或“提供”)值的函數。最后,Consumer 表示一個函數,它接受一個參數并且什么都不返回,本質上消費它的參數(essentially consuming its argument)。6 個基本功能接口總結如下:
Interface | Function Signature | Example |
---|---|---|
UnaryOperator<T> | T apply(T t) | String::toLowerCase |
BinaryOperator<T> | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | boolean test(T t) | Collection::isEmpty |
Function<T,R> | R apply(T t) | Arrays::asList |
Supplier<T> | T get() | Instant::now |
Consumer<T> | void accept(T t) | System.out::println |
??Function 接口有九個附加變體,供結果類型為基本類型時使用。源(source)類型和結果類型總是不同,因為從類型到自身的函數是 UnaryOperator。如果源類型和結果類型都是基本類型,則使用 SrcToResult 作為前綴 Function,例如 LongToIntFunction(六個變體)。如果源是基本類型并且結果是對象引用,則使用<Src>ToObj 作為前綴 Function,例如 DoubleToObjFunction(三個變體)。
??有三個基本功能性接口的兩個參數版本,使用它們是有意義的:BiPredicate <T,U>,BiFunction <T,U,R>和 BiConsumer <T,U>。還有 BiFunction 變體返回三種相關的基本類型:ToIntBiFunction <T,U>,ToLongBiFunction <T,U>和 ToDoubleBiFunction <T,U>。Consumer 的兩個參數變體采用一個對象引用和一個基本類型:ObjDoubleConsumer <T>,ObjIntConsumer <T>和 ObjLongConsumer <T>。總共有九個基本接口的雙參數版本。
??最后,還有 BooleanSupplier 接口,這是 Supplier 的一個變量,它返回布爾值。這是任何標準功能接口名稱中唯一明確提到的布爾類型,但是通過 Predicate 及其四種變體形式支持返回布爾值。BooleanSupplier 接口和前面段落中描述的四十二個接口占所有四十三個標準功能接口。不可否認,這是一個很大的合并,而不是非常正交(Admittedly, this is a lot to swallow, and not terribly orthogonal)。另一方面,你需要的大部分功能接口都是為你編寫的,并且它們的名稱足夠常規,以便你在需要時不會遇到太多麻煩。
??大多數標準功能接口僅提供對基本類型的支持。不要試圖用基本類型的包裝類來使用基本的功能性接口,而不是用基本類型的功能性接口(Don’t be tempted to use basic functional interfaces with boxed primitives instead of primitive functional interfaces)。 雖然它有效,但是它違了第 61 項的建議,“基本類型優先于裝箱基本類型”。使用裝箱基本類型進行批量操作可能會導致致命的性能后果。
??現在你知道,通常【情況下】應該使用標準功能性接口而不是編寫自己的接口。但你應該什么時候寫自己的【功能性接口呢】?當然,如果那些標準【接口】沒有符合您的需要,您需要自己編寫,例如,如果您需要一個帶有三個參數的謂詞(predicate),或者一個拋出已檢查異常的謂詞(predicate)。但有時你應該編寫自己的功能性接口,即使其中一個標準結構完全相同。
??考慮我們的老朋友 Comparator<T>,它在結構上與 ToIntBiFunction <T,T>接口相同。即使后者接口已經存在,當前者被添加到庫中時,使用它也是錯誤的。Comparator 有幾個值得擁有自己的接口原因。首先,它的名稱每次在 API 中使用時都提供了優秀的文檔,并且它被大量使用。其次,Comparator 接口對構成有效實例的內容有很強的要求,有效實例包含其通用約定( general contract)。通過接口的實現,你承諾遵守其約定。第三,接口配備了大量有用的默認方法來轉換和組合比較器(comparators)。
??如果你需要一個與 Comparator 共享以下一個或多個特性的功能接口,您應該認真考慮編寫專用的功能接口而不是使用標準接口:
- 它將被普遍使用,并可從描述性名稱中受益。
- 它與之相關的約定很強(It has a strong contract associated with it)。
- 它將受益于自定義的默認方法。
??如果您選擇編寫自己的功能性接口,請記住它是一個界面,因此應該非常謹慎地設計(第 21 項)。
??請注意,EldestEntryRemovalFunction 接口(原書第 199 頁)標有@FunctionalInterface 注釋。此注釋類型在靈魂(spirit)上與@Override 類似。它是程序員意圖的聲明,有三個目的:它告訴讀者該類及其文檔,該接口旨在啟用 lambda;它保持誠實,因為如果它包含多個抽象方法,接口就無法編譯;并且它可以防止維護者在接口升級時意外地將抽象方法添加到接口。始終使用@FunctionalInterface 注釋來注釋您的功能接口。
??最后應該關心的點是關于 API 中功能性接口的使用。如果在客戶端中有可能產生歧義,則不要提供具有多個重載的方法,這些方法在相同的參數位置采用不同的功能接口。這不僅僅是一個理論問題。ExecutorService 的 submit 方法可以采用 Callable <T>或 Runnable,并且可以編寫一個需要強制轉換的客戶端程序來表示正確的重載(第 52 項)。避免此問題的最簡單方法是不要編寫在同一參數位置使用不同功能接口的重載。這是第 52 項建議中的一個特例,“慎用重載”。
??總而言之,既然 Java 已經有了 lambda,那么在設計 API 時必須考慮到 lambda。接受輸入上的功能接口類型并在輸出上返回它們。通常最好使用 java.util.function.Function 中提供的標準接口,但請注意那些相對少見的情況,那就最好編寫自己的功能接口。
45.謹慎使用 Stream
??在 Java 8 中添加了 Stream API,以簡化串行或并行批量執行操作的任務。這個 API 提供了兩個關鍵的抽象概念:流(stream)表示有限或無限的數據元素序列,流管道(stream pipeline)表示對這些元素的多級計算。流中的元素可以來自任何地方。常見的來源包括集合,數組,文件,正則表達式模式匹配器,偽隨機數生成器和其他流。流中的數據元素可以是對象的引用或基本類型。支持三種基本類型:int,long 和 double。
??流管道由源流和零個或多個中間操作(intermediate operations )以及一個終端操作( terminal operation)組成。每個中間操作以某種方式轉換流,例如將每個元素映射到該元素的函數或過濾掉不滿足某些條件的所有元素。中間操作都將一個流轉換為另一個流,其元素類型可以與輸入流相同或與之不同。終端操作對從最后的中間操作產生的流執行最終計算,例如將其元素存儲到集合中,返回某個元素或打印其所有元素。
??流管道是懶求值(evaluated lazily):在調用終端操作之前是不會開始求值的,并且不會去計算那些在完成終端操作的過程中不需要的數據元素。這種懶求值使得可以使用無限流。請注意,沒有終端操作的流管道是靜默無操作的,因此不要忘記包含一個【終端操作】(Stream pipelines are evaluated lazily: evaluation doesn’t start until the terminal operation is invoked, and data elements that aren’t required in order to complete the terminal operation are never computed. This lazy evaluation is what makes it possible to work with infinite streams. Note that a stream pipeline without a terminal operation is a silent no-op, so don’t forget to include one. )。
??流 API 非常流暢:它旨在允許將構成管道的所有調用鏈接(chain)到單個表達式中。實際上,多個管道可以鏈接(chain)在一起形成一個表達式。
??默認情況下,流管道按順序運行。使管道并行執行就像在管道中的任何流上調用并行方法一樣簡單,但很少這樣做(第 48 項)。
??流 API 具有足夠的通用性(The streams API is sufficiently versatile),幾乎任何計算都可以使用流來執行,但僅僅因為你可以這么做并不意味著你應該這樣做。如果使用得當,流可以使程序更短更清晰; 如果使用不當,可能會使程序難以閱讀和維護。
??考慮以下程序,該程序從字典文件中讀取單詞并打印其大小符合用戶指定的最小值的所有相同字母異序詞組(anagram groups)。回想一下,如果兩個單詞由不同順序的相同字母組成,則它們是相同字母異序詞。程序從用戶指定的字典文件中讀取每個單詞并將單詞放入 map 中。map 的鍵是用字母按字母順序排列的單詞,因此“staple”的鍵是“aelpst”,“petals”的鍵也是“aelpst”:兩個單詞是相同字母異序詞,所有的相同字母異序詞共享相同的字母形式(或 alphagram,因為它有時是已知的((or alphagram, as it is sometimes known))。map 的值是包含按字母順序排列的共享形式的所有單詞的列表。字典處理完畢后,每個列表都是一個完整的相同字母異序詞組。然后程序遍歷 map 的 values()并打印每個大小符合閾值的列表:
// Prints all large anagram groups in a dictionary iteratively
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
??該計劃的一個步驟值得注意。將每個單詞插入到 map 中(以粗體顯示的:groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word);)使用了在 Java 8 中添加的 computeIfAbsent 方法。此方法在 map 中查找鍵:如果鍵存在,則該方法僅返回與其關聯的值。如果不是,則該方法通過將給定的函數對象應用于鍵來計算值,將該值與鍵相關聯,并返回計算的值。computeIfAbsent 方法簡化了將多個值與每個鍵相關聯的映射的實現。
??現在考慮以下程序,它解決了同樣的問題,但大量使用了流。請注意,除了打開字典文件的代碼之外,整個程序都包含在一個表達式中。在單獨的表達式中打開字典的唯一原因是允許使用 try-with-resources 語句,以確保字典文件已關閉:
// Overuse of streams - don't do this!
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
??如果你發現此代碼難以閱讀,請不要擔心; 你不是一個人。它更短,但可讀性更小,特別是對于不是使用流的專家級程序猿。過度使用流會使程序難以閱讀和維護。
??幸運的是,有一個讓人開心的工具。以下程序使用流而不會過度使用流來解決相同的問題。結果是一個比原始程序更短更清晰的程序:
// Tasteful use of streams enhances clarity and conciseness
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
// alphabetize method is the same as in original version
}
??即使你以前很少接觸過流,這個程序也不難理解。它在 try-with-resources 塊中打開字典文件,獲取包含文件中所有行的流。stream 變量被命名為 words,表示流中的每個元素都是一個 word。此流上的管道沒有中間操作; 它的終端操作將所有 word 收集到一個 map 中,該 map 按字母順序排列單詞(第 46 項)。這與在以前版本的程序中構建的 map 完全相同。然后在 map 的 values()中打開一個新的 Stream<List<String>>。當然,這個流中的元素是相同字母異序詞組。過濾流以便忽略大小小于 minGroupSize 的所有組,最后,通過終端操作 forEach 打印剩余的組。
??請注意,小心選擇了 lambda 參數名稱。參數 g 應該真正命名為 group,但是生成的代碼行對于本書來說太寬了。在沒有顯式類型的情況下,仔細命名 lambda 參數對于流管道的可讀性至關重要。
??另外請注意,單詞字母化是在單獨的 alphabetize 方法中完成的。這通過提供操作的名稱并將實現細節保留在主程序之外來增強可讀性。使用輔助方法對于流管道中的可讀性比在迭代代碼中更為重要,因為管道缺少顯式類型信息和命名臨時變量。
??可以使用流重新實現 alphabetize 方法,但是基于流的 alphabetize 方法不太清晰,更難以正確編寫,并且可能更慢。這些缺陷是由于 Java 缺乏對原始 char 流的支持(這并不意味著 Java 應該支持 char 流;這樣做是不可行的)。要演示使用流處理 char 值的危險,請考慮以下代碼:
"Hello world!".chars().forEach(System.out::print);
??你可能希望它打印 Hello world!,但如果你運行它,你會發現它打印 721011081081113211911111410810033。這是因為“Hello world!”.chars()返回的流的元素不是 char 值而是 int 值,因此調用的是 print 的 int 重載【方法】。令人遺憾的是,名為 chars 的方法返回一個 int 值流。你可以通過使用強制轉換來強制調用正確的重載來修復程序:
"Hello world!".chars().forEach(x -> System.out.print((char) x));
??但理想情況下,你應該避免使用流來處理 char 值。
??當你開始使用流時,你可能會有將所有循環轉換為流的沖動的感覺,但要抵制這種沖動。盡管這只是有可能發生,但它會損害代碼庫的可讀性和可維護性。通常,使用流和遍歷的某種組合可以最好地完成中等復雜程度的任務,如上面的 Anagrams 程序所示。因此,重構現有代碼以使用流,并僅在有意義的情況下在新代碼中使用它們。
??如該項目中的程序所示,流管道使用函數對象(通常是 lambdas 或方法引用)表示重復計算,而遍歷代碼使用代碼塊表示重復計算。以下操作你可以在代碼塊中執行,但無法在函數對象中執行:
在代碼塊中,你可以讀取或修改范圍內的任何局部變量; 在 lambda 中,你只能讀取最終或有效的最終變量[JLS 4.12.4],并且你無法修改任何局部變量。
在代碼塊中,不可以從封閉方法返回,中斷或繼續封閉循環,或拋出聲明此方法被拋出的任何已受檢異常; 在一個 lambda 你無法做到這些事情。
??如果使用這些技巧可以更好地表達計算【過程】,那么流就可能不是最好的方式(If a computation is best expressed using these techniques, then it’s probably not a good match for streams)。相反,流可以很容易做一些事情:
- 均勻地轉換元素序列
- 過濾元素序列
- 使用單個操作組合元素序列(例如,添加它們,串聯(concatenate )它們或計算它們的最小值)
- 將元素序列累積(accumulate)到集合中,或者通過一些常見屬性對它們進行分組
- 在元素序列中搜索滿足某個條件的元素
??如果使用這些技巧可以更好地表達計算【過程】,那么流是它的良好候選者。
??使用流很難做的一件事是同時從管道的多個階段訪問相應的元素:一旦將值映射到某個其他值,原始值就會丟失。一種解決方法是將每個值映射到包含原始值和新值的對對象(pair object),但這不是一個令人滿意的解決方案,尤其是如果管道的多個階段需要對對象。由此產生的代碼是混亂和冗長的,這破壞了流的主要目的。如果適當使用的話,更好的解決方法是在需要訪問早期階段值的時候反轉映射。(When it is applicable, a better workaround is to invert the mapping when you need access to the earlier-stage value)。
??例如,讓我們編寫一個程序來打印前 20 個梅森素數(Mersenne primes)。為了更新你的記憶,梅森數是一個 2^p-1 的數字。如果 p 是素數,相應的梅森數可能是素數; 如果是這樣的話,那就是梅森素數。作為我們管道中的初始流,我們需要所有素數。這是一種返回該(無限)流的方法。我們假設使用靜態導入來輕松訪問 BigInteger 的靜態成員:
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
??方法(primes)的名稱是描述流的元素的復數名詞。強烈建議所有返回流的方法使用此命名約定,因為它增強了流管道的可讀性。該方法使用靜態工廠 Stream.iterate,它接受兩個參數:流中的第一個元素,以及從前一個元素生成流中的下一個元素的函數。這是打印前 20 個梅森素數的程序:
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
??這個程序是上文描述中的直接編碼:它從素數開始,計算相應的梅森數,過濾掉除素數之外的所有數字(幻數 50 控制概率素性測試(the magic number 50 controls the probabilistic primality tes)),將得到的流限制為 20 個元素,并打印出來。
??現在假設我們想要在每個梅森素數之前加上它的指數(p)。該值僅出現在初始流中,因此在終端操作中無法訪問,從而打印結果。幸運的是,通過反轉第一個中間操作中發生的映射,可以很容易地計算出梅森數的指數。指數只是二進制表示中的位數,因此該終端操作生成所需的結果:
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
??有很多任務,無論是使用流還是迭代都不明顯。例如,考慮初始化一副新牌的任務。假設 Card 是一個值不可變的類,它封裝了 Rank 和 Suit,兩者都是枚舉類型。此任務代表任何需要的計算可以從兩組中選擇所有元素對的任務。數學家稱之為兩組的笛卡爾積(Cartesian product )。這是一個帶有嵌套 for-each 循環的迭代實現,對你來說應該很熟悉:
// Iterative Cartesian product computation
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));
return result;
}
??這是一個基于流的實現,它使用了中間操作 flatMap。此操作將流中的每個元素映射到流,然后將所有這些新流連接成單個流(或展平它們(or flattens them))。請注意,此實現包含嵌套的 lambda,以粗體顯示;
// Stream-based Cartesian product computation
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit ->
Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}
??newDeck 的兩個版本中哪一個更好?它歸結為個人偏好和你的編程環境。第一個版本更簡單,也許感覺更自然。大部分 Java 程序猿將能夠理解和維護它,但是一些程序猿會對第二個(基于流的)版本感覺更舒服。如果你對流和函數式編程很精通,那么它會更簡潔,也不會太難理解。如果你不確定自己喜歡哪個版本,則迭代版本可能是更安全的選擇。如果你更喜歡流版本,并且你相信其他使用該代碼的程序猿跟你有共同的偏好,那么你應該使用它。
??總之,一些任務最好用流完成,其他任務最好用遍歷完成。通過組合這兩種方法可以最好地完成許多任務。選擇哪種方法用于任務沒有硬性規定,但有一些有用的啟發式方法。在許多情況下,將清楚使用哪種方法; 在某些情況下,它不會。如果你不確定某個任務是否更適合流或遍歷,那么就兩個都嘗試一下,并看一下哪個更好。
46.優先選擇 Stream 中無副作用的函數
??如果你是一個【使用】流的新手,可能很難掌握它們。僅僅將你的計算【過程】表示為流管道可能很難。當你成功的時候【成功地將計算過程用流管道表示出來】,你的程序會運行,但你可能幾乎沒有任何好處。Streams 不僅僅是一個 API,它還是一個基于函數式編程的范例。為了獲得流必須提供的表現力,速度和某些情況下的并行性,你必須采用范例和 API。
??流范例中最重要的部分是將計算結構化為一系列轉換,其中每個階段的結果盡可能接近前一階段結果的純函數( pure function )。純函數的【執行】結果取決于其輸入:它不依賴于任何可變狀態,也不更新任何狀態。為了實現這一點,你傳遞給流操作的任何函數對象(中間或終端)都應該沒有副作用。
??有時,你可能會看到類似于此代碼段的流代碼,它會在文本文件中構建單詞的頻率表:
// Uses the streams API but not the paradigm--Don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
??這段代碼出了什么問題?畢竟,它使用流,lambdas 和方法引用,并得到正確的答案。簡單地說,它根本不是流代碼; 它的迭代代碼偽裝成流代碼。它沒有從流 API 中獲益,并且它比相應的迭代代碼更長,更難以閱讀,并且可維護性更小。問題源于這樣一個事實:這個代碼在一個終端 forEach 操作中完成所有工作,使用一個變異外部狀態的 lambda(頻率表)。執行除了呈現流執行的計算結果之外的任何操作的 forEach 操作都是“代碼中的壞味道”,就比如一個變異狀態的 lambda。那么這段代碼應該怎么樣?
// Proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}
??此代碼段與前一代碼相同,但正確使用了流 API。它更短更清晰。那么為什么有人會用另一種方式寫呢? 因為它使用了他們已經熟悉的工具。Java 程序員知道如何使用 for-each 循環,而 forEach 終端操作是類似的。但 forEach 操作是終端操作中最不強大的操作之一,也是最不友好的流操作。它很顯然是使用了迭代,因此不適合并行化。forEach 操作應僅用于報告流計算的結果,而不是用于執行計算。有時,將 forEach 用于其他目的是有意義的,例如將流計算的結果添加到預先存在的集合中。
??改進的代碼使用了一個收集器(collector),這是一個新概念,你必須學習了才能使用流。Collectors API 是令人生畏的:它有三十九種方法,其中一些方法有多達五種類型參數。好消息是,你可以從這個 API 中獲得大部分好處,而無需深入研究其完整的復雜性。對于初學者,你可以忽略 Collector 接口,并將收集器視為封裝縮減策略的不透明對象(an opaque object that encapsulates a reduction strategy)。在這種情況下,縮減意味著將流的元素組合成單個對象。收集器生成的對象通常是一個集合(它代表名稱收集器((which accounts for the name collector))。
??用于將流的元素收集到真正的集合中的收集器是很簡單的。有三個這樣的收集器:toList(),toSet()和 toCollection(collectionFactory)。它們分別返回一個集合,一個列表和一個程序猿指定的集合類型。有了這些知識,我們可以編寫一個流管道來從頻率表中提取前十個列表。
// Pipeline to get a top-ten list of words from a frequency table
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
??請注意,我們沒有使用其類 Collectors 限定 toList 方法。習慣性地將收集器的所有成員都靜態導入是明智的,因為它使流管道更具可讀性。
??這段代碼中唯一棘手的是我們傳遞給 sorted【方法】的部分,compare(freq::get).reversed()的比較器。comparing 方法是采用密鑰提取功能的比較器構造方法(第 14 項)。該函數接收一個單詞,“提取(extraction)”實際上是一個表查找:綁定方法引用 freq::get 在頻率表中查找單詞并返回單詞在文件中出現的次數。最后,我們在比較器上調用 reverse,因此我們將單詞【出現的頻率】從最頻繁到最不頻繁進行排序。然后將流限制為十個單詞并將它們收集到一個列表中是一件簡單的事情。
??之前的代碼片段使用 Scanner 的流方法通過掃描程序獲取流。該方法時在 Java 9 中添加的。如果你使用的是早期版本,則可以使用類似于第 47 項(streamOf(Iterable <E>))的適配器來將實現了 Iterator 的 scanner 轉換為流。
??那么 Collectors 的其他 36 種方法呢?它們中的大多數存在是為了讓你將流收集到 map 中,這比將它們收集到真實集合中要復雜得多。每個流元素與鍵和值相關聯,并且多個流元素可以與相同的鍵相關聯。
??最簡單的 map 收集器是 toMap(keyMapper,valueMapper),它接受兩個函數,其中一個函數將一個流元素映射到一個鍵,另一個函數映射到一個值。我們在第 34 項的 fromString 實現中使用了這個收集器來創建從枚舉的字符串形式到枚舉本身的映射:
// Using a toMap collector to make a map from string to enum
private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect(toMap(Object::toString, e -> e));
??如果流中的每個元素都映射到唯一鍵,則這種簡單的 toMap 形式是完美的。 如果多個流元素映射到同一個鍵,則管道將以 IllegalStateException 異常來終止【計算】。
??更復雜的 toMap 形式(比如 groupingBy 方法)為你提供了各種方法來提供處理此類沖突的策略。一種方法是除了鍵和值映射器之外,還為 toMap 方法提供合并函數。合并函數是 BinaryOperator<V>,其中 V 是映射的值類型。使用合并函數將與鍵關聯的任何其他值與現有值組合,因此,例如,如果合并函數是乘法,則通過值映射最終得到的值是與鍵關聯的所有值的乘積。
??toMap 的三參數形式對于創建從鍵到與該鍵關聯的所選元素的映射也很有用。例如,假設我們有各種藝術家的唱片專輯流,我們想要一個從錄音藝術家到最暢銷專輯的 map 映射。這個 collector 就能完成這項工作。
// Collector to generate a map from key to chosen element for key
Map<Artist, Album> topHits = albums.collect(toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
??請注意,比較器使用靜態工廠方法 maxBy,它是從 BinaryOperator 靜態導入的。此方法將 Comparator<T>轉換為 BinaryOperator<T>,用于計算指定比較器隱含的最大值。在這種情況下,比較器由比較器構造方法 comparing 返回,它采用密鑰提取器功能(key extractor function)Album::sales。這可能看起來有點復雜,但代碼可讀性很好。簡而言之,它說,“將專輯流轉換為 map,將每位藝術家映射到銷售量最佳專輯的專輯。”這接近問題的陳述【程度】令人感到驚訝【意思就是說這代碼的意思很接近問題的描述(OS:臭不要臉)】。
??toMap 的三參數形式的另一個用途是產生一個收集器,當發生沖突時強制執行 last-write-wins 策略【保留最后一個沖突值】。對于許多流,結果將是不確定的,但如果映射函數可能與鍵關聯的所有值都相同,或者它們都是可接受的,則此收集器的行為可能正是你想要的:
// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (v1, v2) -> v2)
??toMap 的第三個也是最后一個版本采用第四個參數,即一個 map 工廠,用于指定特定的 map 實現,例如 EnumMap 或 TreeMap。
??toMap 的前三個版本也有變體形式,名為 toConcurrentMap,它們并行高效運行并生成 ConcurrentHashMap 實例。
??除了 toMap 方法之外,Collectors API 還提供了 groupingBy 方法,該方法返回【一個】收集器用來生成基于分類器函數(classifier function)將元素分組到類別中的映射。分類器函數接收一個元素并返回它的所屬類別。此類別用作元素的 map 的鍵。groupingBy 方法的最簡單版本是僅采用分類器并返回一個映射,其值是每個類別中所有元素的列表。這是我們在第 45 項中的 Anagram 程序中使用的收集器,用于生成從按字母順序排列的單詞到共享字母順序的單詞列表的映射:
words.collect(groupingBy(word -> alphabetize(word)))
??如果希望 groupingBy 返回一個生成帶有除列表之外的值的映射的收集器,則除了分類器之外,還可以指定下游收集器(downstream collector)。下游收集器從一個包含類別中所有元素的流中生成一個值。此參數的最簡單用法是傳遞 toSet(),這將生成一個映射,其值是元素集而不是列表。這會生成一個映射,該映射將每個類別與類別中的元素數相關聯,而不是包含元素的集合。這就是你在本項目開頭的頻率表示例中看到的內容:
Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));
??groupingBy 的第三個版本允許你指定除下游收集器之外的 map 工廠。請注意,此方法違反了標準的 telescoping 參數列表模式:mapFactory 參數位于 downStream 參數之前,而不是之后。此版本的 groupingBy 使你可以控制包含的映射以及包含的集合(This version of groupingBy gives you control over the containing map as well as the contained collections),因此,例如,你可以指定一個收集器,該收集器返回一個 value 為 TreeSet 的 TreeMap。
??groupingByConcurrent 方法提供了 groupingBy 的所有三個重載的變體。 這些變體并行高效運行并生成 ConcurrentHashMap 實例。還有一個很少使用的 grouping 的相近【的方法】叫做 partitioningBy。代替分類器方法,它接收一個謂詞(predicate)并返回鍵為布爾值的 map。此方法有兩個重載【版本】,其中一個除謂詞之外還包含下游收集器。通過 counting 方法返回的收集器僅用作下游收集器。通過 count 方法直接在 Stream 上提供相同的功能,因此沒有理由說 collect(counting())( there is never a reason to say collect(counting())) 。此屬性還有十五種收集器方法。它們包括九個方法,其名稱以 summing,averaging 和 summarizing 開頭(其功能在相應的基本類型流上可用)。它們還包括 reducing 方法的所有重載,以及 filter,mapping,flatMapping 和 collectingAndThen 方法。大多數程序猿可以安心地忽略大多數這種方法。從設計角度來看,這些收集器代表了嘗試在收集器中部分復制流的功能,以便下游收集器可以充當“迷你流(ministreams)”。
??我們還有三種 Collectors 方法尚未提及。雖然他們在 Collectors 里面,但他們不涉及集合。前兩個是 minBy 和 maxBy,它們取比較器并返回由比較器確定的流中的最小或最大元素。它們是 Stream 接口中 min 和 max 方法的小擴展【簡單的實現】,是 BinaryOperator 中類似命名方法返回的二元運算符的收集器類似物。回想一下,我們在最暢銷專輯的例子中使用了 BinaryOperator.maxBy。
??最后的 Collectors 方法是 join,它只對 CharSequence 實例的流進行操作,例如字符串。 在其無參數形式中,它返回一個簡單地連接元素的收集器。它的一個參數形式采用名為 delimiter 的單個 CharSequence 參數,并返回一個連接流元素的收集器,在相鄰元素之間插入分隔符。如果傳入逗號作為分隔符,則收集器將返回逗號分隔值字符串(但請注意,如果流中的任何元素包含逗號,則字符串將不明確)。除了分隔符之外,三個參數形式還帶有前綴和后綴。生成的收集器會生成類似于打印集合時獲得的字符串,例如[came, saw, conquered]。
??總之,流管道編程的本質是無副作用的功能對象。這適用于傳遞給流和相關對象的幾乎所有的函數對象(This applies to all of the many function objects passed to streams and related objects)。終端操作 forEach 僅應用于報告流執行的計算結果,而不是用于執行計算。為了正確使用流,你必須了解收集器。最重要的收集器工廠是 toList,toSet,toMap,groupingBy 和 join。
47.Stream 要優先用 Collection 作為返回類型
??許多方法返回元素序列。在 Java 8 之前,這些方法的返回類型是集合的接口 Collection,Set 和 List;Iterable;和數組類型。通常,很容易決定返回哪些類型。準確來說是一個集合接口。如果該方法僅用于 for-each 循環或返回的序列無法實現某些 Collection 方法(通常為 contains(Object)),則使用 Iterable 接口。如果返回的元素是基本類型值或者存在嚴格的性能要求,則使用數組。在 Java 8 中,流被添加到 java 庫中,這使得為返回序列的方法選擇恰當的返回類型的任務變得非常復雜。
??你可能聽說過,流現在是返回一系列元素的公認選擇,正如第 45 項所描述的,流不會使迭代過時:編寫好的代碼需要適當地組合流和遍歷。如果 API 只返回一個流,而某些用戶想要使用 for-each 循環遍歷返回的序列,那么這些用戶理所當然會感到不安。特別令人沮喪的是,Stream 接口包含 Iterable 接口中唯一的抽象方法,Stream 的此方法規范與 Iterable 兼容。
??可悲的是,這個問題沒有好的解決方法。乍一看,似乎可以將方法引用傳遞給 Stream 的迭代器方法。結果代碼可能有點嘈雜和模糊,但并非不合理:
// Won't compile, due to limitations on Java's type inference
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
// Process the process
}
??不幸的是,如果你嘗試編譯此代碼,你將收到一條錯誤消息:
Test.java:6: error: method reference not expected here
for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator) {
^
??為了使代碼編譯,你必須將方法引用強制轉換為適合參數化的 Iterable:
// Hideous workaround to iterate over a stream
for (ProcessHandle ph : (Iterable<ProcessHandle>) ProcessHandle.allProcesses()::iterator)
??此客戶端代碼有效,但在實踐中使用它太嘈雜和模糊。更好的解決方法是使用適配器方法。JDK 沒有提供這樣的方法,但是使用上面的代碼片段中相同的技術,可以很容易地編寫一個方法。請注意,在適配器方法中不需要強制轉換,因為 Java 類型推斷在此上下文中正常工作:
// Adapter from Stream<E> to Iterable<E>
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
??使用此適配器,你可以使用 for-each 語句迭代任何流:
for (ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
// Process the process
}
??請注意,第 34 項中的 Anagrams 程序的流版本使用 Files.lines 方法讀取字典,而迭代版本使用 scanner。Files.lines 方法優于 scanner,它可以在讀取文件時悄悄地處理(silently swallows)任何異常。理想情況下,我們也會在迭代版本中使用 Files.lines。如果 API 僅提供對序列的流的訪問并且他們希望使用 for-each 語句遍歷序列,那么程序員將會做出這種折中的方法【在迭代的版本中使用 Files.lines】。
??相反,想要使用流管道處理序列的程序猿理所當然會因為 API 僅提供 Iterable 而感到難過【傲嬌】。再提一次 JDK 沒有提供適配器,但編寫一個是很容易的:
// Adapter from Iterable<E> to Stream<E>
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
??如果你正在編寫一個返回一系列對象的方法,并且你知道它只會在流管道中使用,那么你當然可以隨意返回一個流。類似地,返回僅用于遍歷的序列的方法應返回 Iterable。但是,如果你正在編寫一個返回序列的公共 API,那么你應該為想要編寫流管道的用戶以及想要編寫 for-each 語句的用戶提供服務。除非你有充分的理由相信【使用該 API 的】大多數用戶希望使用相同的機制。
??Collection 接口是 Iterable 的子類型,并且具有 stream 方法,因此它提供迭代和流訪問。因此,Collection 或適當的子類型通常是公共序列返回方法的最佳返回類型。 Arrays 還提供了 Arrays.asList 和 Stream.of 方法的簡單遍歷和流訪問。如果你返回的序列小到足以容易地放入內存中,那么最好返回一個標準的集合實現,例如 ArrayList 或 HashSet。但是不要在內存中存儲大的序列而只是為了將它作為集合返回。
??如果你返回的序列很大但可以簡潔地表示,請考慮實現一個特殊用途的集合。例如,假設你要返回給定集的冪集(power set),該集包含其所有子集。{a,b,c}的冪集為{{},{a},{b},{c},{a,b},{a,c},{b,c},{a,b,c}}。如果一個集合具有 n 個元素,則其冪集具有 2^n 個。因此,你甚至不應該考慮將冪集存儲在標準集合的實現中。但是,在 AbstractList 的幫助下,很容易為此實現自定義集合。
??技巧是使用冪集中每個元素的索引作為位向量,其中索引中的第 n 位表示源集合中是否存在第 n 個元素。本質上,從 0 到 2^n - 1 的二進制數和 n 個元素集的冪集之間存在自然映射。以下是代碼:
// Returns the power set of an input set as custom collection
public class PowerSet {
public static final <E> Collection<Set<E>> of(Set<E> s) {
List<E> src = new ArrayList<>(s);
if (src.size() > 30)
throw new IllegalArgumentException("Set too big " + s);
return new AbstractList<Set<E>>() {
@Override public int size() {
return 1 << src.size(); // 2 to the power srcSize
}
@Override public boolean contains(Object o) {
return o instanceof Set && src.containsAll((Set)o);
}
@Override public Set<E> get(int index) {
Set<E> result = new HashSet<>();
for (int i = 0; index != 0; i++, index >>= 1)
if ((index & 1) == 1)
result.add(src.get(i));
return result;
}
};
}
}
??請注意,如果輸入集具有超過 30 個元素,則 PowerSet.of 會拋出異常。這突出了使用 Collection 作為返回類型的缺點(而 Stream 或 Iterable 沒有該缺點):Collection 具有 int 返回大小的方法,該方法將返回序列的長度,限制為 Integer.MAX_VALUE 或 2^31-1。如果集合更大,甚至無限,Collection 規范允許 size 方法返回 2^31-1,但這不是一個完全令人滿意的解決方案。
??為了在 AbstractCollection 上編寫 Collection 實現,你只需要實現 Iterable 所需的兩個方法:contains 和 size。通常,編寫這些方法的有效實現是很容易的。如果不可行,可能是因為在迭代發生之前無法預先確定序列的內容,返回流或可迭代的【結果】,哪種感覺起來更自然就返回哪種。如果你要選擇的話,你可以使用兩種不同的方法將兩種類型都返回。
??有時你會根據實施方式的難易程度選擇返回類型。例如,假設你要編寫一個返回輸入列表的所有(連續)子列表的方法。生成這些子列表只需要三行代碼并將它們放在標準集合中,但保存此集合所需的內存是源列表大小的二次方。雖然這并不像指數級的冪集那么糟糕,但顯然是不可接受的。正如我們為冪集所做的那樣,實現自定義集合將是冗長的,因為 JDK 缺乏 Iterator 框架實現來幫助我們。
??但是【我們可以】直接實現輸入列表的所有子列表的流,盡管它確實需要一些洞察力。讓我們調用一個子列表,該子列表包含列表的第一個元素和列表的前綴(prefix)。例如,(a,b,c)的前綴是(a),(a,b)和(a,b,c)。 類似地,讓我們調用包含后綴的最后一個元素的子列表,因此(a,b,c)的后綴是(a,b,c),(b,c)和(c)。洞察的點就是列表的子列表只是前綴的后綴(或相同的后綴的前綴)和空列表。通過這個觀點直接就可以有了清晰、合理簡潔的實施方案:
// Returns a stream of all the sublists of its input list
public class SubLists {
public static <E> Stream<List<E>> of(List<E> list) {
return Stream.concat(Stream.of(Collections.emptyList()), prefixes(list).flatMap(SubLists::suffixes));
}
private static <E> Stream<List<E>> prefixes(List<E> list) {
return IntStream.rangeClosed(1, list.size()).mapToObj(end -> list.subList(0, end));
}
private static <E> Stream<List<E>> suffixes(List<E> list) {
return IntStream.range(0, list.size()).mapToObj(start -> list.subList(start, list.size()));
}
}
??請注意,Stream.concat 方法用于將空列表添加到返回的流中。另請注意,flatMap 方法(第 45 項)用于生成由所有前綴的所有后綴組成的單個流。最后,請注意我們通過映射 IntStream.range 和 IntStream.rangeClosed 返回的連續 int 值的流來生成前綴和后綴。簡單地說,這個習慣用法是整數索引上標準 for 循環的流等價物( This idiom is, roughly speaking, the stream equivalent of the standard for-loop on integer indices)。因此,我們的子列表實現的思想類似明顯的嵌套 for 循環:
for (int start = 0; start < src.size(); start++)
for (int end = start + 1; end <= src.size(); end++)
System.out.println(src.subList(start, end));
??可以將此 for 循環直接轉換為流。結果比我們之前的實現更簡潔,但可讀性稍差。它的思想類似第 45 項中笛卡爾積的流代碼:
// Returns a stream of all the sublists of its input list
public static <E> Stream<List<E>> of(List<E> list) {
return IntStream.range(0, list.size())
.mapToObj(start -> IntStream.rangeClosed(start + 1, list.size())
.mapToObj(end -> list.subList(start, end)))
.flatMap(x -> x);
}
??與之前的 for 循環一樣,此代碼不會產生(emit)空列表。為了解決這個問題,你可以使用 concat,就像我們在之前版本中所做的那樣,或者在 rangeClosed 調用中用(int)Math.signum(start)替換 1。
??這些子列表的流實現中的任何一個都很好,但兩者都需要用戶使用一些 Stream-to-Iterable 適配器或在迭代更自然的地方使用流。Stream-to-Iterable 適配器不僅使客戶端代碼混亂,而且還會使我的機器上的循環速度降低 2.3 倍。專用的 Collection 實現(此處未顯示)相當冗長,但運行速度是我機器上基于流的實現的 1.4 倍。
??總之,在編寫返回元素序列的方法時,請記住,你的某些用戶可能希望將它們作為流進行處理,而其他用戶可能希望使用它們進行遍歷。盡量適應這兩個群體。如果返回集合是可行的,那么就返回集合。如果你已經擁有集合中的元素,或者序列中的元素數量很小足以證明創建新元素是正確的,那么就返回標準集合,例如 ArrayList。否則,請考慮實現自定義的集合,就像我們為冪集所做的那樣。如果返回集合是不可行的,則返回一個流或可迭代的【類型】,無論哪個看起來更自然。如果在將來的 Java 版本中,Stream 接口聲明被修改為擴展(extend)Iterable,那么你應該隨意返回流,因為它們將允許進行流處理和遍歷。
48.謹慎使用 Stream 并行
??在主流語言中,在提供便于并發編程任務功能方面,Java 始終處于最前沿【的位置】(Among mainstream languages, Java has always been at the forefront of providing facilities to ease the task of concurrent programming)。當 Java 于 1996 年發布時,它內置了對線程的支持,具有同步和等待/通知【的功能】(When Java was released in 1996, it had built-in support for threads, with synchronization and wait/notify)。Java 5 引入了 java.util.concurrent 庫,包含并發集合和執行器框架。 Java 7 引入了 fork-join 包,這是一個用于并行分解(parallel decomposition)的高性能框架。Java 8 引入了流,可以通過對并行方法的單個調用來并行化。用 Java 編寫并發程序變得越來越容易,但編寫正確快速的并發程序就跟以前一樣困難。安全性和活性違規(liveness violations )是并發編程中的事實,并行流管道也不例外。
??考慮第 45 項中的這個程序:
// Stream-based program to generate the first 20 Mersenne primes
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
??在我的機器上,該程序立即開始打印質數,并需要 12.5 秒才能完成運行。假設我試圖通過向流管道添加對 parallel()的調用來加速它。你認為它的表現會怎樣?它【的運行速度】會加快幾個百分點嗎?還是慢幾個百分點?可悲的是,發生的事情是它沒有打印任何東西,但是 CPU 使用率飆升至 90%并且無限期地停留在那里(活性失敗(liveness failure))。該程序最終可能會終止,但我不愿意去發現【等待這個結果】; 半小時后我強行停止【了程序】。
??這里發生了什么?簡而言之,流的庫不知道如何并行化此管道并且試探啟動(heuristics)失敗。即使在最好的情況下,如果源來自 Stream.iterate,或者使用中間操作限制,并行化管道也不太可能提高其性能(parallelizing a pipeline is unlikely to increase its performance if the source is from Stream.iterate, or the intermediate operation limit is used.)。這條管道必須應對這兩個問題。更糟糕的是,默認的并行化策略是通過假設處理一些額外元素并丟棄任何不需要的結果不會帶來任何損失的前提下來處理限制的不可預測性。在這種情況下,找到每個梅森質數需要大約兩倍的時間才能找到前一個。因此,計算單個額外元素的成本大致等于計算所有先前元素組合的成本,并且這種看起來沒什么損失的管道會使自動并行化算法癱瘓。這個故事的寓意很簡單:不要不加選擇的地使用并行化流。導致的性能后果可能是災難性的。
??并行性的性能增益最好是在 ArrayList,HashMap,HashSet 和 ConcurrentHashMap 實例上;int 數組;和 long 數組(performance gains from parallelism are best on streams over ArrayList, HashMap, HashSet, and ConcurrentHashMap instances; arrays; int ranges; and long ranges),將這作為一項規則。這些數據結構的共同之處在于它們都可以準確且分成任何所需大小的子范圍的代價是很小的,這使得在并行線程之間劃分工作變得容易。流庫用于執行此任務的抽象是 spliterator,它由 Stream 和 Iterable 上的 spliterator 方法返回。
??所有這些數據結構的另一個重要因素是它們在順序處理時提供了非常好的位置引用(locality of reference):元素的順序和【元素的】引用一起存儲在存儲器中。這些引用所引用的對象在存儲器中可能彼此不接近,這減少了位置引用(The objects referred to by those references may not be close to one another in memory, which reduces locality-of-reference.)。對于并行化操作而言,位置引用非常重要:如果沒有位置引用,線程大部分時間會處在空閑狀態,等待數據從內存傳輸到處理器的緩存。具有最佳位置引用的數據結構是原始數組,因為數據本身連續存儲在存儲器中。
??流管道終端操作的本質也會影響并行執行的有效性。如果與管道的整體工作相比在終端操作中完成了大量工作并且該操作本質上是按順序的,那么并行化管道的有效性是受限的。并行性最佳的終端操作是減少(reductions),其中從管道中出現的所有元素使用 Stream 的 reduce 方法或減少預打包(prepackaged reductions)(例如 min,max,count 和 sum)進行組合。短路操作(shortcircuiting)anyMatch,allMatch 和 noneMatch 也適用于并行操作。Stream 的 collect 方法執行的操作(稱為可變約簡( mutable reductions))不是并行性的良好選擇,因為組合集合的開銷是很昂貴的。
??如果你編寫自己的 Stream,Iterable 或 Collection 實現并且希望獲得良好的并行性能,則必須覆蓋 spliterator 方法并廣泛測試生成的流的并行性能。編寫高質量的 spliterators 是很困難的,超出了本書的范圍。
??并行化流不僅會導致性能不佳,包括活性失敗; 它可能導致不正確的結果和不可預測的行為(安全性失敗)。使用映射器,過濾器和其他程序員提供的不符合其規范的功能對象的管道并行化可能會導致安全性失敗。Stream 規范對這些功能對象提出了嚴格的要求。例如,傳遞給 Stream 的 reduce 操作的累加器和組合器函數必須是關聯的,非侵入的和無狀態的。如果你違反了這些要求(其中一些在第 46 項中討論過),但按順序運行你的管道,則可能會產生正確的結果; 如果你將它并行化,它可能會失敗,也許是災難性的。
??沿著這些思路,值得注意的是,即使并行化的梅森素數程序已經完成,它也不會以正確的(升序)順序打印素數。要保留順序版本顯示的順序,你必須使用 forEachOrdered 替換 forEach 終端操作,該操作保證以相遇順序(encounter order)遍歷并行流。
??即使假設你正在使用有效可拆分的源流(帶有一個并行化或代價低的終端操作)和非侵入(non-interfering)的函數對象,你無法從并行化中獲得很好的加速效果,除非管道做了足夠的實際工作來抵消使用并行化相關的成本(unless the pipeline is doing enough real work to offset the costs associated with parallelism)。作個非常粗略的估計,流中元素的數量乘以每個元素執行的代碼行數應該至少為十萬[Lea14]。
??重要的是要記住并行化流是嚴格的性能優化。與任何優化一樣,你必須在更改之前和之后測試性能,以確保它【的優化是】值得做【的】(第 67 項)。理想情況下,你應該在實際的系統設置中執行測試。通常,程序中的所有并行流管道都在公共 fork-join 線程池中運行。單個行為不當的管道可能會影響系統中其他不相關部分的行為。
??聽起來使用流并行會一直在違背你的意愿,它們確實是這樣的(If it sounds like the odds are stacked against you when parallelizing stream pipelines, it’s because they are.)。那些維護數百萬行代碼的人大量使用流,只發現了在很少數的地方使用并行流是有效地。這并不意味著你應該避免并行化流。在適當的情況下,只需通過向流管道添加并行調用,就可以實現處理器內核數量的近線性(near-linear)加速。某些領域,例如機器學習和數據處理,特別適合這些加速。
??作為并行性有效的流管道的一個簡單示例,請考慮此函數來計算 π(n),素數小于或等于 n:
// Prime-counting stream pipeline - benefits from parallelization
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
??在我的機器上,使用此功能計算 π(10^8)需要 31 秒。 只需添加 parallel()調用即可將時間縮短為 9.2 秒:
// Prime-counting stream pipeline - parallel version
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
??換句話說,并行化計算可以在我的四核機器上將其加速 3.7 倍。 值得注意的是,這并不是你在實踐中如何計算大 n 值的 π(n)。有更高效的算法,特別是 Lehmer 的公式。
??如果要并行化隨機數流,請從 SplittableRandom 實例開始,而不是 ThreadLocalRandom(或基本上過時的 Random)。SplittableRandom 是專門為此而設計的,具有線性加速的潛力。ThreadLocalRandom 設計用于單個線程,并將自適應為并行流的源,但不會像 SplittableRandom 一樣快。隨機同步每個操作,因此會導致過度(近似殺戮)的爭搶(so it will result in excessive, parallelism-killing contention)【意思應該是導致的資源爭搶會很激烈】。
??總之,除非你有充分的理由相信它將保持計算的正確性并提高其速度,否則就不應該嘗試并行化流管道。不恰當地并行化流的成本可能是程序失敗或性能災難。如果你認為并行性可能是合理的,請確保在并行運行時代碼保持【運行結果的】正確,并在實際條件下進行詳細的性能測試。如果你的代碼仍然正確并且這些實驗證明你對性能提升的猜疑,那么才能在生產環境的代碼中使用并行化流(If your code remains correct and these experiments bear out your suspicion of increased performance, then and only then parallelize the stream in production code.)。
參考鏈接
https://gitee.com/lin-mt/effective-java-third-edition
Kotlin開發者社區
專注分享 Java、 Kotlin、Spring/Spring Boot、MySQL、redis、neo4j、NoSQL、Android、JavaScript、React、Node、函數式編程、編程思想、"高可用,高性能,高實時"大型分布式系統架構設計主題。
High availability, high performance, high real-time large-scale distributed system architecture design。
分布式框架:Zookeeper、分布式中間件框架等
分布式存儲:GridFS、FastDFS、TFS、MemCache、redis等
分布式數據庫:Cobar、tddl、Amoeba、Mycat
云計算、大數據、AI算法
虛擬化、云原生技術
分布式計算框架:MapReduce、Hadoop、Storm、Flink等
分布式通信機制:Dubbo、RPC調用、共享遠程數據、消息隊列等
消息隊列MQ:Kafka、MetaQ,RocketMQ
怎樣打造高可用系統:基于硬件、軟件中間件、系統架構等一些典型方案的實現:HAProxy、基于Corosync+Pacemaker的高可用集群套件中間件系統
Mycat架構分布式演進
大數據Join背后的難題:數據、網絡、內存和計算能力的矛盾和調和
Java分布式系統中的高性能難題:AIO,NIO,Netty還是自己開發框架?
高性能事件派發機制:線程池模型、Disruptor模型等等。。。
合抱之木,生于毫末;九層之臺,起于壘土;千里之行,始于足下。不積跬步,無以至千里;不積小流,無以成江河。