(译)函数式 JS #3: 状态

445 阅读10分钟

这是"函数式 JS" 系列文章的第三篇。 点击查看 上一篇第一篇

photo by Yung Chang on Unsplash

原文链接 By: Krzysztof Czernek

介绍

上一篇我们讨论了一些与函数式编程相关的术语。你现在了解了高阶函数一等公民函数以及纯函数等概念 - 我们下面就看看如何使用他们。

我们会看到如何使用纯函数来帮助我们避免与状态管理相关的Bug。您还将了解(最好是 - 理解)一些新的词汇:副作用(side effects)不变性(immutability)引用透明(referential transparency)

首先,让我们看看应用程序的状态是什么意思,它有什么用,以及如果我们不仔细处理它会出现什么问题。

什么是状态?

在不同的场合下我们都会用到状态(state)这个词。我们这里主要关心的是应用程序的状态

简而言之,你可以将应用程序状态视为以下这几点的集合:

  • 所有变量的当前值,
  • 所有被分配的对象,
  • 打开的文件描述符,
  • 打开的网络套接字(network sockets)等

这些基本上代表了应用程序当前正在运行的所有信息。

在以下示例中,counteruser变量都包含有关给定时刻的应用程序状态的信息:

let counter = 0
let user = {
  firstName: 'Krzysztof',
  lastName: 'Czernek'
}

counter = counter + 1

user.firstName = 'KRZYSZTOF'
user.lastName = 'CZERNEK'

上面的代码片段是一个 全局状态(global state) 的示例 - 每段代码都可以访问 counteruser 变量。

我们再看一下 局部状态(local state),如下面的代码段所示:

const countBiggerThanFive = numbers => {
  let counter = 0
  for (let index = 0; index < numbers.length; index++) {
    if (numbers[index] > 5) {
      counter++
    }
  }
  return counter
}

countBiggerThanFive([1, 2, 3, 4, 5, 6, 7, 8, 9, -5])

这里,counter 保存了 countBiggerThanFive 函数调用的当前状态。

每次调用 countBiggerThanFive 函数时,都会创建一个新变量并用 0 来初始化。然后,它会在迭代 numbers 时更新,最后从函数返回后被销毁。它只能由函数内部的代码访问 - 因此,我们才把它视为局部状态的一部分。

类似地,index 变量表示 for 循环的当前状态 - 循环外的代码不能读取或更改它。

关键是,应用程序状态 不仅与全局变量有关 - 它可以在应用程序代码的各种“层次”下定义。

为什么这个很重要?让我们深入挖掘一下。

共享状态

我们可以看到,状态对我们的程序来说是必需的。我们需要跟踪正在发生的事情,并能够从应用程序状态更新模型 (model) 的行为。

我们可能会想用更多的全局状态来保存一些有用的信息,好让我们程序中的任意一段代码都可以访问。

假设我们使用currentUser变量来保存当前登录用户的信息。可以想见我们的应用程序的不同部分都可能需要用这个数据来做出一些“判断” - 例如授权,个性化等等。

currentUser 作为全局变量这个想法可能很诱人,因为这样的话代码中的每个函数都可以随时根据需要来访问和更改它。(共享状态(shared state) 说的就是这个意思。

但这就带来了一个作用域的问题 - 如果应用程序中的每个功能都能够对 currentUser进行更改,那么您就要考虑这种更改会有什么样的后果。要知道改变这个变量会影响很多个其他可以访问currentUser的函数。

这可能会导致非常棘手的 bug,并使应用程序的逻辑很难理解。如果一个变量可以在任何地方改变,那么追踪变更发生的地点时间就会非常困难。

显而易见 - 全局状态越多,你在改变它们的时候就越要小心。相反,如果更多地使用局部状态,情况就会好很多。

可变共享状态 (Mutable shared state)

相较于只读的全局状态,可变的(mutable)共享状态会让情况变得更复杂。

让我们看看可变共享状态对我们的应用程序的可读性和可维护性有什么影响。

它使得代码更难理解

一般来说,有越多的地方可以改变一个状态,就越难以跟踪某个时间点它的取值。

假设您有一些函数可以对同一个全局变量进行更改。你最后会发现有很多种可能的顺序去调用这些函数。

如果你想保证这样的变量总是处于正确的状态,那你就需要考虑所有可能的组合- 可怕的是,这种组合可能有无限多:)

