stream的實用方法和注意事項

語言: CN / TW / HK

相信大家一定都在專案開發中享受過stream帶來的便利性和優雅的程式碼風格。接下來補充幾個專案中不常見到但是同樣實用的api,同時跟大家一起探討stream這把雙刃劍的另一面。

使用但不常見的方法

filter、map、skip等方法想必大家都十分熟悉 無需贅述。這裡僅介紹工程中使用較少但同樣實用的方法。

▐   reduce

reduce有3個引數:初始值、累加器、組合器。下面通過幾個case為大家逐一講解。由於比較繞,下面貼上ide執行結果

當順序讀流或者累加器的引數和它的實現的型別匹配時,我們不需要使用組合器。通常只有在處理物件屬性時則需要組合器來幫助編譯器推斷入參型別。實際在序列流中組合器並不會實際執行,只需要出入參型別滿足編譯器推斷要求即可。可以看到上方result3的計算,末尾組合器適用max還是min 結果是一樣的。

▐   allMatch/anyMatch/noneMatch

判斷集合中是否 全部都匹配/存在任意匹配/不存在匹配 某一規則。

比如下面一段程式碼,判斷集合中的物件是否全部合法。語義十分簡單。下面對比stream寫法和常規寫法。兩種寫法的執行結果是一樣的。

@Data
@AllArgsConstructor
public static class Calendar {
private LocalDate date;
private boolean today;
private boolean signed;
}
//日曆初始化
LocalDate now = new LocalDate();
List<Calendar> calendars = Arrays.asList(
new Calendar(new LocalDate(1661174238000L), false, false)
, new Calendar(new LocalDate(1661828371000L), false, false)
, new Calendar(new LocalDate(1661433438000L), false, false)
, new Calendar(new LocalDate(1661519838000L), false, false)
, new Calendar(new LocalDate(1661779038000L), false, false)
, new Calendar(now, true, true)
);
//判斷昨天是否簽到過。寫法一
boolean yesterdaySigned = calendars.stream()
.anyMatch(
t -> Days.daysBetween(t.getDate(), now).getDays() == 1 && t.isSigned()
);
System.out.println("昨天是否簽到過 -> " + yesterdaySigned);


//寫法二
boolean yesterdaySigned2 = false;
for (Calendar calendar : calendars) {
if (Days.daysBetween(calendar.getDate(), now).getDays() == 1) {
//找到昨天的日曆,並判斷是否簽到
yesterdaySigned2 = calendar.isSigned();
break;
}
}
System.out.println("昨天是否簽到過寫法二 -> " + yesterdaySigned2);

這裡寫法一雖然更簡練但是存在問題,大家有看出來的嗎。這個問題放在“注意事項”中專門講解。

▐   flatMap

跟map的區別是可以將一個物件轉化成多個物件並以流的方式返回,適合用於集合巢狀場景下的扁平化處理。概念較為拗口,以下用ide截圖演示。可以看到特定場景下flatmap相對map有先天優勢。

注意事項

▐   書寫順序影響效能

stream實際使用中,filter和map最為常見。這兩個操作都是逐個元素執行並逐個向下遊操作傳遞,我們稱之為“垂直操作”(補充:sorted是“水平操作”,即會截斷後續運算直至自己將流中所有元素操作完成)。其中filter較為特殊,被其攔截後不會繼續向下遊傳遞。基於此原理,儘可能將filter前置往往可以大幅提高stream操作效能。如下所示:

一個長度為5的字符集,map-filter-foreach 順序執行 則會有5次map、5次filter、1次foreach;

filter-map-foreach順序執行,則會有5次filter、1次map、1次foreach執行。並且很容易推斷filter過濾度越高效能差異就會越明顯。

原理不少人可能會覺得簡單易懂,但遺憾的是在大型專案中往往總能找到有此類效能缺陷的程式碼,諸如

        List<Long> awardId = timeFilterAwardConfigs.stream()
.map(config -> config.getAwardId())
.filter(awardId -> awardId > 0)
.collect(Collectors.toList());

但在更復雜的場景下,也並非要求filter無腦提前於其他操作。比如下面這個例子

        //假設一份使用者集
List<User> userList = Arrays.asList(
new User("張三", 22)
, new User("李四", 21)
, new User("王五", 19)
, new User("趙六", 25)
);
//要輸出這份集合中所有使用者所就職的公司的年度營業額總和,要求公司所在地都在杭州市餘杭區
// 注意使用者中可能有無業遊民。不考慮就職公司重合或者一人就職多家公司的情況。
//寫法一
int allCompanyTurnover1 = userList.stream()
.map(user -> calculateAnnualTurnover(queryUserCompany(user)))
.filter(Objects::nonNull)
.reduce(0, Integer::sum);
//寫法二
int allCompanyTurnover2 = userList.stream()
.filter(user -> {
Company company = queryUserCompany(user);
return company != null && !"餘杭".equals(company.getLocal());
})
.map(user -> calculateAnnualTurnover(queryUserCompany(user)))
.reduce(0, Integer::sum);

