Doraemonls

不使用循环的JavaScript

Doraemonls · 2017-02-24翻译 · 348阅读 原文链接

上一篇文章里,我们提到缩进是代码复杂度的重要标志。我们的目标就是写出更低复杂度的JavaScript代码。通过正确的抽象,我们可以解决这个问题。但是如何确定使用哪种抽象?迄今为止,我们还没有找到坚实的例子来阐述这一点。具体到这篇文章而言,让我们来看看如何不使用循环来处理JavaScript数组,最终达到简化的代码的目的。

“循环是一种命令式的控制结构,它不利于重用,也无法方便的和其他操作结合。除此之外,它还意味着代码在不断改变或者不断响应在新迭代中的变化。” —Luis Atencio[1]

Loops

We’ve been saying that control structures like loops introduce complexity. But so far we’ve not seen any evidence of how that happens. So let’s take a look at how loops in JavaScript work. 上面我们已经讲过类似循环的控制结构会引入复杂性问题。不过还没有明确的证据解释这一切如何发生。所以我们不妨来看看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,每次循环自增1。同时还要将这个1和数组长度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 loop。这个方法直接给出了循环需要操作的数组项,就像这样:

let output = [];
for (let item of input) {
    let newItem = oodlify(item);
    output.push(newItem);
}

代码更简单了。请注意,计数器和比较操作都去掉了。我们甚至不需要从数组中取出每一项。for...of loops循环把那些脏乱差的活都自己干了。如果就此打住,在哪都使用for...of loops循环,我们就已经可以减少代码的一些复杂度,做的不错。但是,目标还在远方。

Mapping

for…of循环比for-loop更简洁,但我们还需要一些例子。让我们初始化一个output数组,并在循环内对调用push() 方法。我们可以让代码更加简洁明了,要怎么做?让我们把这个例子扩展一点。

如果我们需要对两个数组做oodlify操作呢?

const fellowship = [
    'frodo',
    'sam',
    'gandalf',
    'aragorn',
    'boromir',
    'legolas',
    'gimli',
];

const band = [
    'John',
    'Paul',
    'George',
    'Ringo',
];

最简单的就是用两个loop,分别对每个数组循环。

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);
}

This works. And code that works is better than code that doesn’t. But, it’s repetitive—not very DRY. We can refactor it to reduce some of the repetition. So, we create a function: 恩,这段代码行得通。行得通的代码起码比无效的代码好。不过,它有重复,所以不符合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方法。如果使用这个内置方法,我们的代码就像下面这样:

let bandoodle     = band.map(oodlify);
let floodleship   = fellowship.map(oodlify);
let bandizzle     = band.map(izzlify);
let fellowshizzle = fellowship.map(izzlify);

Note the lack of indenting. Note the lack of loops. Sure, there might be a loop going on somewhere, but that’s not our concern any more. This code is now both concise and expressive. It is also simple. 请注意,这里没有缩进,没有循环。当然了,肯定在某个地方有个循环,但我们不需要了解。这段代码清晰明了,也容易理解。

为什么这段代码如此简单?这个问题看上去有些愚蠢,但请你仔细想一想。因为代码短小吗?并不是。代码简洁并不意味它缺少复杂度。它简单是因为我们分离了不必要的逻辑。oodlify and izzlify这两个方法都是操作字符串。它们并不需要了解循环或者数组的概念。还有另一个函数map,它只关心数组,并不关心数组中的元素,甚至无需知道数组内的数据类型,更不用知道对数组的数据做什么样的操作。它只需要执行我们传递给它的函数即可。所以,我们把字符串处理和数组处理分离了,而不是全部混合在一起。所以我们说,这段代码是简洁的。

Reducing

Now, map is very handy, but it doesn’t cover every kind of loop we might need. It’s only useful if you want to create an array of exactly the same length as the input. But what if we wanted to add up an array of numbers? Or find the shortest string in a list? Sometimes we want to process an array and reduce it down to just one value. 现在你知道map很容易上手,但它也并不能满足可能遇到的各种循环情况。只有在你需要创建一个长度一样的数组时,这个方法很有效。但如果需要求数组所有值的和呢?或者找到其中最短的字符串呢?有些时候我们就需要对数组进行Reduce操作,把它输出的更小,直到只有一个值为止。

看看下面这个例子。比如说我们有一个超级英雄的数组,

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 loop循环,代码会像这样:

let strongest = {strength: 0};
for (let hero of heroes) {
    if (hero.strength > strongest.strength) {
        strongest = hero;
    }
}

综合考虑,这段代码并不烂。每一次循环里,都找到当前最强的。为了把这个模式看的更透彻,让我们假设我们还要找到所有超级英雄的力量值总和。

