[譯]Reduce(軟體編寫)(第五部分)

吳曉軍發表於2017-04-17

[譯]Reduce(軟體編寫)(第五部分)

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0) (譯註:該圖是用 PS 將煙霧處理成方塊狀後得到的效果,參見 flickr。))

注意:這是 “軟體編寫” 系列文章的第五部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數語言程式設計和組合化軟體(compositional software)技術(譯註:關於軟體可組合性的概念,參見維基百科 Composability)。後續還有更多精彩內容,敬請期待!

<上一篇 | << 返回第一篇

在函數語言程式設計中,reduce(也稱為:fold,accumulate)允許你在一個序列上迭代,並應用一個函式來處理預先宣告的累積值和當前迭代到的元素。當迭代完成時,將返回這個累積值。許多其他有用的功能都可以通過 reduce 實現。多數時候,reduce 可以說是處理集合(collection)最優雅的方式。

reduce 接受一個 reducer 函式以及一個初始值,最終返回一個累積值。對於 Array.prototype.reduce() 來說, 初始列表將由 this 指明, 所以列表本身不會作為該函式的引數:

array.reduce(
  reducer: (accumulator: Any, current: Any) => Any,
  initialValue: Any
) => accumulator: Any複製程式碼

我們利用如下方式對一個陣列進行求和:

[2, 4, 6].reduce((acc, n) => acc + n, 0); // 12複製程式碼

對於陣列的每步迭代,reducer 函式都會被呼叫,並且向其傳入了累積值和當前迭代到的陣列元素。reducer 的職責在於以某種方式將當前迭代的元素 “合攏(fold)” 到累加值中。reducer 規定了 “合攏” 的手段和方式,完成了對當前元素的 “合攏” 後,reducer 將返回新的累加值,然後, .reduce() 將開始處理陣列中的下一個元素。reducer 需要一個初始值才能開始工作,所以絕大多數的 .reduce() 實現都需要接收一個初始值作為引數。

在陣列元素求和一例中,reducer 函式第一次呼叫時,acc 將會以 0 值(該值是傳入 .reduce() 方法的第二個引數)開始。然後,reducer 返回了 0 + 22 是陣列的第一個元素), 也就是返回了 2 作為新的累積值。下一步,acc = 2, n = 4 傳入了 reducer,reducer返回了 2 + 46)。在最後一步迭代中,acc = 6, n = 6, reducer 返回了 12。迭代完成,.reduce() 返回了最終的累積值 12

在這一例子中,我們傳入了一個匿名函式作為 reducer,但是我們也可以抽象出每次求和的過程為一個具名函式,這使得我們程式碼的複用程度更高:

const summingReducer = (acc, n) => acc + n;
[2, 4, 6].reduce(summingReducer, 0); // 12複製程式碼

通常,reduce 的工作過程為由左向右。在 JavaScript 中,我們也有一個 [].reduceRight() (譯註:MDN -- Array.prototype.reduceRight())方法來讓 reduce 由右向左地工作。 具體說來,如果你對陣列 [2, 4, 6] 應用 .reduceRight() ,第一個被迭代到的元素就將是 6,最後一個迭代到的元素就是 2

無所不能的 reduce

別吃驚,reduce 確實無所不能,你所熟悉的 map()filter()forEach() 以及其他函式都可藉助於 reduce 來建立。

Map:

const map = (fn, arr) => arr.reduce((acc, item, index, arr) => {
  return acc.concat(fn(item, index, arr));
}, []);複製程式碼

對於 map 來說,我們的累積值就是一個新的陣列物件,該陣列物件中的每個元素都由原陣列對應元素對映得到。累積陣列中新的元素由傳入 map 的對映函式(fn)所確定:對於當前迭代到的元素 item,我們通過 fn 計算出新的元素,並將其拼接入累加陣列 acc 中。

Filter:

const filter = (fn, arr) => arr.reduce((newArr, item) => {
  return fn(item) ? newArr.concat([item]) : newArr;
}, []);複製程式碼

filter 的工作方式與 map 類似,只不過原陣列的元素只有通過一個真值檢測函式(predicate function)才能被送入新的累積陣列中。亦即,相較於 map,filter 是有條件地選擇元素到累積陣列中,並且不會改變元素的值。

上面幾個例子,你處理的資料都是一些數值序列,你在數值序列上應用指定的函式迭代資料,並將結果合攏到累積值中。大多數應用都因此開始雛形初備,但是你想過這個問題:假如你的序列是函式序列呢?

Compose:

reduce 也是實現函式組合的便捷渠道。假如你想用將函式 g 的輸出作為函式 f 的輸入,即組合這兩個函式: f . g,那麼你可以使用下面的 JavaScript 程式碼片,它沒有任何的抽象:

f(g(x))複製程式碼

reduce 讓我們能抽象出函式組合過程,從而讓你也能輕易地實現更多層次的函式組合:

f(g(h(x)))複製程式碼

為了使函式組合是由右向左的,我們就要使用上面提到的 .reduceRight() 方法來抽象函式組合過程:

const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);複製程式碼

