Java函數語言程式設計:三、流與函數語言程式設計

2022-11-06 06:01:44

本文是Java函數語言程式設計的最後一篇,承接上文:
Java函數語言程式設計:一、函數式介面,lambda表示式和方法參照
Java函數語言程式設計:二、高階函數,閉包,函陣列合以及柯里化

前面都是概念和鋪墊,主要講述了函數語言程式設計中,如何獲取我們需要的函數作為引數或輸出來進行程式設計,同時補充了一些要注意的知識。比如柯里化,閉包等等。

而這一篇要講的是Java函數語言程式設計的主菜,也就是如何把我們苦苦獲取的函數,運用在真正的對於資料的處理之中。

在以前,我們通常會通過一個集合把這些資料放在一起,然後詳細編寫其處理過程使之能被逐一處理,最後再通過一個集合把它們獲取出來,這沒有任何問題。但是對於某些情況下而言,我們已完全洞悉並且厭煩了這些處理過程,我們渴望獲得一種更輕便,更簡易的手段,能使得整個集合中的資料處理就像水流通過管道一樣,我們可以隨意在這條管道上拼接各式各樣的制式的處理器來處理這些資料,並最後給出一個結果。
——這個制式的處理器就是我們的函數,而這個管道就是流


流是一個與任何特定的儲存機制都沒有關係的元素序列,我們一般會這樣說流:沒有儲存

不同於對於任何一個集合的操作,當我們使用流時,我們是從一個管道中抽取元素進行處理,這非常重要,因為大多數時候我們不會無緣無故的將元素放進一個集合,我們一定是希望對其進行一些處理,也就是說,我們不是為了儲存才將它們放入集合的。

如果是這樣,那麼就意味著我們的程式設計很多時候需要轉向流而不是集合。

流最關鍵的優點是,能夠使得我們的程式更小也更好理解。事實上,lambda函數和方法參照正是在這裡才發揮出了其真正的威力,它們一同將Java帶入了宣告式程式設計:我們說明想要完成什麼,而不是指明需要怎麼去做。

  • 類似流+函數語言程式設計這樣實現的宣告式程式設計機制,就被稱之為內部迭代,我們看不見其內部的具體操作
  • 而通過迴圈,將內部的資料一個一個處理成型的機制就被稱為外部迭代,我們可以顯式的看清和修改內部的操作

流帶來的宣告式程式設計是Java 8最重要的新特性之一,為此,Java還引入了新的關鍵詞default以便它們大刀闊斧的修改一些老的集合類,以便使得它們支援流。

下面,我們將分三個階段來了解,我們可以怎樣去使用流,並運用流和函數語言程式設計獲得極佳的程式設計體驗

  • 流的建立
  • 流的中間操作
  • 流的終結操作

1、流的建立

最基本的流的建立方法就是

  • Stream.of(一組條目)
  • Collection.stream()

我們可以把任意相同型別的一組條目寫在Stream.of()的引數中使之變成一個流,比如:

Stream.of("a", "b", "c", "d");
Stream.of(new Node(1), new Node(2), new Node(3));
Stream.of(1, 2, 3, 4, 5);

Collection介面的stream()方法則更是我們的好夥伴,所有實現了該介面的集合,都可以直接轉變為一個流由我們處理。

此外,我們還有以下生成流的手段

  • 亂數流
  • int基本型別的區間範圍方法
  • generate()方法
  • iterate()方法
  • 流生成器
  • Arrays.stream()將陣列轉換為流
  • 正規表示式

下面來逐一瞭解

亂數流

Random類已經得到了增強,現在有一組可以生成流的方法。

  • ints()
  • longs()
  • doubles()
  • boxed()

可以清楚的看到,我們只能通過Random類獲取三種基本型別的流,或者在其後加上boxed()來獲取它們的包裝類的流。實際上,Random類生成的這些數值,還有別的價值,比如通過亂數來獲取某個列表中的隨機下表對應值,以此來獲取隨機的物件。

int區間範圍方法

IntStraem類提供了新的range()方法,可以生成一個流,它代表一個由int值組成的序列,對於IntStream.range(a, b)來說,這個流中的資料是[a, b)區間的所有整數。

利用這個方法,我們可以通過流很好的代替某些迴圈了,比如:

public class Repeat{
    public static repeat(int n, Runnable action){
        IntStream.range(0, n).forEach(i -> action.run());
    }
}

這樣一個方法就是把我們的action方法執行n次,可以很好的替代普通的迴圈。

generate() 方法

Stream.generate()方法可以接受一個方法作為引數,該方法必須要返回一個範例或基本型別。總之,無論你給出的方法返回了什麼,generate()方法會無限的根據該方法產生元素並塞入流中,如果你不希望它無限產生,那麼你應該使用limit()來限制次數

AtomicInteger i = new AtomicInteger();
Stream.generate(() -> i.getAndIncrement())
    .limit(20)
    .forEach(System.out::println);
// 輸出為從0到19

iterate()方法

