簡明 JavaScript 函數式編程——入門篇

寫在開頭

本文較長,總共分爲三大部分:(對於函數式編程以及其優勢有必定理解的童鞋,能夠直接從 第二部分 開始閱讀)javascript

第一部分:首先會經過實際代碼介紹一下什麼是函數式編程以及使用它的意義。前端

第二部分:我會着重介紹一下函數式編程中最重要的兩個方法:柯里化和函數組合,以及他們的使用方法和實踐經驗。java

第三部分:實戰篇,主要是經過一個實戰讓你們對這種編程範式有一個更深入的理解。nginx

最後會總結一下函數式編程的優勢和侷限,並給出一些建議。git

什麼是函數式編程

早在 1950 年代,隨着 Lisp 語言的建立,函數式編程( Functional Programming,簡稱 FP)就已經開始出如今你們視野。es6

而直到近些年,函數式以其優雅,簡單的特色開始從新風靡整個編程界,主流語言在設計的時候無一例外都會更多的參考函數式特性( Lambda 表達式,原生支持 map ,reduce ……),Java8 開始支持函數式編程。github

而在前端領域,咱們一樣能看到不少函數式編程的影子:ES6 中加入了箭頭函數,Redux 引入 Elm 思路下降 Flux 的複雜性,React16.6 開始推出 React.memo(),使得 pure functional components 成爲可能,16.8 開始主推 Hook,建議使用 pure function 進行組件編寫……正則表達式

這些無一例外的說明,函數式編程這種古老的編程範式並無隨着歲月而褪去其光彩,反而越發生機勃勃。算法

另外還有一些例子能證實函數式編程也適應於大型軟件的編寫:數據庫

WhatsApp:經過 Erlang,WhatsApp 能夠支持 9 億用戶,而其團隊中只有 50 名工程師

Discord:使用 Elixir,相似方式的 Discord 每分鐘處理超過一百萬個請求

於我我的而言,函數式編程就像第三次工業革命,前兩次分別爲命令式編程(Imperative programming)和麪向對象編程(Object Oriented Programming)。

初窺

概念說的再多也不夠例子直觀

Talk is cheap, show me the code

假設咱們有這麼個需求,咱們登記了一系列人名存在數組中,如今須要對這個結構進行一些修改,須要把字符串數組變成一個對象數組,方便後續的擴展,而且須要把人名作一些轉換:

['john-reese', 'harold-finch', 'sameen-shaw'] 
// 轉換成 
[{name: 'John Reese'}, {name: 'Harold Finch'}, {name: 'Sameen Shaw'}]
複製代碼

命令式編程

用傳統的編程思路,咱們一上來就能夠擼代碼,臨時變量,循環走起來:

const arr = ['john-reese', 'harold-finch', 'sameen-shaw'];
const newArr = [];
for (let i = 0, len = arr.length; i < len ; i++) {
  let name = arr[i];
  let names = name.split('-');
  let newName = [];
  for (let j = 0, naemLen = names.length; j < naemLen; j++) {
    let nameItem = names[j][0].toUpperCase() + names[j].slice(1);
    newName.push(nameItem);
  }
  newArr.push({ name : newName.join(' ') });
}
return newArr;
複製代碼

完成,這幾乎是全部人下意識的編程思路,徹底的面向過程。你會想我須要依次完成:

  • 定義一個臨時變量 newArr。
  • 我須要作一個循環。
  • 循環須要作 arr.length 次。
  • 每次把名字的首位取出來大寫,而後拼接剩下的部分。
  • ……
  • 最後返回結果。

這樣固然能完成任務,最後的結果就是一堆中間臨時變量,光想變量名就讓人感到崩潰。同時過程當中摻雜了大量邏輯,一般一個函數須要從頭讀到尾才知道它具體作了什麼,並且一旦出問題很難定位。

函數式

一直以來,我也沒以爲這樣編程有什麼問題,直到我遇到了函數式編程。咱們來看一看一個 FPer 會如何思考這個問題:

  1. 我只須要一個函數能實現從 String 數組Object 數組 的轉換:

convertNames :: [String] -> [Object]
複製代碼
  1. 這裏面涉及到一個 String -> Object 的轉換,那我須要有這麼個函數實現這種轉換:

convert2Obj :: String -> Object
複製代碼
  1. 至於這種轉換,能夠輕鬆想到須要兩個函數完成:

    • capitalizeName:把名稱轉換成指定形式
    • genObj:把任意類型轉換成對象

  2. 若是再細想一下,capitalizeName 其實也是幾個方法的組合(split, join, capitalize),剩下的幾個函數都是很是容易實現的。

