英文:aeflash.com
譯者:百度EFE – yaochang
網址:http://efe.baidu.com/blog/avoid-foreach/
點選“閱讀原文”可檢視本文網頁版
遍歷集合,會產生副作用。——如 mori.each 檔案所說
首先宣告,本文和效能無關。執行 for 迴圈總是比執行 Array.forEach 快。如果效能測試顯示迭代的開銷足夠顯著並且效能優先,那麼你絕對應該使用 for 迴圈而不是 forEach(總是使用 for 迴圈是典型的過早最佳化。forEach 仍然可以在 1 微秒內遍歷長度為 50 的陣列)。本文和編碼風格有關,是我對 forEach 和其它 Array.prototype 方法的思考,與效能無關。
forEach 為副作用而生
當人們想要把程式碼重構成一個更加實用的風格時,往往首選 [].forEach 或 _.each。forEach 直接模擬最基本的 for 迴圈——遍歷陣列並且執行一些操作——所以它是一個很機械的轉換。但是就像 for 迴圈一樣,forEach 在程式的某個地方必定會產生副作用。它必須修改父作用域中的物件,或者呼叫一個外部方法,或者使用迭代函式以外的其它變數。使用 forEach 也意味著你的迭代函式和它所在的作用域產生了耦合。
在程式設計中,我們通常認為副作用是不好的。他們使程式更難理解,可能導致 bug 的產生,而且難以重構。當然,forEach 在大專案中引起的副作用是微不足道的,但是這些副作用是不必要的。
當然也有一些副作用是無法避免的。
arr.forEach(function (item) {
console.log(item);
});
這種情況完全可以接受。
forEach 隱藏了迭代的意圖
閱讀 forEach 程式碼段的時候,你並不能馬上知道它的作用,只知道它會在某個地方產生副作用,然後必須閱讀這段程式碼或者註釋才明白。這是一個非語意的方法。
除了 forEach,還有更好的迭代方法。比如 map——在使用迭代函式以後會傳回一個新陣列;比如 filter——傳回由符合條件的元素組成的新陣列;比如 some(或者 _.any)——如果陣列中至少有一個元素滿足要求時傳回 true;比如 every(或者_.all)——如果陣列中所有元素滿足要求時傳回 true;比如 reduce——遍歷陣列並且使用陣列中的所有元素進行某種操作迭代生成一個新的變數,陣列中的很多方法都可以用 reduce 來實現。ES5 的陣列方法非常強大,希望你對此並不陌生。Lodash/Underscore 庫增強了 ES5 的方法,增加了很多有用且語意化的迭代函式(此外還提供了可用於物件的陣列原型方法的更優實現)。
重構
下麵是一些實際專案中使用 each 的例子,看看如何更好地重構它們。
例 1
var obj = {};
arr.forEach(function (item) {
obj[item.key] = item;
});
這是一個很常見的操作——將陣列轉換為物件。由於迭代函式依賴 obj,所以 forEach 跟它所在的作用域耦合在一起。迭代函式不能在它的閉包作用域之外執行。我們換個方式來重寫它:
var obj = arr.reduce(function (newObj, item) {
newObj[item.key] = item;
return newObj;
}, {});
現在歸併函式只依賴於它的形參,沒有別的。reduce 無副作用——遍歷集合,並且只產出一個東西。它是 ES5 方法中最不語意的方法,但它很靈活,可以用來實現所有其餘的函式。
Lodash 還有更語意化的寫法:
var obj = _.zipObject(_.pluck(arr, ‘key’), arr);
這裡需要遍歷2次,但是看起來更直觀。
譯者註:實際上有更好的方法
var obj = _.indexBy(arr, ‘key’);
例 2
var replacement = ‘foo’;
var replacedUrls = urls;
urls.forEach(function replaceToken(url, index) {
replacedUrls[index] = url.replace(‘{token}’, replacement);
});
用 map 重構:
var replacement = ‘foo’;
var replacedUrls;
replacedUrls = urls.map(function (url) {
return url.replace(‘{token}’, replacement);
});
map 函式仍然依賴於 replacement 的作用域,但是迭代的意圖更加清晰。前一種方法改變了 urls 陣列,而 map 函式則分配了一個新的陣列。需要註意的是,它對 urls 的修改不易被察覺,其它地方可能仍然期望 urls 中會含有 {token}。採用分配新陣列的方法可以防止這個小細節引發的問題,代價就是需要多一點記憶體開銷。
例 3
var html = ”;
var self = this;
_.each(this.values, function (value) {
var id = ‘radio_button_’ + self.groupName + ‘_’ + value.id;
html += ”
+ ‘
‘ + ‘‘+ ‘‘ + ‘
‘;
if (!touchEnabled) {
var tooltip = value.getTooltip();
if (tooltip) {
self.tooltips.push(tooltip);
}
}
});
這個例子稍微複雜一點。這段程式碼實際上做了兩件事:拼接 HTML 字串,為每一個 value 建立 tooltips。其實迭代函式沒必要這麼複雜——或者如 Rich Hickey 所說的 「complected」。它將兩種操作放在一個函式裡,而實際上沒有必要。第一部分操作是典型的reduce 函式的應用範圍,所以我們把這兩部分操作分開:
var html;
var self = this;
html = _.reduce(this.values, function (str, value) {
var id = ‘radio_button_’ + self.groupName + ‘_’ + value.id;
str += ”
+ ‘
‘ + ‘‘+ ‘‘ + ‘
‘;
return str;
}, ”);
_.each(this.values, function (value) {
if (!touchEnabled) {
var tooltip = value.getTooltip();
if (tooltip) {
self.tooltips.push(tooltip);
}
}
});
現在第一部分就可以接受了。在 values 上迭代,最後生成 HTML 字串。它仍然依賴於 self.groupName,不過可以透過偏函式(partial application)來避免。
譯者註:Underscore 中提供了偏函式 _.partial 可以幫助我們解決這個問題,相應的程式碼如下:
var part = _.partial(function (groupName, str, value) {
// ….
}, self.groupName);
_.reduce(this.values, part, ”);
現在來看一下第二部分。如果 touchEnabled 為假,可以得到 tooltip。這時不確定會不會傳回一個有效的 tooltip,因此將每個實體對應的 tooltip 放進陣列中的操作是帶條件的。我們可以把多個方法串聯起來解決這個問題:
if (!touchEnabled) {
this.tooltips = this.tooltips.concat(
this.values
.map(function (value) {
return value.getTooltip();
})
.filter(_.identity)
);
}
我們將 touch 檢查移到迴圈的外面,因為只需要檢查一次就夠了。然後對集合使用 map 方法,在每次迭代中呼叫 getTooltip(),然後過濾掉不符合條件的值。最後結果合併到 tooltips 陣列。這種方法每次都會建立臨時陣列,但是正如我在其它例子中所說的,表達清晰更重要。
你可以定義一個輔助函式把上面的行內函式去掉:
var dot = _.curry(function (methodName, object) {
return object[methodName]();
});
// …
this.tooltips = this.tooltips.concat(
this.values
.map(dot(‘getTooltip’))
.filter(_.identity)
);
這樣更簡潔直觀。
譯者註:這裡其實可以直接用 _.invoke 函式和 _.union 函式,更加簡潔。
this.tooltips = _.union(this.tooltips, _.filter(_.invoke(this.values, ‘getTooltip’), _.identity));
例 4
var matches = [];
var nonMatches = [];
values.forEach(function(value) {
if (matchesSomething(value)) {
matches.push(value);
}
else {
nonMatches.push(value);
}
});
這個例子看起來很簡單——基於判斷條件將陣列拆分成兩個。但還不夠簡單。我會這樣重寫:
var matches = values.filter(matchesSomething);
var nonMatches = values.filter(not(matchesSomething));
迭代函式實際上在做兩件事,拆分成兩個迭代函式更加清晰。如果確實有成千上萬的值,或者 matchesSomething 操作非常耗時,我會保留第一種方案。
譯者註:這段程式碼其實可以用 reduce 加以改進:
var result = values.reduce(function (result, value) {
if (matchesSomething(value)) {
result.matches.push(value);
}
else {
result.nonMatches.push(value);
}
}, {matches: [], nonMatches: []});
重構時你會發現多了些東西,如果這些東西使程式更簡單,那就沒問題。多個簡單的東西組合起來會比一個大而複雜的東西更容易理解。
轉換器
讓我們再看一下例 3 的最終程式碼:
this.tooltips = this.tooltips.concat(
this.values
.map(dot(‘getTooltip’))
.filter(_.identity)
);
map 和 filter 的串聯操作意味著臨時陣列的建立和刪除。對於元素較少的陣列來說是可以接受的,額外的開銷可以忽略不計。但是如果這個陣列包含了數千個元素,或者要做很多次的對映和過濾操作呢?這時單一的迭代函式又變得誘人了。
幸運的是,隨著 Transducers(其實是 transform 和 reducer 的合成詞,transform 是變換,reducer 就是 JS 中的 reducer)的出現,你可以任性地將很多 map 和 filter 函式放在一個迭代中。也就是說,先在第一個元素上應用所有變換(map 和 filter 或者其它),然後依次處理其它元素。本文中不會深入研究 Transducers 的原理(這裡有關於它的介紹),不過經過 Transducers 改造以後會是這樣:
var t = require(‘transducers-js’);
var tooltips = t.transduce(
t.comp( // 變換函式
t.map(dot(‘getTooltip’)),
t.filter(_.identity),
// 想加多少map和filter就加多少
t.map(someFunction),
t.filter(somePredicate)
),
concat, // reducer
[], // 初始值
values // 輸入
);
它看上去有點不一樣,和 reduce 類似,但是它只涉及到一個迭代器,而且只分配了一個唯一的陣列。你想加入多少 map 和filter,就加入多少,它只會迭代一次。透過使用其它函式替換 concat,你也可以讓它傳回任何型別的結果。如果你想瞭解更多,那就深入地研究一下 Transducers 吧。
譯者註:有了 ES6 的 Generator,這事就是原生支援的了。
總結
- forEach 總會產生副作用。如果你想避免產生副作用,那就不要使用它了。
- forEach 隱藏了迭代的意圖。推薦使用更加語意化的迭代方法,例如 map、filter 和 some。
- 如果每次迭代包含了太多操作,將它們拆分到不同的函式中。
- 透過多個方法的串聯呼叫,將不同的資料轉換隔離開來。如果效能不可接受,那就使用 Transducers 重構它。
- 改造後的程式最終會多了操作,但是如果你處理得當,那麼每一步都會更容易理解。
- 如果你確實需要迴圈產生的副作用,完全可以用 forEach。
- 最後,如果效能測試表明更加語意化的迭代函式是效能瓶頸或者被頻繁執行, 那就用 for 迴圈好了。