关键词: 函数式编程 纯函数 函数柯里化 函数组合
〇. 概述
函数式编程是一个古老的概念, 他的历史可以追溯到计算机诞生之初的 λ 演算,
在 1950 年代, 随着 Lisp 语言的创建,函数式编程 ( Functional Programming, 简称 FP ) 就已经开始出现在大家视野
直到近些年函数式以其优雅, 简单的特点开始重新风靡整个编程界, 主流语言在设计的时候无一例外都会更多的参考函数式特性
在前端领域, 我们同样能看到很多函数式编程的影子: ES6 中的新增的箭头函数, react 中大量使用函数式编程, Vue3 也开始拥抱函数式编程...
下面用一个例子直观的展示函数式编程:
假设现在有这样一个需求点:
用户输入一些产品 id 列表, "114514,114515,114516" ,( 因为输入时不严谨, 所以实际存在两种分隔符: 全角逗号 , 和半角逗号 ,) 为了便于之后扩展使用, 需要将"id 列表" 转换成如下格式的框对象数组
js
[{ id: "114514" }, { id: "114515" }, { id: "114516" }]
如果是面向过程的编程思路, replace 替换, split 分割, forEach 遍历...一步一步的实现就可以了:
js
const id_input = "114514,114515,114516";
const handleFuncPOP = str => {
// 全角逗号替换成半角逗号
const ids = str.replace(/,/g, ",");
// 分割成数组
const idsList = ids.split(",");
const result = [];
// 遍历数组, 转换成对象
idsList.forEach(item => {
result.push({ id: item });
});
return result;
};
console.log(handleFuncPOP(id_input));
但如果是一个 FPer, 他会这样思考:
1, 我要实现 原始字符串 => 规范的字符串 => 字符串数组 => 对象数组的转换;
2, 替换字符串和字符串转数组可以用 Lodash 提供的 replace 和 split;
3, 字符串数组转对象数组需遍历数组, 然后将数组的每一项转换成目标格式的对象;
4, 数组遍历可以使用 Lodash 提供的 map , 转换对象需要自己实现一个抽象;
5, 最后将这些函数组合起来, 就可以供业务逻辑调用了
改用函数式编程的思维编写代码:
ps: 这段代码里面涉及的 柯里化 curry, 函数组合 flowRight 等概念接下来会深入介绍, 此处只需简单体会下函数式编程的代码即可~
js
const fp = require("lodash/fp");
const id_input = "114514,114515,114516";
const createObj = fp.curry((key, arr) => new Object({ [key]: arr }));
const handleFuncFP = fp.flowRight(fp.map(createObj("id")), fp.split(","), fp.replace(",", ","));
console.log(handleFuncFP(id_input));
对比两种编程思想, 面向过程的代码中有很多中间变量 (往往是实际项目中很多 bug 的来源), 复用性低 , 代码不够简洁, 且没有函数式编程线性逻辑可读性好
ps: 这里指的函数式代码可读性好 指的是对于一个熟悉函数式编程的开发人员, 对比其他编程范式代码发散的逻辑来说, 函数式编程的代码更趋近于线性逻辑且更接近自然语言, 所以可读性好
一. 什么是函数式编程
函数式编程没有一个标准的定义, 你可以通过以下介绍对它有一个自己的理解
-
函数式编程(Functional Programming, 简称 FP)是一种"编程范式(programming paradigm)"
-
函数式编程中的"函数"指的不是代码中的函数 Function, 而是数学中的函数, 即映射关系 y = f(x)
-
函数式编程避免使用程序状态以及易变对象
-
函数是编程抽象出的函数都是细粒度的函数, 这些函数可以无限次复用, 通过组合可以将这些细粒度的函数变成功能强大的函数
-
如果说面向对象编程是抽象现实当中的事物, 那么函数式编程就是对运算过程的抽象
函数式编程有几个前提概念, 其中最重要的概念就是"函数是一等公民"
二. 如何理解"函数是一等公民"
所谓"一等公民(first class)"指的是在 JavaScript 和很多其他编程语言中, 函数与其他数据类型一样, 可以去任何"值"可以去的地方, 很少有限制
譬如我们可以将函数赋值给其他变量, 也可以将函数作为参数传入另一个函数, 亦或者将函数作为别的函数的返回值返回(引出下一个概念, 高阶函数)
三. 使用高阶函数的意义
高阶函数定义: 将函数作为参数或作为返回结果的函数
JavaScript 中也提供了很多好用的高阶函数: forEach, map, filter...
抽象可以帮助我们屏蔽细节, 只需关注于我们的目标, 高阶函数可以让我们更好的去抽象通用的问题:
js
// 打印数组每个元素
const array = [1, 2, 3, 4];
// 普通方法
for (let i = 0; i < array.length; i++) {
console.log(array[i]);
}
// 高阶函数的方法
// 可以将需求抽象成两个函数: 遍历数组 + 打印元素
// 遍历数组过程的抽象, 可以使用ES6提供的遍历数组函数 forEach
// 打印元素: item => console.log(item)
array.forEach(item => console.log(item));
四. 回顾闭包
函数式编程另一个重要的前提概念, 回顾一下我们的老朋友"闭包"
闭包的概念:
-
函数和周围状态(词法环境)的引用捆绑在一起形成闭包
-
可以在另一个作用域中调用一个函数的内部函数, 并访问到该函数的作用域中的成员
闭包可以"缓存"外层函数的变量:
js
// 生成一个只会执行一次的函数
function once(fn) {
let done = false;
return function () {
if (!done) {
// 这里访问的就是闭包中的done
done = true;
return fn.apply(this, arguments);
}
};
}
let pay = once(money => console.log(`支付${money}元`));
pay(5); // => 打印"支付5元"
pay(5); // => 不会打印
pay(5); // => 不会打印
闭包也可以"缓存"入参:
js
// 生成求不同次幂的函数
function makePower(power) {
return function (number) {
return MAth.pow(number, power);
};
}
let power2 = makePower(2);
let power3 = makePower(3);
power2(2); // => 4
power3(2); // => 8
五. 纯函数
纯函数定义: 相同的输入永远会得到相同的输出 , 而且没有任何可察觉的副作用
举个例子: slice 和 splice 同样可以实现数组截取, 但是 slice 每次输出相同的结果, 而 splice 每次输出的结果不同; slice 不会改变原数组, 但 splice 会改变原数组, 也就是说 slice 是纯函数, 而 splice 是不纯的函数:
js
let numberList = [1, 2, 3, 4, 5];
// 纯函数 slice
console.log(numberList.slice(0, 3)); // => [1, 2, 3]
console.log(numberList.slice(0, 3)); // => [1, 2, 3]
console.log(numberList.slice(0, 3)); // => [1, 2, 3]
// 不纯的函数 splice
console.log(numberList.splice(0, 3)); // => [1, 2, 3]
console.log(numberList.splice(0, 3)); // => [4, 5]
console.log(numberList.splice(0, 3)); // => []
关于副作用:
-
如果函数依赖于外部的状态就无法保证相同输出, 就会带来副作用
-
副作用使得方法通用性下降不适合扩展和可重用性
-
副作用会给程序带来安全隐患和不确定性
-
副作用不可能完全禁止, 尽可能控制它们在可控范围内发生
-
副作用来源
-
- 配置文件
-
- 数据库
-
- 获取用户的输入
-
- api 接口调用
-
- ...
函数式编程的一个特点是不会保留中间的结果, 变量是无状态 的, 所以函数式编程所用的函数基本都是纯函数, 这样就可以放心的把一个函数的结果交给另一个函数去执行, 且不用考虑会产生副作用
因为纯函数相对相同的输入始终有相同的输出, 所以需要的时候可以把纯函数的结果缓存起来
六. 函数柯里化
函数柯里化的概念: 当一个函数有多个参数的时候先传递一部分参数, 然后返回一个新的函数接收剩余的参数, 直到收集全部所需的参数, 返回最终结果
举一个简单的例子体验下柯里化:
js
const _ = require("lodash");
const getSum = (a, b, c) => a + b + c;
const curredGetSum = _.curry(getSum);
console.log(curredGetSum(1, 2, 3)); // => 6
console.log(curredGetSum(1)(2)(3)); // => 6
手动实现一个简单的柯里化
js
function curry(func) {
return function curriedFn(...args) {
// 判断实参和形参的个数
if (args.length < func.length) {
return function () {
return curriedFn(...args.concat(Array.form(arguments)));
};
}
return func(...args);
};
}
// 可以将柯里化形象的理解为: 每次收集一颗龙珠(入参)放兜(闭包)里, 直到集齐全部七颗龙珠(全部入参)才能召唤神龙(返回结果)
总结柯里化:
-
柯里化可以让我们给一个记住了某些参数的新函数 (可以理解为对函数的配置)
-
柯里化是一种对函数参数的"缓存" (闭包的应用)
-
柯里化让函数变得灵活, 让函数的粒度更小
-
柯里化可以将多元函数转换成一元函数, 利用函数组合可以产生更强大的函数
七. 函数组合
函数组合的概念:
-
如果一个函数要经过多个函数处理才能得到最终值, 这个时候可以把中间过程的函数组合并成一个函数
-
函数就像是数据的管道 pipe, 函数组合就是把这些管道连接起来, 让数据穿过多个管道, 最终形成结果
ps: 关于管道可以回想下 vue 被废弃的一个功能: 过滤器 filter, filter 中就有管道的概念
-
函数组合通常是从右向左执行
-
函数组合要满足结合律
js
const fun1 = compose(f, g, h);
const fun2 = compose(compose(f, g), h);
const fun3 = compose(f, compose(g, h));
// fun1, fun2, fun3 等价
手动实现一个简单的函数组合
js
function compose(...args) {
return function (value) {
return args.reverse().reduce(function (acc, fn) {
return fn(acc);
}, value);
};
}
// 改写成箭头函数
const compose =
(...args) =>
value =>
args.reverse().reduce((acc, fn) => fn(acc), value);
八. Lodash
Lodash 是一个纯函数的功能库, 提供了对数组, 数字, 字符串, 函数等操作的一些方法
Lodash 专门为函数式编程提供了 fp 模块, Lodash/fp 的函数遵循 "自动柯里化, 函数优先, 数据置后"的理念, 方便函数式编程使用, 同样类型的库还有 Ramda 等
可以回顾下开头的例子, 体验 Lodash/fp
九. 总结
总结下函数式编程的优点
-
更少的状态量, 降低复杂度, 减少出错概率
-
引用透明, 不会牵一发而动全身
-
高度可组合, 代码简洁, 流程清晰, 更接近自然语言
-
打包过程中可以更好的利用 tree shaking 来过滤无用代码
-
有很多优秀的库可以帮助我们进行函数式开发, 譬如 Lodash, Ramda
相对的, 它也有一些缺点
-
学习路径陡峭, 新概念多且抽象
-
大量使用闭包, 递归, 且为了实现状态不变的特性往往需要创建新对象, 导致额外消耗空间, GC 压力大, 影响性能
-
复杂功能需要大量抽象函数, 抽象的粒度不好掌握
函数式编程的核心思想:
- 多使用纯函数减少副作用的影响
- 抽象函数提高复用率
- 使用柯里化增加函数适用率
- 使用函数函数组合减少中间变量的使用
学习函数式编程的意义在于: 让你意识到在指令式编程, 面向对象编程之外, 还有一种全新的思路, 一种利用函数去抽象问题的思路, 学习函数式编程可以丰富你的武器库, 不然你的手里只有一把锤子, 看什么都是钉子
日常开发中, 往往我们要取长补短, 不同的场景下用合适的方法, 我们学习不同编程范式的最终目的都是为了让自己的编码更加高效, 易懂, 稳定, 软件开发没有"银弹"
参考资料