顧名思義,這個方法通過迭代不斷產生元素,它可以將第一個引數作為輸入賦給第二個引數 (也就是那個方法),然後該方法會產生一個輸出,隨後該輸出又會作為輸入再度交給方法來產生下一個輸出,由此不斷迭代。一個典型的例子是由此產生一個斐波那契數列的方法,如下所示。

int x = 0;
public Stream<Integer> numbers(){
    return Stream.iterate(1, o ->{
        int result = o + x;
        x = o;
        return result;
    });
}

public static void main(String[] args) {
    test2 t = new test2();
    t.numbers()
        .limit(20)
        .forEach(System.out::println);
}

流生成器

流生成器方法Stream.builder()可以返回Stream.Builder<T>類,你可以自定義這個返回的類的泛型以便適配需求,隨後,你可以將它當作一個類似StringBuilder一樣的存在使用,通過add()等方法向裡面塞入元素,並最終通過build()方法來返回一個流。

Stream.Builder<String> builder = Stream.builder();
builder.add("a").add("b").add("c").build()
    .map(x -> x.toUpperCase())
    .forEach(System.out::print);
// 輸出ABC

Arrays流方法

Arrays.stream()靜態方法可以將一個陣列轉化為流,非常簡單易理解

int[] chars = {1,2,3,4,5};
        Arrays.stream(chars)
                .forEach(System.out::print);
// 輸出12345

正規表示式

Java 8在java.util.regex.Pattern類中加入了一個新方法splitAsStream(),該方法接受一個字元序列並可以根據我們傳入的公式將其分拆為一個流。

要注意的是,這個地方的輸入不能直接是一個流,必須得是一個CharSequence

String s = "abcdefg";
Pattern.compile("[be]").splitAsStream(s)
    .map(x -> x+"?")
    .forEach(System.out::print);
// 輸出a?cd?fg?

2、中間操作

我們獲取了流,那麼我們要做什麼呢?顯然,我們希望逐個對流中的資料進行操作,我們有以下方式可選:

  • 檢視元素
    • peek()
  • 對元素排序
    • sorted()
    • sorted(Comparator compa)
  • 移除元素
    • distinct()
    • filter(Predicate)
  • 將函數應用於每個元素
    • map(Function func)
    • mapToInt(ToIntFunction func)
    • mapToLong(ToLongFunction func)
    • mapToDouble(ToDoubleFunction func)
  • 應用函數期間組合流
    • flatMap(Function func)
    • flatMapToInt(ToIntFunction func)
    • flatMapToLong(ToLongFunction func)
    • flatMapToDouble(ToDoubleFunction func)

檢視元素

主要就是peek(),它允許我們在不做任何操作的情況下檢視流中的所有元素,其意義在於我們可以通過它來跟蹤和偵錯我們的流程式碼,當你不知道你的程式碼中,這些流元素究竟被變成了什麼樣子的話,可以使用這個方法而不是forEach()來終止流。

對元素排序

sorted()方法,同樣很好理解,如果你不給Comparator作為引數,那麼就是一個很普通的排序方法,類似Arrays.sort()這樣,你可以檢視原始碼來看看預設順序究竟如何。

不過更可靠的方法是我們自己來實現一個Comparator來操控整個流的比較結果。

移除元素

主要有兩種方法,分別是distinct()filter二者都很好用,distinct()可以消除那些重複的元素,這比通過Set來獲取元素要便捷得多。

filter(Predicate)更是全能,該方法需要以一個返回值為布林的方法為變數,它會負責拋棄那些返回值為false的方法,留下那些返回值為true的方法,可以大大降低我們的程式碼量。

將函數應用於各個元素

主要就是map(Function func),其他三個方法只是返回值變為對應的基本型別流而已,主要是為了提高效率。我們需要提供一個能夠處理流中元素並返回新值的方法,隨後該方法就會將我們提供的引數方法應用於每個元素上,十分方便

在應用map()期間組合流

flatMap(),其實和map()的區別就是,有時候我們提供的引數方法會返回一個流而不是一個元素。這樣的話,我們就需要另一個方法能夠以流為引數進行處理,也就是需要一個方法把我們返回的流平展開成為元素,類似於把所有返回的流拼接在一起,成為一個更大的流然後再進行處理。

一個典型的例子:

public static void main(String[] args){
    Stream.of(1, 2, 3)
        .flatMap(i -> Stream.of('a', 'b', 'c'))
        .forEach(System.out::println);
    
    // 上面的flatMap()處如果使用map()那麼會返回三個元素為{a, b, c}的流
    // 而如果是faltMap()則返回的是元素為{a, b, c, a, b, c, a, b, c}的流
}

3、Optional型別

到此我們已經瞭解了流的建立和中間操作,但是在學習終結操作之前,我們還有一個更重要的問題:健壯性研究。

在前面的處理環節我們需要考慮,如果流中存在一個null會發生什麼呢?要知道流可不是什麼快樂通道,作為程式設計師,我們必須要考慮周全,環環相扣。

所以為了防止在某些不該出現null的地方出現了null導致處理失敗,我們需要一個類似預留位置的存在,它既可以作為流元素佔位也可以在我們要找的元素不存在時告知我們(即不會丟擲異常)

