淺談函數語言程式設計與 Java Stream

jrh發表於2021-09-20

前言

LearnKu 社群的小夥伴們大家好啊,許久不見~

在這一篇文章中,我將介紹函數語言程式設計的基本概念,如何使用函數語言程式設計的思想編寫程式碼以及 Java Stream 的基本使用方法。

本文不會涉及到任何晦澀難懂的數學概念,函數語言程式設計的理論以及函數語言程式設計的高階特性,譬如:惰性求值(Lazy Evaluation),模式匹配等。所以,請放心食用。

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

  • 說不清什麼是函數語言程式設計的人
  • 不知道什麼時候可以使用 Java Stream 的人
  • Java8 出來了這麼久,還是無法寫好 Stream 操作的人

本文使用的程式碼語言為:Java,一部分案例使用了 Python 。這篇文章參考並引用了很多優秀文章的內容,所有的參考連結在最後,如果想要更多地瞭解函數語言程式設計以及 Java Stream 的相關操作,我推薦你把本文最後給出連結的那些資料儘可能詳細地看一遍,相信一定會對你有所幫助 :-)

一:函數語言程式設計

1. 什麼是函數語言程式設計?

在向你介紹什麼是函數語言程式設計之前,我們不妨來簡單瞭解一些歷史。

函數語言程式設計的理論基礎是阿隆佐.邱奇(Alonzo Church)在 1930 年代開發的 λ 演算(λ-calculus)。

Alonzo Church

λ 演算其本質是一種數學的抽象,是數理邏輯中的一個形式系統(Formal System)。這個系統是為一個超級機器設計的程式語言,在這種語言裡面,函式的引數是函式,返回值也是函式。這種函式用希臘字母 Lambda(λ)來表示。

這個時候,λ 演算還僅僅是阿隆佐的一種思想,一種計算模型,並沒有運用到任何的硬體系統上。直到 20 世紀 50 年代後期,一位 MIT 的教授 John McCarthy 對阿隆佐的研究產生了興趣,並於 1958 年開發了早期的函數語言程式設計語言 LISP,可以說,LISP 語言是一種阿隆佐的 λ 演算在現實世界的實現。很多電腦科學家都認識到了 LISP 強大的能力。1973 年在 MIT 人工智慧實驗室的一些程式設計師研發出了一種機器,並把它叫做 LISP 機,這個時候,阿隆佐的 λ 演算終於有了自己的硬體實現!

那麼話說回來,什麼是函數語言程式設計呢?

維基百科中,函數語言程式設計(Functional Programming)的定義如下:

函數語言程式設計是一種程式設計正規化。它把計算當成是數學函式的求值,從而避免改變狀態和使用可變資料。它是一種宣告式的程式設計正規化,通過表示式和宣告而不是語句來程式設計。

說到這裡,你可能還是不明白,究竟什麼是FP(Functional Programming)?既然 FP 作為一種程式設計正規化,就不得不提與之相對應的另一種程式設計正規化——傳統的指令式程式設計(Imperative Programming)。接下來,我們就通過一些程式碼案例,讓你直觀地感受一下,函數語言程式設計與指令式程式設計有哪些差異;另外,我想告訴你的是即便不瞭解函數語言程式設計中那些深奧的概念,我們也可以使用函數語言程式設計的思想來寫程式碼:)

案例一:二叉樹映象

這個題目可以在 LeetCode 上找到,感興趣的朋友可以自行搜尋一下。

題目要求是這樣的:請完成一個函式,輸入一個二叉樹,該函式輸出它的映象。

例如輸入:

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

映象輸出:

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

傳統的指令式程式設計的程式碼是這樣的:

# 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

可以看到,指令式程式設計就像是我們程式設計師規定的一種描述計算機所需要作出一系列行為的指令集(行動清單)。我們需要詳細地告訴計算機每一步需要執行什麼命令,就像是這段程式碼一樣,我們首先判斷節點是否為空;然後使用一個臨時變數儲存左子樹,並完成左子樹與右子樹的映象翻轉,最後左右互換。我們只要將計算機需要完成的那些步驟寫出,然後交給機器執行即可,這種“面向機器程式設計”的思想就是指令式程式設計。

我們再來看一下函數語言程式設計風格的程式碼:

# 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))

你可能會覺得不解,這個程式碼是在哪裡體現出函數語言程式設計的呢?

