一種新的流:為 Java 加入生成器(Generator)特性

阿里云云原生發表於2023-04-25

作者:文鐳(依來)

前言

這篇文章不是工具推薦,也不是應用案例分享。其主題思想,是介紹一種全新的設計模式。它既擁有抽象的數學美感,僅僅從一個簡單介面出發,就能推演出龐大的特性集合,引出許多全新概念。同時也有紮實的工程實用價值,由其實現的工具,效能均可顯著超過同類的頭部開源產品。

這一設計模式並非因Java而生,而是誕生於一個十分簡陋的指令碼語言。它對語言特性的要求非常之低,因而其價值對眾多現代程式語言都是普適的。

關於Stream

首先大概回顧下Java裡傳統的流式API。自Java8引入lambda表示式和Stream以來,Java的開發便捷性有了質的飛躍,Stream在複雜業務邏輯的處理上讓人效率倍增,是每一位Java開發者都應該掌握的基礎技能。但排除掉parallelStream也即併發流之外,它其實並不是一個好的設計。

第一、封裝過重,實現過於複雜,原始碼極其難讀。我能理解這或許是為了相容併發流所做的妥協,但畢竟耦合太深,顯得艱深晦澀。每一位初學者被原始碼嚇到之後,想必都會產生流是一種十分高階且實現複雜的特性的印象。實際上並不是這樣,流其實可以用非常簡單的方式構建

第二、API過於冗長。冗長體現在stream.collect這一部分。作為對比,Kotlin提供的toList/toSet/associate(toMap)等等豐富操作是可以直接作用在流上的。Java直到16才摳摳索索加進來一個Stream可以直接呼叫的toList,他們甚至不肯把toSet/toMap一起加上。

第三、API功能簡陋。對於鏈式操作,在最初的Java8裡只有map/filter/skip/limit/peek/distinct/sorted這七個,Java9又加上了takeWhile/dropWhile。然而在Kotlin中,除了這幾個之外人還有許多額外的實用功能。

例如:

mapIndexed,mapNotNull,filterIndexed,filterNotNull,onEachIndexed,distinctBy, sortedBy,sortedWith,zip,zipWithNext等等,翻倍了不止。這些東西實現起來並不複雜,就是個順手的事,但對於使用者而言有和沒有的體驗差異可謂巨大。

在這篇文章裡,我將提出一種全新的機制用於構建流。這個機制極其簡單,任何能看懂lambda表示式(閉包)的同學都能親手實現,任何支援閉包的程式語言都能利用該機制實現自己的流。也正是由於這個機制足夠簡單,所以開發者可以以相當低的成本擼出大量的實用API,使用體驗甩開Stream兩條街,不是問題。

關於生成器

生成器(Generator)[1]是許多現代程式語言裡一個廣受好評的重要特性,在Python/Kotlin/C#/Javascript等等語言中均有直接支援。它的核心API就是一個yield關鍵字(或者方法)。

有了生成器之後,無論是iterable/iterator,還是一段亂七八糟的閉包,都可以直接對映為一個流。舉個例子,假設你想實現一個下劃線字串轉駝峰的方法,在Python裡你可以利用生成器這麼玩

def underscore_to_camelcase(s):
    def camelcase():
        yield str.lower
        while True:
            yield str.capitalize

    return ''.join(f(sub) for sub, f in zip(s.split('_'), camelcase()))

這短短几行程式碼可以說處處體現出了Python生成器的巧妙。首先,camelcase方法裡出現了yield關鍵字,直譯器就會將其看作是一個生成器,這個生成器會首先提供一個lower函式,然後提供無數的capitalize函式。由於生成器的執行始終是lazy的,所以用while true的方式生成無限流是十分常見的手段,不會有效能或者記憶體上的浪費。其次,Python裡的流是可以和list一起進行zip的,有限的list和無限的流zip到一起,list結束了流自然也會結束。

這段程式碼中,末尾那行join()括號裡的東西,Python稱之為生成器推導(Generator Comprehension)[2],其本質上依然是一個流,一個zip流被map之後的string流,最終透過join方法聚合為一個string。

以上程式碼裡的操作, 在任何支援生成器的語言裡都可以輕易完成,但是在Java裡你恐怕連想都不敢想。Java有史以來,無論是歷久彌新的Java8,還是最新的引入了Project Loom[3]的OpenJDK19,連協程都有了,依然沒有直接支援生成器。

本質上,生成器的實現要依賴於continuation[4]的掛起和恢復,所謂continuation可以直觀理解為程式執行到指定位置後的斷點,協程就是指在這個函式的斷點掛起後跳到另一個函式的某個斷點繼續執行,而不會阻塞執行緒,生成器亦如是。

Python透過棧幀的儲存與恢復實現函式重入以及生成器[5],Kotlin在編譯階段利用CPS(Continuation Passing Style)[6]技術對位元組碼進行了變換,從而在JVM上模擬了協程[7]。其他的語言要麼大體如此,要麼有更直接的支援。

那麼,有沒有一種辦法,可以在沒有協程的Java裡,實現或者至少模擬出一個yield關鍵字,從而動態且高效能地建立流呢。答案是,有。

正文

Java裡的流叫Stream,Kotlin裡的流叫Sequence。我實在想不出更好的名字了,想叫Flow又被用了,簡單起見姑且叫Seq。

概念定義

首先給出Seq的介面定義

public interface Seq<T> {
    void consume(Consumer<T> consumer);
}

它本質上就是一個consumer of consumer,其真實含義我後邊會講。這個介面看似抽象,實則非常常見,java.lang.Iterable天然自帶了這個介面,那就是大家耳熟能詳的forEach。利用方法推導,我們可以寫出第一個Seq的例項

List<Integer> list = Arrays.asList(1, 2, 3);
Seq<Integer> seq = list::forEach;

可以看到,在這個例子裡consume和forEach是完全等價的,事實上這個介面我最早就是用forEach命名的,幾輪迭代之後才改成含義更準確的consume。

利用單方法介面在Java裡會自動識別為FunctionalInteraface這一偉大特性,我們也可以用一個簡單的lambda表示式來構造流,比如只有一個元素的流。

static <T> Seq<T> unit(T t) {
    return c -> c.accept(t);
}

這個方法在數學上很重要(實操上其實用的不多),它定義了Seq這個泛型型別的單位元操作,即T -> Seq<T>的對映。

map與flatMap

map

從forEach的直觀角度出發,我們很容易寫出map[8],將型別為T的流,轉換為型別為E的流,也即根據函式T -> E得到Seq<T> -> Seq<E>的對映。

default <E> Seq<E> map(Function<T, E> function) {
  return c -> consume(t -> c.accept(function.apply(t)));
}

