標籤:

函數式編程在Redux/React中的應用

本文簡述了軟體複雜度問題及應對策略:抽象和組合;展示了抽象和組合在函數式編程中的應用;並展示了Redux/React在解決前端狀態管理的複雜度方面對上述理論的實踐。這其中包括了一段有趣的Redux推導。

軟體複雜度及其應對策略

軟體複雜度

軟體的首要技術使命是管理複雜度。——代碼大全

在軟體開發過程中,隨著需求的變化和系統規模的增大,我們的項目不可避免地會趨於複雜。如何對軟體複雜度及其增長速率進行有效控制,便成為一個日益突出的問題。下面介紹兩種控制複雜度的有效策略。

對應策略

抽象

世界的複雜、多變和人腦處理問題能力的有限性,要求我們在認識世界時對其做簡化,提取出一般化和共性的概念,形成理論和模型,然後反過來指導我們改造世界。而一般化的過程即抽象的過程,抽象思維使我們忽略不同事物的細節差異,抓住它們的本質,並提出解決本質問題的普適策略。

例如,範疇論將世界抽象為對象和對象之間的聯繫,Linux 將所有I/O介面都抽象為文件,Redux將所有事件抽象為action。

組合

組合是另一種處理複雜事物的有效策略。通過簡單概念的組合可以構造出複雜的概念;通過將複雜任務拆分為多個低耦合度的簡單的子任務,我們可以對各子任務分而治之;各子任務解決後,將它們重新組合起來,整個任務便得以解決。

軟體開發的過程,本質上也是人們認識和改造世界的一種活動,所以也可以藉助抽象和組合來處理複雜的任務。

抽象與組合在函數式編程中的應用

函數式編程是相對於命令式編程而言的。命令式編程依賴數據的變化來管理狀態變化,而函數式編程為克服數據變化帶來的狀態管理的複雜性,限制數據為不可變的,其選擇使用流式操作來進行狀態管理。而流式操作以函數為基本的操作單元,通過對函數的抽象和組合來完成整個任務。下面對抽象和組合在函數式編程中的應用進行詳細的講解。

高階函數的抽象

一種功能強大的語言,需要能為公共的模式命名,建立抽象,然後直接在抽象的層次上工作。

如果函數只能以數值或對象為參數,將會嚴重限制人們建立抽象的能力。經常會有一些同樣的設計模式能用於若干不同的過程。為了將這種模式描述為相應的概念,就需要構造出這樣的函數,使其以函數作為參數,或者將函數作為返回值。這類能操作函數的函數稱為高階函數。

在進行序列操作時,我們抽象出了三類基本操作:map、filter 和 reduce 。可以通過向這三個抽象出來的高階函數注入具體的函數,生成處理具體問題的函數;進一步,通過組合這些生成的具體的函數,幾乎可以解決所有序列相關的問題。以 map 為例,其定義了一大類相似序列的操作:對序列中每個元素進行轉換。至於如何轉換,需要向 map 傳入一個具體的轉換函數進行具體化。這些抽象出來的高階函數相當於具有某類功能的通用型機器,而傳入的具體函數相當於特殊零件,通用機器配上具體零件就可以應用於屬於該大類下的各種具體場景了。

map 的重要性不僅體現在它代表了一種公共的模式,還體現在它建立了一種處理序列的高層抽象。迭代操作將人們的注意力吸引到對於序列中逐個元素的處理上,引入 map 抑制了對這種細節層面上的關注,強調的是從源序列到目標序列的變換。這兩種定義形式之間的差異,並不在於計算機會執行不同的計算過程,而在於我們對同一種操作的不同思考方式。從作用上看,map 幫我們建立了一層抽象屏障,將序列轉換的函數實現,與如何提取序列中元素以及組合結果的細節隔離開。這種抽象也提供了新的靈活性,使我們有可能在保持從序列到序列的變換操作框架的同時,改變序列實現的底層細節。

例如,我們有一個序列:

const list = [9, 5, 2, 7]n

若對序列中的每個元素加 1:

map(a => a + 1, list) //=> [10, 6, 3, 8]n

