最近一段时间,函数式编程又开始活跃起来了。函数式编程是一种编程范式,它将电脑运算视为数学上的函数计算,并且避免使用程序状态以及可变数据。函数式编程强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。
今天我试图用js去梳理函数式编程相关的一些知识。文中代码风格采用Standard,使用ES6语法。
函数式编程初试
首先我们来看一个例子,计算数组元素的和。
我们可以快速用命令式来实现,代码如下:
const arr = [1, 2, 3, 4]
let sum = 0
for (let i = 0, len = arr.length; i < len; i++) {
sum += arr[i]
}
利用ES5种提供的reduce方法,代码如下:
const arr = [1, 2, 3, 4]
let sum = arr.reduce((x, a) => x + a)
可以看到,reduce的函数式代码与前面命令式代码解决问题的方式和角度是完全不同的。命令式需要关心所有的过程,如何遍历以及如何组织结果数据。而reduce由于将遍历、操作以及结果数据的组织的过程封装至Array中,我们真正关心的逻辑就是reduce的参数里的匿名函数中的过程。
函数式编程特性
函数是一等公民
函数是一等公民,指的是函数与其它数据类型一样,可以赋值给其他变量,也可以作为参数传递,或者作为别的函数的返回值。
举例来说,下面代码中log就是一个函数,作为另一个函数的参数。
function log (str) {
const date = new Date()
console.log('[' + date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds() + ']:', str)
}
['debug', 'info', 'warn'].forEach(log)
纯函数
纯函数是这样的一类函数,相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。函数式编程强调没有“副作用”,意味着函数要保持独立,每次都返回同样结果,没有其他行为,尤其是不能修改外部变量的值。
// 纯函数
function increase (x) {
return x + 1
}
// 不纯函数
let seed = 2
function increaseX (x) {
return x + seed
}
从上面的代码中,我们可以明显看到increaseX依赖于外部可变变量seed,也就是说,increaseX的返回值取决于系统状态,这会导致一些bug变得难以捉摸。
追求纯函数其实还是因为纯函数带来的一些好处,比如说能够根据输入来缓存;纯函数是完全自给自足的,可移植性较强;让测试更加容易;可并行运行,而不用担心进入竞争状态。
不可变数据
JavaScript一共有6种原始类型,分别是Boolean,Null,Undefined,Number String和Symbol(ES6 新增)。除了这些原始类型,其他的都是 Object,而 Object 都是可变的。
const person = {name: 'wen'}
Object.assign(person, {age: 25})
console.log(person); // => {name: 'wen', age: 25}
不过,我们可以使用一个函数作为状态突变的边界,完全隔绝于外部代码的变化。比如说,我们采用merge来隐藏person的可变性。
function merge (...args) {
let obj = {}
args.unshift(obj)
return Object.assign.apply(null, args)
}
const person = {name: 'wen'}
const wen = merge(person, {age: 25}, {telephone: 123456})
console.log(person); // => {name: 'wen'}
console.log(wen); // => {name: 'wen', age: 25, telephone: 123456}
ES5中提供了Object.freeze()来冻结一个对象,但是它是浅操作,也就是说冻结只会发生在最顶层。如果想要深度冻结一个对象,就需要使用递归数据结构。
function deepFreeze (obj) {
let propNames = Object.getOwnPropertyNames(obj)
propNames.forEach((name) => {
let prop = obj[name]
if (typeof prop === 'object' && prop !== null) {
deepFreeze(prop)
}
})
return Object.freeze(obj)
}
除此之外,Immutable.js也是解决Javascript Immutable Data的一种方案。
惰性求值
惰性求值是指尽可能地推迟求解表达式,它不会预先算好所有的元素,而是在用到的时候才去计算。这样做可以使昂贵的计算到了必要才会执行,同时可以将值缓存起来,产生更高效的代码。源于惰性求值特性,Lazy.js相对于lodash、underscore有更好的性能。
ES6中提供了Generator和iterator特性,下面我们利用这两个特性来实现惰性求值。
function * calulate () {
yield 1 * 2
yield 3 * 4
yield 5 * 6
}
let cal = calulate()
console.log(cal.next().value) // 2
console.log(cal.next().value) // 12
console.log(cal.next().value) // 30
yield之后的表达式不会立即执行,会等到next移到这一句的时候才会执行。
尾调用优化
递归最大的好处就简化代码,可以把一个复杂的问题用很简单的代码描述出来。但是,如果递归很深的话,stack会受不了,会导致性能急剧下降。这就需要使用尾调用优化,每次递归时都会重用stack,这样一来能够提升性能,当然,这需要语言或编译器的支持。
ES6中提供了尾调用优化,它必须在严格模式下,使用它时还必须记住以下一些东西:
尾调用函数不能在当前堆栈中引用其他变量,也即非闭包;
在尾调用返回后,并不需要处理记住还得做哪些操作(比如下方的 n*factorial(n-1) 相乘);
最后一个尾调用函数的返回值也就是函数的值,并不需要再往上计算。
下面我们以阶乘为例:
function factorial (n) {
if (n <= 1) {
return 1
} else {
// 未优化 - 调用中引用了变量n
return n * factorial(n - 1)
}
}
在上面的代码中,我们可以看到调用函数中n一直在变化,需要一直去记录,那么栈会越来越大。通过参数将尾调用结果存起来,一直在尾部调用下去,最后一次尾部调用完之后并不需要继续计算,达到尾调用优化的效果,代码如下:
function factorial (n, p = 1) {
if (n <= 1) {
return 1 * p
} else {
let result = n * p
// 优化
return factorial(n - 1, result)
}
}
常见的函数式操作及实现
映射map
map操作队对原集合的每个元素执行给定的元素,从而变成一个新的集合。一般来说,如果需要变换一个集合的时候,就用map。以下代码为map的简单实现
function map (obj, callback) {
const results = []
if (obj === undefined || obj === null) {
return results
}
if (typeof obj.map === 'function') {
return obj.map(callback)
}
const keys = Object.keys(obj)
const length = (keys || obj).length
for (let i = 0; i < length; i++) {
results[i] = callback(obj[keys[i]], keys[i], obj)
}
return results
}
export default map
折叠reduce
reduce操作是用一个累积量来收集集合元素,reduce函数一般在需要为累计量设定一个初始值的时候使用,一般用在需要将集合分成一小块一小块来处理的时候。实现如下:
function reduce (obj, callback, initial) {
if (obj === undefined || obj === null) {
throw new TypeError('reduce called on null or undefined')
}
if (typeof callback !== 'function') {
throw new TypeError('callback not a function')
}
const keys = Object.keys(obj)
const length = (keys || obj).length
let value = initial
let i = 0
if (!initial) {
value = obj[keys[0]]
i = 1
}
if (!initial && length === 0) {
throw new TypeError('Reduce of empty array with no initial value')
}
for (; i < length; i++) {
value = callback(value, obj[keys[i]], keys[i], obj)
}
return value
}
export default reduce
筛选filter
filter是根据用户定义的条件来筛选列表中的条目,并由此产生一个较小的新列表,在需要根据筛选条件来产生一个子集合的时候使用,代码实现如下:
function filter (obj, callback, context) {
if (obj === undefined || obj === null) {
throw new TypeError('obj cannot be undefined or null')
}
if (typeof callback !== 'function') {
throw new TypeError('callback should be function')
}
const keys = Object.keys(obj)
const length = (keys || obj).length
const ret = []
for (let i = 0; i < length; i++) {
if (callback.call(context, obj[keys[i]], keys[i], obj)) {
ret.push(obj[keys[i]])
}
}
return ret
}
export default filter
组合compose
compose使我们能够从简单的、通用的函数建立复杂的函数。通过把函数作为其它函数的构建单元, 我们可以建立真正模块化的应用,使其具有的可读性和可维护性。组合最重要的一个方面是,他们使用纯函数,那么代码的可测试、可维护性、可扩展性更强。以下为compose的代码实现:
function compose (...args) {
let start = args.length - 1
return (...applyArgs) => {
let i = start
let result = args[start].apply(this, applyArgs)
while (i--) {
result = args[i].call(this, result)
}
return result
}
}
export default compose
柯里化curry
curry通过将一个函数集合部分应用到函数中,从而动态创建一个新函数,这个新函数将会保存重复的参数,并且还会使用预填充原始函数所期望的完整参数列表。当我们发现正在调用同一函数,并且传递的参数大部分都是相同的,那么该函数可能是用于curry化的一个很好的候选参数。代码实现如下:
function curry (fn, ...args) {
return (...newArgs) => {
return fn.apply(null, args.concat(newArgs))
}
}
export default curry
记忆memoize
memoize通过将函数返回结果保存起来,在下一次调用该函数时就不用重做潜在的繁重计算,从而提高性能。使用函数属性以便使得计算过的值无须再次计算,代码实现如下:
function memoize (fn) {
const memoizeFn = (...args) => {
const cache = memoizeFn.cache
const key = JSON.stringify(args)
if (!cache[key]) {
cache[key] = fn.apply(this, args)
}
return cache[key]
}
memoizeFn.cache = {}
return memoizeFn
}
export default memoize
函数式语言中的设计模式
函数式语言的重用发生于较粗的细粒度级别上,着眼于提取一些共通的运作机制,并参数化地调整其行为。以下简单地探讨在函数式编程中的一些设计模式,提供解决办法的另一种思路。
工厂方法
工厂方法通常在类的静态方法中实现,主要用于创建相似对象时执行重复操作以及为客户端提供创建对象的接口。
在函数式编程中,curry化相当于产出函数的工厂,我们可以很容易地设立一个条件来返回其他函数的函数,也就是函数工厂。我们来看下面一个例子,假设有一个两数相加的普通函数,经过柯里化加工,我们可以制造出在其参数上加一的递增函数。
const adder = (x, y) => {x + y}
const incrementer = curry(adder, 1)
享元模式
运行共享技术有效地支持大量细粒度的对象,避免大量拥有相同内容的小类的开销(如耗费内存),使大家共享一个类(元类)。
在函数式编程中,被记忆(memoize)的函数允许运行时缓存其结果,能够避免大量非常相同内容的开销。
策略模式
策略模式定义了算法家族,分别封装起来,让他们之间可以互相替换,此模式让算法的变化不会影响到使用算法的客户。
在函数式编程中,因为函数是第一等公民,可以使建立和操作各种策略的工作变得更为简单。
总结
在实际的工作中,我们不用囿于函数式或者面向对象,通常是两者混合使用,结合两者的优势,目的是使代码可以更加简单、优美,而且更易于调试。