仅个人观点,欢迎讨论。
先用一句话描绘fp的轮廓:使用(纯)函数抽象作用在数据之上的控制流。
概念
什么是状态
- 程序执行过程中生存了一定时间的值。
- 这些值可能存在于内存、硬盘、显示器等任何程序可以到达的能存储数据的地方。
变量是状态,屏幕上的一个像素点也是状态。
什么是变化
- 状态的改变,包括值的改变、引用的改变和存储介质的改变。
- 本质上是一种隐式通信。
程序生成文件,数据从内存到磁盘,即是状态存储介质的改变。
什么是副作用
- 引起了外部状态的变化。
如何区分外部状态和内部状态
- 对函数而言,外部状态大致可对应公有状态,内部状态大致可对应私有状态。即与其他函数共享的是外部状态,该函数独有的是内部状态。
- 内部状态不一定在函数内。
js
const useCounter = () => {
let count = 0;
const counter = () => count++;
return counter;
};
count
虽然在counter
外,但无法被其他函数访问,所以它是counter
的内部状态。
什么是无状态函数
- 不依赖外部状态也不影响外部状态的函数。
什么是纯函数
- 首先是无状态函数。
- 对相同的输入总有相同的输出。
内部状态也会对输出产生影响,比如上面的counter,是无状态函数但不是纯函数。
函数式编程,什么是"函数"
- 数学上的函数,即一种映射关系。
- 本身没有状态,只是将一种状态映射到另一种状态。
形式
- 通过封装抽象操作。
- 通过组合构建控制流。
- 通过柯里化等手段在操作过程中注入依赖。
以下两个案例均使用fx-flow。
- 文件分片上传
ts
import { concurrent, consume, delay, filter, map, pipe, toAsync } from 'fx-flow'
type FileChunk = number
const fileChunks: FileChunk[] = [1, 2, 3, 4, 5]
const notUploaded = (chunk: FileChunk) => delay(100, chunk > 1)
const uploadChunk = (chunk: FileChunk) => delay(1000, chunk).then(() => console.log(`${chunk} has been uploaded`))
// 一边滤除已上传的分片,一边上传文件分片,并发数为3
// 在惰性求值的作用下并不是全部过滤完再上传,而是过滤一部分,凑齐3个,同时上传,完成后再循环
pipe(fileChunks, toAsync, filter(notUploaded), map(uploadChunk), concurrent(3), consume)
- 转换参数->调用接口->转换返回值->统一错误信息
ts
const queryCompanyStatisticsInfoBiz = async (args) =>
flow(
ok(args),
into((data) => ({ companyCode: data.company })),
andThen(queryCompanyStatisticsInfoApi),
andThen((data) =>
ok(
format([
['场站', data.stationCount, '个'],
['气瓶', data.cylinderCount, '支'],
['车辆', data.carCount, '辆'],
['从业人员', data.employeeCount, '人'],
['用户', data.customerCount, '户']
])
)
),
mapErr(() => '查询企业统计信息失败')
)
灵魂
为什么用纯函数来抽象操作
- 纯函数既不依赖外部状态,也不改变外部状态,对相同的输入总保持相同的输出。意味着可测试,意味着调用时通常符合直觉。
- 与外部无联系,意味着易理解,易排查错误。
有状态的各种"子"
函子、单子、半群和幺半群等映射或代数结构在此统称为"子"。
- 在控制流中前后操作之间通常需要通信,无状态的纯函数无法胜任工作。于是"子"挺身而出,作为前后操作交互的媒介,推动控制流。
- "子"通过映射进行状态流转,该步骤对上层的操作屏蔽。
- "子"脱胎于范畴论。
以最简单的函子为例。fn
为操作,Identify
为推动控制流的"子"。Identify
保存当次操作的结果,并在map
时传递给下一个操作。
ts
class Identify<T> {
private constructor(private value: T) {}
static of<U>(x: U) {
return new Identify(x);
}
public map<R>(fn: (x: T) => R) {
return new Identify(fn(this.value));
}
}
const fn1 = (x: number) => x * Math.random();
const fn2 = (x: number) => x.toFixed(0);
Identify.of(100).map(fn1).map(fn2);
为什么通过映射进行状态流转
- 传统的状态流转方式一般是直接修改状态(特指外部状态),则可能存在未知的第三方通过监听该状态的变化执行不可预测的操作。此为隐式通信。
- 若产生一个新的状态,则无法在旧状态上观测到变化。必须应用显式通信。这让程序的行为变得可预测,就能减少错误。
是否有极致的"纯"
- 没有极致的"纯"。
- 抽象建立在具象之上,封装之内,尽可按传统方式进行状态流转,注意控制隐式通信作用域大小即可。
- "纯"的操作干不了实事,不与外界交互就只是内存里的一堆数据。带副作用的操作同样重要,但在条件允许的情况下最好放到流的尾部(或支流的尾部)。
探索实践
- fx-flow是一个承载函数式编程理念的工具库,以实用为目标,支持js/ts,是本人探索函数式编程实践的产物。该库具备流式编程、错误处理、并发控制、惰性求值和类型提示等功能。并结合ts的特点,对不可变性和柯里化进行了创新,有更强的运行时性能。测试覆盖率也达到90%以上。
- fp-ts是一个高度符合函数式理念的工具库,提供了各种常用的"子"。但对不熟悉fp的开发者来说前期学习成本略高,实际应用中可能会因逻辑组合方式的颠覆式变化而感到代码可读性降低。
扩展
响应式编程正与函数式编程一起快速成长为现代前端开发的主流范式。其核心是实现对异步数据流变化的自动反馈。关于frp,请听下回分解。