在此前我的文章中,曾分2篇詳細(xì)探討了下JAVA中Stream流的相關(guān)操作,2篇文章在掘金社區(qū)收獲了累計(jì) 10w+
閱讀、2k+
點(diǎn)贊以及 5k+
收藏的記錄。能夠得到眾多小伙伴的認(rèn)可,是技術(shù)分享過(guò)程中最開(kāi)心的事情。
不少小伙伴在評(píng)論中提出了一些的疑問(wèn)或自己的獨(dú)到見(jiàn)解,也在評(píng)論區(qū)中進(jìn)行了熱烈的互動(dòng)討論。梳理了下相關(guān)評(píng)論內(nèi)容,針對(duì)一些典型的討論點(diǎn)進(jìn)行拿出來(lái)聊一聊,同時(shí)也是對(duì)此前兩篇Java Stream相關(guān)文章內(nèi)容的補(bǔ)充完善。
Stream處理時(shí)列表到底循環(huán)了多少次
看下面這段Stream使用的常見(jiàn)場(chǎng)景:
Stream.of(17, 22, 35, 12, 37)
.filter(age -> age > 18)
.filter(age -> age < 35)
.map(age -> age + "歲")
.collect(Collectors.toList());
在這段代碼里面,同時(shí)有2個(gè) filter
操作和1個(gè) map
操作以及1個(gè) collect
操作,那么這段代碼執(zhí)行的時(shí)候,究竟是對(duì)這個(gè)list執(zhí)行了幾次循環(huán)操作呢?是每一個(gè)Stream步驟都會(huì)進(jìn)行一次遍歷操作嗎?為了驗(yàn)證這個(gè)問(wèn)題,我們將上述代碼改寫(xiě)一下,打印下每個(gè)步驟的結(jié)果:
List<String> ages = Stream.of(17,22,35,12,37)
.filter(age -> {
System.out.println("filter1 處理:" + age);
return age > 18;
})
.filter(age -> {
System.out.println("filter2 處理:" + age);
return age < 35;
})
.map(age -> {
System.out.println("map 處理:" + age);
return age + "歲";
})
.collect(Collectors.toList());
先執(zhí)行,得到如下的執(zhí)行結(jié)果。其實(shí)結(jié)果已經(jīng)很明顯的可以看出,stream流處理的時(shí)候,是對(duì)列表進(jìn)行了一次循環(huán),然后順序的執(zhí)行給定的stream執(zhí)行語(yǔ)句。
按照上述輸出的結(jié)果,可以看出其處理的過(guò)程可以等價(jià)于如下的常規(guī)寫(xiě)法:
List<Integer> ages = Arrays.asList(17,22,35,12,37);
List<String> results = new ArrayList<>();
for (Integer age : ages) {
if (age > 18) {
if (age < 35) {
results.add(age + "歲");
}
}
}
System.out.println(results);
所以,Stream并不會(huì)去遍歷很多次。其實(shí)上述邏輯也符合Stream 流水線
加工的整體模式,試想一下,一條流水線上分環(huán)節(jié)加工一件商品,同一件產(chǎn)品也不會(huì)在流水線上加工2次的吧~
Stream究竟是讓代碼更易讀還是更難懂
自動(dòng)Java8引入了 Lambda
、函數(shù)式接口
、Stream
等新鮮內(nèi)容以來(lái),針對(duì)使用Stream或Lambda語(yǔ)法究竟是讓代碼更易懂還是更復(fù)雜的爭(zhēng)議,一直就沒(méi)有停止過(guò)。有的同學(xué)會(huì)覺(jué)得Stream語(yǔ)法的方式,一眼就可以看出業(yè)務(wù)邏輯本身的含義,也有一些同學(xué)認(rèn)為使用了Stream之后代碼的可讀性降低了很多。
其實(shí),這是個(gè)人編碼模式與理念上的不同感知而已。Stream主打的就是讓代碼更聚焦自身邏輯,省去其余繁文縟節(jié)對(duì)代碼邏輯的干擾,整體編碼上會(huì)更加的簡(jiǎn)潔。但是剛接觸的時(shí)候,難免會(huì)需要一定的適應(yīng)期。技術(shù)總是在不斷迭代、不斷擁抱新技術(shù)、不去刻意排斥新技術(shù),或許是一個(gè)更好的選項(xiàng)。
那么,話說(shuō)回來(lái),如何讓自己能夠一眼看懂Stream代碼、感受到Stream的簡(jiǎn)潔之美呢?分享個(gè)人的一個(gè)經(jīng)驗(yàn):
-
先了解幾個(gè)常見(jiàn)的Stream的api的功能含義(Stream的API封裝的很優(yōu)秀,很多都是字面意義就可以理解)
-
改變意識(shí),聚焦純粹的業(yè)務(wù)邏輯本身,不要在乎具體寫(xiě)法細(xì)節(jié)
下面舉了個(gè)例子,如何用上述的2條方法,快速的讓自己理解一段Stream代碼表達(dá)的意思。
那么上面這段代碼的含義就是,先根據(jù)員工子公司過(guò)濾所有上海公司的人員,再獲取員工工資最高的那個(gè)人信息。怎么樣?按照這個(gè)方法,是不是可以發(fā)現(xiàn),Stream的方式,確實(shí)更加容易理解了呢~
在IDEA中debug調(diào)試Stream代碼段
技術(shù)分享其實(shí)是一個(gè)雙向的過(guò)程,分享的同時(shí),也是自我學(xué)習(xí)與提升的機(jī)會(huì),除了可以梳理發(fā)現(xiàn)一些自己之前忽略的知識(shí)點(diǎn)并加以鞏固,還可以在互動(dòng)的時(shí)候get到新的技能。
比如,我在此前的 Java Stream
介紹的文章中,有提過(guò)基于Stream進(jìn)行編碼的時(shí)候會(huì)導(dǎo)致代碼 debug調(diào)試
的時(shí)候會(huì)比較困難,尤其是那種只有一行Lambda表達(dá)式的情況(因?yàn)槿绻a邏輯多行編寫(xiě)的時(shí)候,可以在代碼塊內(nèi)部打斷點(diǎn),這樣其實(shí)也可以進(jìn)行debug調(diào)試)。
關(guān)于這一點(diǎn),很多小伙伴也有相同的感受,比如下面這個(gè)評(píng)論:
你以為這就結(jié)束了?接下來(lái)一個(gè)小伙伴的提示,“震驚”了眾人!納尼?原來(lái)Stream代碼段也是可以debug單步調(diào)試的?
跟蹤Stream中單步處理過(guò)程的操作入口按鈕長(zhǎng)這樣:
并且,另一個(gè)小伙伴補(bǔ)充說(shuō)這是IDEA從 2019.03
版本開(kāi)始有的功能:
嗯?難怪呢,我一直用的2019.02版本的,所以才沒(méi)用上這個(gè)功能(強(qiáng)行給自己找了個(gè)臺(tái)階、哈哈哈)。于是,我悄悄的將自己的idea升級(jí)到了最新的2023.02版本(PS:新版本的UI挺好看,就是bug賊多)。好啦,言歸正傳,那么究竟應(yīng)該如何利用IDEA來(lái)實(shí)現(xiàn)單步DEBUG呢?一一起來(lái)感受下吧。
在代碼行前面添加斷點(diǎn)的時(shí)候,如果要打斷點(diǎn)的這行代碼里面包含Stream中間方法(mapfiltersort
之類(lèi)的)的時(shí)候,會(huì)提示讓選擇斷點(diǎn)的具體類(lèi)型。
一共有三種類(lèi)型斷點(diǎn)可供選擇:
-
Line:斷點(diǎn)打在這一行上,不會(huì)進(jìn)入到具體的Stream執(zhí)行函數(shù)塊中
-
Lambda:代碼打在內(nèi)部的lambda代碼塊上
-
Line and Lambda:代碼走到這行或者執(zhí)行這一行具體的函數(shù)塊內(nèi)容的時(shí)候,都會(huì)進(jìn)入斷點(diǎn)
下面這個(gè)圖可以更清晰的解釋清楚上述三者的區(qū)別。一般來(lái)說(shuō),我們debug的時(shí)候,更多的是關(guān)注自身的業(yè)務(wù)具體邏輯,而不會(huì)過(guò)多去關(guān)注Stream執(zhí)行框架的運(yùn)轉(zhuǎn)邏輯,所以大部分情況下,我們選擇第二個(gè)Lambda選項(xiàng)即可。
按照上面所述,我們?cè)诖a行前面添加一個(gè)Lambda類(lèi)型斷點(diǎn),然后debug模式啟動(dòng)程序執(zhí)行,等到斷點(diǎn)進(jìn)入的時(shí)候便可以正常的進(jìn)行debug并查看內(nèi)部的處理邏輯了。
如果遇到圖中這種只有一行的lambda形式代碼,想要看下返回值到底是什么的,可以選中執(zhí)行的片段,然后 ALT+F8
打開(kāi)Evaluate界面(或者右鍵選擇 Evaluate Expression
),點(diǎn)擊 Evaludate
按鈕執(zhí)行查看具體結(jié)果。
大部分情況下,掌握這一點(diǎn),已經(jīng)可以應(yīng)付日常的開(kāi)發(fā)過(guò)程中對(duì)Stream代碼邏輯的debug訴求了。但是上述過(guò)程偏向于細(xì)節(jié),如果需要看下整個(gè)Stream代碼段整體層面的執(zhí)行與數(shù)據(jù)變化過(guò)程,就需要上面提到的Stream Trace功能。要想使用該功能,斷點(diǎn)的位置也是有講究的,必須要將斷點(diǎn)打在stream開(kāi)流的地方,否則看不到任何內(nèi)容。另外,對(duì)于一些新版本的IDEA而言,這個(gè)入口也比較隱蔽,藏在了下拉菜單中,就像下面這個(gè)樣子。
我們找到Trace Current Stream ChAIn并點(diǎn)擊,可以打開(kāi)Stream Trace界面,這里以chain鏈的方式,和stream代碼塊邏輯對(duì)應(yīng),分步驟展示了每個(gè)stream處理環(huán)節(jié)的執(zhí)行結(jié)果。比如我們以 filter
環(huán)節(jié)為例,窗口中以左右視圖的形式,左側(cè)顯示了原始輸入的內(nèi)容,右側(cè)是經(jīng)過(guò)filter處理后符合條件并保留下來(lái)的數(shù)據(jù)內(nèi)容,并且還有連接線進(jìn)行指引,一眼就可以看出哪些元素是被過(guò)濾舍棄了的:
不止于此,Stream Trace除了提供上述分步查看結(jié)果的能力,還支持直接顯示整體的鏈路執(zhí)行全貌。點(diǎn)擊Stream Trace窗口左下角的 Flat Mode
按鈕即可切換到全貌模式,可以看到最初原始數(shù)據(jù),如何一步步被處理并得到最終的結(jié)果。
看到這里,以后還會(huì)說(shuō)Stream不好調(diào)試嗎?至少我不會(huì)了。
小心Collectors.toMap出現(xiàn)key值重復(fù)報(bào)錯(cuò)
在我們常規(guī)的HashMap的 put(key,value)
操作中,一般很少會(huì)關(guān)注key是否已經(jīng)在map中存在,因?yàn)閜ut方法的策略是存在會(huì)覆蓋已有的數(shù)據(jù)。但是在Stream中,使用 Collectors.toMap
方法來(lái)實(shí)現(xiàn)的時(shí)候,可能稍不留神就會(huì)踩坑。所以,有小伙伴在評(píng)論區(qū)熱心的提示,在使用此方法的時(shí)候需要手動(dòng)加上 mergeFunction
以防止key沖突。
這個(gè)究竟是怎么回事呢?我們看下面的這段代碼:
public void testCollectStopOptions() {
List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));
// collect成HashMap,key為id,value為Dept對(duì)象
Map<Integer, Dept> collectMap = ids.stream()
.collect(Collectors.toMap(Dept::getId, dept -> dept));
System.out.println("collectMap:" + collectMap);
}
執(zhí)行上述代碼,不出意外的話會(huì)出意外。如下結(jié)果:
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Dept{id=22}
at java.util.stream.Collectors.lambda$throwingMerger$0(Collectors.java:133)
at java.util.HashMap.merge(HashMap.java:1254)
at java.util.stream.Collectors.lambda$toMap$58(Collectors.java:1320)
at java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:481)
at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:471)
at java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708)
at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499)
因?yàn)樵谑占鬟M(jìn)行map轉(zhuǎn)換的時(shí)候,由于出現(xiàn)了重復(fù)的key,所以拋出異常了。為什么會(huì)出現(xiàn)異常呢?為什么不是以為的覆蓋呢?我們看下源碼的實(shí)現(xiàn)邏輯:
可以看出,默認(rèn)情況下如果出現(xiàn)重復(fù)key值,會(huì)對(duì)外拋出IllegalStateException異常。同時(shí),我們看到,它其實(shí)也有提供重載方法,可以由使用者自行指定key值重復(fù)的時(shí)候的執(zhí)行策略:
所以,我們的目標(biāo)是出現(xiàn)重復(fù)值的時(shí)候,使用新的值覆蓋已有的值而非拋出異常,那我們直接手動(dòng)指定下讓toMap按照我們的要求進(jìn)行處理,就可以啦。改造下前面的那段代碼,傳入自行實(shí)現(xiàn)的 mergeFunction
函數(shù)塊,即指定下如果key重復(fù)的時(shí)候,以新一份的數(shù)據(jù)為準(zhǔn):
public void testCollectStopOptions() {
List<Dept> ids = Arrays.asList(new Dept(17), new Dept(22), new Dept(22));
// collect成HashMap,key為id,value為Dept對(duì)象
Map<Integer, Dept> collectMap = ids.stream()
.collect(Collectors.toMap(
Dept::getId,
dept -> dept,
(exist, newOne) -> newOne));
System.out.println("collectMap:" + collectMap);
}
再次執(zhí)行,終于看到我們預(yù)期中的結(jié)果了:
collectMap:{17=Dept{id=17}, 22=Dept{id=22}}
By The Way,個(gè)人感覺(jué)JDK在這塊的默認(rèn)實(shí)現(xiàn)邏輯有點(diǎn)不合理。雖然現(xiàn)在默認(rèn)的拋異常方式,可以強(qiáng)制讓使用端感知并去指定自己的邏輯,但這默認(rèn)邏輯與map的put操作默認(rèn)邏輯不一致,也讓很多人都會(huì)無(wú)辜踩坑。如果將默認(rèn)值改為有則覆蓋的方式,或許會(huì)更符合常理一些 —— 畢竟被廣泛使用的HashMap的源碼里,put操作默認(rèn)就是覆蓋的,不信可以看HashMap源碼的實(shí)現(xiàn)邏輯:
慎用peek承載業(yè)務(wù)處理邏輯
peek
和 foreach
在Stream流操作中,都可以實(shí)現(xiàn)對(duì)元素的遍歷操作。區(qū)別點(diǎn)在與peek屬于中間方法,而foreach屬于終止方法。這也就意味著peek只能作為管道中途的一個(gè)處理步驟,而沒(méi)法直接執(zhí)行得到結(jié)果,其后面必須還要有其它終止操作的時(shí)候才會(huì)被執(zhí)行;而foreach作為無(wú)返回值的終止方法,則可以直接執(zhí)行相關(guān)操作。
那么,只要有終止方法一起,peek方法就一定會(huì)被執(zhí)行嗎?非也!看版本、看場(chǎng)景! 比如在 JDK1.8
版本中,下面這段代碼中的peek方法會(huì)正常執(zhí)行,但是到了 JDK17
中就會(huì)被自動(dòng)優(yōu)化掉而不執(zhí)行peek中的邏輯:
public void testPeekAndforeach() {
List<String> sentences = Arrays.asList("hello world", "Jia Gou Wu Dao");
sentences.stream().peek(sentence -> System.out.println(sentence)).count();
}
至于原因,可以看下JDK17官方API文檔中的描述:
因?yàn)閷?duì)于 findFirst
、count
之類(lèi)的方法,peek操作被視為與結(jié)果無(wú)關(guān)聯(lián)的操作,直接被優(yōu)化掉不執(zhí)行了。所以說(shuō)最好按照API設(shè)計(jì)時(shí)預(yù)期的場(chǎng)景去使用API,避免自己給自己埋坑。
我們從peek的源碼的注釋上可以看出,peek的推薦使用場(chǎng)景是用于一些調(diào)試場(chǎng)景,可以借助peek來(lái)將各個(gè)元素的信息打印出來(lái),便于開(kāi)發(fā)過(guò)程中的調(diào)試與問(wèn)題定位分析。
我們?cè)倏聪聀eek這個(gè)詞的含義解釋:
既然開(kāi)發(fā)者給它起了這么個(gè)名字,似乎確實(shí)僅是為了窺視執(zhí)行過(guò)程中數(shù)據(jù)的變化情況。為了避免讓自己踩坑,最好按照設(shè)計(jì)者推薦的用途用法進(jìn)行使用,否則即使現(xiàn)在沒(méi)問(wèn)題,也不能保證后續(xù)版本中不會(huì)出問(wèn)題。
字符串拼接明明有join,那么Stream中Collectors.join存在意義是啥
在介紹Stream流的收集器時(shí),有介紹過(guò)使用 Collectors.joining
來(lái)實(shí)現(xiàn)多個(gè)字符串元素之間按照要求進(jìn)行拼接的實(shí)現(xiàn)。比如將給定的一堆字符串用逗號(hào)分隔拼接起來(lái),可以這么寫(xiě):
public void testCollectJoinStrings() {
List<String> ids = Arrays.asList("AAA", "BBB", "CCC");
String joinResult = ids.stream().collect(Collectors.joining(","));
System.out.println(joinResult);
}
有很多同學(xué)就提出字符串元素拼接直接用 String.join
就可以了,完全沒(méi)必要搞這么復(fù)雜。
如果是純字符串簡(jiǎn)單拼接的場(chǎng)景,確實(shí)直接String.join會(huì)更簡(jiǎn)單一些,這種情況下使用Stream進(jìn)行拼接的確有些大材小用了。但是 joining
的方法優(yōu)勢(shì)要體現(xiàn)在Stream體系中,也就是與其余Stream操作可以結(jié)合起來(lái)綜合處理。String.join
對(duì)于簡(jiǎn)單的字符串拼接是OK的,但是如果是一個(gè)Object對(duì)象列表,要求將Object某一個(gè)字段按照指定的拼接符去拼接的時(shí)候,就力不從心了——而這就是使用 Collectors.joining
的時(shí)機(jī)了。比如下面的實(shí)例:
小結(jié)
好啦,關(guān)于Java Stream相關(guān)的內(nèi)容點(diǎn)的補(bǔ)充,就聊到這里啦。如果需要全面了解Java Stream的相關(guān)內(nèi)容,可以看我此前分享的文檔。那么,你對(duì)Java Stream是否還有哪些疑問(wèn)或者自己的獨(dú)特理解呢?歡迎一起交流下。