注意:如果 JavaScript 的版本沒有提供 [].reduceRight(),你可以藉助於 reduce 實現該方法。該實現留給讀者自己思考。

Pipe:

compose() 很好地描述了由內至外的組合過程,某種程度上,這是數學上的關於輸入輸出的組合。如果你想從事件發生順序上來思考函式組合呢?

假設我們想要對一個數值加 1,然後對新得到的數值進行翻倍。如果是利用 compose(),就需要這麼做:

const add1 = n => n + 1;
const double = n => n * 2;

const add1ThenDouble = compose(
  double,
  add1
);

add1ThenDouble(2); // 6
// ((2 + 1 = 3) * 2 = 6)複製程式碼

發現問題沒有?第一步(加1操作)是 compose 序列上的最後一個元素,所以,compose 需要你自底向上地分析流程的執行。

我們使用 reduce 由左向右的常用特性取代由右向左的組合方式,以示區別,我們用 pipe 來描述新的組合方式:

const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);複製程式碼

現在,新的流程就可以這麼撰寫:

const add1ThenDouble = pipe(
  add1,
  double
);

add1ThenDouble(2); // 6
// ((2 + 1 = 3) * 2 = 6)複製程式碼

如你所見,在組合中,順序是非常重要的,如果你調換了 doubleadd1 的順序,你將得到截然不同的結果:

const doubleThenAdd1 = pipe(
  double,
  add1
);

doubleThenAdd1(2); // 5複製程式碼

之後,我們還會討論跟多的關於 compose()pipe() 的細節。現在,你所要知道的只是,reduce() 是一個極為強大的工具,因此一定要掌握它。 如果在學習過程中遇到了挫折,也大可不必灰心,很多開發者都花了大量時間才能掌握 reduce。

Redux 中的 reduce

你可能聽說過 “reducer” 這個術語被用於描述 Redux 的狀態更新。這篇文章撰寫之時,對於使用了 React 或者 Angular 進行構建的 web 應用來說,Redux 是最流行的狀態管理庫/架構(Angualar 中的類 Redux 管理是 ngrx/store )。

Redux 使用了 reducer 函式來管理應用狀態。一個 Redux 風格的 reducer 接收一個當前應用狀態 state 和 和互動物件 action 作為引數(譯註:當前狀態就相當於累積值,而 action 就相當於目前處理的元素),處理完成後,返回一個新的應用狀態:

reducer(state: Any, action: { type: String, payload: Any}) => newState: Any複製程式碼

Redux 的一些 reducer 規則需要你牢記在心:

  1. 一個 reducer 如果進行了無參呼叫,它要返回它的初始狀態。
  2. 如果 reducer 操縱的 action 沒有宣告型別,他要返回當前狀態。
  3. 最最重要的是,Redux reducer 必須是純函式。

現在,我們以 Redux 風格重寫上面的求和 reducer,該 reducer 的行為將由 action 型別決定:

const ADD_VALUE = 'ADD_VALUE';

const summingReducer = (state = 0, action = {}) => {
  const { type, payload } = action;

  switch (type) {
    case ADD_VALUE:
      return state + payload.value;
    default: return state;
  }
};複製程式碼

關於 Redux 的一個非常美妙的事兒就是,其 reducer 都是標準的 reducer (譯註:即接收 accumulatorcurrent 兩個引數的 reducer ),這意味著你將 Redux 中的 reducer 插入到任何現有的 reduce() 實現中去,比如最常用的 [].reduce()。以此為例,我們可以建立一個 action 物件的陣列,並對其進行 reduce 操作,傳入 reduce() 的將是我們定義好的 summingReducer,據此,我們獲得一個狀態快照。之後,一旦對 Redux 中的狀態樹(store)分派了同樣的 action 序列,那麼一定能俘獲到相同的狀態快照:

const actions = [
  { type: 'ADD_VALUE', payload: { value: 1 } },
  { type: 'ADD_VALUE', payload: { value: 1 } },
  { type: 'ADD_VALUE', payload: { value: 1 } },
];

actions.reduce(summingReducer, 0); // 3複製程式碼

這使得對 Redux 風格的 reducer 的單元測試變得極為容易。

總結

現在,你應該可以瞥見 reduce 的強大甚至是無所不能了。雖然,理解 reduce 要比理解 map 或者 filter 難一些,還是函數語言程式設計中重要的工具,這個工具強大在它是一個基礎工具,能夠通過它構建出更多更強大的工具。

下一篇: Functors 與 Categories >

接下來

想學習更多 JavaScript 函數語言程式設計嗎?

跟著 Eric Elliott 學 Javacript,機不可失時不再來!

[譯]Reduce(軟體編寫)(第五部分)

Eric Elliott“編寫 JavaScript 應用” (O’Reilly) 以及 “跟著 Eric Elliott 學 Javascript” 兩書的作者。他為許多公司和組織作過貢獻,例如 Adobe SystemsZumba FitnessThe Wall Street JournalESPNBBC 等 , 也是很多機構的頂級藝術家,包括但不限於 UsherFrank Ocean 以及 Metallica

大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一起。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章