無迴圈 JavaScript

鬍子大哈發表於2019-03-01

作者:James Sinclair

編譯:鬍子大哈

翻譯原文:huziketang.com/blog/posts/…

英文原文:JAVASCRIPT WITHOUT LOOPS

轉載請註明出處,保留原文連結以及作者資訊


之前有討論過,縮排(非常粗魯地)增加了程式碼複雜性。我們的目標是寫出複雜度低的 JavaScript 程式碼。通過選擇一種合適的抽象來解決這個問題,可是你怎麼能知道選擇哪一種抽象呢?很遺憾的是到目前為止,沒有找到一個具體的例子能回答這個問題。這篇文章中我們討論不用任何迴圈如何處理 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);複製程式碼

可以注意到,縮排消失,迴圈消失。當然迴圈可能轉移到了其他地方,但是我們已經不需要去關心它們了。現在的程式碼簡潔有力,完美。

為什麼這個程式碼這麼簡單呢?這可能是個很傻的問題,不過也請思考一下。是因為短嗎?不是,簡潔並不代表不復雜。它的簡單是因為我們把問題分離了。有兩個處理字串的函式: oodlifyizzlify,這些函式並不需要知道關於陣列或者迴圈的任何事情。同時,有另外一個函式: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,現在有兩個問題,我們想要:

  1. 找到所有的女性英雄;
  2. 找到所有能量值大於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);複製程式碼

mapreduce 一樣,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 方法,把問題簡化為:你只要關注如何判斷你要找的東西就可以了,不必關心迭代到底怎麼實現等細節問題。

總結

這些迭代函式的例子很好地詮釋“抽象”的作用和優雅。回想一下我們所講的內建方法,每個例子中我們都做了三件事:

  1. 消除了迴圈結構,使得程式碼變的簡潔易讀;
  2. 通過適當的方法名稱來描述我們使用的模式,也就是:mapreducefilterfind
  3. 把問題從處理整個陣列簡化到處理每個元素。

注意在每一種情況下,我們都用幾個純函式來分解問題和解決問題。真正令人興奮的是通過僅僅這麼四種模式模式(當然還有其他的模式,也建議大家去學習一下),在 JS 程式碼中你就可以消除幾乎所有的迴圈了。這是因為 JS 中幾乎每個迴圈都是用來處理陣列,或者生成陣列的。通過消除迴圈,降低了複雜性,也使得程式碼的可維護性更強。


我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點

相關文章