(譯)函式式 JS #3: 狀態

RockyDd發表於2019-01-21

這是"函式式 JS" 系列文章的第三篇。 點選檢視 上一篇第一篇

(譯)函式式 JS #3: 狀態
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)

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

相關文章