寫法一顯然更符合直覺,寫法二雖然filter提前過濾掉了一部分資料,但是queryUserCompany存在重複計算。所以此種情況下就需要綜合 filter過濾度和queryUserCompany重複計算的開銷進行權衡。如果filter過濾度足夠高(比如餘杭的公司很少)同時queryUserCompany 資源開銷不大,那麼寫法二更優,反之寫法一更優。

▐   並非適用所有場景

  • 效能上

這裡就可以說回到剛才講anyMatch時看到的那段程式碼

//判斷昨天是否簽到過。寫法一
boolean yesterdaySigned = calendars.stream()
.anyMatch(
t -> Days.daysBetween(t.getDate(), now).getDays() == 1 && t.isSigned()
);
System.out.println("昨天是否簽到過 -> " + yesterdaySigned);


//寫法二
boolean yesterdaySigned2 = false;
for (Calendar calendar : calendars) {
if (Days.daysBetween(calendar.getDate(), now).getDays() == 1) {
//找到昨天的日曆,並判斷是否簽到
yesterdaySigned2 = calendar.isSigned();
break;
}
}
System.out.println("昨天是否簽到過寫法二 -> " + yesterdaySigned2);

列印觀察執行次數如下

顯然 anyMatch 會無條件遍歷所有元素再返回,而直觀的遍歷寫法往往不會犯這種錯誤,拿到結果後可以提前break。大家可能會想到先利用filter過濾獲獲取“昨天”的日曆,然後再anymatch

boolean yesterdaySigned = calendars.stream()
.filter(t -> Days.daysBetween(t.getDate(), now).getDays() == 1)
.anyMatch(Calendar::isSigned);

但是很可惜,filter同樣會完整遍歷整個集合。事實上遍觀所有stream方法似乎都沒有辦法很好的解決這個問題。也歡迎大家一起探討。

  • 可閱讀性

摘取 了某業務中判斷 週期內簽到次數的方法,採用stream和for迴圈常規寫法

    private int getCycleActionCount(Date start, Date end, List<ActionCalendar> calendar) {
int count = 0;
for (ActionCalendar calendarDay : calendar) {
Date date = calendarDay.getDate();
if (date.after(start) && date.before(end) && calendarDay.isComplete()) {
//在週期內任意一天簽到,簽到次數自增。
count++;
}
}
return count;
}


private int getCycleActionCount2(Date start, Date end, List<ActionCalendar> calendar) {
return Math.toIntExact(
calendar.stream()
.filter(
//統計週期內簽到天數
t -> (
t.getDate().after(start) && t.getDate().before(end) && t.isComplete()
)
).count()
);
}

這樣看兩者之間 光從可閱讀性上看並沒有特別大的區分度。而即使熟練的stream 愛好者,相信寫出一段stream程式碼後也會多看幾眼確認效能、縮排是否達到最優。可見在某些場景下無論效能、可讀性還是書寫便利性都不佔優,此時stream似乎就不是最優選擇了。

總結

stream在多數場景下都能幫助我們更快的寫出優美的程式碼,但是在更為複雜的場景下則需要對API之間的執行順序、lambda表示式的使用、甚至此場景是否適用stream寫法進行一定的思考,以避免出現效能或可讀性的缺陷。

總的來看stream和直觀的for遍歷是互補而非替代關係,兩者搭配,幹活不累。

此外stream家族中還有個強大的種子選手“parallelStream”(並行流)沒有介紹。他通常用在超大集合的處理中,日常工程中難尋使用場景,同時使用上比上面說到的序列流處理有更多的注意事項。這裡暫不展開分享。

團隊介紹

大淘寶技術-使用者平臺技術團隊
使用者平臺技術團隊是一支集研發、資料、演算法一體的團隊,負責淘寶天貓的使用者增長,遊戲互動,平臺會員和私域運營等消費者核心業務。在對使用者爭奪進入白熱化的時期,團隊正承擔著捍衛電商主機板塊增長的重要使命,是阿里核心電商戰場的參與者,用持續的技術創新來驅動阿里電商引擎的穩步前行。
這是一支年輕開放的團隊,在這裡你將收穫超大規模高併發場景的架構設計能力,洞悉使用者增長最前沿的實踐方法,在數字化時代收穫最核心的競爭力。團隊技術氛圍濃厚,倡導創新和工程師文化,鼓勵用資料和程式碼發現解決問題。團隊研發流程規範,程式碼質量高,學習成長速度快。

✿    拓展閱讀

作者 | 袁俊子(圓鏡)

編輯| 橙子君