補充一個替代 for 迴圈的新姿勢

serialcoder發表於2019-01-12

本文英文版發表在 Lei's Blog

我也沒想到我還在這個問題上死磕……

最近複習以前的知識點,發現之前鑽的不夠深,然後繼續開了下腦洞,然後就有了今天要寫的內容。

可能有不熟悉背景的朋友,這裡我簡單介紹下。我之前在掘金寫了一篇很引戰的文章《如何在 JS 程式碼中消滅 for 迴圈》。這個標題嚴謹推敲一下是不合適的,但已經成歷史了,我就不改了。後來我又解釋了為什麼要在特定場合下避免 for 迴圈,這裡就不贅述了。接下來我要寫的程式碼也包含大量 for 迴圈,這和我之前的解釋並不衝突。

去年學習 Haskell 的時候發現 Haskell 的一種寫法很簡潔和優雅,很難用 JS 來實現。那段程式碼長這樣:

sum (takeWhile (<10000) (filter odd (map (^2) [1..])))
複製程式碼

我當時就想在 JS 裡面怎麼用高階函式實現這段程式碼。當然我是想不出來的,然後我 Google 搜了一下,還真有人寫了。Lazy Evaluation in JavaScript with Generators, Map, Filter, and Reduce

我基於這篇文章,加了一個 takeWhile 方法,然後用 JS 翻譯了上面那段 Haskell 程式碼。然後我寫了一篇文章,逐塊解釋程式碼,發表在掘金(後來刪了)。前時間我把這篇文章重新分享出來,然後就有人質疑我從哪抄的程式碼。

我確實一開始沒有標明出處,只有認了。同時別人的監督也促使我反省我為什麼不基於別人的成果擴充套件些自己的東西?

我接下來要基於原始碼從兩個方向擴充套件。

一是將 class 改寫成工廠函式。這裡不想再引起爭議,我不是提倡不要用 class。在 Node 開發中,為了效能是要用 class 的。這裡折騰改寫出於兩個原因:

  1. 通過改寫徹底弄懂程式碼。
  2. 個人偏好上我更喜歡工廠函式。擴充套件性和安全性上,工廠函式更優(歡迎證明我是錯的)。

二是將原文 Lazy 函式改寫成接受任何 iterable(原文只接受無限 iterator),並加上常用的列表操作高階函式。這一步完成後,我們其實就能用 Lazy 來實現大資料集遍歷了。我之前解決這個問題是用的 Transducer。惰性求值和 transduce 原理不一樣,但目的有重合。

我首先用工廠函式改寫原版:

const Lazy = iterator => {
  const next = iterator.next.bind(iterator)

  const map = f => {
    const modifiedNext = () => {
      const item = next()
      const mappedValue = f(item.value)
      return {
        value: mappedValue,
        done: item.done,
      }
    }
    const newIter = { ...iterator, next: modifiedNext }
    return Lazy(newIter)
  }

  const filter = predicate => {
    const modifiedNext = () => {
      while (true) {
        const item = next()
        if (predicate(item.value)) {
          return item
        }
      }
    }
    const newIter = { ...iterator, next: modifiedNext }
    return Lazy(newIter)
  }

  const takeWhile = predicate => {
    const result = []
    let value = next().value
    while (predicate(value)) {
      result.push(value)
      value = next().value
    }
    return result
  }

  return Object.freeze({
    map,
    filter,
    takeWhile,
    next,
  })
}

const numbers = function*() {
  let i = 1
  while (true) {
    yield i++
  }
}

Lazy(numbers())
  .map(x => x ** 2)
  .filter(x => x % 2 === 1)
  .takeWhile(x => x < 10000)
  .reduce((x, y) => x + y)
// => 16650
複製程式碼

以上程式碼在我部落格中有逐步解釋。

接著擴充套件 Lazy 函式,讓它接受任何 Iterable 和自定義 Generator:

/*
 * JS 中判斷 generator 有些麻煩,我用了這個庫:
 * https://github.com/ljharb/is-generator-function
 */

const Lazy = source => {
  if (typeof source[Symbol.iterator] !== 'function' && !isGeneratorFunction(source))
    throw new Error('The source input must be an iterable or a generator function')

  const iterator = isGeneratorFunction(source)
    ? source()
    : source[Symbol.iterator]();

  const _lazy = it => {
    const next = it.next.bind(it)

    const map = f => {
      const modifiedNext = () => {
        const item = next()
        const mappedValue = f(item.value)
        return {
          value: mappedValue,
          done: item.done,
        }
      }
      const newIter = { ...it, next: modifiedNext }
      return _lazy(newIter)
    }

    const filter = predicate => {
      const modifiedNext = () => {
        let item = next()
        /*
         * 注意這裡的改動,這裡為了處理有限資料集,
         * 不能再用死迴圈了
         */
        while (!item.done) {
          if (predicate(item.value)) {
            return item
          }
          item = next()
        }
        /*
         * 如果上面的迴圈完成,還沒匹配值,
         * 通知 iterator 及時結束
         */
        return { value: null, done: true }
      }
      const newIter = { ...it, next: modifiedNext }
      return _lazy(newIter)
    }

    const takeWhile = predicate => {
      const result = []
      let value = next().value
      while (predicate(value)) {
        result.push(value)
        value = next().value
      }
      return result
    }

    const take = n => {
      const values = []
      let item = next()
      for (let i = 0; i < n; i++) {
        /*
         * 如果資料集長度比 n 要小,
         * 遍歷提前完成,要及時 break 迴圈
         */
        if (item.done) break
        values.push(item.value)
        item = next()
      }

      return values
    }

    /*
     * ZipWith 把兩個包在 Lazy 函式中的 Iterable
     * 按指定回撥函式拼接起來
     */
    const zipWith = (fn, other) => {
      const modifiedNext = () => {
        const first = next()
        const second = other.next()
        /*
         * 只要有一個 Iterable 遍歷完成,
         * 告訴外層 Iterator 結束
         */
        if (first.done || second.done) {
          return { value: null, done: true }
        }
        return {
          value: fn(first.value, second.value),
          done: false,
        }
      }
      const newIter = { ...it, next: modifiedNext }
      return _lazy(newIter)
    }

    return Object.freeze({
      map,
      filter,
      takeWhile,
      next,
      take,
      zipWith,
    })
  }
  return _lazy(iterator)
}
複製程式碼

來試驗一下:

Lazy([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
  .map(x => x * 2)
  .filter(x => x % 3 === 0)
  .take(10) // => [6, 12, 18]

Lazy([1, 2, 3, 4, 5, 6, 7, 8])
  .zipWith((x, y) => x + y, Lazy([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]))
  .map(x => x + 1)
  .take(10) // => ​​​​​[ 3, 5, 7, 9, 11, 13, 15, 17 ]​​​​​
複製程式碼

注意本文只是提供了一個 proof of concept, 效能問題和其它潛在的問題我還沒驗證過,所以不建議在生產中直接使用本文程式碼。

另外,有一個叫 lazy.js 的庫提供了類似的功能。感興趣的朋友可以參考 lazy.js 的 API,基於本文程式碼繼續擴充套件。比如,給 Lazy 加上非同步迭代的功能就可以很簡單做到。歡迎折騰反饋。

相關文章