函數式編程中的組合子

函數式編程是一個比較大的話題,裏面的知識體系很是的豐富,在這裏我並不想講的特別的詳細。爲了應對實際中的應用,咱們講一下函數式編程中最爲實用的應用方式——組合子。組合子自己是一種高階函數,他的特色就是將函數進行延遲或者轉換,在函數式編程中應用最爲普遍。

什麼是組合子

組合子在數學中就有,但咱們講的並非數學中的定義,而是在JavaScript領域中的組合子概念。按照我所理解的JavaScript函數式編程,我將組合子分爲輔助組合子函數組合子。後續咱們會對這兩種組合子進行區別。javascript

組合子(全稱:組合子函數)又稱之爲裝飾器函數,用於轉換函數或數據,加強函數或數據行爲的高階函數。這裏咱們提到的高階函數並不陌生,所謂的高階函數,就是以函數爲參數或者返回值的函數。輔助組合子是最爲簡單的組合子,它是具備數據流程控制的抽象函數。而函數組合子就很特別了,它必須以函數(稱之爲原函數契函數)爲參數,其大體有以下特色:css

  1. 函數組合子自己就是高階函數
  2. 不改變原函數契函數)的最終意圖;
  3. 能加強原函數(契函數)的行爲;

圖片描述

圖片描述

高階函數的概念咱們已經很熟悉了,這裏不作多的解釋,咱們只強調,函數組合子是以原函數爲參數,返回新函數高階函數。知道這一點,咱們再來解釋後面兩個特性,所謂「不改變原函數的最終意圖」,即原函數是作什麼的,新函數就是作什麼的。原函數新函數的所需參數是一致的,返回值也是同樣的。這裏咱們先賣個關子,稍後咱們會用實際的案例來說述一下這個特性。「能加強原函數的行爲」,這是函數式編程的核心概念之一,他並不難理解,但須要咱們花更多的時間去關注。html

那麼什麼是函數的行爲,爲何要加強函數的行爲。函數有三個重要的部分:輸入、處理、輸出。輸入就是指的參數,而處理就是函數體中對參數的執行過程,輸出就是返回值。在JavaScript語言中,即便函數沒有輸出,都約定輸出的是undefined,以上都是咱們很是熟悉的概念。前端

參數便是「元」(arity)的,分爲:一元unary)、二元binary)、三元ternary)、多元polyadic)、可變元variadic)。除了一元函數,其餘的參數都或多或少存在着這樣的兩個問題:java

  1. 參數的獲取時機;
  2. 參數的獲取順序;

圖片描述

以咱們最常使用的ajax.get爲例子,該函數有4個參數,最常使用的是其中的三元用法:git

/**
 * @param {String} url - 請求地址
 * @param {*} data - 請求參數
 * @param {Function} success - 成功回調函數
 */
$.get(url, data, success(response));

對於success回調函數,咱們由於知道數據獲取的格式以及目標轉換格式,所以咱們能夠很快的構建這個回調。但若是url地址是要從DOM上獲取,或者從其餘資源文件中讀取,那麼該函數的執行與否,會更多的依賴url的獲取與否,甚至還要考慮異步的問題。github

組合子就是要解決這個問題,但這裏咱們不着急解決剛纔提出的例子,由於在講解組合子以前,咱們還要鋪墊的說到函數式編程中比較重要的三個概念:柯里化偏函數應用函數組合ajax

柯里化

若是函數的參數足夠多,而我又不肯定函數參數是否能在同一時間所有獲取到,那麼在執行這個函數前,總要等待用戶的輸入所有完成時,才能執行。而在等待以前,任何一處參數的獲取時間將會影響後續過程的執行:spring

var url = getUrl(); // 若是這句耗時過長,將致使後面很難被執行到
var data = getData();
var callback = function (res) {};
$.get(url, data, callback);

若是不是一次性輸入完成,爲什麼不返回一個新的函數來等待用戶的下一次輸入呢?柯里化很輕蔑的說了這麼一句,因而它這樣作:數據庫

var curryGet = url => data => callback => $.get(url, data, callback);
curryGet(url)(data)(callback);

是的,你沒看錯,這簡直像魔法同樣,原來JavaScript中的函數還能這麼玩。爲了照顧不少沒有學習ES6+的同窗,咱們直接用ES5的語法來書寫。爲了保證一致性,後面都將採用ES5的語法,特別狀況下我會用ES6+來從新描述。那麼剛纔的柯里化代碼用ES5寫就是:

var currGet = function (url) {
  return function (data) {
    return function (callback) {
      return $.get(url, data, callback);
    }
  }
}

天啊,是否是已經看暈了,是否是有小夥伴火燒眉毛的想去學習ES6+了呢?但這裏原理很簡單,只是用到了名爲閉包的魔法。原先依靠逗號分隔的參數,如今要一次次的輸入,而且輸入完最後一個方可執行。在不少同窗看來,這樣作並不高明,增長了不少function的包裹不說,運行的結果和以前沒有區別。是的,但他沒有任何意義?

這裏咱們並不打算詳細研究函數編程的性能問題,這是一個很大的話題,在架構中也是有取有舍的。我只能說,整體性能上不必定比原來的差,某些場景下的優化空間還會比傳統方式更大。你們只管放心使用便可,後續會對函數式編程優化進行專題講演的。

柯里化做爲一種最簡單的組合子之一,他是一種高階函數(顯然這裏咱們沒有用到柯里化組合子),沒有改變原有函數的意圖,但卻延遲了函數的執行。

偏函數應用

若是在編寫代碼時,咱們總能預知datacallback的肯定性和及時性,url須要最後代入,那麼能夠考慮使用偏函數的魔法,仍是剛纔的例子,咱們能夠這樣的改造:

var partialGet = function (fn, data, callback) {
  return function (url) {
    return fn(url, data, callback);
  }
};
var partialGetByUrl = partialGet($.get, data, callback);
partialGetByUrl(url1);
partialGetByUrl(url2);
// ...

看,如今咱們已經實現了函數的重用了,而且咱們並無改造原有的函數,僅僅對原函數進行了改造。它的做用和柯里化很是的類似,但沒有柯里化那麼貪婪。偏函數應用僅僅是提取原函數中的部分參數,用剩餘參數返回一個新函數。不難發現,偏函數柯里化都是延遲了原函數的參數,只是延遲的進度不一樣而已。

函數組合

仍然接着上面的例子,若是url是從DOM中獲取的原始輸入,彷佛咱們爲了合理性和安全性,應該對url進行一個預處理過程,大體能夠假設有這樣的代碼:

var preformat = function (url) {};  // 預處理
partialGetByUrl(preformat(url));

這樣寫彷佛一點問題都沒有,咱們再來增長點難度:

var trim = function (txt) {};  // 去除兩邊空格
var encode = function (txt) {};  // 編碼加密
var preformat = function (url) {};  // 預處理
partialGetByUrl(encode(preformat(trim(url))));

天啊,我相信你已經也看暈了,更愚蠢的是,若是處理字符串的順序變化了,改動也是很頭疼的。面對這樣的問題,偉大的組合函數出現了:

