Skip to content

remeda-lazy-evaluator

懶有懶的好處,聊聊 Lazy Evaluation

故事發生於某天平凡的午後,鱈魚我和平常一樣快樂地用著 Remeda,使用 take 時忽然很好奇原始碼如何實作。


旁白:「『未看先猜一定是 `Array.slice` 啦!』鱈魚邊說邊打開程式碼,結果出現火星文,看不懂的大笨魚只能失落的挺著肥肚肚,望之興嘆。」

鱈魚:「給我照腳本念,不要偷嘴我!ლ(´口`ლ)

讓我們看看 Remedatake 原始碼:

ts
export function take(...args: ReadonlyArray<unknown>): unknown {
  return purry(takeImplementation, args, lazyImplementation)
}

function takeImplementation<T extends IterableContainer>(array: T, n: number): Array<T[number]> {
  return n < 0 ? [] : array.slice(0, n)
}

function lazyImplementation<T>(n: number): LazyEvaluator<T> {
  if (n <= 0) {
    return lazyEmptyEvaluator
  }

  let remaining = n
  return (value) => {
    remaining -= 1
    return { done: remaining <= 0, hasNext: true, next: value }
  }
}

purryRemeda 特殊函數,不在此文章討論範圍,有興趣的朋友可以來這裡看看

可以看到的確有 Array.slice 沒錯,不過還有 lazyImplementation 部分,這個其實就是實作「惰性求值」(Lazy Evaluation)邏輯。

至於甚麼是「惰性求值」?簡單來說,就是在需要時才計算,而不是一開始就計算所有東西。

細節網路上超多文章,這裡就不贅述了。(ゝ∀・)y

來 Bench 一下

讓我們來 Bench 一下,感受看看差異吧。

邏輯為:將一個巨大的矩陣 map、filter 後,只取前 5 筆資料。

資料定義如下:

ts
export const mapFn = (x: number): number => x * 2
export const filterFn = (x: number): boolean => x % 3 === 0
export const TAKE_COUNT = 5

const DATA_SIZE = 1_000_000
export const data = Array.from({ length: DATA_SIZE }, (_, i) => i + 1)

總共測試三種版本程式:

Remedalazy 版本:

lazy.ts

ts
import { filter, map, pipe, take } from 'remeda'

pipe(
  data,
  map(mapFn),
  filter(filterFn),
  take(TAKE_COUNT)
)

原生 Array 版本:

native.ts

ts
data
  .map(mapFn)
  .filter(filterFn)
  .slice(0, TAKE_COUNT)

Remeda function 拆開來用,不要使用 lazy

no-lazy.ts

ts
import { filter, map, take } from 'remeda'

const mapped = map(data, mapFn)
const filtered = filter(mapped, filterFn)
take(filtered, TAKE_COUNT)

實際跑個 Bench

可以看到結果差距非常驚人。

NameOps/sec
lazy.ts1,113,819 Best
no-lazy.ts63 (-100.0%)
native.ts61 (-100.0%)
  • lazy 版本表現最佳,達到每秒 111 萬次操作,遠超過其他兩個版本。

  • no-lazynative 版本表現相近很合理,因為 Remedamapfilter 都是使用原生的 Array.mapArray.filter

lazy 版本會那麼快,是因為它只需要計算到滿足 take 的部分,並不會計算整個資料集,所以時間才差那麼多。

所以用 Remeda 性能都會好棒棒嗎?

倒也不能這麼說,讓我們去掉 take 的部分,再跑一次看看。

NameOps/sec
lazy.ts19(-70.4%)
no-lazy.ts64 Best
native.ts63(-1.8%)

可以看到 lazy 的效能大幅下降,甚至比不上 no-lazy 版本。

這是因為 lazytake 只在需要時才計算,當沒有 take 時,整個資料集都需要計算,這樣就失去了惰性求值的優勢,反而會因為 lazy 額外的計算邏輯,導致效能下降。

或者把資料數量改成 50 筆,再跑一次。

NameOps/sec
lazy.ts1,017,423 (-36.0%)
no-lazy.ts1,539,932 (-3.2%)
native.ts1,590,550 Best

可以看到 lazy 版本下降惹 Σ(ˊДˋ;)

這是因為瀏覽器為了 JS 性能操碎了心,這點資料才不算甚麼,lazy 的計算邏輯反而拖累了性能。

原生不能懶懶的嗎?

可以使用 generator 來實作惰性求值邏輯:

generator.ts

ts
function* mapIterator<T, U>(
  iterable: Iterable<T>,
  mapFn: (item: T) => U
): IterableIterator<U> {
  for (const item of iterable) {
    yield mapFn(item)
  }
}

function* filterIterator<T>(
  iterable: Iterable<T>,
  filterFn: (item: T) => boolean
): IterableIterator<T> {
  for (const item of iterable) {
    if (filterFn(item)) {
      yield item
    }
  }
}

function* takeIterator<T>(
  iterable: Iterable<T>,
  count: number
): IterableIterator<T> {
  let i = 0
  for (const item of iterable) {
    if (i++ >= count)
      break
    yield item
  }
}

export function run() {
  const iterator = takeIterator(
    filterIterator(
      mapIterator(data, mapFn),
      filterFn
    ),
    TAKE_COUNT
  )
  const result = Array.from(iterator)
}

Bench 結果:

NameOps/sec
lazy.ts636,538 (-0.2%)
no-lazy.ts40 (-100.0%)
native.ts40 (-100.0%)
generator.ts637,597 Best

也有 proposal-iterator-helpers 提案,截至 2025/05/26 還在 Stage 4,雖然已經有瀏覽器實作,不過要注意支援度

iIterator.ts

ts
function* toIterator<T>(array: T[]) {
  for (const val of array) {
    yield val
  }
}

export function run() {
  toIterator(data)
    .map(mapFn)
    .filter(filterFn)
    .take(TAKE_COUNT)
    .toArray()
}

Bench 結果:

NameOps/sec
lazy.ts586,274 Best
no-lazy.ts30 (-100.0%)
native.ts34 (-100.0%)
generator.ts501,987 (-14.4%)
iterator.ts545,057 (-7.0%)

Bench 程式碼

以上就是這次的簡單測試。੭ ˙ᗜ˙ )੭


鱈魚:「以後遇到懶得做的 feature,就說我正在 Lazy Evaluation 就對惹。(ゝ∀・)b

PM:(抽出生魚片刀 ( ˘•ω•˘ ))

總結 🐟

  • Remedalazy 版本在處理大量資料時,能夠避免不必要的計算,提升效能。
  • 不過當資料量小時,lazy 反而會拖慢效能。
  • 性能會因情境、runtime、瀏覽器最佳化與程式寫法而有所不同,最好應該實際跑過 benchmark 再做選擇。