比较JavaScript中的几种循环

循环是一种很重要的控制结构,它很难被重用,也很难插入到其他操作之中。另外,它意味着随着每次迭代,代码也在不断的变化之中。——Luis Atencio

循环

我们先介绍一下语法层面我们可以使用的循环语句。

while和do … while

while循环只有一个判断条件,条件满足,就不断循环,条件不满足时则退出循环。而do { ... } while()while循环的唯一区别在于,不是在每次循环开始的时候判断条件,而是在每次循环完成的时候判断条件。

1
2
3
4
5
6
7
const arr = [1, 2, 3];
let i = 0;
const len = arr.length;
while (i < len) {
console.log(arr[i])
i = i + 1;
}

do { ... } while()循环要小心,循环体会至少执行1次,而forwhile循环则可能一次都不执行。

for循环

简单for循环

下面先来看看大家最常见的一种写法:

1
2
3
4
const arr = [1, 2, 3];
for(let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}

当数组长度在循环过程中不会改变时,我们应将数组长度用变量存储起来,这样会获得更好的效率,下面是改进的写法:

1
2
3
4
const arr = [1, 2, 3];
for(let i = 0, len = arr.length; i < len; i++) {
console.log(arr[i]);
}

for … in

for-in 循环遍历的是对象的属性,而不是数组的索引。因此, for-in 遍历的对象便不局限于数组,还可以遍历对象。例子如下:

1
2
3
4
5
6
7
8
9
const person = {
fname: "san",
lname: "zhang",
age: 99
};
let info;
for(info in person) {
console.log("person[" + info + "] = " + person[info]);
}

for-in 被设计用来与以字符串作为键的普通的旧的对象工作,对于数组而言,它不是那么有效。

1
2
3
4
5
var a = ["a", "b", "c"];
for(var index in a){
console.log(a[index]);
console.log(typeof index);
}

虽然一样可以得到结果,但这是一个糟糕的选择:

  1. 赋值给index并不是一个数字,而是一个String
  2. 作用于数组的for-in循环除了遍历数组元素以外,还会遍历自定义属性,举个例子,如果你的数组中有一个课枚举的类型a.name,那么循环将额外执行一次,遍历到名为name的索引。甚至数组原型链上的属性都能被访问到。
  3. 这段代码可能按照 随机顺序遍历数组,即输出的结果顺序与属性在对象中的顺序无关,也与属性的字母顺序无关,与其他任何顺序也无关。

for…of

for...of是ES6新引入的特性。修复了ES5引入的for...in的不足。

1
2
3
4
var a = ["a", "b", "c"];
for(var value of a){
console.log("for of:" + value);
}

这样就清晰很多了,注意这里计数器和比较都不用了,你甚至都不用把元素从数组里面取出来。for…of 帮我们做了里面的脏活累活。for…of 循环可以使用的范围包括数组、Set 和 Map 结构、某些类似数组的对象(比如 arguments 对象、DOM NodeList 对象)、 Generator 对象,以及字符串。for…of 还可以正确响应 break, continue, return。如果现在用 for…of 来代替所有的 for 循环,其实就可以很大程度上降低复杂性。但是,我们还可以做进一步的优化。

for-of 在普通的旧的对象上不能正常工作,但是如果你想要在一个对象的属性上进行循环的话,你可以使用 for–in ( 本来就是它的功能 ) 或者内置的 Object.keys( ) :

1
2
3
4
// 将对象的自己的枚举属性转储到控制台
for (var key of Object.keys(someObject)) {
console.log(key + ": " + someObject[key]);
}

无循环 JavaScript

我们先前说过,像循环这样的控制结构引入了复杂性。在 for…of 中虽然我们不必再使用计数器和比较,甚至都不用把元素从数组里面取出来。但是依然需要一些配置性的代码,如想输出一个新数组的话,不得不初始化一个 output 数组并且每次循环都要调用 push() 函数,我们还想做进一步的优化。

现在假设有一个数组band,我们想用一个函数doodlify处理每一个元素,然后用处理结果组建一个新数组。

1
2
3
4
5
6
7
8
9
10
// doodlify :: String -> String
function doodlify(s) {
return s.replace(/[aeiou]/g, 'oodle');
}
const band = ['John', 'Paul', 'George', 'Ringo'];
let bandoodle = [];
for (let item of band) {
let newItem = doodlify(item);
bandoodle.push(newItem);
}

如果有两个数组需要调用 doodlify 函数会怎么样?很容易想到的方法是对每个数组都做循环:

1
2
3
4
5
6
7
8
9
10
11
let bandoodle = [];
for (let item of band) {
let newItem = doodlify(item);
bandoodle.push(newItem);
}

let bandoodle2 = [];
for (let item of band2) {
let newItem = doodlify(item);
bandoodle2.push(newItem);
}

这样确实没问题,但是重复的代码太多了——不够“DRY”。我们来重构它以降低重复性,创建一个函数:

1
2
3
4
5
6
7
8
9
10
11
function doodlifyArray(input) {
let output = [];
for (let item of input) {
let newItem = oodlify(item);
output.push(newItem);
}
return output;
}

let bandoodle = oodlifyArray(band);
let bandoodle2 = oodlifyArray(band2);

这看起来好多了,可是如果我们想使用另外一个函数该怎么办?

1
2
3
function codify(s) {
return s.replace(/[aeiou]+/g, 'code');
}

