1958 年,约翰·麦卡锡(John McCarthy)在麻省理工学院设计了 Lisp——世界上第一门函数式编程语言。它的灵感不来自硬件或工程实用性,而来自阿隆佐·邱奇(Alonzo Church)在 1930 年代发展的数学系统:Lambda 演算(Lambda Calculus)。
这个血统决定了函数式编程的气质:它首先是数学,其次才是编程。
破除误解:函数式不只是"用函数写代码"
许多人以为"函数式编程"就是多用函数、少用循环。这完全误解了这个范式的本质。
函数式编程的核心思想是:程序是数学函数的组合,而不是机器指令的序列。
数学函数有一个关键特性:引用透明性(Referential Transparency)——给定相同的输入,函数始终返回相同的输出,且不产生任何可观察的副作用。这和我们日常说的"函数"不同:
# 不是函数式的——依赖外部状态,有副作用
count = 0
def increment():
global count
count += 1
return count函数式的——纯函数,引用透明 def add(x, y): return x + y ```
纯函数(Pure Function) 是函数式编程的基本单位。它不读写全局变量,不修改传入的参数,不打印到屏幕,不写文件。它只计算并返回一个值。
Lambda 演算:数学基础
邱奇的 Lambda 演算只有三条规则,却是图灵完备的:
- 变量:$x$
- 抽象(函数定义):,表示"以 $x$ 为参数的函数"
- 应用(函数调用):,表示"把 $f$ 应用到 $x$"
比如,把两个数相加可以表示为:
邱奇还证明,任何可计算函数都可以用 Lambda 演算表达——这与图灵机等价(邱奇-图灵论题)。因此,函数式语言在理论上与命令式语言有相同的计算能力,但表达方式完全不同。
核心概念
不可变性(Immutability):函数式编程中,数据创建后不再修改。修改数据的唯一方式是创建一个新的数据副本(带有修改内容)。Haskell 的所有变量默认不可变;Clojure 的数据结构默认持久化(persistent)——修改操作返回新结构,共享未修改的部分。
高阶函数(Higher-Order Functions):函数可以接受其他函数作为参数,或返回函数作为结果。最常见的高阶函数:
- map:把函数应用到列表每个元素,返回新列表
- filter:用谓词函数筛选列表元素
- fold/reduce:把列表折叠成单一值
-- Haskell:求列表中所有偶数的平方和
sumSquaresOfEvens xs = foldr (+) 0 (map (^2) (filter even xs))
```函数组合(Function Composition):把多个函数串联,一个函数的输出成为下一个函数的输入。数学上写作 ,代码中是管道操作符或 compose 函数。这是构建复杂功能的主要方式。
柯里化(Currying):以 Haskell Curry 命名(Curry 本人并未发明,但系统研究了它)。把一个接受多个参数的函数转换为一系列每次接受一个参数的函数:。柯里化使部分应用(partial application)成为可能。更准确的出处是:这个思想最早见于 Gottlob Frege(1893),由 Moses Schönfinkel 在 1924 年的论文里系统化,Curry 再加以发展;"currying"这个名字其实是 Christopher Strachey 在 1967 年所造,Curry 晚年得知后曾表示这本应归功于 Schönfinkel。
惰性求值(Lazy Evaluation):直到真正需要值时才计算它。Haskell 默认使用惰性求值,这使得处理无限列表成为可能:
-- Haskell:定义无限的自然数列表,只取前10个
take 10 [1..] -- [1,2,3,4,5,6,7,8,9,10]
```函数式编程的主要语言
| 语言 | 年份 | 特点 |
|---|---|---|
| Lisp | 1958 | 第一门函数式语言,S 表达式语法 |
| ML | 1973 | 引入类型推断,影响了 Haskell、F# |
| Haskell | 1990 | 纯函数式、惰性求值、强类型 |
| Erlang | 1987 | 面向电信并发,OTP 框架 |
| Clojure | 2007 | JVM 上的 Lisp,持久化数据结构 |
| F# | 2005 | .NET 生态的函数式语言 |
| Scala | 2004 | 函数式 + 面向对象的混合 |
| Elm | 2012 | 前端函数式,无运行时异常 |
许多主流语言(Python、JavaScript、Java、C++)也在逐步引入函数式特性,如 lambda 表达式、stream API、不可变数据类型。
函数式如何进入主流
很长一段时间里,函数式编程被当作学术界的玩具。真正的转折发生在 2010 年前后:不是哪一门纯函数式语言赢了,而是函数式的思想被主流语言逐个吸收。
Java 8(2014 年 3 月发布) 是标志性事件。它一次性引入了 lambda 表达式、函数式接口(functional interface)和 Stream API,让 list.stream().filter(...).map(...).collect(...) 这样的写法第一次进入企业级 Java。一门十几年里以"啰嗦"著称的语言,开始鼓励用函数组合替代显式循环。
C# 的 LINQ(随 C# 3.0 / .NET 3.5 在 2007 年推出)更早一步,把 Where、Select、Aggregate 连同延迟求值(deferred execution)带进了 .NET。这些名字换了壳,本质就是 filter、map、reduce。
JavaScript 与 React 则把函数式带到了最大的开发者群体。React 官方明确要求:组件必须是纯函数——给定相同的 props 和 state,必须返回相同的 UI,渲染期间不得产生副作用,副作用只能放进事件处理器或 Effect。这条纪律不是洁癖,而是 React 并发渲染(concurrent rendering)的前提:因为框架可能多次渲染、甚至中途暂停同一个组件,只有纯函数才不会因此出错。
配套的 Redux 把同样的原则用于状态管理:reducer 必须是纯函数,状态不可变,每次更新返回一个新状态对象。这带来两个直接好处——React 能用浅比较(shallow equality)廉价地判断"变没变、要不要重渲染",以及"时间旅行调试"(time-travel debugging)能精确地在历史状态间跳转,因为没有隐藏的副作用。
这才是函数式真正的胜利方式:不是让所有人改用 Haskell,而是让纯函数、不可变、高阶函数成为每个主流语言里的常识。
不可变性与并发:被低估的价值
初学者最大的误解是:"数据不能改,那程序还怎么干活?"在单线程时代,这个质疑听起来有道理。但到了多核与分布式时代,结论反了过来。
并发 bug 的根源几乎总是同一个:可变的共享状态(shared mutable state)。两个线程同时读写同一块数据,就会出现竞态条件(race condition)。命令式编程对付它的办法是加锁(mutex、信号量),而锁本身又带来死锁、性能下降和极难复现的 bug。
不可变性从根上消除了这个问题:如果数据永不改变,多个线程就能同时读它,完全不需要加锁;一个不可变对象可以安全地按引用传给任意线程。麻省理工的并发课程把"实现线程安全"归纳为三条路——封闭(confinement)、不可变(immutability)、加锁(locks)——其中不可变是最省心的一条。
Erlang 把这个思想推到了极致。Joe Armstrong 在爱立信为电信交换机设计的 Erlang,进程之间不共享任何内存,只通过传递不可变消息通信(actor 模型)。没有共享状态,就没有锁,也就没有那一整类并发 bug;配合"任它崩溃"(let it crash)的监督树哲学,造出了能跑多年不停机的系统。今天 WhatsApp 等大规模实时服务的后端正建立在这套模型之上。
MapReduce 是另一个被低估的例子。Google 的 Jeffrey Dean 与 Sanjay Ghemawat 在 2004 年的论文里直言:他们的抽象"受到 Lisp 及许多函数式语言中 map 和 reduce 原语的启发"。正因为 map 阶段的函数是纯的、无副作用,框架才能放心地把同一个计算自动切分到上千台机器上并行执行——这正是后来 Hadoop、Spark 这条大数据技术栈的思想源头。
至于"每次创建新副本会不会太慢"的担心,函数式社区早有答案:持久化数据结构(persistent data structures)通过结构共享(structural sharing),让"修改"只复制变化的那一小部分,其余与旧版本共享,从而把开销降到对数级。Chris Okasaki 1998 年的《Purely Functional Data Structures》是这一领域的奠基之作,Clojure、Scala 的不可变集合都用了同类技术。
单子:函数式处理副作用
纯函数不能有副作用——但真实程序必须读文件、连数据库、打印日志。函数式编程如何处理这个矛盾?
答案是单子(Monad):一种数学结构(来自范畴论),把副作用"包裹"进类型系统,在类型层面区分纯计算和有副作用的计算。Haskell 用 IO a 类型标记所有有副作用的操作,并通过 do 语法糖让代码仍然可读。
单子的解释极难,难到社区里有一句广为流传的调侃:"任何人一旦真正理解了单子,就丧失了把它讲清楚给别人听的能力。" Brent Yorgey 在 2009 年把这种现象命名为"单子教程谬误"(the Monad Tutorial Fallacy):教程作者往往直接抛出抽象,却跳过了自己当初建立直觉时反复挣扎的那些具体例子。本质上,单子是一个容器,定义了如何把一系列操作串联起来。
代价与争议
学习曲线:函数式思维对习惯了命令式的程序员来说需要思维重构。"如何用函数式写一个 for 循环"是初学者的常见困惑(答案是递归或 fold)。
性能开销:不可变数据结构需要在每次"修改"时创建新副本,可能产生大量短命对象,对垃圾回收器造成压力。现代函数式运行时(如 Haskell 的 GHC)有高度优化的内存管理,但与手工优化的命令式代码相比仍有差距。
调试困难:点自由(point-free)风格的代码——即不显式命名参数、只组合函数——可能极难阅读和调试:
``haskell
process = filter (> 0) . map (*2) . take 100
``
这段代码的意图明确,但对不熟悉的人来说像天书。
纯函数式的现实局限:Haskell 社区有一个持续的争论:单子和类型类(type class)系统带来的复杂性,是否值得纯函数式带来的好处?部分人转向了 Rust 这类"借用了函数式思想但不强制纯函数"的语言。
跨域连接
- 并发与并行:并发缺陷的根源是可变的共享状态。数据永不改变,多个线程就能同时读而无需加锁,一整类竞态随之消失。代价是每次"修改"都产生新对象,靠结构共享只复制变化的那一小部分,把开销压到对数级。推论是:线程安全的三条路——封闭、不可变、加锁——里,只有不可变不需要协调。
- 范畴论:引用透明真正买到的是等式推理——表达式可以像代数一样被替换成它的值。编译器据此才敢做惰性求值、公共子表达式消除与重排;副作用一旦引入,这条替换规则立刻失效,所有基于它的变换也随之非法。
- 语义学:组合性原则说,整体的意义由部分的意义及其组合方式决定。纯函数正是让程序满足组合性,而副作用相当于让一个词的意义依赖上下文之外的世界状态——于是局部理解不再可能,必须通读全局。推论是:把副作用集中到少数边界处,其余部分才重新获得可局部推理的性质。
- 柏拉图主义:把程序看作对与执行无关的数学真理的描述,不只是气质问题。若含义独立于求值顺序,编译器与运行时就有权改变顺序——并行、惰性、失败重算都由此获得授权。这是一条哲学立场直接兑换成的工程自由度。
- 认知心理学:点自由风格省去参数命名,而命名恰恰是人理解代码的主要抓手。可检验推论:复杂度相同时,取消中间命名会抬高理解成本——可读性之争的根源不在美学偏好,而在工作记忆能同时持有多少个未命名的中间量。同理,函数式真正进入主流靠的不是语言更替,而是纯函数与不可变成为各语言的常识。
参考文献
- Abelson, H. & Sussman, G. Structure and Interpretation of Computer Programs. MIT Press, 1996. (SICP,最好的函数式入门之一)
- Wadler, P. Monads for Functional Programming. 收录于 Advanced Functional Programming, Springer, 1995.
- Church, A. A Formulation of the Simple Theory of Types. Journal of Symbolic Logic 5(2), 1940. (Lambda 演算的基础论文)
- Dean, J. & Ghemawat, S. MapReduce: Simplified Data Processing on Large Clusters. OSDI, 2004. (明言其抽象受 Lisp 等函数式语言 map/reduce 原语启发)
- Okasaki, C. Purely Functional Data Structures. Cambridge University Press, 1998. (持久化数据结构与结构共享的奠基之作)
- React 官方文档. Keeping Components Pure. react.dev. (组件必须是纯函数,并发渲染的前提)
延伸阅读
- Hutton, G. Programming in Haskell. Cambridge University Press, 2016.
- Bird, R. Introduction to Functional Programming Using Haskell. Prentice Hall, 1998.