Skip to content

函数式编程核心:纯函数、不可变、高阶函数、组合

函数式编程(Functional Programming, FP)常被视为一门“高深”的编程范式,充斥着 mapreducecurry 等术语。但剥离其数学外衣,它的核心思想极为务实:构建可预测、可测试、可组合的代码

在现代 JavaScript 和 TypeScript 开发中,函数式编程并非“是否要学”的问题,而是“如何用好”的问题。pipecompose 作为函数式流水线的基石,正是这种思想的集中体现。

要理解它们,我们必须先回归函数式编程的四大支柱:纯函数、不可变性、高阶函数、组合

纯函数:可预测性的根基

一个函数是“纯”的,当且仅当:

  1. 相同的输入,永远返回相同的输出
  2. 没有副作用(不修改外部状态、不发起网络请求、不读写文件等)。

例如:

ts
const add = (a: number, b: number): number => a + b

这个函数是纯的。无论你调用多少次 add(2, 3),它总是返回 5

而下面这个函数是“不纯”的:

ts
let counter = 0
const increment = (): number => ++counter

因为 increment() 的返回值依赖于外部状态 counter,相同的输入(无输入)可能产生不同的输出。

为什么纯函数重要?

  • 可测试:你不需要 mock 全局变量或网络请求。
  • 可缓存:结果可以被记忆化(memoize)。
  • 可并行:没有共享状态,无需锁机制。
  • 可推理:函数行为完全由输入决定,代码更容易理解。

在工具库中,mapfilterdebounce(若正确实现)都是纯函数的典范——它们不修改原数据,只返回新值。

不可变性:状态的稳定契约

不可变性(Immutability)意味着:一旦创建一个值,它就不能被更改

在 JavaScript 中,原始类型(numberstring 等)天然是不可变的,但对象和数组是可变的:

ts
const arr = [1, 2, 3]
arr.push(4) // 原数组被修改

这在复杂应用中是危险的:你无法确定某个函数是否偷偷修改了你的数据。

不可变性要求我们始终返回新引用:

ts
const append = <T>(arr: T[], item: T): T[] => [...arr, item]

const newArr = append([1, 2, 3], 4) // [1, 2, 3, 4]
// 原数组 [1, 2, 3] 未被修改

radash 中的 omitpickdeepSet 等函数都遵循这一原则。它们不修改传入的对象,而是创建一个新对象,仅在必要路径上创建新引用(结构共享)。

不可变性的优势:

  • 避免意外修改:数据流清晰,调试更容易。
  • 简化状态管理:在 React、Vue 等框架中,不可变更新能高效触发重渲染。
  • 支持时间旅行:你可以保存历史状态,实现撤销/重做。

高阶函数:函数的“元操作”

高阶函数(Higher-Order Function)是指满足以下任一条件的函数:

  1. 接受一个或多个函数作为参数。
  2. 返回一个函数。

debounce 就是一个典型的高阶函数:

ts
const debouncedFn = debounce(fn, 300)

debounce 接受函数 fn 作为参数,并返回一个新的函数 debouncedFn。这个新函数封装了防抖逻辑。

其他例子:

  • retry(fn):返回一个带重试机制的函数。
  • memoize(fn):返回一个带缓存的函数。
  • curry(fn):返回一个可以逐步接收参数的函数。

高阶函数的强大之处在于:它们让函数成为可操作的数据

你可以像传递字符串或数字一样传递函数,并通过高阶函数为其“添加能力”。这正是函数式编程的“装饰器”模式。

组合:从简单到复杂的桥梁

组合(Composition)是函数式编程的终极武器。

它的思想很简单:将多个小函数连接起来,形成一个大函数

数学上,函数组合定义为:

(f ∘ g)(x) = f(g(x))

在代码中,这意味着:

ts
const toUpper = (s: string) => s.toUpperCase()
const exclaim = (s: string) => `${s}!`
const length = (s: string) => s.length

// 组合:先转大写,再加感叹号,最后取长度
const transform = (s: string) => length(exclaim(toUpper(s)))

但这不够优雅。我们希望有更声明式的方式。

pipecompose:函数式流水线的基石

pipecompose 就是为函数组合而生的工具。

pipe:从左到右的流水线

pipe 将多个函数从左到右依次执行,前一个函数的输出作为下一个函数的输入。

ts
const pipe = <T>(...fns: Function[]) => (value: T) => 
  fns.reduce((acc, fn) => fn(acc), value)

使用 pipe,上面的例子变为:

ts
const transform = pipe(
  toUpper,
  exclaim,
  length
)

transform('hello') // 7

这就像一条工厂流水线:原始数据 hello 进入,经过“转大写”、“加感叹号”、“取长度”三道工序,最终产出 7

compose:从右到左的组合

composepipe 逻辑相同,但执行顺序相反:

ts
const compose = <T>(...fns: Function[]) => (value: T) =>
  fns.reduceRight((acc, fn) => fn(acc), value)

因此:

ts
const transform = compose(length, exclaim, toUpper)

等价于 length(exclaim(toUpper(x)))

compose 更贴近数学的 f ∘ g 写法,但 pipe 更符合人类从左到右的阅读习惯。现代工具库(如 radash)通常优先提供 pipe

类型安全的 pipe:TypeScript 的挑战

实现一个类型安全的 pipe 是对 TypeScript 泛型的深度考验。

理想情况下,我们希望:

ts
const fn = pipe(
  (x: number) => x.toString(),    // number => string
  (s) => s.length,                // string => number
  (n) => n * 2                    // number => number
)
// fn 的类型应自动推导为 (x: number) => number

这需要利用函数重载或泛型数组的递归推导。一个简化实现如下:

ts
type UnaryFn<T, R> = (arg: T) => R

declare function pipe<A, B>(fn1: UnaryFn<A, B>): UnaryFn<A, B>
declare function pipe<A, B, C>(
  fn1: UnaryFn<A, B>,
  fn2: UnaryFn<B, C>
): UnaryFn<A, C>
declare function pipe<A, B, C, D>(
  fn1: UnaryFn<A, B>,
  fn2: UnaryFn<B, C>,
  fn3: UnaryFn<C, D>
): UnaryFn<A, D>
// 可继续扩展...

虽然手动重载繁琐,但它确保了类型系统能精确追踪每一步的输入输出。

组合的威力:构建声明式业务逻辑

pipe 的真正价值,在于它让复杂逻辑变得可读、可维护。

例如,处理用户输入:

ts
const processInput = pipe(
  trim,           // 去除首尾空格
  escapeHtml,     // 转义 HTML
  limitLength(100), // 限制长度
  validateEmail   // 验证是否为邮箱
)

这段代码无需注释。它本身就是文档。

更进一步,你可以组合异步函数(asyncPipe),或并行执行多个任务(parallel),从而构建出完整的函数式应用架构。

结语:从命令式到声明式的跃迁

函数式编程的核心,不是避免 for 循环,而是提升抽象层级

当你使用 map 而非 for 循环,你不再关心“如何遍历”,而是声明“我要映射”。

当你使用 pipe 而非一系列变量赋值,你不再关心“中间状态”,而是声明“数据流经哪些转换”。

pipecompose 是这一思想的极致体现:它们将程序视为数据在函数流水线中的流动

手写 radash,正是为了深入这些核心概念。当你亲手实现 pipe,你不仅学会了如何连接函数,更学会了如何设计可组合的 API

而这,正是构建大型、可维护系统的关键。