淺談函數(shù)式編程與 Java Stream

前言

image

在這一篇文章中燎潮,我將介紹函數(shù)式編程的基本概念,如何使用函數(shù)式編程的思想編寫代碼以及 Java Stream 的基本使用方法殉了。

本文不會(huì)涉及到任何晦澀難懂的數(shù)學(xué)概念冯遂,函數(shù)式編程的理論以及函數(shù)式編程的高階特性脐往,譬如:惰性求值(Lazy Evaluation)休吠,模式匹配等。所以业簿,請放心食用瘤礁。

這篇文章對于以下的人群或許有一定的幫助:

  • 說不清什么是函數(shù)式編程的人
  • 不知道什么時(shí)候可以使用 Java Stream 的人
  • Java8 出來了這么久,還是無法寫好 Stream 操作的人

本文使用的代碼語言為:Java梅尤,一部分案例使用了 Python 柜思。這篇文章參考并引用了很多優(yōu)秀文章的內(nèi)容,所有的參考鏈接在最后巷燥,如果想要更多地了解函數(shù)式編程以及 Java Stream 的相關(guān)操作赡盘,我推薦你把本文最后給出鏈接的那些資料盡可能詳細(xì)地看一遍,相信一定會(huì)對你有所幫助 :-)

一:函數(shù)式編程

1. 什么是函數(shù)式編程缰揪?

在向你介紹什么是函數(shù)式編程之前陨享,我們不妨來簡單了解一些歷史。

函數(shù)式編程的理論基礎(chǔ)是阿隆佐.邱奇(Alonzo Church)在 1930 年代開發(fā)的 λ 演算(λ-calculus)。

Alonzo Church

λ 演算其本質(zhì)是一種數(shù)學(xué)的抽象抛姑,是數(shù)理邏輯中的一個(gè)形式系統(tǒng)(Formal System)赞厕。這個(gè)系統(tǒng)是為一個(gè)超級機(jī)器設(shè)計(jì)的編程語言,在這種語言里面定硝,函數(shù)的參數(shù)是函數(shù)皿桑,返回值也是函數(shù)。這種函數(shù)用希臘字母 Lambda(λ)來表示蔬啡。

這個(gè)時(shí)候诲侮,λ 演算還僅僅是阿隆佐的一種思想,一種計(jì)算模型箱蟆,并沒有運(yùn)用到任何的硬件系統(tǒng)上浆西。直到 20 世紀(jì) 50 年代后期,一位 MIT 的教授 John McCarthy 對阿隆佐的研究產(chǎn)生了興趣顽腾,并于 1958 年開發(fā)了早期的函數(shù)式編程語言 LISP近零,可以說,LISP 語言是一種阿隆佐的 λ 演算在現(xiàn)實(shí)世界的實(shí)現(xiàn)抄肖。很多計(jì)算機(jī)科學(xué)家都認(rèn)識(shí)到了 LISP 強(qiáng)大的能力久信。1973 年在 MIT 人工智能實(shí)驗(yàn)室的一些程序員研發(fā)出了一種機(jī)器,并把它叫做 LISP 機(jī)漓摩,這個(gè)時(shí)候裙士,阿隆佐的 λ 演算終于有了自己的硬件實(shí)現(xiàn)!

那么話說回來管毙,什么是函數(shù)式編程呢腿椎?

維基百科中,函數(shù)式編程(Functional Programming)的定義如下:

函數(shù)式編程是一種編程范式夭咬。它把計(jì)算當(dāng)成是數(shù)學(xué)函數(shù)的求值啃炸,從而避免改變狀態(tài)和使用可變數(shù)據(jù)。它是一種聲明式的編程范式卓舵,通過表達(dá)式和聲明而不是語句來編程南用。

說到這里,你可能還是不明白掏湾,究竟什么是FP(Functional Programming)裹虫?既然 FP 作為一種編程范式,就不得不提與之相對應(yīng)的另一種編程范式——傳統(tǒng)的指令式編程(Imperative Programming)融击。接下來筑公,我們就通過一些代碼案例,讓你直觀地感受一下尊浪,函數(shù)式編程與指令式編程有哪些差異匣屡;另外涩拙,我想告訴你的是即便不了解函數(shù)式編程中那些深?yuàn)W的概念,我們也可以使用函數(shù)式編程的思想來寫代碼:)

案例一:二叉樹鏡像

這個(gè)題目可以在 LeetCode 上找到耸采,感興趣的朋友可以自行搜索一下兴泥。

題目要求是這樣的:請完成一個(gè)函數(shù),輸入一個(gè)二叉樹虾宇,該函數(shù)輸出它的鏡像搓彻。

例如輸入:

     4
   /   \
  2     7
 / \   / \
1   3 6   9

鏡像輸出:

     4
   /   \
  7     2
 / \   / \
9   6 3   1

傳統(tǒng)的指令式編程的代碼是這樣的:

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Solution:
    def mirrorTree(self, root: TreeNode) -> TreeNode:
        if root is None: 
            return None
        tmp = root.left;
        root.left = self.mirrorTree(root.right)
        root.right = self.mirrorTree(tmp);
        return root

