1. 程式人生 > >Effective Java 第三版——46. 優先考慮流中無副作用的函式

Effective Java 第三版——46. 優先考慮流中無副作用的函式

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。
Effective Java, Third Edition

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實現,例如EnumMapTreeMap

toMap的前三個版本也有變體形式,名為toConcurrentMap,它們並行高效執行並生成ConcurrentHashMap例項。

除了toMap方法之外,Collectors API還提供了groupingBy方法,該方法返回收集器以生成基於分類器函式(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())。 此屬性還有十五種收集器方法。 它們包括九個方法,其名稱以summingaveragingsummarizing開頭(其功能在相應的原始流型別上可用)。 它們還包括reduce方法的所有過載,以及filtermappingflatMappingcollectingAndThen方法。 大多數程式設計師可以安全地忽略大多數這些方法。 從設計的角度來看,這些收集器代表了嘗試在收集器中部分複製流的功能,以便下游收集器可以充當“迷你流(ministreams)”。

我們還有三種收集器方法尚未提及。 雖然他們在收Collectors類中,但他們不涉及集合。 前兩個是minBymaxBy,它們取比較器並返回比較器確定的流中的最小或最大元素。 它們是Stream介面中min和max方法的次要總結,是BinaryOperator中類似命名方法返回的二元運算子的類似收集器。 回想一下,我們在最暢銷的專輯中使用了BinaryOperator.maxBy方法。

最後的Collectors中方法是join,它僅對CharSequence例項(如字串)的流進行操作。 在其無引數形式中,它返回一個簡單地連線元素的收集器。 它的一個引數形式採用名為delimiter的單個CharSequence引數,並返回一個連線流元素的收集器,在相鄰元素之間插入分隔符。 如果傳入逗號作為分隔符,則收集器將返回逗號分隔值字串(但請注意,如果流中的任何元素包含逗號,則字串將不明確)。 除了分隔符之外,三個引數形式還帶有字首和字尾。 生成的收集器會生成類似於列印集合時獲得的字串,例如[came, saw, conquered]

總之,程式設計流管道的本質是無副作用的函式物件。 這適用於傳遞給流和相關物件的所有許多函式物件。 終結操作orEach僅應用於報告流執行的計算結果,而不是用於執行計算。 為了正確使用流,必須瞭解收集器。 最重要的收集器工廠是toListtoSettoMapgroupingBy和join