var compose = function (f4, f3, f2, f1) {
  return function (txt) {
    return f4(f3(f2(f1(txt))));
  }
};
var getByUrl = compose(
    partialGetByUrl,
  encode,
  preformat,
  trim
);
getByUrl(url1);
getByUrl(url2);

書寫上好看了很多,而且你能夠爲所欲爲的去組合這些函數的,固然這是有一些前提的(純函數數據不變性),但我不打算在這裏講解這些前提。

這時有不少人很困惑,貌似組合函數對於組合子的特性前兩點都是知足的,惟獨第三點看似不像。注意了,加強的函數行爲不只僅有延遲,組合串聯也是一種加強手段,前一個函數會由於後一個函數而加強。甚至你能夠換一種理解方式,若是沒有後一個函數的提早處理就會致使前一個函數執行失敗,這也是一種加強手段。

輔助組合子

說了那麼多,前面說到的都是針對特定問題的高階函數解決方案,拋開先前說的三個特性,如今回來咱們以前組合子的話題,首先講講最爲簡單的輔助組合子,它們自己不處理函數,只是處理數據,所以能夠稱之爲輔助組合子(或者說是投影函數(Projecting Function))。但其實輔助組合子自己並非不處理函數,而是函數也能夠做爲特殊的數據,他們雖然小、寫法簡單,可是意義仍然和組合子同樣重大:

// ES5
function nothing () {}
function identity (val) {
  return val;
}
function defaultTo (def) {
  return function (val) {
    return val || def;
  }
}
function always (constant) {
  return function () {
    return constant;
  }
}

// ES6+
const nothing = () => {};
const identity = val => val;
const defaultTo = def => val => val || def;
const always = cons => val => cons;

無爲(nothing)

圖片描述

nothing函數表面上看好像沒有什麼意義,但它的名稱就和它自己同樣,不做任何事情,是空函數的默認值,在ES6+的場景中比較常見,好比稍後將見到的alt組合子,就能夠進行默認值傳參:

const alt = (f1 = nothing, f2 = nothing) => val => f1(val) || f2(val);

照舊(identity)

圖片描述

identity函數是範疇論中很是著名的id函子,一樣有關函子的概念不是本文的重點,有興趣的朋友能夠自行找資料學習。id函子有着一個很是重要的特性,也就是輸入什麼值都將不經處理的返回,即便輸入的是函數也是可行的。咱們能夠利用這個特性在遞歸中使用,用構建後繼傳遞遞歸(CPS)版的斐波那契數:

// n >= 1
const fib = (n, cont = identity) => 
    n <= 1 ? 
  cont(n) : 
    fib(n - 2, pre => fib(n - 1, mid => cont(pre + mid)));
注意:這段代碼要想解讀清楚比較困難,咱們只須要知道這個函數的結果是對的便可。

默許(defaultTo)

圖片描述

defaultTo函數的出場率是最高的,好比咱們處理一些非預期值的時候:

const defaultLikeArray = defaultTo([]);
const array1 = defaultLikeArray('');
const array2 = defaultLikeArray([1, 2, 3]);

array1由於不是預期值,所會返回一個空的數組,防止該參數代入後續函數中後出現問題。defaultTo是一種OR組合子,後續還有更多相似的組合子出現。

圖片描述

恆定(always)

圖片描述

always函數不少人看不大懂,認爲畫蛇添足,直接用const關鍵字構建一個常量不就能夠了嗎。這就是函數式編程的特色,一切以函數爲中心。該函數經過函數式的方式,構建了某些數據的統一源:

const alwaysUser = always({});
const user1 = alwaysUser();
const user2 = alwaysUser();
user1 === user2; // => true
user1.name = '張三';
user1 === user2; // => true

function alwaysLikeUser () {
  return {};
}
const user3 = alwaysLikeUser();
const user4 = alwaysLikeUser();
user3 == user4; // false

像這樣,你就能保證不一樣位置的數據修改是針對的統一源頭,某些場景下仍是很是實用的。

函數組合子

柯里化偏函數應用函數組合的例子中咱們能夠看到函數組合子的身影,但函數組合子自己更具有抽象性,他是這些特定問題的抽象,而且能夠複用在絕大部分(只要符合條件)的函數身上。下面,咱們就來說解一下常見的函數組合子

收縮(gather)

/**
 * 函數簽名: ((a, b, c, …, n) → x) → [a, b, c, …, n] → x
 * 函數做用: 參數收縮
 * 函數特性: 降維
 * @param fn - 原函數
 * @returns 參數收縮後的新函數
 */

// ES5
function gather (fn) {
  return function (argsArr) {
    return fn.apply(null, argsArr)
  }
}

// ES6+
const gather = fn => argsArr => fn(...argsArr);

// eg: 將`可變元`函數轉換爲`一元`函數
var log = gather(console.log);
log(['標題', 'log', '日誌類容']); // => 標題 log 日誌類容
var max = gather(Math.max);
max([1,2,3]); // => 3

圖片描述

絕大部分人看到此組合子後,會很敏感的認爲,這不就是Function.prototype.apply操做麼?是的,gather函數就是封裝的Function.prototype.apply,可是它並不關心數據自己,換句說法,它並不關心放進來的是什麼函數(函數參數的抽象)。咱們常常會在別的函數體中調用某個函數的callapply方法,但不多有人嘗試將該方法進行封裝。gather函數只關注函數的行爲,而不關注函數自己是什麼,你傳入任意的函數均可以。gather函數的目的是將可變元參數的函數轉換爲一元的,這是一種降維操做,能夠適配用戶的輸入。被建立的新函數會經過gather的逆運算,將[a, b, c, ...]結構的數據轉換爲(a, b, c, ...)結構的數據再代入原函數

展開(spread)

/**
 * 函數簽名: ([a, b, c, …, n] → x) → (a, b, c, …, n) → x
 * 函數做用: 參數展開
 * 函數特性: 升維
 * @param fn - 原函數
 * @returns 參數展開後的新函數
 */

// ES5
function spread (fn) {
  return function () {
    var argsArr = [].slice.call(arguments);
    return fn(argsArr)
  }
}

// ES6+
const spread = fn => (...argsArr) => fn(argsArr);

// eg: 將`一元`函數轉換爲`可變元`函數
var promiseAll = spread(Promise.all);
promiseAll(promiseA, promiseB, promiseC);

圖片描述

spread函數與gather函數是對稱的,一般他們會相互配合的使用,在後續的juxt案例中就能夠看到。它的目的可使得原函數參數進行升維操做,由一元進化爲可變元,形如(a, b, c, ...)的參數通過spread的逆運算會轉變爲[a, b, c, …]形式再代入原函數

值得注意的是,spread(gather)identity是等效的(不是相等==),你能夠用幾個可變元和多元函數來實驗一下。

spread(gather(console.log))(1, 2, 3); // => 1, 2, 3
identity(console.log)(1, 2, 3); // => 1, 2, 3

顛倒(reverse)

/**
 * 函數簽名: ((a, b, c, …, n) → x) → (n, …, c, b, a) → x
 * 函數做用: 參數倒序
 * 函數特性: 換序
 * @param fn - 原函數
 * @returns 參數收縮後的新函數
 */

