Effective Java 3rd Edition — 第七章 lambda表示式與流
Item 42 : Prefer lambdas to anonymous classes
使用lambda表示式來代替匿名類
Collections.sort(words, new Comparator<String>() { public int compare(String s1, String s2) { return Integer.compare(s1.length(), s2.length()); } });
匿名類適合於傳統面向物件程式設計中需要函式物件的場景,特別是策略模式
lambda類似於匿名類但是更為簡潔:
// Lambda expression as function object (replaces anonymous class) Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
lambda隱藏了引數型別和返回值,這樣讓程式碼量更少,同時忽略lambda表示式的引數型別,除非它能讓你的程式碼更清楚。
lambda使用中另外一個需要考慮的是型別推斷,因為編譯器是不會去作型別推斷的。比如上面的程式碼如果words的型別是 List
而不是 List<String>
,程式碼根本不會編譯。因為編譯器能獲取到大部分的型別資訊從而作型別推斷,但是如果不提供型別資訊的話,編譯器就無法知道。
上面的程式碼還可進一步簡化:
words.sort(comparingInt(String::length));
非常重要的一點:
由於lambda表示式缺少命名(型別)和註釋,如果本身計算缺乏解釋性或者程式碼超過3行了就不要使用,那樣反而會讓程式碼更加晦澀難懂,最理想的lambda使用一行就能完事(不超過3行)。
並不是說匿名類就沒用了,比如想建立一個抽象類的例項就可以使用匿名類來實現,而lambda就無法辦到。 同時在lambda表示式中無法獲取到自身的引用,因為在lambda表示式中的 this
指的是你實際使用的例項,在匿名類中 this
指的就是匿名類例項,涉及到上面的情況還是需要使用匿名類了。
lambda和匿名類共享了不能可靠序列化(反序列化)的類屬性,所以千萬不要序列化lambda表示式和匿名類。
總的來說在java8之後儘量不要使用匿名類,除非你建立的例項並不支援函式介面。
Item 48 : Prefer method references to lambdas
多使用方法引用
方法引用比lambda表示式更加簡潔。比如:
map.merge(key, 1, (count, incr) -> count + incr);
這個方法是1.8之後新增到Map中的,就是合併一個Map,
最終會新增一對key-value到map中,value就是後面操作的一個統計,方法返回值就是這個統計的最終value。
如果用方法引用來寫:
map.merge(key, 1, Integer::sum);
在IDEA中有上面第一種程式碼存在的時候會提示可以用方法引用來代替,下面是五種方法引用和lambda表示式對比:
總的來說就是哪個簡單用哪個。
Item 44 : Favor the use of standard functional interfaces
使用標準的函式介面
lambda表示式的出現改變了Java API的設計模式,比如模版方法模式,子類覆蓋父類方法去宣告父類的行為,現在的做法是提供一個靜態工廠方法,傳入函式物件去做同樣的事情。
java.util.function
內建了多種函式介面可供使用,分為6種基本型別:
Operator
介面表明引數和返回值都是一樣;
Predicate
表明函式只接收一個引數並返回一個boolean值;
Supplier
表明函式沒有引數但返回一個結果;
Consumer
表明函式接收一個引數但沒有返回值;
Function
表明引數和返回值都不是同一個型別;
上面六種基本型別針對 int
, long
, double
三種基本資料型別又有3種不同的介面變種,比如 IntPredicate
, LongBinaryOperator
Function
介面有9種變種介面用在返回型別是基本資料型別的時候:
IntToDoubleFunction
3種基本資料型別作為引數並返回物件共3種,比如 DoubleToObjFunction
針對 Predicate
Consumer
Function
又有多引數的介面:
BiPredicate<T, U>
BiFunction<T,U,R>
BiConsumer<T,U>
BiFunction
有3種變種(2個引數,返回基本資料型別):
ToIntBiFunction<T,U>
ToLongBiFunction<T,U>
ToDoubleBiFunction<T,U>
Consumer
3種變種(2個引數,一個物件,一個基本資料型別):
ObjDoubleConsumer
ObjIntConsumer
ObjLongConsumer
最後是 BooleanSupplier
,返回boolean值 Supplier
的一個變種,但是返回boolean在 Predicate
已經得到了支援
上面大部分的函式介面都是對基本資料型別提供的支援,不要使用他們的時候使用包裝類( Integer
等),這樣可能會造成嚴重的效能問題。
那麼什麼時候需要自己定義函式介面而不是使用java內建的函式介面?
Comparator Comparator
一旦自己定義函式介面就需要使用 FunctionalInterface
註解,原因:
- 表明這是一個函式介面用來支援lambda表示式;
- 讓你自己知道這個介面是不會被編譯的除非添加了抽象方法;
- 在介面變更中防止無意的新增抽象方法;
最後一點就是不要在提供多個過載方式的時候在同一個引數位置使用不同的函式介面,這樣可能會造成歧義。
比如 ExecutorService
的 submit
方法,同時支援 Callable
和 Runnable
作為引數,這樣呼叫的時候就有可能需要引數型別轉換才行。
Item 45 : Use streams judiciously
適當的使用流
Java8中新增的流提供了2個重要的抽象:
- 流,代表有限或無限的元素佇列;
- 流管道,代表對流中的元素做的多級運算;
流中的元素來源可以是集合,陣列,檔案,正則匹配資料等等,可以是物件引用也可是基本資料型別,基本資料型別支援 int
long
double
流管道包含了0個或多箇中間操作和一個最終操作,中間操作做一些對映、過濾、匹配等等操作,而最終操作則從最後一箇中間操作返回做最終的計算,返回一個集合或者是計算等等。
流管道是懶操作(lazily),如果不做最終操作,中間操作都是空操作,因為它沒有做任何計算。流管道預設都是順序執行的,雖然可以讓他並行執行,但是很少這樣做。雖然流API可以用在很多計算的場景,但是並不能隨意的使用它。流API能否正確使用會影響你的程式碼可讀性和可維護性。
比如下面的程式碼(排版修改過):
// 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); } } }
上面就是一個過度使用流的一個例子,所以正確的使用流API是非常重要的。
由於缺少引數型別,在lambda表示式中要謹慎給引數命名。
當你開始使用流的時候會非常想把所有程式碼用流重構一遍,但千萬不要這樣做,
只在確實有需要的時候才去重構。
上面程式碼的功能,流通過lambda表示式和方法引用來實現,實際上我們寫程式碼塊或者抽象私有方法(或者helper方法)也能實現上面的功能。
使用程式碼塊的優勢:
- 使用程式碼塊你可以讀取修改任何區域性變數,而lambda表示式你只能讀取
final
變數,不能修改區域性變數; - 使用程式碼塊你可以返回或者丟擲任何檢查異常,在迴圈中使用
break
,continue
來終止,lambda表示式任何一點都無法做到;
流操作非常適合以下的場景:
- 修改元素佇列;
- 過濾元素佇列;
- 用一個單獨操作來合併元素佇列(比如計算總和,最大值,最小值等等);
- 將元素佇列合併到一個集合或者是按照某些條件分組;
- 從元素佇列中查詢滿足條件的元素;
流還有一個不足的地方是不好處理流操作每一步的引數,比如中間操作a->b->c a中的引數想要在c中使用,那隻能交換bc操作的順序或者用一個變數去儲存,但這樣就違背了stream的最主要目的(程式碼簡潔)。
同時流與普通迭代迴圈的使用是根據實際情況來選擇的,普通迭代程式碼容易懂,流程式碼簡潔但是需要懂流的操作才能明白。
Item 46 : Prefer side-effect-free functions in streams
使用流中的安全方法(無副作用)
流並不僅僅是一個API,更是函式程式設計的一個極佳範例。流最重要的是組織你的操作佇列,每一步操作都是對外封閉的;其結果只依靠於操作內部的入參,不依靠其他任何可變狀態也不改變任何狀態;所有的中間操作和最終操作都沒有任何副作用。
有時候你可以看到下面的吊程式碼:
// 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); }); }
統計一個文字中每個單詞的頻率,沒什麼毛病,但是其實根本沒用流。它在迭代裡呼叫 merge
方法進行統計,並且迭代就是最終操作,中間操作都在迭代中。
// 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())); }
上面的程式碼更簡潔,但為啥還是有人寫最上面的程式碼?因為人都會使用自己熟悉的東西,但是 forEach
的使用只應該在展示流計算的結果或者用來新增流計算結果到一個集合中,而不是用作流的計算。
// 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
API,在流中使用可以讓程式碼更加簡單可讀。
Colletctors
中有39個方法,有些甚至還有5個引數的方法,所以沒有必要完全去了解 Collectors
,同時 Collectors
返回值一般都是集合,剛好可以使用流去處理。
其內部有 toList
, toSet
, toCollection
三個方法分別返回對應 List
, Set
, Collection
,剩下的36個方法大部分都是處理返回map,比返回集合更加的複雜。
toMap
方法使用唯一的key繫結流中的元素,如果多個元素嘗試繫結同一個key,流就以 IllegalStateException
終止。
groupingBy
和 toMap
一樣,提供給你分組統計的方法來解決上面繫結同一個key的問題,使用的是 merge
方法。
三個引數的 toMap
方法用指定key建立一個map,這個key要繫結到一個已經有key的value上。同時三引數的 toMap
還有類似 toConcurrentMap
的變種方法。四個引數的 toMap
用來宣告一些特殊的map,比如 EnumMap
, TreeMap
等等。
最簡單的 groupingBy
使用就是返回一個分組統計之後的map,只傳入一個引數;
傳入兩個引數的 groupingBy
第一個引數和普通的使用一樣,第二個引數傳入 counting()
,返回的就不是分組之後的元素map,而是每個分組的數量。
第三種變種則允許宣告一個map工廠,比如你可以宣告一個collector返回一個value是 TreeSets
的map。
groupingByConcurrent
則提供了上面三種的變種,只不過是並行的,同時返回 ConcurrentHashMap
上面 counting()
返回的collector僅用於流下游的收集,像 collect(counting()) 這種程式碼是不應該寫出來的。
另外需要說的是 joining
方法,該方法只用於處理 CharSequence
,提供三個方法,無參,一個引數和三個引數。
一個引數傳入一個叫分隔符的字元,返回用該字元分隔的 Collector
;三個引數除了傳入分隔符,還需要傳入字首和字尾,其他和一個引數的相同。
這節主要介紹了流的基本用法,以及一些常用的API。
Item 47 : Prefer Collection to Stream as a return type
選擇適當的返回型別,集合或者是流
在寫程式碼的時候需要考慮是返回元素的佇列還是返回集合或者是迭代器,你需要考慮你的使用者是用返回值來做迭代還是繼續的做流的一些操作,最好的就是都提供。
好在java8的 Collection
已經在介面中添加了流的支援。如果你的返回值夠小的話最好直接就返回一個集合的實現,比如 ArrayList
。
Item 48 : Use caution when making streams parallel
小心使用並行流
先看下面並行獲取前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); } static Stream<BigInteger> primes() { return Stream.iterate(TWO, BigInteger::nextProbablePrime).parallel(); }
普通方式在我的機器上大概13s左右的時間,並行是不是會快很多呢?實際上根本不會有任何輸出,程式處於卡住的狀態,然後cpu佔用飆升。
因為流並不知道如何並行的進行這個操作,然後就啟發式失敗。
事實上,通過 Stream.iterate
或者流中有 limit
操作的,使用並行都不會增加其效能。所以不要盲目的使用並行流,錯誤的使用除了影響效能之外還可能導致錯誤的結果甚至奇怪的行為和其他災難性後果。
因為流的並行操作是一個嚴格的效能提升選項,通常並行流的操作都在一個公共的 fork-join
池裡,如果一個單獨的操作沒有按照預期執行很可能影響其他沒有任何相關性操作的效能。
作為一個約定,並行流能提升以下情況的效能:
ArrayList
,
HashMap
,
HashSet
,
ConcurrenHashMap
,
陣列
,
int
和
long
原因:
- 他們都能夠隨意的被分割成用於並行操作的大小;
- 他們都提供了很好的區域性引用性,因為都儲存在記憶體一塊連續的區域,特別是基本型別的陣列在並行流下的效能提升更為明顯;
同時流的最終操作的特性也會影響並行流的效能,如果最終操作中進行了大量的計算,那麼並行也不會提升太大的效能。
最好的情況就是在最終操作中做減法,比如 min
, max
, count
, sum
這些操作,但是像 collect
這種操作就不適合並行的操作,因為太耗時了。
通常正常情況下通過並行能夠提升的效能和你的處理器核數成線性關係的。
總的來說別輕易的使用並行流,除非經過了嚴格測試,並且確實有效能提升才使用。
如果需要編寫自己的流,迭代器或者集合實現,如果你需要並行的操作,
那麼一定要覆蓋 spliterator
方法,並且經過測試。
這章其實就是第三版Effective Java的核心更新部分了,主要講的就是Java8的新特性:lambda表示式和流的使用以及需要注意的點。
就我個人實際開發情況來說,工作中使用lambda表示式的時候不是特別多,因為複雜的迴圈基本不會使用lambda表示式,而是使用 forEach
迴圈或者是使用迭代器。
至於流的使用其實還是需要一定的學習成本的,加入的東西很多,想要完整的掌握還是需要花點時間的。特別是並行流的使用,沒有完全掌握流的話還是不要使用為好,普通流的效能大部分場景下也足夠了。
BugHome版權所有丨轉載請註明出處:https://minei.me/archives/491.html