它会降低可测试性

要为函数编写单元测试,你需要预测它会在什么样的环境下运行。然后为所有这些可能的环境编写测试用例 - 以确保这些函数能够始终正确运行。

如果你的函数所依赖的唯一东西只是它的参数时,那就容易多了。

另一方面,如果你的函数使用甚至修改共享状态 - 那你就必须为所有测试预先配置此状态。你可能还需要在使用之后重置这些共享状态,以便能够正确测试其他依赖这个状态的函数。

它会影响性能

如果你的函数依赖于可变共享状态,那么就没有简单的方法在并行运算中使用它 - 即使理论上是可行的。

并行函数的不同“实例”可能会同时访问和改变同一个状态,这种行为通常难于预测。

处理这样的问题并非易事。即使你可以找到一种可靠的方法,你也很可能会引入更多的复杂性并使你的函数失去模块化和可重用的能力。


好的,那么如果我们想避免使用全局变量来表示和跟踪应用程序状态,我们该怎么做?让我们看看有哪些可能的方式。

使用参数 (parameters) 而不是状态 (state)

避免共享状态引起的问题的最简单方法是确保你的函数不要引用它,除非万不得已。我们来看一个例子:

const currentUser = getCurrentUser()

const getUserBalance = () => {
  return currentUser.balance
}

console.log(getUserBalance())

我们可以看到 getUserBalance 函数引用了 currentUser--实际上这就是一个共享状态。

从表面上看,这没什么问题 - 但实际上,我们在 getUserBalancecurrentUser 之间引入了隐式耦合。例如,如果我们想更改 currentUser 的名称,我们还需要在 getUserBalance 中更改它。

为了缓解这种情况,我们可以更改 getUserBalance 以将 currentUser 传入其中。即使这看起来是一个很小的改动,它也会使代码更具可读性和可维护性。

const currentUser = getCurrentUser()

const getUserBalance = user => {
  return user.balance
}

console.log(getUserBalance(currentUser))

不变性(Immutability)

即使你明确地将所有用到的变量都显式地传递给函数,你还是需要小心。

一般来说,您需要确保不要 改变(mutate) 传递给函数的任何参数。我们来看一个例子:

const getUserBalance = user => {
  return user.balance
}

const rewardUser = user => {
  user.balance = user.balance * 2
  return user
}

const currentUser = getCurrentUser()
console.log(getUserBalance(currentUser))

const rewardedUser = rewardUser(currentUser)
console.log(getUserBalance(currentUser), getUserBalance(rewardedUser))

这里的问题是,rewardUser 函数不仅返回具有双倍余额的用户 - 它还会更改传入的user变量。它会使currentUserrewardedUser变量引用相同的,被修改了的值。

这种操作会使代码逻辑很难理清。

以下是如何改进:

const getUserBalance = user => {
  return user.balance
}

const rewardUser = user => {
  return {
    ...user,
    balance: user.balance * 2
  }
}

const currentUser = getCurrentUser()
console.log(getUserBalance(currentUser))

const rewardedUser = rewardUser(currentUser)
console.log(getUserBalance(currentUser), getUserBalance(rewardedUser))

通常,你需要确保你的函数几乎*总是返回一个新对象,并且不要修改它们的参数。这就是我们所说的不变性。

你只需要简单地记住这个规则,并在你的代码库中严格遵守它。根据我的经验,这个并不难做到。

其他一些做法包括使用一些外部工具来提供不可变的集合,例如来自 Facebook 的 Immutable.js。它不仅可以防止修改数据,还可以有效地重用数据结构来提高性能。

这方面更全面的概述,请阅读Cory House 关于不变性的方法的文章。虽然这篇文章的标题里有“React”,但是不要担心 - 里面讨论的技术也适用于 JavaScript。

*在函数内部修改参数的唯一原因(据我所知)是基于优化性能的需要。但是决定这么做之前,请务必先分析一下你的应用程序的性能。

回到函数

你可能会问,上面说的这些与函数式编程有什么关系。

上一次,我们讨论了函数但并没有给出一个明确的标准。现在,根据我们新学到的知识,我们可以调整一下我们的定义。

