(譯)函數式 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)

那是一個頗有趣, 又激動人心,但同時也頗有挑戰的部分。下次見!

相關文章
相關標籤/搜索