JavaScript函数式编程指南
1. 什么是函数式编程
函数式编程(Functional Programming, FP),FP是编程范式之一,我们常听说的编程范式还有面向过程编程、面向对象编程。
面向对象编程的思维方式: 把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承和多态来演示事物事件的联系。
函数式编程的思维方式: 把现实世界的事物和事物之间的联系抽象到程序世界(对运算过程进行抽象)
程序的本质: 根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多有输入和输出的函数
x -> f(联系、映射) -> y, y=f(x)
函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,例如: y = sin(x)
, x
和y
的关系
举一个例子来简单看一下函数式和非函数式:
非函数式:
let num1 = 2
let num2 = 3
let sum = num1 + num2
console.log(sum)
函数式:
function add (n1, n2) {
return n1 + n2
}
let sum = add(2, 3)
console.log(sum)
2. 前置知识
函数是一等公民
高阶函数
闭包
2.1 函数是一等公民(MDN First-class Function)
函数可以存储在变量中
函数作为参数
函数作为返回值
在JavaScript中函数就是一个普通的对象
(可以通过new Function()
生成),我们可以把函数存储到变量/数组
中,它还可以作为另一个函数的参数和返回值
,甚至我们可以在程序运行的时候通过new Function('alert(1)')
来构造一个新的函数。
把函数赋值给变量:
let fn = function() {
console.log('Hello First-class Function')
}
再来看一个稍微复杂点的例子:
const BlogController = {
index(posts) { return Views.index(posts) },
show(post) { return Views.show(post) },
create(attrs) { return Db.create(attrs) },
update(post, attrs) { return Db.update(post, attrs) },
destroy(post) { return Db.destroy(post) }
}
我们把上面的代码赋值给变量:
const BlogController = {
index: Views.index,
show: Views.show,
create: Db.create,
update: Db.update,
destroy: Db.destroy
}
2.2 高阶函数
鉴于函数的行为与普通对象类似,其理所当然地可以作为其他函数的参数
进行传递,或是由其他函数返回
。这些函数则称为高阶函数。
因为函数的一等性和高阶性, JavaScript函数具有值
的行为,也就是说,函数就是一个基于输人的且尚未求值
的不可变的值。
2.2.1 函数作为参数
以下是一些我们常用的高阶函数,分别来实现一下:
forEach
map
filter
every
some
forEach:
const forEach = (array, fn) => {
for (let arr of array) {
fn(arr)
}
}
let arr = [1, 3, 4, 7, 8]
forEach(arr, function(item) {
console.log(item)
})
map:
const map = (array, fn) => {
let results = []
for (let arr of array) {
results.push(fn(arr))
}
return results
}
let arr = [1, 3, 4, 7, 8]
console.log(map(arr, (item) => item * item))
filter:
const filter = (array, fn) => {
let results = []
for (let arr of array) {
fn(arr) && results.push(arr)
}
return results
}
let arr = [1, 3, 4, 7, 8]
console.log(filter(arr, (item) => item > 3))
every:
const every = (array, fn) => {
let result = true
for (let arr of array) {
result = fn(arr)
if (!result) {
break
}
}
return result
}
let arr = [1, 3, 4, 7, 8]
console.log(every(arr, (item) => item > 3))
some:
const some = (array, fn) => {
let result = true
for (let arr of array) {
result = fn(arr)
if (result) {
break
}
}
return result
}
let arr = [1, 3, 4, 7, 8]
console.log(some(arr, (item) => item > 3))
2.2.2 函数作为返回值
function makeFn() {
let msg = "Hello function"
return function() {
console.log(msg)
}
}
// const fn = makeFn()
// fn()
makeFn()()
2.2.3 实现once
function once(fn) {
let done = true
return function() {
if (done) {
done = false
return fn.apply(fn, arguments)
}
}
}
let pay = once(function(money) {
console.log(`支付 ${money}元`)
})
pay(5)
pay(5)
使用高阶函数的意义:
抽象可以帮我们屏蔽细节,只需要关注与我们的目标
高阶函数是用来抽象通用的问题
2.3 闭包
闭包(Closure): 函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包。
可以在另一个作用域中
调用一个函数的内部函数
并访问到该函数的作用域中的成员
。
闭包的本质: 函数在执行的时候会放到一个执行栈上,当函数执行完毕之后会从执行栈上移除,但是堆上的作用域成员因为被外部引用不能释放
,因此内部函数依然可以访问外部函数的成员
符合以下两点即是闭包:
- 在该函数外部,对该函数内部有引用
- 在另一个作用域访问到 该函数作用域中的局部成员
从技术上讲,闭包有3个可访问的作用域:
在它自身声明之内声明的变量
对全局变量的访问
对外部函数变量的访问
来看一段代码:
const fn = arg => {
let outer = 'Visible'
let innerFn = () => {
console.log(outer)
console.log(arg)
}
return innerFn
}
var closureFn = fn(5)
closureFn()
当innerFn被返回时,JavaScript 执行引擎视 innerFn为一个闭包,并相应的设置了它的作用域。如上面所述,这3个作用域在 innerFn 返回时都被设置了,返回函数的引用存储在closureFn中。所以,当closureFn通过作用域链被调用时就记住了arg、outer的值
上面的once函数也使用到了闭包
或者再来看一个求平方根的例子:
function makePower(power) {
return function(number) {
return Math.pow(number, power)
}
}
let power2 = makePower(2)
let power3 = makePower(3)
console.log(power2(4))
console.log(power3(5))
继续,我们再看一个求工资的例子:
function makeSalary(base) {
return function(performance) {
return base + performance // 基本工资+绩效工资
}
}
let salaryLevel1 = makeSalary(12000)
let salaryLevel2 = makeSalary(15000)
console.log(salaryLevel1(2000))
console.log(salaryLevel2(3000))
求工资demo浏览器调试
上图详细展示了代码执行过程中的调用栈变化,来看一下各部分表示什么:
Call Stack:调用栈
anonymous: script标签中的代码,全部放在一个匿名函数中
Scope:作用域
Local: 局部作用域
Closure: 闭包
Global: 全局作用域
需要注意的是,我们通过let定义的salaryLevel1
、salaryLevel2
有自己的作用域,如果我们使用var,则会挂到全局上。
3. 纯函数
纯函数: 相同的输入永远会得到相同的输出,而且没有任何可观察的副作用
纯函数就类似数学中的函数(用来描述输入和输出之间的关系),y = f(x)
纯函数好处:
- 可缓存
- 可测试
- 并行处理 (在多线程环境下并行操作共享的内存数据很可能会出现意外情况。
而纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数Web Worker
)- 没有副作用
3.1 记忆函数
function getArea(r) {
return Math.PI * r * r
}
const memoize = (fn) => {
let cache = {}
return function() {
let key = JSON.stringify(arguments)
cache[key] = cache[key] || fn.apply(fn, arguments)
return cache[key]
}
}
let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(4))
3.2 副作用
//不纯的
let mini = 18
function checkAge (age) {
return age >= mini
}
// 纯的(有硬编码,后续可以通过柯里化解决)
function checkAge (age) {
let mini = 18
return age >= mini
}
副作用让一个函数变的不纯(如上例),纯函数根据相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。
副作用来源:
配置文件
数据库
获取用户的输入
......
所有的外部交互都有可能带来副作用,副作用也使得方法通用性下降不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性,但是副作用不可能完全禁止,尽可能控制它们在可控范围内发生。
4. 柯里化
什么是柯里化?
柯里化是把一个多参数函数转换为一个嵌套的一元函数的过程
柯里化可以让我们给一个函数传递较少的参数,得到一个已经记住了某些固定参数的新函数,这是一种对函数参数的缓存
好处:
让函数变的更灵活,让函数的粒度更小
可以把多元函数转换成一元函数, 可以组合使用函数产生强大的功能
来看一个demo:
function checkAge(min) {
return function(age) {
return age >= min
}
}
// es6写法
let checkAge = (min) => (age) => age >= min
let checkAge18 = checkAge(18)
console.log(checkAge18(20))
下面我们来实现一下lodash的curry方法
功能: 创建一个函数,该函数接收一个或多个func的参数,如果func所需要的参数都被提供则执行func并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
参数: 需要柯里化的函数
返回值: 柯里化后的函数
function getSum(a, b, c) {
return a + b + c
}
function curry(fn) {
// 只允许传入函数,传入其它类型会报错
if(typeof fn !== 'function') {
throw Error('No function provided')
}
return function curriedFn(...args) {
if (args.length < fn.length) {
return function() {
return curriedFn(...args.concat(Array.from(arguments)))
}
}
return fn(...args)
}
}
//柯里化后的函数
let curried = curry(getSum)
// 测试
console.log(curried(1, 2, 3))
console.log(curried(1)(2)(3))
console.log(curried(1, 2)(3))
最后我们再来看一个判断数据类型的案例:
function isType(type) {
return function (obj) {
return Object.prototype.toString.call(obj) === `[object ${type}]`
}
}
const isObject = isType('Object')
const isArray = isType('Array')
function isType(type, obj) {
return Object.prototype.toString.call(obj) === `[object ${type}]`
}
let isTypeCurried = curry(isType)
const isObject = isTypeCurried('Object')
4.2 partial(偏函数)
偏函数是 JS 函数柯里化运算的一种特定应用场景。简单描述,就是把一个函数的某些参数先固化,也就是设置默认值,返回一个新的函数,在新函数中继续接收剩余参数,这样调用这个新函数会更简单。
const partial = function(fn, ...args) {
return function(...fullArguments) {
let position = 0
for(let i = 0; i < args.length && position < fullArguments.length; i++) {
if(args[i] === undefined) {
args[i] = fullArguments[position++]
}
}
return fn.apply(null, args)
}
}
let subtract = (a, b) => b - a
subFrom20 = partial(subtract, undefined, 20);
console.log(subFrom20(5))
上述代码实现了一个偏函数,而且成功达到了目的。
再来看一个例子,如果stringify调用的最后两个参数都是null, 2,我们可以用偏函数来封装一下
// 普通
let obj = { foo: "bar", bar: "foo" }
console.log(JSON.stringify(obj, null, 2)) // { foo: "bar", bar: "foo" }
// 偏函数
let prettyprintJson = partial(JSON.stringify, undefined, null, 2)
console.log(prettyprintJson(obj)) // { foo: "bar", bar: "foo" }
console.log(prettyprintJson({ foo: "foo", bar: "bar" })) // { foo: "bar", bar: "foo" }
这里有个bug,如果我们用一个不同的参数再次调用prettyprintJson,它总是返回第一次调用结果。因为我们通过用参数替换undefined值的方式修改args
,而数组传递的是引用
5. 函数组合
函数组合(compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。
函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
函数组合默认是从右到左执行
来看下面一个例子:
function compose(f, g) {
return function(value) {
return f(g(value))
}
}
function reverse(array) {
return array.reverse()
}
function first(array) {
return array[0]
}
const last = compose(first, reverse)
console.log(last([1, 2, 3, 4, 5]))
5.1 lodash中的函数组合
lodash中组合函数flow()
或者flowRight()
,他们都可以组合多个函数
flow() 是从左到右运行
flowRight() 是从右到左运行,使用的更多一些
const reverse = (arr) => arr.reverse()
const first = (arr) => arr[0]
const toUpper = (s) => s.toUpperCase()
const f = _.flowRight(toUpper, first, reverse)
console.log(f(["w", "s", "c"]))
5.2 compose函数实现
function compose(...args) {
return function(value) {
return args.reverse().reduce(function(acc, fn) {
return fn(acc)
}, value)
}
}
// 从右往左
const compose = (...fns) => value => reduce(fns.reverse(), (acc, fn) => fn(acc), value)
// pipe 从左往右
const pipe = (...fns) => value => reduce(fns, (acc, fn) => fn(acc), value)
我们使用reduce函数实现了函数组合,那么我们再来看一下reduce是怎么实现的,代码如下:
reduce(array, fn, initialValue) {
let accumlator;
if(initialValue != undefined) {
accumlator = initialValue
} else {
accumlator = array[0]
}
if(initialValue === undefined) {
for(let i = 1; i < array.length; i++) {
accumlator = fn(accumlator, array[i])
}
} else {
for(let arr of array) {
accumlator = fn(accumlator, arr)
}
}
return [accumlator]
}
来看一下这两个循环,当initialValue未定义时,我们需要从第二个元素开始循环数组,将它作为累加器初始值,如果initialValue由调用者传入,我们就需要遍历整个数组。
5.3 结合律
函数的组合要满足结合律(associativity):
我们既可以把g和h组合,还可以把f和g组合,结果都是一样的
// 结合律(associativity)
let f = compose(f, g, h)
let associative = compose(compose(f, g), h) == compose(f, compose(g, h))
5.4 函数组合调试
如果组合函数某一个环节出了问题,我们可以写一个trace函数,来打印一下到底是在哪个环节出了问题,代码如下:
const trace = _.curry((tag, v) => {
console.log(tag, v)
return v
})
const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, array) => _.join(array, sep))
const map = _.curry((fn, array) => _.map(array, fn))
const f = _.flowRight(join("-"), trace("map"), map(_.toLower), split(" "))
console.log(f("NEVER SAY DIE"))
lodash中其实提供了对函数式编程更友好的方法,我们来看一下lodash的fp
模块与lodash的对比
// lodash模块
const_ = require("lodash")
_.map(["a", "b", "c"], _.toUpper)
// => ['A', 'B', 'C']
_.map(["a", "b", "c"])
// => ['a', 'b', 'c']
_.split("Hello World", " ")
// lodash/fp 模块
const fp = require("lodash/fp")
fp.map(fp.toUpper, ["a", "b", "c"])
fp.map(fp.toUpper)(["a", "b", "c"])
fp.split(" ", "Hello World")
fp.split(" ")("Hello World")
5.5 lоdаѕh 和 lоdаѕh/fр 模块中 map 方法的区别
要注意的的是, lodash 中的map方法接收三个参数,而 lоdаѕh/fр 中map只接收一个参数
const _ = require("lodash")
// map里的函数接收三个参数
console.log(_.map(["23", "8", "10"], parseInt))
// parseInt('23', 0, array)
// parseInt('8', 1, array)
// parseInt('10', 2, array)
const fp = require("lodash/fp")
// 只接收一个参数
console.log(fp.map(parseInt, ["23", "8", "10"]))
5.6 Pointfree(其实就是函数组合)
Point Free: 我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。
不需要指明处理的数据
只需要合成运算过程
需要定义一些辅助的基本运算函数
// 非 Point Free 模式
// Hello World => hello——world
function f(word) {
return word.toLowerCase().replace(/ls+/g, "_")
}
// Point Free
const fp = require("lodash/fp")
const f = fp.flowRight(fp.replace(/s+/g, "_"), fp.toLower)
console.log(f("Hello World"))
6. 惰性函数
6.1 惰性调用
我们使用flowRight函数来模拟lodash的惰性调用
const fp = require('lodash/fp')
class MyWrapper {
constructor (value) {
this._wrapped = value
this._actions = []
}
chain (value) {
this._wrapped = value
return this
}
filter (fn) {
this._actions.push(fp.filter(fn))
return this
}
map (fn) {
this._actions.push(fp.map(fn))
return this
}
sum () {
this._actions.push(fp.sum)
return this
}
value () {
// fp.flowRight()
// fp.compose()
let fn = fp.compose(...this._actions.reverse())
return fn(this._wrapped)
}
}
let _ = {
chain: function chain (value) {
return new MyWrapper(value)
}
}
let employees = [
{ name: 'Jack', age: 25, sex: 'male', salary: 20000 },
{ name: 'Tom', age: 30, sex: 'male', salary: 30000 },
{ name: 'Jim', age: 26, sex: 'male', salary: 25000 },
{ name: 'Carl', age: 25, sex: 'male', salary: 22000 },
{ name: 'Abel', age: 32, sex: 'male', salary: 32000 },
{ name: 'Gary', age: 31, sex: 'male', salary: 28000 },
{ name: 'Kevin', age: 23, sex: 'male', salary: 27000 }
]
let salary = _.chain(employees)
.filter(e => e.age >= 30)
.map(e => e.salary)
.sum()
.value()
console.log(salary)
6.2 惰性载入函数
function createXHR(){
if (typeof XMLHttpRequest != "undefined"){
return new XMLHttpRequest();
} else if (typeof ActiveXObject != "undefined"){
if (typeof arguments.callee.activeXString != "string"){
var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len;
for (i=0,len=versions.length; i < len; i++){
try {
new ActiveXObject(versions[i]);
arguments.callee.activeXString = versions[i];
break;
} catch (ex){
//跳过
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
} else {
throw new Error("No XHR object available.");
}
}
惰性载入表示函数执行的分支仅会发生一次。有两种实现惰性载入的方式。
第一种就是在函数被调用时再处理函数。在第一次调用的过程中,该函数会被覆盖为另外一个按合适方式执行的函数,这样任何对原函数的调用都不用再经过执行的分支了。例如,可以用下面的方式使用惰性载入重写 createXHR()。
function createXHR(){
if (typeof XMLHttpRequest != "undefined"){
createXHR = function(){return new XMLHttpRequest();
} else if (typeof ActiveXObject != "undefined"){
createXHR = function(){
if (typeof arguments.callee.activeXString != "string"){
var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len;
for (i=0,len=versions.length; i < len; i++){
try {
new ActiveXObject(versions[i]);
arguments.callee.activeXString = versions[i];
break;
} catch (ex){
//skip
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
};
} else {
createXHR = function(){
throw new Error("No XHR object available.");
};
}
return createXHR();
}
在这个惰性载入的 createXHR()中,if 语句的每一个分支都会为 createXHR 变量赋值,有效覆盖了原有的函数。最后一步便是调用新赋的函数。下一次调用 createXHR()的时候,就会直接调用被分配的函数,这样就不用再次执行 if 语句了。
第二种实现惰性载入的方式是在声明函数时就指定适当的函数。这样,第一次调用函数时就不会损失性能了,而在代码首次加载时会损失一点性能。
var createXHR = (function(){
if (typeof XMLHttpRequest != "undefined"){
return function(){
return new XMLHttpRequest();
}
} else if (typeof ActiveXObject != "undefined"){
return function(){
if (typeof arguments.callee.activeXString != "string"){
var versions = ["MSXML2.XMLHttp.6.0", "MSXML2.XMLHttp.3.0", "MSXML2.XMLHttp"], i, len;
for (i=0,len=versions.length; i < len; i++){
try {
new ActiveXObject(versions[i]);
arguments.callee.activeXString = versions[i];
break;
} catch (ex){
//skip
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
};
} else {
return function(){
throw new Error("No XHR object available.");
};
}
})();
这个例子中使用的技巧是创建一个匿名、自执行的函数,用以确定应该使用哪一个函数实现。
惰性载入函数的优点是只在执行分支代码时牺牲一点儿性能。
7. 尾递归调用优化
递归是一种旨在通过将问题分解成较小的自相似问题来解决问题本身的技术,将这些小的自相似问题结合在一起,就可以得到最终的解决方案。递归函数包含以下两个主要部分。
• 基例(也称为终止条件) :能够令递归函数计算出具体结果的一组输入,而不必再重复下去
• 递归条件:处理函数调用自身的一组输入(必须小于原始值)。如果输入不变小,那么递归就会无限期地运行,直至程序崩溃。随着函数的递归,输入会无条件地变小,最终到达触发基例的条件,以一个值作为递归过程的终止。
事实上,纯函数式语言甚至没有标准的循环结构,如 do、 for 和 while,因为所有循环都是递归完成的。递归使代码更易理解,因为它是以多次在较小的输人上重复相同的操作为基础的
7.1 尾调用优化
尾调用优化这个版本的实现将递归调用作为函数体中最后的步骤,也就是尾部位置。
7.2 递归定义的数据结构
递归算法执行整个树的先序遍历,从根开始并且下降到所有子节点。由于其自相似性,从根节点遍历树和从任何节点遍历子树是完 全一样的,这就是递归定义。
树的先序遍历按照以下步骤执行,从根节点开始。
- 显示根元素的数据部分。
- 通过递归地调用先序函数来遍历左子树。
- 以相同的方式遍历右子树。
7.3 递归的弱点
如果你见过错误 Range Error : Maximum Call Stack Exceeded or too much recursion
,就会知道递归有问题了。通过下面这个简单的脚本,可以测试浏览器函数堆栈大概的大小:
function increment (i) {
console.log(i);
increment(++i);
}
increment(i);
不同的浏览器的堆找错误会有不同:例如在某台计算机上, Chrome 会在 17500 次递归后触发异常,而 Firefox 的会递归大约 213000 次。不要以这些数值作为递归函数的上界!这些数字只是为了说明递归是有限制的。代码预设应该要远远低于这些阀值,否则递归肯定是有问题的。
遍历这种异常巨大的数组的另一种方式就是利用高阶函数,如 map、 filter 以及reduce。使用这些函数不会产生嵌套的函数调用,因为堆栈在每次迭代循环后都能得到回收。
虽然柯里化和递归导致更多的内存占用,但是鉴于它们带来的灵活性和复用性以及递归解决方案固有的正确性,又感觉这些额外的内存花费是值得的。
另外我们可以通过惰性求值来推迟函数执行
7.4 尾递归调用优化
当使用尾递归时,编译器有可能帮助你做尾部调用优化(TCO)
imageTCO 也称为尾部调用消除,是 ES6 添加的编译器增强功能。同时,在最后的位置调用别的函数也可以优化(虽然通常是本身),该调用位置称为尾部位置 (尾递归因此而得名)。
这为什么算是一种优化?函数的最后一件事情如果是递归的函数调用,那么运行时会认为不必要保持当前的栈帧,因为所有工作已经完成,完全可以抛弃当前帧。在大多数情况下,只有将函数的上下文状态作为参数传递给下一个函数调用(正如在递归阶乘函数处看到的) ,才能使递归调用不需要依赖当前帧。通过这种方式,递归每次都会创建一个新的帧,回收旧的帧,而不是将新的帧叠在旧的上。因为 factorial 是尾递归的形式,所以factorial(4)的调用会从典型的递归金字塔:
factorial(4)
4 * factorial(4)
4 * 3 * factorial(2)
4 * 3 * 2 * factorial(1)
4 * 3 * 2 * 1 * factorial(0)
4 * 3 * 2 * 1 * 1
4 * 3 * 2 * 1
4 * 3 * 2
4 * 6
return 24
相对于如下上下文堆栈
factorial(4)
factorial(3, 4)
factorial(2, 12)
factorial(1, 24)
factorial(0, 24)
return 24
return 24
尾递归 factorial(4)求值的详细视图。函数只使用了一帧。TCO 负责抛弃当前帧,为新的帧让路,就像 factorial 在循环中求值一样
7.5 将非尾递归转为尾递归
const factorial = (n) => (n===1)? 1 : (n * factorial(n - 1));
递归调用并没有发生在尾部,因为最后返回的表达式是 n * factorial (n - 1)。切记,最后一个步骤一定要是递归,这样才会在运行时 TCO 将 factorial 转换成一 个循环。改成尾递归只需要两步。
- 将当前乘法结果当作参数传人递归函数。
- 使用 ES6 的默认参数给定一个默认值(也可以部分地应用它们,但默认参数会让代码更整洁 )。
const factorial = (n, current = 1) => (n === 1) ? current : factorial(n - 1, n * current) ,
这个阶乘函数运行起来眼标准循环没什么区别,没有额外创建堆栈帧,同时仍保留了它原本的数学声明的感觉。之所以能够做这种转换,是因为尾递归函数跟循环有着共同的特点
标准循环(左)及其等效的尾递归函数之间的相似之处。在这两个代码示例中,读者可以很容易地找到基例、事后操作、累计参数和结果function sum (arr) {
if (_.isEmpty(arr)) {
return 0;
}
return _.first(arr) + sum(_.rest(arr));
}
// 转为尾调用
function sum(arr, ace = 0) {
if(_.isEmpty(arr)) {
return 0;
}
return sum(rest(arr), ace+_.first(arr));
}
尾递归带来递归循环的性能接近于 for 循环。所以对于有尾递归优化的语言,比如 ES6,就可以在保持算法的正确性和 mutation 的控制,同时还能保持不会拖累性能。不过尾调用也不仅限于尾递归 。 也可以是调用另一个函数,这种情况在 JavaScript 代码中也很常见。不过要注意的是,这是个新的 JavaScript标准,即便 ES4就开始起草,很多浏览器也没有广泛实现。
还有一种解决方式是使用 trampolining。 trampolining 可以用迭代的方式模拟尾递归,所以可以非常理想、容易地控制 JavaScript 的堆栈。
trampolining是一个接受函数的函数,它会多次调用函数,直到满足一定的条件。一个可反弹或者重复的函数被封装在 thunk 结构中。 thunk 只不过是多了 一层函数包裹。在函数式 JavaScript背景下,可以用 thunk及简单的匿名函数包裹期望惰性求值的值。
要检查 TCO 和其他 ES6 特性的兼容性,可以登录: https://kangax.github.io/compat table/es6/。
8. 函子(Functor)
到目前为止已经已经学习了函数式编程的一些基础,但是我们还没有演示在函数式编程中如何把副作用控制在可控的范围内、异常处理、异步操作等。
容器: 包含值和值的变形关系(这个变形关系就是函数)
函子: 是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map 方法可以运行一个函数, 对值进行处理(变形关系)
函子是为了解决纯函数中的异常带来的副作用,函子可以帮我们控制副作用(IO),进行异常处理(Either)或异步任务(Task)
来看一个简单的函子:
class Container {
constructor(value) {
this._value = value
}
map(fn) {
return new Container(fn(this._value))
}
}
console.log(new Container(5).map((x) => x + 1).map((x) => x * x))
来总结一下:
函数式编程的运算不直接操作值,而是由函子完成
函子就是个实现了 map 契约的对象
我们可以把函子想象成一个盒子,这个盒子里封装了一个值,想要处理盒子中的值,我们需要给盒子的 map 方法传递一个处理值的函数(纯函数) ,由这个函数来对值进行处理
最终map方法返回一个包含新值的盒子(函子)
8.1 Pointed函子
Pointed 函子是实现了of 静态方法的函子
of 方法是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文Context (把值放到容器中,使用map来处理值)
来看一下具体的代码实现:
class Container {
static of(value) {
return new Container(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return Container.of(fn(this._value))
}
}
来调用一下:
console.log(
Container.of(5)
.map((x) => x + 1)
.map((x) => x * x)
)
8.2 MayBe函子
我们在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理,MayBe函子的作用就是可以对外部的空值情况
做处理(控制副作用在允许的范围)
来看下面代码:
class MayBe {
static of(value) {
return new MayBe(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
}
isNothing() {
return this._value === null || this._value === undefined
}
}
console.log(
MayBe.of(null)
.map((x) => x + 1)
.map((x) => x * x)
)
8.3 Either函子
在使用MayBe函子时我们发现一个问题,虽然处理了空值问题,但是多次调用map时我们无法判断是哪里返回了空值,这个时候我们可以通过Either
函子来解决。
Eiher:两者中的任何一个,类似于if...else..
的处理
由于异常会让函数变的不纯,我们使用 Either
函子可以用来做异常处理
class Left {
static of(value) {
return new Left(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return this
}
}
class Right {
static of(value) {
return new Right(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return Right.of(fn(this._value))
}
}
function parseJSON(str) {
try {
return Right.of(JSON.parse(str))
} catch (e) {
return Left.of({ error: e.message })
}
}
let r3 = parseJSON("{name: zs}")
console.log(r3)
上述代码中,我们定义了一个Left
容器,一个Right
容器,我们在parseJSON
函数中通过try...catch来捕获错误,正常情况下,使用Right
容器,当函数异常时,会走Left
容器。
8.4 IO函子
IO函子中的
_value
是一个函数,这里是把函数作为值来处理
IO函子可以把不纯的动作存储到_value
中,延迟执行这个不纯的操作(惰性执行),包装当前的操作纯
把不纯的操作交给调用者来处理
来看代码:
const fp = require("lodash/fp")
class IO {
static of(х) {
return new IO(function() {
return x
})
}
constructor(fn) {
this._value = fn
}
map(fn) {
//把当前的value和传入的fn组合成一个新的函数
return new IO(fp.flowRight(fn, this._value))
}
}
下面我们使用IO函子来读取一个文件并获取文件内容:
let readFile = function(filename) {
return new IO(function() {
return fs.readFileSync(filename, "utf-8")
})
}
let print = function(x) {
return new IO(function() {
console.log(x)
return x
})
}
let cat = fp.flowRight(print, readFile)
// IO(IO(x))
let r = cat("index.html")._value()._value()
console.log(r)
我们发现,虽然实现了功能,但是代码中出现了IO(IO(x))
这样的函子嵌套,有没有方法来解决这个问题呢,我们来试一下Monad
函子
8.5 Monad函子
Monad函子是可以变扁的Pointed函子,IO(IO(x))
一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad
来看一下具体实现:
const fp = require("lodash/fp")
const fs = require("fs")
class IO {
static of(x) {
return new IO(function() {
return x
})
}
constructor(fn) {
this._value = fn
}
map(fn) {
//把当前的value和传入的fn组合成一个新的函数
return new IO(fp.flowRight(fn, this._value))
}
join() {
return this._value()
}
flatMap(fn) {
return this.map(fn).join()
}
}
这时我们再来尝试使用Monad
来解决上面的问题:
let readFile = function(filename) {
return new IO(function() {
return fs.readFileSync(filename, "utf-8")
})
}
let print = function(x) {
return new IO(function() {
console.log(x)
return x
})
}
let r = readFile("index.html")
.map((x) => x.toUpperCase())
.flatMap(print)
.join()
console.log(r)
8.6 Task(异步执行)
异步任务的实现过于复杂,我们使用folktale中的Task来演示
folktale是一个标准的函数式编程库,和lodash、ramda 不同的是,他没有提供很多功能函数,只提供了一些函数式处理的操作, 例如: compose、 curry等,和一些函子如 Task、Either、MayBe 等
我们来看一个 folktale 的使用演示:
const { compose, curry } = require("folktale/core/lambda")
const { toUpper, first } = require("lodash/fp")
// 第一个参数是传入函数的参数个数
let f = curry(2, function(x, y) {
console.log(x + y)
})
f(3, 4)
f(3)(4)
// 函数组合
let f = compose(toUpper, first)
f(["one", "two"])
下面我们使用task
来处理异步任务:
const fs = require("fs")
const { task } = require("folktale/concurrency/task")
function readFile(filename) {
return task((resolver) => {
fs.readFile(filename, "utf-8", (err, data) => {
if (err) resolver.reject(err)
resolver.resolve(data)
})
})
}
readFile("package.json")
.run()
.listen({
onRejected: (err) => {
console.log(err)
},
onResolved: (value) => {
console.log(value)
},
})