// ES5
function reverse (fn) {
  return function argsReversed () {
    var args = [].reverse.call(arguments);
    return fn.apply(null, args);
  }
}

// ES6+
const reverse = fn => (...argsArr) => fn(...argsArr.reverse());

// eg: 將`多元`函數的參數反轉爲`多元`函數
var pipe = reverse(compose);
pipe(
  trim,
  format,
  encode,
  request
)(url);

圖片描述

reverse組合子也是抽象了函數做爲數據,能將任意的函數的參數「反轉」,注意,它並非真的將函數參數反轉了,而是生成了一個等待反轉參數輸入的新函數。

一樣, reverse(reverse)idengtity也是等效的,你能夠自行嘗試

左偏(partial)

/**
 * 函數簽名: ((a, b, c, …, n) → x) → [a, b, c, …] → ((d, e, f, …, n) → x)
 * 函數做用: 前置參數提早
 * 函數特性: 降維
 * @param fn - 原函數
 * @returns 前置參數提早後的新函數
 */

// ES5
function partial (fn) {
  var presetArgs = [].slice.call(arguments, 1);
  return function () {
    var laterArgs = [].slice.call(arguments);
    return fn.apply(
      null, 
      presetArgs.concat(laterArgs)
    );
  }
}

// ES6+
const partial = (fn, ...presetArgs) => (...laterArgs) => fn(...presetArgs, ...laterArgs);

// eg: 將`多元`函數轉換成比先前更少元的`多元`函數
var getByHandler = partial($.get, url, data);
getByHandler(console.log);
getByHandler(convert);
getByHandler(render);

圖片描述

partial函數就是一種偏函數應用(Partial Application),它能夠提取函數中的一個或多個參數,但不是所有參數,構造出一個新函數。一樣它能夠下降原函數(契函數)的維度,使得函數的調用被延遲。之因此稱之爲「「函數應用,是對應於徹底函數應用的稱呼。那麼什麼是」徹底函數應用「呢?先看以下代碼:

// 假設咱們抽離出一個map函數
cost map = (arr, transfomer) => [].map.call(arr, transfomer);
// 那麼正常的調用過程就是`徹底函數應用`
map([1, 2, 3], x => x + 1);
// 提取部分參數構成新函數的過程就是`偏函數應用`
const mapWith = partial(map, [1, 2, 3]);
mapWith(x => x + 1);
// 固然你能夠手動提取後面的參數
const withHandler = (fn, handler) => arr => fn(arr, handler);
const mapWithAddOne = withHandler(map, x => x + 1);
mapWithAddOne([1, 2, 3]); // => [2, 3, 4]
mapWithAddOne([-1, 0, 1]); // => [0, 1, 2]

偏函數應用能夠延遲函數的執行,把真正關注的數據放在最後,從而實現函數的可複用性。本小節主要介紹的是左偏,與之對應的還有右偏

右偏(partialRight)

/**
 * 函數簽名: ((a, b, c, …, n) → x) → [d, e, …, n] → ((a, b, c, …) → x)
 * 函數做用: 前置參數提早
 * 函數特性: 降維
 * @param fn - 原函數
 * @returns 前置參數提早後的新函數
 */

// ES5
function partialRight (fn) {
  var laterArgs = [].slice.call(arguments, 1);
  return function () {
    var presetArgs = [].slice.call(arguments);
    return fn.apply(
      null, 
      presetArgs.concat(laterArgs)
    );
  }
}

// ES6+
const partialRight = (fn, laterArgs) => (...presetArgs) => fn(...presetArgs, ...laterArgs);

// eg: 將`多元`函數轉換成比先前更少元的`多元`函數
var getByUrl = partialRight($.getJSON, [data, render]);
getByUrl(url1);
getByUrl(url2);
getByUrl(url3);

圖片描述

partialRight函數的思想是和partial如出一轍的,甚至咱們利用咱們已經學會的reversepartial函數轉換成partialRight函數,有興趣的同窗能夠自行嘗試一下,稍後咱們也會給出答案。

注意: 學到這裏的時候,咱們不難發現,從 gatherpartial都是針對參數的維度變化。雖然他們都是能夠代入函數爲參數,但對函數特性是有要求的,好比 升維的前提必須是數組。再一個,咱們已經能夠利用他們在不改變原有函數的前提下,組合出各類適合使用需求的函數。

柯里化(curry)

/**
 * 函數簽名: (* → a) → (* → a)
 * 函數做用: 逐個參數提早
 * 函數特性: 降維
 * @param {Function} fn 原函數
 * @param {Number} arity 原函數的參數個數, 默認值: 原函數的參數個數
 * @returns 柯里化後的下一個`一元`函數
 */

// ES5
function curry (fn, arity) {
  arity = arity || fn.length;
  return (function nextCurried (prevArgs) {
    return function curried (nextArg) {
      var args = prevArgs.concat(nextArg);
      return args.length >= arity ? fn.apply(null, args) : nextCurried(args);
    }
  })([]);
}

// ES6+
const curry = (fn, arity = fn.length) => {
  return (function nextCurried (prevArgs) {
    return function curried (nextArg) {
      let args = prevArgs.concat(nextArg);
      return args.length >= arity ? fn(...args) : nextCurried(args);
    };
  })([]);
}

// eg: 
var square = curry(reverse(Math.pow))(2);
square(2); // => 4
square(3); // => 9

圖片描述

不少人看到這裏,以爲curry拆分參數沒什麼用,反而讓原來一個函數變成了多個函數,增長了JS腳本引擎的解析難度(調用棧增長)。函數式編程強調的是一種編程的範式,它是一種代碼哲學,也是一種編程藝術,但切不可爲了函數式而函數式,這會讓本來的開發過程變得繁重。並且,函數式編程確實存在必定的性能上的優化空間(但這不是咱們本次講稿所要敘述的內容),但也不能由於這一點而徹底避諱使用它,咱們應該根據環境要求去選擇更加適當的組合方式。

再次回到curry函數,好比有以下案例:

// 未柯里化,這是不少人都會舉的例子
const add = (a, b) => a + b;
add(1, 2); // => 3
// 柯里化後
const addCurry = curry(add);
const addOne = addCurry(1);
[1, 2, 3].map(addOne);

curry在原有函數add的基礎上,進行了延拓,咱們無需去從新封裝一個新的函數來描述addOne的特性,由於addOne的特性自己就是add部分具象化。這即是在鼓勵咱們,去編寫更抽象的函數,而後使用函數式編程使其具象化而且可複用。

curry函數自己的特性,與partial也是極爲類似的,只不過它拆分的更加細膩,是逐個拆分。然而,問題也暴露出來了,若是函數是不定元的,那麼curry又該如何保證正常使用呢?一塊兒看看curry函數的定義,發現第二個參數默認是取原函數的參數長度。咱們知道,JavaScript中的Function能夠經過length屬性來獲取參數的長度,例如:

console.log.length; // => 1
Math.sin.length; // => 1
Math.random.length; // => 0
JSON.parse.length; // => 2