這個想法的實現就是Optional型別,這些型別只會通過某些標準流操作返回,因為這些操作不一定能保證所要的結果一定存在:

  • findFirst()返回包含第一個元素的Optional,若流為空,則返回Optional.empty
  • findAny()返回包含任何元素的Optional,若流為空,則返回Optional.empty
  • max()min()分別返回包含流中最大或最小值的Optional,若流為空,則返回Optional.empty
  • reduce()的其中一個實現,引數為一個接收兩個引數並返回一個結果的方法參照,其作用就是返回各個元素根據該引數計算得到的值,其中每次迭代計算出的值會作為下一次計算的第一個引數
    比如1,2,3,4給出reduce((x1, x2) -> x1+x2)
    那麼計算流程會是1+2=3, 3+3=6,6+4=10
  • average()可以對數值化的流計算均值並以對應的Optional類物件返回

現在,我們可以從流中獲取Optional物件了,那麼有什麼用呢?這就要提到便捷函數了

便捷函數可以用於獲取Optional中封裝的資料,並且簡化了步驟

  • ifPresent(Consumer):如果值存在,則通過該值呼叫Consumer函數,否則跳過
  • orElse(otherObject):如果值存在,則返回該物件,否則返回引數物件
  • orElseGet(Supplier):如果值存在,則返回該物件,否則返回Supplier方法創造的物件
  • orElseThrow(Supplier):如果值存在,則返回該物件,否則丟擲一個使用Supplier方法創造的異常

如果我們需要自己建立Optional物件,那麼我們可以使用這些Optional類的靜態方法:

  • empty():返回一個空的Optional
  • of(value):如果已經知道這個value不是null,可以使用該方法把它封裝在一個Optional物件中
  • ofNullable(value):如果不能確定封裝值是不是null,則使用此方法封裝

最後,還有三種方法支援對Optional進行事後處理,提供最後一次處理機會

  • filter(Predicate)
  • map(Function)
  • flatMap(Function)

它們的作用都和中間操作中的對應方法一致,只不過返回值會被封裝在Optional物件中

最後,回到我們的主角Stream上來,有時候,我們不是給出的引數含有null而是處理的結果可能含有null那麼我們可能會希望將這些返回值包含在Optional物件中,那麼我們可以通過類似x -> Optional.of(result)這樣的方法將其封裝,但是,如果這麼做了就一定要清楚我們該如何獲取這樣的流中的物件。請牢記,要先驗證是否存在,才能獲取

Stream
    .filter(Optional::isPresent)
    .map(Optional::get)  // 到這裡,流中的資料就都是Optional物件中包含的值了
    // 繼續處理

4、終結操作

這些操作接受一個流作為引數,並生成一個最終結果而非返回那個流,因此,只要呼叫這些方法,流處理就將終結

  • 將流轉化為一個陣列
    • toArray()
    • toArray(generator)
      該方法會將元素儲存在generator中,而不是建立一個新的並返回
  • 在每個流元素上應用某個終結操作
    • forEach(Consumer)
      在每個元素上呼叫Consumer方法
    • forEachOrdered(Consumer)
      該版本確保對元素的操作順序是原始的流順序
  • 收集操作
    • collect(Collector)
      相當複雜的一個方法,可以將所有元素存入我們給出的Collector容器中。
      • 本方法主要複雜在,我們實際上可以使用java.util.stream.Collectors檔案中相當多的物件,而且其中有一部分很複雜
        比如如果我們希望放入一個TreeSet中使它們總是有序,那麼我們可以使用Collectors.toCollection(TreeSet::new)來建立該容器並應用
    • collect(Supplier, BiConsumer, BiConsumer)
      • 在極小情況下,我們無法從Collectors類中找到我們想要的處理容器,那麼就需要第二個方法
  • 組合所有的流元素
    • reduce(BinaryOperator)
      組合所有元素,組合的方法就是引數方法
    • reduce(identity, BinaryOperator)
      以identity為初始值組合所有元素,方法為第二個引數
    • reduce(identity, BiFunction, BinaryOperator)
      複雜,未作介紹
  • 匹配,都是根據Predicate返回一個布林值
    • allMatch(Predicate)
    • anyMatch(Predicate)
    • noneMatch(Predicate)
  • 選擇一個元素
    • findFirst()
      返回一個包含流中第一個元素的Optional物件,若流中沒有元素即返回Optional.empty
    • findAny()
      返回一個包含流中任意一個元素的Optional物件,若流中沒有元素則為Optional.empty
      • 不過需要注意的是,該方法對於非並行的流似乎總是會選擇流中的第一個元素,如果是並行的則隨機
  • 獲取流相關的資訊
    • count()
      計算流中元素數量
    • max(Comparator)
      通過Comaprator獲取流中最大的元素
    • min(Comparator)
      通過Comparator獲取流中最小的元素
    • 如果是數值化的流,除了上面這些,還有以下方法
    • average()
      獲得平均值
    • sum()
      獲得累加值
    • summaryStatics()
      返回可能有用的摘要資料,基本沒什麼用