FP4:Pure function

November 14, 2017 by Sylvenas

在我们认识纯函数之前,我们来仔细审视一下函数的概念,或许从另外一种角度来观察函数,可以让我么更加容易的理解函数式编程的理念。

What is a Function? 函数是一组执行任务和计算值的过程,一个函数由称为函数体的一系列语句组成,函数包括函数的的输入参数(arguments),计算得出的输出结果(return value);函数可以有以下用途:

  • Mapping:根据输入参数生成一些输出数据。一个函数将输入值映射到输出值。
  • Procedures:可以调用一个函数来执行一系列的步骤。这个序列被称为一个过程,这种风格的编程模式,被称为面向过程编程。
  • I/O:有些函数可以与系统其他部分通信,比如:数据存储,打印日志,数据请求等等。

Mapping

纯函数全部都是映射。纯函数把输入参数映射到返回值,意味着每一个输入的参数集,都有一个对应的输出。 Math.max()函数的输入参数是一组数字,返回值为最大的数字。

Math.max(1,5,8) //8

在这个例子中1,5,8是输入参数,Math.max()函数的作用是把输入参数里的最大数,作为结果返回,例子中的8是传入的最大的数字,并被作为结果返回。 函数在计算机和数学领域都是非常重要的,可以帮助我们更好的处理数据。一个好的开发人员应当给函数语义化的命名,方便其他开发人员看到函数名字的时候,就能明白这个函数的作用。 数学中的函数和JavaScript中的函数工作方式类似,在数学中我们都见过这样的函数:

f(x) = 2x;

上面函数的意思是:命名了一个名为f的函数,并接收一个名为x的参数,并将x乘2作为计算结果。 如果要使用这个函数,只要给x一个具体的值就可以了。

f(2)

在数学中,上面的表达式f(2)和4是完全相同的,那么在其他你能看到f(2)的地方都可以用4进行等价替换。 现在我们用JavaScript的语法重写一下上面的逻辑:

const double = x => x * 2;

你可以使用console.log()方法打印上面函数的返回值:

console.log(double(5)); //10

在上面的数学描述中,我们可以使用4来替换f(2),同样的在JavaScript程序中,我们可以直接使用函数执行的结果10来替换double(5)

所以,console.log(double(5))console.log(10)是完全相同的。

上面的替换之所以是成立的,是因为double函数是一个纯函数,但是如果double函数有副作用的话,例如:函数内部保存数据到数据库或者打印了日志等等,这种情况下,我们就不再能用10替换double(5),哪怕函数的返回值没有任何变化。

如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明(referential transparency)的.

Pure Functions

在计算机编程中,假如满足下面这两个条件的约束,一个函数可以被描述为一个纯函数(pure function)

  • 给出相同的参数,那么函数的返回值一定相同。该函数结果值不依赖任何隐藏信息或程序执行处理可能改变的状态,也不能依赖于任何来自I/O的外部输入。
  • 在对函数返回值的计算过程中,不会产生任何语义上可观察的副作用或输出,例如对象的变化或者输出到I/O的操作。

如果程序的需求可以通过纯函数来实现,那么我们更建议你优先使用纯函数。纯函数需要一些输入参数并根据输入值返回一些输出。纯函数是一段程序中最简单的可重用代码块,计算机科学中有一条重要的设计理念就是KISS(保持简单,Keep It Simple, Stupid),毫无疑问,纯函数是一段及其简单的代码块。

纯函数有很多实用的属性,构成了函数式编程的基础。

纯函数不依赖于任何外部状态,也不修改任何外部状态。使得纯函数完全独立于外部环境,自然也就避免了共享可变状态所带来的错误。这样的代码便于进行推理计算,不容易出错。这使得代码在移动、重构、重新组织、单元测试和代码调试都变的非常更简单。

纯函数的不可变性所带来的另一个好处是:由于(多个线程之间)不共享状态,不会造成资源争用(Race condition),也就不需要用锁来保护可变状态,也就不会出现死锁,这样可以更好的处理并发。

尤其是在对称多处理器(SMP)架构下能够更好的利用多个处理器(核)提供的并行处理能力。并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

由于纯函数是引用透明的,以及函数式编程不像命令式编程那样关注执行步骤,这个系统提供了优化函数式程序的空间,例如惰性求值(通过延迟执行的方式,把某些数据缓存起来)。

The Trouble with Shared State

几年以前,我正开发一个音乐应用。这个应用允许用户查询音乐家的数据库,并将该艺术家的播放列表加载到一个网页播放器中。当用户输入查询条件时,会调用某个ajax,即时显示搜索结果。也就是基于ajax的自动完成。

但是,我们遇到了一个问题是用户的打字的速度常常超过了我们API完成自动查询的速度。这就导致了一些奇怪的bug,它会触发资源争用(Race condition),导致更新的建议会被过时的建议替换。