console.log函數的參數長度雖然和Math.sin函數的長度同樣,可是log函數能夠再添加可變元的參數的,若是對log使用curry魔法,將致使施了魔法的函數和原函數徹底一致。所以,爲了解決curry函數自己的缺陷,咱們能夠手動創建一個新函數,用於指定函數參數的個數:

// 方式一,不使用組合子
const curry2Log = curry(console.log, 2);
const curry3Log = curry(console.log, 3);
curry2Log('title')('message'); // => title message
curry3Log('title')('message')('2018'); // => title message 2018
// 方式二,使用組合子(偏函數)
const curryN = (n, f) => partialRight(curry, [n]);
// 或者(反轉函數)
const curryN = reverse(curry);
const curry4Log = curryN(4, console.log);
const curry5Log = curryN(5, console.log);

看,萬變不離其宗,是否是很是的神奇,就像咱們介紹的同樣——像魔法,因此大家是否是對他們愈來愈感興趣了呢?

到此,咱們已經講解了有關參數維護變化的組合子,大家發現了沒, gatherspreadreversepartialcurry都是基於參數維度的變化。咱們已經在文中提過不少次 參數維度,一元函數是一維的,二元函數是二維的……多元函數是多維的,可變元函數是不定維的。這些組合子的特色就是改變維度,讓 原函數能夠變化成其它可複用的方式。固然,除了這些組合子能夠改變維度,還有像 unarybinarynAry等組合子能夠強行改變維度,好比 unary是將任意函數變爲一元的,若是自己就是二元以上的函數,是會有損失的。即使你很難摸清楚維度切換的法門,也不用擔憂,函數式編程的精妙在於,當你須要的時候,你就知道怎麼去選擇了,咱們所要作的是掌握和了解更多的組合子。

棄離(tap)

/**
 * 函數簽名: (a → *) → a → a
 * 函數做用: 對輸入值執行給定函數並當即返回輸入值
 * 函數特性: id
 * @param {Function} fn - 原函數
 * @returns 輸入值
 */

// ES5
function tap (fn) {
  return function (val) {
    return (fn(val), val);
  }
}

// ES6+
const tap = fn => val => (fn(val), val);

// eg:
var sayX = x => console.log('x is ' + x);
var tapSayX = R.tap(sayX);
tapSayX(100); // 100

tap函數相似一個ididentity,以後咱們都簡稱id)的組合子,函數自己作什麼並不關心,咱們都沒有接受它的返回值。那麼它的用處是幹嗎呢?函數式編程中有一個很是重要的概念叫純函數,這個詞並不陌生,但很難甄別,先來看看下面哪些函數是「純」的:

const addOne = x => x + 1;
const log = console.log;
const clickHandler = function (e) {
  e.preventDefault();
  $(this).html($(e.target).html());
};
var count = 1;
function getCount () {
  return count;
}
function append (arr, item) {
  arr.push(item);
  return arr;
}

還有不少的例子就很少舉例了,上面的函數,除了addOne,其它的都不算純,咱們來看純函數應該具有的特性:

  1. 獨立性:沒有反作用,不會影響外部,也不受外部影響;
  2. 常恆性:官方說法叫引用透明性(Referential Transparency),即在任意時間裏,傳入相同且肯定的參數,返回相同且肯定的值;

簡單的說,真正的純函數是「永恆」和「不變」的。再回頭看上面的案例,clickHandler使用了this,該函數會由於上下文的變化而做用不一樣(獨立性和常恆性被打破)。getCount引用了外部變量,這也是很是危險的,由於你沒法保證變量a永遠沒法被其餘人改變(常恆性被打破)。append傳入的參數arr是一個引用類型,所以函數體內部對外部產生的反作用(獨立性被打破)。有的人認爲console.log是一個純函數,是的,若是不考慮那麼嚴格的話,它確實是一個比較純的函數,但它和DOM操做內存操做寫庫操做(都屬於I/O操做)同樣,對外部產生了變化,所以它也不是一個純函數(獨立性)。但log操做也很特殊,由於DOM操做內存操做寫庫操做和它的區別就在於他們三個都存在併發,所以結果是不肯定的;是存在異常的,異常會中斷函數自己的運行;是不可預測的,雖然咱們知道大部分都能正確返回,可不得不認可對結果預測的不穩定性。再看看log,雖然對外部(控制檯)有I/O操做,但它既不存在併發,也不會有異常發生,結果是可預測的。所以,log能夠認爲是一個非嚴格意義上的純函數,畢竟查看數據時它是很是有幫助的。

雖然 addOne函數咱們認識是一個 的,但若是傳入的不是一個 基礎類型,而是一個引用類型呢?那就不是的,由於內部的改變是影響了外部。這樣的需求是時常發生的,那麼又改如何編寫函數式所須要的純函數呢?這就須要咱們使用一些函數式的類庫了,例如 RamdaImmutableRamda讓全部傳入的參數對象都會通過 clonedeepClone操做,使之引用關係被斷開,從而產生新的對象返回; Immutable能夠構造出具有 持久性不變性的數據結構,從而剝離反作用。本講稿也是推薦各位使用出名的函數式編程庫,而不要本身再重複造輪子的去構造這些 組合子,咱們只是借用這些例子來說解他們的特性和實際用途。

說了那麼多純函數,這和tap函數究竟有什麼關係,和以後的組合子又有什麼關係呢?tap函數就是用來隔離那些不純的操做(實際使用時應加入clonedeepClone),保留原始數據流通到下一個關口,它能夠用於輔助函數式編程進行數據調試。例如:

const getByHandler = partial($.ajax, [url, data]);
getByHandler(pipe(    // 假設獲取到的數據是一個對象數組
    sortByField,  // 排序
  map(changeKey('_guid', 'id')),  // 將數據庫的字段`_guid`切換爲`id`
  tap(
      console.log // 將上一步的操做結果打印,並使數據經過
    // 甚至能夠發起一個入庫操做,讓中間數據持久化
  ),
  renderData    //渲染數據到DOM中
));

通過map的數據到了tap以後,並無發生變化,就轉而到了renderData去進行渲染了,log函數只是簡單的將上層數據進行了打印。但若是tap內的函數是一個不純的怎麼辦?咱們的tap函數還缺乏一個重要的輔助函數deepClone,也就是數據進去時只傳遞副本,這樣就能有效避免災難的發生。包擴咱們先後寫的代碼,都沒有一些著名的庫寫的完備、高性能且安全,咱們只是經過這些代碼示意來展現函數式編程的魅力。你們只要知道,通過tap函數的數據會直接返回,而tap包裹的函數使用完成後會直接棄用。

交替(alt)

/**
 * 函數簽名: (a → x) -> (b → y) → v → x || y
 * 函數做用: 對輸入值執行給定兩個函數並返回不爲空的結果
 * 函數特性: or
 * @param {Function} f1 - 原處理函數
 * @param {Function} f2 - 二次處理函數
 * @returns 不爲空的結果值
 */

// ES5
function nothing () {}
function alt (f1, f2) {
  var f1 = f1 || nothing;
  var f2 = f2 || nothing;
  return function (val) {
    return f1(val) || f2(val);
  }
}

