之前有讨論過, 縮進(非常粗魯地)增加了代碼複雜性
。我們的目标是寫出複雜度低的 JavaScript 代碼。通過選擇一種合适的抽象來解決這個問題,可是你怎麼能知道選擇哪一種抽象呢?很遺憾的是到目前為止,沒有找到一個具體的例子能回答這個問題。這篇文章中我們讨論不用任何循環如何處理 JavaScript 數組,最終得出的效果是可以降低代碼複雜性。
循環是一種很重要的控制結構,它很難被重用,也很難插入到其他操作之中。另外,它意味着随着每次疊代,代碼也在不斷的變化之中。——Luis Atencio
循環
我們先前說過,像循環這樣的控制結構引入了複雜性。但是也沒有給出确切的證據證明這一點,我們先看看 JavaScript 中循環的工作原理。
在 JavaScript 中,至少有四、五種實作循環的方法,最基礎的是
while
循環。我們首先先建立一個示例函數和數組:
// oodlify :: String -> String
function oodlify(s) {
return s.replace(/[aeiou]/g, 'oodle');
}
const input = [
'John',
'Paul',
'George',
'Ringo',
];
現在有了一個數組,我們想要用 oodlify 函數處理每一個元素。如果用
while
循環,就類似于這樣:
let i = 0;
const len = input.length;
let output = [];
while (i < len) {
let item = input[i];
let newItem = oodlify(item);
output.push(newItem);
i = i + 1;
}
注意這裡發生的事情,我們用了一個初始值為 0 的計數器 i,每次循環都會自增。而且每次循環中都和 len 進行比較以保證循環特定次數以後終止循環。這種利用計數器進行循環控制的模式太常用了,是以 JavaScript 提供了一種更加簡潔的寫法:
for
循環,寫起來如下:
const len = input.length;
let output = [];
for (let i = 0; i < len; i = i + 1) {
let item = input[i];
let newItem = oodlify(item);
output.push(newItem);
}
這一結構非常有用,
while
循環非常容易把自增的 i 給忘掉,進而引起無限循環;而
for
循環把和計數器相關的代碼都放到了上面,這樣你就不會忘掉自增 i,這确實是一個很好的改進。現在回到原來的問題,我們目标是在數組的每個元素上運作 oodlify() 函數,并且将結果放到一個新的數組中。
對一個數組中每個元素都進行操作的這種模式也是非常普遍的。是以在 ES2015 中,引入了一種新的循環結構可以把計數器也簡化掉:
for...of
循環。每一次傳回數組的下一個元素給你,代碼如下:
let output = [];
for (let item of input) {
let newItem = oodlify(item);
output.push(newItem);
}
這樣就清晰很多了,注意這裡計數器和比較都不用了,你甚至都不用把元素從數組裡面取出來。
for...of
幫我們做了裡面的髒活累活。如果現在用
for...of
來代替所有的
for
循環,其實就可以很大程度上降低複雜性。但是,我們還可以做進一步的優化。
mapping
for...of
循環比
for
循環更清晰,但是依然需要一些配置性的代碼。如不得不初始化一個 output 數組并且每次循環都要調用 push() 函數。但有辦法可以讓代碼更加簡潔有力,我們先擴充一下問題。
如果有兩個數組需要調用 oodlify 函數會怎麼樣?
const fellowship = [
'frodo',
'sam',
'gandalf',
'aragorn',
'boromir',
'legolas',
'gimli',
];
const band = [
'John',
'Paul',
'George',
'Ringo',
];
很容易想到的方法是對每個數組都做循環:
let bandoodle = [];
for (let item of band) {
let newItem = oodlify(item);
bandoodle.push(newItem);
}
let floodleship = [];
for (let item of fellowship) {
let newItem = oodlify(item);
floodleship.push(newItem);
}
這确實ok,有能正确執行的代碼,就比沒有好。但是重複的代碼太多了——不夠“
DRY”。我們來重構它以降低重複性,建立一個函數:
function oodlifyArray(input) {
let output = [];
for (let item of input) {
let newItem = oodlify(item);
output.push(newItem);
}
return output;
}
let bandoodle = oodlifyArray(band);
let floodleship = oodlifyArray(fellowship);
這看起來好多了,可是如果我們想使用另外一個函數該怎麼辦?
function izzlify(s) {
return s.replace(/[aeiou]+/g, 'izzle');
}
上面的 oodlifyArray() 一點用都沒有了。但如果再建立一個 izzlifyArray() 函數的話,代碼又重複了。不管那麼多,先寫出來看看什麼效果:
function oodlifyArray(input) {
let output = [];
for (let item of input) {
let newItem = oodlify(item);
output.push(newItem);
}
return output;
}
function izzlifyArray(input) {
let output = [];
for (let item of input) {
let newItem = izzlify(item);
output.push(newItem);
}
return output;
}
這兩個函數驚人的相似。那麼是不是可以把它們抽象成一個通用的模式呢?我們想要的是:給定一個函數和一個數組,通過這個函數,把數組中的每一個元素做操作後放到新的數組中。我們把這個模式叫做 map 。一個數組的 map 函數如下:
function map(f, a) {
let output = [];
for (let item of a) {
output.push(f(item));
}
return output;
}
這裡還是用了循環結構,如果想要完全擺脫循環的話,可以做一個遞歸的版本出來:
function map(f, a) {
if (a.length === 0) { return []; }
return [f(a[0])].concat(map(f, a.slice(1)));
}
遞歸解決方法非常優雅,僅僅用了兩行代碼,幾乎沒有縮進。但是通常并不提倡于在這裡使用遞歸,因為在較老的浏覽器中的遞歸性能非常差。實際上,map 完全不需要你自己去手動實作(除非你自己想寫)。map 模式很常用,是以 JavaScript 提供了一個内置 map 方法。使用這個 map 方法,上面的代碼變成了這樣:
let bandoodle = band.map(oodlify);
let floodleship = fellowship.map(oodlify);
let bandizzle = band.map(izzlify);
let fellowshizzle = fellowship.map(izzlify);
可以注意到,縮進消失,循環消失。當然循環可能轉移到了其他地方,但是我們已經不需要去關心它們了。現在的代碼簡潔有力,完美。
為什麼這個代碼這麼簡單呢?這可能是個很傻的問題,不過也請思考一下。是因為短嗎?不是,簡潔并不代表不複雜。它的簡單是因為我們把問題分離了。有兩個處理字元串的函數: oodlify 和 izzlify,這些函數并不需要知道關于數組或者循環的任何事情。同時,有另外一個函數:map ,它來處理數組,它不需要知道數組中元素是什麼類型的,甚至你想對數組做什麼也不用關心。它隻需要執行我們所傳遞的函數就可以了。把對數組的進行中和對字元串的處理分離開來,而不是把它們都混在一起。這就是為什麼說上面的代碼很簡單。
reducing
現在,map 已經得心應手了,但是這并沒有覆寫到每一種可能需要用到的循環。隻有當你想建立一個和輸入數組同樣長度的數組時才有用。但是如果你想要向數組中增加幾個元素呢?或者想找一個清單中的最短字元串是哪個?其實有時我們對數組進行處理,最終隻想得到一個值而已。
來看一個例子,現在一個數組裡面存放了一堆超級英雄:
const heroes = [
{name: 'Hulk', strength: 90000},
{name: 'Spider-Man', strength: 25000},
{name: 'Hawk Eye', strength: 136},
{name: 'Thor', strength: 100000},
{name: 'Black Widow', strength: 136},
{name: 'Vision', strength: 5000},
{name: 'Scarlet Witch', strength: 60},
{name: 'Mystique', strength: 120},
{name: 'Namora', strength: 75000},
];
現在想找最強壯的超級英雄。使用
for...of
循環,像這樣:
let strongest = {strength: 0};
for (hero of heroes) {
if (hero.strength > strongest.strength) {
strongest = hero;
}
}
雖然這個代碼可以正确運作,可是實在太爛了。看這個循環,每次都儲存到目前為止最強的英雄。繼續提需求,接下來我們想要所有超級英雄的總強度:
let combinedStrength = 0;
for (hero of heroes) {
combinedStrength += hero.strength;
}
在這兩個例子中,都在循環開始之前初始化了一個變量。然後在每一次的循環中,處理一個數組元素并且更新這個變量。為了使這種循環套路變得更加明顯一點,現在把數組中間的部分抽離到一個函數當中。并且重命名這些變量,以進一步突出相似性。
function greaterStrength(champion, contender) {
return (contender.strength > champion.strength) ? contender : champion;
}
function addStrength(tally, hero) {
return tally + hero.strength;
}
const initialStrongest = {strength: 0};
let working = initialStrongest;
for (hero of heroes) {
working = greaterStrength(working, hero);
}
const strongest = working;
const initialCombinedStrength = 0;
working = initialCombinedStrength;
for (hero of heroes) {
working = addStrength(working, hero);
}
const combinedStrength = working;
用這種方式來寫,兩個循環變得非常相似了。它們兩個之間唯一的差別是調用的函數和初始值不同。兩個的功能都是對數組進行處理,最終得到一個值。是以,我們建立一個 reduce 函數來封裝這個模式。
function reduce(f, initialVal, a) {
let working = initialVal;
for (item of a) {
working = f(working, item);
}
return working;
}
reduce 模式在 JavaScript 中也是很常用的,是以 JavaScript 為數組提供了内置的方法,不需要自己來寫。通過内置方法,代碼就變成了:
const strongestHero = heroes.reduce(greaterStrength, {strength: 0});
const combinedStrength = heroes.reduce(addStrength, 0);
ok,如果足夠細心的話,你會注意到上面的代碼其實并沒有短很多。不過也确實比自己手寫的 reduce 代碼少寫了幾行。但是我們的目标并不是使代碼變短或者少寫,而是降低代碼複雜度。現在的複雜度降低了嗎?我會說是的。把處理每個元素的代碼和處理循環代碼分離開來了,這樣代碼就不會互相糾纏在一起了,降低了複雜度。
reduce 方法乍一看可能覺得非常基礎。我們舉的 reduce 大部分也比如做加法這樣的簡單例子。但是沒有人說 reduce 方法隻能傳回基本類型,它可以是一個 object 類型,甚至可以是另一個數組。當我第一次意識到這個問題的時候,自己也是豁然開朗。是以其實可以用 reduce 方法來實作 map 或者 filter,這個留給讀者自己做練習。
filtering
現在我們有了 map 處理數組中的每個元素,有了 reduce 可以處理數組最終得到一個值。但是如果想擷取數組中的某些元素該怎麼辦?我們來進一步探索,現在增加一些屬性到上面的超級英雄數組中:
const heroes = [
{name: 'Hulk', strength: 90000, sex: 'm'},
{name: 'Spider-Man', strength: 25000, sex: 'm'},
{name: 'Hawk Eye', strength: 136, sex: 'm'},
{name: 'Thor', strength: 100000, sex: 'm'},
{name: 'Black Widow', strength: 136, sex: 'f'},
{name: 'Vision', strength: 5000, sex: 'm'},
{name: 'Scarlet Witch', strength: 60, sex: 'f'},
{name: 'Mystique', strength: 120, sex: 'f'},
{name: 'Namora', strength: 75000, sex: 'f'},
];
ok,現在有兩個問題,我們想要:
- 找到所有的女性英雄;
- 找到所有能量值大于500的英雄。
使用普通的
for...of
循環,會得到如下代碼:
let femaleHeroes = [];
for (let hero of heroes) {
if (hero.sex === 'f') {
femaleHeroes.push(hero);
}
}
let superhumans = [];
for (let hero of heroes) {
if (hero.strength >= 500) {
superhumans.push(hero);
}
}
邏輯嚴密,看起來還不錯?但是裡面又出現了重複的情況。實際上,差別在于
if
的判斷語句,那麼能不能把
if
語句重構到一個函數中呢?
function isFemaleHero(hero) {
return (hero.sex === 'f');
}
function isSuperhuman(hero) {
return (hero.strength >= 500);
}
let femaleHeroes = [];
for (let hero of heroes) {
if (isFemaleHero(hero)) {
femaleHeroes.push(hero);
}
}
let superhumans = [];
for (let hero of heroes) {
if (isSuperhuman(hero)) {
superhumans.push(hero);
}
}
這種隻傳回
true
或者
false
的函數,我們一般把它稱作斷言(predicate)函數。這裡用了斷言(predicate)函數來判斷是否需要保留目前的英雄。
上面代碼的寫法會看起來比較長,但是把斷言函數抽離出來,可以讓重複的循環代碼更加明顯。現在把種循環抽離到一個函數當中。
function filter(predicate, arr) {
let working = [];
for (let item of arr) {
if (predicate(item)) {
working = working.concat(item);
}
}
}
const femaleHeroes = filter(isFemaleHero, heroes);
const superhumans = filter(isSuperhuman, heroes);
同 map 和 reduce 一樣,JavaScript 提供了一個内置數組方法,沒必要自己來實作(除非你自己想寫)。用内置數組方法,上面的代碼就變成了:
const femaleHeroes = heroes.filter(isFemaleHero);
const superhumans = heroes.filter(isSuperhuman);
為什麼這段代碼比
for...of
循環好呢?回想一下整個過程,我們要解決一個“找到滿足某一條件的所有英雄”。使用 filter 使得問題變得簡單化了。我們需要做的就是通過寫一個簡單函數來告訴 filter 哪一個數組元素要保留。不需要考慮數組是什麼樣的,以及繁瑣的中間變量。取而代之的是一個簡單的斷言函數,僅此而已。
與其他的疊代函數相比,使用 filter 是一個四兩撥千斤的過程。我們不需要通讀循環代碼來了解到底要過濾什麼,要過濾的東西就在傳遞給它的那個函數裡面。
finding
filter 已經信手拈來了吧。這時如果隻想找一個英雄該怎麼辦?比如找 “Black Widow”。使用 filter 會這樣寫:
function isBlackWidow(hero) {
return (hero.name === 'Black Widow');
}
const blackWidow = heroes.filter(isBlackWidow)[0];
這段代碼的問題是效率不夠高。filter 會檢查數組中的每一個元素,而我們知道這裡面隻有一個 “Black Widow”,當找到她的時候就可以停住,不用再看後面的元素了。那麼,依舊利用斷言函數,我們寫一個 find 函數來傳回第一次比對上的元素。
function find(predicate, arr) {
for (let item of arr) {
if (predicate(item)) {
return item;
}
}
}
const blackWidow = find(isBlackWidow, heroes);
同樣地,JavaScript 已經提供了這樣的方法:
const blackWidow = heroes.find(isBlackWidow);
find 再次展現了四兩撥千斤的特點。通過 find 方法,把問題簡化為:你隻要關注如何判斷你要找的東西就可以了,不必關心疊代到底怎麼實作等細節問題。
總結
這些疊代函數的例子很好地诠釋“抽象”的作用和優雅。回想一下我們所講的内置方法,每個例子中我們都做了三件事:
- 消除了循環結構,使得代碼變的簡潔易讀;
- 通過适當的方法名稱來描述我們使用的模式,也就是:map,reduce,filter 和 find;
- 把問題從處理整個數組簡化到處理每個元素。
注意在每一種情況下,我們都用幾個純函數來分解問題和解決問題。真正令人興奮的是通過僅僅這麼四種模式模式(當然還有其他的模式,也建議大家去學習一下),在 JS 代碼中你就可以消除幾乎所有的循環了。這是因為 JS 中幾乎每個循環都是用來處理數組,或者生成數組的。通過消除循環,降低了複雜性,也使得代碼的可維護性更強。
原文釋出時間為:2017年02月22日
原文作者:胡子大哈
本文來源:
掘金 https://juejin.im/entry/5b3a29f95188256228041f46如需轉載請聯系原作者