函数式编程范式:这次怎么落地的
同一笔订单算两次价,两次结果不一样——排查才发现是共享对象被顺手改了。函数式编程不是换语法,是把「状态往哪放、副作用往哪关」这件事想明白。
我们一个 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」。
我们怎么权衡
最后定了几条团队规则,不是理论最优,是能持续写下去:
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/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。