懶有懶的好處,聊聊 Lazy Evaluation
故事發生於某天平凡的午後,鱈魚我和平常一樣快樂地用著 Remeda
,使用 take
時忽然很好奇原始碼如何實作。
旁白:「『未看先猜一定是 `Array.slice` 啦!』鱈魚邊說邊打開程式碼,結果出現火星文,看不懂的大笨魚只能失落的挺著肥肚肚,望之興嘆。」
鱈魚:「給我照腳本念,不要偷嘴我!ლ(´口`ლ)」
讓我們看看 Remeda
的 take
原始碼:
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 }
}
}
purry
是 Remeda
特殊函數,不在此文章討論範圍,有興趣的朋友可以來這裡看看。
可以看到的確有 Array.slice
沒錯,不過還有 lazyImplementation
部分,這個其實就是實作「惰性求值」(Lazy Evaluation)邏輯。
至於甚麼是「惰性求值」?簡單來說,就是在需要時才計算,而不是一開始就計算所有東西。
細節網路上超多文章,這裡就不贅述了。(ゝ∀・)y
來 Bench 一下
讓我們來 Bench 一下,感受看看差異吧。
邏輯為:將一個巨大的矩陣 map、filter 後,只取前 5 筆資料。
資料定義如下:
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)
總共測試三種版本程式:
Remeda
的 lazy
版本:
lazy.ts
import { filter, map, pipe, take } from 'remeda'
pipe(
data,
map(mapFn),
filter(filterFn),
take(TAKE_COUNT)
)
原生 Array 版本:
native.ts
data
.map(mapFn)
.filter(filterFn)
.slice(0, TAKE_COUNT)
將 Remeda
function 拆開來用,不要使用 lazy
:
no-lazy.ts
import { filter, map, take } from 'remeda'
const mapped = map(data, mapFn)
const filtered = filter(mapped, filterFn)
take(filtered, TAKE_COUNT)
可以看到結果差距非常驚人。
Name | Ops/sec |
---|---|
lazy.ts | 1,113,819 Best |
no-lazy.ts | 63 (-100.0%) |
native.ts | 61 (-100.0%) |
lazy
版本表現最佳,達到每秒 111 萬次操作,遠超過其他兩個版本。no-lazy
與native
版本表現相近很合理,因為Remeda
的map
、filter
都是使用原生的Array.map
、Array.filter
。
lazy
版本會那麼快,是因為它只需要計算到滿足 take
的部分,並不會計算整個資料集,所以時間才差那麼多。
所以用 Remeda 性能都會好棒棒嗎?
倒也不能這麼說,讓我們去掉 take
的部分,再跑一次看看。
Name | Ops/sec |
---|---|
lazy.ts | 19(-70.4%) |
no-lazy.ts | 64 Best |
native.ts | 63(-1.8%) |
可以看到 lazy
的效能大幅下降,甚至比不上 no-lazy
版本。
這是因為 lazy
的 take
只在需要時才計算,當沒有 take
時,整個資料集都需要計算,這樣就失去了惰性求值的優勢,反而會因為 lazy
額外的計算邏輯,導致效能下降。
或者把資料數量改成 50 筆,再跑一次。
Name | Ops/sec |
---|---|
lazy.ts | 1,017,423 (-36.0%) |
no-lazy.ts | 1,539,932 (-3.2%) |
native.ts | 1,590,550 Best |
可以看到 lazy
版本下降惹 Σ(ˊДˋ;)
這是因為瀏覽器為了 JS 性能操碎了心,這點資料才不算甚麼,lazy
的計算邏輯反而拖累了性能。
原生不能懶懶的嗎?
可以使用 generator 來實作惰性求值邏輯:
generator.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 結果:
Name | Ops/sec |
---|---|
lazy.ts | 636,538 (-0.2%) |
no-lazy.ts | 40 (-100.0%) |
native.ts | 40 (-100.0%) |
generator.ts | 637,597 Best |
也有 proposal-iterator-helpers 提案,截至 2025/05/26 還在 Stage 4,雖然已經有瀏覽器實作,不過要注意支援度。
iIterator.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 結果:
Name | Ops/sec |
---|---|
lazy.ts | 586,274 Best |
no-lazy.ts | 30 (-100.0%) |
native.ts | 34 (-100.0%) |
generator.ts | 501,987 (-14.4%) |
iterator.ts | 545,057 (-7.0%) |
以上就是這次的簡單測試。੭ ˙ᗜ˙ )੭
鱈魚:「以後遇到懶得做的 feature,就說我正在 Lazy Evaluation 就對惹。(ゝ∀・)b」
PM:(抽出生魚片刀 ( ˘•ω•˘ ))
總結 🐟
Remeda
的lazy
版本在處理大量資料時,能夠避免不必要的計算,提升效能。- 不過當資料量小時,
lazy
反而會拖慢效能。 - 性能會因情境、runtime、瀏覽器最佳化與程式寫法而有所不同,最好應該實際跑過 benchmark 再做選擇。