纯函数对于多种用途至关重要,包括函数式编程,可靠的并发和React + Redux应用程序。但是“纯函数”是什么意思?
在我们研究纯函数是什么之前,仔细研究一下函数可能是一个好主意。观察它们的方式可能有所不同,这将使函数式编程更易于理解。
什么是函数?
函数是一种方法,它需要一些输入,称为自变量,并产生一些输出称为返回值。函数可以达到以下目的:
- 映射:根据给定的输入产生一些输出。函数将输入值映射到输出值。
- 过程:可以调用一个函数来执行一系列步骤。该序列称为过程,而这种风格的编程称为过程编程。
- I / O:存在一些与系统其他部分进行通信的函数,例如屏幕,存储,系统日志或网络。
映射
纯函数都是关于映射的。函数将输入参数映射到返回值,这意味着对于每组输入,都存在一个输出。函数将获取输入并返回相应的输出。
Math.max()以数字作为参数并返回最大数字:
Math.max(2, 8, 5); // 8
在此示例中,2,8和5是 参数。它们是传递给函数的值。
Math.max()是一个函数,它接受任意数量的参数并返回最大的参数值。在这种情况下,我们传入的最大数字是8,这就是返回的数字。
函数在计算和数学中真的很重要。它们帮助我们以有用的方式处理数据。好的程序员会给函数起描述性的名称,以便当我们看到代码时,我们可以看到函数名称并了解函数的作用。
数学也有函数,它们的工作方式与JavaScript中的函数非常相似。您可能已经看过代数函数。他们看起来像这样:
f(x)= 2 x
这意味着我们要声明一个名为f的函数,它接受一个名为x的参数并将x乘以2。
要使用此函数,我们只需为x提供一个值:
f(2)
在代数中,这与编写完全相同:
4
因此,在任何看到f(2)的地方都可以替换4。
现在让我们将该函数转换为JavaScript:
const double = x => x * 2;
您可以使用console.log()
检查函数的输出:
console.log( double(5) ); // 10
还记得我说过在数学函数中,您可以将f(2)
替换为4
吗?在这种情况下,JavaScript引擎取代了双(5)
的答案,10
。
因此,console.log(double(5));
与console.log(10);
相同
这是正确的,因为double()是纯函数,但是如果double()具有副作用,例如将值保存到磁盘或登录到控制台,则不能简单地替换double(5) `与10保持不变。
如果要引用透明,则需要使用纯函数。
纯函数
纯函数是一个函数,其中:
- 给定相同的输入,将始终返回相同的输出。
- 无副作用。
如果函数在不使用返回值的情况下调用是有意义的,那么它就是不纯的。
我建议您偏爱纯函数。意思是,如果使用纯函数来实现程序要求是可行的,则应将它们用于其他选项。纯函数接受一些输入,并根据该输入返回一些输出。它们是程序中最简单的可重用代码块。也许计算机科学中最重要的设计原理是KISS(Keep It Simple, Stupid)。纯函数以最佳方式愚蠢而简单。
纯函数具有许多有益的特性,并构成了函数编程的基础。纯函数完全独立于外部状态,因此,它们不受与共享可变状态有关的所有类型的错误的影响。它们的独立特性也使其成为跨多个CPU以及整个分布式计算集群进行并行处理的极佳候选者,这使其成为许多类型的科学和资源密集型计算任务所必需的。
纯函数也非常独立-在代码中可以轻松移动,重构和重组,使您的程序更加灵活并适应未来的变化。
共享状态的麻烦
几年前,我正在开发一个应用程序,该应用程序允许用户搜索音乐艺术家的数据库并将该艺术家的音乐播放列表加载到Web播放器中。大约是Google Instant登陆的时间,当您键入搜索查询时,它会显示即时搜索结果。AJAX驱动的自动完成函数一时风靡一时。
唯一的问题是,用户键入的速度通常快于返回API的自动完成搜索响应的速度,从而引起一些奇怪的错误。这将触发比赛条件,在此情况下,较新的建议将被过时的建议取代。
为什么会这样呢?因为每个AJAX成功处理程序都被授予访问权限以直接更新显示给用户的建议列表。最慢的AJAX请求将始终通过盲目替换结果来赢得用户的注意,即使这些替换的结果可能是较新的。
为了解决这个问题,我创建了一个建议管理器-一个单一的事实来源来管理查询建议的状态。它知道当前有一个待处理的AJAX请求,并且当用户键入新内容时,待处理的AJAX请求将在发出新请求之前被取消,因此一次仅一个响应处理程序将能够触发UI状态更新。
任何类型的异步操作或并发都可能导致类似的竞争状况。如果输出取决于不可控事件的顺序(例如网络,设备延迟,用户输入,随机性等),则会发生争用情况。实际上,如果您使用共享状态,并且该状态依赖于不确定性因素而变化的序列,那么就所有意图和目的而言,输出都是无法预测的,这意味着无法正确测试或完全理解。正如Martin Odersky(Scala的创建者)所说:
非确定性=并行处理+可变状态
程序确定性通常是计算中的理想属性。也许您认为您还可以,因为JS在单个线程中运行,因此不受并行处理问题的影响,但是正如AJAX示例所示,单线程JS引擎并不意味着没有并发性。相反,JavaScript有许多并发源。API I / O,事件侦听器,Web Worker,iframe和超时都可以将不确定性引入程序中。将其与共享状态结合起来,就可以得出一些错误的秘诀。
纯函数可以帮助您避免此类错误
给定相同的输入,总是返回相同的输出
使用我们的double()函数,您可以将结果替换为函数调用,程序将具有相同的含义— double(5)始终与程序中的10具有相同的含义,无论上下文,无论您调用它几次还是何时调用它。
但是您不能对所有函数说同样的话。某些函数依赖于您传入的参数以外的信息来产生结果。
考虑以下示例:
Math.random(); // => 0.4011148700956255
Math.random(); // => 0.8533405303023756
Math.random(); // => 0.3550692005082965
尽管我们没有传递任何参数到任何函数调用的,他们都产生了不同的输出,这意味着'的Math.random()`是不是纯粹的。
每次您运行Math.random()
都会产生一个介于0和1之间的新随机数,因此很明显,您不能只用0.4011148700956255替换它而不改变程序的含义。
每次都会产生相同的结果。当我们要求计算机提供一个随机数时,通常意味着我们想要的结果与上次获得的结果不同。骰子两边印有相同数字的意义是什么?
有时我们不得不询问计算机当前时间。我们不会详细介绍时间函数的工作原理。现在,只需复制以下代码:
const time = () => new Date().toLocaleTimeString();
time(); // => "5:15:45 PM"
如果用当前时间替换“ time()”函数调用会发生什么?
它总是会说是同一时间:函数调用被替换的时间。换句话说,它只能每天产生一次正确的输出,并且只有在您替换函数的确切时刻运行程序。
很显然,“ time()”不像我们的“ double()”函数。
如果函数在给定相同的输入的情况下始终产生相同的输出,则该函数是纯函数。您可能还记得代数类中的这一规则:相同的输入值将始终映射到相同的输出值。但是,许多输入值可能会映射到相同的输出值。例如,以下函数是pure:
const highpass = (cutoff, value) => value >= cutoff;
相同的输入值将始终映射到相同的输出值:
highpass(5, 5); // => true
highpass(5, 5); // => true
highpass(5, 5); // => true
许多输入值可能映射到相同的输出值:
highpass(5, 123); // true
highpass(5, 6); // true
highpass(5, 18); // true
highpass(5, 1); // false
highpass(5, 3); // false
highpass(5, 4); // false
纯函数一定不能依赖任何外部可变状态,因为它不再是确定性的或参照透明的。
纯函数不会产生副作用
纯函数不会产生任何副作用,这意味着它无法更改任何外部状态。
不变性
JavaScript的对象参数是引用,这意味着如果函数要更改对象或数组参数上的属性,则将使该函数外部可访问的状态发生变化。纯函数不得改变外部状态。
考虑一下这个变异的,不纯洁的 addToCart()函数:
// impure addToCart mutates existing cart
const addToCart = (cart, item, quantity) => {
cart.items.push({
item,
quantity
});
return cart;
};
test('addToCart()', assert => {
const msg = 'addToCart() should add a new item to the cart.';
const originalCart = {
items: []
};
const cart = addToCart(
originalCart,
{
name: "Digital SLR Camera",
price: '1495'
},
1
);
const expected = 1; // num items in cart
const actual = cart.items.length;
assert.equal(actual, expected, msg);
assert.deepEqual(originalCart, cart, 'mutates original cart.');
assert.end();
});
它通过传递购物车,要添加到该购物车的物料以及物料数量来工作。然后,该函数返回相同的购物车,并添加了商品。
这样做的问题是,我们刚刚改变了一些共享状态。其他函数可能依赖于该购物车对象状态是调用该函数之前的状态,并且既然我们已经更改了该共享状态,我们就不得不担心如果更改该状态会对程序逻辑产生什么影响调用函数的顺序。重构代码可能会导致弹出错误,从而可能破坏订单并导致客户不满意。
现在考虑这个版本:
// Pure addToCart() returns a new cart
// It does not mutate the original.
const addToCart = (cart, item, quantity) => {
const newCart = lodash.cloneDeep(cart);
newCart.items.push({
item,
quantity
});
return newCart;
};
test('addToCart()', assert => {
const msg = 'addToCart() should add a new item to the cart.';
const originalCart = {
items: []
};
// deep-freeze on npm
// throws an error if original is mutated
deepFreeze(originalCart);
const cart = addToCart(
originalCart,
{
name: "Digital SLR Camera",
price: '1495'
},
1
);
const expected = 1; // num items in cart
const actual = cart.items.length;
assert.equal(actual, expected, msg);
assert.notDeepEqual(originalCart, cart,
'should not mutate original cart.');
assert.end();
});
在此示例中,我们在对象中嵌套了一个数组,这就是为什么我要进行深度克隆。这种状态比您通常要处理的状态更为复杂。对于大多数事情,您可以将其分解为较小的块。
例如,Redux使您可以组成化简器,而不是处理每个化简器中的整个应用程序状态。结果是您不必每次只想更新整个应用程序状态的一小部分时就创建一个深层克隆。相反,您可以使用非破坏性数组方法或Object.assign()来更新应用状态的一小部分。
参考
Master the JavaScript Interview: What is a Pure Function?