文章目录
第四章 行为得体——纯函数
上一章我们重点介绍了函数式编程中的关键要素——函数,并详细介绍了箭头函数及相关概念,如注入(injection
)、回调(callback
)、替代(polyfilling
)、打桩(stubbing
)等。本章将重新审视并应用其中一些观点,并主要介绍以下内容:
- 引入“纯度”的概念,以及为何要关注纯函数、非纯函数;
- 考察“引用透明度”的概念;
- 认识到副作用隐含的问题;
- 介绍纯函数的一些优点;
- 阐述非纯函数背后的逻辑;
- 设法最小化非纯函数的数量;
- 探讨纯函数和非纯函数的测试方法。
4.1. 纯函数 Pure functions
纯函数的行为方式与数学上的函数相同,并具备诸多好处。当一个函数满足以下两个条件,即为 纯函数:
- 给定相同的参数,该函数总是计算并返回相同的结果:无论调用多少次或在什么条件下调用,该命题都成立;其结果不依赖于任何 外部 的信息或状态,否则这些信息或状态可能会在程序执行期间发生变化并导致返回值的改变。函数结果也不能依赖于
I/O
结果、随机数、以及其他一些外部变量或不能直接控制的值; - 在计算其结果时,该函数不会引起任何可观察到的“副作用”:这包括输出到
I/O
终端、对象的改变、函数外的程序状态更改等等;
简单来说,纯函数不依赖(也不修改)其作用域之外的任何内容,并且始终为相同的输入参数返回相同的结果。
在该语境下常用的另一个术语概念是 幂等性,但二者并不完全相同。幂等函数可以根据需要多次调用,并且总是产生相同的结果;但这并不意味着该函数没有副作用。幂等性通常出现在 RESTful
风格的服务上下文中。这里举个简单的例子来区分纯度(purity
)和幂等性(idempotency
)。调用 PUT
请求会导致数据库记录被更新(副作用),但如果反复调用,元素将不会被进一步修改,因此数据库的全局状态也不会进一步改变。
借用一个软件设计原则(单一职责)来提醒自己:一个函数应该 做一件事就只做这件事,除此之外什么都不做。如果一个函数实现了其他逻辑或具备一些隐藏功能,那么该函数对其状态的依赖将无法正确预测结果,加大开发者的处理难度。
下面就来深入研究一下这些控制因素。
4.1.1. 引用透明 Referential transparency
数学里的 引用透明 是一种特性(property
),可以用表达式的值替换表达式,而不改变原来的任何结果。
知识拓展
引用透明的对立面,是 引用不透明。这样的函数在调用时,即便使用相同的参数也无法保证总是产生相同的结果。
例如,考虑一个做优化处理的编译器在执行 常量折叠 时发生的变化。原始代码如下:
const x = 1 + 2 * 3;
编译器可能会将 2 * 3
视为常量,优化为:
const x = 1 + 6;
更理想的情况,甚至可以完全避免求和:
const x = 7;
编译器正是利用了所有数学表达式和函数按照定义都具备的引用透明的特性来节省执行时间的。此外,如果编译器无法预测给定表达式的结果,则无法完成任何形式的代码优化,只能在运行时进行计算。
拓展
在 λ 演算中,如果将目标函数表达式的值替换为函数的计算值,则该操作称为 β 化简。请注意,这里只能使用引用透明的函数。
所有的算术表达式(包含数学运算符和函数)都是引用透明的:22 * 9
总是可以被 198
替换;涉及 I/O
的表达式是不透明的,因为在具体执行之前无法得知它们的结果。同理,涉及日期和时间相关的函数或随机数表达式也是不透明的。
至于自定义的函数,很容易写出一些不满足 引用透明 条件的来。事实上,一个函数甚至都不需要返回一个值,尽管 JavaScript
解释器会默认返回一个 undefined
的值。
拓展
一些语言还区分了函数(
function
)和过程(procedure
)。函数要返回一个值,过程不返回任何东西;但JavaScript
不是。还有一些编程语言提供了确保函数引用透明的方法。
如果要分类的话,函数可以分为以下三类:
- 纯函数:返回值至取决于函数参数、没有任何副作用的函数;
- 副作用函数:不返回任何值、但会产生某些副作用的函数(实际上也会返回一个
undefined
值,但不是讨论重点); - 带副作用的函数:返回值不仅仅取决于参数,还含有一些副作用。
在函数式编程中,尤为强调第一类——引用透明的纯函数。此时编译器不仅可以推断程序行为(从而能够优化生成的代码),程序员也可以更轻松地推断程序及其组件之间的联系。这反过来又可以帮助证明算法的正确性,或通过函数等效替换来进一步优化代码。
4.1.2. 副作用 Side effects
何为 副作用?我们可以将其定义为在执行某些计算或过程期间,程序所发生的状态变化;或者与外部元素所发生的交互,如用户、Web 服务、另一台计算机等等。
对于这个定义的适用范围可能存在误解。在日常谈话中一谈到副作用,更像是在谈论 附带伤害——某个特定行为的一些意料之外的结果;然而,计算机领域的副作用,则包含了函数外部的所有可能的影响或变化。如果一个函数要执行 console.log()
来显示结果,则会被视为副作用,即便它正是该函数本应实现的首要功能。
本节主要介绍以下内容:
JavaScript
中常见的副作用;- 全局及内部状态引发的问题;
- 函数参数不固定时的情况;
- 一些总是很棘手的函数;
1 常见副作用 Usual side effects
编程中被视为副作用的东西可太多了。在 JavaScript
中,无论前端后端,您可能会看到以下常见的副作用:
- 改变全局变量;
- 改变作为参数传入的对象;
- 任何类型的输入输出操作,例如显示
alert
消息或将一些文本写入日志; - 操作、更改文件系统;
- 更新数据库;
- 调用
Web
服务; - 查询或修改
DOM
; - 触发任何外部进程;
- 仅仅是调用了另一个碰巧产生副作用的函数。这可以理解为不纯函数具有 传染性:调用不纯函数的函数会自动变为不纯函数!
有了这个定义,让我们来看看哪些因素会导致函数不纯(或引用不透明)。
译注
下一篇将介绍几个具体的存在副作用的场景,进而介绍纯函数的诸多优势,敬请关注!