// ES6+
const nothing = () => {};
const alt = (f1 = nothing, f2 = nothing) => val => f1(val) || f2(val);

// eg:
var getFromDB = () => {};
var getFromCache = () => {};
var getData = alt(getFromDB, getFromCache);
getData(query);

圖片描述

alt函數是最簡單的一種OR組合子,它描述的就是程序語言中的if-else,只不過它並非用特定的表達式去判斷,而是用||符號。OR組合子的變種有不少,像Ramda.js中的ifElseunlesswhencond都有相似邏輯功能,但更爲豐富一些。

補救(tryCatch)

/**
 * 函數簽名: (a → x) → (b → y) → v → x || y
 * 函數做用: 對輸入值執行`tryer`函數,若無異常則直接返回處理結果,反之返回`catcher`處理後的結果
 * 函數特性: or
 * @param {Function} tryer - 處理函數
 * @param {Function} catcher - 補救函數
 * @returns 不爲異常的結果值
 */

//  ES5
function tryCatch (tryer, catcher) {
  return function (val) {
    try {
      return tryer(val);
    }catch (e) {
      return catcher(val);
    }
  }
}

// ES6+
const tryCatch = (tryer, catcher) => val => {
  try {
    return tryer(val);
  }catch (e) {
    return catcher(val);
  }
};

// eg:
var toJson = tryCatch(JSON.parse, defaultTo({}));
var toArray = tryCatch(JSON.parse, defaultTo([]));
toJson('{"a": 1}'); // => {a: 1}
toArray(''); // []

tryCatch函數也是一種OR組合子,它和全部的類OR組合子同樣,目的就是實現非此即彼

同時(seq)

/**
 * 函數簽名: (a → x, b → y, …,) → val → undefined
 * 函數做用: 對輸入值執行給定的全部函數
 * 函數特性: fork
 */

// ES5
function seq () {
  var fns = [].slice.call(arguments);
  return function (val) {
    for (var i = 0; i < fns.length; i++) {
      fns(val);
    }
  }
}

// ES6+
const seq = (...fns) => val => fns.forEach(fn => fn(val));

// eg: ajax請求成功後,作三件事
// 1. render 將請求到的數據渲染到頁面上;
// 2. cache 將數據緩存到前端數據庫中;
// 3. log 寫一段日誌,打印請求到的數據,方便控制檯觀測;
var ajaxSuccessHandler = seq(render, cache, log);

圖片描述

seq組合子是一種分流操做,它的實際用途正如案例中所描述的,能夠同時作一些事情。雖然代碼是同步的版本,但也很容易用學到的知識去建立異步版本的。seq並不關注這些分流函數的結果,因此能夠同步去作一些操做,尤爲是I/O操做。注意,它的特性是fork,後面的組合子中,將會基於fork進行擴展。

圖片描述

彙集(converge)

/**
 * 函數簽名: ((x1, x2, …) → z) → [((a, b, …) → x1), ((a, b, …) → x2), …] → (a → b → … → z)
 * 函數做用: 將輸入值fork到各個forker函數中運行,並將結果集彙集到join函數中運行,返回最終結果
 * 函數特性: fork-join
 * @param {Function} join 彙集函數
 * @param {...Function} forkers 分撿函數列表
 * @returns join函數的返回值
 */

// ES5
function converge (join, forkers) {
  return function (val) {
    var args = [];
    for (var i = 0; i < forkers.length; i++) {
      args[i] = forkers[i](val);
    }
    join.apply(null, args);
  }
}

// ES6+
const converge = (join, forkers) => val => join(...forkers.map(forker => forker(val)));

// eg: 數組求平均數
var len = arr => arr.length;
var sum = arr => arr.reduce((init, item) => init + item, 0);
var div = (sum, len) => sum / len; 
var avg = converge(div, [sum, len]);
avg([1,2,3,4,5]); // => 3

圖片描述

圖片描述

converge函數實際上是seq的祖先,你看他們的特性都是fork,但爲何說convergeseq的先祖呢?學了那麼多組合子的知識,咱們來嘗試用converge來重寫seq吧:

// 不用組合子的寫法
const seq = (...fns) => converge(nothing, fns);
// 使用組合子的寫法
const seq = spread(curry(converge)(nothing));
const seq = spread(partial(converge, [nothing]));

重寫的思路也很簡單,首先使用徹底函數應用,而後找參數的特色,不使用組合子(即徹底函數應用)的時候,咱們發現seq形參converge的第二實參是對應的,但coverge的第二實參是一維的,所以須要用spread升維。而後converge的第一實參前置出來便可,因此咱們可使用curry或者partial。看,組合子再一次發揮了極其重要的做用。

映射(map)

/**
 * 函數簽名: (a → b) → [a] → [b]
 * 函數做用: 將系列輸入值映射到`transfomer`函數中運行,並將結果整理成新的系列
 * 函數特性: map
 * @param {Function} transfomer - 轉換器
 * @returns 通過映射後的新系列
 */

// ES5
function map (transfomer) {
  return function (arr) {
    var result = [];
    for (var i = 0; i < arr.length; i++) {
      result.push(transfomer(arr[i]));
    }
    return result;
  }
}

// ES6+
const map = transfomer => arr => arr.map(transfomer);

圖片描述

map竟然是組合子,不少人一臉茫然。是的,你沒聽錯,在ES5上增長的這些數組函數,都是組合子,只不過它是數組實例的方法。但推的更廣一點,但凡具有Iterator特性的對象,均可以具有map方法。而在範疇論中,Functor也是能夠具有map方法的,這不在咱們本章的討論範圍呢,咱們假定擁有map特性的都是集合。以上代碼中,咱們用本身的方式抽離出了map函數,它的特色是,將集合中的每一項提取並映射到目標函數transfomer中,並將結果從新整理成集合。map操做和fork操做都很是的類似,前者是數組分發,後者是單值分發。因而可知,mapfork特性(不是函數)是能夠相互轉換的,感興趣的同窗能夠繼續往下看。

圖片描述

前面咱們提到過unary組合子,可是沒有給出實現方式以及實際用途,如今咱們能夠結合map組合子來使用。首先是unary的代碼形式:

/**
 * 函數簽名: (* → b) → (a → b)
 * 函數做用: 將二元以上的函數轉換成一元的(不推薦轉零元)
 * 函數特性: 降維
 * @param {Function} fn - 原函數
 * @returns 降爲一元的新函數
 */

// ES5
function unary (fn) {
  return function (value) {
    return fn(value);
  }
}

// ES6+
const unary = fn => value => fn(value);

而後咱們來看以下的案例:

// 假設數據從某個文件中獲取,轉換出來以後是一個字符串數組
const datasFromFile = ['1', '2', '3', '4'];
// 對字符串數組進行轉換,轉變爲數字數組
datasFromFile.map(parseInt); // => [1, NaN, NaN, NaN]

