作者:文鐳(依來)
前言
這篇文章不是工具推薦,也不是應用案例分享。其主題思想,是介紹一種全新的設計模式。它既擁有抽象的數學美感,僅僅從一個簡單接口出發,就能推演出龐大的特性集合,引出許多全新概念。同時也有紮實的工程實用價值,由其實現的工具,性能均可顯著超過同類的頭部開源產品。
這一設計模式並非因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緩存。緩存滿了,就餵給回調函數,然後繼續刷緩存。這種基於回調函數的做法的確十分經典,但是難免有一些不方便的地方:
-
消費者需要關心生產者的內部緩存,比如這裏的緩存就是一個list。
-
消費者如果想拿走全部數據,需要放一個list進去挨個add或者每次addAll。這個操作是非惰性的。
-
難以把讀取過程轉變爲Stream,任何流式操作都必須要用list存完並轉爲流後,才能再做處理。靈活性很差。
-
消費者不方便干預數據生產過程,比如達到某種條件(例如個數)後直接中斷,除非你在實現回調監聽器時把這個邏輯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_EssentialsOfPython/Generators_and_Comprehensions.html
[3]https://openjdk.org/projects/loom/
[4]https://en.wikipedia.org/wiki/Continuation
[5]https://hackernoon.com/the-magic-behind-python-generator-functions-bc8eeea54220
[6]https://en.wikipedia.org/wiki/Continuation-passing_style
[7]https://kotlinlang.org/spec/asynchronous-programming-with-coroutines.html
[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
[24]https://en.wikipedia.org/wiki/Monad_(functional_programming)
更多內容,請點擊此處進入雲原生技術社區查看