无循环 JavaScript(map、reduce、filter和find)
之前有讨论过,缩进(非常粗鲁地)增加了代码复杂性。我们的目标是写出复杂度低的JavaScript代码。通过选择一种合适的抽象来解决这个问题,可是你怎么能知道选择哪一种抽象呢?很遗憾的是到目前为止,没有找到一个具体的例子能回答这个问题。这篇文章中我们讨论不用任何循环如何处理JavaScript数组,最终得出的效果是可以降低代码复杂性。
循环是一种很重要的控制结构,它很难被重用,也很难插入到其他操作之中。另外,它意味着随着每次迭代,代码也在不断的变化之中。——LuisAtencio
我们先前说过,像循环这样的控制结构引入了复杂性。但是也没有给出确切的证据证明这一点,我们先看看JavaScript中循环的工作原理。
循环
在JavaScript中,至少有四、五种实现循环的方法,最基础的是while循环。我们首先先创建一个示例函数和数组:
//oodlify::String->String functionoodlify(s){ returns.replace(/[aeiou]/g,'oodle'); } constinput=[ 'John', 'Paul', 'George', 'Ringo', ];
现在有了一个数组,我们想要用oodlify函数处理每一个元素。如果用while循环,就类似于这样:
leti=0; constlen=input.length; letoutput=[]; while(i注意这里发生的事情,我们用了一个初始值为0的计数器i,每次循环都会自增。而且每次循环中都和len进行比较以保证循环特定次数以后终止循环。这种利用计数器进行循环控制的模式太常用了,所以JavaScript提供了一种更加简洁的写法:for循环,写起来如下:
constlen=input.length; letoutput=[]; for(leti=0;i这一结构非常有用,while循环非常容易把自增的i给忘掉,进而引起无限循环;而for循环把和计数器相关的代码都放到了上面,这样你就不会忘掉自增i,这确实是一个很好的改进。现在回到原来的问题,我们目标是在数组的每个元素上运行oodlify()函数,并且将结果放到一个新的数组中。
对一个数组中每个元素都进行操作的这种模式也是非常普遍的。因此在ES2015中,引入了一种新的循环结构可以把计数器也简化掉:for...of循环。每一次返回数组的下一个元素给你,代码如下:
letoutput=[]; for(letitemofinput){ letnewItem=oodlify(item); output.push(newItem); }这样就清晰很多了,注意这里计数器和比较都不用了,你甚至都不用把元素从数组里面取出来。for...of帮我们做了里面的脏活累活。如果现在用for...of来代替所有的for循环,其实就可以很大程度上降低复杂性。但是,我们还可以做进一步的优化。
mapping
for...of循环比for循环更清晰,但是依然需要一些配置性的代码。如不得不初始化一个output数组并且每次循环都要调用push()函数。但有办法可以让代码更加简洁有力,我们先扩展一下问题。
如果有两个数组需要调用oodlify函数会怎么样?
constfellowship=[ 'frodo', 'sam', 'gandalf', 'aragorn', 'boromir', 'legolas', 'gimli', ]; constband=[ 'John', 'Paul', 'George', 'Ringo', ];很容易想到的方法是对每个数组都做循环:
letbandoodle=[]; for(letitemofband){ letnewItem=oodlify(item); bandoodle.push(newItem); } letfloodleship=[]; for(letitemoffellowship){ letnewItem=oodlify(item); floodleship.push(newItem); }这确实ok,有能正确执行的代码,就比没有好。但是重复的代码太多了——不够“DRY”。我们来重构它以降低重复性,创建一个函数:
functionoodlifyArray(input){ letoutput=[]; for(letitemofinput){ letnewItem=oodlify(item); output.push(newItem); } returnoutput; } letbandoodle=oodlifyArray(band); letfloodleship=oodlifyArray(fellowship);这看起来好多了,可是如果我们想使用另外一个函数该怎么办?
functionizzlify(s){ returns.replace(/[aeiou]+/g,'izzle'); }上面的oodlifyArray()一点用都没有了。但如果再创建一个izzlifyArray()函数的话,代码又重复了。不管那么多,先写出来看看什么效果:
functionoodlifyArray(input){ letoutput=[]; for(letitemofinput){ letnewItem=oodlify(item); output.push(newItem); } returnoutput; } functionizzlifyArray(input){ letoutput=[]; for(letitemofinput){ letnewItem=izzlify(item); output.push(newItem); } returnoutput; }这两个函数惊人的相似。那么是不是可以把它们抽象成一个通用的模式呢?我们想要的是:给定一个函数和一个数组,通过这个函数,把数组中的每一个元素做操作后放到新的数组中。我们把这个模式叫做map。一个数组的map函数如下:
functionmap(f,a){ letoutput=[]; for(letitemofa){ output.push(f(item)); } returnoutput; }这里还是用了循环结构,如果想要完全摆脱循环的话,可以做一个递归的版本出来:
functionmap(f,a){ if(a.length===0){return[];} return[f(a[0])].concat(map(f,a.slice(1))); }递归解决方法非常优雅,仅仅用了两行代码,几乎没有缩进。但是通常并不提倡于在这里使用递归,因为在较老的浏览器中的递归性能非常差。实际上,map完全不需要你自己去手动实现(除非你自己想写)。map模式很常用,因此JavaScript提供了一个内置map方法。使用这个map方法,上面的代码变成了这样:
letbandoodle=band.map(oodlify); letfloodleship=fellowship.map(oodlify); letbandizzle=band.map(izzlify); letfellowshizzle=fellowship.map(izzlify);可以注意到,缩进消失,循环消失。当然循环可能转移到了其他地方,但是我们已经不需要去关心它们了。现在的代码简洁有力,完美。
为什么这个代码这么简单呢?这可能是个很傻的问题,不过也请思考一下。是因为短吗?不是,简洁并不代表不复杂。它的简单是因为我们把问题分离了。有两个处理字符串的函数:oodlify和izzlify,这些函数并不需要知道关于数组或者循环的任何事情。同时,有另外一个函数:map,它来处理数组,它不需要知道数组中元素是什么类型的,甚至你想对数组做什么也不用关心。它只需要执行我们所传递的函数就可以了。把对数组的处理中和对字符串的处理分离开来,而不是把它们都混在一起。这就是为什么说上面的代码很简单。
reducing
现在,map已经得心应手了,但是这并没有覆盖到每一种可能需要用到的循环。只有当你想创建一个和输入数组同样长度的数组时才有用。但是如果你想要向数组中增加几个元素呢?或者想找一个列表中的最短字符串是哪个?其实有时我们对数组进行处理,最终只想得到一个值而已。
来看一个例子,现在一个数组里面存放了一堆超级英雄:
constheroes=[ {name:'Hulk',strength:90000}, {name:'Spider-Man',strength:25000}, {name:'HawkEye',strength:136}, {name:'Thor',strength:100000}, {name:'BlackWidow',strength:136}, {name:'Vision',strength:5000}, {name:'ScarletWitch',strength:60}, {name:'Mystique',strength:120}, {name:'Namora',strength:75000}, ];现在想找最强壮的超级英雄。使用for...of循环,像这样:
letstrongest={strength:0}; for(heroofheroes){ if(hero.strength>strongest.strength){ strongest=hero; } }虽然这个代码可以正确运行,可是实在太烂了。看这个循环,每次都保存到目前为止最强的英雄。继续提需求,接下来我们想要所有超级英雄的总强度:
letcombinedStrength=0; for(heroofheroes){ combinedStrength+=hero.strength; }在这两个例子中,都在循环开始之前初始化了一个变量。然后在每一次的循环中,处理一个数组元素并且更新这个变量。为了使这种循环套路变得更加明显一点,现在把数组中间的部分抽离到一个函数当中。并且重命名这些变量,以进一步突出相似性。
functiongreaterStrength(champion,contender){ return(contender.strength>champion.strength)?contender:champion; } functionaddStrength(tally,hero){ returntally+hero.strength; } constinitialStrongest={strength:0}; letworking=initialStrongest; for(heroofheroes){ working=greaterStrength(working,hero); } conststrongest=working; constinitialCombinedStrength=0; working=initialCombinedStrength; for(heroofheroes){ working=addStrength(working,hero); } constcombinedStrength=working;用这种方式来写,两个循环变得非常相似了。它们两个之间唯一的区别是调用的函数和初始值不同。两个的功能都是对数组进行处理,最终得到一个值。所以,我们创建一个reduce函数来封装这个模式。
functionreduce(f,initialVal,a){ letworking=initialVal; for(itemofa){ working=f(working,item); } returnworking; }reduce模式在JavaScript中也是很常用的,因此JavaScript为数组提供了内置的方法,不需要自己来写。通过内置方法,代码就变成了:
conststrongestHero=heroes.reduce(greaterStrength,{strength:0}); constcombinedStrength=heroes.reduce(addStrength,0);ok,如果足够细心的话,你会注意到上面的代码其实并没有短很多。不过也确实比自己手写的reduce代码少写了几行。但是我们的目标并不是使代码变短或者少写,而是降低代码复杂度。现在的复杂度降低了吗?我会说是的。把处理每个元素的代码和处理循环代码分离开来了,这样代码就不会互相纠缠在一起了,降低了复杂度。
reduce方法乍一看可能觉得非常基础。我们举的reduce大部分也比如做加法这样的简单例子。但是没有人说reduce方法只能返回基本类型,它可以是一个object类型,甚至可以是另一个数组。当我第一次意识到这个问题的时候,自己也是豁然开朗。所以其实可以用reduce方法来实现map或者filter,这个留给读者自己做练习。
filtering
现在我们有了map处理数组中的每个元素,有了reduce可以处理数组最终得到一个值。但是如果想获取数组中的某些元素该怎么办?我们来进一步探索,现在增加一些属性到上面的超级英雄数组中:
constheroes=[ {name:'Hulk',strength:90000,sex:'m'}, {name:'Spider-Man',strength:25000,sex:'m'}, {name:'HawkEye',strength:136,sex:'m'}, {name:'Thor',strength:100000,sex:'m'}, {name:'BlackWidow',strength:136,sex:'f'}, {name:'Vision',strength:5000,sex:'m'}, {name:'ScarletWitch',strength:60,sex:'f'}, {name:'Mystique',strength:120,sex:'f'}, {name:'Namora',strength:75000,sex:'f'}, ];ok,现在有两个问题,我们想要:
找到所有的女性英雄;
找到所有能量值大于500的英雄。
使用普通的for...of循环,会得到如下代码:letfemaleHeroes=[]; for(letheroofheroes){ if(hero.sex==='f'){ femaleHeroes.push(hero); } } letsuperhumans=[]; for(letheroofheroes){ if(hero.strength>=500){ superhumans.push(hero); } }逻辑严密,看起来还不错?但是里面又出现了重复的情况。实际上,区别在于if的判断语句,那么能不能把if语句重构到一个函数中呢?
functionisFemaleHero(hero){ return(hero.sex==='f'); } functionisSuperhuman(hero){ return(hero.strength>=500); } letfemaleHeroes=[]; for(letheroofheroes){ if(isFemaleHero(hero)){ femaleHeroes.push(hero); } } letsuperhumans=[]; for(letheroofheroes){ if(isSuperhuman(hero)){ superhumans.push(hero); } }这种只返回true或者false的函数,我们一般把它称作断言(predicate)函数。这里用了断言(predicate)函数来判断是否需要保留当前的英雄。
上面代码的写法会看起来比较长,但是把断言函数抽离出来,可以让重复的循环代码更加明显。现在把种循环抽离到一个函数当中。
functionfilter(predicate,arr){ letworking=[]; for(letitemofarr){ if(predicate(item)){ working=working.concat(item); } } } constfemaleHeroes=filter(isFemaleHero,heroes); constsuperhumans=filter(isSuperhuman,heroes);同map和reduce一样,JavaScript提供了一个内置数组方法,没必要自己来实现(除非你自己想写)。用内置数组方法,上面的代码就变成了:
constfemaleHeroes=heroes.filter(isFemaleHero); constsuperhumans=heroes.filter(isSuperhuman);为什么这段代码比for...of循环好呢?回想一下整个过程,我们要解决一个“找到满足某一条件的所有英雄”。使用filter使得问题变得简单化了。我们需要做的就是通过写一个简单函数来告诉filter哪一个数组元素要保留。不需要考虑数组是什么样的,以及繁琐的中间变量。取而代之的是一个简单的断言函数,仅此而已。
与其他的迭代函数相比,使用filter是一个四两拨千斤的过程。我们不需要通读循环代码来理解到底要过滤什么,要过滤的东西就在传递给它的那个函数里面。
finding
filter已经信手拈来了吧。这时如果只想找一个英雄该怎么办?比如找“BlackWidow”。使用filter会这样写:
functionisBlackWidow(hero){ return(hero.name==='BlackWidow'); } constblackWidow=heroes.filter(isBlackWidow)[0];这段代码的问题是效率不够高。filter会检查数组中的每一个元素,而我们知道这里面只有一个“BlackWidow”,当找到她的时候就可以停住,不用再看后面的元素了。那么,依旧利用断言函数,我们写一个find函数来返回第一次匹配上的元素。
functionfind(predicate,arr){ for(letitemofarr){ if(predicate(item)){ returnitem; } } } constblackWidow=find(isBlackWidow,heroes);同样地,JavaScript已经提供了这样的方法:
constblackWidow=heroes.find(isBlackWidow);find再次体现了四两拨千斤的特点。通过find方法,把问题简化为:你只要关注如何判断你要找的东西就可以了,不必关心迭代到底怎么实现等细节问题。
总结
这些迭代函数的例子很好地诠释“抽象”的作用和优雅。回想一下我们所讲的内置方法,每个例子中我们都做了三件事:
消除了循环结构,使得代码变的简洁易读;
通过适当的方法名称来描述我们使用的模式,也就是:map,reduce,filter和find;
把问题从处理整个数组简化到处理每个元素。
注意在每一种情况下,我们都用几个纯函数来分解问题和解决问题。真正令人兴奋的是通过仅仅这么四种模式模式(当然还有其他的模式,也建议大家去学习一下),在JS代码中你就可以消除几乎所有的循环了。这是因为JS中几乎每个循环都是用来处理数组,或者生成数组的。通过消除循环,降低了复杂性,也使得代码的可维护性更强。作者:JamesSinclair
编译:胡子大哈翻译原文:http://huziketang.com/blog/posts/detail?postId=58ad37c3204d50674934c3ab
英文原文:JAVASCRIPTWITHOUTLOOPS