You Should Learn Functional Programming in 2018(函数式编程入门与现代 JavaScript 实践)
原文发表于 2018 年。本文保留原文从 Lambda 演算、Lisp、Elixir 到高阶函数和不可变状态的路线,同时补充 JavaScript 语境下的纯函数、副作用、组合、异步和并发边界。
**先说明一个容易被夸大的结论:**函数式编程不是“取代面向对象”的唯一答案,也不是禁止一切变量修改。它是一组帮助我们控制数据流和副作用的思想;现代 JavaScript 通常采用混合范式。
一、简短历史
函数式编程有很长的历史。Lisp 在 1958 年问世,它把列表、符号计算和函数作为一等概念;更早的 Lambda 演算则提供了研究“函数抽象”和“函数应用”的数学模型。Clojure、Scala、Erlang、Haskell、Elixir 等语言,分别以不同方式吸收了函数式思想。
Lambda 演算常用两个概念解释:
- 函数抽象(function abstraction):给一个表达式引入参数和名称;
- 函数应用(function application):将具体值传入函数并求值。
例如,下面的表达式表示一个把参数加一的函数:
f = λx. x + 1
f(5) => 5 + 1
这段表示法有助于理解函数式编程的理论来源,但 JavaScript 开发不需要直接使用 Lambda 演算。JavaScript 的函数、闭包和表达式已经提供了相似的实践工具。