若對序列中的每個元素平方:

map(a => a * a, list) //=> [81, 25, 4, 49]n

我們只需向 map 傳入具體的轉換函數,map 便會自動將函數映射到序列的的每個元素。

高階函數的組合

高階函數使我們可以顯式地使用程序設計元素描述過程(函數)的抽象,並能像操作其它元素一樣去操作它們。這讓我們可以對函數進行組合,將多個簡單子函數組合成一個處理複雜任務的函數。下面對高階函數的組合進行舉例說明。

現有一份某公司僱員某月的考核表,我們想統計所有到店餐飲部開發人員該月完成的任務總數,假設員工七月績效結構如下:

[{n name: Pony,n level: p2.1,n segment: 到餐n tasks: 16,n month: 201707,n type: RD,n ...n}, {n name: Jack,n level: p2.2,n segment: 外賣n tasks: 29,n month: 201707,n type: QA,n ...n}n...n]n

我們可以這樣做:

const totalTaskCount = compose(n reduce(sum, 0), // 4. 計算所有 RD 任務總和n map(person => person.tasks), // 3. 提取每個 RD 的任務數n filter(person => person.type === RD), // 2. 篩選出到餐部門中的RDn filter(person => person.segment === 到餐) // 1. 篩選出到餐部門的員工n)n

上述代碼中,compose 是用來做函數組合的,上一個函數的輸出作為下一個函數的輸入。類似於流水線及組成流水線的工作台。每個被組合的函數相當於流水線上的工作台,每個工作台對傳過來的工件進行加工、篩選等操作,然後輸出給下一個工作台進行處理。

compose 調用順序為從右向左(自下而上),Ramda 提供了另一個與之對應的API:pipe,其調用順序為從左向右。compose意為組合,pipe意為管道、流,其實流是一種縱向的函數組合。

計算到餐RD完成任務總數示意圖如下所示:

通過上節map示例和本節的計算到餐RD完成任務總數的示例,我們可以看到利用高階函數進行抽象和組合的強大和簡潔之處。這種通用模式(模塊)+ "具體函數"組合的模式,顯示了通用模塊的普適性和處理具體問題時的靈活性。

上面講了很多高階函數的優勢和實踐,然而一門語言如何才能支持高階函數呢?

通常,程序設計語言總會對基本元素的可能使用方式進行限制。帶有最少限制的元素被稱為一等公民,包括的 "權利或者特權" 如下所示:

  • 可以使用變數命名;
  • 可以提供給函數作為參數;
  • 可以由函數作為結果返回;
  • 可以包含在數據結構中;

幸運的是在JavaScript中,函數被看作是一等公民,也即我們可以在JavaScript中像使用普通對象一樣使用高階函數進行編程。

流式操作

由上述過程我們得到了一種新的模式——數據流。信號處理工程師可以很自然地用流過一些級聯的處理模塊信號的方式來描述這一過程。例如我們輸入公司全員月度考核信息作為信號,首先會流過兩個過濾器,將所有不符合要求的數據過濾掉,這樣得到的信號又通過一個映射,這是一個 "轉換裝置",它將完整的員工對象轉換為對應的任務信息。這一映射的輸出被饋入一個累加器,該裝置用 sum 將所有的元素組合起來,以初始的0開始。

要組織好這些過程,最關鍵的是將注意力集中在處理過程中從一個步驟流向下一個步驟的"信號"。如果我們用序列來表示這些信號,就可以利用序列操作實現每步處理。

或許因為序列操作模式非常具有一般化的性質,於是人們發明了一門專門處理序列的語言Lisp(LISt Processor)……

將程序表示為針對序列的操作,這樣做的價值就在於能幫助我們得到模塊化的程序設計,也就是說,得到由一些比較獨立的片段的組合構成的設計。通過提供一個標準部件的庫,並使這些部件都有著一些能以各種靈活方式相互連接的約定介面,將能進一步推動人們去做模塊化的設計。

用流式操作進行狀態管理

在前面,我們已經看到了組合和抽象在克服大型系統複雜性方面所起的作用。但還需要一些能夠在整體架構層面幫助我們構造起模塊化的大型系統的策略。

目前有兩種比較流行的組織策略:面向對象和流式操作。

面向對象組織策略將注意力集中在對象上,將一個大型系統看成一大批對象,它們的狀態和行為可能隨著時間的進展而不斷變化。流式操作組織策略將注意力集中在流過系統的信息流上,很像電子工程師觀察一個信號處理系統。

在利用面向對象模式模擬真實世界中的現象時,我們用具有局部狀態的計算對象去模擬真實世界裡具有局部狀態的對象;用計算機裡面隨著時間的變化去表示真實世界裡隨著時間的變化;在計算機里,被模擬對象隨著時間的變化是通過對那些模擬對象中局部變數的賦值實現的。

我們必須讓相應的模型隨著時間變化,以便去模擬真實世界中的現象嗎?答案是否定的。如果以數學函數的方式考慮這些問題,我們可以將一個量 x 隨時間而變化的行為,描述為一個時間的函數 x(t)。如果我們集中關注的是一個個時刻的 x,可以將它看做一個變化著的量。如果關注的是這些值的整個時間史,那麼就不需要強調其中的變化——這一函數本身並沒有變化。

如果用離散的步長去度量時間,就可以用一個(可能無窮的)序列來模擬變化,以這種序列表示被模擬系統隨著時間變化的歷史。為此,我們需要引進一種稱為流的新數據結構。從抽象的角度看,一個流也是一個序列(無窮序列)。

流處理使我們可以模擬一些包含狀態的系統,但卻不需要賦值或者變動數據,能避免由於引進了賦值而帶來的內在缺陷。

例如在前端開發中,一般會用對象模型(DOM)來模擬和直接操控網頁,隨著與用戶不斷交互,網頁的局部狀態不斷被修改,其中的行為也會隨時間不斷變化。隨著時間的累積,我們頁面狀態管理變得愈加複雜,以致於最終我們可能自己也不知道網頁當前的狀態和行為。

為了克服對象模型隨時間變化帶來的狀態管理困境,我們引入了 Redux,也就是上面提到的流處理模式,將頁面狀態 state 看作時間的函數 state = state(t) -> state = stateF(t),因為狀態的變化是離散的,所以我們也可以寫成 stateF(n) 。通過提取 state 並顯式地增加時間維度,我們將網頁的對象模型轉變為流處理模型,用 [state] 序列表示網頁隨著時間變化的狀態。

由於 state 可以看做整個時間軸上的無窮(具有延時)序列,並且我們在之前已經構造起了對序列進行操作的功能強大的抽象機制,所以可以利用這些序列操作函數處理 state ,這裡我們用到的是 reduce 。

函數式編程在Redux/React中的應用

從reduce到Redux

reduce

reduce 是對列表的迭代操作的抽象,map 和 filter 都可以基於 reduce 進行實現。Redux借鑒了 reduce 的思想,是 reduce 在時間流處理上的一種特殊應用。接下來我們展示Redux是怎樣由 reduce一步步推導出來的。

首先看一下 reduce 的類型簽名:

reduce :: ((a, b) -> a) -> a -> [b] -> annreduce :: (reducer, initialValue, list) -> resultnreducer :: (a, b) -> aninitialValue :: anlist :: [b]nresult :: an

上述類型簽名採用的是Hindley-Milner 類型系統,接觸過Haskell的的同學對此會比較熟悉。其中 :: 左側部分為函數或參數名稱,右側為該函數或參數的類型。

reduce 接受三個參數:累積器 reducer ,累積初始值 initialValue,待累積列表 list 。我們迭代遍歷列表的元素,利用累積器reducer 對累積值和列表當前元素進行累積操作,reducer 輸出新累積值作為下次累積操作的輸入。依次循環迭代,直到遍歷結束,將此時的累積值作為 reduce 最終累積結果輸出。

reduce 在某些編程語言中也被稱為 foldl。中文翻譯有時也被稱為摺疊、歸約等。如果將列表看做是一把展開的扇子,列表中的每個元素看做每根扇骨,則 reduce 的過程也即扇子從左到右不斷摺疊(歸約、累積)的過程。當扇子完全合上,一次摺疊也即完成。當然,摺疊順序也可以從右向左進行,即為 reduceRight 或 foldr。

reduce 代碼實現如下:

const reduce = (reducer, initialValue, list) => {n let acc = initialValue;n let val;n for(let i = 0; i < list.length; i++) {n val = list[i];n acc = reducer(acc, val);n }n return acc;n};n

例如,我們想對一個數字列表 [2, 3, 4] 進行累加操作(初始值為 1 ),可以表示為:

reduce((a, b) => a + b, 1, [2, 3, 4])n

示意圖如下所示:

介紹完 reduce 的基本概念,接下來展示如何由 reduce 一步步推導出 Redux,以及 Redux 各部分與 reduce 的對應關係。

Redux

首先定義 Redux 的類型簽名:

redux :: ((state, action) -> state) -> initialState -> [action] -> statenredux :: (reducer, initialState, stream) -> resultnnreducer :: (state, action) -> stateninitialState :: statenlist :: [action]nresult :: staten

將 reduce 參數的名稱變換一下,便得到Redux的類型簽名。從類型簽名看,Redux參數包含 reducer 函數,state初始值 initialState ,和一個以 action 為元素的時間流列表 stream :: [action];返回值為最終的狀態 state。

Redux初步實現

下面看一下Redux的初步實現:

const redux = (reducer, initialState, stream) => {n let state = initialState;n let action;n for(let i = 0; i < stream.length; i++) {n action = stream[i];n state = reducer(state, action);n }n return state;n}n

首先設置Redux state 的初始值 initialState,stream 代表基於時間的事件流列表,action = stream[i] 代表事件流上某個時間點發生的一次 action。每次 for 循環,我們將當前的狀態 state和 action 傳給 reducer 函數,根據本次 action 對當前 state 進行更新,產生新的 state。新的 state 作為下次 action 發生時的 state 參與狀態更新。

Redux基本原理其實已經講完了,Redux的各個概念如:reducer 函數、state、 stream :: [action] 也是和 reduce 一一對應的。不同之處在於,redux 中的列表 stream,是一個隨時間不斷生成的無限長的 action 動作列表,而 reduce 中的列表是一個普通的 list。

等一下,上述Redux實現貌似缺了些什麼……

是的,在Redux中,狀態的改變和獲取是通過兩個函數來操作的:dispatch、getState,接下來我們將這兩個函數添加進去。

Redux優化實現

const redux = (reducer, initialState, stream) => {n let currentState = initialState;n let action;nn const dispatch = action => {n currentState = reducer(currentState, action);n };n const getState = () => currentState;nn for(i = 0; i < stream.length; i++) {n action = stream[i];n dispatch(action);n }n return state; // the end of the world :)n}n

這樣我們就可以通過 dispatch(action) 來更新當前的狀態,通過 getState 也可以拿到當前的狀態。

但是還是感覺不太對?

在上述實現中,stream 並不是現實中的事件流,只是普通的列表而已,dispatch 和 getState 介面也並沒有暴露給外部,同時在Redux最後還有一個 return state ,既然說過 stream 是一個無限長的列表,那 return state 貌似沒有什麼意義。

好吧,上述兩次Redux代碼實現,其實都是對Redux原理的說明,下面我們來真正實現一個現實中可運行的最小Redux代碼片段。

Redux可用的最小實現

const redux = (reducer, initialState) => {n let currentState = initialState;nn const dispatch = action => {n currentState = reducer(currentState, action);n };n const getState = () => currentState;nn return ({n dispatch,n getState,n });n};nnconst store = redux(reducer, initialState);nconst action = { type, payload };nstore.dispatch(action);nstore.getState();n

Yes! 我們將 stream 從Redux函數中抽離出來,或者說是從電腦屏幕上抽取到現實世界中了。

我們首先使用 reducer 和 initialState 初始化 redux 為 store;然後現實中每次事件發生時,我們通過 store.dispatch(action) 更新store中狀態;同時通過 store.getState() 來獲取 store 的當前狀態。

等等,這怎麼聽著像是面向對象的編程方式,對象中包含私有變數:currentState 和操作私有變數的方法:dispatch 和 getState,偽代碼如下所示:

const store = {n private currentState: initialState,n public dispatch: (action) => { currentState = reducer(currentState, action)},n public getState: () => currentState,n}n

是的,從這個角度講,我們確實是用了函數式的過程實現了一個面向對象的概念。

如果你再仔細看的話,我們用閉包(編程領域的閉包,與集合意義上的閉包不同)實現的這個對象,雖然最後的Redux實現返回的是形式為 { dispatch, getState } store 對象,但 dispatch 和 getState捕獲了Redux內部創建的 currentState,因此形成了閉包。

Redux的運作過程如下所示:

Redux 和 reduce 的聯繫與區別

我們來總結一下 Redux 和 reduce 的聯繫與區別。

相同點:

  • reduce和Redux都是對數據流進行fold(摺疊、歸約);
  • 兩者都包含一個累積器(reducer)((a, b) -> a VS (state, action) -> state )和初始值(initialValue VS initialState ),兩者都接受一個抽象意義上的列表(list VS stream )。

不同點:

  • reduce:接收一個有限長度的普通列表作為參數,對列表中的元素從前往後依次累積,並輸出最終的累積結果。
  • Redux:由於基於時間的事件流是一個無限長的抽象列表,我們無法顯式地將事件流作為參數傳給Redux,也無法返回最終的累積結果(事件流無限長)。所以我們將事件流抽離出來,通過 dispatch 主動地向 reducer 累積器 push action,通過 getState 觀察當前的累積值(中間的累積過程)。
  • 從冷、熱信號的角度看,reduce 的輸入相當於冷信號,累積器需要主動拉取(pull)輸入列表中的元素進行累積;而Redux的輸入(事件流)相當於熱信號,需要外部主動調用 dispatch(action) 將當前元素push給累積器。

由上可知,Redux將所有的事件都抽象為 action,無論是用戶點擊、Ajax請求還是頁面刷新,只要有新的事件發生,我們就會 dispatch 一個 action 給 reducer,並結合上一次的狀態計算出本次狀態。抽象出來的統一的事件介面,簡化了處理事件的複雜度。

Redux還規範了事件流——單向事件流,事件 action 只能由 dispatch 函數派發,並且只能通過 reducer 更新系統(網頁)的狀態 state,然後等待下一次事件。這種單向事件流機制能夠進一步簡化事件管理的複雜度,並且有較好的擴展性,可以在事件流動過程中插入 middleware,比如日誌記錄、thunk、非同步處理等,進而大大增強事件處理的靈活性。

Redux 的增強:Transduce與Redux Middleware

transduce 作為增強版的 reduce,是在 Clojure 中首次引入的。transduce 相當於 compose 和 reduce 的組合,相對於 reduce 改進之處為:列表中的每個元素在放入累積器之前,先對其進行一系列的處理。這樣做的好處是能同時降低代碼的時間複雜度和空間複雜度。

假設有一個長度為n的列表,傳統列表處理的做法是先用 compose 組合一系列列表處理函數對列表進行轉換處理,最後對處理好的列表進行歸約(reduce)。假設我們組合了 m 個列表處理函數,加上最後一次 reduce,時間複雜度為 n * (m + 1);而使用 transduce 只需要一次循環,所以時間複雜度為 n 。由於 compose 的每個處理函數都會產生中間結果,且這些中間結果有時會佔用很大的內存,而 transduce 邊轉換邊累積,沒有中間結果產生,所以空間複雜度也得到了有效的控制。

我們也可以對Redux進行類似地增強優化,每次 dispatch(action) 時,我們先根據 action 進行一系列操作,最後傳給 reducer 函數進行真正的狀態更新。這就是上文提到的Redux middleware。Redux是一個功能和擴展性非常強的狀態管理庫,而圍繞Redux產生的一系列優秀的middlewares讓Redux/React 形成了一個強大的前端生態系統。個人認為Redux/React自身良好的架構、先進的理念,加上一系列優秀的第三方插件的支持,是React/Redux成功的關鍵所在。

純函數在React中的應用

Redux可以用作React的數據管理(數據源),React接受Redux輸出的state,然後將其轉換為瀏覽器中的具體頁面展示出來:

view = React(state)n

由上可知,我們可以將React看作輸入為state,輸出為view的「純」函數。下面講解純函數的概念、優點,及其在React中的應用。

純函數的定義:相同的輸入,永遠會得到相同的輸出,並且沒有副作用。

純函數的運算既不受外部環境和內部不確定性因素的影響,也不會影響外部環境。輸出只與輸入有關。

由此可得純函數的一些優點:可緩存、引用透明、可等式推導、可預測、單測友好、易於並發操作等。

其實函數式編程中的純函數指的是數學意義上的函數,數學中函數定義為:

函數是不同數值之間的特殊關係:每一個輸入值返回且只返回一個輸出值。

從集合的角度講,函數分為三部分:定義域和值域,以及定義域到值域的映射。函數調用(運算)的過程即定義域到值域映射的過程。

如果忽略中間的計算過程,從對象的角度看,函數可以看做是鍵值對映射,輸入參數為鍵,輸出參數為鍵對應的值。如果一段代碼可以替換為其執行結果,而且是在不改變整個程序行為的前提下替換的,我們就說這段代碼是引用透明的。

由於純函數相同的輸入總是返回相同的輸出,我們認為純函數是引用透明的。

純函數的緩存便是引用透明的一個典型應用,我們將被調用過的參數及其輸出結果作為鍵值對緩存起來,當下次調用該函數時,先查看該參數是否被緩存過,如果是,則直接取出緩存中該鍵對應的值作為調用結果返回。

緩存技術在做耗時較長的函數調用時比較有用,比如GPU在做大型3D遊戲畫面渲染時,會對計算時間較長的渲染做緩存,從而增強畫面的流暢度。網頁中的DOM操作也是非常耗時的,而React組件本身也是純函數,所以React對 state 可以進行緩存,如果state沒有變化,就還用之前的網頁,頁面不需要重新渲染。

帶有緩存的最終 React-Redux 框架如下所示:

總結

我們從產生軟體複雜度的原因出發,從方法層面上講了控制代碼複雜度的兩種基本方式:抽象和組合,利用處理列表的高階函數(map、filter、reduce、compose)對抽象和組合進行了舉例解釋。

然後從整體架構層面上講了應對複雜度的策略:面向對象和流式處理,分析了兩者的基本理念,以及流式處理在狀態管理方面的優勢,引申出基於時間的抽象事件流。

然後我們展示了如何從列表處理方法 reduce 推導出可用的事件流處理框架Redux,並將 reduce 的加強版 transduce 與Redux的 middleware 做了類比。

最後講了純函數在 react/redux 框架中的應用:將頁面渲染抽象為純函數,利用純函數進行緩存等。

貫穿文章始終的是抽象、組合、函數式編程以及流式處理。希望通過本文讓大家對軟體開發的一些基本理念及其應用有所了解。從 reduce 推導出Redux的過程非常有趣,感興趣的同學可以多看一下。

參考文檔

  • Harold A, Gerald J S, Julie S. Structure and Interpretation of Computer Programs. MIT Press. 1996.
  • Neal Ford. 函數式編程思維. 郭曉剛 譯. 人民郵電出版社, 2015.
  • Category Theory for Programmers.
  • Mostly Adequate Guide to Functional Programming(中文版).

作者簡介

增迪,美團點評前端工程師,熟悉函數式編程、Haskell等,有較為豐富的函數式編程前端實踐經驗。參與 Ramda 函數式編程庫及其中文網站的開發與維護。


推薦閱讀:

為什麼諸多編程語言都將模式匹配作為重要構成?
Haskell中的foldl和foldr的聯繫?
紅塵里的Haskell(之一)——Haskell工具鏈科普
用哪些編程語言寫出的代碼,讀著能感受到美?

TAG:函数式编程 |