whyfp
Functional programming = programming with values instead of mutation.
函数式程序的计算主要表现为值之间的转换,而不是不断修改一个世界状态。mutation 本质上会产生隐式 coupling。
FP 并不是让程序不存在依赖,而是迫使 dependency 显式化。
当 expression evaluation 与 effect execution 分离后,必须重新提供一种描述 effect sequencing 的机制。 IO 的真正含义:不是执行副作用,而是描述副作用。
强类型系统的最终价值不是检查 int/string,而是让程序的 invariants、effects 和允许的 program space 变成机器可检查的结构。
S 软件越来越大,开发者依赖模块化来对抗复杂度——把大问题切成小问题,分别求解再粘起来
C 但传统观点把 FP 推销为"没有赋值、没有副作用",强调它"不让你做什么",这听起来像戴着镣铐跳舞,无法解释为什么要选 FP
Q FP 的正面价值究竟在哪?为什么"少了赋值"反而能让程序更好?
A 模块化的上限取决于粘合方式(glue)的能力。FP 提供两种新的粘合剂——高阶函数(粘函数)和惰性求值(粘数据),让你能切出更小、更可复用的模块
核心论点
- 函数式编程重要,是因为它提供了更强的
组合胶水,从而提升模块化能力:高阶函数和惰性求值显著提升 -
- 高阶函数(函数之间的组合):把
控制结构/递归模式抽出来,把稳定结构和变化逻辑分离
- 高阶函数(函数之间的组合):把
-
- 惰性求值(数据的生产者和消费者可以独立写):把
生产者和消费者解耦
- 惰性求值(数据的生产者和消费者可以独立写):把
模块化能力 = 切分能力 × 粘合能力。FP 通过
高阶函数(粘函数) 和惰性求值(粘数据流),把粘合能力拉高了一个数量级。
FP 是一套减少隐式状态、隐式控制流和隐式副作用的方法:用不可变性、纯函数和显式 effect 降低推理成本,用高阶函数隔离遍历与控制结构,并让类型系统承载约束。 FP 的工程价值是把复杂度从隐式状态、控制流和副作用中移出,放进可组合的函数、明确的数据流与可检查的类型约束中。
为什么"模块化"是关键
软件工程界的共识是:要解决大问题,先把它分解成小问题、独立求解、再组合起来。但这个流程的分解粒度受限于一件事——你能不能把切开的部分粘回去。
Our ability to decompose a problem into parts depends directly on our ability to glue solutions together.
如果粘合方式很弱,你就不敢切得太细——切出来的零件无法独立写、无法复用,反而增加成本。所以判断一种语言/范式是否真的"模块化友好",看的不是语法,而是它提供了哪些粘合剂。
Hughes 的论证策略:不去争论 FP 缺了什么,而是展示它多了什么。
粘合剂 1:高阶函数
把递归模式抽成函数
以列表求和为例:
sum [] = 0
sum (x:xs) = x + sum xs把"初始值 0"和"组合操作 +"抽出来,就得到 foldr:
foldr f z [] = z
foldr f z (x:xs) = f x (foldr f z xs)
sum = foldr (+) 0
product = foldr (*) 1
anyTrue = foldr (||) False
length = foldr (\_ n -> n + 1) 0foldr 把递归遍历这个稳定结构抽出来,让你只写变化的逻辑(f, z)。这就是把"控制结构"模块化。
推广到树
同样的套路对树结构成立:
data Tree a = Node a [Tree a]
foldTree f g z (Node x ts) = f x (foldr g z (map (foldTree f g z) ts))一旦有了 foldTree,所有"对树做某种聚合"的操作都退化成传两个函数。
牛顿求平方根:把无限序列与收敛判定解耦
经典的"牛顿迭代法"求平方根,命令式写法把"产生下一个近似"和"判断收敛"缠在一个 while 循环里。FP 把它分开:
next n x = (x + n/x) / 2
repeat f a = a : repeat f (f a) -- 无限迭代序列
within eps (a:b:rest)
| abs (a-b) <= eps = b
| otherwise = within eps (b:rest)
sqrt n = within 1e-6 (repeat (next n) 1.0)repeat负责生产within负责消费/收敛判定- 替换
within为relative(相对误差),或替换next为别的迭代函数(数值积分、求导),全部即插即用
这一步已经依赖惰性求值——repeat 产生的是无限序列,能跑起来正是因为 within 只取它需要的前几个。
粘合剂 2:惰性求值
解耦生产者和消费者
惰性求值让"算什么"和"算多少"分离。生产者写得像在产生无限数据,消费者按需取用,运行时只算用到的部分。
这给了一种新的程序结构:
program = consumer . producer
不再需要在生产侧写if 已经够了 then 停,也不需要在消费侧写如果还不够 then 通知再产一个。两边都"假装对方是无限/无穷耐心的"。
Alpha-Beta 剪枝:AI 中的解耦
下棋程序传统上把"展开博弈树"和"剪枝评估"塞在同一个递归里,以避免展开爆炸。Hughes 展示了用惰性求值如何拆开:
gametree p = Node p (map gametree (moves p)) -- 概念上无限的博弈树
prune 0 (Node p _) = Node p []
prune n (Node p ts) = Node p (map (prune (n-1)) ts)
evaluate = maximize . maptree static . prune 5 . gametreegametree假装可以无限展开prune控制深度maximize在最大/最小化交替时做剪枝(alpha-beta)static是静态评估函数
每一段都是独立的、可单独测试和替换的纯函数。惰性求值保证了概念上的无限树不会真的被全部构造出来——只构造 maximize 实际访问到的那部分。
与命令式的对比
命令式语言也有"粘合剂":函数调用、模块、类。但:
- 函数只能粘
一阶值,不能粘函数本身(除非语言支持高阶函数,但即使支持,赋值/副作用的存在让组合的性质难以推理) - 数据流只能用
一次性中间集合传递(要么提前算好整个列表占内存,要么写个 callback 反转控制流),无法像惰性那样"按需挤牙膏" - 副作用让"等式推理"失效——你不能简单把
f(x)替换成它的定义,因为 f 可能改了某个全局状态
因此命令式中"模块的最小单位"被迫做大,模块化的天花板更低。
理论基础:无类型 λ 演算
λ 演算把函数抽象与函数应用作为原生概念,是函数式编程的理论基础。Church encoding 进一步说明,仅用函数也可以表示数字、布尔和控制结构。
目标:将
函数抽象(定义)、函数应用(调用)作为逻辑系统的原生概念,从而推理出其他概念与定理
- 变量(符号)表示一切: 比如函数名称 f1, f2, x...
- 抽象出函数: 函数定义 (λ p. body)
- 函数的应用:函数调用 (f p) NOTE: 函数定义时,参数只能一个
函数等价性!
约定:全大写变量,表示符合演算的3个规则
演算:数字(整数)
用
函数应用(调用)次数表示数字,强调的是动作执行的次数
1 -> f(x) 2 -> f(f(x)) 3 -> f(f(f(x)))
one = () => f(x) one = (f, x) => f(x) one = f => x => f(x) 柯里化 two = f => x => f(f(x)) zero = f => x => x 调用零次
转换成阿拉伯数:x为0,f为+1
演算:布尔
两个值互斥;布尔看作选择器
TRUE = x => y => x FALSE = x => y => y
演算:IF
IF = bool => value1 => value2 => bool(value1)(value2)
IF(TRUE)('yes')('no') => 'yes' IF(FALSE)('yes')('no') => 'no'
根据函数等价性:IF = bool => bool
演算:IS_ZERO(数字+IF)
IS_ZERO = n => n( ()=>FALSE )(TRUE) // n 为数字,接收两个参数f和x // n == ZERO, return TRUE; ZERO 不会调用f,直接返回x,即TRUE // n != ZERO, return FALSE; 非ZERO 会调用f,直接返回FALSE
演算:加一
UP = n => f => x => f(n(f)(x)) // n(f)(x) 表示数字n // f() 表示加1
演算:减一
PAIR = x => y => f(x)(y) FIRST = pair => pair(x => y => x) SECOND = pair => pair(x => y => y)
// 接收两个连续数字,把窗口向右滑一位数 // 连续n次调用SLIDE,得到[n-1, n] // 传入 n 的到 [n-1, n] // n(SLIDE)(PAIR(ZERO)(ZERO)) SLIDE 为 f; PAIR(ZERO)(ZERO) 为x; 和数字一样,即为调用 SLIDE n 次; SLIDE = p => PAIR(SECOND(p))(UP(SECOND(p)))
DOWN = n => FIRST(n(SLIDE)(PAIR(ZERO)(ZERO)))
影响与延伸
- 这篇文章是为 FP 正名的
奠基之作,把讨论从"FP 是什么"转向"FP 给我什么" - 现代很多设计直接继承这个视角:
- Iterator / Stream / Reactive:本质是把
惰性序列移植回主流语言 - Map/Reduce、Spark RDD:foldr 思想的工业化
- React 的组件组合、Hooks:高阶函数式的 UI 模块化
- Go 1.23 iter 包、Rust Iterator trait:补回惰性求值这块拼图
- Iterator / Stream / Reactive:本质是把
- 对工程师的启示:评估一个抽象/库的好坏,问
它的粘合能力如何——能不能让我切得更细、组合更自由 - 与 abstraction 互补:抽象是"切",FP 谈的是"粘"
评论