函数式编程范式:这次怎么落地的

同一笔订单算两次价,两次结果不一样——排查才发现是共享对象被顺手改了。函数式编程不是换语法,是把「状态往哪放、副作用往哪关」这件事想明白。

我们一个 TypeScript 订单服务,促销规则叠满减、会员折扣、渠道券,逻辑全堆在 OrderService 里。线上偶发「用户结算页价格和支付回调对不上」,日志里两次调用间隔不到 200ms,输入看起来一样,输出差几毛钱。

查到最后,根因很土:算价过程中改了一个共享的 orderContext 对象。A 请求算到一半,B 请求进来改了同一个引用上的 items,A 的后半段读到的已经是脏数据。加锁能止血,但规则一多,锁粒度、死锁、性能全是新坑。

这次重构没有换语言(还是 Node.js + TypeScript),也没有全盘 Haskell 化,而是把函数式编程里几条能直接落地的原则拆出来用:纯函数算业务、不可变传数据、副作用推到边界。下面按几个实际问题整理——函数式编程是什么、解决什么、优势在哪、哪些场景值得用、会踩什么坑、我们怎么权衡。

函数式编程是什么

一句话:把程序看成「输入 → 输出」的数据变换,尽量不让函数偷偷改外面的东西。

和命令式编程对比,差别不在「用不用 for 循环」,而在状态怎么处理:

命令式常见写法函数式倾向
状态对象字段随时改,多处共享改就是新建一份,旧数据不动
函数读全局、写外部、调接口同样输入同样输出,副作用显式隔离
组合步骤串行,隐式依赖多小函数拼大函数,依赖从参数进来

三个词够用了:纯函数(算完不留痕迹)、不可变(数据只增不改)、组合(map/filter/reduce 和高阶函数把流水线搭起来)。Monad、Functor 那些是工具箱里的进阶件,日常业务落地用不到可以先不管。

它解决什么问题

回到我们的算价 bug,本质是三类老问题叠在一起:

1. 共享可变状态难推理

多个请求、多个线程(Node 虽然是单线程,但 async 交错一样坑)同时碰同一块内存,谁先谁后不可控,bug 复现靠运气。

2. 副作用和数据逻辑缠在一起

算折扣的函数里顺手 await redis.get()、顺手改 order.total,单元测试要么 mock 一堆,要么只能走集成测试。

3. 并发和缓存不好做

函数有隐藏依赖,就没法安全并行,也没法放心 memoize——今天缓存命中了,明天外部状态变了,结果还是旧的。

函数式编程不消灭副作用(程序总得写库、调接口),而是把「算」和「做」分开:算的部分纯,做的部分集中到少数入口。

有什么优势

这次重构后,体感最明显的四点:

可测试。 促销规则拆成 (items, rules) => PricedItems,测试用例就是几组输入输出,不启数据库、不 mock Redis。

可并行。 不同 line item 的折扣互不影响,后面规则变复杂了可以直接 Promise.all 并行算,不用先画锁图。

可缓存。 纯函数 (cart, ruleSetVersion) => price 可以按参数做 memo,规则版本号不变结果就不变。

变更局部化。 新增「满三件打八折」只加一条规则函数,不用在 800 行的 calculate() 里找该改哪几个 if

// 之前:一个方法改共享 context
function applyMemberDiscount(ctx: OrderContext) {
  ctx.total *= ctx.memberRate  // 副作用:改外部对象
}

// 之后:返回新对象,输入不变
function applyMemberDiscount(
  priced: PricedCart,
  memberRate: number
): PricedCart {
  return {
    ...priced,
    total: priced.total * memberRate,
    appliedRules: [...priced.appliedRules, 'member'],
  }
}

哪些业务场景值得用

不是哪都该函数式,我们按场景划了四档:

很适合(团队默认这么写)

  • 定价、计费、对账、报表聚合——规则多、组合多、输错一块钱都是事故,纯函数最好测。
  • React / Redux 前端状态——UI = f(state),reducer 必须纯;Hooks 把副作用关进 useEffect
  • 数据清洗 / ETL 管道——一行行 map → filter → reduce,中间状态透明,出错了好回放。
  • 事件溯源 / 审计日志——状态是事件 fold 出来的,天然不可变。

可以用,但别教条

  • 普通 CRUD 服务——读多写少的查询组装适合纯函数;事务边界、ORM 保存还是命令式更顺。
  • 配置校验、表单校验——用 schema + 纯校验函数很香;提交动作本身必然是副作用。