可以看到,指令式編程就像是我們程序員規(guī)定的一種描述計(jì)算機(jī)所需要作出一系列行為的指令集(行動(dòng)清單)嘱朽。我們需要詳細(xì)地告訴計(jì)算機(jī)每一步需要執(zhí)行什么命令旭贬,就像是這段代碼一樣,我們首先判斷節(jié)點(diǎn)是否為空搪泳;然后使用一個(gè)臨時(shí)變量存儲(chǔ)左子樹稀轨,并完成左子樹與右子樹的鏡像翻轉(zhuǎn),最后左右互換岸军。我們只要將計(jì)算機(jī)需要完成的那些步驟寫出奋刽,然后交給機(jī)器運(yùn)行即可,這種“面向機(jī)器編程”的思想就是指令式編程艰赞。

我們再來看一下函數(shù)式編程風(fēng)格的代碼:

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Solution:
    def mirrorTree(self, root: TreeNode) -> TreeNode:
        if root is None:
            return None
        return TreeNode(root.val, self.mirrorTree(root.right), self.mirrorTree(root.left))

你可能會(huì)覺得不解佣谐,這個(gè)代碼是在哪里體現(xiàn)出函數(shù)式編程的呢?

先別急方妖,讓我慢慢向你解釋狭魂。函數(shù)(function)這個(gè)名詞最早是由萊布尼茲在 1694 年開始使用,用來描述輸出值的變化同輸入值變化的關(guān)系党觅。而中文的“函數(shù)”一詞雌澄,由清朝數(shù)學(xué)家李善蘭翻譯,其著作《代數(shù)學(xué)》書中解釋為:“凡此變量中函(包含)彼變量者杯瞻,則此為彼之函數(shù)”镐牺。無論哪一種解釋,我們都知道了又兵,函數(shù)這一概念描述的是一種關(guān)系映射任柜,即:一種東西到另外一種東西之間的對應(yīng)關(guān)系卒废。

所以沛厨,我們可以使用函數(shù)式的思維去思考,獲得一棵二叉樹的鏡像這個(gè)函數(shù)的輸入是一棵“原樹”摔认,返回的結(jié)果是一棵翻轉(zhuǎn)后的“新樹”逆皮,而這個(gè)函數(shù)的本質(zhì)就是從“原樹”到“新樹”的一個(gè)映射。

進(jìn)而参袱,我們可以找到這個(gè)映射的關(guān)系為“新樹”的每一個(gè)節(jié)點(diǎn)都遞歸地和“原樹”相反电谣。

雖然這兩段代碼都使用了遞歸秽梅,但是思考的方式是截然不同的。前者描述的是“從原樹得到新樹應(yīng)該怎樣做”剿牺,后者描述的是從“原樹”到“新樹”的映射關(guān)系企垦。

案例二:翻轉(zhuǎn)字符串

題目為獲得一個(gè)字符串的翻轉(zhuǎn)。

指令式編程:

def reverse_string(s):
    stack = list(s)
    new_string = ""
    while len(stack) > 0:
        new_string += stack.pop()
    return new_string 

這段 Python 代碼非常簡單晒来,我們模擬將一個(gè)字符串先從頭至尾執(zhí)行入棧操作钞诡,然后再從尾到頭執(zhí)行出棧操作,得到的就是一個(gè)翻轉(zhuǎn)后的字符串了湃崩。

函數(shù)式編程:

def reverse_string(s):
    if len(s) <= 1:
        return s
    return reverse_string(s[1:]) + s[0]  

如何理解函數(shù)式編程的思想書寫翻轉(zhuǎn)字符串的邏輯呢荧降?獲得一個(gè)字符串的翻轉(zhuǎn)這個(gè)函數(shù)的輸入是“原字符串”,返回的結(jié)果是翻轉(zhuǎn)后的“新字符串”攒读,而這個(gè)函數(shù)的本質(zhì)就是從“原字符串”到“新字符串”的一個(gè)映射朵诫,將“原字符串”拆分為首字符和剩余的部分,剩余的部分翻轉(zhuǎn)后放在前薄扁,再將首字符放在最后就得到了“新字符串”剪返,這就是輸入與輸出的映射關(guān)系。

通過以上這兩個(gè)示例邓梅,我們可以看到随夸,指令式編程和函數(shù)式編程在思想上的不同之處:指令式編程的感覺就像我們在小學(xué)求解的數(shù)學(xué)題一樣,需要一步一步計(jì)算震放,我們關(guān)心的是解決問題的過程宾毒;而函數(shù)式編程則關(guān)心的是數(shù)據(jù)到數(shù)據(jù)的映射關(guān)系。

2. 函數(shù)式編程的三大特性

函數(shù)式編程具有三大特性:

  • immutable data
  • first class functions
  • 遞歸與尾遞歸的“天然”支持

immutable data

函數(shù)式編程中殿遂,函數(shù)是基礎(chǔ)單元诈铛,我們通常理解的變量在函數(shù)式編程中也被函數(shù)所代替了:在函數(shù)式編程中變量僅僅代表某個(gè)表達(dá)式,但是為了大家可以更好地理解墨礁,我仍然使用“變量”這個(gè)表達(dá)幢竹。

純粹的函數(shù)式編程所編寫的函數(shù)是沒有“變量”的,或者說這些“變量”是不可變的恩静。這就是函數(shù)式編程的第一個(gè)特性:immutable data(數(shù)據(jù)不可變)焕毫。我們可以說,對于一個(gè)函數(shù)驶乾,只要輸入是確定的邑飒,輸出也是可以確定的,我們稱之為無副作用级乐。如果一個(gè)函數(shù)內(nèi)部“變量”的狀態(tài)不確定疙咸,就會(huì)導(dǎo)致同樣的輸入可能得到不同的輸出,這是不被允許的。所以瘫怜,我們這里所說的“變量”就要求是不能被修改的熊昌,且只能被賦一次初始值望抽。