先別急,讓我慢慢向你解釋。函式(function)這個名詞最早是由萊布尼茲在 1694 年開始使用,用來描述輸出值的變化同輸入值變化的關係。而中文的“函式”一詞,由清朝數學家李善蘭翻譯,其著作《代數學》書中解釋為:“凡此變數中函(包含)彼變數者,則此為彼之函式”。無論哪一種解釋,我們都知道了,函式這一概念描述的是一種關係對映,即:一種東西到另外一種東西之間的對應關係。

所以,我們可以使用函式式的思維去思考,獲得一棵二叉樹的映象這個函式的輸入是一棵“原樹”,返回的結果是一棵翻轉後的“新樹”,而這個函式的本質就是從“原樹”到“新樹”的一個對映。

進而,我們可以找到這個對映的關係為“新樹”的每一個節點都遞迴地和“原樹”相反。

雖然這兩段程式碼都使用了遞迴,但是思考的方式是截然不同的。前者描述的是“從原樹得到新樹應該怎樣做”,後者描述的是從“原樹”到“新樹”的對映關係。

案例二:翻轉字串

題目為獲得一個字串的翻轉。

指令式程式設計:

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

這段 Python 程式碼非常簡單,我們模擬將一個字串先從頭至尾執行入棧操作,然後再從尾到頭執行出棧操作,得到的就是一個翻轉後的字串了。

函數語言程式設計:

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

如何理解函數語言程式設計的思想書寫翻轉字串的邏輯呢?獲得一個字串的翻轉這個函式的輸入是“原字串”,返回的結果是翻轉後的“新字串”,而這個函式的本質就是從“原字串”到“新字串”的一個對映,將“原字串”拆分為首字元和剩餘的部分,剩餘的部分翻轉後放在前,再將首字元放在最後就得到了“新字串”,這就是輸入與輸出的對映關係。

通過以上這兩個示例,我們可以看到,指令式程式設計和函數語言程式設計在思想上的不同之處:指令式程式設計的感覺就像我們在小學求解的數學題一樣,需要一步一步計算,我們關心的是解決問題的過程;而函數語言程式設計則關心的是資料到資料的對映關係。

2. 函數語言程式設計的三大特性

函數語言程式設計具有三大特性:

  • immutable data
  • first class functions
  • 遞迴與尾遞迴的“天然”支援

immutable data

函數語言程式設計中,函式是基礎單元,我們通常理解的變數在函數語言程式設計中也被函式所代替了:在函數語言程式設計中變數僅僅代表某個表示式,但是為了大家可以更好地理解,我仍然使用“變數”這個表達。

純粹的函數語言程式設計所編寫的函式是沒有“變數”的,或者說這些“變數”是不可變的。這就是函數語言程式設計的第一個特性:immutable data(資料不可變)。我們可以說,對於一個函式,只要輸入是確定的,輸出也是可以確定的,我們稱之為無副作用。如果一個函式內部“變數”的狀態不確定,就會導致同樣的輸入可能得到不同的輸出,這是不被允許的。所以,我們這裡所說的“變數”就要求是不能被修改的,且只能被賦一次初始值。

first class functions

在函數語言程式設計中,函式是第一類物件,“first class functions” 可以讓你的函式像“變數”一樣被使用。所謂的“函式是第一類物件”的意思是說一個函式既可以作為其他函式的輸入引數值,也可以作為一個函式的輸出,即:從函式中返回一個函式。

我們來看一個例子:

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

這個示例中 inc() 函式返回了另一個函式incx(),於是,我們可以用 inc() 函式來構造各種版本的 inc 函式,譬如: inc2()inc5()。這個技術叫做函式柯里化(Currying),它的實質就是使用了函數語言程式設計的 “first class functions” 這個特性。

遞迴與尾遞迴的“天然”支援

遞迴這種思想和函數語言程式設計是很配的,有點像是下雨天,巧克力和音樂更配的那種感覺。

函數語言程式設計本身強調的是程式的執行結果而非執行過程,遞迴也是一樣,我們更多在乎的是遞迴的返回值,即:巨集觀語義,而不是它在計算機中是怎麼被壓棧,怎麼被巢狀呼叫的。

經典的遞迴程式案例是實現階乘函式,這裡我使用的是 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)
}

這段程式碼可以正常執行。不過,遞迴程式的本質就是方法的呼叫,在遞迴沒有達到 basecase 時,方法棧會不停壓入棧幀,直到遞迴呼叫有返回值時,方法棧的空間才會被釋放。如果遞迴呼叫很深,就很容易造成效能的下降,甚至出現 StackoverflowError。

而尾遞迴則是一種特殊的遞迴,“尾遞迴優化技術”可以避免上述出現的問題,使其不再發生棧溢位的情況。

