去往js函数式编程(3)
我们将使用接下来的函数实现:让你更具有声明性,你会发现自己的关注点将转移到你需要什么,而不是如何做;繁琐的细节被隐藏在我们的函数内部。我们将不再编写一系列可能嵌套的 for 循环,而是专注于使用函数作为构建快来制定我们期望的结果。
使用 reduce()操作可以得到单个值;使用 map()可以得到一个新数组;使用 forEach()可以得到几乎任何类型的结果。如果你在谷歌上搜索,你会找到一些文章声称这些函数不高效,因为手动编写的循环可能更快。虽然这可能是真的,但实际上并不重要。除非你的代码真的受到速度问题的困扰,并且你能够测量到慢速是由于使用这些高阶函数导致的,否则试图避免使用它们并用更长的代码来替代,这样可能会增加错误的概率,实际上并没有太多意义。
如果你需要多少次遍历数组,执行某个操作,以产生一个单一的值作为结果。这种操作通常可以通过应用 reduce()和 reduceRight()函数来以函数式的方式实现。在通常的函数式编程术语中,我们称之为折叠操作:reduce()被称为 foldl(表示从左到右折叠)或者简称为 fold,而 reduceRight()相应地被称为 foldr。在范畴论的术语中,这两个操作都是折叠映射:将容器中的所有值缩减为单一结果。尝试使用 reduce()或 reduceRight()有几个优点:所有循环控制访问都会自动处理,因此你不可能出现比如“少一次循环”的错误。初始化和处理结果值也是隐式完成的。
基本上,为了对数组进行 reduce 操作,我们需要提供一个双参数函数,以及一个初始值。
const myArray = [22, 9, 60, 12, 4, 56]
const sum = (x, y) => x + y
const mySum = myArray.reduce(sum, 0) //163
实际上,你不需要定义 sum 变量,可以直接写 myArray.reduce((x,y)=>x+y,0),然而,以这种方式编写代码的意义更清晰。与编写循环,初始化变量来保存计算结果以及遍历数组进行求和相比,你只需要声明应该执行的操作。使用这些函数进行编程能让你更声明性的方式工作,专注于什么而不是如何。你甚至可以在不提供初始值的情况下执行这个操作:如果你跳过初始值,将使用数组的第一个值,并且内部循环将从数组的第二个元素开始;但是,如果数组为空,而且你没有提供初始值,要小心,因为这将导致运行时错误!
我们再深入一点,如何计算一组数字的平均值?如果你向某人解释这个问题,你的回答肯定会类似于对列表中所有元素求和,然后除以元素的个数。从编程的角度来看,这不是一个过程性的描述(你没有解释如何求和元素,如何遍历数组),而是一个声明性的描述,因为你说的是要做什么,而不是如何做。
const average = (arr) => arr.reduce(sum, 0) / arr.length
const average2 = (sum, val, ind, arr) => {
sum += val
return ind == arr.length - 1 ? sum / arr.length : sum
}
通过当前的索引,我们可以进行一些花招:在这种情况下,我们始终对值进行求和,但如果我们在数组的末尾,我们还会进行除法运算,以返回数组的平均值。但从可读性的角度来看,第一个更加声明性,更接近数学定义,而不是第二个版本。
一次计算多个值应该怎么做呢?那可以使用 reduce 每次返回一个对象。
const average3 = (arr) => {
const sumCount = arr.reduce(
(accum, value) => ({
sum: value + accum.sum,
count: accum.count + 1
}),
{ sum: 0, count: 0 }
)
return sumCount.sum / sumCount.count
}
只不过,这些来计算平均值的方法越来越晦涩,还是看 reduceRight 吧,和 reduce()的工作方式完全相同,只是从数组的末尾开始循环到数组的开头。对于很多操作,着没有任何区别,但在某些情况下会有所不同。如果我们想要实现一个将字符串反转的函数。一种解决方案是使用 split()将字符串转换为数组,然后反转该数组,最后使用 join()将其重新组合起来。但我们换一种方式,尝试使用 reduceRight():
const reverseString2 = (str) => str.spllit('').reduceRight((x, y) => x + y, '')
你也许认为先对数组应用 reverse(),然后再使用 reduce(),那么效果将与直接将 reduceRight()应用于原始数组相同。只是,reverse()会改变给定的数组,因此通过反转原始数组,会导致一个意外的副作用!
使用 map()相比直接使用循环的优势:首先,你不需要编写任何循环,这减少了可能出现错误的可能性。其次,即使原始数组和索引位置存在供你使用,你甚至不需要访问它们,除非你真的需要。最后,会生成一个新数组,因此你的代码是存粹的。在使用 map()时只有两个注意事项:始终从函数中返回值。如果忘记返回值,那么将生成一个填满 undefined 的数组,因为 js 对所有函数都提供默认的返回值 undefined.如果输入数组的元素是对象或数组,并且你在输出数组中包含它们,那么 js 仍然允许访问原始元素。
我们写一个有用的辅助函数,它在很多场景下都非常方便。一个 range(start,stop)函数,它生成一个由数字组成的数组,数值范围从 start(包含)到 stop(不包含):
const range = (start, stop) =>
new Array(stop - start).fill(0).map((v, i) => start + i)
let from2To6 = range(2, 7) // [2,3,4,5,6]
为什么要使用 fill(0),因为 map()会跳过所有未定义的数组元素。lodash 库提供了 range 函数的更强大版本,可以按升序或降序排列,并且还可以指定步长。
const factorialByRange = (n) => range(1, n + 1).reduce((x, y) => x * y, 1)
factorialByRange(5) //120
// 使用range生成A到Z的字母数组
const ALPHABET = range('A'.charCodeAt(), 'Z'.charCodeAt() + 1).map((x) =>
String.fromCharCode(x)
)
// ["A"..."Z"]
我们来看下如何通过 reduce 来模拟 flat()和 flatMap(),以便更好地练习。如果一个元素恰好是一个数组,我们就递归地展开它:
const flatAll = (arr) =>
arr.reduce((f, v) => f.concat(Array.isArray(v) ? flatAll(v) : v), [])
// 首先写一个只展开数组的单个级别的flatOne()函数。
const flatOne1 = (arr) => [].concat(...arr)
const flatOne2 = (arr) => arr.reduce((f, v) => f.concat(v), [])
const flat1 = (arr, n = 1) => {
if (n === Infinity) {
return flatAll(arr)
} else {
let result = arr
range(0, n).forEach(() => {
result = flatOne(result)
})
return result
}
}
const flat2 = (arr, n = 1) =>
n === Infinity
? flatAll(arr)
: n === 1
? flatOne(arr)
: flat2(flat(flatOne(arr), n - 1))
// 使用reduce()模拟filter()
const myFilter = (arr, fn) =>
arr.reduce((x, y) => (fn(y) ? x.concat(y) : x), [])