first class functions

在函數(shù)式編程中糕韧,函數(shù)是第一類對象吩案,“first class functions” 可以讓你的函數(shù)像“變量”一樣被使用研儒。所謂的“函數(shù)是第一類對象”的意思是說一個(gè)函數(shù)既可以作為其他函數(shù)的輸入?yún)?shù)值恋昼,也可以作為一個(gè)函數(shù)的輸出顶瞳,即:從函數(shù)中返回一個(gè)函數(shù)亲桦。

我們來看一個(gè)例子:

def inc(x):
    def incx(y):
        return x+y
    return incx

inc2 = inc(2)
inc5 = inc(5)

print(inc2(5)) # 7
print(inc5(5)) # 10

這個(gè)示例中 inc() 函數(shù)返回了另一個(gè)函數(shù)incx(),于是浊仆,我們可以用 inc() 函數(shù)來構(gòu)造各種版本的 inc 函數(shù)客峭,譬如: inc2()inc5()。這個(gè)技術(shù)叫做函數(shù)柯里化(Currying)抡柿,它的實(shí)質(zhì)就是使用了函數(shù)式編程的 “first class functions” 這個(gè)特性舔琅。

遞歸與尾遞歸的“天然”支持

遞歸這種思想和函數(shù)式編程是很配的,有點(diǎn)像是下雨天洲劣,巧克力和音樂更配的那種感覺备蚓。

image

函數(shù)式編程本身強(qiáng)調(diào)的是程序的執(zhí)行結(jié)果而非執(zhí)行過程,遞歸也是一樣囱稽,我們更多在乎的是遞歸的返回值郊尝,即:宏觀語義,而不是它在計(jì)算機(jī)中是怎么被壓棧战惊,怎么被嵌套調(diào)用的流昏。

經(jīng)典的遞歸程序案例是實(shí)現(xiàn)階乘函數(shù),這里我使用的是 JS 語言:

// 正常的遞歸
const fact = (n) => {
    if(n < 0)
        throw 'Illegal input'
    if (n === 0)
        return 0
    if (n === 1)
        return 1
    return n * fact(n - 1)
}

這段代碼可以正常運(yùn)行吞获。不過况凉,遞歸程序的本質(zhì)就是方法的調(diào)用,在遞歸沒有達(dá)到 basecase 時(shí)各拷,方法棧會(huì)不停壓入棧幀刁绒,直到遞歸調(diào)用有返回值時(shí),方法棧的空間才會(huì)被釋放烤黍。如果遞歸調(diào)用很深知市,就很容易造成性能的下降,甚至出現(xiàn) StackoverflowError速蕊。

而尾遞歸則是一種特殊的遞歸嫂丙,“尾遞歸優(yōu)化技術(shù)”可以避免上述出現(xiàn)的問題,使其不再發(fā)生棧溢出的情況互例。

什么是尾遞歸奢入?如果一個(gè)函數(shù)中,所有遞歸形式的調(diào)用都出現(xiàn)在函數(shù)的末尾媳叨,我們稱這個(gè)遞歸函數(shù)就是尾遞歸的腥光。

上面的求解階乘的代碼就不是尾遞歸的,因?yàn)槲覀冊?code>fact(n - 1) 調(diào)用之后糊秆,還需要一步計(jì)算過程武福。

而尾遞歸實(shí)現(xiàn)階乘函數(shù)如下:

// 尾遞歸
const fact = (n,total = 1) => {
    if(n < 0)
        throw 'Illegal input'
    if (n === 0)
        return 0
    if (n === 1)
        return total
    return fact(n - 1, n * total)
}

首先,尾遞歸優(yōu)化需要語言或編譯器的支持痘番,像 Java捉片,Python 并沒有尾遞歸優(yōu)化,其不做尾遞歸優(yōu)化的原因是為了在拋出異常時(shí)可以有完整的 Stack Trace 輸出汞舱。像 JavaScript伍纫,C 等語言則具備對尾遞歸的優(yōu)化。而編譯器可以做到這點(diǎn)昂芜,是因?yàn)楫?dāng)編譯器檢測到一個(gè)函數(shù)的調(diào)用是尾遞歸時(shí)莹规,它就會(huì)覆蓋當(dāng)前的棧幀而不是在方法棧中新壓入一個(gè),尾遞歸通過覆蓋當(dāng)前的棧幀泌神,使得所使用的棧內(nèi)存大大縮減良漱,且實(shí)際的運(yùn)行效率有了顯著的提高。

3. Java8 的函數(shù)式編程

函數(shù)式接口

Java8 中引入了一個(gè)概念——函數(shù)式接口欢际。這個(gè)目的就是為了讓 Java 語言可以更好地支持函數(shù)式編程母市。

下面就是一個(gè)函數(shù)式接口:

public interface Action {
    public void action();
}

函數(shù)式接口只能有一個(gè)抽象方法,除此之外這個(gè)函數(shù)式接口看起來和普通的接口并沒有啥區(qū)別损趋。

如果你想讓別人立刻理解這個(gè)接口是一個(gè)函數(shù)式接口的話患久,可以加上 @FunctionalInterface 注解,該注解除了限定并保證你的函數(shù)式接口只有一個(gè)抽象方法之外浑槽,不會(huì)提供任何額外的功能墙杯。