什麼是尾遞迴?如果一個函式中,所有遞迴形式的呼叫都出現在函式的末尾,我們稱這個遞迴函式就是尾遞迴的。

上面的求解階乘的程式碼就不是尾遞迴的,因為我們在fact(n - 1) 呼叫之後,還需要一步計算過程。

而尾遞迴實現階乘函式如下:

// 尾遞迴
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)
}

首先,尾遞迴優化需要語言或編譯器的支援,像 Java,Python 並沒有尾遞迴優化,其不做尾遞迴優化的原因是為了在丟擲異常時可以有完整的 Stack Trace 輸出。像 JavaScript,C 等語言則具備對尾遞迴的優化。而編譯器可以做到這點,是因為當編譯器檢測到一個函式的呼叫是尾遞迴時,它就會覆蓋當前的棧幀而不是在方法棧中新壓入一個,尾遞迴通過覆蓋當前的棧幀,使得所使用的棧記憶體大大縮減,且實際的執行效率有了顯著的提高。

3. Java8 的函數語言程式設計

函式式介面

Java8 中引入了一個概念——函式式介面。這個目的就是為了讓 Java 語言可以更好地支援函數語言程式設計。

下面就是一個函式式介面:

public interface Action {
    public void action();
}

函式式介面只能有一個抽象方法,除此之外這個函式式介面看起來和普通的介面並沒有啥區別。

如果你想讓別人立刻理解這個介面是一個函式式介面的話,可以加上 @FunctionalInterface 註解,該註解除了限定並保證你的函式式介面只有一個抽象方法之外,不會提供任何額外的功能。

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

其實,早在 Java8 出現以前,就已經有很多函式式介面了,譬如我們熟知的 RunnableComparatorInvocationHandler 等,這些介面都是符合函式式介面的定義的。

Java8 引入的常用的函式式介面有這麼幾個:

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

說句篇外話,我個人非常討厭在文章中講解 API 的用法。

  • 第一點:JDK 文件已經將每種 API 的用法非常詳細地寫出來了,沒必要再次贅述這些東西。
  • 第二點:我的文章字數有限,在有限的文字中,表達出來的應該是可以引導讀者思考和評論的東西,而不是浪費大家閱讀時間的糟粕。

所以,如果想要搞清所有的函式式介面的用法,大家可以自行查閱文件。

Lambda 表示式與方法引用

下面一段程式碼實現的功能為按照字串長度的順序對列表進行排序:

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());
    }
});

這段程式碼將匿名類的缺點暴露了出來——冗長,程式碼含義不清晰。在 Java8 中,引入了 Lambda 表示式來簡化這種形式的程式碼,如果你使用的程式碼編譯器是 IDEA,你就會發現在寫完這段程式碼之後,編譯器提示你:Anonymous new Comparator<String>() can be replaced with lambda

當你按下 option + enter 鍵之後,你就會發現自己開啟了一扇新世界的大門:-)

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

接下來,我會向你不完全解釋一下為什麼 Lambda 表示式可以這樣做:

首先,能夠使用 Lambda 表示式的依據是必須有相應的函式式介面,這也反過來說明了,為什麼函式式介面只能有一個抽象方法(如果有多個抽象方法,Lambda 怎麼知道你寫的是啥子嘞)。

第二點,Lambda 表示式的寫法在沒有經過“型別推斷”的簡化前應該是這樣的:

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

之所以可以將括號內的型別省略,是因為 Lambda 表示式另一個依據是型別推斷機制,在上下文資訊足夠的情況下,編譯器可以推斷出參數列的型別,而不需要顯式指名。型別推斷的機制是極為複雜的,大家可以參考一下 Java8 的 JLS 引入的型別推斷這個章節,連結:docs.oracle.com/javase/specs/jls/s...

在將匿名類轉化為 Lambda 表示式之後,聰明的你(實際上是聰明的編譯器 doge)又發現了,編譯器繼續提示你:Can be replaced with Comparator.comparingInt

我們繼續敲下 option + enter 鍵,發現 Lambda 表示式簡化成了這樣:

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

你發現,自己好像又邁入了一扇新世界的大門:-)

String::length 這種表示方式叫做方法引用,即:呼叫了 String 類的 length() 方法。其實 Lambda 表示式就已經夠簡潔了,但是方法引用表達的含義更清晰。使用方法引用的時候,只需要使用 :: 雙冒號即可,無論是靜態方法還是例項方法都可以這樣被引用。

《Effective Java》這本 Java 實踐聖經中的 Item 42,43 如下:

  • Prefer lambdas to anonymous classes(Lambda 表示式優於匿名類)
  • Prefer method references to lambdas(方法引用優於 Lambda 表示式)

