這篇文章的起因是我在知乎上對JavaScript 函數語言程式設計存在效能問題麼?這個問題的回答。其實在這個問題之前挺久我就想做相關的嘗試,但懶癌無藥醫,挖坑如山倒,填坑如抽絲。
廢話不多說,走你。
C# 3.0引入了引以為豪的LINQ(Language INtergrated Query),可以用類函式式的方式操作集合(C#中的IEnumerable介面)。
在JS中,陣列也有類似的filter
、map
、reduce
一類方法,但存在重複遍歷問題,利用C#中LINQ的思路,給JS實現一套LINQ是否可行呢?
C#中的LINQ
C#中的LINQ是通過yield
來避免重複遍歷的,抽象的說,Where
(對應filter)、Select
(對應map)這類的方法呼叫的時候,都只會把操作“暫存”起來,直到呼叫了ToArray
、Aggregate
(對應reduce)之類的方法,才會“驅動”它去進行遍歷。
舉一個簡單的例子
1 2 3 4 5 |
var array = new []{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; var sum = array.Where(n => n % 2 === 0) .Select(n => n + 3) .Aggregate((sum, n) => sum + n, 0); // 程式碼不一定能編譯,我是裸寫的 |
上面是一個最基本的filter
/map
/reduce
的過程(下文也會繼續用這個例子),只有在Aggregate
呼叫的時候,才會對陣列進行遍歷,而Where
和Select
只是一些型別為IQueryable<T>
的中間過程。
C#中的LINQ得益於C#的yield
關鍵字,配合First-Class-Function可以不費吹灰之力地構建IEnumerable<T>
,而C#中的foreach
提供了對IEnumerable<T>
的語法糖,這樣就可以很自然的對LINQ的中間結果進行二次加工,而不需要繁瑣地手工呼叫.Next()
。
JS中的filter/map/reduce
JS中的原生陣列就自帶了filter
/map
/reduce
等一系列函式化的集合操作方法,但使用中有一個隱患就是,每次呼叫它們都會進行一次完整的遍歷,這樣當用這樣連寫的風格,就會造成重複遍歷
1 2 3 |
var sum = array.filter(n => n % 2 === 0) .map(n => n + 3) .reduce((sum, n) => sum + n, 0) |
上面的程式碼,在filter
和map
被呼叫的時候,都會遍歷一次陣列,reduce
的時候再遍歷一次,這樣總共就被遍歷了三次,當集合比較大的時候,這估計不是大家所想見發生的事情。
如果在filter
/map
/reduce
的回撥函式裡列印一些除錯資訊,我們會發現呼叫的次序大概會是這樣的
1 2 3 4 5 6 7 8 9 10 11 12 |
filter filter filter ... x10 map map map ... x5 reduce reduce reduce ... x5 |
JS中的LINQ
yield/generator
ES6中有了yield
和Generator Function(不熟悉的可以先回顧一下我幾百年前寫的這篇和這篇文章),並且,由於Symbol.iterator
和for of
語法的引入,能用生成器構造集合了,並且還能和for of
無縫銜接。
也就是說,ES6已經有了C#那樣優雅地實現LINQ的基礎設施,我們就來實現一個簡單的試試。
IQueryable
首先我們像C#那樣實現一個IQueryable
類,並且它通過Symbol.iterator
能夠支援被for of
遍歷
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Queryable { constructor(iterable) { this.iterable = iterable } [Symbol.iterator]() { let iterator = this.iterable[Symbol.iterator]() return iterator } } Array.prototype.asQueryable = function() { return new Queryable(this) } |
由於我們是“面向介面程式設計”的,這裡我們並不關心new Queryable(xxx)
傳入的是一個Array
、一個Generator
還是一個Queryable
,反正它們都可以被for of
遍歷。
然後為了方便,在Array.prototype
上掛了一個方法,別嫌髒,娛樂而已。
嘗試一下
1 2 3 4 |
let arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] for (let item in arr.asQueryable()) { console.log(arr) } |
filter/map/reduce
我們的Queryable
類已經可以享受for of
語法糖的便利了,然後我們就可以基於這個給它愚快地新增各種集合操作方法了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
function* _filter(iterable, predicate) { for (let item of iterable) { let checked = predicate(item) if (checked) { yield item } } } function* _map(iterable, mapper) { for (let item of iterable) { let mapped = mapper(item) yield mapped } } class Queryable { constructor(iterable) { this.iterable = iterable } [Symbol.iterator]() { let iterator = this.iterable[Symbol.iterator]() return iterator } filter(predicate) { let iterable = _filter(this, predicate) return new Queryable(iterable) } map(mapper) { let iterable = _map(this, mapper) return new Queryable(iterable) } reduce(reducer, initial) { let result = initial for (let item of this) { result = reducer(result, item) } return result } } |
這裡注意,filter
和map
分別呼叫了_filter
和_map
方法,它們返回的結果都是Generator
,我們知道一個Generator
只定義了集合如何“被遍歷”,而事實上它沒有真正發生操作,需要呼叫next()
或者for of
(也就是next()
的語法糖)來“驅動”它進行遍歷。
而reduce
當中呼叫了for of
,也就是它真正發生了遍歷。
趕緊爽一爽
1 2 3 4 |
var sum = array.asQueryable() .filter(n => n % 2 === 0) .map(n => n + 3) .reduce((sum, n) => sum + n, 0) |
如果在filter
/map
/reduce
的回撥函式裡列印一些除錯資訊,我們會發現呼叫的次序大概會是這樣的
1 2 3 4 5 |
filter/map/reduce filter filter/map/reduce filter ... |
只遍歷了一遍
擴充套件LINQ
有了上面三個方法我們可以順便構造一下length
和toArray
這類的方法,比如
1 2 3 4 5 6 7 8 9 10 |
Queryable.prototype.length = function() { return this.reduce(n => n + 1, 0) } Queryable.prototype.toArray = function() { return this.reduce((arr, it) => { arr.push(it) return arr }, []) } |
當然其實map
/reduce
都是foldl
/foldr
的具象(吃我一發安利,參考我寫的使用JavaScript實現“真·函數語言程式設計”,所以上面的那些方法其實都可以寫得更“函式式”,但既然這篇文章只是為了實驗,就不搞那麼多么蛾子了。
效能測試
我們用benchmark模組對上述程式碼進行效能測試,並且引入兩個對照組,不多說了,直接看程式碼吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
function useRawLoop() { let result = 0 for (let i = 0; i < arr.length; ++i) { let n = arr[i] if (n % 2 === 0) { n += 3 result += n } } return result } function useLoop() { let result = 0 for (let n of arr) { if (isEven(n)) { n = add3(n) result = sum(result, n) } } return result } function useArray() { return arr.filter(isEven) .map(add3) .reduce(sum, 0) } function useLINQ() { return arr.asQueryable() .filter(isEven) .map(add3) .reduce(sum, 0) } |
用長度為100的陣列進行測試,結果
1 2 3 4 |
RawLoop x 380,068 ops/sec ±1.01% (88 runs sampled) Loop x 138,121 ops/sec ±1.91% (81 runs sampled) Array x 59,682 ops/sec ±1.14% (89 runs sampled) LINQ x 17,235 ops/sec ±1.69% (87 runs sampled) |
可以看出,我們的LINQ效能非常非常的廢柴,主要原因:
- JS對Generator的優化非常廢柴
- JS對
for of
的優化非常廢柴——因為它就是Generator#next()
的語法糖
結論
雖然我們通過ES6的一系列新特性給JS實現了lazy的LINQ,避免重複遍歷,是實現了,但想象中的效能提高卻是化為泡影。
當然,通過不斷優化,減少for of
的使用,改為手工.next()
遍歷,也許效能還會高一些,但一來我不太相信它會有很明顯的變化。二來更重要的是,不用for of
的話,我們就不能實現“無痛”的集合操作程式碼編寫了,既然已經不能“無痛”,那麼同樣“痛”的方法自然有效能更優的,而且根本不需要Symbol.iterator
、Generator等等這一大堆新特性。
所以這是一個成功的嘗試,也是一個失敗的嘗試。成功之處在於很開心能看到ES6有如此強大的基礎設施用於編寫優雅程式碼,發揮創造力。失敗之處麼,自然是由於現階段的JS引擎並沒有對這些新引入的特性進行值得稱道的優化,這也提醒我對於這些新特性——至少是說,需要runtime支援的新特性——不要盲目的追新。