谨慎用

  • 硬实时、微秒级热点路径——不可变结构频繁拷贝,GC 压力要实测;我们没在核心撮合引擎上推 FP,只在周边规则引擎用。
  • 强 OO 领域模型——DDD 里聚合根就是要封装变更,硬改成全不可变值对象,团队读不懂也写不动。

别为了用而用

  • 简单增删改查、三人小项目、生命周期不到半年的脚本——引入 immutable 库 + 一堆 spread,代码量翻倍,收益接近零。

它会带来什么问题

落地过程中我们踩过的坑,比文档里写的「学习曲线陡」具体:

对象拷贝开销。 大购物车每次规则都 {...cart, items: cart.items.map(...)}, profiling 能看到分配次数上去。后来对 items 用结构共享(只拷贝变了的行),热点路径才acceptable。

调试栈变深。 五层 pipe(fn1, fn2, fn3, fn4, fn5) 报错栈里全是高阶函数,新人定位慢。折中:核心业务链保持扁平,组合只用在非热点辅助逻辑。

类型体操。 TypeScript 里嵌套 ReturnType<typeof ...> 写到吐。我们约定:导出函数签名手写 interface,不在业务代码里推导十层。

和现有命令式代码接缝。 老模块还是 class + mutable field,新模块 immutable,边界处要么 adapter 一层层转,要么整块重写。我们选了新功能新写法、老代码触达时再改,避免大爆炸重构。

团队认知分裂。 有人把 FP 理解成「不能用 let」,有人当成「可以用任何炫技库」。代码 review 花了不少时间对齐:到底什么是「够用的 FP」。

我们怎么权衡

最后定了几条团队规则,不是理论最优,是能持续写下去

graph TD A[这段逻辑要写/改] --> B{有没有 IO 或持久化?} B -->|有| C[放 adapter / service 边界] B -->|没有| D{规则会不会被组合复用?} D -->|会| E[纯函数 + 不可变数据] D -->|不会| F{团队都能读懂吗?} F -->|能| E F -->|不能| G[命令式写清楚也行<br/>别为了范式牺牲可读性]

1. 业务规则默认纯函数。 算价、资格判断、库存扣减计算(不是实际扣库)都走 (input) => output,测试覆盖率先拉这部分。

2. 副作用只在边界出现一次。 Controller 收请求 → 调纯函数算结果 → Repository 写库。不在循环里偷偷 await db.update

3. 不可变用「够用」级别。 普通对象 spread + readonly 类型够用就不上 Immer;Immer 只在大列表频繁更新时用。

4. 性能敏感先量后改。 没 profiling 数据不准为了可变回去;有数据证明拷贝是瓶颈,局部可变或结构共享,不整篇回滚。

5. 可读性一票否决。 同样逻辑,纯函数版本比命令式难懂,就写命令式。范式是手段,不是 KPI。

这次落地长什么样

算价模块拆成三层,和上面规则对应:

HTTP Handler          ← 副作用:鉴权、读库、写库、发消息
    ↓
PricingEngine         ← 纯:规则管道,输入 CartSnapshot,输出 PricedCart
    ↓
Rule functions        ← 纯:每条促销一条 (cart) => cart'

CartSnapshot 进引擎前 deep freeze(开发环境)或 TypeScript Readonly 约束,防止手滑。规则注册表按优先级排序,引擎就是 fold:

type Rule = (cart: PricedCart) => PricedCart

function runRules(cart: PricedCart, rules: Rule[]): PricedCart {
  return rules.reduce((acc, rule) => rule(acc), cart)
}

上线跑了一阵,「同单不同价」归零;单测从 12 个集成用例扩到 80+ 个纯函数用例,CI 时间反而短了(不用起容器)。代价是算价相关 PR 的 diff 经常整文件是 spread 和解构,review 时要盯有没有漏拷贝层级。

收个尾

函数式编程是什么?一种把计算和副作用拆开、用不可变数据换可推理性的写法。 它解决的是共享状态、隐式依赖、测试和并发难搞这类问题;在定价、前端状态、数据管道、事件溯源这类场景里,算是被反复验证过的最佳实践方向。

它也会带来拷贝开销、调试成本、团队分裂——所以我们的权衡很现实:边界副作用、中间纯计算、可读性优先、渐进替换。 没追求「纯函数原教旨」,也没回到一个大类包打天下。

如果你也在纠结要不要上 FP,可以先找一块规则多、测起来痛苦、又不太吃纳秒级性能的模块试。算价、校验、报表都行。试完你会发现,函数式编程真正落地的,往往不是语法,而是敢不敢让数据流动起来,而不是到处改同一块内存

版权声明: 本文首发于 指尖魔法屋-函数式编程范式:这次怎么落地的https://blog.thinkmoon.cn/post/5-functional-programming-paradigm-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!