譯者按: 有時候一個演算法的直觀、簡潔、高效是需要作出取捨的。
本文采用意譯,版權歸原作者所有
函數語言程式設計中用於運算元組的方法就像“毒品”一樣,它讓很多人愛上函數語言程式設計。因為它們真的十分常用而且又超級簡單。 .map()
和 .filter()
都僅需一個引數,該引數定義運算元組每一個元素的函式即可。reduce()
會複雜一些,我之前寫過一篇文章介紹為什麼人們難以掌握reduce()
方法,其中一個原因在於很多入門資料都僅僅用算術作為例子。我寫了很多用reduce()
來做算術以外的例子。
用reduce()
來計算陣列的平均值是一個常用的模式。程式碼看起來非常簡單,不過在計算最終結果之前你需要做兩個準備工作:
- 陣列的長度
- 陣列所有元素之和
這兩個事情看起來都很簡單,那麼計算陣列的平均值並不是很難了吧。解法如下:
function average(nums) {
return nums.reduce((a, b) => a + b) / nums.length;
}
複製程式碼
確實不是很難,是吧?但是如果資料結構變得複雜了,就沒那麼簡單了。比如,陣列裡面的元素是物件,你需要先過濾掉某些物件,然後從物件中取出數字。這樣的場景讓計算平均值變得複雜了一點。
接下來我們處理一個類似的問題(從this Free Code Camp challenge獲得靈感),我們會提供 5 種不同的解法,每一種方法有各自的優點和缺點。這 5 種方法也展示了 JavaScript 的靈活。我希望可以給你在使用reduce
的實戰中一些靈感。
問題提出
假設我們有一個陣列,記錄了維多利亞時代常用的口語。接下來我們要找出那些依然現存於 Google Books 中的詞彙,並計算他們的平均流行度。資料的格式是這樣的:
const victorianSlang = [
{
term: "doing the bear",
found: true,
popularity: 108
},
{
term: "katterzem",
found: false,
popularity: null
},
{
term: "bone shaker",
found: true,
popularity: 609
},
{
term: "smothering a parrot",
found: false,
popularity: null
},
{
term: "damfino",
found: true,
popularity: 232
},
{
term: "rain napper",
found: false,
popularity: null
},
{
term: "donkey’s breakfast",
found: true,
popularity: 787
},
{
term: "rational costume",
found: true,
popularity: 513
},
{
term: "mind the grease",
found: true,
popularity: 154
}
];
複製程式碼
接下來我們用 5 中不同的方法計算平均流行度值。
1. for 迴圈
初次嘗試,我們不使用reduce()
。如果你對陣列的常用函式不熟悉,用 for 迴圈可以讓你更好地理解我們要做什麼。
let popularitySum = 0;
let itemsFound = 0;
const len = victorianSlang.length;
let item = null;
for (let i = 0; i < len; i++) {
item = victorianSlang[i];
if (item.found) {
popularitySum = item.popularity + popularitySum;
itemsFound = itemsFound + 1;
}
}
const averagePopularity = popularitySum / itemsFound;
console.log("Average popularity:", averagePopularity);
複製程式碼
如果你熟悉 JavaScript,上面的程式碼理解起來應該很容易:
- 初始化
polularitySum
和itemsFound
變數。popularitySum
記錄總的流行度值,itemsFound
記錄我們已經找到的所有的條目; - 初始化
len
和item
來幫助我們遍歷陣列; - for 迴圈每一次增加
i
的值,直到迴圈n
次; - 在迴圈中,我們每次取出當前索引位置的條目
vitorianSlang[i]
; - 檢查該條目是否在 Google Books 中
- 如果在,獲取
popularity
並累加到popularitySum
; - 並遞增
itemsFound
; - 最後,用
popularitySum
除以itemsFound
來計算平均值。
程式碼雖然不是那麼簡潔,但是順利完成了任務。使用陣列迭代方法可以更加簡潔,接下來開始吧…..
2. 簡單模式: filter, map 和 sum
我們首先將這個問題拆分成幾個子問題:
- 使用
fitler()
找到那些在 Google Books 中的條目; - 使用
map()
獲取流行度; - 使用
reuduce()
來計算總的流行度; - 計算平均值。
下面是實現程式碼:
// 輔助函式
// ----------------------------------------------------------------------------
function isFound(item) {
return item.found;
}
function getPopularity(item) {
return item.popularity;
}
function addScores(runningTotal, popularity) {
return runningTotal + popularity;
}
// 計算
// ----------------------------------------------------------------------------
// 找出所有isFound為true的條目
const foundSlangTerms = victorianSlang.filter(isFound);
// 從條目中獲取流行度值,返回為陣列
const popularityScores = foundSlangTerms.map(getPopularity);
// 求和
const scoresTotal = popularityScores.reduce(addScores, 0);
// 計算平均值
const averagePopularity = scoresTotal / popularityScores.length;
console.log("Average popularity:", averagePopularity);
複製程式碼
注意看addScores
函式以及呼叫reduce()
函式的那一行。addScores()
接收兩個引數,第一個runningTotal
,我們把它叫做累加數,它一直記錄著累加的總數。每訪問陣列中的一個條目,我們都會用addScores
函式來更新它的值。第二個引數popularity
是當前某個元素的值。注意,第一次呼叫的時候,我們還沒有runningTotal
的值,所以在呼叫reduce()
的時候,我們給runningTotal
初始化。也就是reduce()
的第二個引數。
這個版本的程式碼簡潔很多了,也更加的直觀。我們不再告訴 JavaScript 引擎如何迴圈,如何對當前索引的值做操作。我們定義了很多小的輔助函式,並且把它們組合起來完成任務。filter()
,map()
和reduce()
幫我們做了很多工作。上面的實現更加直觀地告訴我們這段程式碼要做什麼,而不是底層如何去實現。
3. 簡單模式 II: 記錄多個累加值
在之前的版本中,我們建立了很多中間變數:foundSlangTerms
,popularityScores
。接下來,我們給自己設一個挑戰,使用鏈式操作,將所有的函式呼叫組合起來,不再使用中間變數。注意:popularityScores.length
變數需要用其它的方式來獲取。我們可以在addScores
的累加引數中記錄它。
// 輔助函式
// ---------------------------------------------------------------------------------
function isFound(item) {
return item.found;
}
function getPopularity(item) {
return item.popularity;
}
// 我們使用一個物件來記錄總的流行度和條目的總數
function addScores({ totalPopularity, itemCount }, popularity) {
return {
totalPopularity: totalPopularity + popularity,
itemCount: itemCount + 1
};
}
// 計算
// ---------------------------------------------------------------------------------
const initialInfo = { totalPopularity: 0, itemCount: 0 };
const popularityInfo = victorianSlang
.filter(isFound)
.map(getPopularity)
.reduce(addScores, initialInfo);
const { totalPopularity, itemCount } = popularityInfo;
const averagePopularity = totalPopularity / itemCount;
console.log("Average popularity:", averagePopularity);
複製程式碼
我們在reduce
函式中使用物件來記錄了totalPopularity
和itemCount
。在addScores
中,每次都更新itemCount
的計數。
通過filter
,map
和reduce
計算的最終的結果儲存在popularityInfo
中。你甚至可以繼續簡化上述程式碼,移除不必要的中間變數,讓最終的計算程式碼只有一行。
4. point-free 式函式組合
注意: 如果你不熟悉函式式語言或則覺得難以理解,請跳過這部分!
如果你熟悉curry()
和compose()
,接下來的內容就不難理解。如果你想知道更多,可以看看這篇文章: ‘A Gentle Introduction to Functional JavaScript’. 特別是第三部分 。
我們可以使用compose
函式來構建一個完全不帶任何變數的程式碼,這就叫做point-free
的方式。不過,我們需要一些幫助函式。
// 輔助函式
// ----------------------------------------------------------------------------
const filter = p => a => a.filter(p);
const map = f => a => a.map(f);
const prop = k => x => x[k];
const reduce = r => i => a => a.reduce(r, i);
const compose = (...fns) => arg => fns.reduceRight((arg, fn) => fn(arg), arg);
// The blackbird combinator.
// See: https://jrsinclair.com/articles/2019/compose-js-functions-multiple-parameters/
const B1 = f => g => h => x => f(g(x))(h(x));
// 計算
// ----------------------------------------------------------------------------
// 求和函式
const sum = reduce((a, i) => a + i)(0);
// 計算陣列長度的函式
const length = a => a.length;
// 除法函式
const div = a => b => a / b;
// 我們使用compose()來將函式組合起來
// compose()的引數你可以倒著讀,來理解程式的含義
const calcPopularity = compose(
B1(div)(sum)(length),
map(prop("popularity")),
filter(prop("found"))
);
const averagePopularity = calcPopularity(victorianSlang);
console.log("Average popularity:", averagePopularity);
複製程式碼
我們在compose
中做了所有的計算。從後往前看,首先filter(prop('found'))
篩選出所有在 Google Books 中的條目,然後通過map(prop('popularity'))
獲取所有的流行度數值,最後使用 magical blackbird (B1
) combinator 來對同一個輸入進行sum
和length
的計算,並求得平均值。
// All the lines below are equivalent:
const avg1 = B1(div)(sum)(length);
const avg2 = arr => div(sum(arr))(length(arr));
const avg3 = arr => sum(arr) / length(arr);
const avg4 = arr => arr.reduce((a, x) => a + x, 0) / arr.length;
複製程式碼
不要擔心看不明白,上面主要是為大家演示有 4 種方式來實現average
功能。這就是 JavaScript 的優美之處。
相對來說,本文的內容是有點極客的。雖然筆者之前深度使用函式式語言 Haskell 做過不少研究專案,對函式式頗有理解,但是 point-free 風格的程式碼,我們是不建議在實際工程中使用的,維護成本會很高。我們Fundebug所有的程式碼都要求直觀易懂,不推崇用一些奇淫技巧來實現。除非某些萬不得已的地方,但是一定要把註釋寫得非常清楚,來降低後期的維護成本。
5. 終極優化: 一次計算出結果
之前所有的解法都可以很好地工作。那些使用reduce()
的解法都有一個共同點,它們將大的問題拆解問小的子問題,然後通過不同的方式將它們組合起來。但是也要注意它們對陣列遍歷了三次,感覺很沒有效率。如果一次就可以計算出來,才是最佳的方案。確實可以,不過需要一點數學運算。
為了計算 n 個元素的平均值,我們使用下面的公式:
那麼,計算 n+1 個元素的平均值,使用同樣的公式(唯一不同的是 n 變成 n+1):
它等同於:
同樣等同於:
做點變換:
結論是,我們可以一直記錄當前狀態下的所有滿足條件的元素的平均值。只要我們知道之前所有元素的平均值和元素的個數。
// 求平均值
function averageScores({ avg, n }, slangTermInfo) {
if (!slangTermInfo.found) {
return { avg, n };
}
return {
avg: (slangTermInfo.popularity + n * avg) / (n + 1),
n: n + 1
};
}
const initialVals = { avg: 0, n: 0 };
const averagePopularity = victorianSlang.reduce(averageScores, initialVals).avg;
console.log("Average popularity:", averagePopularity);
複製程式碼
這個方法只需要遍歷一次就計算出平均值,缺點是我們做了更多的計算。每一次當元素滿足條件,都要做乘法和除法,而不是最後才做一次除法。不過,它使用了更少的記憶體,因為沒有中間的陣列變數,我們只是記錄了一個僅僅有兩個元素的物件。
這樣寫還有一個缺點,程式碼一點都不直觀,後續維護麻煩。至少一眼看過去不能理解它是做什麼的。
所以,到底哪一種方案才是最好的呢?視情形而定。也許你有一個很大的陣列要處理,也許你的程式碼需要在記憶體很小的硬體上跑。在這些場景下,使用第 5 個方案最佳。如果效能不是問題,那麼就算使用最低效的方法也沒問題。你需要選擇最適合的。
還有一些聰明的朋友會思考:是否可以將問題拆解為子問題,仍然只遍歷一次呢?是的,確實有。需要使用 transducer。
關於Fundebug
Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了10億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等眾多品牌企業。歡迎大家免費試用!
版權宣告
轉載時請註明作者Fundebug以及本文地址: blog.fundebug.com/2019/06/05/…