纯函数与副作用

纯函数

顾名思义,纯函数往往指那些仅根据输入参数决定输出并且不会产生任何副作用的函数。纯函数最优秀的特性之一在于其结果的可预测性:

const z = 10;
function add(x, y) {
  return x + y;
}
console.log(add(1, 2)); // prints 3
console.log(add(1, 2)); // still prints 3
console.log(add(1, 2)); // WILL ALWAYS print 3

add函数中并没有操作z变量,即没有读取z的数值也没有修改z的值。它仅仅根据参数输入的xy变量然后返回二者相加的和。这个add函数就是典型的纯函数,而如果在add函数中涉及到了读取或者修改z变量,那么它就失去了纯洁性。我们再来看另一个函数

function justTen() {
  return 10;
}

对于这样并没有任何输入参数的函数,如果它要保持为纯函数,那么该函数的返回值就必须为常量。不过像这种固定返回为常量的函数还不如定义为某个常量呢,就没必要大材小用用函数了,因此我们可以认为绝大部分的有用的纯函数至少允许一个输入参数。再看看下面这个函数

function addNoReturn(x, y) {
    constst z = x + y
}

注意这个函数并没有返回任何值,它确实拥有两个输入参数xy,然后将这两个变量相加赋值给z,因此这样的函数也可以认为是无意义的。这里我们可以说,绝大部分有用的纯函数必须要有返回值。总结而言,纯函数应该具有以下几个特效

  • 绝大部分纯函数应该拥有一或多个参数值。
  • 纯函数必须要有返回值。
  • 相同输入的纯函数的返回值必须一致。
  • 纯函数不能够产生任何的副作用。

共享状态与副作用

在软件开发中有个很有趣的观点:共享的状态时万恶之源。共享状态(Shared State )可以是存在于共享作用域(全局作用域与闭包作用域)或者作为传递到不同作用域的对象属性的任何变量、对象或者内存空间。在面向对象编程中,我们常常是通过添加属性到其他对象的方式共享某个对象。共享状态问题在于,如果开发者想要理解某个函数的作用,必须去详细了解该函数可能对于每个共享变量造成的影响。譬如我们现在需要将客户端生成的用户对象保存到服务端,可以利用saveUser()函数向服务端发起请求,将用户信息编码传递过去并且等待服务端响应。而就在你发起请求的同时,用户修改了个人头像,触发了另一个函数updateAvatar()以及另一次saveUser()请求。正常来说,服务端会先响应第一个请求,并且根据第二个请求中用户参数的变更对于存储在内存或者数据库中的用户信息作相应的修改。不过某些意外情况下,可能第二个请求会比第一个请求先到达服务端,这样用户选定的新的头像反而会被第一个请求中的旧头像覆写。这里存放在服务端的用户信息就是所谓的共享状态,而因为多个并发请求导致的数据一致性错乱也就是所谓的竞态条件(Race Condition ),也是共享状态导致的典型问题之一。另一个共享状态的常见问题在于不同的调用顺序可能会触发未知的错误,这是因为对于共享状态的操作往往是时序依赖的。

const x = {
  val: 2,
};

const x1 = () => (x.val += 1);

const x2 = () => (x.val *= 2);

x1();
x2();

console.log(x.val); // 6

const y = {
  val: 2,
};

const y1 = () => (y.val += 1);

const y2 = () => (y.val *= 2);

// 交换了函数调用顺序
y2();
y1();

// 最后的结果也受到了影响
console.log(y.val); // 5

副作用指那些在函数调用过程中没有通过返回值表现的任何可观测的应用状态变化,常见的副作用包括但不限于:

  • 修改任何外部变量或者外部对象属性
  • 在控制台中输出日志
  • 写入文件
  • 发起网络通信
  • 触发任何外部进程事件
  • 调用任何其他具有副作用的函数

在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。Haskell 或者其他函数式编程语言通常会使用Monads来隔离与封装副作用。在绝大部分真实的应用场景进行编程开始时,我们不可能保证系统中的全部函数都是纯函数,但是我们应该尽可能地增加纯函数的数目并且将有副作用的部分与纯函数剥离开来,特别是将业务逻辑抽象为纯函数,来保证软件更易于扩展、重构、调试、测试与维护。这也是很多前端框架鼓励开发者将用户的状态管理与组件渲染相隔离,构建松耦合模块的原因。

下一页