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

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

photo by Yung Chang on Unsplash

原文连接 By: Krzysztof Czernekios

介绍

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

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

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

什么是状态?

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

简而言之,你能够将应用程序状态视为如下这几点的集合:axios

  • 全部变量的当前值,
  • 全部被分配的对象,
  • 打开的文件描述符,
  • 打开的网络套接字(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)

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

相关文章
相关标签/搜索