let combinedStrength = 0;
for (let 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 (let item of a) {
        working = f(working, item);
    }
    return working;
}

由于现在mapreduce 的模式应用广泛,因此JavaScript也对数组提供了内置的方法。因此我们可以不需要使用自己的函数。直接使用内置方法,代码就变成:

const strongestHero = heroes.reduce(greaterStrength, {strength: 0});
const combinedStrength = heroes.reduce(addStrength, 0);

现在,如果你再多思考一下,你也许会发现这段代码并不见得少了很多。使用内置的函数,代码可以缩减到只有一行。但如果使用我们写的reduce函数,那么代码会再长一些。然而,我们的目标是减少代码的复杂度,而不是减少代码数量。那么,我们成功的降低了复杂度吗?我要说,是的。我们分离了循环和处理每项数据的逻辑。代码写的更少,复杂度也更小了。

一眼看上去,reduce 函数好像有点太低级了。但大多数reduce的用例都其实很像数组求和。不过这并不一定意味着reduce 的返回值必须要是原始类型。它的返回值当然也可以是一个对象,甚至是一个新的数组。当我曾经第一次意识到这件事的时候,我还吃了一惊。所以,我们其实可以实现通过 reduce实现mapfilter 这样的函数。不过我把这件事留给你去尝试。

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'},
];

假设我们有两个问题。

  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);

同样,JavaScript也为数组对象提供了类似map以及reduce的内置函数。我们也可以不用自己写的版本,使用内置的filter函数,代码就变成:

const femaleHeroes = heroes.filter(isFemaleHero);
const superhumans  = heroes.filter(isSuperhuman);

为什么这比写for…of的循环好?想想我们在实践中是怎样做的吧!我们的问题有一个通用的模式:在所有的超级英雄中找出那些符合某条件的。一旦我们能够使用filter,那我们的工作就轻松了。我们只需要告诉filter什么时候留下数据。这只需要一个很短小的函数,无需关注循环和数组本身。这样一来,我们就只需实现一个小微函数即可。就这么简单。

和之前提到的其他循环方法类似,filter能用更少的空间传递更多的信息。我们不需要读懂全部的循环代码才知道最后过滤出的内容会是什么。相反,只需要用一个简单的方法调用就可以了。

Finding

现在我们知道过滤是非常简单的了。但如果我们只要尝试找出数组其中一项呢?比如说我们要把黑寡妇找到, filter的代码可以这样写:

function isBlackWidow(hero) {
    return (hero.name === 'Black Widow');
}

const blackWidow = heroes.filter(isBlackWidow)[0];

问题在于这段代码十分低效。filter会对所有元素进行循环判断。但我们已经知道这里只有一个黑寡妇,找到她我们就可以停止循环。不过,对于断言的方法,倒是可以借鉴。那不妨来写一个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函数,我们的问题就从如何在一个数组里找寻一个特定值简化成了如何确定我们找的是什么。这样,我们又可以不用关心循环内的细节了。

总结

对于如何进行抽象,和如何优雅的使用抽象,这些循环功能都是非常好的例子。假设任何情况下都使用内置的数组方法。那么在不同情况下,我们只需要做以下三件事:

  1. 消除循环结构,让代码更加简洁易读。

  2. 确定问题模式,找到对应的内置方法,即map, reduce, filter, 或者find其中的一种。

  3. 把对整个数组的处理逻辑简化成对每个数据的处理。

要注意,在每种情况下,我们都把问题分解,用更小更纯的函数来解决。更让人惊叹的是,基本上只需要这四种模式(当然还有其他,我建议你学习一下),基本上你就可以告别 _所有的 _JS代码循环了。因为绝大多数JS中的循环,都是处理数组,或者构建新数组,或者两者兼而有之。而当我们去掉循环的时候,代码的复杂度就降低了,可维护性也增加了。


2月23号的一点更新

有些人指出,在有关超级英雄力量值的例子里,使用reducefilter对数组进行两次循环的例子有点低效。可以使用ES2015 扩展运算符会让代码更简洁。下面是我对这部分代码的重构:

function processStrength({strongestHero, combinedStrength}, hero) {
    return {
        strongestHero: greaterStrength(strongestHero, hero),
        combinedStrength: addStrength(combinedStrength, hero),
    };
}
const {strongestHero, combinedStrength} = heroes.reduce(processStrength, {strongestHero: {strength: 0}, combinedStrength: 0});

这个版本比前面两次循环的版本复杂一点,但如果数组巨大,那么这一版的改动就非常明显了。不过不管那一种,算法复杂度都是O(n)


  1. Atencio, Luis. 2016, Functional Programming in JavaScript. Manning Publications. iBooks.
相关文章