好了,咱們的任務完成了,能夠 運行代碼

const capitalize = x => x[0].toUpperCase() + x.slice(1).toLowerCase();

const genObj = curry((key, x) => {
  let obj = {};
  obj[key] = x;
  return obj;
}) 

const capitalizeName = compose(join(' '), map(capitalize), split('-'));
const convert2Obj = compose(genObj('name'), capitalizeName)
const convertName = map(convert2Obj);

convertName(['john-reese', 'harold-finch', 'sameen-shaw'])
複製代碼

你能夠先忽略其中的 currycompose 函數(後面 會介紹)。只是看這個編程思路,能夠清晰看出,函數式編程的思惟過程是徹底不一樣的,它的着眼點是函數,而不是過程,它強調的是如何經過函數的組合變換去解決問題,而不是我經過寫什麼樣的語句去解決問題,當你的代碼愈來愈多的時候,這種函數的拆分和組合就會產生出強大的力量。

爲何叫函數式編程

以前咱們已經初窺了函數式編程,知道了它的魅力,如今咱們繼續深刻了解一下函數式編程吧。

其實函數咱們從小就學,什麼一次函數,二次函數……根據學術上函數的定義,函數便是一種描述集合和集合之間的轉換關係,輸入經過函數都會返回有且只有一個輸出值。

因此,函數其實是一個關係,或者說是一種映射,而這種映射關係是能夠組合的,一旦咱們知道一個函數的輸出類型能夠匹配另外一個函數的輸入,那他們就能夠進行組合。還記得以前寫的 convert2Obj這個函數:

const convert2Obj = compose(genObj('name'), capitalizeName)
複製代碼

它實際上就完成了映射關係的組合,把一個數據從 String 轉換成了 String 而後再轉換成 Object。數學好的童鞋就知道,這就是數學上的複合運算:g°f = g(f(x))

在咱們的編程世界中,咱們須要處理的其實也只有「數據」和「關係」,而關係就是函數。咱們所謂的編程工做也不過就是在找一種映射關係,一旦關係找到了,問題就解決了,剩下的事情,就是讓數據流過這種關係,而後轉換成另外一個數據罷了。

我特別喜歡用流水線去形容這種工做,把輸入當作原料,把輸出當作產品,數據能夠不斷的從一個函數的輸出能夠流入另外一個函數輸入,最後再輸出結果,這不就是一套流水線嘛?

因此,如今你明確了函數式編程是什麼了吧?它其實就是強調在編程過程當中把更多的關注點放在如何去構建關係。經過構建一條高效的建流水線,一次解決全部問題。而不是把精力分散在不一樣的加工廠中來回奔波傳遞數據。

函數式編程的特色

函數是「一等公民」 (First-Class Functions)

這是函數式編程得以實現的前提,由於咱們基本的操做都是在操做函數。這個特性意味着函數與其餘數據類型同樣,處於平等地位,能夠賦值給其餘變量,也能夠做爲參數,傳入另外一個函數,或者做爲別的函數的返回值,例如前面的

const convert2Obj = compose(genObj('name'), capitalizeName)
複製代碼

聲明式編程 (Declarative Programming)

經過上面的例子能夠看出來,函數式編程大多時候都是在聲明我須要作什麼,而非怎麼去作。這種編程風格稱爲 聲明式編程 。這樣有個好處是代碼的可讀性特別高,由於聲明式代碼大多都是接近天然語言的,同時,它解放了大量的人力,由於它不關心具體的實現,所以它能夠把優化能力交給具體的實現,這也方便咱們進行分工協做。

SQL 語句就是聲明式的,你無需關心 Select 語句是如何實現的,不一樣的數據庫會去實現它本身的方法而且優化。React 也是聲明式的,你只要描述你的 UI,接下來狀態變化後 UI 如何更新,是 React 在運行時幫你處理的,而不是靠你本身去渲染和優化 diff 算法。

惰性執行(Lazy Evaluation)

所謂惰性執行指的是函數只在須要的時候執行,即不產生無心義的中間變量。像剛纔的例子,函數式編程跟命令式編程最大的區別就在於幾乎沒有中間變量,它從頭至尾都在寫函數,只有在最後的時候才經過調用 convertName 產生實際的結果。

無狀態和數據不可變 (Statelessness and Immutable data)

這是函數式編程的核心概念:

  • 數據不可變: 它要求你全部的數據都是不可變的,這意味着若是你想修改一個對象,那你應該建立一個新的對象用來修改,而不是修改已有的對象。
  • 無狀態: 主要是強調對於一個函數,無論你什麼時候運行,它都應該像第一次運行同樣,給定相同的輸入,給出相同的輸出,徹底不依賴外部狀態的變化。