flatMap

同理,可以繼續寫出flatMap,即將每個元素展開為一個流之後再合併。

default <E> Seq<E> flatMap(Function<T, Seq<E>> function) {
    return c -> consume(t -> function.apply(t).consume(c));
}

大家可以自己在IDEA裡寫寫這兩個方法,結合智慧提示,寫起來其實非常方便。如果你覺得理解起來不太直觀,就把Seq看作是List,把consume看作是forEach就好。

filter與take/drop

map與flatMap提供了流的對映與組合能力,流還有幾個核心能力:元素過濾與中斷控制。

filter

過濾元素,實現起來也很簡單

default Seq<T> filter(Predicate<T> predicate) {
    return c -> consume(t -> {
        if (predicate.test(t)) {
            c.accept(t);
        }
    });
}

take

流的中斷控制有很多場景,take是最常見的場景之一,即獲取前n個元素,後面的不要——等價於Stream.limit。

由於Seq並不依賴iterator,所以必須透過異常實現中斷。為此需要構建一個全域性單例的專用異常,同時取消這個異常對呼叫棧的捕獲,以減少效能開銷(由於是全域性單例,不取消也沒關係)

public final class StopException extends RuntimeException {
    public static final StopException INSTANCE = new StopException();

    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

以及相應的方法

static <T> T stop() {
    throw StopException.INSTANCE;
}

default void consumeTillStop(C consumer) {
    try {
        consume(consumer);
    } catch (StopException ignore) {}
}

然後就可以實現take了:

default Seq<T> take(int n) {
    return c -> {
        int[] i = {n};
        consumeTillStop(t -> {
            if (i[0]-- > 0) {
                c.accept(t);
            } else {
                stop();
            }
        });
    };
}

drop

drop是與take對應的概念,丟棄前n個元素——等價於Stream.skip。它並不涉及流的中斷控制,反而更像是filter的變種,一種帶有狀態的filter。觀察它和上面take的實現細節,內部隨著流的迭代,存在一個計數器在不斷重新整理狀態,但這個計數器並不能為外界感知。這裡其實已經能體現出流的乾淨特性,它哪怕攜帶了狀態,也絲毫不會外露。

default Seq<T> drop(int n) {
    return c -> {
        int[] a = {n - 1};
        consume(t -> {
            if (a[0] < 0) {
                c.accept(t);
            } else {
                a[0]--;
            }
        });
    };
}

其他API

onEach

對流的某個元素新增一個操作consumer,但是不執行流——對應Stream.peek。

default Seq<T> onEach(Consumer<T> consumer) {
    return c -> consume(consumer.andThen(c));
}

zip

流與一個iterable元素兩兩聚合,然後轉換為一個新的流——在Stream裡沒有對應,但在Python裡有同名實現。

default <E, R> Seq<R> zip(Iterable<E> iterable, BiFunction<T, E, R> function) {
    return c -> {
        Iterator<E> iterator = iterable.iterator();
        consumeTillStop(t -> {
            if (iterator.hasNext()) {
                c.accept(function.apply(t, iterator.next()));
            } else {
                stop();
            }
        });
    };
}

終端操作

上面實現的幾個方法都是流的鏈式API,它們將一個流對映為另一個流,但流本身依然是lazy或者說尚未真正執行的。真正執行這個流需要使用所謂終端操作,對流進行消費或者聚合。在Stream裡,消費就是forEach,聚合就是Collector。對於Collector,其實也可以有更好的設計,這裡就不展開了。不過為了示例,可以先簡單快速實現一個join。

default String join(String sep) {
    StringJoiner joiner = new StringJoiner(sep);
    consume(t -> joiner.add(t.toString()));
    return joiner.toString();
}

以及toList。

default List<T> toList() {
    List<T> list = new ArrayList<>();
    consume(list::add);
    return list;
}

至此為止,我們僅僅只用幾十行程式碼,就實現出了一個五臟俱全的流式API。在大部分情況下,這些API已經能覆蓋百分之八九十的使用場景。你完全可以依樣畫葫蘆,在其他程式語言裡照著玩一玩,比如Go(笑)。

生成器的推導

本文雖然從標題開始就在講生成器,甚至毫不誇張的說生成器才是最核心的特性,但等到把幾個核心的流式API寫完了,依然沒有解釋生成器到底是咋回事——其實倒也不是我在賣關子,你只要仔細觀察一下,生成器早在最開始講到Iterable天生就是Seq的時候,就已經出現了。

List<Integer> list = Arrays.asList(1, 2, 3);
Seq<Integer> seq = list::forEach;

沒看出來?那把這個方法推導改寫為普通lambda函式,有

Seq<Integer> seq = c -> list.forEach(c);

再進一步,把這個forEach替換為更傳統的for迴圈,有

Seq<Integer> seq = c -> {
    for (Integer i : list) {
        c.accept(i);
    }
};

由於已知這個list就是[1, 2, 3],所以以上程式碼可以進一步等價寫為

Seq<Integer> seq = c -> {
    c.accept(1);
    c.accept(2);
    c.accept(3);
};

是不是有點眼熟?不妨看看Python裡類似的東西長啥樣:

def seq():
    yield 1
    yield 2
    yield 3

二者相對比,形式幾乎可以說一模一樣——這其實就已經是生成器了,這段程式碼裡的accept就扮演了yield的角色,consume這個介面之所以取這個名字,含義就是指它是一個消費操作,所有的終端操作都是基於這個消費操作實現的。功能上看,它完全等價於Iterable的forEach,之所以又不直接叫forEach,是因為它的元素並不是本身自帶的,而是透過閉包內的程式碼塊臨時生成的

這種生成器,並非傳統意義上利用continuation掛起的生成器,而是利用閉包來捕獲程式碼塊裡臨時生成的元素,哪怕沒有掛起,也能高度模擬傳統生成器的用法和特性。其實上文所有鏈式API的實現,本質上也都是生成器,只不過生成的元素來自於原始的流罷了。

有了生成器,我們就可以把前文提到的下劃線轉駝峰的操作用Java也依樣畫葫蘆寫出來了。

static String underscoreToCamel(String str) {
    // Java沒有首字母大寫方法,隨便現寫一個
    UnaryOperator<String> capitalize = s -> s.substring(0, 1).toUpperCase() + s.substring(1).toLowerCase();
     // 利用生成器構造一個方法的流
    Seq<UnaryOperator<String>> seq = c -> {
        // yield第一個小寫函式
        c.accept(String::toLowerCase);
        // 這裡IDEA會告警,提示死迴圈風險,無視即可
        while (true) {
            // 按需yield首字母大寫函式
            c.accept(capitalize);
        }
    };
    List<String> split = Arrays.asList(str.split("_"));
    // 這裡的zip和join都在上文給出了實現
    return seq.zip(split, (f, sub) -> f.apply(sub)).join("");
}

大家可以把這幾段程式碼拷下來跑一跑,看它是不是真的實現了其目標功能。

生成器的本質

雖然已經推匯出了生成器,但似乎還是有點摸不著頭腦,這中間到底發生了什麼,死迴圈是咋跳出的,怎麼就能生成元素了。為了進一步解釋,這裡再舉一個大家熟悉的例子。

生產者-消費者模式

生產者與消費者的關係不止出現在多執行緒或者協程語境下,在單執行緒裡也有一些經典場景。比如A和B兩名同學合作一個專案,分別開發兩個模組:A負責產出資料,B負責使用資料。A不關心B怎麼處理資料,可能要先過濾一些,進行聚合後再做計算,也可能是寫到某個本地或者遠端的儲存;B自然也不關心A的資料是怎麼來的。這裡邊唯一的問題在於,資料條數實在是太多了,記憶體一次性放不下。在這種情況下,傳統的做法是讓A提供一個帶回撥函式consumer的介面,B在呼叫A的時候傳入一個具體的consumer。

public void produce(Consumer<String> callback) {
    // do something that produce strings
    // then use the callback consumer to eat them
}

這種基於回撥函式的互動方式實在是過於經典了,原本沒啥可多說的。但是在已經有了生成器之後,我們不妨膽子放大一點稍微做一下改造:仔細觀察上面這個produce介面,它輸入一個consumer,返回void——咦,所以它其實也是一個Seq嘛!

Seq<String> producer = this::produce;

接下來,我們只需要稍微調整下程式碼,就能對這個原本基於回撥函式的介面進行一次升級,將它變成一個生成器。

public Seq<String> produce() {
    return c -> {
        // still do something that produce strings
        // then use the callback consumer to eat them
    };
}

基於這一層抽象,作為生產者的A和作為消費者的B就真正做到完全的、徹底的解耦了。A只需要把資料生產過程放到生成器的閉包裡,期間涉及到的所有副作用,例如IO操作等,都被這個閉包完全隔離了。B則直接拿到一個乾乾淨淨的流,他不需要關心流的內部細節,當然想關心也關心不了,他只用專注於自己想做的事情即可。

更重要的是,A和B雖然在操作邏輯上完全解耦,互相不可見,但在CPU排程時間上它們卻是彼此交錯的,B甚至還能直接阻塞、中斷A的生產流程——可以說沒有協程,勝似協程。

至此,我們終於成功發現了Seq作為生成器的真正本質 :consumer of callback。明明是一個回撥函式的消費者,搖身一變就成了生產者,實在是有點奇妙。不過仔細一想倒也合理:能夠滿足消費者需求(callback)的傢伙,不管這需求有多麼奇怪,可不就是生產者麼。

容易發現,基於callback機制的生成器,其呼叫開銷完全就只有生成器閉包內部那堆程式碼塊的執行開銷,加上一點點微不足道的閉包建立開銷。在諸多涉及到流式計算與控制的業務場景裡,這將帶來極為顯著的記憶體與效能優勢。後面我會給出展現其效能優勢的具體場景例項。

另外,觀察這段改造程式碼,會發現produce輸出的東西,根本就還是個函式,沒有任何資料被真正執行和產出。這就是生成器作為一個匿名介面的天生優勢:惰性計算——消費者看似得到了整個流,實際那只是一張愛的號碼牌,可以塗寫,可以廢棄,但只有在拿著貨真價實的callback去兌換的那一刻,才會真正的執行流。

生成器的本質,正是人類本質的反面:鴿子剋星——沒有任何人可以鴿它

IO隔離與流輸出

Haskell發明了所謂IO Monad[9]來將IO操作與純函式的世界隔離。Java利用Stream,勉強做到了類似的封裝效果。以java.io.BufferedReader為例,將本地檔案讀取為一個Stream<String>,可以這麼寫:

Stream<String> lines = new BufferedReader(new InputStreamReader(new FileInputStream("file"))).lines();

如果你仔細檢視一下這個lines方法的實現,會發現它使用了大段程式碼去建立了一個iterator,而後才將其轉變為stream。暫且不提它的實現有多麼繁瑣,這裡首先應該注意的是BufferedReader是一個Closeable,安全的做法是在使用完畢後close,或者利用try-with-resources語法包一層,實現自動close。但是BufferedReader.lines並沒有去關閉這個源,它是一個不那麼安全的介面——或者說,它的隔離是不完整的。Java對此也打了個補丁,使用java.nio.file.Files.lines,它會新增加一個onClose的回撥handler,確保stream耗盡後執行關閉操作。

那麼有沒有更普適做法呢,畢竟不是所有人都清楚BufferedReader.lines和Files.lines會有這種安全性上的區別,也不是所有的Closeable都能提供類似的安全關閉的流式介面,甚至大機率壓根就沒有流式介面。

好在現在我們有了Seq,它的閉包特性自帶隔離副作用的先天優勢。恰巧在涉及大量資料IO的場景裡,利用callback互動又是極為經典的設計方式——這裡簡直就是它大展拳腳的最佳舞臺。

用生成器實現IO的隔離非常簡單,只需要整個包住try-with-resources程式碼即可,它同時就包住了IO的整個生命週期。

Seq<String> seq = c -> {
    try (BufferedReader reader = Files.newBufferedReader(Paths.get("file"))) {
        String s;
        while ((s = reader.readLine()) != null) {
            c.accept(s);
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
};

核心程式碼其實就3行,構建資料來源,挨個讀資料,然後yield(即accept)。後續對流的任何操作看似發生在建立流之後,實際執行起來都被包進了這個IO生命週期的內部,讀一個消費一個,彼此交替,隨用隨走。

換句話講,生成器的callback機制,保證了哪怕Seq可以作為變數四處傳遞,但涉及到的任何副作用操作,都是包在同一個程式碼塊裡惰性執行的。它不需要像Monad那樣,還得定義諸如IOMonad,StateMonad等等花樣眾多的Monad。

與之類似,這裡不妨再舉個阿里中介軟體的例子,利用Tunnel將大家熟悉的ODPS表資料下載為一個流:

public static Seq<Record> downloadRecords(TableTunnel.DownloadSession session) {
    return c -> {
        long count = session.getRecordCount();
        try (TunnelRecordReader reader = session.openRecordReader(0, count)) {
            for (long i = 0; i < count; i++) {
                c.accept(reader.read());
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    };
}

有了Record流之後,如果再能實現出一個map函式,就可以非常方便的將Record流map為帶業務語義的DTO流——這其實就等價於一個ODPS Reader。

非同步流

基於callback機制的生成器,除了可以在IO領域大展拳腳,它天然也是親和非同步操作的。畢竟一聽到回撥函式這個詞,很多人就能條件反射式的想到非同步,想到Future。一個callback函式,它的命運就決定了它是不會在乎自己被放到哪裡、被怎麼使用的。比方說,丟給某個暴力的非同步邏輯:

public static Seq<Integer> asyncSeq() {
    return c -> {
        CompletableFuture.runAsync(() -> c.accept(1));
        CompletableFuture.runAsync(() -> c.accept(2));
    };
}

這就是一個簡單而粗暴的非同步流生成器。對於外部使用者來說,非同步流除了不能保證元素順序,它和同步流沒有任何區別,本質上都是一段可執行的程式碼,邊執行邊產生資料。 一個callback函式,誰給用不是用呢。

併發流

既然給誰用不是用,那麼給ForkJoinPool用如何?——Java大名鼎鼎的parallelStream就是基於ForkJoinPool實現的。我們也可以拿來搞一個自己的併發流。具體做法很簡單,把上面非同步流示例裡的CompletableFuture.runAsync換成ForkJoinPool.submit即可,只是要額外注意一件事:parallelStream最終執行後是要阻塞的(比如最常用的forEach),它並非單純將任務提交給ForkJoinPool,而是在那之後還要做一遍join。

對此我們不妨採用最為暴力而簡單的思路,構造一個ForkJoinTask的list,依次將元素提交forkJoinPool後,產生一個task並新增進這個list,等所有元素全部提交完畢後,再對這個list裡的所有task統一join。

default Seq<T> parallel() {
    ForkJoinPool pool = ForkJoinPool.commonPool();
    return c -> map(t -> pool.submit(() -> c.accept(t))).cache().consume(ForkJoinTask::join);
}

這就是基於生成器的併發流,它的實現僅僅只需要兩行程式碼——正如本文開篇所說,流可以用非常簡單的方式構建。哪怕是Stream費了老大勁的併發流,換一種方式,實現起來可以簡單到令人髮指。

這裡值得再次強調的是,這種機制並非Java限定,而是任何支援閉包的程式語言都能玩。事實上,這種流機制的最早驗證和實現,就是我在AutoHotKey_v2[10]這個軟體自帶的簡陋的指令碼語言上完成的。

再談生產者-消費者模式

前面為了解釋生成器的callback本質,引入了單執行緒下的生產者-消費者模式。那在實現了非同步流之後,事情就更有意思了。

回想一下,Seq作為一種中間資料結構,能夠完全解耦生產者與消費者,一方只管生產資料交給它,另一方只管從它那裡拿資料消費。這種構造有沒有覺得有點眼熟?不錯,正是Java開發者常見的阻塞佇列,以及支援協程的語言裡的通道(Channel) ,比如Go和Kotlin。

通道某種意義上也是一種阻塞佇列,它和傳統阻塞佇列的主要區別,在於當通道里的資料超出限制或為空時,對應的生產者/消費者會掛起而不是阻塞,兩種方式都會暫停生產/消費,只是協程掛起後能讓出CPU,讓它去別的協程裡繼續幹活。

那Seq相比Channel有什麼優勢呢?優勢可太多了:首先,生成器閉包裡callback的程式碼塊,嚴格確保了生產和消費必然交替執行,也即嚴格的先進先出、進了就出、不進不出,所以不需要單獨開闢堆記憶體去維護一個佇列,那沒有佇列自然也就沒有鎖,沒有鎖自然也就沒有阻塞或掛起。其次,Seq本質上是消費監聽生產,沒有生產自然沒有消費,如果生產過剩了——啊,生產永遠不會過剩,因為Seq是惰性的,哪怕生產者在那兒while死迴圈無限生產,也不過是個司空見慣的無限流罷了。

這就是生成器的另一種理解方式,一個無佇列、無鎖、無阻塞的通道。Go語言channel常被詬病的死鎖和記憶體洩露問題,在Seq身上壓根就不存在;Kotlin搞出來的非同步流Flow和同步流Sequence這兩套大同小異的API,都能被Seq統一替換。

可以說,沒有比Seq更安全的通道實現了,因為根本就沒有安全問題。生產了沒有消費?Seq本來就是惰性的,沒有消費,那就啥也不會生產。消費完了沒有關閉通道?Seq本來就不需要關閉——一個lambda而已有啥好關閉的。

為了更直觀的理解,這裡給一個簡單的通道示例。先隨便實現一個基於ForkJoinPool的非同步消費介面,該介面允許使用者自由選擇消費完後是否join。

default void asyncConsume(Consumer<T> consumer) {
    ForkJoinPool pool = ForkJoinPool.commonPool();
    map(t -> pool.submit(() -> consumer.accept(t))).cache().consume(ForkJoinTask::join);
}

有了非同步消費介面,立馬就可以演示出Seq的通道功能。

@Test
public void testChan() {
    // 生產無限的自然數,放入通道seq,這裡流本身就是通道,同步流還是非同步流都無所謂
    Seq<Long> seq = c -> {
        long i = 0;
        while (true) {
            c.accept(i++);
        }
    };
    long start = System.currentTimeMillis();
    // 通道seq交給消費者,消費者表示只要偶數,只要5個
    seq.filter(i -> (i & 1) == 0).take(5).asyncConsume(i -> {
        try {
            Thread.sleep(1000);
            System.out.printf("produce %d and consume\n", i);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    });
    System.out.printf("elapsed time: %dms\n", System.currentTimeMillis() - start);
}

執行結果

produce 0 and consume
produce 8 and consume
produce 6 and consume
produce 4 and consume
produce 2 and consume
elapsed time: 1032ms

可以看到,由於消費是併發執行的,所以哪怕每個元素的消費都要花1秒鐘,最終總體耗時也就比1秒多一點點。當然,這和傳統的通道模式還是不太一樣,比如實際工作執行緒就有很大區別。更全面的設計是在流的基礎上加上無鎖非阻塞佇列實現正經Channel,可以附帶解決Go通道的許多問題同時提升效能,後面我會另寫文章專門討論。

生成器的應用場景

上文介紹了生成器的本質特性,它是一個consumer of callback,它可以以閉包的形式完美封裝IO操作,它可以無縫切換為非同步流和併發流,並在非同步互動中扮演一個無鎖的通道角色。除去這些核心特性帶來的優勢外,它還有非常多有趣且有價值的應用場景。

樹遍歷

一個callback函式,它的命運就決定了它是不會在乎自己被放到哪裡、被怎麼使用的,比如說,放進遞迴裡。而遞迴的一個典型場景就是樹遍歷。作為對比,不妨先看看在Python裡怎麼利用yield遍歷一棵二叉樹的:

def scan_tree(node):
    yield node.value
    if node.left:
        yield from scan_tree(node.left)
    if node.right:
        yield from scan_tree(node.right)

對於Seq,由於Java不允許函式內部套函式,所以要稍微多寫一點。核心原理其實很簡單,把callback函式丟給遞迴函式,每次遞迴記得捎帶上就行。

//static <T> Seq<T> of(T... ts) {
//    return Arrays.asList(ts)::forEach;
//}

// 遞迴函式
public static <N> void scanTree(Consumer<N> c, N node, Function<N, Seq<N>> sub) {
    c.accept(node);
    sub.apply(node).consume(n -> {
        if (n != null) {
            scanTree(c, n, sub);
        }
    });
}

// 通用方法,可以遍歷任何樹
public static <N> Seq<N> ofTree(N node, Function<N, Seq<N>> sub) {
    return c -> scanTree(c, node, sub);
}

// 遍歷一個二叉樹
public static Seq<Node> scanTree(Node node) {
    return ofTree(node, n -> Seq.of(n.left, n.right));
}

這裡的ofTree就是一個非常強大的樹遍歷方法。遍歷樹本身並不是啥稀罕東西,但把遍歷的過程輸出為一個流,那想象空間就很大了。在程式語言的世界裡樹的構造可以說到處都是。比方說,我們可以十分簡單的構造出一個遍歷JSONObject的流。

static Seq<Object> ofJson(Object node) {
    return Seq.ofTree(node, n -> c -> {
        if (n instanceof Iterable) {
            ((Iterable<?>)n).forEach(c);
        } else if (n instanceof Map) {
            ((Map<?, ?>)n).values().forEach(c);
        }
    });
}

然後分析JSON就會變得十分方便,比如你想校驗某個JSON是否存在Integer欄位,不管這個欄位在哪一層。使用流的any/anyMatch這樣的方法,一行程式碼就能搞定:

boolean hasInteger = ofJson(node).any(t -> t instanceof Integer);

這個方法的厲害之處不僅在於它足夠簡單,更在於它是一個短路操作。用正常程式碼在一個深度優先的遞迴函式里執行短路,要不就丟擲異常,要不就額外新增一個上下文引數參與遞迴(只有在返回根節點後才能停止),總之實現起來都挺麻煩。但是使用Seq,你只需要一個any/all/none。

再比如你想校驗某個JSON欄位裡是否存在非法字串“114514”,同樣也是一行程式碼:

boolean isIllegal = ofJson(node).any(n -> (n instanceof String) && ((String)n).contains("114514"));

對了,JSON的前輩XML也是樹的結構,結合眾多成熟的XML的解析器,我們也可以實現出類似的流式掃描工具。比如說,更快的Excel解析器?

更好用的笛卡爾積

笛卡爾積對大部分開發而言可能用處不大,但它在函式式語言中是一種頗為重要的構造,在運籌學領域構建最最佳化模型時也極其常見。此前Java裡若要利用Stream構建多重笛卡爾積,需要多層flatMap巢狀。

public static Stream<Integer> cartesian(List<Integer> list1, List<Integer> list2, List<Integer> list3) {
    return list1.stream().flatMap(i1 ->
        list2.stream().flatMap(i2 ->
            list3.stream().map(i3 -> 
                i1 + i2 + i3)));
}

對於這樣的場景,Scala提供了一種語法糖,允許使用者以for迴圈+yield[11]的方式來組合笛卡爾積。不過Scala的yield就是個純語法糖,與生成器並無直接關係,它會在編譯階段將程式碼翻譯為上面flatMap的形式。這種糖形式上等價於Haskell裡的do annotation[12]。

好在現在有了生成器,我們有了更好的選擇,可以在不增加語法、不引入關鍵字、不麻煩編譯器的前提下,直接寫個巢狀for迴圈並輸出為流。且形式更為自由——你可以在for迴圈的任意一層隨意新增程式碼邏輯。

public static Seq<Integer> cartesian(List<Integer> list1, List<Integer> list2, List<Integer> list3) {
    return c -> {
        for (Integer i1 : list1) {
            for (Integer i2 : list2) {
                for (Integer i3 : list3) {
                    c.accept(i1 + i2 + i3);
                }
            }
        }
    };
}

換言之,Java不需要這樣的糖。Scala或許原本也可以不要。

可能是Java下最快的CSV/Excel解析器

我在前文多次強調生成器將帶來顯著的效能優勢,這一觀點除了有理論上的支撐,也有明確的工程實踐資料,那就是我為CSV家族所開發的架構統一的解析器。所謂CSV家族除了CSV以外,還包括Excel與阿里雲的ODPS,其實只要形式符合其統一正規化,就都能進入這個家族。

但是對於CSV這一家子的處理其實一直是Java語言裡的一個痛點。ODPS就不說了,好像壓根就沒有。CSV的庫雖然很多,但好像都不是很讓人滿意,要麼API繁瑣,要麼效能低下,沒有一個的地位能與Python裡的Pandas相提並論。其中相對知名一點的有OpenCSV[13],Jackson的jackson-dataformat-csv[14],以及號稱最快的univocity-parsers[15]。

Excel則不一樣,有集團開源軟體EasyExcel[16]珠玉在前,我只能確保比它快,很難也不打算比它功能覆蓋全。

對於其中的CsvReader實現,由於市面上類似產品實在太多,我也沒精力挨個去比,我只能說反正它比公開號稱最快的那個還要快不少——大概一年前我實現的CsvReader在我辦公電腦上的速度最多隻能達到univocity-parsers的80%~90%,不管怎麼最佳化也死活拉不上去。直到後來我發現了生成器機制並對其重構之後,速度直接反超前者30%到50% ,成為我已知的類似開源產品裡的最快實現。

對於Excel,在給定的資料集上,我實現的ExcelReader比EasyExcel快50%~55% ,跟POI就懶得比了。測試詳情見以上鍊接。

注:最近和Fastjson作者高鐵有很多交流,在暫未正式釋出的Fastjson2的2.0.28-SNAPSHOT版本上,其CSV實現的效能在多個JDK版本上已經基本追平我的實現。出於嚴謹,我只能說我的實現在本文釋出之前可能是已知最快的哈哈。

改造EasyExcel,讓它可以直接輸出流

上面提到的EasyExcel是阿里開源的知名產品,功能豐富,質量優秀,廣受好評。恰好它本身又一個利用回撥函式進行IO互動的經典案例,倒是也非常適合拿來作為例子講講。根據官網示例,我們可以構造一個最簡單的基於回撥函式的excel讀取方法

public static <T> void readEasyExcel(String file, Class<T> cls, Consumer<T> consumer) {
    EasyExcel.read(file, cls, new PageReadListener<T>(list -> {
        for (T person : list) {
            consumer.accept(person);
        }
    })).sheet().doRead();
}

EasyExcel的使用是透過回撥監聽器來捕獲資料的。例如這裡的PageReadListener,內部有一個list快取。快取滿了,就餵給回撥函式,然後繼續刷快取。這種基於回撥函式的做法的確十分經典,但是難免有一些不方便的地方:

  1. 消費者需要關心生產者的內部快取,比如這裡的快取就是一個list。
  2. 消費者如果想拿走全部資料,需要放一個list進去挨個add或者每次addAll。這個操作是非惰性的。
  3. 難以把讀取過程轉變為Stream,任何流式操作都必須要用list存完並轉為流後,才能再做處理。靈活性很差。
  4. 消費者不方便干預資料生產過程,比如達到某種條件(例如個數)後直接中斷,除非你在實現回撥監聽器時把這個邏輯override進去[17]。

利用生成器,我們可以將上面示例中讀取excel的過程完全封閉起來,消費者不需要傳入任何回撥函式,也不需要關心任何內部細節——直接拿到一個流就好。改造起來也相當簡單,主體邏輯原封不動,只需要把那個callback函式用一個consumer再包一層即可:

public static <T> Seq<T> readExcel(String pathName, Class<T> head) {
    return c -> {
        ReadListener<T> listener = new ReadListener<T>() {
            @Override
            public void invoke(T data, AnalysisContext context) {
                c.accept(data);
            }

            @Override
            public void doAfterAllAnalysed(AnalysisContext context) {}
        };
        EasyExcel.read(pathName, head, listener).sheet().doRead();
    };
}

這一改造我已經給EasyExcel官方提了PR[18],不過不是輸出Seq,而是基於生成器原理構建的Stream,後文會有構建方式的具體介紹。

更進一步的,完全可以將對Excel的解析過程改造為生成器方式,利用一次性的callback呼叫避免內部大量狀態的儲存與修改,從而帶來可觀的效能提升。這一工作由於要依賴上文CsvReader的一系列API,所以暫時沒法提交給EasyExcel。

用生成器構建Stream

生成器作為一種全新的設計模式,固然可以提供更為強大的流式API特性,但是畢竟不同於大家最為熟悉Stream,總會有個適應成本或者遷移成本。對於既有的已經成熟的庫而言,使用Stream依然是對使用者最為負責的選擇。值得慶幸的是,哪怕機制完全不同,Stream和Seq仍是高度相容的。

首先,顯而易見,就如同Iterable那樣,Stream天然就是一個Seq:

Stream<Integer> stream = Stream.of(1, 2, 3);
Seq<Integer> seq = stream::forEach;

那反過來Seq能否轉化為Stream呢?在Java Stream提供的官方實現裡,有一個StreamSupport.stream的構造工具,可以幫助使用者將一個iterator轉化為stream。針對這個入口,我們其實可以用生成器來構造一個非標準的iterator:不實現hastNext和next,而是單獨過載forEachRemaining方法,從而hack進Stream的底層邏輯——在那迷宮一般的原始碼裡,有一個非常隱秘的角落,一個叫AbstractPipeline.copyInto的方法,會在真正執行流的時候呼叫Spliterator的forEachRemaining方法來遍歷元素——雖然這個方法原本是透過next和hasNext實現的,但當我們把它過載之後,就可以做到假狸貓換真太子。

public static <T> Stream<T> stream(Seq<T> seq) {
    Iterator<T> iterator = new Iterator<T>() {
        @Override
        public boolean hasNext() {
            throw new NoSuchElementException();
        }

        @Override
        public T next() {
            throw new NoSuchElementException();
        }

        @Override
        public void forEachRemaining(Consumer<? super T> action) {
            seq.consume(action::accept);
        }
    };
    return StreamSupport.stream(
        Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED),
        false);
}

也就是說,我們現在甚至能用生成器來構造Stream了!比如:

public static void main(String[] args) {
    Stream<Integer> stream = stream(c -> {
        c.accept(0);
        for (int i = 1; i < 5; i++) {
            c.accept(i);
        }
    });
    System.out.println(stream.collect(Collectors.toList()));
}

圖靈在上,感謝Stream的作者沒有偷這個懶,沒有用while hasNext來進行遍歷,不然這操作我們還真玩不了。

當然由於這裡的Iterator本質已經發生了改變,這種操作也會有一些限制,沒法再使用parallel方法將其轉為併發流,也不能用limit方法限制數量。不過除此以外,像map, filter, flatMap, forEach, collect等等方法,只要不涉及流的中斷,都可以正常使用。

無限遞推數列

實際應用場景不多。Stream的iterate方法可以支援單個種子遞推的無限數列,但兩個乃至多個種子的遞推就無能為力了,比如最受程式設計師喜愛的炫技專用斐波那契數列:

public static Seq<Integer> fibonaaci() {
    return c -> {
        int i = 1, j = 2;
        c.accept(i);
        c.accept(j);
        while (true) {
            c.accept(j = i + (i = j));
        }
    };
}

另外還有一個比較有意思的應用,利用法裡樹的特性,進行丟番圖逼近[22],簡而言之,就是用有理數逼近實數。這是一個非常適合拿來做demo的且足夠有趣的例子,限於篇幅原因我就不展開了,有機會另寫文章討論。

流的更多特性

流的聚合

如何設計流的聚合介面是一個很複雜的話題,若要認真討論幾乎又可以整出大幾千字,限於篇幅這裡簡單提幾句好了。在我看來,好的流式API應該要讓流本身能直接呼叫聚合函式,而不是像Stream那樣,先用Collectors構造一個Collector,再用stream去呼叫collect。可以對比下以下兩種方式,孰優孰劣一目瞭然:

Set<Integer> set1 = stream.collect(Collectors.toSet());
String string1 = stream.map(Integer::toString).collect(Collectors.joinning(","));

Set<Integer> set2 = seq.toSet();
String string2 = seq.join(",", Integer::toString);

這一點上,Kotlin做的比Java好太多。不過有利往往也有弊,從函式介面而非使用者使用的角度來說,Collector的設計其實更為完備,它對於流和groupBy是同構的:所有能用collector對流直接做到的事情,groupBy之後用相同的collector也能做到,甚至groupBy本身也是一個collector。

所以更好的設計是既保留函式式的完備性與同構性,同時也提供由流直接呼叫的快捷方式。為了說明,這裡舉一個Java和Kotlin都沒有實現但需求很普遍的例子,求加權平均:

public static void main(String[] args) {
    Seq<Integer> seq = Seq.of(1, 2, 3, 4, 5, 6, 7, 8, 9);

    double avg1 = seq.average(i -> i, i -> i); // = 6.3333
    double avg2 = seq.reduce(Reducer.average(i -> i, i -> i)); // = 6.3333
    Map<Integer, Double> avgMap = seq.groupBy(i -> i % 2, Reducer.average(i -> i, i -> i)); // = {0=6.0, 1=6.6}
    Map<Integer, Double> avgMap2 = seq.reduce(Reducer.groupBy(i -> i % 2, Reducer.average(i -> i, i -> i)));
}

上面程式碼裡的average,Reducer.average,以及用在groupBy裡的average都是完全同構的,換句話說,同一個Reducer,可以直接用在流上,也可以對流進行分組之後用在每一個子流上。這是一套類似Collector的API,既解決了Collector的一些問題,同時也能提供更豐富的特性。重點是,這玩意兒是開放的,且機制足夠簡單,誰都能寫。

流的分段處理

分段處理其實是一直以來各種流式API的一個盲點,不論是map還是forEach,我們偶爾會希望前半截和後半截採取不同的處理邏輯,或者更直接一點的說希望第一個元素特殊處理。對此,我提供了三種API,元素替換replace,分段map,以及分段消費consume。

還是以前文提到的下劃線轉駝峰的場景作為一個典型例子:在將下劃線字串split之後,對第一個元素使用lowercase,對剩下的其他元素使用capitalize。使用分段的map函式,可以更快速的實現這一個功能。

static String underscoreToCamel(String str, UnaryOperator<String> capitalize) {
    // split=>分段map=>join
    return Seq.of(str.split("_")).map(capitalize, 1, String::toLowerCase).join("");
}

再舉個例子,當你解析一個CSV檔案的時候,對於存在表頭的情況,在解析時就要分別處理:利用表頭資訊對欄位重排序,剩餘的內容則按行轉為DTO。使用適當的分段處理邏輯,這一看似麻煩的操作是可以在一個流裡一次性完成的。

一次性流還是可重用流?

熟悉Stream的同學應該清楚,Stream是一種一次性的流,因為它的資料來源於一個iterator,二次呼叫一個已經用完的Stream會丟擲異常。Kotlin的Sequence則採用了不同的設計理念,它的流來自於Iterable,大部分情況下是可重用的。但是Kotlin在讀檔案流的時候,採用的依然是和Stream同樣的思路,將BufferedReader封裝為一個Iterator,所以也是一次性的。

不同於以上二者,生成器的做法顯然要更為靈活,流是否可重用,完全取決於被生成器包進去的資料來源是否可重用。比如上面程式碼裡不論是本地檔案還是ODPS表,只要資料來源的構建是在生成器裡邊完成的,那自然就是可重用的。你可以像使用一個普通List那樣,多次使用同一個流。從這個角度上看,生成器本身就是一個Immutable,它的元素生產,直接來自於程式碼塊,不依賴於執行環境,不依賴於記憶體狀態資料。對於任何消費者而言,都可以期待同一個生成器給出始終一致的流。

生成器的本質和人類一樣,都是復讀機

當然,復讀機復讀也是要看成本的,對於像IO這種高開銷的流需要重複使用的場景,反覆去做同樣的IO操作肯定不合理,我們不妨設計出一個cache方法用於流的快取。

最常用的快取方式,是將資料讀進一個ArrayList。由於ArrayList本身並沒有實現Seq的介面,所以不妨造一個ArraySeq,它既是ArrayList,又是Seq——正如我前面多次提到的,List天然就是Seq。

public class ArraySeq<T> extends ArrayList<T> implements Seq<T> {
    @Override
    public void consume(Consumer<T> consumer) {
        forEach(consumer);
    }
}

有了ArraySeq之後,就可以立馬實現流的快取

default Seq<T> cache() {
    ArraySeq<T> arraySeq = new ArraySeq<>();
    consume(t -> arraySeq.add(t));
    return arraySeq;
}

細心的朋友可能會注意到,這個cache方法我在前面構造併發流的時候已經用到了。除此以外,藉助ArraySeq,我們還能輕易的實現流的排序,感興趣的朋友可以自行嘗試。

二元流

既然可以用consumer of callback作為機制來構建流,那麼有意思的問題來了,如果這個callback不是Consumer而是個BiConsumer呢?——答案就是,二元流!

public interface BiSeq<K, V> {
    void consume(BiConsumer<K, V> consumer);
}

二元流是一個全新概念,此前任何基於迭代器的流,比如Java Stream,Kotlin Sequence,還有Python的生成器,等等等等,都玩不了二元流。我倒也不是針對誰,畢竟在座諸位的next方法都必須吐出一個物件例項,意味著即便想構造同時有兩個元素的流,也必須包進一個Pair之類的結構體裡——故而其本質上依然是一個一元流。當流的元素數量很大時,它們的記憶體開銷將十分顯著。

哪怕是看起來最像二元流的Python的zip:

for i, j in zip([1, 2, 3], [4, 5, 6]):
    pass

這裡的i和j,實際仍是對一個tuple進行解包之後的結果。

但是基於callback機制的二元流和它們完全不一樣,它和一元流是同等輕量的!這就意味著節省記憶體同時還快。比如我在實現CsvReader時,重寫了String.split方法使其輸出為一個流,這個流與DTO欄位zip為二元流,就能實現值與欄位的一對一匹配。不需要藉助下標,也不需要建立臨時陣列或list進行儲存。每一個被分割出來的substring,在整個生命週期裡都是一次性的,隨用隨丟。

這裡額外值得一提的是,同Iterable類似,Java裡的Map天生就是一個二元流。

Map<Integer, String> map = new HashMap<>();
BiSeq<Integer, String> biSeq = map::forEach;

有了基於BiConsumer的二元流,自然也可以有基於TriConsumer三元流,四元流,以及基於IntConsumer、DoubleConsumer等原生型別的流等等。這是一個真正的流的大家族,裡邊甚至還有很多不同於一元流的特殊操作,這裡就不過多展開了,只提一個:

二元流和三元流乃至多元流,可以在Java裡構造出貨真價實的惰性元組tuple。當你的函式需要返回多個返回值的時候,除了手寫一個Pair/Triple,你現在有了更好的選擇,就是用生成器的方式直接返回一個BiSeq/TriSeq,這比直接的元組還額外增加了的惰性計算的優勢,可以在真正需要使用的時候再用回撥函式去消費。你甚至連空指標檢查都省了。

結束語

首先感謝你能讀到這裡,我要講的故事大體已經講完了,雖然還有許多稱得上有趣的細節沒放出來討論,但已經不影響這個故事的完整性了。我想要再次強調的是,上面這所有的內容,程式碼也好,特性也好,案例也罷,包括我所實現的CsvReader系列——全部都衍生自這一個簡單介面,它是一切的源頭,是夢開始的地方,完全值得我在文末再寫一遍

public interface Seq<T> {
    void consume(Consumer<T> consumer);
}

對於這個神奇的介面,我願稱之為:

道生一——先有Seq定義

一生二——匯出Seq一體兩面的特性,既是流,又是生成器

二生三——由生成器實現出豐富的流式API,而後匯出可安全隔離的IO流,最終匯出非同步流、併發流以及通道特性

至於三生萬物的部分,還會有後續文章,期待能早日對外開源吧。

附錄

附錄的原本內容包含API文件,引用地址,以及效能benchmark。由於暫未開源,這裡僅介紹下Monad相關。

Monad

Monad[24]是來自於範疇論裡的一個概念,同時也是函數語言程式設計語言代表者Haskell裡極為重要的一種設計模式。但它無論是對流還是對生成器而言都不是必須的,所以放在附錄講。

我之所以要提Monad,是因為Seq在實現了unit, flatMap之後,自然也就成為了一種Monad。對於關注相關理論的同學來說,如果連提都不提,可能會有些難受。遺憾的是,雖然Seq在形式上是個Monad,但它們在理念上是存在一些衝突的。比方說在Monad裡至關重要的flatMap,既是核心定義之一,還承擔著組合與拆包兩大重要功能。甚至連map對Monad來說都不是必須的,它完全可以由flatMap和unit推匯出來(推導過程見下文),反之還不行。但是對於流式API而言,map才是真正最為關鍵和高頻的操作,flatMap反而沒那麼重要,甚至壓根都不太常用。

Monad這種設計模式之所以被推崇備至,是因為它有幾個重要特性,惰性求值、鏈式呼叫以及副作用隔離——在純函式的世界裡,後者甚至稱得上是性命攸關的大事。但是對包括Java在內的大部分正常語言來說,實現惰性求值更直接的方式是面向介面而不是物件導向(例項)程式設計,介面由於沒有成員變數,天生就是惰性的。鏈式操作則是流的天生特性,無須贅述。至於副作用隔離,這同樣不是Monad的專利。生成器用閉包+callback的方式也能做到,前文都有介紹。

推導map的實現

首先,map可以由unit與flatMap直接組合得到,這裡不妨稱之為map2:

default <E> Seq<E> map2(Function<T, E> function) {
    return flatMap(t -> unit(function.apply(t)));
}

即把型別為T的元素,轉變為型別為E的Seq,再用flatMap合併。這個是最直觀的,不需要流的先驗概念,是Monad的固有屬性。當然其在效率上肯定很差,我們可以對其化簡。

已知unit與flatMap的實現

static <T> Seq<T> unit(T t) {
    return c -> c.accept(t);
}

default <E> Seq<E> flatMap(Function<T, Seq<E>> function) {
    return c -> supply(t -> function.apply(t).supply(c));
}

先展開unit,代入上面map2的實現,有

default <E> Seq<E> map3(Function<T, E> function) {
    return flatMap(t -> c -> c.accept(function.apply(t)));
}

把這個flatMap裡邊的函式提出來變成flatFunction,再展開flatMap,有

default <E> Seq<E> map4(Function<T, E> function) {
    Function<T, Seq<E>> flatFunction = t -> c -> c.accept(function.apply(t));
    return consumer -> supply(t -> flatFunction.apply(t).supply(consumer));
}

容易注意到,這裡的flatFunction連續有兩個箭頭,它其實就完全等價於一個雙引數(t, c)函式的柯里化currying。我們對其做逆柯里化操作,反推出這個雙引數函式:

Function<T, Seq<E>> flatFunction = t -> c -> c.accept(function.apply(t));
// 等價於
BiConsumer<T, Consumer<E>> biConsumer = (t, c) -> c.accept(function.apply(t));

可以看到,這個等價的雙引數函式其實就是一個BiConsumer ,再將其代入map4,有

default <E> Seq<E> map5(Function<T, E> function) {
    BiConsumer<T, Consumer<E>> biConsumer = (t, c) -> c.accept(function.apply(t));
    return c -> supply(t -> biConsumer.accept(t, c));
}

注意到,這裡biConsumer的實參和形參是完全一致的,所以可以將它的方法體代入下邊直接替換,於是有

default <E> Seq<E> map6(Function<T, E> function) {
    return c -> supply(t -> c.accept(function.apply(t)));
}

到這一步,這個map6,就和前文從流式概念出發直接寫出來的map完全一致了。證畢!

參考連結:

[1]https://en.wikipedia.org/wiki/Generator_(computer_programming)

[2]https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPytho...

[3]https://openjdk.org/projects/loom/

[4]https://en.wikipedia.org/wiki/Continuation

[5]https://hackernoon.com/the-magic-behind-python-generator-func...

[6]https://en.wikipedia.org/wiki/Continuation-passing_style

[7]https://kotlinlang.org/spec/asynchronous-programming-with-cor...

[8]https://zh.wikipedia.org/wiki/Map_(%E9%AB%98%E9%98%B6%E5%87%BD%E6%95%B0)

[9]https://crypto.stanford.edu/~blynn/haskell/io.html

[10]https://www.autohotkey.com/docs/v2/

[11]https://stackoverflow.com/questions/1052476/what-is-scalas-yield

[12]https://stackoverflow.com/questions/10441559/scala-equivalent-of-haskells-do-notation-yet-again

[13]https://opencsv.sourceforge.net/

[14]https://github.com/FasterXML/jackson-dataformats-text/tree/master/csv

[15]https://github.com/uniVocity/univocity-parsers

[16]https://github.com/alibaba/easyexcel

[17]https://github.com/alibaba/easyexcel/issues/1566

[18]https://github.com/alibaba/easyexcel/pull/3052

[20]https://github.com/alibaba/easyexcel/pull/3052

[21]https://github.com/alibaba/fastjson2/blob/f30c9e995423603d5b80f3efeeea229b76dc3bb8/extension/src/main/java/com/alibaba/fastjson2/support/csv/CSVParser.java#L197

[22]https://www.bilibili.com/video/BV1ha41137oW/?is_story_h5=fals...

[24]https://en.wikipedia.org/wiki/Monad_(functional_programming)

更多內容,請點選此處進入雲原生技術社群檢視

相關文章