带你来到函数式编程的大门口,长文预警
❝随着 React 的流行,函数式编程受到了越来越多的关注。Vue3 也开始拥抱函数式编程,那么到底什么是函数式编程呢?
❞
前言
正式开始前,先简单介绍一下 Loadsh。Lodash 是一个一致性、模块化、高性能的 JavaScript 实用工具库,这里我们主要关注它对函数的一些扩展方法,也就是下文中我们可能用到的实现函数式编程相关的一些方法。
概念
函数式编程,Function Programming(FP),是一种编程范式。
编程范式指的是计算机编程的典范模式或方法,比较常见的编程范式有面向对象编程、面向过程编程、指令式编程等。
需要注意的是,函数式编程中的函数指的不是程序中的函数(方法),而是数学中的函数即映射关系,如:y=sin(x),描述数据(函数)之间的映射,同时需要保证相同的输入始终能得到相同的输出
函数式编程不会保留计算中间的结果,因此也可以说是无状态的。函数式编程可以将一个函数的执行结果再交由另一个函数去处理。
在前端开发中,主要开发语言是 javascript,而 js 自身并不是函数式编程语言,也就没有一个明确的定义。
在前端开发中,通常使用 FP 通过声明纯函数抽象数据的处理,来避免或尽可能减少函数调用对于外部状态和系统产生的副作用,这样一种思想称为函数式编程
函数是'一等公民'
先来简单回顾一下 js 中的函数
JavaScript 借鉴 Scheme 语言,将函数提升到"一等公民"(first class citizen)的地位。
在编程语言中,一等公民指的是可以作为函数参数,可以作为函数返回值,也可以赋值给变量,因此 JS 中的函数也可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值
闭包和高阶函数
函数编程支持函数作为第一类对象,有时称为闭包或者仿函数(functor)对象。实质上,就是用闭包来替代对象,存储变量。
由于函数是一等公民,函数也可以当作参数传递,就形成了高阶函数。
高阶函数(Higher-order function)可以用另一个函数(间接地,用一个表达式) 作为其输入参数,在某些情况下,它也可以返回一个函数作为其输出参数。
纯函数
函数式编程需要保证相同输入始终得到相同的输出,这正是纯函数的特点
优势
纯函数具备以下几个优势
-
可缓存:对相同输入始终有相同结果,可以把结果缓存起来 -
可测试 -
并行处理 -
多线程环境下并行操作共享的内存数据可能出现意外 -
纯函数不需要访问共享的内存数据,所以并行环境下可以任意运行纯函数(如:在 Web Worker 中) -
没有任何可观测的 “副作用”
副作用
如果函数依赖外部的状态就无法保证输出相同,就会带来副作用
依赖的外部来源包括:全局变量、配置文件、数据库、用户输入等
所有的外部交互都有可能带来副作用,副作用也使得方法的通用性下降,不适合扩展和可重用性,同时副作用会给程序带来安全隐患,带来不确定性,但副作用不可能完全禁止,只能尽可能控制在可控范围内
尾递归和尾调用
尾调用是函数式编程中一个很重要的概念,当一个函数执行时的最后一个步骤是返回另一个函数的调用,这就叫做尾调用。
尾递归是指在函数最后调用函数自身
// 尾调用
const a = (x) => (x ? f() : g());
// 尾递归
function foo() {
return foo();
}
柯里化
概念
❝在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术
❞
简单讲就是
-
将多参的函数转换成单参的形式 -
将低阶函数转化为高阶函数的过程
function checkAge(min, age) {
return age > min;
}
// 使用
checkAge(18, 20);
checkAge(18, 25);
checkAge(18, 12);
这是一个检查年龄是否大于我们给定的最小年龄的函数
柯里化一下
function checkAge(min) {
return function (age) {
return age >= min;
};
}
// 使用
let checkAge18 = checkAge(18);
checkAge18(20);
checkAge18(25);
checkAge18(12);
经过柯里化后,我们不需要每次调用时都传入 18,只需要传入我们需要检查的年龄即可 更大的意义在于能够缓存部分参数
通用方法
Lodash 提供的 curry 方法:lodash.curry(func)
-
功能:创建一个函数,该函数接收一个或多个 func 的参数 -
如果 func 所需要的参数都被提供,则执行 func 并返回执行结果 -
否则继续返回该函数并等待接收剩余的参数 -
参数:需柯里化的函数 -
返回值:柯里化后的函数
上栗子
const _ = require("lodash");
// 目标函数
function getSum(a, b, c) {
return a + b + c;
}
// 柯里化
const curried = _.curry(getSum);
// 使用
curried(1, 2, 3);
curried(1)(2)(3);
curried(1, 2)(3);
lodash 的 curry 函数帮我们将 getSum 方法做了柯里化处理,返回的 curried 方法,可以接收多个参数,少于三个,则返回一个函数,刚好三个则直接返回计算结果
这么好的东西不手写可惜了鸭,来自己手写一个
function curry(fn) {
return function curriedFn(...args) {
// 判断实参与形参的个数
if (args.length < fn.length) {
// 实参个数小于形参个数
// fn 所需要的参数还不够,返回一个函数
return function () {
// 注意:这里不能使用箭头函数,箭头函数自身没有 arguments
return curriedFn(...args.concat(Array.from(arguments)));
};
}
// 实参个数大于或等于形参
// 参数个数满足 fn 的参数要求,执行 fn
return fn(...args);
};
}
小结
-
可以给一个函数传递较少的参数得到一个已经记住某些固定参数的新函数 -
简单理解,柯里化是对函数参数的“缓存” -
让函数变的更灵活,让函数的粒度更小 -
可以把多元函数转化为一元函数,可以组合使用函数产生强大的功能
函数组合
概念
如果一个函数需要经过多个函数处理才得到最终值,我们可以把中间的过程合并为一个函数;这里的函数一般指一元的纯函数
老规矩,上栗子
function add(num) {
return num + 1;
}
function multiple(num) {
return num * 1000;
}
function division(num) {
return num / 188;
}
const result = division(multiple(add(46)));
console.log("猜猜我是几?", result);
好的,一个无聊的栗子;数字 46,经过加法、乘法、除法运算后得到最终答案
假设我们把函数比作管道,可以得到如下的示意图
数据依次经过三个管道(函数)后得出我们想要的结果;函数组合就是将中间的三个管道组合成一个船新的管道
const compose = (val) => division(multiple(add(val)));
注:函数组合默认从右到左执行
通用方法
还是先来看一下 lodash 中的通用方法
针对函数组合 lodash 提供了两个方法
-
flowRight:从右到左执行(常用) -
flow:从左到右运行
入参都是函数,可以是多个,以我们上面的栗子重新使用 lodash 实现一下函数组合
const _ = require("lodash");
const compose = _.flowRight(division, multiple, add);
console.log("猜猜我是几?", compose(46));
既然有这么好用的东西,那肯定要来手写一波
其实,实现起来很简单,只要把上一个函数的计算结果传递给下一个函数即可
function flowRight(...fns) {
return (val) => fns.reduce((result, fn) => fn(result), val);
}
结合律
你没有看错,函数组合也有结合律,以上面的栗子来讲
// 常规组合
const compose = _.flowRight(division, multiple, add);
// 第一种
const compose = _.flowRight(_.flowRight(division, multiple), add);
// 第二种
const compose = _.flowRight(division, _.flowRight(multiple, add));
第一种组合与第二种组合的结果是一样的,将三个函数的大组合划分为了更小的组合,三个函数最终的执行顺序没有改变
调试
函数组合将各个函数包在其中,外界对各个函数的计算无感知,导致一旦出错,很难排查
例如这个需求,我们的预期效果是:将NEVER GIVE UP
转换为 never-give-up
思路就是将字符串拆成单词,转成小写,再使用 “-” 连接
const _ = require("lodash");
// 这里的 split join 可以直接使用
// lodash/fp 模块中的 split 与 join
// 避免柯里化操作
const split = _.curry((sep, str) => _.split(str, sep));
const join = _.curry((sep, array) => _.join(array, sep));
const f = _.flowRight(join("-"), _.toLower, split(" "));
console.log(f("NEVER GIVE UP"));
执行上面的代码后,最后输出的却是 n-e-v-e-r-,-g-i-v-e-,-u-p
,显然不是我们想要的
想要快速排查出问题,我们可以在每个函数执行后打印一下结果
这里先声明一个 trace 函数
const trace = _.curry((tag, v) => {
console.log(tag, v);
return v;
});
再来改造一下 f 函数
// 在 split 函数与 toLower 函数之后都打印一下结果
const f = _.flowRight(
join("-"),
trace("toLower 之后"),
_.toLower,
trace("split 之后"),
split(" ")
);
调试过程就不展开了,调试过后发现是 toLower 函数将数组转为了字符串输出了
Point Free
Point Free 是一种编程风格
可以把「数据处理的过程定义成与数据无关的合成运算」,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数
特点就是
-
不需要指明处理的数据 -
「只需要合成运算过程」 -
需要定义一些辅助的基本运算函数
这种风格能够帮助减少不必要的命名,让代码保持简洁和通用。
// 非 pointfree
var snakeCase = function (word) {
return word.toLowerCase().replace(/\s+/gi, "_");
};
// pointfree
var snakeCase = compose(replace(/\s+/gi, "_"), toLowerCase);
Functor(函子)
定义
还记得我们前面讲的纯函数是有副作用的,这里“函子”的作用就是为了将副作用控制在可控范围内
函子其实就是一个特殊的容器,包含 值 和 值的变形关系,这个变形关系指的就是函数。
我们可以通过一个普通对象来实现,该对象具有 map 方法,map 方法可以运行一个函数对值进行处理
class Container {
constructor(value) {
this._value = value;
}
map(fn) {
return new Container(fn(this._value));
}
}
使用
new Container(5).map((x) => x + 1).map((x) => x * x);
// Container { _value: 36 }
一般函数式编程的运算不直接操作值,而是由函子完成。
函子本质上就是一个实现了 map 契约的对象,可以把函子想象成一个盒子,这个盒子里封装了一个值,给盒子的 map 方法传递一个处理值的函数(纯函数),最终 map 方法返回一个包含新值的盒子(函子)
Pointed 函子
指实现了 of 静态方法的函子
of 方法为了避免使用 new 来创建对象,更深的含义是为了把值放到上下文 Context (把值放到容器中,使用 map 来处理值)
class Container {
static of(value) {
return new Container(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return Container.of(fn(this._value));
}
}
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;
}
}
这个就好理解了,传入的值为 null 或是 undefined,直接给内部 value 赋值 null,不再执行传入的 fn 函数
Either 函子
类似于 if...else... 的处理
Either 函子主要用来做异常处理
最常见的异常就是解析 json 的时候,可以利用 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 (error) {
return Left.of({ error });
}
}
parseJSON('{"name":"zs"}').map((x) => console.log(x));
Right 正常执行 JSON.parse
;如果解析失败,报错了,则使用 Left 函子,Left 函子不执行函数,只负责返回错误信息
IO 函子
IO 函子指的是它的 value 是一个函数,通常这个函数是不纯的(相对纯函数来说),因此需要延迟执行,也叫 惰性执行。IO 函子直接把不纯的操作交给调用者处理。
const fp = require("lodash/fp");
class IO {
static of(x) {
return new IO(function () {
return x;
});
}
constructor(fn) {
this._value = fn;
}
map(fn) {
return new IO(fp.flowRight(fn, this._value));
}
}
这里我们实现了一个 IO 函子。
静态函数 of 中,new 一个 IO,传入一个匿名函数,直接返回 x。map 方法返回一个新 IO 对象,将组合后的 fn 和 value 传入。
继续 JSON.parse 的栗子
const r = IO.of(`{"age":"23"}`)
.map((x) => JSON.parse(x))
.map((x) => console.log(x));
r 是 IO 函子,value 是组合后的函数,只有被调用时整个流程才会开始执行,即:r._value()
这里无论最终结果是否会报错,都交由调用者处理
Monad 函子
IO 函子因为 _value 存的是函数,因此存在嵌套调用的问题
const readFile = function (filename) {
return new IO(function read() {
return fs.readFileSync(filename, "utf-8");
});
};
const print = function (x) {
return new IO(function out() {
console.log(x);
return x;
});
};
readFile('package.json').map(print)._value()._value()
这个栗子就很好的说明了这个问题,我们这里分成两个函数去完成读取文件内容并打印的工作。
-
readFile 函数执行,返回一个新的 IO 实例,实例的 _value 就是 read 函数 与 out 函数的组合 -
这时候执行 _value(),相当于依次执行了 read 与 out,读取了文件数据,并返回了一个新的 IO 实例,这个实例的 _value 就是 out 函数 -
最后还需要执行一次 _value(),打印出结果
解决办法就是使用 Monad 函子
Monad 函子也叫 单子 函子。是一种可以变扁的 Pointed 函子。具有 join 和 of 两个方法。
使用 Monad
const fs = require("fs");
const fp = require("lodash/fp");
// IO Monad
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();
}
}
// 应用
const readFile = function (filename) {
return new IO(function read() {
return fs.readFileSync(filename, "utf-8");
});
};
const print = function (x) {
return new IO(function out() {
console.log(x);
return x;
});
};
// 执行
readFile("package.json")
.flatMap(print) // IO { _value: [Function] }
.join();
相比于原来的 IO 函子,多了 join、flatMap 两个方法,我们来看一下具体是怎么执行的
readFile 方法返回了一个 IO 实例,它的 _value 属性就是 read 方法,只不过这个 read 方法已经明确了要读取的是哪个文件
IO {
_value: function () {
return fp.flowRight(print, read);
}
}
然后是 flatMap,flatMap 方法先调用了 map 方法,返回了一个新的 IO 实例,这个新的 IO 实例的 _value 属性就是上一步的 read 方法与 print 方法的组合,执行顺序就是从右向左执行,先 read 再 print
IO {
_value: function read() {
return fs.readFileSync('package.json', 'utf-8');
}
}
flatMap 最后还调用了一下 join 方法,就是执行了一次 _value,先读取文件内容,将结果传给 print,print 返回一个新的 IO 实例
IO {
_value: function out () {
console.log([文件内容]);
return [文件内容];
}
}
最后执行 join,执行这里的 out 方法,输出文本内容
Task 函子
类似 Promise,常用来处理异步任务
这里借助 folktale 库的 task 来了解一下 task 函子
const fs = require("fs");
const { task } = require("folktale/concurrency/task");
const { split, find } = require("lodash/fp");
function readFile(filename) {
return task((resolver) => {
fs.readFile(filename, "utf-8", (err, data) => {
if (err) resolver.reject(err);
resolver.resolve(data);
});
});
}
task 接受一个回调函数做为参数,并为该回调提供了一个 resolver 对象,异步操作成功时调用 resolver 对象的 resolve 方法,失败时调用 resolver 对象的 reject 方法
// 执行
readFile("package.json")
.map(split("\n"))
.map(find((x) => x.includes("version")))
.run()
.listen({
onRejected: (err) => {
console.log(err);
},
onResolved: (value) => {
console.log(value);
},
});
task 的执行,需要执行 run 方法,在执行之前可以通过 .map
,对执行后的结果做一些处理。这里先是将读取后的内容按换行符拆分为数组,之后在数组中查找包含 version 字符的元素并返回
同时,task 也提供 listen 方法,监听执行状态。传入带有 onRejected 与 onResolved 属性的对象,本质上就是两个回调函数
优势与应用
优势
-
代码简洁,开发较快
函数式编程大量使用函数,功能抽离,减少了代码的重复,代码复用性高,因此整体代码量少,开发效率高。
-
易于理解,接近自然语言
用描述性的表达式组合不同的函数形成程序,易于理解
比如:(1 + 2) \* 3 - 4
<-------> subtract(multiply(add(1,2), 3), 4)
-
易于代码的维护和管理
函数式编程不依赖、也不会改变外界的状态,只要给定输入参数,返回的结果必定相同。每一个函数都可以被看做独立单元,很有利于进行单元测试(unit testing)和除错(debugging),以及模块化组合。
-
易于"并发编程"
函数式编程不需要考虑"死锁"(deadlock),因为它不修改变量,所以根本不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,部署"并发编程"(concurrency)
-
易于代码升级和扩展
函数式编程几乎没有副作用或者说副作用较小,只要保证接口不变,内部实现是与外部无关的。
应用
框架
-
React 函数式编程
函数式编程时 react 框架设计的核心思想之一,每个组件其实都是一个函数,对这些函数进行组合之后形成 data 和 view 之间的映射关系,React 自身可以看作是一个 ui 函数,入参是 data
例子:
dom 函数化(jsx),react 算法中的尾递归,尾调用,fiber 生成算法,hooks 等
-
vue3 函数式编程
vue3 新增了一个新的函数 setup,类似于 react 的 hooks
-
Redux 函数式编程
redux 是函数式编程在解决前端数据流中很典型的应用。
中间件机制(compose),纯函数 Reducer,createStore(enhancer)等
-
Rxjs 函数式编程
Rxjs 是一个解决复杂异步操作的库,结合了响应式编程和函数式编程。
-
koa 中的函数式编程
中间件的实现 compose
-
lodash 中的函数式编程
lodash 中的 fp 模块
单元测试
严格函数式编程的每一个符号都是对直接量或者表达式结果的引用,没有函数产生副作用。对被测试程序中的每个函数,只需在意其参数,而不必考虑函数调用顺序,不用谨慎地设置外部状态。所有要做的就是传递代表了边际情况的参数。如果程序中的每个函数都通过了单元测试,软件系统的质量就有了保证
新语法
-
箭头函数 -
数组的扩展 find findIndex -
Map 数据结构 -
Reflect 映射 -
对象的链判断运算符(MayBe 函子) message?.body?.user?.firstName
-
函数扩展,尾调用优化 -
函数的部分执行(新提案) -
管道运算符 >|
(草案审议)
允许可控的副作用
前端可能的副作用
-
更改全局状态 -
发送一个 http 请求 -
可变数据 -
打印/log -
获取用户输入 -
DOM 查询 -
访问系统状态
在无法避免副作用的情况下,使用函子和单子来控制副作用,也包括错误的处理。
许多异步解决方案其实是函子的具体实现,比如 Promise 是一个 Monad
总结
-
现状
函数式编程的核心是引用透明,结果可预测,数据不可变,在编程过程中带来了易于维护,并行编程的开发体验,具有很重要的意义。
函数式编程为组件的编写提供了更高的灵活度与可读性,并且更符合一个前端编写者的习惯,目前在前端应用非常广泛。尤其以 react, redux 应用最为流行和广泛,以及其他衍生生态。
JavaScript,作为最流行的编程语言之一,特别适合函数式编程
-
展望
函数式编程正逐渐成为计算机科学和软件工程领域的流行趋势
函数式编程发展的应该是「思想」,而不是以函数式编程为基础语言的发展
后记
文章同时发在以下社区:
-
掘金:https://juejin.cn/post/7088283692258295821 -
瓜地:https://melonfield.cn/column/detail/cvrdUTpElql