引言
前不久,公司後端同事找到我,邀請我在月會上分享函數語言程式設計,我說你還是另請高明吧…… 我也不是謙虛,我一個前端頁面仔,怎麼去給以 Java 後端開發為主的技術部講函數語言程式設計呢?但是同事說你還是試試吧。然後我就去先試著準備下。
由於我最近在學函式式領域建模(Functional Domain Modeling),一開始我想講下 Scala,然後我找到了 Functional and Reactive Domain Modeling 這本書。但是轉念一想,我都沒寫過後端,對後端的業務場景根本不瞭解,看本書就去講,有些不尊重聽眾智商了。最後我打算在 Java 裡找些接地氣的內容來分享,然後我就去學了 Java,發現了 Java 裡面有 Stream 這麼好的函式式一級語言支援。
最終經過謹慎考慮後,我還是沒有去分享。我沒有業務上的理解,僅僅去講些語法特性和奇技淫巧,有些班門弄斧了。我在學習 Stream 的過程中有一些發現,現在把這些發現分享出來。
本文程式碼在我上一篇文章中出現過,但這次解釋更詳細。
JavaScript 更適合用來學習一些 FP 概念
本文無意丟擲 JavaScript 和 Java 誰比誰好這種無聊的論斷,我僅想指出,JS 這種弱型別語言可以避免一些語法噪音,讓初學者在學習一些 FP 概念時,快速直抵核心,理解本質。
在學 Java Stream 時,要先學 lambda expression, method reference, 然後學 Interface 和 Functional Interface, 學這些僅僅為了支援 lambda 傳參。而用 JS 的話,函式順手就寫,順手就傳,不用那麼多準備儀式。
再次宣告一下,Java 這種物件導向特性有其強大之處。這裡僅僅指出在學習理解一些 FP 概念上,JS 更簡單靈活。
理解 Stream
Stream 可以拆解成三個部分:
如上圖,Stream 可分為資料來源,管道資料操作,資料消費三個部分。源資料生成後,流經管道,最終在管道終端被消費。最終的消費可能是資料流被聚合成新的資料集,也可能是逐個執行副作用(forEach
)。
下面用 JS 程式碼解釋這三個部分。
資料來源
資料來源可以理解成是一個 generator 函式被反覆執行,生成資料流。什麼是 generator 呢?最簡單的 generator 可以是這樣:
const getRandNum = () => Math.floor(Math.random() * 100);
複製程式碼
每次執行 getRandNum
它都隨機生成一個 0 到 100 的整數,它滿足我們對 generator 行為的期待。可以看出 generator 其實就是一個動態生成資料的行為,那如果資料來源是靜態資料集,怎麼得到這種動態 generator 呢?很簡單:
function getGeneratorFromList(list) {
let index = 0;
return function generate() {
if (index < list.length) {
const result = list[index];
index += 1;
return result;
}
};
}
// 例子:
const generate = getGeneratorFromList([1, 2, 3]);
generate(); // 1
generate(); // 2
generate(); // 3
generate(); // undefined
複製程式碼
給 getGeneratorFromList
傳個陣列,它會返回一個 generator,這個 generator 每被執行一次都吐出傳入陣列當前遍歷到的元素。這裡只考慮陣列,其它情況很容易擴充套件。
資料來源部分就講完了。其實很簡單。
管道組合
資料來源生成後,就進入管道了。管道里對原 generator 進行各種轉換,組合生成符合期待的 generator。注意上一句的描述,管道里僅僅是基於原 generator 函式生成新的 generator 函式,計算行為並不會被觸發。
我們給管道里塞入一些高階操作函式,這些高階操作函式接受前一個函式吐出的 generator,返回加入了新行為的 generator。我們先來定義一個 map
:
function map(mapping) {
return function(generate) {
return function mappedGenerator() {
const value = generate();
if (value !== undefined) {
return mapping(value);
}
};
};
}
複製程式碼
map
函式連續返回了兩個函式。這裡先簡要解釋下為什麼這麼做,可能會比較難懂,等你看了後面的程式碼再回過頭來看會更容易理解些。當使用者呼叫 map
並將結果傳入管道時,map
返回了第二層函式。當管道組合被觸發時,第二層函式被執行,最終 generator 函式被返回,然後返回的 generator 被傳給管道里的下一個高階操作函式。
再來定義一個 filter
:
function filter(predicate) {
return function(generate) {
return function filteredGenerator() {
const value = generate();
if (value !== undefined) {
if (predicate(value)) {
return value;
}
return filteredGenerator();
}
};
};
}
複製程式碼
當判斷條件不滿足時,generator 會跳過當前值,持續遞迴,直到遍歷到符合條件的值才將值返回出去。
map
和 filter
是最重要的高階操作函式,其它的操作函式就不展開解釋了。
在提供了高階操作函式之後,我們要提供一個函式將這些高階函式組合起來。
function Stream(source) {
let initialGenerator;
if (Array.isArray(source)) {
initGenerator = getGeneratorFromList(source);
} else {
initialGenerator = source;
}
function pipe(...operators) {
return operators.reduce((prev, current) => current(prev), initialGenerator);
}
return { pipe };
}
複製程式碼
本文就只考慮資料來源是 generator 函式和陣列兩個情況了。這種考慮當然是不嚴謹的,但足以解釋 Stream 的實現。
pipe
函式將操作函式從左往右依次執行,並將上一個函式執行的結果傳給下一個函式。如此則完成了管道組合操作。
資料消費
前面兩步完成後,就剩下如何將動態的 generator 轉換成靜態的資料集了(forEach 執行副作用本文就不考慮了)。這一步也比較簡單:
function toList(generate) {
const arr = [];
let value = generate();
while (value !== undefined) {
arr.push(value);
value = generate();
}
return arr;
}
複製程式碼
這裡就只展示生成陣列了,其它資料型別讀者可自行擴充套件。
至此,完整的 Stream 就實現了。當然,操作符支援上還不完整,但你能明白我的意思。
再定義一個 take
,然後測試下:
function take(n) {
return function(generate) {
let count = 0;
return function() {
if (count < n) {
count += 1;
return generate();
}
};
};
}
Stream(getRandNum).pipe(
filter(x => x % 2 === 1),
take(10),
toList
);
// => 10 個隨機奇數
複製程式碼
注意,getRandNum
永遠都不會返回 undefined
,那為什麼 toList
沒有進入死迴圈?這是因為 take
給原 generator 加入了新的行為,讓它只能返回 10 個有效值。這也是惰性求值的魅力。
更多高階操作符如下:
function skipWhile(predicate) {
return function(generate) {
let startTaking = false;
return function skippedGenerator() {
const value = generate();
if (value !== undefined) {
if (startTaking) {
return value;
} else if (!predicate(value)) {
startTaking = true;
return value;
}
return skippedGenerator();
}
};
};
}
function takeUntil(predicate) {
return function(generate) {
return function() {
const value = generate();
if (value !== undefined) {
if (predicate(value)) {
return value;
}
}
};
};
}
複製程式碼
後記
Java 裡面的 Stream 底層實現我並沒有學習,我只學了下 API 用法。但是從 Stream API 的行為特性來推斷,其底層實現應該和本文展示的 JS 實現思想是想通的。
前幾天在知乎上偶然發現有人未經我授權把我去年發表的《如何在 JS 裡面消滅 for 迴圈》轉載了。我看了下評論,比在掘金被罵的還慘。沒想到在掘金被罵了一輪還要在知乎上再被罵一輪……
如果你寫 Java,Java 8 給你提供了 Stream 你不用,偏偏要用 for 迴圈,還攻擊用前者的人是在玩語法遊戲,是不是很傻?