爲何會出現[1, NaN, NaN, NaN]這樣的結果?若是你對parseInt函數了解,應該知道它有兩個參數string(被解析的字符串)和radix(解析基數)。第二個參數告訴程序string會以什麼樣的進制數進行解析,這個函數咱們很少贅述了,只要知道默認值是0就能按照十進制進行解析。出現這個問題的緣由是由於Array.prototype.map組合子中的transfomer默認帶有三個參數:item(項)、index(項索引)、array(數組實例)。所以調用時,索引被添加上去致使後面的字符串沒法按照該索引所確立的進制數進行轉換,但使用了unary就能夠:

datasFromFile.map(unary(parentInt)); // => [1, 2, 3, 4]

分撿(useWith)

/**
 * 函數簽名: ((x1, x2, …) → z) → [(a → x1), (b → x2), …] → (a → b → … → z)
 * 函數做用: 將系列輸入值映射到各個transfomer函數中運行,並將結果集彙集到join函數中運行,返回最終結果
 * 函數特性: map-join
 * @param {Function} join - 彙集函數
 * @param {Function[]} transfomers - 轉換器
 * @returns join函數的返回值
 */

// ES5
function useWith (join, transfomers) {
  return function (vals) {
    var args = [];
    for (var i = 0; i < transfomers.length; i++) {
      args[i] = transfomers[i](vals[i]);
    }
    join.apply(null, args);
  }
}

// ES6+
const useWith = (join, transfomers) => vals => join(...transfomers.map((transfomer, i) => transfomer(vals[i])));

// eg:
var square = val => Math.pow(val, 2);
var sumSqrt = (a, b) => Math.sqrt(a + b);
var pythagoreanTriple = useWith(sumSqrt, [square, square]);
pythagoreanTriple([3, 4]); // => 5
pythagoreanTriple([5, 12]); // => 13
pythagoreanTriple([7, 24]); // => 25

圖片描述

useWith函數和converge函數很是的類似,咱們從它們的結構圖上能夠發現,一個是先map,一個是先fork。這兩個函數在實際使用中很常見的。

規約(reduce)

/**
 * 函數簽名: ((a, b) → a) → a → [b] → a
 * 函數做用: 將初始值代入`reducer`的第一參數,輸入系列映射爲`reducer`的第二參數,並將`reducer`的返回值迭代到下次`reducer`的第一參數中,將最終返回值構成新的系列
 * 函數特性: reduce
 * @param {Function} reducer 規約函數
 * @param {*} init 初始數
 */

// ES5
function reduce (reducer, init) {
  return function (arr) {
    var result = init;
    for (var i = 0; i < arr.length; i++) {
      result = reducer(result, arr[i]);
    }
    return result;
  }
}

// ES6+
const reduce = (reducer, init) => arr => arr.reduce(reducer, init);

圖片描述

reduce是集合中一個比較特殊的函數,功能特性爲「摺疊」,可以將一個列表摺疊成一個單一輸出。用來作統計是很是不錯的。它常和其它函數聯繫使用,不只能實現功能,還能讓代碼的語意化變得有藝術感,這裏很少作贅述。和reduce很像的組合子還有sortfilterflat等,它們的特別之處就是要等待謂詞函數(一種契函數)的嵌入,才能發揮真正的做用。這些函數的特性既不是改變維度,也不是控制邏輯流程(參數分發和結果選擇),它們是真正具有數據處理功能的函數。

組合(compose)

/**
 * 函數簽名: ((y → z), (x → y), …, (o → p), ((a, b, …, n) → o)) → ((a, b, …, n) → z)
 * 函數做用: 將輸入值代入最末函數,並將結果代入上一個函數,直到全部函數所有調用完成,返回最終結果
 * 函數特性: chain
 * @param {...Function} fns - 函數列表
 * @returns 從下到上依次執行的結果
 */

// ES5
function compose() {
  var fns = [].slice.call(arguments);
  var len = fns.length;
  return function (val) {
    var result = val;
    for (var i = len - 1; i >= 0; i--) {
      result = fns[i](result);
    }
    return result;
  }
}

// ES6+
const compose = (...fns) => val => fns.reverse().reduce((result, fn) => fn(result), val);

如今,咱們抽離出更加通用的組合函數compose,能夠將任意個函數組合在一塊兒。但注意,除了最後一個函數能夠是可變元的,其它的函數都應該是一元的,它的執行順序是從後到前,若是不適應這樣的方式,也能夠reverse一下參數,構成命令行中常見的管道方式pipe函數:

const pipe = reverse(compose);

謂語組合子

謂詞,用來 描述斷定客體性質、特徵或客體之間關係的詞項。

謂詞函數,用於表達是什麼(is)作什麼(do)怎麼樣(how)等的函數。

謂語組合子是一種最多見的函數組合子,它須要組合謂詞函數(predicate)(或叫斷言函數)來實現其功能,這個咱們前面已經接觸過一次。常見的關鍵字有ofbyiswhendo等等,在實際開發中咱們已經見過不少謂語組合子,只是你們都不知道它們的稱呼。下面,咱們來從新回顧一下。

過濾(filter)

例如,有限數字列表或哈希中,過濾出偶數。此時是偶數isEven就是謂詞函數

// 構建`是什麼`的`謂詞函數`
const isEven = n => n % 2 === 0;
// 將`isEven`嵌入到`filter`組合子中
const filter = fn => list => list.filter(fn);
const getEvens = filter(isEven);
getEvens([1, 2, 3, 4]); // => [2, 4]

謂詞函數返回boolean類型,嵌入filter後發生效用。與filter具有一樣特性的組合子有不少,例如:findeverysome等。

分組(group)

例如,將一個對象數組按照對象的name字段進行分組。此時name字段byName就是謂詞函數

// 構建`怎麼樣`的`謂詞函數`
const byName = obj => obj.name;
// 將`byName`嵌入到`group`組合子中
const group = fn => list => list.reduce((groups, item) => {
  const name = JSON.stringify(fn(item));
  groups[name] = groups[name] || [];
  groups[name].push(item);
  return groups;
}, {});
const groupByName = group(byName);
groupByName([
  {name: 'A', tag: 'a'},
  {name: 'B', tag: 'b'},
  {name: 'A', tag: 'α'},
  {name: 'B', tag: 'β'}
]);

謂詞函數返回某個屬性,嵌入group後發生效用。與group具有相同特性的組合子有不少,例如:flatpair等。

排序(sort)

sort組合子須要嵌入一個comparator函數,是一個比較函數,用於描述兩個參數之間作比較(即作什麼)的過程。咱們先來看看最簡單的例子:

const diff = (a, b) => a - b;
const sort = fn => list => list.sort(fn);
const asc = sort(diff);
asc([4, 2, 7, 5]); // => [2, 4, 5, 7]

謂詞函數返回一個單值,嵌入sort後發生效用。與sort具有相同特性的組合子有不少,例如mapreduce等。

其它

這裏,咱們再次講到了reduce,與它類似的,map也是謂語組合子,它們主要負責組合作什麼這類的謂詞函數。因而可知,在函數組合子這一節中提到的大部分組合子都是具有謂語特性的,主要目的是達成謂語的"作什麼":