@FunctionalInterface
public interface Action {
    public void action();
}

其實(shí),早在 Java8 出現(xiàn)以前括荡,就已經(jīng)有很多函數(shù)式接口了高镐,譬如我們熟知的 RunnableComparator畸冲,InvocationHandler 等嫉髓,這些接口都是符合函數(shù)式接口的定義的。

Java8 引入的常用的函數(shù)式接口有這么幾個(gè):

  • Function<T,R> { R apply(T t); }
  • Predicate<T> { boolean test(T t); }
  • Consumer<T> { void accept(T t); }
  • Supplier<T> { T get(); }
  • ... ...

說句篇外話邑闲,我個(gè)人非常討厭在文章中講解 API 的用法算行。

  • 第一點(diǎn):JDK 文檔已經(jīng)將每種 API 的用法非常詳細(xì)地寫出來了,沒必要再次贅述這些東西苫耸。
  • 第二點(diǎn):我的文章字?jǐn)?shù)有限州邢,在有限的文字中,表達(dá)出來的應(yīng)該是可以引導(dǎo)讀者思考和評論的東西褪子,而不是浪費(fèi)大家閱讀時(shí)間的糟粕量淌。

所以骗村,如果想要搞清所有的函數(shù)式接口的用法,大家可以自行查閱文檔呀枢。

Lambda 表達(dá)式與方法引用

下面一段代碼實(shí)現(xiàn)的功能為按照字符串長度的順序?qū)α斜磉M(jìn)行排序:

List<String> words = List.of("BBC", "A", "NBA", "F_WORD");
Collections.sort(words, new Comparator<String>() {
    @Override
    public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

這段代碼將匿名類的缺點(diǎn)暴露了出來——冗長胚股,代碼含義不清晰。在 Java8 中裙秋,引入了 Lambda 表達(dá)式來簡化這種形式的代碼琅拌,如果你使用的代碼編譯器是 IDEA,你就會(huì)發(fā)現(xiàn)在寫完這段代碼之后摘刑,編譯器提示你:Anonymous new Comparator<String>() can be replaced with lambda进宝。

當(dāng)你按下 option + enter 鍵之后,你就會(huì)發(fā)現(xiàn)自己開啟了一扇新世界的大門:-)

List<String> words = List.of("BBC", "A", "NBA", "F_WORD");
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

接下來枷恕,我會(huì)向你不完全解釋一下為什么 Lambda 表達(dá)式可以這樣做:

首先党晋,能夠使用 Lambda 表達(dá)式的依據(jù)是必須有相應(yīng)的函數(shù)式接口,這也反過來說明了活尊,為什么函數(shù)式接口只能有一個(gè)抽象方法(如果有多個(gè)抽象方法隶校,Lambda 怎么知道你寫的是啥子嘞)。

第二點(diǎn)蛹锰,Lambda 表達(dá)式的寫法在沒有經(jīng)過“類型推斷”的簡化前應(yīng)該是這樣的:

Collections.sort(words, (String s1, String s2) -> Integer.compare(s1.length(), s2.length()));

之所以可以將括號內(nèi)的類型省略深胳,是因?yàn)?Lambda 表達(dá)式另一個(gè)依據(jù)是類型推斷機(jī)制,在上下文信息足夠的情況下铜犬,編譯器可以推斷出參數(shù)表的類型舞终,而不需要顯式指名。類型推斷的機(jī)制是極為復(fù)雜的癣猾,大家可以參考一下 Java8 的 JLS 引入的類型推斷這個(gè)章節(jié)敛劝,鏈接:https://docs.oracle.com/javase/specs/jls/se8/html/jls-18.html

在將匿名類轉(zhuǎn)化為 Lambda 表達(dá)式之后纷宇,聰明的你(實(shí)際上是聰明的編譯器 doge)又發(fā)現(xiàn)了夸盟,編譯器繼續(xù)提示你:Can be replaced with Comparator.comparingInt

我們繼續(xù)敲下 option + enter 鍵像捶,發(fā)現(xiàn) Lambda 表達(dá)式簡化成了這樣:

Collections.sort(words, Comparator.comparingInt(String::length));

你發(fā)現(xiàn)上陕,自己好像又邁入了一扇新世界的大門:-)

image

String::length 這種表示方式叫做方法引用,即:調(diào)用了 String 類的 length() 方法拓春。其實(shí) Lambda 表達(dá)式就已經(jīng)夠簡潔了释簿,但是方法引用表達(dá)的含義更清晰。使用方法引用的時(shí)候硼莽,只需要使用 :: 雙冒號即可庶溶,無論是靜態(tài)方法還是實(shí)例方法都可以這樣被引用。

《Effective Java》這本 Java 實(shí)踐圣經(jīng)中的 Item 42,43 如下:

  • Prefer lambdas to anonymous classes(Lambda 表達(dá)式優(yōu)于匿名類)
  • Prefer method references to lambdas(方法引用優(yōu)于 Lambda 表達(dá)式)

從 Java8 開始偏螺。Lambda 是迄今為止表示小函數(shù)對象的最佳方式行疏。除非必須創(chuàng)建非函數(shù)式接口類型的實(shí)例,否則不要使用匿名類作為函數(shù)對象砖茸。而方法引用則是對 Lambda 表達(dá)式的進(jìn)一步優(yōu)化隘擎,它相比于 Lambda 表達(dá)式有更清晰化的語義殴穴。如果方法引用比 Lambda 表達(dá)式看起來更簡短更清晰凉夯,就使用方法引用吧!