從 Java8 開始。Lambda 是迄今為止表示小函式物件的最佳方式。除非必須建立非函式式介面型別的例項,否則不要使用匿名類作為函式物件。而方法引用則是對 Lambda 表示式的進一步優化,它相比於 Lambda 表示式有更清晰化的語義。如果方法引用比 Lambda 表示式看起來更簡短更清晰,就使用方法引用吧!

一個策略模式的案例帶你再次回顧 Java 函數語言程式設計

這一章節,我為大家準備了一個商場打折的案例:

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 方法用來計算在不同的商場營銷策略下,使用者打折後的金額。方便起見,程式中的金額操作我就不考慮精度丟失的問題了。

這個程式最大的問題是,如果我們的商場有了新的促銷策略,譬如全場打六折;明天商場倒閉,全場揮淚大甩賣三折起等,就需要在 calculatePrice 方法新新增一個 case。如果後續我們陸陸續續新增幾十種打折方案,就要不停修改我們的業務程式碼,並且使程式碼變得冗長難以維護。

所以,我們的“策略”應該和具體的業務分離開,這樣才能降低程式碼之間的耦合,使得我們的程式碼變得易於維護。

我們可以使用策略模式來改進我們的程式碼。

DiscountStrategy 介面如下,當然,聰明如你也發現了這就是一個標準的函式式介面(為了防止你看不見,我特意加上了 @FunctionalInterface 註解~ doge):

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

接下來,我們只需要讓不同的打折策略實現 DiscountStrategy 這個介面即可。

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;
        }
    }
}

這樣,我們的業務程式碼和“打折策略”就實現了分離:

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 之後,引入了大量的函式式介面,我們發現 DicountStrategy 這個介面和 BiFunction 介面簡直就是從一個胚子裡刻出來的!

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 的虧:-)

經過一番猛如虎的操作之後,我們的程式碼精簡成了這個樣子:

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);
    }
}

從這個商場打折的案例,我們可以看到 Java 對函數語言程式設計支援的一個“心路歷程”。值得一提的是,這個案例最後部分的程式碼,我使用了 Lambda 表示式,這是因為打折策略並沒有出現太複雜的情況,並且,我主要也是為了演示 Lambda 表示式的使用。但是,事實上,這是一種非常不好的實踐,我仍然推薦你將不同的策略抽取成一個類的這種做法。我們看到:

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

這段程式碼已經開始變得有一些複雜了,如果我們的策略邏輯比這段程式碼還要複雜,即便你使用 Lambda 寫出來,閱讀這段程式碼的人仍然會覺得難以理解(並且 Lambda 不是具名的,更是增加了閱讀者的困惑)。所以,在你無法使用一行 Lambda 完成你的功能時,就應該考慮將這段程式碼抽取出來,防止影響閱讀它的人的體驗。

二:Java Stream

Java Stream 是 Java8 最最最重要的特性,沒有之一,它更是 Java 函數語言程式設計中的靈魂!

網上關於 Java Stream 的介紹已經有很多了,在這篇文章中,我不會介紹太多關於 Stream 的特性以及各種 API 的使用方法,諸如:mapreduce 等(畢竟你自己隨便 google 就會出來一大堆文章),我打算和你探究一些新鮮玩意。

如你所見,2021 年的今天,JDK17 已經問世了,可是很多人的業務程式碼中仍然充斥著大量 Java5 的語法——一些本該使用 Stream 幾行程式碼完成的操作,仍然在用又臭又長的 if...elsefor 迴圈來代替著。為什麼會出現這種情況呢?Java8 實際上是一個老古董了,為啥不用?

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

我總結了兩類不願使用 Stream API 的人:

  • 第一類人曰:“不知道什麼時候用,即便知道可以用,但是也用不好”
  • 第二類人曰:“Stream 會導致效能的下降,不如不用”

那麼接下來,我就從這兩種論點入手,和你分析一下 Stream 該啥時候用,Stream 是不是真的那麼難寫,Stream 是否會影響程式的效能等問題。

1. 什麼時候可以使用 Stream,怎麼用?

啥時候可以使用 Stream ?簡而言之,一句話:當你操作的資料是陣列或集合時就可以用(其實不僅僅是陣列和集合,Stream 的源除了陣列和集合外,還可以是檔案,正規表示式模式匹配器,偽隨機數生成器等,不過陣列和集合是最常見的)。

Java Stream 誕生的原因就是為了解放程式設計師操作集合(Collection)時的生產力,你可以將它類比成一個迭代器,因為陣列和集合都是可迭代的,所以當你操作的資料是陣列或集合時,就應該考慮是否可以使用 Stream 來簡化自己的程式碼。

