whyfp

2026-04-301 出链

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 提供两种新的粘合剂——高阶函数(粘函数)和惰性求值(粘数据),让你能切出更小、更可复用的模块

核心论点

  • 函数式编程重要,是因为它提供了更强的组合胶水,从而提升模块化能力:高阶函数和惰性求值显著提升
    1. 高阶函数(函数之间的组合):把控制结构/递归模式抽出来,把稳定结构和变化逻辑分离
    1. 惰性求值(数据的生产者和消费者可以独立写):把生产者消费者解耦

模块化能力 = 切分能力 × 粘合能力。FP 通过高阶函数(粘函数) 和惰性求值(粘数据流),把粘合能力拉高了一个数量级。

FP 是一套减少隐式状态、隐式控制流和隐式副作用的方法:用不可变性、纯函数和显式 effect 降低推理成本,用高阶函数隔离遍历与控制结构,并让类型系统承载约束。 FP 的工程价值是把复杂度从隐式状态、控制流和副作用中移出,放进可组合的函数、明确的数据流与可检查的类型约束中。

为什么"模块化"是关键

软件工程界的共识是:要解决大问题,先把它分解成小问题、独立求解、再组合起来。但这个流程的分解粒度受限于一件事——你能不能把切开的部分粘回去

Our ability to decompose a problem into parts depends directly on our ability to glue solutions together.

如果粘合方式很弱,你就不敢切得太细——切出来的零件无法独立写、无法复用,反而增加成本。所以判断一种语言/范式是否真的"模块化友好",看的不是语法,而是它提供了哪些粘合剂

Hughes 的论证策略:不去争论 FP 缺了什么,而是展示它多了什么

粘合剂 1:高阶函数

把递归模式抽成函数

以列表求和为例:

haskell
sum []     = 0
sum (x:xs) = x + sum xs

把"初始值 0"和"组合操作 +"抽出来,就得到 foldr

haskell
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) 0

foldr递归遍历这个稳定结构抽出来,让你只写变化的逻辑(f, z)。这就是把"控制结构"模块化。

推广到树

同样的套路对树结构成立:

haskell
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 把它分开:

haskell
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 负责消费/收敛判定
  • 替换 withinrelative(相对误差),或替换 next 为别的迭代函数(数值积分、求导),全部即插即用

这一步已经依赖惰性求值——repeat 产生的是无限序列,能跑起来正是因为 within 只取它需要的前几个。

粘合剂 2:惰性求值

解耦生产者和消费者

惰性求值让"算什么"和"算多少"分离。生产者写得像在产生无限数据,消费者按需取用,运行时只算用到的部分。

这给了一种新的程序结构:

program = consumer . producer

不再需要在生产侧写if 已经够了 then 停,也不需要在消费侧写如果还不够 then 通知再产一个。两边都"假装对方是无限/无穷耐心的"。

Alpha-Beta 剪枝:AI 中的解耦

下棋程序传统上把"展开博弈树"和"剪枝评估"塞在同一个递归里,以避免展开爆炸。Hughes 展示了用惰性求值如何拆开:

haskell
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 . gametree
  • gametree 假装可以无限展开
  • prune 控制深度
  • maximize 在最大/最小化交替时做剪枝(alpha-beta)
  • static 是静态评估函数

每一段都是独立的、可单独测试和替换的纯函数。惰性求值保证了概念上的无限树不会真的被全部构造出来——只构造 maximize 实际访问到的那部分。

与命令式的对比

命令式语言也有"粘合剂":函数调用、模块、类。但:

  • 函数只能粘一阶值,不能粘函数本身(除非语言支持高阶函数,但即使支持,赋值/副作用的存在让组合的性质难以推理)
  • 数据流只能用一次性中间集合传递(要么提前算好整个列表占内存,要么写个 callback 反转控制流),无法像惰性那样"按需挤牙膏"
  • 副作用让"等式推理"失效——你不能简单把 f(x) 替换成它的定义,因为 f 可能改了某个全局状态

因此命令式中"模块的最小单位"被迫做大,模块化的天花板更低。

理论基础:无类型 λ 演算

λ 演算把函数抽象与函数应用作为原生概念,是函数式编程的理论基础。Church encoding 进一步说明,仅用函数也可以表示数字、布尔和控制结构。

https://www.bilibili.com/video/BV1hX4y1i7hY

目标:将函数抽象(定义)函数应用(调用)作为逻辑系统的原生概念,从而推理出其他概念与定理

  1. 变量(符号)表示一切: 比如函数名称 f1, f2, x...
  2. 抽象出函数: 函数定义 (λ p. body)
  3. 函数的应用:函数调用 (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:补回惰性求值这块拼图
  • 对工程师的启示:评估一个抽象/库的好坏,问它的粘合能力如何——能不能让我切得更细、组合更自由
  • abstraction 互补:抽象是"切",FP 谈的是"粘"
  1. https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf
  2. https://zackoverflow.dev/writing/influence-of-fp-on-the-mainstream
  3. https://www.developing.dev/p/co-creator-of-haskell-functional
  4. https://www.developing.dev/p/creator-of-ocaml-functional-programming

评论