这是我参与更文挑战的第11天,活动详情查看: 更文挑战
前言
函数式编程的理论基础是λ演算(包括一条变换规则和一条函数定义方式),其本身是一种数学抽象而不是编程语言。函数式编程可以理解为声明式编程的一种形式,它写出来的东西更像是一系列声明语句。推荐使用pointfree的编程风格,减少无意义的中间变量,让代码更有可读性。
核心特点
数据不可变
要求所有的数据都是不可变的,如果你要修改一个对象,那么需要创建一个新的对象来修改,而不是修改已有的对象。
无状态
对于一个函数,给定相同的输入,必须给出相同的输出,不依赖外部状态的变化。
为了实现数据不可变和无状态这两个特点,函数式编程要求函数必须具备两个特性:没有副作用和纯函数。没有副作用的纯函数显然都是引用透明的。
基本概念
- 避免副作用
- 纯函数
- 避免改变状态
- 避免共享状态
- 引用透明
1. 避免副作用
副作用是在计算的过程中,系统状态的一种变化。
副作用的来源:
- 配置文件
- 数据库
- 用户输入
- 修改文件系统
- 发送http请求
- 随机数
- 引用自由变量
- 控制台输出、日志打印
- 浏览器的cookie
- ...
函数式编程推荐使用Monads从纯函数中分离和封装副作用
2. 纯函数
纯函数需要满足的条件:
- 相同输入总是相同输出
- 不产生副作用
- 不依赖于外部状态
JS中的原生纯函数:
String.prototype.toUpperCase
Function.prototype.bind
Array.prototype.concat
---------------------------------------------
非纯函数:
Date.now
Math.random
Array.prototype.sort
document.body.appendChild
复制代码
纯函数的优点:
- 易于测试(上下文无关)
- 可并行计算(时序无关,不依赖死锁)
- bug自限性(不会扩散,不会影响到其他)
- 可缓存(相同的输入永远等于相同的输出,所以可以对结果进行缓存)
- 可移植
Lodash就是典型的纯函数代码库 打开node_modules,基本都能看到Lodash模块
3. 避免改变状态
函数式编程要求每个函数必须独立,所有的功能都不许修改外部变量。这样每个数据状态都是不会被改变的。使用纯函数是不会改变外部状态的,这样也就不会有副作用。
4. 避免共享状态
在面向对象编程中,可以看到很多的共享变量,共享内存空间等。函数式编程要求各个函数都要避免共享状态,因为使用共享状态,调用函数的顺序不同,会导致共享状态的数据发生变化。
5. 引用透明
函数如果不依赖外部变量或状态,只依赖输入的参数,那么这个函数就是引用透明的。我们如果可以使用唯一的值来替换调用的函数表达式并且不改变程序的运行状态,则证明这个函数是引用透明的。
引用透明的函数一定是纯函数。
let increment = counter => counter+1;
let sum = 2 * 3 + increment(6) + increment(5);
//increment(6), increment(5) 可以被唯一替换
复制代码
具体实现
- 高阶函数
- 柯里化
- 偏函数
- 函数复合(管道/组合)
高阶函数
满足以下任意一个条件:
- 接收一个函数作为参数
- 返回一个函数
Array内置的map,filter,reduce等,都是高阶函数;
还比如常用到的功能防抖节流等,都可以用高阶函数实现
附上节流代码:
// 节流
function throttle(fun, delay) {
let timer = null;
let prev = Date.now();
return function() {
let self = this;
let _args = arguments;
let now = Date.now();
let diff = now-prev;
if(timer) {
clearTimeout(timer);
}
if(diff >= delay) { //距离上次执行的时间差大于等于设定的时间值
fun.apply(self,_args);
prev = now;
} else { //边界问题,方法脱离事件后保证触发一次执行函数
timer = setTimeout(function(){
fun.apply(self, _args);
prev = Date.now();
timer = null;
}, delay-diff);
}
}
}
let throttleFun = throttle(opFun, 1000);
复制代码
高阶函数可以用来抽象通用的问题。同时,抽象可以帮助我们屏蔽细节,只需要关注我们的目标。比如map函数,它可以映射对象,字符串,数字,任意数据类型,因为它接收一个函数作为参数来处理各种数据类型。
柯里化
柯里化又称部分求值,可以把多元函数转化成一元函数,把接受多个参数的函数变换成接受一个单一参数的函数,然后返回一个新的函数接受剩余参数。 最大的特点是不断的累积传入的参数,延迟执行,增加了函数的适用性。
Function.prototype.bind方法就是一个典型的柯里化函数,它是将第一个参数设置为执行上下文,其余参数依次传递给调用方法。
柯里化的优点:
- 延迟计算/运行(惰性求值)
- 参数复用
- 提前返回
- 多元函数转换成一元函数,可以配合函数组合产生强大功能
//参数复用
function curryCheck(reg) {
return function(txt) {
return reg.test(txt);
}
}
let hasNumber = curryCheck(/\d+/g);
let hasString = curryCheck(/[a-zA-Z]+/g)
hasNumber('sfksfjl')
hasString('sfsfs')
//提前返回
const addEvent = (function(){
if (window.addEventListener) {
return function (type, el, fn, capture) {
el.addEventListener(type, fn, capture);
}
}
else if(window.attachEvent){
return function (type, el, fn) {
el.attachEvent('on' + type, fn);
}
}
})();
复制代码
柯里化简单示例:
//一般实现
function add(a,b) {
return a+b;
}
//闭包函数实现柯里化
function add(a) {
return function(b) {
a+b;
}
}
//箭头函数实现柯里化
const add = x => y => x+y;
const f = add(1);
f(2) // 3;
复制代码
看一道经典的题目:
编写一个sum函数,实现如下功能:
sum(1)(2)(3)(4) //10
sum(1)(2)(3)...(n)
复制代码
sum(1)(2)(3)
function curry(fun) {
return function curried() {
let args = Array.prototype.slice.call(arguments);
if(arguments.length < fun.length) { //还在继续输入
return function() {
let innerArgs = Array.prototype.slice.call(arguments);
let allParams = args.concat(innerArgs);
return curried.apply(this, allParams);
}
}else {
return fun.apply(this , args);
}
}
}
复制代码
sum(1)(2)..(n)
function add (...args) {
return args.reduce((a, b) => a + b)
}
function curry (fn) {
let args = [] //存参数,fn.length无法处理剩余参数,仅包括第一个具有默认值之前的参数个数
return function curried (...newArgs) {
if (newArgs.length) {
args = [
...args,
...newArgs
]
return curried;
} else {
let val = fn.apply(this, args)
args = [] //保证再次调用时清空
return val
}
}
}
let addCurry = curry(add)
console.log(addCurry(1)(2)(4)(7)(8)(9)())
复制代码
小知识:函数的length是它的形参的个数,不包括剩余参数,仅包括第一个具有默认值之前的参数个数。 如:fn = (a, b, c, d, e) => {} 则fn.length=5
偏函数
固定函数的某一个或者几个参数,返回一个新的函数来接收剩下的变量参数。
看起来偏函数和柯里化函数很相似,一个是转化成n个一元函数,一个是转化成一个n-x元函数。引用functional-progamming-jargon中的描述,两者的关系是:
Curried functions are automatically partially applied.
复制代码
函数组合
如果一个函数要经过多个函数处理才能得到最后的结果,这时候可以把中间过程的函数合并成一个函数,称为函数组合。函数就像数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最后的结果。
函数组合的特点:
- 函数组合默认从右到左执行
- 组合的每个函数必须是纯函数
函数组合示例:
const compose = f => g => x => f(g(x));
const f = compose (x => x * 4) (x => x + 3);
f(2) //20
复制代码
考虑compose传递的参数是不固定的,用rest参数来改造:
const compose = (...fns) => {
return (...args) => {
let res = args;
console.log('----------...args数据',res);
for (let i = fns.length - 1; i > -1; i--) {
console.log('---------------',fns[i], res)
res = fns[i](res);
}
return res;
}
}
复制代码
框架应用
学习React, Vue等框架都是不可避免要接触到函数式编程
React是典型的函数式编程框架,强调一个组件不能去修改传入的prop值;在Redux中,更是强调数据不可变性(immutable),每个reducer是不能直接去修改state的,只能重新返回一个新的state。(state是只读的,reducer必须是纯函数)。
Redux创建中间件(柯里化)
const createMiddleware = store => next => action => {
//中间件具体实现
}
//上边的代码其实就类似于:
const createMiddleware = store => {
return next => {
return action => {
//具体实现
}
}
}
复制代码
看一下redux-thunk中间件源码:
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
复制代码
Redux组合中间件
function compose(middlewares) {
return middlewares.reduce((prev, next) => {
return (...args) => {
return prev(next(...args);
}
})
}
复制代码
Koa组合中间件
function compose (middleware) {
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
复制代码
Koa中间件机制就是函数式组合思想,将一组顺序执行的函数复合为一个函数,外层函数的参数是内层函数的返回值。
const mid1 = function (ctx, next) {}
const mid2 = function (ctx, next) {}
const mid3 = function (ctx, next) {}
复制代码
第一个中间件执行时,我们需要获得以下结构:
mid1(ctx, () => mid2(ctx, () => mid3(ctx, () => {})))
复制代码
可以想象利用递归来模拟这种结果:
const middlewares = [mid1, mid2, mid3];
function dispatch(i) {
return middlewares[i]()(ctx, () => dipatch(i+1));
}
dispatch(0);
复制代码
函数式编程的优点
- 促使将任务分解成简单的函数
- 使用流式的调用链来处理数据
- 通过响应式范式降低事件驱动代码的复杂性。
函数式编程的弊端
- 递归陷阱。 函数式编程语言由于不可改变数据结构的原因,基本上所有的循环都是靠递归来代替。然而递归需要保存大量的调用记录,如果使用不当就容易导致栈溢出。
- 性能开销。柯里化,偏函数,闭包等的使用会有一些开销,函数嵌套比普通函数更加占用内存。它需要去对一个方法进行包装,从而产生上下文切换的性能开销。
- 资源占用。为了实现对象的不可变,往往需要创建新的对象,对垃圾回收也更有压力。
总结
- 函数式编程是一种强调以函数使用为主的编程范式
- 在函数式编程中,将多个不同的函数组合是一个非常重要的思想
- 函数式编程将函数当作积木,通过一些高阶函数来提高代码的模块化和可重用性
- 在某些场景下,函数式编程并不是最优的。