const add = (a, b) => a + b;
const sum = list => list.reduce(add, 0);
sum([1, 2, 3, 4]); // 10

const pow = (x, n) => Math.pow(x, n); // x的n次方
const squ = list => list.map(pow);
squ([2, 2, 2, 2]); // [1, 2, 4, 8]

謂語組合子還有不少不少,其目的就是將某種功能的可開放性交給謂詞函數進行擴展。通常在函數庫中,見到相似以下字眼並後跟函數參數的,頗有可能就是謂語組合子tobywhilewhenwithofall/everyany/somenone…… 像iseqgt用於判斷的謂詞函數,有的是用於謂語組合子的,有的是由函數組合子構造出來的。

一些常見函數庫中的謂語組合子

// lodash
_.countBy
_.dropRightWhile
_.differenceWith
// ramda
R.indexBy
R.takeWhile
R.mergeWith

組合子變換

回顧一下咱們已經掌握的組合子,它們具有的特性以下:

  1. 變換維度(升維、降維、換序、偏應用、柯里化);
  2. 數據流程(id、or、fork、map、join);
  3. 數據處理(reduce、sort、filter...)

咱們不只認識了這些組合子,而且知道他們是經過什麼方法得到的,也嘗試了用已經學過的組合子來構建起他等效的組合子。如今咱們手動構建其它要想或者可能會用到的組合子:

juxt

juxt將函數列表做用於值列表,在沒有封裝以前,咱們看它是怎麼使用的:

// 獲取一系列數的範圍
const getRange = juxt([Math.min, Math.max]);
getRange(3, 4, 9, -3); // => [-3, 9]

這個方法使用上和以前的組合子很有一些類似,到底是哪些地方類似,只要找出來,謎題天然解開。這個函數的特性是:

  1. 參數爲一系列函數,即函數數組;
  2. 最終輸入爲一系列輸入;
  3. 全部的輸入都是同時參與這一系列函數的處理;

很顯然,第三點就是咱們以前學過的fork-join特性。並且參數和最終輸入都很是的類似,有區別的是converge的最終輸入是一元的,且它有一個join函數。因而咱們能夠知道,要想改造converge成爲juxt,須要:

  1. 降維,將參數進行壓縮後再展開;
  2. join函數用id消除便可;
var juxt = fns => spread(converge(spread(identity), fns.map(gather)));

如今,你經過juxt構建的函數來構建getRange而且代入數據,結果徹底一致,其數據代入的過程是:

  1. (3, 4, 9, -3)通過外部spread的逆運算變爲[3, 4, 9, -3]
  2. 每一個函數都被map內的gather函數處理過,所以[3, 4, 9, -3]都會通過內部的gather的逆運算變爲(3, 4, 9, -3)
  3. Math.minMath.max可執行參數爲(3, 4, 9, -3)的運算,獲得結果(-3, 9)
  4. (-3, 9)通過內部spread的逆運算,變爲[-3, 9]
  5. [-3, 9]代入到identity函數,返回原值[-3, 9]

如今是否是以爲特別的神奇,有興趣的朋友能夠嘗試進行其它組合。

實戰案例

數據判斷

咱們有這樣的對象信息從sessionStorage中獲取(該代碼摘至芒果項目):

{
  authorites: ['xxxx', 'xxxx', 'ROLE_ADMIN', 'xxxx', 'xxxx']
}

這個對象描述了一個用戶信息的緩存,其中authorites反應了用戶所具有的權限,若是權限字段中帶有ROLE_ADMIN,則咱們認爲他就是一個系統管理員。若是不使用函數式編程,會是這樣的:

// 不使用函數式(ES6+)
function isAdmin (userInfo) {
  var authorites = [];
  if (userInfo.hasOwnProperty('authorites')) {
    authorites = userInfo.authorites || [];
  }
  return authorites.some(item => item === 'ROLE_ADMIN');
}

而後咱們看看函數式編程會怎麼的改寫,這裏咱們將用到比較出名的函數式函數庫Ramda

// 使用Ramda(ES6+)
const isAdmin = R.pipe(            // 1. 從上到下串聯(組合)函數
    R.prop('authorites'),            // 2. 獲取`數據`的`authorites`信息
  R.defaultTo([]),                    // 3. 數據處理,若是爲`undefined`、`null`或`NaN`則返回`[]`
  R.contains('ROLE_ADMIN')    // 4. 判斷是否包含`ROLE_ADMIN`信息
);

這段代碼明顯就比沒有使用函數式的代碼要剪短很多,這不足爲奇,你甚至還能發現userInfo這個參數沒有了。這段代碼應該從上往下讀,由於咱們使用了能組合函數的pipe函數:

  1. R.pipe將各個函數依次從上往下執行的串聯(組合)起來;
  2. R.prop用於獲取上一個輸入的對象的authorites屬性;
  3. R.defaultTo用於設置一個默認值;
  4. R.contains用於判斷數組中是否含有ROLE_ADMIN這一項;

R.pipe將各個函數串聯(組合)起來,並返回一個新的函數,這個新函數的輸入就是userInfo,它不是不存在,而是被Pointfree化,中文翻譯爲「無參風格」。這個代碼若是完整的寫則表示爲:

const isAdmin = userInfo => R.pipe(...)(userInfo);

函數式中有一個重要特性就是:若是f = x => g(x),那麼f === g。這就是Pointfree風格。它不是徹底無參,只是弱化了數據自己的形式,而注重過程(方法)的實現。數據進去以後會獲取一個authorites信息a,然而處理該信息的默認值b,最後判斷是否包含預約信息c,並將結果c返回。因爲isAdmin = R.pipe(f1, f2, f3),經過f1/f2/f3就能計算出isAdmin,那麼整個過程就根本不須要知道a/b/c,甚至連最開始的數據均可以不須要知道。咱們把數據處理的過程,從新定義成了一種與參數無關的合成(pipecompose)運算,這種將數據進行更加抽象的方式使得函數變得可自由組合,從而提高複用性。但這也要求,咱們在編寫函數時,參數應該更加偏向抽象的數據形式,而儘量不要偏向業務。後面的例子,咱們也會用到Pointfree風格,並講到使用無參風格所須要的一些條件。

數據轉換

如今,咱們嘗試作以下兩種數據之間的轉換:

var list = [{id: 1, name: 'a'}, {id: 2, name: 'b'}, /* ... */];
var obj = {
  "1": {id: 1, name: 'a'},
  "2": {id: 2, name: 'b'},
  /* ... */
};

這是一個很是常見的列表轉哈希需求,目的是爲了給列表數據作緩存,如今咱們不用函數式來實現:

// 不用函數式(ES6+)
const list2object = function (list) {
  const result = {};
  list.forEach(item => {
    result[item.id] = item;
  });
  return result;
};

若是咱們查閱Ramda文檔,很容易將該函數進行改寫,但咱們先不這麼作,咱們來看看這樣的函數有什麼 問題?不難發現,取id這一操做應該是能夠配置的。咱們只須要加入謂詞函數便可:

