無循環 JavaScript

BarrettBTKS 7年前發布 | 14K 次閱讀 JavaScript開發 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;
}

注意看好每一步,首先用了一個計數器 i,把這個計數器初始化為 0,然后在每次循環中將其自增。每次必須要和 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 給忘掉,進而引起無限循環。這確實是一個改進,但是重新思考一下這個問題。我們想要達到的目標是在數組的每個元素上運行 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 的函數,我們一般把它稱作 謂詞 。這里用了謂詞來判斷是否保存當前的英雄元素項。

上面代碼的寫法會看起來比較長。但是這樣的重構很好地避免了之前的代碼重復問題。可以進一步地抽象到一個函數中。

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. 把問題從處理整個數組簡化到處理每個元素。

這里要注意的是,我們把每個問題都打散,用一個或幾個純函數來解決。而真正令人興奮的是僅僅通過 4 個模式(當然還有其他的模式,也建議大家去學習一下),在 JS 代碼中你就可以消除幾乎所有的循環了。這是因為 JS 中幾乎每個循環都是用來處理數組,或者生成數組的。通過消除循環,降低了復雜性,也使得代碼的可維護性更強。

 

 

來自:http://geek.csdn.net/news/detail/188367

 

 本文由用戶 BarrettBTKS 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!