上面的 oodlifyArray() 一点用都没有了。但如果再创建一个 codifyArray() 函数的话,代码又重复了。写出来看看什么效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function doodlifyArray(input) {
let output = [];
for (let item of input) {
let newItem = doodlify(item);
output.push(newItem);
}
return output;
}

function codifyArray(input) {
let output = [];
for (let item of input) {
let newItem = codify(item);
output.push(newItem);
}
return output;
}

两个函数惊人的相似。那么是不是可以把它们抽象成一个通用的模式呢?我们想要的是:给定一个函数和一个数组,通过这个函数,把数组中的每一个元素做操作后放到新的数组中。我们把这个模式叫做 map 。一个数组的 map 函数如下:

1
2
3
4
5
6
7
function map(f, a) {
let output = [];
for (let item of a) {
output.push(f(item));
}
return output;
}

这里还是用了循环结构,如果想要完全摆脱循环的话,可以做一个递归的版本出来:

1
2
3
4
function map(f, a) {
if (a.length === 0) { return []; }
return [f(a[0])].concat(map(f, a.slice(1)));
}

递归解决方法非常优雅,仅仅用了两行代码,几乎没有缩进。但通常不提倡于在这里使用递归,因为在较老的浏览器中的递归性能非常差。实际上,map 完全不需要你自己去手动实现(除非自己想写)。map 模式很常用,因此 JavaScript 提供了一个内置 map 方法。使用这个 map 方法,上面的代码变成了这样:

1
2
3
4
let bandoodle     = band.map(doodlify);
let bandoodle2 = band2.map(doodlify);
let bandcode = band.map(codify);
let bandcode2 = band2.map(codify);

有两个处理字符串的函数:doodlify 和 codefy,这些函数并不需要知道关于数组或者循环的任何事情。同时,有另外一个函数:map ,它来处理数组,它不需要知道数组中元素是什么类型的,甚至你想对数组做什么也不用关心。它只需要执行我们所传递的函数就可以了。现在我们把问题分离了,对数组的处理和对字符串的处理分开,并且用高阶函数(可以接受函数作为参数的函数)的方式把处理函数传递进去,可以注意到,缩进消失,循环消失。当然循环可能转移到了其他地方,但是我们已经不需要去关心它们了。现在的代码简洁有力,完美。

但是现在我要是只想让数组的元素挨个执行某个函数而不想返回一个新数组呢,其实array对象也已经有方法实现,那就是forEach,类似的还有filter和reduce,他们的区别可以看下面图:

1

至此我们尝试了不用任何循环来处理 JavaScript 数组,为了实现这一点,我们借助了高阶函数,其实给代码增加一点点函数式编程的特性,最终得出的效果是可以降低代码复杂性。

引入函数式编程可能带来性能问题,但是作为普通开发者,过早优化是万恶之源。应该把可读性,可维护性,可测试性放到首位。

概念整理

其实表示“重复”这个含义的词有很多,比如循环(loop),递归(recursion),遍历(traversal), 迭代(iterate),让我们梳理一下他们各自的概念和之间的关系。

循环:算是最基础的概念,凡是重复执行一段代码,都可以称之为循环,大部分的递归、遍历、迭代都是循环 。

递归:定义是,根据一种(几种)基本情况定义的算法,其他复杂情况都可以被逐步还原为基本情况。在编程中的特征就是,在函数定义内重复调用该函数。例如斐波那契数列:

定义F(0) = 1,F(1) = 1,所有其他情况:F(x) = F(x-1) + F(x-2)

所有大于1的整数经过有限次的反推之后都可以转换到两种基本情况。而在编程中,算法则是这样的:

1
2
3
4
5
6
int F(x)
{
if(x==0 || x==1)
return 1; //这里是退出递归的条件, 以保证在有限次递归后能够得到结果
return F(x-1)+F(x-2); //转化为更为基本的情况, 重复调用自身进行计算
}

迭代在数学和编程中有不同的含义。

迭代(数学):在循环的基础上, 每一次循环, 都比上一次更为接近结果。有很多数学问题, 都是迭代算法, 如牛顿迭代法(求平方根)。

1
2
3
int result = 0;
for(int i = 0; i < 10; i++)
result += i; //每一次循环之后, result都更加接近结果45

迭代(编程):按顺序访问一个列表中的每一项, 在很多编程语言中表现为foreach语句:

1
2
3
$arr = [1, 2, 3, 4];
foreach($arr as $i)
echo $i;

遍历:按一定规则访问一个非线性的结构中的每一项,强调非线性结构(树,图)。而迭代一般适用于线性结构(数组, 队列)。

结论

  • 循环(loop) - 最基础的概念, 所有重复的行为
  • 递归(recursion) - 在函数内调用自身, 将复杂情况逐步转化成基本情况
  • (数学)迭代(iterate) - 在多次循环中逐步接近结果
  • (编程)迭代(iterate) - 按顺序访问线性结构中的每一项
  • 遍历(traversal) - 按规则访问非线性结构中的每一项

这些概念都表示“重复”的含义,彼此互相交叉,在上下文清晰的情况下,不必做过于细致的区分。

参考链接

JAVASCRIPT WITHOUT LOOPS

请问编程里迭代和循环有什么区别?

如何形象地解释 JavaScript 中 map、foreach、reduce 间的区别?

如何从性能方面选择for,map和forEach?