纯函数
纯函数是一个这样的函数:
- 给相同的输入(参数)总能返回相同的输出,不依赖外部状态。
- 不产生副作用,除了对外返回的值。
纯函数是一个映射函数,仅仅依赖输入的参数并且根据它的算法产出它的输出值,对整个程序没有其他依赖关系或影响。
以数组的方法slice
和splice
为例。slice
是纯函数,对于相同的输入总是有相同的输出。splice
不是纯函数,不仅修改了原数组的值,同时对于相同的输入有不同的输出。
javascript
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); // []
我们看另外一个例子:
javascript
// impure
let minimum = 21;
const checkAge = age => age >= minimum;
// pure
const checkAge = (age) => {
const minimum = 21;
return age >= minimum;
};
副作用
那么在纯函数的定义中提到的这个副作用是什么呢?我们将会把副作用称为在我们的计算中除了计算结果之外发生的任何事情。
代码间相互作用本身并没有什么不好,在代码中,我们经常会使用到它。但是也会有负面的含义。因为副作用使代码相互作用和相互依赖,不能轻易改动或移动代码,使代码一大坨,不容易维护。
副作用是在计算返回结果时,改变系统的状态或与外界可观测的交互。
副作用包含不仅限于包含以下情况:
- 修改文件系统
- 将数据插入数据库
- http请求
- 改变参数本身的数据(参数是object类型时)
- 打印到屏幕上/打印日志
- 获取用户输入
- DOM查询
- 访问系统状态
这样的例子不胜枚举。与函数外部的任何交互都是一种副作用。你可能会想如果没有它们,编程就不实用。函数式编程的理论假设副作用是错误行为的主要原因。并不是说我们被禁止使用它们,而是我们使用它们并且以一种可控的方式运行它们。
副作用使函数不再纯粹。根据定义纯函数,必须总是返回相同的输出给定相同的输入,当处理我们的局部函数之外的问题,这是不可能保证的。 下面来看看副作用的例子。
访问和修改非局部变量
javascript
let oldDigit = 5;
function addNumber(newValue) {
return oldDigit += newValue;
}
addNumber()
函数不是纯函数的原因有下面3个:
- 依赖外部变量
oldDigit
- 修改外部变量
oldDigit
的值 - 非确定性的函数。因为依赖外部变量
oldDigit
,如果oldDigit
值改变了,不能确定相同的输入值总是有相同的输出。
打印日志
javascript
function printName() {
console.log("My name is Oluwatobi Sofela.");
}
console.log()
会导致函数产生副作用,因为它会影响外部代码的状态------即控制台对象的状态。
通过参数修改外部数据
javascript
const myNames = ["Oluwatobi", "Sofela"];
function updateMyName(newName) {
myNames.push(newName);
return myNames;
}
在上面的代码片段中,updateMyName()
是一个非纯函数,因为改变了外部状态myNames
。把上面的不是纯函数改成如下所示的纯函数:
javascript
function updateMyName(newName) {
const myNames = ["Oluwatobi", "Sofela"];
myNames[myNames.length] = newName;
return myNames;
}
接下来让我们看看为什么坚持为什么相同的输入必须有相同的输出,我们来看看数学函数。
数学函数
函数是值之间的特殊关系:它的每个输入值只返回一个输出值。
换句话说,它只是两个值之间的关系:输入和输出。虽然每个输入都有一个输出,但这个输出不一定是唯一的。下面是一个从x到y的完全有效函数的图:
相比之下,下图显示的关系不是函数,因为输入值5指向多个输出:
或者是一个以x为输入,y为输出的图:
如果输入决定输出,则不需要详细的实现细节。因为函数只是输入到输出的简单映射,所以可以简单地记下对象字面量,然后用[]而不是()访问它们。
javascript
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
当然,你可能想要计算而不是手写出来,但这说明了思考函数的另一种方式。
通过以上给我们的启示是,纯函数是数学函数。使用纯函数能带来很大的好处,下面让我们看看一些实例。
纯函数使用实例
可缓存数据
纯函数总是可以通过输入缓存输出结果。这通常是通过一种叫做记忆的技术来实现的:
javascript
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
下面是一个简化的实现,尽管还有很多更健壮的版本可用。
javascript
const memoize = (f) => {
const cache = {};
return (...args) => {
const argStr = JSON.stringify(args);
cache[argStr] = cache[argStr] || f(...args);
return cache[argStr];
};
};
需要注意的是,你可以通过延迟求值将一些非纯函数转换为纯函数:
javascript
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));
这里有趣的是,我们实际上并没有进行http调用,而是返回一个函数,该函数在被调用时将执行此操作。这个函数是纯粹的,因为在给定相同输入的情况下,它总是返回相同的输出。在给定url和params的情况下,这个函数在执行时,使用根据url和params做相应的http调用。
我们的memoize函数工作得很好,尽管它没有缓存http调用的结果,而是缓存生成的函数。这看起来还不是很有用,但我们很快就会学到一些技巧来实现这一点。重要的是,我们可以缓存每个函数,不管它们看起来有多么具有破坏性。
可移植 /自文档化
纯函数是完全自包含的。功能所需的一切都交给了它。思考一下......这有什么好处呢?对于初始接触的人来说,函数的依赖关系是明确的,因此更容易看到和理解。
javascript
// 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,电子邮件,和attrs,这些是基本的要传给函数的依赖信息。
我们将学习如何使这样的函数纯粹而不只是推迟求值,但应该清楚的是,纯粹的形式比它的非纯粹的形式有更多的信息,不知道非纯粹的函数里面做了什么工作。
另外需要注意的是,我们被迫"注入"依赖项,或将它们作为参数传入,这使得我们的应用程序更加灵活,因为我们已经参数化了数据库或邮件等。如果我们选择使用一个不同的Db,我们只需要用它来调用我们的函数。如果我们正在编写一个新的应用程序,希望在其中重用这个可靠的函数,只需将我们当时拥有的Db和Email赋给这个函数即可。纯粹的功能可以运行在任何我们想要运行的地方。
你最后一次将一个方法复制到一个新的应用程序是什么时候?我最喜欢的引用之一来自Erlang的创造者Joe Armstrong:"面向对象语言的问题是,它们拥有所有这些隐含的环境。你想要一根香蕉,但你得到的是一只拿着香蕉的大猩猩......还有整个丛林"。
引用透明
很多人认为,使用纯函数的最大好处是引用透明。代码能够被返回值代替,同时不改变程序的行为。
由于纯函数没有副作用,它们只能通过输出值影响程序的行为。此外,由于它们的输出值可以只使用它们的输入值可靠地计算出来,纯函数将始终保持引用透明性。让我们看一个例子。
javascript
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));
decrementHP, isSameTeam 和 punch都是纯函数因此都是引用透明的。我们可以使用一种叫做等式推理的技术,用对等物替换来推出代码。 这有点像手动计算代码而不用考虑程序执行计算的各种行为。使用引用透明,我们看看下面的例子。
首先我们把函数isSameTeam的代入:
javascript
const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));
:因为我们的数据是不可变的,所以我们可以用实际值替换它们
javascript
const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));
我们看到('red' === 'green'是假的,所以我们可以移除整个if分支
javascipt
const punch = (a, t) => decrementHP(t);
我们把decrementHP代入进去,我们会看到,在这种情况下,punch变成了一个将hp减少1的调用。
javascipt
const punch = (a, t) => t.set('hp', t.get('hp') - 1);
这种对代码进行推理的能力对于一般的重构和理解代码是极好的。
并行代码
纯函数的代码是能够并行的,因为它们不共享内存并且不会竞争资源。 在使用服务端js线程或浏览器的web worker,很有可能使用并行代码。有的不太支持使用并行代码,为了避免非纯函数带来的复杂性。
总结
我们已经知道了什么是纯函数以及纯函数的好处,我现在开始,我们努力用纯粹的方式写我们的所有函数,从不是纯粹代码的部分分离出非纯函数。
问题 1.一个函数接受参数是函数类型,这个函数还是纯函数吗? 只要函数中使用的所有值都仅由其参数定义,那么它就是一个纯函数。只要参数是纯的(包含参数函数是纯函数),函数就是纯的。
参考文献