为什么会出现这种事情呢?这是因为每次访问ajax成功处理程序时,都会直接更新显示给用户的建议列表。最慢的那个ajax请求处理函数会直接修改UI,即使有时候被修改的UI是更新UI(更新的建议)。

为了解决这个问题,我创建了一个建议管理器--一个唯一的数据来源,来管理查询建议的状态。它知道当前还未完成的ajax请求,当用户输入一些新东西时,在新请求发出之前,未完成的ajax会被取消,这样每次就只有一个响应处理程序会触发UI的更新。

所有类型的异步操作或者并发都会导致类似的资源争用。如果输出取决于不可控制的时间顺序(比如:网络、设备延迟、用户输入、随机等),那么资源争用就会发生。实际上,如果你正使用共享的状态,而该状态依赖于某些不确定的因素,那么,最终的结果将是不可预测的,也就是说,不能正确的预测和完全理解。
也许你认为既然JS是运行在单线程中,那么它对并行处理的问题应该免疫,所以不会导致资源争用。但是正如刚刚的ajax的示例,单线程的JS引擎并不意味着能够避免并发。相反,在JS中有很多并发的来源。I/O,事件监听,Web Worker,Timeout等等都会在程序中引入不确定性。如果这些和共享状态混合在一起的话,就有可能会导致一些bug.

纯函数可以帮你避免这些类型的bug.

相同的输入,总会返回相同的输出

上面例子中的double函数,你可以直接使用函数的值来替代函数调用,这在程序看来没有任何不一样。也就是说在程序中,不管上下文时什么,不管调用多少次,不管什么时候调用,double(5)10没有任何不一样。
很明显,这并不适用于所有的函数。有些函数的输出结果依赖于其他信息,而不是传进来的参数。例如:

Math.random(); // => 0.4011148700956255
Math.random(); // => 0.8533405303023756
Math.random(); // => 0.3550692005082965

即使没有给函数传递任何参数,产生的输出也都不相同,也就是说Math.random并非是一个纯函数。每次执行Math.random(),都会生成一个0到1的随机数,所以很显然,你没法用0.4011148700956255来替换Math.random(),并且不改变程序的含义。当我们要求计算机生成一个随机数时,通常意味着我们想要的是一个与最后一次的到的数不同的结果。如果骰子的每一边印的都是同样的数字,那么骰子又有什么意义呢?
有时候我们需要计算机给出当前时间,如下代码:

const time = () => new Date().toLocaleTimeString();
time();   // => "5:15:45 PM"

如果用当"5:15:45 PM"替换time()函数调用会发生什么呢?也就是说,它每天只有一次会产生正确的输出,而且只有在函数调用被替换的那一刻才会正确。 所以很显然,time()double()不一样。

一个函数需要满足:只要给出相同输入,总是会产生相同输出的时候,才是纯函数,你可能还记得数学中的这条规则:相同的输入值总是会映射到相同的输出值。不过,这里的输入值,可以是一个值,也可以时一组值。例如:如下的函数时纯函数:

const highpass = (cutoff, value) => value >= cutoff;

相同的输入值总会映射到相同的输出值:

highpass(5, 5); // => true
highpass(5, 5); // => true
highpass(5, 5); // => true

Pure Functions Produce No Side Effects

纯函数不会产生副作用,也就是说他不能改变内核外部的状态。

Immutability

JavaScript函数的参数时按引用传递的,也就是说,如果函数内部修改了对象参数或者数组参数上的属性,那么它就会修改函数外部的状态。然而纯函数不能修改外部状态
考虑如下addToCart()函数,该函数时一个非纯函数,会修改外部状态:

// 非纯函数的 addToCart 会修改已有的购物车
const addToCart = (cart, item, quantity) => {
  cart.items.push({
    item,
    quantity
  });
  return cart;
};

通过传进一个购物车,商品,商品数量作为参数,然后函数返回同一个购物车,购物车里带有刚刚新增的商品。
现在会导致的问题是,我们刚刚修改了一些共享的状态。其他函数可能依赖于addToCart函数被条用之前的该购物车对象的状态,而我们现在已经修改了这个共享的状态,就不得不考虑一下它会对程序逻辑产生什么样的影响。
现在考虑如下版本:

// 纯函数 addToCart() 返回一个新购物车,不会修改原始购物车
const addToCart = (cart, item, quantity) => {
  const newCart = lodash.cloneDeep(cart);

  newCart.items.push({
    item, 
    quantity
  });
  return newCart;
};

在本例中,有一个数组嵌套在一个对象中,这是为什么我要做深拷贝的原因。在实际情况下,你可以把它分解成更小的块。

例如:redux会让你合并reducer,而不是在每一个reducer中处理整个应用程序的状态。这样做的结果是。你不必在每次只想更新一小部分的时候,为整个应用程序的状态创建一个深拷贝。而是使用非破坏性的Object.assign(),来更新应用状态的一小部分。