用 JS 程式碼解釋 Java Stream

serialcoder發表於2019-04-04

引言

前不久,公司後端同事找到我,邀請我在月會上分享函數語言程式設計,我說你還是另請高明吧…… 我也不是謙虛,我一個前端頁面仔,怎麼去給以 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 可以拆解成三個部分:

用 JS 程式碼解釋 Java 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 會跳過當前值,持續遞迴,直到遍歷到符合條件的值才將值返回出去。

mapfilter 是最重要的高階操作函式,其它的操作函式就不展開解釋了。

在提供了高階操作函式之後,我們要提供一個函式將這些高階函式組合起來。

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 迴圈,還攻擊用前者的人是在玩語法遊戲,是不是很傻?

相關文章