图片来源:Wikimedia Commons:NASA Computer Room 7090。图片来自美国国家档案与记录管理局,Wikimedia 页面标注了其版权/授权信息;重新发布时仍应遵守来源页面的许可要求。
二、函数式编程的核心概念
2.1 函数是一等值
函数是一等值,意味着函数可以像数字、字符串和对象一样:
- 保存到变量;
- 作为参数传给另一个函数;
- 作为函数的返回值;
- 放入数组或对象中。
Elixir 原文示例可以写成:
double = fn x -> x * 2 end
double.(2) # 4
JavaScript 中对应的写法是:
const double = value => value * 2
console.log(double(2)) // 4
console.log([1, 2, 3].map(double)) // [2, 4, 6]
函数表达式、箭头函数和普通函数都可以是一等值。箭头函数不会创建自己的 this、arguments 和 prototype,因此选择箭头函数还是普通函数应根据调用语义,而不是因为“函数式编程必须使用箭头函数”。
2.2 高阶函数
高阶函数(higher-order function)至少满足下面一个条件:接收函数作为参数,或返回一个函数。map 是典型例子:它把一个转换函数应用到集合的每个元素,并返回新数组。
const numbers = [1, 2, 3, 4, 5]
const doubled = numbers.map(value => value * 2)
console.log(doubled) // [2, 4, 6, 8, 10]
console.log(numbers) // [1, 2, 3, 4, 5]
map 不会改变原数组,但回调函数仍然可能修改外部变量或元素对象。因此“使用高阶数组方法”不等于“代码自动纯净”。
const users = [{ name: 'A', active: true }, { name: 'B', active: false }]
// 转换数据,不修改 users
const names = users.filter(user => user.active).map(user => user.name)
// 虽然使用了 map,但仍然产生了副作用
const result = users.map(user => {
user.seen = true
return user
})
console.log(names) // ['A']
console.log(result === users) // false,但 result 中的对象仍与 users 共享
2.3 不可变性(immutable state)
在 Elixir 这样的语言中,数据结构本身通常不可变:更新操作会返回新值,而不会修改旧值。
iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> put_elem(tuple, 1, "world")
{:ok, "world"}
iex> tuple
{:ok, "hello"}
JavaScript 的对象和数组默认可变。我们可以通过约定、复制、Object.freeze() 或专门的不可变数据结构来降低意外修改:
const original = Object.freeze({
name: 'hello',
tags: Object.freeze(['js'])
})
// 严格模式下直接修改会抛错;非严格模式下可能静默失败
// original.name = 'world'
const updated = {
...original,
name: 'world',
tags: [...original.tags, 'fp']
}
console.log(original) // { name: 'hello', tags: ['js'] }
console.log(updated) // { name: 'world', tags: ['js', 'fp'] }
Object.freeze() 只进行浅冻结,嵌套对象仍需要递归冻结;它也不等同于深拷贝。工程中不必把所有对象都冻结,通常只在状态边界、开发检查或明确需要不可变语义的地方使用。
2.4 纯函数
纯函数在相同输入下总是得到相同输出,并且没有可观察的外部副作用:
function add(a, b) {
return a + b
}
function formatUser(user) {
return `${user.name} <${user.email}>`
}
下面的函数不是纯函数,因为它读取时间、随机数或修改外部变量:
const events = []
function record(value) {
events.push(value)
return value
}
function now() {
return Date.now()
}
纯函数并不意味着“函数内部绝不能调用任何 API”。关键在于副作用是否被隔离、输入是否显式、结果是否可预测。例如可以把时间作为参数传入:
function isExpired(expiresAt, currentTime) {
return currentTime >= expiresAt
}
console.log(isExpired(100, 120)) // true
实际应用通常采用“纯核心 + 副作用边界”:网络、DOM、日志、存储和随机数在边界处完成,核心数据转换尽量保持纯净。
2.5 副作用
副作用包括但不限于:
- 修改外部变量、对象或数组;
- 读写 DOM、文件、数据库、网络和
localStorage; - 输出日志;
- 修改随机数生成器、时间或全局配置;
- 抛出异常或触发可观察的异步操作。
副作用不是错误。没有副作用,程序无法与世界交互;函数式编程的目标是让副作用显式、集中且容易测试,而不是把副作用伪装成不存在。
三、函数式编程的应用价值
原文提到云计算、可扩展性、并发和分布式系统,这些方向今天仍然重要,但不能把某个公司的规模表现简单归因于函数式编程。WhatsApp 使用 Erlang、Discord 使用 Elixir 的案例说明了语言运行时、消息模型、团队工程实践等因素的价值,不是“只要使用函数式语言就能获得同样结果”。原文中的 2015 年 WhatsApp 用户数和 Discord 文章统计也属于当时的历史数据,不应当作当前指标。相关历史资料可读:Wired 对 WhatsApp 的 2015 年报道、Discord 的 GenStage 案例。
函数式思想在现代 JavaScript 中常见于:
- 用
map、filter、reduce表达数据转换; - 使用纯函数编写校验、格式化和状态更新;
- 用不可变更新降低共享状态带来的耦合;
- 用组合函数构建可复用的数据处理管道;
- 将 I/O 和 UI 更新放在清晰的边界;
- 用
Promise、async/await描述异步数据流。
3.1 组合(composition)
组合是把小函数连接成更大的函数。下面的 pipe 从左到右执行:
const pipe = (...functions) => input =>
functions.reduce((value, fn) => fn(value), input)
const trim = value => value.trim()
const parseNumber = value => Number(value)
const doubleNumber = value => value * 2
const parseAndDouble = pipe(trim, parseNumber, doubleNumber)
console.log(parseAndDouble(' 21 ')) // 42
要组合异步函数,需要明确 Promise 边界,不能直接把同步 reduce 当作异步管道:
const pipeAsync = (...functions) => input =>
functions.reduce(
(promise, fn) => promise.then(fn),
Promise.resolve(input)
)
const loadAndFormat = pipeAsync(
async id => ({ id, name: 'Ada' }),
async user => user.name.toUpperCase()
)
loadAndFormat(1).then(console.log) // ADA
3.2 闭包
闭包是函数与其词法环境的组合。它可以保存状态,但保存状态不代表状态一定不可变:
function createCounter() {
let count = 0
return {
read: () => count,
increment: () => ++count
}
}
const counter = createCounter()
console.log(counter.read()) // 0
counter.increment()
console.log(counter.read()) // 1
闭包可以帮助隐藏实现细节,但也可能因为长期保存大对象、事件处理器或定时器而延长对象生命周期。需要在组件销毁时移除监听器,在不需要时清理定时器。
3.3 声明式和命令式
命令式代码描述“如何一步步做”:
const doubled = []
for (const value of [1, 2, 3]) {
doubled.push(value * 2)
}
声明式代码更强调“结果是什么”:
const doubled = [1, 2, 3].map(value => value * 2)
声明式写法通常更易组合,但并不保证更快。对热点路径、复杂控制流和性能敏感代码,清晰的循环可能比层层 reduce 更合适;应以测量结果和可维护性为准。
四、不要把函数式编程绝对化
原文把面向对象编程描述为无法应对并发,这个结论过于绝对。面向对象、函数式、过程式和数据导向设计都可以用于大型系统;并发安全取决于共享状态、同步方式、消息模型和运行时,而不是仅由范式名称决定。
同样,以下说法也需要谨慎:
- “不可变数据一定更快”:复制会产生成本,结构共享和持久化数据结构可以缓解,但仍需测量;
- “纯函数解决所有并发问题”:I/O、资源竞争和取消仍需设计;
- “链式数组方法一定优于循环”:多个中间数组可能增加分配,现代引擎也可能优化,但不能假设;
- “函数式编程禁止修改”:更准确的说法是限制共享可变状态,把局部可变性控制在小范围内。
推荐的工程实践是:
- 让数据转换、校验和业务规则尽可能纯;
- 在模块边界明确输入、输出和副作用;
- 用不可变更新处理共享状态,用局部可变变量优化实现;
- 对异步操作显式处理错误、取消和并发数量;
- 用测试验证函数的输入、输出和边界,而不是只追求“函数式”的外观。
五、JavaScript 中可以继续学习的方向
- MDN:JavaScript 函数
- MDN:First-class Function
- MDN:Array.prototype.map()
- MDN:Array.prototype.reduce()
- MDN:Object.freeze()
- MDN:Promise
- Elixir 入门
- Erlang/OTP 官方文档
- 原文还列举了两篇旧的第三方 Elixir 文章;由于链接已失效或不稳定,这里不再作为推荐资料。
六、总结
函数式编程的长期价值不在于把所有代码写成一串箭头函数,而在于帮助我们:
- 把函数当作可组合的数据;
- 降低隐式共享状态;
- 隔离并管理副作用;
- 用纯函数表达可测试的业务规则;
- 在需要时选择不可变更新、组合和声明式数据处理。
2018 年的原文仍然适合作为函数式编程的入门引子;截至现在,更稳妥的实践是将这些思想与 JavaScript 的对象模型、异步模型、模块系统和实际性能需求结合起来。