什么是函数式编程
函数式编程(Functional Programming)也称函数程序设计是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及可变物件。
在js中,函数是一等公民,函数本身既可以作为其他函数的入参,也可以作为一个函数的返回值。而函数式编程强调函数执行的结果而非过程。倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算结果,而不是设计一个复杂的执行过程
函数式编程中的概念
- 纯函数
- 高阶函数(Higher-Order Function)
- 柯里化(Currying)
- 组合(composition)和管道(pipe)
- 无值编程风格(Pointfree)
纯函数
概念
相同的输入,总是会的到相同的输出,并且在执行过程中没有任何副作用。
比如一个函数,不管什么时候在哪里调用这个函数,只要输入一致,那么得到的输出就一致
const double = (val) => 2*val
作用
- 使用纯函数可以编写可测试的代码
- 不依赖外部环境计算,不会产生副作用,提高函数的复用性,也更加容易维护和重构,代码质量更高
- 更加容易被调用,可以组装成复杂任务的模块或函数,符合模块化概念及单一职责原则
- 结果可以缓存,因为只要输入相同,其输出就一定相同
应用
- 数组对象中的很多基本方法都是纯函数,比如map, forEach, reduce…
- Redux中的reducer,Redux中的基本原则之一就是使用纯函数来修改,reducer中接受的state和action描述如何改变state
- lodash中的工具库也都是纯函数来实现的
其他补充
什么是副作用:指的是函数在执行过程中产生了外部可观察变化,比如:
- 发起HTTP请求
- 操作DOM
- 修改外部数据(不在函数体内的数据,见下反例)
- console.log()打印数据
- 调用Date.now()或者Math.random()
let count = 0;
const addCount = () => count += 1;// 每次调用addCount ,都会修改外部数据 count的值。就会有副作用
addCount()
高阶函数
概念
高阶函数:以函数作为输入或者输出的函数被称为高阶函数(Higher-Order Function)
作用
抽象通用逻辑
比如使用”命令式“编程的话,注重如何做的”过程“,比如下面这个for循环,通过循环拿到每一个值,然后再循环体中写出”如何做“的具体逻辑
let arr = [1,2,3];
for(let i=0;i<arr.length;i++){console.log(arr[i]);
}
这种场景就可以通过高阶函数抽象过程,声明式编程(注重做“什么”,注重结果。比如下面的这种写法,通过高阶函数 “forEach”来抽象循环"如何"做的逻辑,直接关注 做"什么"(忽略怎么获取每一项的过程,关注回调函数做什么的逻辑)
const forEach = function(arr,fn){for(let i=0;i<arr.length;i++){fn(arr[i]);}
}
let arr = [1,2,3];
forEach(arr,(item)=>{console.log(item);
})
缓存特性
比如典型的应用场景就是防抖和节流
应用
防抖
n 秒后在执行该事件,若在 n 秒内被重复触发,则重新计时
const debounce = (fn, wait = 1000) => {let time = null;return (...args) => {// 使用箭头函数,避免this指向问题 if (!time) {clearTimeout(timer)}// 第一/n次传递time = setTimeout(() => fn(...args), timer)}
}
节流
n 秒内只运行一次,若在 n 秒内重复触发,只有一次生效
const throttle = (fn, timer) => {const preTime = + new Date()return (...args) => {// 使用箭头函数,避免this指向问题 const now = + new Date()if (now - preTime >= timer) {fn(...args)preTime = + new Date()}}
}
柯里化
概念
柯里化是把一个多参数函数转化成一个嵌套的一元函数的过程;
比如一个三元函数
const sum = (num1, num2, num3) => num1 + num2 + num3
// 正常多参数调用函数
const val = sum(2,3,5)// 多参数的柯里化
const curry = (fn) => {// 使用箭头函数,避免this指向问题return curried = (...args) => {// fn.length 为fn函数的入参个数if (args.length >= fn.length) {// 接受到的参数达到fn的入参个数即运行fnreturn fn(...args)} else {// 接受到的参数未达到fn的入参个数,接着返回一个函数接受后面的参数return (...args2) => curried(...[...args, ...args2])}}
}// 柯里化之后
const mySum = curry(sum)
const val2 = muSum(2)(3)(5)
作用
- 让纯函数更”纯“,每次接受一个参数,松散解耦,便于我们将复杂的问题简单化
- 某些语言及特定环境下只能接受一个参数
- 函数的惰性执行,返回的是一个函数,而并不是立即运行函数
- 参数的复用
应用
比如需要校验一个正则,我们将验证的逻辑封装为一个函数,然后在需要验证的地方调用这个函数,如果需要验证多次的话,验证规则就需要重复传递了
function checkByRegExp(regExp,string) {return regExp.test(string);
}
// 校验电话号码
checkByRegExp(/^1\d{10}$/, '186xxx');
// 校验邮箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@163.com'); // 需要多次验证的时候
checkByRegExp(/^1\d{10}$/, '186xxx'); // 校验电话号码
checkByRegExp(/^1\d{10}$/, '131xxx'); // 校验电话号码
checkByRegExp(/^1\d{10}$/, '132xxx'); // 校验电话号码checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test@163.com'); // 校验邮箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test2@qq.com'); // 校验邮箱
checkByRegExp(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, 'test3@gmail.com'); // 校验邮箱
可以看到如果需要多次验证的时候,就需要反复传入正则规则了,此时我们可以先将原来的函数柯里化之后再验证就只需要传如需要验证的电话号码和邮箱即可
// 原函数
function checkByRegExp(regExp,string) {return regExp.test(string);
}//生成工具函数,验证电话号码(curry 就是上面的柯里化函数)
let checkPhone = curry(checkByRegExp)(/^1\d{10}$/);
//生成工具函数,验证邮箱
let checkEmail = curry(checkByRegExp)(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);// 验证电话
console.log(checkPhone('186xxx'))
console.log(checkPhone('182xxx'));
console.log(checkPhone('181xxx'));// 验证邮箱
console.log(checkEmail('test@163.com'));
console.log(checkEmail('test2@163.com'));
console.log(checkEmail('test3@163.com'));
组合(composition)和管道(pipe)
概念
在Unix中有这么一套思想:
1、每个程序只做好一件事情。为了完成一项新的任务,重新构建要好于在复杂的旧程序中添加新”属性“。在函数式编程中,”接受一个参数并返回数据“正是遵循了该条思路。
2、每个程序的输出应该是另一个尚未可知的程序的输入。
就像上面的图一样
- a作为最原始f1的输入,经过f1函数处理之后得到结果m
- m又作为f2的输入,经过f2的处理之后得到记过n
- n又作为f3的输入,经过f3的处理得到最终结果b
基于上面的思想,我们可以将函数的颗粒度拆分的足够小,每个函数都只解决一个问题,非常的轻量,可维护性高。更加容易测试。让后通过组合/管道的方式将简单的函数组合起来解决大而复杂的问题。
组合和管道基本一致,只是顺序不一样。compose 执行是从右到左,pipe是从左至右的执行。
作用
如上所说,我们可以基于此思想把很多小函数组合起来完成更复杂的逻辑。显然维护轻量级的函数要更加的容易。
应用
比如我们希望计算一句话中包含字符a的长度并判断是奇数还是偶数
那可以分三步。得到该字符串包含a的数组,第二步得到个数,第三步得到奇数还是偶数。第一步的结果是第二步的输入,第二步的结果是第三步的输入。三个组合之后就可以解决这个问题
// 接受若干个小函数组合const pipe = (...fns) => {// 返回一个高阶函数用于接受值return wrapFn = (val) => {// 将接受到的值使用reduce来依次计算并传递给所有函数return fns.reduce((acc, fn) => fn(acc), val)}}// 接受若干个小函数放入管道const compose = (...fns) => {// 返回一个高阶函数用于接受值return wrapFn = (val) => {return fns.reverse().reduce((acc, fn) => fn(acc), val)}}const str = "It's a fine day to go to work by bike"// 计算这一句话的字符a的长度并判断是奇数还是偶数const getCharNum = (str) => str.match(/a/g)const getLength = (list) => list.lengthconst getOddOrEven = (num) => (num%2 === 0) ? 'Even' : 'Odd'// 使用管道const myPipeFn = pipe(getCharNum, getLength, getOddOrEven);const myComposeFn = compose(getOddOrEven, getLength, getCharNum);console.log(myPipeFn(str));console.log(myComposeFn(str));
无值编程风格(Pointfree)
其实在上面管道和组合中的应用demo就是pointfree(无值编程)风格,即不使用所要处理的值,只合成运算过程。
在上述demo中,使用管道/组合把需要操作的函数合在一起,定义成了一种和参数无关的合成运算,不需要用到代表数据的那个参数,只需要把一些简单的运算步骤合成在一起即可。
Pointfree 的本质就是使用一些通用的函数,组合出各种复杂运算。上层运算不要直接操作数据,而是通过底层函数去处理。这就要求,将一些常用的操作封装成函数。
其中使用无值编程风格的一个典型库就是Ramda函数库
参考文献
函数式编程-维基百科
Pointfree 编程风格指南
Ramda