一個(gè)策略模式的案例帶你再次回顧 Java 函數(shù)式編程

這一章節(jié)采幌,我為大家準(zhǔn)備了一個(gè)商場打折的案例:

public class PriceCalculator {
    public static void main(String[] args) {
        int originalPrice = 100;
        User zhangsan = User.vip("張三");
        User lisi = User.normal("李四");

        // 不打折
        calculatePrice("NoDiscount", originalPrice, lisi);
        // 打 8 折
        calculatePrice("Discount8", originalPrice, lisi);
        // 打 95 折
        calculatePrice("Discount95",originalPrice,lisi);
        // vip 折扣
        calculatePrice("OnlyVip", originalPrice, zhangsan);
    }
    public static int calculatePrice(String discountStrategy, int price, User user) {
        switch (discountStrategy) {
            case "NoDiscount":
                return price;
            case "Discount8":
                return (int) (price * 0.8);
            case "Discount95":
                return (int) (price * 0.95);
            case "OnlyVip": {
                if (user.isVip()) {
                    return (int) (price * 0.7);
                } else {
                    return price;
                }
            }
            default:
                throw new IllegalStateException("Illegal Input!");
        }
    }
}

程序十分簡單劲够,我們的 calculatePrice 方法用來計(jì)算在不同的商場營銷策略下,用戶打折后的金額休傍。方便起見征绎,程序中的金額操作我就不考慮精度丟失的問題了。

這個(gè)程序最大的問題是磨取,如果我們的商場有了新的促銷策略人柿,譬如全場打六折;明天商場倒閉忙厌,全場揮淚大甩賣三折起等凫岖,就需要在 calculatePrice 方法新添加一個(gè) case。如果后續(xù)我們陸陸續(xù)續(xù)新增幾十種打折方案逢净,就要不停修改我們的業(yè)務(wù)代碼哥放,并且使代碼變得冗長難以維護(hù)。

所以爹土,我們的“策略”應(yīng)該和具體的業(yè)務(wù)分離開甥雕,這樣才能降低代碼之間的耦合,使得我們的代碼變得易于維護(hù)胀茵。

我們可以使用策略模式來改進(jìn)我們的代碼社露。

DiscountStrategy 接口如下,當(dāng)然琼娘,聰明如你也發(fā)現(xiàn)了這就是一個(gè)標(biāo)準(zhǔn)的函數(shù)式接口(為了防止你看不見峭弟,我特意加上了 @FunctionalInterface 注解~ doge):

@FunctionalInterface
public interface DiscountStrategy {
    int discount(int price, User user);
}

接下來,我們只需要讓不同的打折策略實(shí)現(xiàn) DiscountStrategy 這個(gè)接口即可轨奄。

NoDiscountStrategy(窮屌絲不配擁有折扣):

public class NoDiscountStrategy implements DiscountStrategy {
    @Override
    public int discount(int price, User user) {
        return price;
    }
}

Discount8Strategy:

public class Discount8Strategy implements DiscountStrategy{
    @Override
    public int discount(int price, User user) {
        return (int) (price * 0.95);
    }
}

Discount95Strategy:

public class Discount95Strategy implements DiscountStrategy{
    @Override
    public int discount(int price, User user) {
        return  (int) (price * 0.8);
    }
}

OnlyVipDiscountStrategy:

public class OnlyVipDiscountStrategy implements DiscountStrategy {
    @Override
    public int discount(int price, User user) {
        if (user.isVip()) {
            return (int) (price * 0.7);
        } else {
            return price;
        }
    }
}

這樣孟害,我們的業(yè)務(wù)代碼和“打折策略”就實(shí)現(xiàn)了分離:

public class PriceCalculator {
    public static void main(String[] args) {
        int originalPrice = 100;
        User zhangsan = User.vip("張三");
        User lisi = User.normal("李四");

        // 不打折
        calculatePrice(new NoDiscountStrategy(), originalPrice, lisi);
        // 打 8 折
        calculatePrice(new Discount8Strategy(), originalPrice, lisi);
        // 打 95 折
        calculatePrice(new Discount95Strategy(), originalPrice, lisi);
        // vip 折扣
        calculatePrice(new OnlyVipDiscountStrategy(), originalPrice, zhangsan);
    }
    public static int calculatePrice(DiscountStrategy strategy, int price, User user) {
        return strategy.discount(price, user);
    }
}

在 Java8 之后,引入了大量的函數(shù)式接口挪拟,我們發(fā)現(xiàn) DicountStrategy 這個(gè)接口和 BiFunction 接口簡直就是從一個(gè)胚子里刻出來的挨务!

DiscountStrategy:

@FunctionalInterface
public interface DiscountStrategy {
    int discount(int price, User user);
}

BiFunction:

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
}

是不是瞬間覺得自己吃了沒有好好讀 API 的虧:-)

image

經(jīng)過一番猛如虎的操作之后,我們的代碼精簡成了這個(gè)樣子:

public class PriceCalculator {
    public static void main(String[] args) {
        int originalPrice = 100;
        User zhangsan = User.vip("張三");
        User lisi = User.normal("李四");

        // 不打折
        calculatePrice((price, user) -> price, originalPrice, lisi);
        // 打 8 折
        calculatePrice((price, user) -> (int) (price * 0.8), originalPrice, lisi);
        // 打 95 折
        calculatePrice((price, user) -> (int) (price * 0.95), originalPrice, lisi);
        // vip 折扣
        calculatePrice(
                (price, user) -> user.isVip() ? (int) (price * 0.7) : price,
                originalPrice,
                zhangsan
        );
    }

