第三章:纯函数之道
重返纯净之境
首先需要明确的是纯函数(Pure Function)这一核心概念。
纯函数是指在给定相同输入时,总是返回相同输出且不产生任何可观测副作用(Side Effect)的函数。
以slice和splice为例。这两个函数功能相似却实现迥异:slice具有纯函数特性,因为每一次相同输入都保证相同输出;而splice则会对原数组进行突变,这就产生了可观测的副作用。
const xs = [1,2,3,4,5];
// pure
xs.slice(0,3); // [1,2,3]
xs.slice(0,3); // [1,2,3]
xs.slice(0,3); // [1,2,3]
// impure
xs.splice(0,3); // [1,2,3]
xs.splice(0,3); // [4,5]
xs.splice(0,3); // []在函数式编程中,我们反感splice这类会*突变(mutate)*数据的笨拙函数。我们需要的是每次调用都可靠返回结果的函数,而非如splice般遗留混乱的函数。
再看另一示例:
// impure
let minimum = 21;
const checkAge = age => age >= minimum;
// pure
const checkAge = (age) => {
const minimum = 21;
return age >= minimum;
};在不纯的实现中,checkAge依赖可变变量minimum计算结果,这意味着函数需参考系统状态。这会增加认知负荷,因为必须理解外部环境才能掌握函数行为。
当前示例虽简单,但状态依赖正是系统复杂度的主要来源之一(详见http://curtclifton.net/papers/MoseleyMarks06a.pdf)。由于外部因素可能导致不同输出结果,这样的checkAge不仅丧失纯度,更使代码推理过程困难重重。
而纯函数实现则是完全自足的。通过将minimum设为不可变(Immutable)来保持纯度——因为状态永不改变。为此我们需要创建冻结对象:
const immutableState = Object.freeze({ minimum: 21 });副作用即暗礁
深入理解'副作用'有助于建立直觉。纯函数定义中提及的'副作用'究竟为何?在计算过程中,我们称除结果计算之外的任何行为都为作用(Effect)。
作用本身无害,后文还将大量使用。关键在于'副作用(Side)'蕴含的负面意义:清水本非幼虫温床,停滞之水方成蚊群渊薮;同理,副作用正是程序的腐化之源。
副作用指的是在计算结果过程中,系统状态变化或与外部世界的可观测交互。
副作用包括但不限于:
- 修改文件系统
- 插入数据库记录
- 发起HTTP请求
- 数据突变(Mutation)
- 屏幕输出/日志记录
- 获取用户输入
- 查询DOM
- 访问系统状态(system state)
列表未尽其全。任何与外部世界的交互皆属副作用。这似乎暗示无副作用编程的不可行性,但函数式编程哲学认为副作用是程序错误的主要诱因。
我们的目标不是禁用副作用,而是以可控方式管理它们。后续章节将学习如何通过函子(Functor)和单子(Monad)来实现,目前先确保将二者的处理逻辑分离。
副作用使得函数失去纯度。因为纯函数的本质要求:相同输入恒得相同输出,这在处理本地函数作用域之外的因素时无法保证。
为何坚持输入输出一致性?挺起衣领,让我们重温八年级数学知识。
八年级数学启示
来自数学学习网站mathisfun.com的定义:
函数是数值间的特殊映射关系:每个输入值必定对应唯一输出值。
换言之,这是输入与输出之间的联结。每个输入严格对应单输出,但多个输入可能映射到相同输出。下图展示了从x到y的有效函数映射:
(https://www.mathsisfun.com/sets/function.html)
对比之下,下图关系不构成函数,因输入值5映射多个输出:
(https://www.mathsisfun.com/sets/function.html)
函数可描述为输入输出对的集合:[(1,2), (3,6), (5,10)](显然该函数将输入值倍增)。
表格表示法:
| Input | Output |
|---|---|
| 1 | 2 |
| 2 | 4 |
| 3 | 6 |
或以图像形式呈现,x为输入,y为输出:

只要输入确定输出,则无须实现细节。由于函数本质是输入到输出的映射,完全可以用对象字面量配合[]代替函数调用()。
const toLowerCase = {
A: 'a',
B: 'b',
C: 'c',
D: 'd',
E: 'e',
F: 'f',
};
toLowerCase['C']; // 'c'
const isPrime = {
1: false,
2: true,
3: true,
4: false,
5: true,
6: false,
};
isPrime[3]; // true当然实际应用中需要动态计算而非硬编码,但这揭示了另一种函数思维方式。(可能疑问:“如何处理多参函数?”。从数学角度看确有不便,目前可将参数打包为数组或将arguments对象视作输入。学习*柯里化(Currying)*后,就能直接模拟数学函数定义了。)
现在揭晓要义:纯函数即数学函数,这正是函数式编程的核心。与这些天使般的函数为伴能带来巨大优势,以下是保持纯度的关键理由。
纯函数优势论证
可缓存性
纯函数总能根据输入进行缓存。这通常通过记忆化(Memoization)技术实现:
const squareNumber = memoize(x => x * x);
squareNumber(4); // 16
squareNumber(4); // 16, returns cache for input 4
squareNumber(5); // 25
squareNumber(5); // 25, returns cache for input 5此处给出简化实现(更健壮的版本可自行查找):
const memoize = (f) => {
const cache = {};
return (...args) => {
const argStr = JSON.stringify(args);
cache[argStr] = cache[argStr] || f(...args);
return cache[argStr];
};
};需要注意的是,对于某些不纯函数,可以通过延迟执行来转为纯函数:
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));关键点在于此时并未真正发起HTTP请求,而是返回一个待执行函数。该函数具备纯度——给定相同的url和params输入,该函数会基于指定的url和params发起特定的HTTP调用。
记忆化函数虽不缓存HTTP结果,但缓存生成函数本身。
这种方法当前效用有限,但后文将提供改进技巧。核心启示是:无论这些函数看起来会产生多大影响,理论上都具备可缓存性。
可移植性/自文档化
纯函数高度自包含,所有依赖均显式传入。这一特性使得:首先,依赖关系清晰可见,无需探究隐藏逻辑;其次,函数签名明确参数用途。
// impure
const signUp = (attrs) => {
const user = saveUser(attrs);
welcomeUser(user);
};
// pure
const signUp = (Db, Email, attrs) => () => {
const user = saveUser(Db, attrs);
welcomeUser(Email, user);
};示例中的纯函数必须明确声明需要Db、Email和attrs三个依赖参数。从签名即可知功能范围。
后续将学习无需延迟执行也能保障纯度的方法,但此处已彰显纯函数的信息透明优势。
通过依赖注入(参数传递),我们能灵活更换数据库或邮件客户端(后文将优化此过程)。若需更换数据库,只需传入新实例;若在新应用中复用此函数,只需适配当前的Db和Email接口。
在JavaScript环境中,这意味着可通过Socket传输函数,或通过Web Workers并行执行,足见其可移植性之强。
相比命令式编程中与环境紧密耦合(通过状态、依赖和可用作用)的典型方法,纯函数可在任何场景自由运行。
你上次将方法复制到新应用是何时?Erlang创始人Joe Armstrong的名言发人深省:「面向对象语言的症结在于隐式环境依赖——你想要香蕉,得到的却是拿着香蕉的大猩猩......以及整片丛林。」
易测试性
纯函数极大简化测试:无需模拟支付网关,不用维护测试后状态。只需给定输入,断言输出即可。
函数式社区还开创了基于生成式输入的测试工具,可自动生成输入数据并验证输出属性。建议尝试专为纯函数设计的Quickcheck测试框架(虽超出本书范畴)。
可推导性
引用透明性(Referential Transparency)是纯函数的最大优势。当代码片段可替换为其执行结果而不改变程序行为时,即具有引用透明性。
由于纯函数无副作用,其对程序的影响仅通过输出体现。而纯函数输出完全由输入决定,因此永远保持引用透明。示例如下:
const { Map } = require('immutable');
// Aliases: p = player, a = attacker, t = target
const jobe = Map({ name: 'Jobe', hp: 20, team: 'red' });
const michael = Map({ name: 'Michael', hp: 20, team: 'green' });
const decrementHP = p => p.set('hp', p.get('hp') - 1);
const isSameTeam = (p1, p2) => p1.get('team') === p2.get('team');
const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));
punch(jobe, michael); // Map({name:'Michael', hp:19, team: 'green'})decrementHP、isSameTeam和punch均为纯函数,故具有引用透明性。可运用*等式推导(Equational Reasoning)*技术,直接替换等价表达式进行逻辑推演。
首先内联isSameTeam函数:
const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));因数据不可变,可用实际值替代团队变量:
const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));条件判断为false,故删除整个if分支:
const punch = (a, t) => decrementHP(t);再内联decrementHP后可见,此时punch即是对hp减1的操作。
const punch = (a, t) => t.set('hp', t.get('hp') - 1);这种等式推导能力对代码重构和理解极有价值。事实上,在海鸥程序重构中就应用了此法,通过加法和乘法特性进行推导。本书后续将持续运用此类技巧。
并行化支持
具备终结性优势的是:纯函数可并行执行,因其不访问共享内存,亦不会因副作用产生竞态条件(Race Condition)。
在服务端JS的多线程环境或浏览器的Web Workers中均可实现,但当前开发实践因处理不纯函数的复杂性而较少采用。
本章小结
我们了解了纯函数的内涵及其在函数式编程中的核心地位。此后将力求以纯函数范式编写代码,虽然需要额外工具辅助,但会尽量隔离纯/非纯代码。
若无适当工具支持,纯函数编程略显繁复:需反复传递参数,禁止使用状态,更严控副作用。如何编写此类看似自虐的程序?让我们掌握新工具——柯里化(Currying)。