這種意識應該是主觀的,只有你經常去操作 Stream,才會漸漸得心應手。

我準備了大量的示例,讓我們看一下 Stream 是如何解放你的雙手,並且簡化程式碼的:-)

示例一:

假設你有一個業務需求,要求篩選出年齡大於等於60的使用者,然後將他們按照年齡從大到小排序並將他們的名字放在 List 中返回。

如果不使用 Stream 的操作會是這樣的:

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,會是這樣的:

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());
}

怎麼樣?是不是覺得逼格瞬間提升?而且最重要的是程式碼的可讀性增強了,不需要任何註釋,你就可以看懂我在做什麼。

示例二:

給定一段文字字串以及一個字串陣列 keywords;判斷文字當中是否包含關鍵詞陣列中的關鍵詞,如果包含任意一個關鍵詞,返回 true,否則返回 false。

譬如:

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

結果返回 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);
}

一行程式碼就完成了我們的需求,是不是現在覺得有點酷了?

示例三:

統計一個給定的字串,所有大寫字母出現的次數。

Stream 寫法:

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

示例四:

假如你有一個業務需求,需要對傳入的 List<Employee> 進行如下處理:返回一個從部門名到這個部門的所有使用者的對映,且同一個部門的使用者按照年齡進行從小到大排序。

例如

輸入為:

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

輸出為:

技術部 -> [{name=李四, department=技術部, age=30 }, {name=張三, department=技術部, 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));
}

示例五:

給定一個字串的 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());
    }
}

到目前為止,我給了你六個示例程式,你可能發現了,這些小案例非常貼合我們平時書寫的業務需求和邏輯,並且即便你寫不好 Stream,也似乎可以看懂這些程式碼在做什麼。

我當然沒有那麼神奇,可以一下子讓你領悟通透世界(《鬼滅之刃》中的一種特殊技法)。我只是想告訴你, 既然能看懂,就可以寫好。Stream 是一個貨真價實的傢伙,它真的可以解放你的雙手,提高生產力。所以,只要你明白何時該去使用 Stream,並且刻意練習,這些操作是不在話下的。

2. Stream 會影響效能?

老實說,這個問題筆者也不知道。

不過,我的文章已經硬著頭皮寫到這裡了,你總不能讓我把前面的東西刪了重寫吧。

所以,我就 Google 了一些文章並把它們詳細地閱讀了一下。

先說結論:

  • Stream 確實不如迭代操作的效能高,並且 Stream 的效能與執行的機器有著很大的關係,機器效能越好,Stream 和 for-loop 之間的差異就越小,一般在 4核+ 的計算機上,Stream 和 for-loop 的差異非常小,絕對是可以接受的
  • 對於基本型別而言,for-loop 的效能整體上要比 Stream 好;對於物件而言,雖然 Stream 還是比 for-loop 效能差一些,但是比起和基本型別的比較來說,差距已經不是那麼大了。這是因為基本型別是快取友好的,並且迴圈本身是 JIT 友好的,自然效能要比 Stream 好上“很多”(實際上完全可以接受)。
  • 對於簡單的操作推薦使用迴圈來實現;對於複雜的操作推薦使用 Stream,一方面是因為 Stream 會讓你的程式碼變得可讀性高且簡潔,另一方面是因為 Java Stream 還會不斷升級,優化,我們的程式碼不用做任何修改就可以享受到升級帶來的好處。

測試程式:

這裡面,我的測試直接使用了大佬在 github 的程式碼。給出大佬的程式碼連結:

程式我就不貼了,大家可以自己去 github 上下載大佬的原始碼

我的測試結果如下

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 核計算機上 Stream 和 for-loop 幾乎是沒有啥差異的。而且多核計算機可以享受 Stream 並行迭代帶來的好處。

三:總結

到這裡這篇文章終於結束了。如果你能看到這裡,想必也是一個狠人。

本文介紹了函數語言程式設計的概念與思想。文章中並沒有涉及到任何數學理論和函數語言程式設計的高階特性,如果想要了解這一部分的同學,可以自行查詢一些資料。

Java Stream 是一個非常重要的操作,它不僅可以簡化程式碼,讓你的程式碼看上去清晰易懂,而且還可以培養你函數語言程式設計的思維。

好啦,至此為止,這一篇文章我就介紹完畢了~歡迎大家關注我的公眾號【kim_talk】,在這裡希望你可以收穫更多的知識,我們下一期再見!

參考資料

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章