函数式编程范式(二)
纯函数
什么是纯函数?
函数式编程中的函数就是纯函数。
具体特征就是,相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。
纯函数就类似于数学中函数(用来描述输入与输出之间的关系),y = f(x)
下面举一个 纯函数 和 非纯函数 的例子 Array.prototype.slice 和 Array.prototype.splice
typescript
let arr = [1, 2, 3, 4, 5, 6, 7, 8]
// 纯函数
console.log(arr.slice(0, 2)) // [ 1, 2 ]
console.log(arr.slice(0, 2)) // [ 1, 2 ]
console.log(arr.slice(0, 2)) // [ 1, 2 ]
// 非纯函数
console.log(arr.splice(0, 2)) // [ 1, 2 ]
console.log(arr.splice(0, 2)) // [ 3, 4 ]
console.log(arr.splice(0, 2)) // [ 5, 6 ]
函数式编程不会保留中间的结果,所以变量是不可变的(无状态的)。
我们可以将一个函数的执行结果交给另外一个函数去处理。
纯函数的好处
-
可预测性
纯函数的「相同输入→相同输出」特性,让代码行为完全可预测 ------ 这是纯函数最核心的价值。
-
可缓存性(Memoization),提升性能
因为纯函数的输出只依赖输入,所以可以把「输入→输出」的映射缓存起来,重复调用时直接返回缓存结果,避免重复计算。
-
可测试性
纯函数无需依赖外部环境,测试时只需关注 "输入→输出",不用模拟 / 搭建复杂的测试环境。
-
可并行执行
纯函数不依赖外部状态,也不修改外部状态,因此可以安全地在多线程 / 并发环境中执行(比如 Web Worker、Node.js 多进程),无需担心 "竞态条件"(多个线程同时修改同一变量)。
-
无 副作用 ,避免隐藏 bug
纯函数不会修改外部状态,因此不会引发 "意外的变量修改""状态污染" 等隐蔽 bug。
副作用
我们再纯函数的优点中提到了 副作用,那究竟什么是副作用呢?
副作用是函数 "本职工作(计算返回值)" 之外的 "额外操作",这些操作会影响函数外部的状态或环境,导致函数的行为不可预测、依赖外部上下文(不纯)。
常见的副作用来源:
- 函数修改了自身作用域之外的变量(全局变量、闭包变量、函数参数中的引用类型)
- 依赖随机值 / 时间等不可控因素
- 修改函数参数的原始值(尤其引用类型)
- 操作 DOM(修改页面元素、绑定事件)
- 发起网络请求
- 读写文件
- 操作本地存储
实际开发中,我们不可能完全避免副作用(比如前端必须操作 DOM、必须发网络请求),我们只能尽可能的让副作用 "可控、可追踪"。
柯里化
柯里化 是将一个 接收多个参数 的函数,转换成 一系列只接收单个参数 的函数的过程。最终通过依次调用这些单参数函数,逐步收集参数,直到所有参数收集完毕后执行原逻辑并返回结果。
有点抽象我们用一个例子来演示一下:
typescript
// 普通函数:一次接收3个参数
const add = (a: number, b: number, c: number) => {
return a + b + c
}
add(1, 2, 3) // 直接传所有参数,返回6
// 柯里化函数:分步接收参数
const curryAdd = (a: number) =>
// 接收第一个参数a,返回接收第二个参数的函数
(b: number) =>
// 接收第二个参数b,返回接收第三个参数的函数
(c: number) =>
// 接收最后一个参数c,执行原逻辑
a + b + c
// 调用方式1:分步传参(核心特征)
curryAdd(1)(2)(3) // 依次传参,返回6
// 调用方式2:也可以部分传参(偏应用)
const add1 = curryAdd(1) // 固定第一个参数为1
const add1And2 = add1(2) // 固定第二个参数为2
add1And2(3) // 传第三个参数,返回6
add1And2(4) // 复用:传新的第三个参数,返回7
柯里化的核心用途
-
参数复用(最常用)
可以固定函数的部分参数,生成一个新函数,后续复用这个新函数时无需重复传固定参数。
-
延迟执行(控制执行时机)
-
柯里化函数不会立即执行原逻辑,而是等参数收集完毕后才执行,适合需要 "先收集参数、后执行" 的场景(如事件处理、防抖节流)。
-
函数粒度细化(符合 "单一职责")
把多参数函数拆分成多个单参数函数,每个函数只负责处理一个参数,逻辑更清晰,也更容易组合(函数组合是函数式编程的核心)。
柯里化通用函数
上面的柯里化示例中,可以看出来我们是利用了高阶函数和闭包的特性,手动嵌套函数实现了函数的柯里化,但是这种方法并不通用。
lodash 中有提供一个通用的柯里化函数
typescript
import { curry } from 'lodash-es'
// 普通函数:一次接收3个参数
const add = (a: number, b: number, c: number) => {
return a + b + c
}
const curryAdd = curry(add)
curryAdd(1)(2)(3) // 6
const add1 = curryAdd(1)
const add1And2 = add1(2)
add1And2(3) // 6
add1And2(4) // 7
// 还可以这样调用
curryAdd(1, 2, 3)
curryAdd(1, 2)(3)
curryAdd(1)(2, 3)
为了便于更好的理解柯里化,我们手动模拟一下 _.curry(func) 的实现
typescript
const curry = <T extends (...arg: any[]) => any>(func: T) => {
return function curriedFn(...args: any[]) {
// 判断实参和形参的个数
if (args.length < func.length) {
// 只传部分参数,返回传递剩余参数的函数
return function (...nextArgs: any[]) {
// 拼接后续传递参数,直至实参个数大于等于行程
return curriedFn(...args.concat(nextArgs))
}
}
// 直接传递全量的参数
return func(...args)
}
}
函数组合
函数组合(Function Composition)是函数式编程中的核心概念,它的本质是将多个单功能的函数组合成一个新函数,让数据依次通过这些函数处理,最终得到结果。
我们如果利用纯函数和柯里化很容易写出洋葱代码 f(g(h(x))),例如:
typescript
// 转大写
const toUpperCase = (str: string) => str.toUpperCase();
// 去除空格
const trim = (str: string) => str.trim();
// 加感叹号
const addExclamation = (str: string) => `${str}!`;
const result = toUpperCase(trim(addExclamation(" hello world "))); // 缺点:阅读顺序是从内到外,不直观
我们只要保证这些过程是纯函数,那么就可以利用函数组合,将这个复杂的过程组合成一个函数,这样我们就只需要调用这个组合函数就可以实现功能,不用关心中间函数的输入输出。
typescript
const processStr = compose(addExclamation, trim, toUpperCase);
const result = processStr(" hello world "); // "HELLO WORLD!!"
通用组合函数
typescript
// flow 从左往右执行 flowRight从右往左执行
import { flow , flowRight } from 'lodash-es'
// 转大写
const toUpperCase = (str: string) => str.toUpperCase();
// 去除空格
const trim = (str: string) => str.trim();
// 加感叹号
const addExclamation = (str: string) => `${str}!`;
const fn = flow(toUpperCase, trim, addExclamation)
console.log(fn(' hello world '))
export default {}
模拟实现 compose:
typescript
/**
* 函数组合:从右到左执行
* @param funcs 要组合的函数数组
* @returns 组合后的新函数
*/
const compose = (...funcs: any[]) => {
return (value: any) => {
return funcs.reverse().reduce((acc, fn) => fn(acc), value)
}
}
函数组合默认是从右到左(compose),而管道函数(pipe)是从左到右,更符合日常阅读习惯
pipe (管道函数),去掉 reverse() 即可。
结合律(associativity)
函数的组合要满足结合律:单独组合某些函数,结果都是一样的
typescript
import { flowRight, toUpper, first, reverse} from 'lodash-es'
// 取得数组最后一项并转为大写
// 下面三种写法 结果一样
const fn1 = flowRight(toUpper, first, reverse)
const fn2 = flowRight(toUpper, flowRight(first, reverse))
const fn3 = flowRight(flowRight(toUpper, first), reverse)
console.log(fn1(['one', 'two', 'three'])) // THREE
console.log(fn2(['one', 'two', 'three'])) // THREE
console.log(fn3(['one', 'two', 'three'])) // THREE
调试组合函数
我们可以通过插入调试函数的方式,来打印调试上一步执行的结果。
typescript
import { flowRight, toUpper, first, reverse} from 'lodash-es'
const log = (value: string) => {
console.log(value)
return value
}
// 取得数组最后一项并转为大写
const fn = flowRight(toUpper, first, log, reverse)
fn1(['one', 'two', 'three']) // [ 'three', 'two', 'one' ]
我们也可以改造一下 log 函数,增加一个 tag 参数,方便查看。
typescript
const trace = curry((tag: string, value: any) => {
console.log(tag + ': ' + value)
return value
})
const fn1 = flowRight(toUpper, first, trace('after reverse'), reverse)
Point Free
Point Free(也译作 "无值风格" 或 "无参风格")是函数式编程中的一种编码范式,核心思想是:定义函数时不直接提及函数所操作的参数(即 "点"),而是通过组合已有的函数来实现逻辑。
简单来说,普通写法会显式写出参数,而 Point Free 写法完全隐藏参数,让代码更简洁、更专注于 "做什么" 而非 "对谁做"。
非 Point Free 模式
typescript
// 显式声明参数 str(这就是"点")
const processStr = (str) => str.trim().toUpperCase();
// 调用
console.log(processStr(" hello world ")); // 输出:HELLO WORLD
Point Free 模式
typescript
// 先定义基础函数
const trim = (str) => str.trim();
const toUpper = (str) => str.toUpperCase();
// 组合函数,完全不提及参数
const processStr = (str) => toUpper(trim(str));
// 更极致的 Point Free(借助函数组合工具)
const compose = (f, g) => (x) => f(g(x)); // 简单的组合函数
const processStrPointFree = compose(toUpper, trim);
// 调用(结果和普通写法一致)
console.log(processStrPointFree(" hello world ")); // 输出:HELLO WORLD
为什么要用 Point Free?
- 代码更简洁:去掉冗余的参数声明,聚焦核心逻辑;
- 复用性更高:通过组合已有函数实现新功能,避免重复写逻辑;
- 可读性更好 :函数名直接体现逻辑意图(比如
compose(toUpper, trim)一眼就能看出 "先去空格再转大写"); - 易于测试:组合的基础函数可单独测试,减少整体逻辑的测试复杂度。