Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必很多人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到現在已經將近8年的時間,但隨着Java 6,7,8,甚至9的發布,Java語言發生了深刻的變化。
在這里第一時間翻譯成中文版。供大家學習分享之用。
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼里方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。但是Java 9 只是一個過渡版本,所以建議安裝JDK 10。
46. 優先考慮流中無副作用的函數
如果你是一個剛開始使用流的新手,那么很難掌握它們。僅僅將計算表示為流管道是很困難的。當你成功時,你的程序將運行,但對你來說可能沒有意識到任何好處。流不僅僅是一個API,它是基於函數式編程的范式(paradigm)。為了獲得流提供的可表達性、速度和某些情況下的並行性,你必須采用范式和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令人生畏:它有39個方法,其中一些方法有多達5個類型參數。好消息是,你可以從這個API中獲得大部分好處,而不必深入研究它的全部復雜性。對於初學者來說,可以忽略收集器接口,將收集器看作是封裝縮減策略( reduction strategy)的不透明對象。在此上下文中,reduction意味着將流的元素組合為單個對象。 收集器生成的對象通常是一個集合(它代表名稱收集器)。
將流的元素收集到真正的集合中的收集器非常簡單。有三個這樣的收集器:toList()
、toSet()
和toCollection(collectionFactory)
。它們分別返回集合、列表和程序員指定的集合類型。有了這些知識,我們就可以編寫一個流管道從我們的頻率表中提取出現頻率前10個單詞的列表。
// 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());
注意,我們沒有對toList方法的類收集器進行限定。靜態導入收集器的所有成員是一種慣例和明智的做法,因為它使流管道更易於閱讀。
這段代碼中唯一比較棘手的部分是我們把comparing(freq::get).reverse()
傳遞給sort方法。comparing
是一種比較器構造方法(條目 14),它具有一個key的提取方法。該函數接受一個單詞,而“提取”實際上是一個表查找:綁定方法引用freq::get
在frequency表中查找單詞,並返回單詞出現在文件中的次數。最后,我們在比較器上調用reverse
方法,因此我們將單詞從最頻繁到最不頻繁進行排序。然后,將流限制為10個單詞並將它們收集到一個列表中就很簡單了。
前面的代碼片段使用Scanner的stream方法在scanner實例上獲取流。這個方法是在Java 9中添加的。如果正在使用較早的版本,可以使用類似於條目 47中(streamOf(Iterable<E>)
)的適配器將實現了Iterator的scanner序轉換為流。
那么收集器中的其他36種方法呢?它們中的大多數都是用於將流收集到map中的,這比將流收集到真正的集合中要復雜得多。每個流元素都與一個鍵和一個值相關聯,多個流元素可以與同一個鍵相關聯。
最簡單的映射收集器是toMap(keyMapper、valueMapper)
,它接受兩個函數,一個將流元素映射到鍵,另一個映射到值。在條目34中的fromString
實現中,我們使用這個收集器從enum的字符串形式映射到enum本身:
// 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方法,提供了處理此類沖突(collisions)的各種方法。一種方法是向toMap方法提供除鍵和值映射器(mappers)之外的merge方法。merge方法是一個
BinaryOperator
,其中
V`是map的值類型。與鍵關聯的任何附加值都使用merge方法與現有值相結合,因此,例如,如果merge方法是乘法,那么最終得到的結果是是值mapper與鍵關聯的所有值的乘積。
toMap的三個參數形式對於從鍵到與該鍵關聯的選定元素的映射也很有用。例如,假設我們有一系列不同藝術家(artists)的唱片集(albums),我們想要一張從唱片藝術家到最暢銷專輯的map。這個收集器將完成這項工作。
// 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提取器函數Album :: sales
。 這可能看起來有點復雜,但代碼可讀性很好。 簡而言之,它說,“將專輯(albums)流轉換為地map,將每位藝術家(artist)映射到銷售量最佳的專輯。”這與問題陳述出奇得接近。
toMap的三個參數形式的另一個用途是產生一個收集器,當發生沖突時強制執行last-write-wins策略。 對於許多流,結果是不確定的,但如果映射函數可能與鍵關聯的所有值都相同,或者它們都是可接受的,則此收集器的行為可能正是您想要的:
// Collector to impose last-write-wins policy
toMap(keyMapper, valueMapper, (oldVal, newVal) ->newVal)
toMap的第三個也是最后一個版本采用第四個參數,它是一個map工廠,用於指定特定的map實現,例如EnumMap
或TreeMap
。
toMap的前三個版本也有變體形式,名為toConcurrentMap
,它們並行高效運行並生成ConcurrentHashMap
實例。
除了toMap方法之外,Collectors API還提供了grouping
By方法,該方法返回收集器以生成基於分類器函數(classifier function)將元素分組到類別中的map。 分類器函數接受一個元素並返回它所屬的類別。 此類別來用作元素的map的鍵。 groupingBy
方法的最簡單版本僅采用分類器並返回一個map,其值是每個類別中所有元素的列表。 這是我們在條目 45中的Anagram
程序中使用的收集器,用於生成從按字母順序排列的單詞到單詞列表的map:
Map<String, Long> freq = words
.collect(groupingBy(String::toLowerCase, counting()));
groupingBy
的第三個版本允許指定除downstream收集器之外的map工廠。 請注意,這種方法違反了標准的可伸縮參數列表模式(standard telescoping argument list pattern):mapFactory
參數位於downStream
參數之前,而不是之后。 此版本的groupingBy
可以控制包含的map以及包含的集合,因此,例如,可以指定一個收集器,它返回一個TreeMap
,其值是TreeSet
。
groupingByConcurrent
方法提供了groupingBy
的所有三個重載的變體。 這些變體並行高效運行並生成ConcurrentHashMap
實例。 還有一個很少使用的grouping
的親戚稱為partitioningBy
。 代替分類器方法,它接受predicate
並返回其鍵為布爾值的map。 此方法有兩種重載,除了predicate
之外,其中一種方法還需要downstream收集器。
通過counting方法返回的收集器僅用作下游收集器。 Stream
上可以通過count
方法直接使用相同的功能,因此沒有理由說 collect(counting())
。 此屬性還有十五種收集器方法。 它們包括九個方法,其名稱以summing
,averaging
和summarizing
開頭(其功能在相應的原始流類型上可用)。 它們還包括reduce
方法的所有重載,以及filter
,mapping
,flatMapping
和collectingAndThen
方法。 大多數程序員可以安全地忽略大多數這些方法。 從設計的角度來看,這些收集器代表了嘗試在收集器中部分復制流的功能,以便下游收集器可以充當“迷你流(ministreams)”。
我們還有三種收集器方法尚未提及。 雖然他們在收Collectors類中,但他們不涉及集合。 前兩個是minBy
和maxBy
,它們取比較器並返回比較器確定的流中的最小或最大元素。 它們是Stream接口中min和max方法的次要總結,是BinaryOperator中類似命名方法返回的二元運算符的類似收集器。 回想一下,我們在最暢銷的專輯中使用了BinaryOperator.maxBy
方法。
最后的Collectors中方法是join
,它僅對CharSequence
實例(如字符串)的流進行操作。 在其無參數形式中,它返回一個簡單地連接元素的收集器。 它的一個參數形式采用名為delimiter
的單個CharSequence
參數,並返回一個連接流元素的收集器,在相鄰元素之間插入分隔符。 如果傳入逗號作為分隔符,則收集器將返回逗號分隔值字符串(但請注意,如果流中的任何元素包含逗號,則字符串將不明確)。 除了分隔符之外,三個參數形式還帶有前綴和后綴。 生成的收集器會生成類似於打印集合時獲得的字符串,例如[came, saw, conquered]
。
總之,編程流管道的本質是無副作用的函數對象。 這適用於傳遞給流和相關對象的所有許多函數對象。 終結操作orEach
僅應用於報告流執行的計算結果,而不是用於執行計算。 為了正確使用流,必須了解收集器。 最重要的收集器工廠是toList
,toSet
,toMap
,groupingBy和join
。