// 使用Ramda(ES6+)
const list2objectBy = name => R.compose(
  R.indexBy,        // 2. 根據謂詞函數進行索引轉換(轉換器)
    R.prop(name)    // 1. 實現的謂詞函數,按照屬性名進行轉換(轉換規則)
);
const list2objectById = list2objectBy('id');
const list2objectByName = list2objectBy('name');

這樣,list2objectById能夠將id提取成list2objectByName能夠將name提取成list2objectBy成爲了創造函數的函數,咱們很是巧妙且靈活的運用了函數式的靈活性。

Mendix案例——MxObject對象數據提取

剛纔的案例,都是比較小比較弱的案例,如今來讓咱們看更大的案例。這是出如今Mendix前端組件開發時,發現的一個問題,首先咱們先描述一下環境,讓你們對這個有個基本認識:

  1. Mendix Client 經過特定api可獲取訂閱數據MxObject;
  2. MxObject是一個超級大對象,可經過一系列api獲取對象屬性;
  3. 第二條所說的屬性就是數據庫中的數據;

不過很惋惜的是,MxObject只能經過get方法獲取某一個屬性,不能直接得到整個對象的JSON值。若是咱們但願經過console.log來打印一個MxObject對象,那就很繁瑣了,要一個個屬性去轉,若是訂閱獲取到的數據是列表MxObjects,會更麻煩。好在該對象的JSON存根中有這樣的一段信息,形如:

{
  jsonData: {
    attributes: {
      customAttr: {value: '@value'}
    },
    guid: '@guid'
  }
}

一個完整的表達是這樣的:

{
  jsonData: {
    attributes: {
      name: {value: 'bill'}
      age: {value: 45},
        address: {value: 'usa'}
        /* ... */
    },
      guid: '9876543210'
  }
}

咱們發現這段數據存根很是的詭異,它有以下特徵:

  1. 全部的數據字段都存儲在jsonData中;
  2. id信息存在guid中,其它信息存在attributes中;
  3. id信息的值是直接存儲的,其它信息的值是存儲在value鍵值對中的;

如今,咱們須要 將它轉換成這樣的格式:

{ 
  id: '9876543210',
    name: 'bill',
  age: 45,
  address: 'usa',
  /* ... */
}

也能夠簡化成以下的形式:

{
  guid: '@guid',
  customAttr: '@value'
}

若是不使用函數式,相信各位都會很輕鬆的寫出來,但咱們講的是函數式,並且我使用的是Ramda庫,因此我是這樣去處理的:

// 因爲id和其它屬性存儲方式不同,所以咱們要分開處理

// 1. 先處理id,處理的思路就是`對象提取`
const getJSONFromMxObjectWithGuid = R.pipe(
  // 獲取MxObject.jsonData屬性
  R.prop('jsonData'),
  // 篩選出guid鍵值對
  R.pick(['guid'])
);
// 2. 再處理非id字段,處理的思路就是`遍歷鍵值對`,再進行時`屬性提取`
const getJsonFromMxObjectWidthoutGuid = R.pipe(
    // 獲取MxObject.jsonData.attributes屬性
  R.path(['jsonData', 'attributes']),
  // 遍歷鍵值對,提取value屬性構成新鍵值對
  // {customAttr: {value: 'value'}} => {customAttr: 'value'}
  R.map(R.prop('value'))
);
// 3. 合併數據
const getJsonFromMxObject = R.converge(
  R.merge, 
  [getJsonFromMxObjectWidthoutGuid, getJSONFromMxObjectWithGuid]
);

如今問題來了,咱們是指望將guid提取出來,變成id的,可是上面的代碼中,咱們沒有對提取的鍵值進行轉換,咱們須要修改源代碼嗎?在函數式的幫助下,咱們的答案是不須要,因爲Ramda並無更換對象鍵名的方法,因此咱們要本身手動建立一個:

/**
 * 重命名Object的鍵名
 * @curried 已柯里化
 * @param {String} oldKey -  舊鍵名
 * @param {String} newKey -  新鍵名
 */
const renameKey = R.curry((oldKey, newKey) => R.converge(
  // 3. 合併對象(合併1.*和2的操做)
  R.merge, [
    // 2. 刪除舊鍵
    R.omit([oldKey]), 
    R.compose(
      // 1.2 建立新鍵值對對象
      R.objOf(newKey), 
      // 1.1 獲取舊鍵值
      R.prop(oldKey)
    )
  ]
));

而後咱們新增一個方法,並修改最終的函數:

const getJSONFromMxObjectWithId = R.pipe(
  getJSONFromMxObjectWithGuid,
  renameKey('guid', 'id')
);
// 3. 合併數據
const getJsonFromMxObject = R.converge(
  R.merge, 
  [getJSONFromMxObjectWithId, getJSONFromMxObjectWithGuid]
);

總結

通過一系列的長文,稍顯「粗略」的介紹了一下函數式編程中組合子的構成、特色和使用方式。之因此說是「粗略」的介紹,是由於有關組合子的內容還有更多更深的,在數學和計算機領域真實存在,但被JavaScript所實現並應用的確實很少,例如:

  1. A組合子apply);
  2. B組合子(已經講到過的compose方法)
  3. K組合子constant
  4. Y組合子fix
  5. C組合子flip
  6. I組合子(已經講到過的identity方法)
  7. S組合子substitution
  8. T組合子thrush
  9. P組合子psi

這些內容再講深一點,就能夠講到函子(Functor)的概念了, 這超出了咱們須要掌握的範圍。有興趣的朋友能夠參閱fantasy-landFantasy-Land-Specification 中文翻譯),這裏有一套已經實現大部分組合子的類庫combinators-js。有機會的話,會給你們講解比較淺顯的函子概念。

認識一些經常使用的組合子後,咱們發現了組合子的妙用,也感覺到了函數式所帶來的代碼美化哲學。但這僅僅是函數式編程中很小的一塊,但也是最爲實用的。來回顧一下它的特性:

  1. 組合子是一種高階函數;
  2. 組合子不改變原函數契函數)的原有功能特性;
  3. 組合子能夠經過變換參數轉變流程控制輸出加強函數
  4. 組合子可經由其它組合子等效轉換成其它組合子

而組合子的使用條件也比較苛刻:

  1. 不管是組合子函數仍是契函數都必須是絕對的;
  2. 組合子必須保證數據的不變性持久性

感謝如下原創系列文章給本文帶來的認知提高和書寫靈感:

準備充分了嘛就想學函數式編程 系列

跌宕起伏的函數式編程 系列

JS 函數式編程指南 系列

JavaScript 輕量級函數式編程 系列

Thinking in Ramda 系列

Ramda 雜談 系列

函數式編程中的「函數們」

代數 JavaScript 規範

Transducers Explained: Part 1 中文

Transducers Explained: Pipelines 中文

JavaScript 中的 Currying(柯里化) 和 Partial Application(偏函數應用)

一步一步教你 JavaScript 函數式編程(第一部分)

一步一步教你 JavaScript 函數式編程(第二部分)

一步一步教你 JavaScript 函數式編程(第三部分)

JavaScript 函數式編程術語大全

函數式編程入門教程

JavaScript中的函數式編程(英文原文)

相關文章
相關標籤/搜索