    static int calculatePrice(BiFunction<Integer, User, Integer> strategy, int price, User user) {
        return strategy.apply(price, user);
    }
}

從這個(gè)商場打折的案例,我們可以看到 Java 對函數(shù)式編程支持的一個(gè)“心路歷程”谎柄。值得一提的是丁侄,這個(gè)案例最后部分的代碼,我使用了 Lambda 表達(dá)式朝巫,這是因?yàn)榇蛘鄄呗圆]有出現(xiàn)太復(fù)雜的情況鸿摇,并且,我主要也是為了演示 Lambda 表達(dá)式的使用劈猿。但是拙吉,事實(shí)上,這是一種非常不好的實(shí)踐揪荣,我仍然推薦你將不同的策略抽取成一個(gè)類的這種做法筷黔。我們看到:

(price, user) -> user.isVip() ? (int) (price * 0.7) : price;

這段代碼已經(jīng)開始變得有一些復(fù)雜了,如果我們的策略邏輯比這段代碼還要復(fù)雜仗颈,即便你使用 Lambda 寫出來佛舱,閱讀這段代碼的人仍然會(huì)覺得難以理解(并且 Lambda 不是具名的,更是增加了閱讀者的困惑)挨决。所以请祖,在你無法使用一行 Lambda 完成你的功能時(shí),就應(yīng)該考慮將這段代碼抽取出來脖祈,防止影響閱讀它的人的體驗(yàn)肆捕。

二:Java Stream

Java Stream 是 Java8 最最最重要的特性,沒有之一撒犀,它更是 Java 函數(shù)式編程中的靈魂福压!

網(wǎng)上關(guān)于 Java Stream 的介紹已經(jīng)有很多了,在這篇文章中或舞,我不會(huì)介紹太多關(guān)于 Stream 的特性以及各種 API 的使用方法荆姆,諸如:mapreduce 等(畢竟你自己隨便 google 就會(huì)出來一大堆文章)映凳,我打算和你探究一些新鮮玩意胆筒。

如你所見,2021 年的今天诈豌,JDK17 已經(jīng)問世了仆救,可是很多人的業(yè)務(wù)代碼中仍然充斥著大量 Java5 的語法——一些本該使用 Stream 幾行代碼完成的操作,仍然在用又臭又長的 if...elsefor 循環(huán)來代替著矫渔。為什么會(huì)出現(xiàn)這種情況呢彤蔽?Java8 實(shí)際上是一個(gè)老古董了,為啥不用庙洼?

這就是我今天要和你探討的問題了顿痪。

我總結(jié)了兩類不愿使用 Stream API 的人:

  • 第一類人曰:“不知道什么時(shí)候用镊辕,即便知道可以用,但是也用不好”
  • 第二類人曰:“Stream 會(huì)導(dǎo)致性能的下降蚁袭,不如不用”

那么接下來征懈,我就從這兩種論點(diǎn)入手,和你分析一下 Stream 該啥時(shí)候用揩悄,Stream 是不是真的那么難寫卖哎,Stream 是否會(huì)影響程序的性能等問題。

1. 什么時(shí)候可以使用 Stream删性,怎么用亏娜?

啥時(shí)候可以使用 Stream ?簡而言之镇匀,一句話:當(dāng)你操作的數(shù)據(jù)是數(shù)組或集合時(shí)就可以用(其實(shí)不僅僅是數(shù)組和集合照藻,Stream 的源除了數(shù)組和集合外袜啃,還可以是文件汗侵,正則表達(dá)式模式匹配器,偽隨機(jī)數(shù)生成器等群发,不過數(shù)組和集合是最常見的)晰韵。

Java Stream 誕生的原因就是為了解放程序員操作集合(Collection)時(shí)的生產(chǎn)力,你可以將它類比成一個(gè)迭代器熟妓,因?yàn)閿?shù)組和集合都是可迭代的雪猪,所以當(dāng)你操作的數(shù)據(jù)是數(shù)組或集合時(shí),就應(yīng)該考慮是否可以使用 Stream 來簡化自己的代碼起愈。

這種意識(shí)應(yīng)該是主觀的只恨,只有你經(jīng)常去操作 Stream,才會(huì)漸漸得心應(yīng)手抬虽。

我準(zhǔn)備了大量的示例官觅,讓我們看一下 Stream 是如何解放你的雙手,并且簡化代碼的:-)

示例一:

假設(shè)你有一個(gè)業(yè)務(wù)需求阐污,要求篩選出年齡大于等于60的用戶休涤,然后將他們按照年齡從大到小排序并將他們的名字放在 List 中返回。

如果不使用 Stream 的操作會(huì)是這樣的:

public List<String> collect(List<User> users) {
    List<User> userList = new ArrayList<>();
    // 篩選出年齡大于等于 60 的用戶
    for (User user : users)
        if (user.age >= 60)
            userList.add(user);

    // 將他們的年齡從大到小排序
    userList.sort(Comparator.comparing(User::getAge).reversed());
        
    List<String> result = new ArrayList<>();
    for (User user : userList)
        result.add(user.name);
    return result;
}

如果使用了 Stream笛辟,會(huì)是這樣的:

public List<String> collect(List<User> users) {
    return users.stream()
            .filter(user -> user.age >= 60)
            .sorted(comparing(User::getAge).reversed())
            .map(User::getName)
            .collect(Collectors.toList());
}

怎么樣功氨?是不是覺得逼格瞬間提升?而且最重要的是代碼的可讀性增強(qiáng)了手幢,不需要任何注釋捷凄,你就可以看懂我在做什么。

示例二:

給定一段文本字符串以及一個(gè)字符串?dāng)?shù)組 keywords围来;判斷文本當(dāng)中是否包含關(guān)鍵詞數(shù)組中的關(guān)鍵詞跺涤,如果包含任意一個(gè)關(guān)鍵詞踱阿,返回 true,否則返回 false钦铁。

譬如:

text = "I am a boy"
keywords = ["cat", "boy"]

結(jié)果返回 true软舌。

如果使用正常迭代的邏輯,我們的代碼是這樣的:

public boolean containsKeyword(String text, List<String> keywords) {

    for (String keyword : keywords) {
        if (text.contains(keyword))
            return true;
    }
    return false;
}

然后牛曹,使用 Stream 是這樣的:

public boolean containsKeyword(String text, List<String> keywords) {
    return keywords.stream().anyMatch(text::contains);
}

一行代碼就完成了我們的需求佛点,是不是現(xiàn)在覺得有點(diǎn)酷了?

示例三:

統(tǒng)計(jì)一個(gè)給定的字符串黎比,所有大寫字母出現(xiàn)的次數(shù)超营。

Stream 寫法:

public int countUpperCaseLetters(String str) {
        return (int) str.chars().filter(Character::isUpperCase).count();
    }

示例四:

假如你有一個(gè)業(yè)務(wù)需求,需要對傳入的 List<Employee> 進(jìn)行如下處理:返回一個(gè)從部門名到這個(gè)部門的所有用戶的映射阅虫,且同一個(gè)部門的用戶按照年齡進(jìn)行從小到大排序演闭。

例如

輸入為:

[{name=張三, department=技術(shù)部, age=40 }, {name=李四, department=技術(shù)部, age=30 },{name=王五, department=市場部, age=40 }]

輸出為:

技術(shù)部 -> [{name=李四, department=技術(shù)部, age=30 }, {name=張三, department=技術(shù)部, age=40 }]
市場部 -> [{name=王五, department=市場部, age=40 }]

Stream 的寫法如下:

public Map<String, List<Employee>> collect(List<Employee> employees) {
    return employees.stream()
            .sorted(Comparator.comparing(Employee::getAge))
            .collect(Collectors.groupingBy(Employee::getDepartment));
}

示例五:

給定一個(gè)字符串的 Set 集合,要求我們將所有長度等于 1 的單詞挑選出來颓帝,然后使用逗號連接米碰。

使用 Stream 操作的代碼如下:

public String filterThenConcat(Set<String> words) {
    return words.stream()
            .filter(word -> word.length() == 1)
            .collect(Collectors.joining(","));
}

示例六:

接下來我們看一道 LeetCode 上的問題:

1431. 擁有最多糖果的孩子

問題我就不再描述了,大家可以自己找一哈~

本題使用 Stream 求解的代碼如下:

class Solution {
    public List<Boolean> kidsWithCandies(int[] candies, int extraCandies) {
        int max = Arrays.stream(candies).max().getAsInt();
        return Arrays.stream(candies)
                .mapToObj(candy -> (candy + extraCandies) >= max)
                .collect(Collectors.toList());
    }
}

到目前為止购城,我給了你六個(gè)示例程序吕座,你可能發(fā)現(xiàn)了,這些小案例非常貼合我們平時(shí)書寫的業(yè)務(wù)需求和邏輯瘪板,并且即便你寫不好 Stream吴趴,也似乎可以看懂這些代碼在做什么。

我當(dāng)然沒有那么神奇侮攀,可以一下子讓你領(lǐng)悟通透世界(《鬼滅之刃》中的一種特殊技法)锣枝。我只是想告訴你, 既然能看懂兰英,就可以寫好撇叁。Stream 是一個(gè)貨真價(jià)實(shí)的家伙,它真的可以解放你的雙手箭昵,提高生產(chǎn)力税朴。所以,只要你明白何時(shí)該去使用 Stream家制,并且刻意練習(xí)正林,這些操作是不在話下的。

2. Stream 會(huì)影響性能颤殴?

老實(shí)說觅廓,這個(gè)問題筆者也不知道。

image

不過涵但,我的文章已經(jīng)硬著頭皮寫到這里了杈绸,你總不能讓我把前面的東西刪了重寫吧帖蔓。

所以,我就 Google 了一些文章并把它們詳細(xì)地閱讀了一下瞳脓。