我们说过纯函数符合以下标准:

  • 它不能依赖任何东西,除了它的输入(参数),
  • 它必须返回一个值,并且
  • 它们必须是确定性的(不能使用随机值等)。

我们现在看到这些可以从另外一个角度重新描述一下。

不能依赖任何东西,除了它的输入”和“必须是确定性的”,这实际上意味着纯函数不能访问或改变共享状态。

必须返回单个值”意味着除了返回值之外,调用这个函数不能有其他可以被观察到的效果。

当函数确实改变了共享状态或具有其他可观察的后果时,我们说它会产生副作用。这意味着调用它的结果不仅包含在此函数的内部状态中。

现在让我们深入研究一下副作用。

副作用(Side effects)

有几种不同类型的副作用,包括:

  • 改变共享状态参数 - 如上节所述,
  • 写磁盘 - 因为它实际上是在修改计算机的状态,
  • 写入控制台 - 就像写入磁盘一样,它修改了计算机的内部状态 - 以及环境(你在屏幕上看到的内容),
  • 调用其他不纯的函数 - 如果你调用的某个函数产生了副作用,那么你的函数也被“感染”了,
  • 进行API调用 - 它会修改你的计算机和目标服务器的状态等。

以下是产生副作用的函数的一些示例:

const users = {}

// Produces side effects – mutates arguments and global state
const loginUser = user => {
  user.loggedIn = true
  users[user.id] = user
  return user
}

// Produces side effects – writes data to storage
const saveUserToken = token => {
  window.localStorage.setItem('userToken', token)
}

// Produces side effects – writes to console
const userDisplayName = user => {
  const name = `${user.firstName} ${user.lastName}`
  console.log(name)
  return name
}

// Produces side effects – uses userDisplayName that produces side effects
const greetingMessage = user => {
  return `Hello, ${userDisplayName(user)}`
}

// Produces side effects – makes an API call
const getUserProfile = user => {
  return axios.get('/user', {
    params: {
      id: user.id
    }
  })
}

显而易见,一个真正有用的程序一定是需要 副作用的。否则,你甚至没办法看到它的效果。

计算机程序不可能全部都是“纯函数”。

我们不想创造无用的纯理论的程序。

函数式编程不是为了编写完全没有副作用的代码。它是要以某种方式把副作用尽可能的限制在一个很小的范围内以便于管理。这是为了让你的程序更易于理解和维护。

在这种情况下还有一个经常使用到的术语 - 引用透明(referential transparency)。虽然它有点复杂并且名字中有些故弄玄虚的单词,但我们现在已经有了足够的知识来了解它与纯函数的关系了。

引用透明(Referential transparency)

如果我们可以用一个函数调用的结果来替换掉这个函数调用本身并且完全不会影响程序的行为,那么我们就可以说这个函数是引用透明的。

尽管从直觉上来看这个显而易见,但我们需要明白,对于一个引用透明的函数,它必须是纯的(不会产生副作用)。

让我们看一个不是引用透明的函数示例:

const getUserName = user => {
  console.log('getting user profile!')
  return `${user.firstName} ${user.lastName}`
}

const getUserData = user => {
  return {
    name: getUserName(user),
    address: user.address
  }
}

getUserData({
  firstName: 'Peter',
  lastName: 'Pan',
  address: 'Neverland'
})

表面上看,对getUserName的调用可以用它的输出替换,并且替换后getUserData 仍然能够正常工作,如下所示:

const getUserData = user => {
  return {
    name: `${user.firstName} ${user.lastName}`,
    address: user.address
  }
}

getUserData({
  firstName: 'Peter',
  lastName: 'Pan',
  address: 'Neverland'
})

但是,我们实际上已经改变了程序的功能 - 它本来会把内容输出到控制台(副作用!),但是现在没有了。虽然这看起来是一个微不足道的变化,但它确实表明了 getUserName 不是引用透明的(getUserData也不是)。


总结

我们现在明白了管理应用程序状态意味着什么,函数式程序员口中的不变性引用透明性副作用是什么意思 - 以及共享状态可能引入哪些问题。

下一次,我们将开始讨论更复杂的函数式编程技术。我们将学习如何识别和使用闭包(clousures)部分应用(partial application)柯里化(currying)

那是一个很有趣, 又激动人心,但同时也很有挑战的部分。下次见!