爲了實現這個目標,函數式編程提出函數應該具有的特性:沒有反作用和純函數。

沒有反作用(No Side Effects)

反作用這個詞咱們可算聽的很多,它的含義是:在完成函數主要功能以外完成的其餘副要功能。在咱們函數中最主要的功能固然是根據輸入返回結果,而在函數中咱們最多見的反作用就是隨意操縱外部變量。因爲 JS 中對象傳遞的是引用地址,哪怕咱們用 const 關鍵詞聲明對象,它依舊是能夠變的。而正是這個「漏洞」讓咱們有機會隨意修改對象。

例如: map 函數的原本功能是將輸入的數組根據一個函數轉換,生成一個新的數組:

map :: [a] -> [b]
複製代碼

而在 JS 中,咱們常常能夠看到下面這種對 map 的 「錯誤」 用法,把 map 看成一個循環語句,而後去直接修改數組中的值。

const list = [...];
// 修改 list 中的 type 和 age
list.map(item => {
  item.type = 1;
  item.age++;
})
複製代碼

這樣函數最主要的輸出功能沒有了,變成了直接修改了外部變量,這就是它的反作用。而沒有反作用的寫法應該是:

const list = [...];
// 修改 list 中的 type 和 age
const newList = list.map(item => ({...item, type: 1, age:item.age + 1}));
複製代碼

保證函數沒有反作用,一來能保證數據的不可變性,二來能避免不少由於共享狀態帶來的問題。當你一我的維護代碼時候可能還不明顯,但隨着項目的迭代,項目參與人數增長,你們對同一變量的依賴和引用愈來愈多,這種問題會愈來愈嚴重。最終可能連維護者本身都不清楚變量究竟是在哪裏被改變而產生 Bug。

傳遞引用一時爽,代碼重構火葬場

純函數 (pure functions)

純函數算是在 「沒有反作用」 的要求上再進一步了。相信你已經在不少地方接觸過這個詞,在 Redux 的三大原則中,咱們看到,它要求全部的修改必須使用純函數。

Changes are made with pure functions

其實純函數的概念很簡單就是兩點:

  • 不依賴外部狀態(無狀態): 函數的的運行結果不依賴全局變量,this 指針,IO 操做等。

  • 沒有反作用(數據不變): 不修改全局變量,不修改入參。

因此純函數纔是真正意義上的 「函數」, 它意味着相同的輸入,永遠會獲得相同的輸出

如下幾個函數都是不純的,由於他們都依賴外部變量,試想一下,若是有人調用了 changeNamecurUser 進行了修改,而後你在另外的地方調用了 saySth ,這樣就會產生你預料以外的結果。

const curUser = {
  name: 'Peter'
}

const saySth = str => curUser.name + ': ' + str;   // 引用了全局變量
const changeName = (obj, name) => obj.name = name;  // 修改了輸入參數
changeName(curUser, 'Jay');  // { name: 'Jay' }
saySth('hello!'); // Jay: hello!
複製代碼

若是改爲純函數的寫法會是怎麼樣呢?

const curUser = {
  name: 'Peter'
}

const saySth = (user, str) => user.name + ': ' + str;   // 不依賴外部變量
const changeName = (user, name) => ({...user, name });  // 未修改外部變量

const newUser = changeName(curUser, 'Jay');  // { name: 'Jay' }
saySth(curUser, 'hello!'); // Peter: hello!
複製代碼

這樣就沒有以前說的那些問題了。

咱們這麼強調使用純函數,純函數的意義是什麼?

  • 便於測試和優化:這個意義在實際項目開發中意義很是大,因爲純函數對於相同的輸入永遠會返回相同的結果,所以咱們能夠輕鬆斷言函數的執行結果,同時也能夠保證函數的優化不會影響其餘代碼的執行。這十分符合測試驅動開發 TDD(Test-Driven Development ) 的思想,這樣產生的代碼每每健壯性更強。

  • 可緩存性:由於相同的輸入老是能夠返回相同的輸出,所以,咱們能夠提早緩存函數的執行結果,有不少庫有所謂的 memoize 函數,下面以一個簡化版的 memoize 爲例,這個函數就能緩存函數的結果,對於像 fibonacci 這種計算,就能夠起到很好的緩存效果。