先說結(jié)論:

  • Stream 確實(shí)不如迭代操作的性能高塑娇,并且 Stream 的性能與運(yùn)行的機(jī)器有著很大的關(guān)系,機(jī)器性能越好劫侧,Stream 和 for-loop 之間的差異就越小埋酬,一般在 4核+ 的計(jì)算機(jī)上,Stream 和 for-loop 的差異非常小烧栋,絕對是可以接受的
  • 對于基本類型而言写妥,for-loop 的性能整體上要比 Stream 好;對于對象而言审姓,雖然 Stream 還是比 for-loop 性能差一些珍特,但是比起和基本類型的比較來說,差距已經(jīng)不是那么大了魔吐。這是因?yàn)榛绢愋褪蔷彺嬗押玫脑玻⑶已h(huán)本身是 JIT 友好的,自然性能要比 Stream 好上“很多”(實(shí)際上完全可以接受)画畅。
  • 對于簡單的操作推薦使用循環(huán)來實(shí)現(xiàn)砸琅;對于復(fù)雜的操作推薦使用 Stream,一方面是因?yàn)?Stream 會(huì)讓你的代碼變得可讀性高且簡潔轴踱,另一方面是因?yàn)?Java Stream 還會(huì)不斷升級,優(yōu)化谚赎,我們的代碼不用做任何修改就可以享受到升級帶來的好處淫僻。

測試程序:

這里面,我的測試直接使用了大佬在 github 的代碼壶唤。給出大佬的代碼鏈接:

程序我就不貼了雳灵,大家可以自己去 github 上下載大佬的源代碼

我的測試結(jié)果如下

int 基本類型的測試:

---array length: 10000000---
minIntFor time:0.026800675 s
minIntStream time:0.027718066 s
minIntParallelStream time:0.009003748 s

---array length: 100000000---
minIntFor time:0.260386317 s
minIntStream time:0.267419711 s
minIntParallelStream time:0.078855602 s

String 對象的測試:

---List length: 10000000---
minStringForLoop time:0.315122729 s
minStringStream time:0.45268919 s
minStringParallelStream time:0.123185242 s

---List length: 20000000---
minStringForLoop time:0.666359326 s
minStringStream time:0.927732888 s
minStringParallelStream time:0.247808256 s

大家可以看到,在我自己用的 4 核計(jì)算機(jī)上 Stream 和 for-loop 幾乎是沒有啥差異的闸盔。而且多核計(jì)算機(jī)可以享受 Stream 并行迭代帶來的好處悯辙。

三:總結(jié)

到這里這篇文章終于結(jié)束了。如果你能看到這里迎吵,想必也是一個(gè)狠人躲撰。

本文介紹了函數(shù)式編程的概念與思想。文章中并沒有涉及到任何數(shù)學(xué)理論和函數(shù)式編程的高級特性击费,如果想要了解這一部分的同學(xué)拢蛋,可以自行查找一些資料。

Java Stream 是一個(gè)非常重要的操作蔫巩,它不僅可以簡化代碼谆棱,讓你的代碼看上去清晰易懂快压,而且還可以培養(yǎng)你函數(shù)式編程的思維。

好啦垃瞧,至此為止蔫劣,這一篇文章我就介紹完畢了~歡迎大家關(guān)注我的公眾號【kim_talk】,在這里希望你可以收獲更多的知識(shí)个从,我們下一期再見拦宣!

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末信姓,一起剝皮案震驚了整個(gè)濱河市鸵隧,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌意推,老刑警劉巖豆瘫,帶你破解...
    沈念sama閱讀 216,591評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異菊值,居然都是意外死亡外驱,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,448評論 3 392
  • 文/潘曉璐 我一進(jìn)店門腻窒,熙熙樓的掌柜王于貴愁眉苦臉地迎上來昵宇,“玉大人,你說我怎么就攤上這事儿子⊥甙ィ” “怎么了?”我有些...
    開封第一講書人閱讀 162,823評論 0 353
  • 文/不壞的土叔 我叫張陵柔逼,是天一觀的道長蒋譬。 經(jīng)常有香客問我,道長愉适,這世上最難降的妖魔是什么犯助? 我笑而不...
    開封第一講書人閱讀 58,204評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮维咸,結(jié)果婚禮上剂买,老公的妹妹穿的比我還像新娘。我一直安慰自己癌蓖,他們只是感情好瞬哼,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,228評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著费坊,像睡著了一般倒槐。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上附井,一...
    開封第一講書人閱讀 51,190評論 1 299
  • 那天讨越,我揣著相機(jī)與錄音两残,去河邊找鬼。 笑死把跨,一個(gè)胖子當(dāng)著我的面吹牛人弓,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播着逐,決...
    沈念sama閱讀 40,078評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼崔赌,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了耸别?” 一聲冷哼從身側(cè)響起健芭,我...
    開封第一講書人閱讀 38,923評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎秀姐,沒想到半個(gè)月后慈迈,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,334評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡省有,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,550評論 2 333
  • 正文 我和宋清朗相戀三年痒留,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片蠢沿。...
    茶點(diǎn)故事閱讀 39,727評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡伸头,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出舷蟀,到底是詐尸還是另有隱情恤磷,我是刑警寧澤,帶...
    沈念sama閱讀 35,428評論 5 343
  • 正文 年R本政府宣布雪侥,位于F島的核電站碗殷,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏速缨。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,022評論 3 326
  • 文/蒙蒙 一代乃、第九天 我趴在偏房一處隱蔽的房頂上張望旬牲。 院中可真熱鬧,春花似錦搁吓、人聲如沸原茅。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,672評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽擂橘。三九已至,卻和暖如春摩骨,著一層夾襖步出監(jiān)牢的瞬間通贞,已是汗流浹背朗若。 一陣腳步聲響...
    開封第一講書人閱讀 32,826評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留昌罩,地道東北人哭懈。 一個(gè)月前我還...
    沈念sama閱讀 47,734評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像茎用,于是被迫代替她去往敵國和親遣总。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,619評論 2 354

推薦閱讀更多精彩內(nèi)容