function memoize(fn) {
    const cache = {};
    return function() {
      const key = JSON.stringify(arguments);
      var value = cache[key];
      if(!value) {
        value = [fn.apply(null, arguments)];  // 放在一個數組中,方便應對 undefined,null 等異常狀況
        cache[key] = value; 
      }
      return value[0];
    }
  }

  const fibonacci = memoize(n => n < 2 ? n: fibonacci(n - 1) + fibonacci(n - 2));
  console.log(fibonacci(4))  // 執行後緩存了 fibonacci(2), fibonacci(3), fibonacci(4)
  console.log(fibonacci(10)) // fibonacci(2), fibonacci(3), fibonacci(4) 的結果直接從緩存中取出,同時緩存其餘的
複製代碼
  • 自文檔化:因爲純函數沒有反作用,因此其依賴很明確,所以更易於觀察和理解(配合後面介紹的 [類型簽名](#hindly-milner 類型簽名)更佳)。

  • 更少的 Bug:使用純函數意味着你的函數中不存在指向不明的 this,不存在對全局變量的引用,不存在對參數的修改,這些共享狀態每每是絕大多數 bug 的源頭。

好了,說了這麼多,接下來就讓咱們看看在 JS 中如何使用函數式編程吧。

流水線的構建

若是說函數式編程中有兩種操做是必不可少的那無疑就是柯里化(Currying)函數組合(Compose),柯里化其實就是流水線上的加工站,函數組合就是咱們的流水線,它由多個加工站組成。

接下來,就讓咱們看看如何在 JS 中利用函數式編程的思想去組裝一套高效的流水線。

加工站——柯里化

柯里化的意思是將一個多元函數,轉換成一個依次調用的單元函數

f(a,b,c) → f(a)(b)(c)
複製代碼

咱們嘗試寫一個 curry 版本的 add 函數

var add = function(x) {
  return function(y) {
    return x + y;
  }; 
};
const increment = add(1);

increment(10); // 11
複製代碼

爲何這個單元函數很重要?還記得咱們以前說過的,函數的返回值,有且只有一個嘛? 若是咱們想順利的組裝流水線,那我就必須保證我每一個加工站的輸出恰好能流向下個工做站的輸入。所以,在流水線上的加工站必須都是單元函數。

如今很好理解爲何柯里化配合函數組合有奇效了,由於柯里化處理的結果恰好就是單輸入的。

部分函數應用 vs 柯里化

常常有人搞不清柯里化和部分函數應用 ( Partial Function Application ),常常把他們混爲一談,其實這是不對的,在維基百科裏有明確的定義,部分函數應用強調的是固定必定的參數,返回一個更小元的函數。經過如下表達式展現出來就明顯了:

// 柯里化
f(a,b,c) → f(a)(b)(c)
// 部分函數調用
f(a,b,c) → f(a)(b,c) / f(a,b)(c)
複製代碼

柯里化強調的是生成單元函數部分函數應用的強調的固定任意元參數,而咱們平時生活中經常使用的實際上是部分函數應用,這樣的好處是能夠固定參數,下降函數通用性,提升函數的適合用性。

// 假設一個通用的請求 API
const request = (type, url, options) => ...
// GET 請求
request('GET', 'http://....')
// POST 請求
request('POST', 'http://....')

// 可是經過部分調用後,咱們能夠抽出特定 type 的 request
const get = request('GET');
get('http://', {..})
複製代碼

高級柯里化

一般咱們不會本身去寫 curry 函數,現成的庫大多都提供了 curry 函數的實現,可是使用過的人確定有會有疑問,咱們使用的 Lodash,Ramda 這些庫中實現的 curry 函數的行爲好像和柯里化不太同樣呢,他們實現的好像是部分函數應用呢?

const add = R.curry((x, y, z) =>  x + y + z);
const add7 = add(7);
add7(1,2) // 10
const add1_2 = add(1,2);
add1_2(7) // 10 
複製代碼

其實,這些庫中的 curry 函數都作了不少優化,致使這些庫中實現的柯里化其實不是純粹的柯里化,咱們能夠把他們理解爲「高級柯里化」。這些版本實現能夠根據你輸入的參數個數,返回一個柯里化函數/結果值。即,若是你給的參數個數知足了函數條件,則返回值。這樣能夠解決一個問題,就是若是一個函數是多輸入,就能夠避免使用 (a)(b)(c) 這種形式傳參了。

因此上面的 add7(1, 2) 能直接輸出結果不是由於 add(7) 返回了一個接受 2 個參數的函數,而是你恰好傳了 2 個參數,知足了全部參數,所以給你計算告終果,下面的代碼就很明顯了:

const add = R.curry((x, y, z) =>  x + y + z);
const add7 = add(7);
add(7)(1) // function
複製代碼

若是 add7 是一個接受 2 個參數的函數,那麼 add7(1) 就不該該返回一個 function 而是一個值了。

所以,記住這句話:咱們能夠用高級柯里化去實現部分函數應用,可是柯里化不等於部分函數應用

柯里化的應用

一般,咱們在實踐中使用柯里化都是爲了把某個函數變得單值化,這樣能夠增長函數的多樣性,使得其適用性更強:

const replace = curry((a, b, str) => str.replace(a, b));
const replaceSpaceWith = replace(/\s*/);
const replaceSpaceWithComma = replaceSpaceWith(',');
const replaceSpaceWithDash = replaceSpaceWith('-');
複製代碼

經過上面這種方式,咱們從一個 replace 函數中產生不少新函數,能夠在各類場合進行使用。

更重要的是,單值函數是咱們即將講到的函數組合的基礎

流水線——函數組合

上面咱們藉助 curry,已經能夠很輕鬆的構造一個加工站了,如今就是咱們組合成流水線的時候了。

函數組合概念

函數組合的目的是將多個函數組合成一個函數。下面來看一個簡化版的實現:

const compose = (f, g) => x => f(g(x))

const f = x => x + 1;
const g = x => x * 2;
const fg = compose(f, g);
fg(1) //3
複製代碼

咱們能夠看到 compose 就實現了一個簡單的功能:造成了一個全新的函數,而這個函數就是一條從 g -> f 的流水線。同時咱們能夠很輕易的發現 compose 實際上是知足結合律的

compose(f, compose(g, t)) = compose(compose(f, g), t)  = f(g(t(x)))
複製代碼

只要其順序一致,最後的結果是一致的,所以,咱們能夠寫個更高級的 compose,支持多個函數組合:

compose(f, g, t) => x => f(g(t(x))
複製代碼

簡單實現以下:

const compose = (...fns) => (...args) => fns.reduceRight((val, fn) => fn.apply(null, [].concat(val)), args);

const f = x => x + 1;
const g = x => x * 2;
const t = (x, y) => x + y;

let fgt = compose(f, g, t);
fgt(1, 2); // 3 -> 6 -> 7
複製代碼

函數組合應用

考慮一個小功能:將數組最後一個元素大寫,假設 log, headreversetoUpperCase 函數存在(咱們經過 curry 能夠很容易寫出來)

命令式的寫法:

log(toUpperCase(head(reverse(arr))))
複製代碼

面向對象的寫法:

arr.reverse()
  .head()
  .toUpperCase()
  .log()
複製代碼

鏈式調用看起來順眼多了,然而問題在於,原型鏈上可供咱們鏈式調用的函數是有限的,而需求是無限的 ,這限制了咱們的邏輯表現力。

再看看,如今經過組合,咱們如何實現以前的功能:

const upperLastItem = compose(log, toUpperCase, head, reverse);
複製代碼

經過參數咱們能夠很清晰的看出發生了 uppderLastItem 作了什麼,它完成了一套流水線,全部通過這條流水線的參數都會經歷:reverse -> head -> toUpperCase -> log 這些函數的加工,最後生成結果。

最完美的是,這些函數都是很是簡單的純函數,你能夠隨意組合,隨意拿去用,不用有任何的顧忌。

其實有些經驗豐富的程序猿已經看出來一些蹊蹺,這不就是所謂管道 ( pipe ) 的概念嘛?在 Linux 命令中常會用到,相似ps grep的組合

ps -ef | grep nginx
複製代碼

只是管道的執行方向和 compose (從右往左的組合 ) 好像恰好相反,所以不少函數庫(Lodash,Ramda)中也提供了另外一種組合方式:pipe(從左往右的組合)

const upperLastItem = R.pipe(reverse, head, toUppderCase, log);
複製代碼

其實函數式編程的理念和 Linux 的設計哲學很像:

有衆多單一目的的小程序,一個程序只實現一個功能,多個程序組合完成複雜任務。

函數組合的好處

函數組合的好處顯而易見,它讓代碼變得簡單而富有可讀性,同時經過不一樣的組合方式,咱們能夠輕易組合出其餘經常使用函數,讓咱們的代碼更具表現力

// 組合方式 1
const last = compose(head, reverse);
const shout = compose(log, toUpperCase);
const shoutLast = compose(shout, last);
// 組合方式 2
const lastUppder = compose(toUpperCase, head, reverse);
const logLastUpper = compose(log, lastUppder);
複製代碼

這個過程,就像搭樂高積木同樣。

lego

因而可知,大型的程序,均可以經過這樣一步步的拆分組合實現,而剩下要作的,就是去構造足夠多的積木塊(函數)。

實踐經驗

在使用柯里化和函數組合的時候,有一些經驗能夠借鑑一下:

柯里化中把要操做的數據放到最後

由於咱們的輸出一般是須要操做的數據,這樣當咱們固定了以前的參數(咱們能夠稱爲配置)後,能夠變成一個單元函數,直接被函數組合使用,這也是其餘的函數式語言遵循的規範:

const split = curry((x, str) => str.split(x));
const join = curry((x, arr) => arr.join(x));
const replaceSpaceWithComma = compose(join(','), split(' '));
const replaceCommaWithDash = compose(join('-'), split(','));
複製代碼

可是若是有些函數沒遵循這個約定,咱們的函數該如何組合?固然也不是沒辦法,不少庫都提供了佔位符的概念,例如 Ramda 提供了一個佔位符號(R.__)。假設咱們的 splitstr 放在首位

const split = curry((str, x) => str.split(x));
const replaceSpaceWithComma = compose(join(','), split(R.__, ' '));
複製代碼

函數組合中函數要求單輸入

函數組合有個使用要點,就是中間的函數必定是單輸入的,這個很好理解,以前也說過了,由於函數的輸出都是單個的(數組也只是一個元素)。

函數組合的 Debug

當遇到函數出錯的時候怎麼辦?咱們想知道在哪一個環節出錯了,這時候,咱們能夠藉助一個輔助函數 trace,它會臨時輸出當前階段的結果。

const trace = curry((tip, x) => { console.log(tip, x); return x; });
const lastUppder = compose(toUpperCase, head, trace('after reverse'), reverse);
複製代碼

多參考 Ramda

現有的函數式編程工具庫不少,Lodash/fp 也提供了,可是不是很推薦使用 Lodash/fp 的函數庫,由於它的不少函數把須要處理的參數放在了首位( 例如 map )這不符合咱們以前說的最佳實踐。

這裏推薦使用 Ramda,它應該是目前最符合函數式編程的工具庫,它裏面的全部函數都是 curry 的,並且須要操做的參數都是放在最後的。上述的 splitjoinreplace 這些基本的都在 Ramda 中能夠直接使用,它一共提供了 200 多個超實用的函數,合理使用能夠大大提升你的編程效率(目前個人我的經驗來講,我須要的功能它 90%都提供了)。

實戰一下

如今你已經基本學會了全部的基礎概念,那讓咱們來實戰一下吧!

假設我如今有一套數據:

const data = [
  {
    name: 'Peter',
    sex: 'M',
    age: 18,
    grade: 99
  },
  ……
]
複製代碼

實現如下幾個經常使用功能:

  1. 獲取全部年齡小於 18 歲的對象,並返回他們的名稱和年齡。
  2. 查找全部男性用戶。
  3. 更新一個指定名稱用戶的成績(不影響原數組)。
  4. 取出成績最高的 10 名,並返回他們的名稱和分數。

我這邊提供如下 Ramda 庫中的參考函數:

// 對象操做(最後一個參數是對象),均會返回新的對象拷貝
R.prop('name')    // 獲取對象 name 字段的值
R.propEq('name', '123')   // 判斷對象 name 字段是否等於‘123’
R.assoc('name', '123')   // 更新對象的'name'的值爲'123'
R.pick(['a', 'd']); //=> {a: 1, d: 4} // 獲取對象某些屬性,若是對應屬性不存在則不返回
R.pickAll(['a', 'd']); //=> {a: 1, d: 4} // 獲取對象某些屬性,若是對應屬性不存在則返回`key : undefined`

// 數組操做
R.map(func)  // 傳統的 map 操做
R.filter(func)  // 傳統的 filter 操做
R.reject(func)  // filter 的補集
R.take(n)    // 取出數組前 n 個元素

// 比較操做
R.equals(a, b)  // 判斷 b 是否等於 a 
R.gt(2, 1) => true  // 判斷第一個參數是否大於第二個參數
R.lt(2, 1) => false // 判斷第一個參數是否小於第二個參數

// 排序操做
R.sort(func)    // 根據某個排序函數排序
R.ascend(func)    // 根據 func 轉換後的值,生成一個升序比較函數
R.descend(func)    // 根據 func 轉換後的值,生成一個降序比較函數
// 例子:
R.sort(R.ascend(R.prop('age')))  // 根據 age 進行升序排序 

// 必備函數
R.pipe()   //compose 的反向,從前日後組合
R.compose()  // 從後到前組合
R.curry()  // 柯里化
複製代碼

能夠想一想看,若是是你會如何寫這些函數,我這裏提供了一個 codepen 的模板,能夠在這裏寫你的答案,會自動測試。

(個人答案放在文章後面,請先思考完再看)

附錄

Hindly Milner 類型簽名

以前咱們遇到了相似這樣的說明:

:: String -> Object
複製代碼

這叫類型簽名,最先是在 Hindley-Milner 類型系統中提出來的。

你也能在 Ramda 的官網上看到相似的類型簽名:

引入它的好處顯而易見,短短一行,就能暴露函數的行爲和目的,方便咱們瞭解語義。有時候一個函數可能很長,光從代碼上很難理解它到底作了什麼:

const replace = reg => sub => str => str.replace(reg, sub);
複製代碼

而加上類型簽名,咱們至少能知道每一步它作了哪些轉換,最後輸出一個什麼樣的結果。

例如這個 replace ,經過類型簽名咱們知道它接受一個 正則表達 式和兩個 String,最後會返回一個 String

// replace :: Regex -> String -> String -> String
const replace = reg => sub => str => str.replace(reg, sub);
複製代碼

這樣的連續箭頭看起來可能很頭疼,其實稍微組合一下能夠發現,它就是柯里化的意思:先傳一個 正則表達式 會返回一個函數,若是再傳一個 String,也會返回函數……直到你輸入了最後一個 String,就會返回一個 String 的結果。

// replace :: Regex -> (String -> (String -> String))
複製代碼

同時類型簽名能夠避免咱們在合併函數的時候輸入和輸出的類型不一致。

例如 join 函數經過類型簽名很明顯是傳入一個 String 的配置,而後就能夠將一個 String 數組 轉換成 String

// join :: String -> [String] -> String
const join = curry((sep, arr) => arr.join(sep));
複製代碼

一樣,下面這個函數,它接受一個 String,而後通過 strLen 轉換能返回一個 Number

// strLen :: String -> Number
const strLen = str => str.length();
複製代碼

那咱們很容易知道,以上兩個函數徹底能夠組合,由於他們輸入和輸出類型一致,經過組合咱們能夠完成一個 String 數組Number 的流水線。

const joinDash = join('-');
const lengthWithDash = compose(strLen, joinDash);
lengthWithDash(['abc', 'def']);  // 7
複製代碼

固然還有時候你的函數可能不是接受特定的類型,而只是作一些通用的事情,此時咱們能夠用 a, b, c…… 這些來替代一些通用類型,例如 map ,它傳入一個能夠把 a 轉換成 b 的函數,而後把a 數組 轉換成b 數組

// map :: (a -> b) -> [a] -> [b]
var map = curry(function(f, xs){
  return xs.map(f);
});

// head :: [a] -> a
var head = function(xs){ return xs[0]; }
複製代碼

如今你就學會了類型簽名的使用了,咱們推薦你寫的每一個函數都加上類型簽名,方便他人,方便本身。

Pointfree 編程風格

我以前提過一下 Pointfree 這種編程風格,它其實就是強調在整個函數編寫過程當中不出現參數(point),而只是經過函數的組合生成新的函數,實際數據只須要在最後使用函數的時候再傳入便可。

// Pointfree 沒有出現須要操做的參數
const upperLastItem = compose(toUpperCase, head, reverse);

// 非 Pointfree 出現了須要操做的參數
const upperLastItem = arr => {
  const reverseArr = arr.reverse();
  const head = reverseArr[0];
  return head.toUpperCase();
}
複製代碼

咱們在使用函數式編程的時候,其實天然就會造成這種風格,它有什麼好處呢?

  • 無需考慮參數命名:能減輕很多思惟負擔,畢竟參數命名也是個很費事的過程。
  • 關注點集中:你無需考慮數據,只須要把全部的注意力集中在轉換關係上。
  • 代碼精簡:能夠省去經過中間變量不斷的去傳遞數據的過程。
  • 可讀性強:一眼就能夠看出來數據的整個的轉換關係。

剛開始使用這種編程風格確定會有不少不適應,可是當你能合理運用這種編程風格後確實會讓代碼更加簡潔和易於理解了。可是凡事無絕對,學了 Pointfree 這種風格並不意味着你要強迫本身作到一個參數都不能出現(好比不少基礎函數,他們自己的編寫就不是 Pointfree 的),函數式編程也不是全部場合都徹底適用的,具體狀況具體分析

記住,你學習各類編程範式的最終目的都是爲了讓本身的編碼更加高效,易懂,同時減小出錯機率,不能由於學了一種編程範式,反而致使本身的編程成本大大增長,這就有點本末倒置了。

實戰答案

當你寫完函數,你能夠看一下,你寫的函數是否是足夠的通用?若是我如今需求由獲取男性用戶變成獲取全部的女性用戶,若是我如今要取全部年齡前 10 名的用戶,你的函數是否能夠很好的複用呢?答案的 codepen 地址,我這裏的答案也不必定是最優的,只是提供一個思路(就像 update,你能夠不用 map,而用 R.update 直接更新數組元素)。

若是在不看答案前,你能寫出全部這些操做,那說明你對函數的組合應用的很好了!

總結

前面介紹了不少函數式編程的概念能夠總結出函數式編程的優勢:

  • 代碼簡潔,開發快速:函數式編程大量使用函數的組合,函數的複用率很高,減小了代碼的重複,所以程序比較短,開發速度較快。Paul Graham 在《黑客與畫家》一書中寫道:一樣功能的程序,極端狀況下,Lisp 代碼的長度多是 C 代碼的二十分之一。
  • 接近天然語言,易於理解:函數式編程大量使用聲明式代碼,基本都是接近天然語言的,加上它沒有亂七八糟的循環,判斷的嵌套,所以特別易於理解。
  • 易於"併發編程":函數式編程沒有反作用,因此函數式編程不須要考慮「死鎖」(Deadlock),因此根本不存在「鎖」線程的問題。
  • 更少的出錯機率:由於每一個函數都很小,並且相同輸入永遠能夠獲得相同的輸出,所以測試很簡單,同時函數式編程強調使用純函數,沒有反作用,所以也不多出現奇怪的 Bug。

所以,若是用一句話來形容函數式編程,應該是:Less code, fewer bugs 。由於寫的代碼越少,出錯的機率就越小。人是最不可靠的,咱們應該儘可能把工做交給計算機。

一眼看下來好像函數式能夠解決全部的問題,可是實際上,函數式編程也不是什麼萬能的靈丹妙藥。正由於函數式編程有以上特色,因此它天生就有如下缺陷:

  • 性能:函數式編程相對於指令式編程,性能絕對是一個短板,由於它每每會對一個方法進行過分包裝,從而產生上下文切換的性能開銷。同時,在 JS 這種非函數式語言中,函數式的方式必然會比直接寫語句指令慢(引擎會針對不少指令作特別優化)。就拿原生方法 map 來講,它就要比純循環語句實現迭代慢 8 倍。

  • 資源佔用:在 JS 中爲了實現對象狀態的不可變,每每會建立新的對象,所以,它對垃圾回收(Garbage Collection)所產生的壓力遠遠超過其餘編程方式。這在某些場合會產生十分嚴重的問題。

  • 遞歸陷阱:在函數式編程中,爲了實現迭代,一般會採用遞歸操做,爲了減小遞歸的性能開銷,咱們每每會把遞歸寫成尾遞歸形式,以便讓解析器進行優化。可是衆所周知,JS 是不支持尾遞歸優化的(雖然 ES6 中將尾遞歸優化做爲了一個規範,可是真正實現的少之又少,傳送門

  • ……

所以,在性能要求很嚴格的場合,函數式編程其實並非太合適的選擇。

可是換種思路想,軟件工程界歷來就沒有中止過所謂的銀彈之爭,卻也歷來沒誕生過什麼真正的銀彈,各類編程語言層出不窮,各類框架突飛猛進,各類編程範式推陳出新,結果誰也沒有真正的替代誰。

學習函數式編程真正的意義在於:讓你意識到在指令式編程,面向對象編程以外,還有一種全新的編程思路,一種用函數的角度去抽象問題的思路。學習函數式編程能大大豐富你的武器庫,否則,當你手中只有一個錘子,你看什麼都像釘子

咱們徹底能夠在平常工做中將函數式編程做爲一種輔助手段,在條件容許的前提下,借鑑函數式編程中的思路,例如:

  • 多使用純函數減小反作用的影響。
  • 使用柯里化增長函數適用率。
  • 使用 Pointfree 編程風格,減小無心義的中間變量,讓代碼更且可讀性。
  • ……

最後,仍是那句老生常談的話:

沒有最好的,只有最適合的

但願你們在實際項目中,能根據本身的需求選擇最適合本身的編程範式,也但願經過學習這種新的編程範式,可讓咱們在二進制的世界行走得更加遊刃有餘。

參考文章

mostly-adequate-guide-chinese

百度百科:函數式編程

百度百科:範疇學

clojure-flavored-javascript

en.wikipedia.org/wiki/Curryi…

en.wikipedia.org/wiki/Partia…

why you should learn functional programming

將來屬於聲明式編程

本文發佈自 網易雲音樂前端團隊,歡迎自由轉載,轉載請保留出處。咱們對人才飢渴難耐,快來 加入咱們

相關文章
相關標籤/搜索