本文譯自:Functional Programming for JavaScript Peoplejavascript
和大多數人同樣,我在幾個月前聽到了不少關於函數式編程的東西,不過並無更深刻的瞭解。於我而言,可能只是一個流行詞罷了。從那時起,我開始更深地瞭解函數式編程而且我以爲應該爲那些總能聽到它但不知道到底是什麼的新人作一點事情。php
談及函數式編程,你可能會想到它們:Haskell 和 Lisp,以及不少關於哪一個更好的討論。儘管它們都是函數式語言,不過的確有很大的不一樣,能夠說各有各的賣點。在文章的結尾處,我但願你可以對這些有一個更加清晰的認識。它們都在某些更加現代的語言上留下了本身的影子。你可能據說過這樣兩種語言:Elm 和 Clojurescript,它們兩個均可以編譯爲 JavaScript。不過在我深刻了解語言的規範以前,我更想讓大家深刻了解函數式語言中的一些核心概念和模式。html
強烈推薦在邁入這個函數式編程的新世界以前,在你的桌上擺一杯咖啡。java
函數式編程的核心就是藉助形式化數學來描述邏輯:lambda 運算。數學家們喜歡將程序描述爲數據的變換,這也引入了第一個概念:純函數。純函數無反作用,僅僅依賴於函數的輸入,而且當輸入相同時輸出保持一致。看下面一個例子:git
// 純函數 const add10 = (a) => a + 10 // 依賴於外部變量的非純函數 let x = 10 const addx = (a) => a + x // 會產生反作用的非純函數 const setx = (v) => x = v
非純函數間接地依賴於參數 x。若是你改變了 x 的值,對於相同的 x,addx 會輸出不一樣的結果。這就使得在編譯時很難去靜態分析和優化程序。不過對 JavaScript 開發者來講更加有用的是,純函數下降了程序的認知難度。寫純函數時,你僅僅須要關注函數體自己。沒必要去擔憂一些外部因素所帶來的問題,好比在 addx 函數中的 x 被改變。github
函數式編程中還有一個很棒的東西就是你能夠在新的函數中組合它們。一個用於在 lambda 運算中描述程序的很特殊的運算符就是組合。組合將兩個函數在一個新的函數中『組合』到一塊兒。以下:算法
const add1 = (a) => a + 1 const times2 = (a) => a * 2 const compose = (a, b) => (c) => a(b(c)) const add1OfTimes2 = compose(add1, times2) add1OfTimes2(5) // => 11
組合與介詞『of』很像。注意參數的順序以及它們是如何被計算的:兩倍後加一:第二個函數會被先調用。組合與 unix 中的 pipe 函數是恰好相反的,它會接收一個由函數組成的數組做爲參數。編程
const pipe = (fns) => (x) => fns.reduce((v, f) => f(v), x) const times2add1 = pipe([times2, add1]) times2add1(5) // => 11
藉助函數組合,咱們能夠經過將多個小函數結合在一塊兒來構建更復雜的數據變化。這篇文章詳細地展現了函數組合是如何幫助你以更加乾淨簡潔的方式來處理數據。數組
從實際來說,組合能夠更好地替代面向對象中的繼承。下面是一個有點牽強但很實際的示例。假如你須要爲你的用戶建立一個問候語。promise
const greeting = (name) => `Hello ${name}`
很棒!一個簡單的純函數。忽然,你的項目經理說你如今須要爲用戶展現更多的信息,在名字前添加前綴。因此你可能把代碼改爲下面這樣:
const greeting = (name, male=false, female=false) => `Hello ${male ? ‘Mr. ‘ : female ? ‘Ms. ‘ : ‘’} ${name}`
代碼並非很糟糕,不過若是咱們又要添加愈來愈多的判斷邏輯,好比『Dr.』或『Sir』呢?若是咱們要添加『MD』或者『PhD』前綴呢?又或者咱們要變動下問候的方式,用『Sup』替代『Hello』呢?如今事情已然變得很棘手。像這樣爲函數添加判斷邏輯並非面向對象中的繼承,不過與繼承而且重寫對象的屬性和方法的狀況有些相似。既然反對添加判斷邏輯,那咱們就來試試函數組合的方式:
const formalGreeting = (name) => `Hello ${name}` const casualGreeting = (name) => `Sup ${name}` const male = (name) => `Mr. ${name}` const female = (name) => `Mrs. ${name}` const doctor = (name) => `Dr. ${name}` const phd = (name) => `${name} PhD` const md = (name) => `${name} M.D.` formalGreeting(male(phd("Chet"))) // => "Hello Mr. Chet PhD"
這就是更加可維護和一讀的緣由。每一個函數僅完成了一個簡單的事情,咱們很容易就能夠將它們組合在一塊兒。如今,咱們尚未完成整個實例,接下來使用 pipe 函數!
const identity = (x) => x const greet = (name, options) => { return pipe([ // greeting options.formal ? formalGreeting : casualGreeting, // prefix options.doctor ? doctor : options.male ? male : options.female ? female : identity, // suffix options.phd ? phd : options.md ?md : identity ])(name) }
另一個使用純函數和函數組合的好處是更加容易追蹤錯誤。不管在何時出現一個錯誤,你都可以經過每一個函數追溯到問題的原因。在面向對象編程中,這一般會至關的複雜,由於你通常狀況下並不知道引起改問題的對象的其餘狀態。
函數柯里化的發明者與 Haskell 的發明者是同一我的-他的名字是 Haskell Curry(更正:發明者不是 Haskell Curry, 而是以 Haskell Curry 命名)。函數柯里化的本質是,能夠在調用一個函數的時候傳入更少的參數,而這個函數會返回另一個函數而且可以接收其餘參數。有一篇很是棒的文章很是詳細地作了解釋,下面是一個使用了 Ramda.js 完成柯里化的簡單示例。
下面的示例中,咱們建立了一個柯里化函數『add』,接收兩個參數。當咱們傳遞一個參數時,會獲得一箇中間函數『add1』,它僅僅會接收一個參數。
const add = R.curry((a, b) => a + b) add(1, 2) // => 3 const add1 = add(1) add1(2) // => 3 add1(10) // => 11
在 Haskell 中,全部的函數會自動柯里化。沒有可選或者默認參數。
通俗地講,函數柯里化很是適用於在 map、compose 和 pipe 中使用。好比:
const users = [{name: 'chet', age:25}, {name:'joe', age:24}] R.pipe( R.sortBy(R.prop('age')), // 經過 age 屬性排序 R.map(R.prop('name')), // 獲得每一個 age 屬性 R.join(', '), // 使用逗號分隔每一個 name )(users) // => "joe, chet"
這使得數據處理更顯聲明式。代碼就像註釋中所描述的同樣!
Monads 和 functors可能只是你所知道的兩個流行詞。若是你想更深層次地理解它們,我強烈推薦這篇文章,使用了很是棒的圖形來作解釋。不過這東西並無那麼的複雜。
儘管 Monads 是很是有趣的。Monads 能夠當作是 value 的一個容器,能夠打開這個容器並對 value 作一些處理,你須要對它進行一個 map 操做。看下面這個簡單示例:
// monad list = [-1,0,1] list.map(inc) // => [0,1,2] list.map(isZero) // => [false, true, false]
關於 monads 和 functors 有一件很重要的事情,就是數學家們也在分類理論中研究這些觀點。這不只幫助咱們理解程序的框架,並且提供了可用於在編譯時靜態分析和優化咱們的代碼代數定理和證實。這是 Haskell 的好處之一-Glasgow Haskell 編譯器 是人類智慧的壯舉。
有不少種定理和特徵存在於分類理論中。好比,下面是一個簡單的特徵:
list.map(inc).map(isZero) // => [true, false, false] list.map(compose(isZero, inc)) // => [true, false, false]
在 map 被編譯後,它會使用一個更加高效的方式進行循環。一般是一個 O(n) 操做(線性時間),不過它依然強依賴於列表中下一項指針的增加。因此第二種在性能上是前一種的2倍。這些也是 Haskell 在編譯時對你的代碼所作的轉化,使它變得很是快-有一個很酷的技巧能夠作到這個,我一下子解釋。
爲了對 monads 作一點擴展,出現了一個頗有趣的 monad 被稱做 『Maybe monad』(在 Swift 中一般叫作 Option 或者 Optional)。在 Haskell,並無相似 null 或者 undefined 的東西。爲了方便表示潛在爲 null 的變量,你能夠將它包在 monad 中,Haskell 編譯器會知道如何處理。
Maybe monad 是一種組合類型,就像 Nothing 或者 Just something。在 Haskell 能夠以下定義 Maybe:
type Maybe = Nothing | Just x
小寫的 x 僅僅意味着任何其餘類型。
做爲一個 monad,你能夠在 Maybe 上使用 .map()
來改變它所包含的值!當你對一個 Maybe 進行 map 時,若是是 Just 類型,將值應用於函數而且返回一個帶有新 value 的新的 Just。若是 Maybe 是 Nothing 類型,則返回 Nothing。在 Haskell 中,語法至關的優雅而且使用了模式匹配,不過在 JavaScript 中你可能須要這樣使用 Maybe:
const x = Maybe.Just(10) const n = x.map(inc) n.isJust() // true n.value() // 11 const x= Maybe.Nothing const n = x.map(inc) // no error! n.isNothing // true
這個 monad 在 JavaScript 代碼中可能不是很是的有用,不過搞懂爲什麼在 Haskell 中這麼有用可能更有趣。Haskell 要求必須定義程序中每一種邊界狀況的處理方法,不然它就不會編譯。當你發送一個 HTTP 請求,你會獲得一個 Maybe 類型,由於請求可能失敗而且什麼都不會返回。若是你沒有處理請求失敗的狀況,程序也不會編譯。這就意味着程序不可能產生運行時錯誤。或許你的代碼出錯了,不過它不會像 JavaScript 中那樣直接中斷執行。
這也是 Elm 的一個大賣點。類型系統和編譯器強制你的程序在沒有運行時錯誤的狀況下才能運行。
分析一下上下文中的 monads 和代數結構中的代碼將有助於你以一種結構化的方式來定義和理解你的問題。好比,Maybe 一個有趣的擴展就是用於錯誤處理的面向軌道編程的概念。值得注意,monads 也適用於處理異步事件。
有不少種有趣的 monads 以及不少我本身也沒能徹底理解的詞語。不過爲了保證術語的一致性,已經有相似於 fantasy-land 的規範和 typeclassopedia 嘗試在分類理論中統一不一樣的概念來達到書寫符合規範的代碼。
另外一種可能影響到整個分類理論和 lambda 計算的東西就是引用的透明性。當徹底同樣的兩個東西彼此卻不相等時,讓數學家來分析邏輯程序一樣是很是困難的。這也是 JavaScript 中廣泛存在的一個問題。
{} == {} // false [] == [] // false [1,2] == [1,2] // false
如今假設在一個不存在引用透明性的世界來進行運算。你根本不須要任何證據就能夠確信一個空數組與另外一個空數組相等。這裏面起做用的僅僅是數組的值,而不是數組所引用的指針。因此函數式編程語言最終使用了深比較來對比兩個值。不過性能上並不完美,因此有幾個小技巧可讓這個比較更加快速。
在繼續討論以前,我須要澄清一件事情:在函數式編程中,不可以在沒有改變引用的狀況下來改變一個變量。不然,函數表現出的變化可能不純!於是,你可以確保若是兩個變量的引用相同,它們的值也必定相等。一樣由於咱們不能直接更改變量,因此每當咱們想要改變它的時候,都須要那些值拷貝到一個新的存儲空間上。這是一種巨大的性能損失而且可能致使垃圾抖動。不過解決方案就是使用結構共享(持久化數據結構)。
一個結構共享的簡單示例就是鏈表。假如你僅僅保持對鏈表尾部的一個引用。當比較兩個鏈表時,你能夠先比較尾部的引用是否相同。這是一個很棒的捷徑,由於若是相等,就結束了比較——兩個鏈表相同!不然,你就須要遞歸表中的每一個元素來判斷它們的值是否相等。能夠高效地將某個值添加到表中,而不是複製整個表到一個新的內存中,你能夠簡單地向一個新的節點上添加一個連接,而後記錄這個引用。這樣,咱們經過一個新的引用在一個新的數據結構中已經在結構上共享了上一個數據結構,而且也對前一個數據結構進行了持久化。
用於改變這些不可變數據的數據結構被稱爲哈希映射的數組索引(HAMT)。這也是 Immutable.js 和 Mori.js 主要作的。Clojurescript 和 Haskell 已經在編譯器中完成了這一步,雖然我還不肯定 Elm 中是否實現。
使用不可變數據結構會給你帶來性能上的改善,而且有助於你保持理智。React 假定 props 和 state 是不可變的,這樣它就可以有效地檢測出前一個 props 和 state 和下一個 props 和 state 在引用上是否相等,來減小沒必要要的重繪。在其餘狀況下,使用不可變數據很便捷地幫助你確保值不會在毫無徵兆的狀況下發生改變。
延遲計算是一類包含了不少相似 thunk 和 generator 規範概念的通用術語。延遲計算就和你所想的同樣:不會在必須作某件事情以前作任何事,儘量長時間的延後。一個類比就是假如你有無限量的盤子要洗。你就不會將全部的盤子都放到水池中而後一次性清洗它們,咱們能夠偷懶一下,一次僅僅洗一個盤子。
在 Haskell 中,延遲計算的本質更加容易理解,因此我會從它提及。首先,咱們須要理解程序是如何計算的。咱們所使用的大部分語言使用的都是由內而外的規約,就像下面這樣:
square(3 + 4) square(7) // 計算最內層的表達式 7 * 7 49
這也是比較明智的程序計算方式。不過咱們先來看一下向外而內的規約。
square(3 + 4) (3 + 4) * (3 + 4) // 計算最外層的表達式 7 * (3 + 4) 7 * 7 49
顯然,由外而內規約的方式不夠明智——咱們須要計算兩次 3 + 4
,因此程序共花費了 5 步。這有點糟糕。不過 Haskell 保留了對每一個表達式的引用而且在它們由外而內規約時傳遞共享的引用。這樣,當 3 + 4
被首次計算後,這個表達式的引用會指向新的表達式 7
。這樣咱們就跳過了重複的步驟。
square(3 + 4) (3 + 4) * (3 + 4) // 計算最外面的表達式 7 * 7 // 因爲引用共享的存在,計算此時減小了一步 49
本質上,延遲計算就是引用共享的由外而內計算。
Haskell 在內部爲你作了不少事情,而且這也意味着你能夠像無限的列表同樣定義東西。好比,你能夠遞歸地定義一個無限的列表。
ones = 1 : ones
假設如今有一個 take(n, list)
函數,它的第一個參數是一個 n 元素的列表。若是咱們使用由內而外的規約,可能會出現無限遞歸計算一個列表,由於它是無限的。不過,藉助由外而內計算,咱們能夠實現按需延遲計算!
然而,因爲 JavaScript 和大多數編程語言都使用了由內而外的規約,咱們複製這種架構的惟一方式就是將數組當作是函數,以下示例:
const makeOnes = () => {next: () => 1} ones = makeOnes() ones.next() // => 1 ones.next() // => 1
如今,咱們已經基於相同的遞歸定義建立了一個延時計算無限列表的表達式。如今咱們建立一個天然數的無限列表:
const makeNumbers = () => { let n = 0 return {next: () => { n += 1 return n } } numbers = makeNumbers() numbers.next() // 1 numbers.next() // 2 numbers.next() // 3
在 ES2015 中,確實爲此實現了一個標準,而且稱爲函數 generator。
function* numbers() { let n = 0 while(true) { n += 1 yield n } }
延遲能夠帶來巨大的性能效益。好比,你能夠對比一下每秒鐘 Lazy.js 計算與 Underscore 和 Lodash 的區別:
下面是解釋它的原理的一個很好的示例(Lazy.js 網站給出的)。假定你如今有一個巨大的數組(元素是人),而且你想對它執行某些轉換:
const results = _.chain(people) .pluck('lastName') .filter((name) => name.startsWith('Smith')) .take(5) .value()
完成這件事最原始的方式就是將全部的名字揀出來,過濾整個數組,而後使用前 5 個。這就是 Underscore.js 以及絕大多數類庫的作法。不過使用 generator,咱們可使用延遲計算 每次僅計算一個值,直到咱們拿到了以 『Smith』開頭的名字。
Haskell 給咱們最大的驚喜就是全部的這些都在語言內部藉助由外而內規約和引用共享實現了。在 JavaScript 中,咱們可以藉助於 Lazy.js,不過若是你想要本身建立這類東西,你就須要理解上述的每一步,返回一個新的 generator。想要拿到一個 generator 中的全部值,你就須要爲它們調用 .next()
。這個鏈方法會將數組編程一個 generator。而後,當你調用 .value()
時,它就會反覆的調用 next()
方法,直到沒有更多的值存在時。而且 .take(5)
能夠確保不會去計算比你須要的更多的的計算!
如今回憶一下以前提到的定理:
list.map(inc).map(isZero) // => [false, true, false] list.map(compose(isZero, inc)) // => [false, true, false]
延遲計算,在內部幫你完成了這類優化。
## Clojure 模式和特性
我已經討論了不少關於 Haskell 的問題,如今我想解釋一下 Clojure 在哪些方面也徹底符合這個。Clojure 具備引用透明性、不可變數據類型,而且你不能任意更改一個變量,除特殊的原子類型外。這使得在 Haskell 中做對比計算時難以置信的方便,它會強制去掃描數組中的每一個元素,而且將全部值記錄到一個關聯數組中,而後隨處均可調用。Clojure 也一樣沒有強類型系統,也沒有相似 Glasgow Haskell 這樣強大的編譯器。而且在 Clojure 中沒有 null 這種東西。也就是說,函數式模式被強烈地推薦而且在 Clojure 中很難不使用到它。
關於 Clojure 有兩件事情給我留下很深的印象:在 Clojure 中全部的東西都是一個私有數據類型,叫作 EDN——Clojure 版本的 JSON。替換了對象和類型,全部東西都只是一些私有數據結構,可用於表示你想要的全部東西。好比,在 JavaScript 中,咱們有原生的 Date 對象。不過當你想要將 date 序列化爲一個 JSON 時發生了什麼呢?你須要建立你本身的自定義 serializer 或 deserializer。在 Clojure 中,你可能須要將一個日期表示爲一個有時間戳和時區組成的關聯數組(除非你使用 Java 實現)。任何字符串格式的函數僅僅假定爲相同的數據結構。因此在 Clojure 中,數據很重要,數據轉換,以及數據加工。一切皆數據。
Clojure 另一個很是酷的地方就是代碼即數據。Clojure 是一個表處理語言,它就是一個列表的解釋器,第一項是一組函數,其他的是參數——這也就是爲何不少人都會說在每種語言中都存在一個 Lisp 的緣由。不過 Lisp 更酷的一點就是它可以建立異常強大的宏。大多數人所用的宏僅限於文本替換宏,你可使用某種字符串模板來生成代碼。而且在 JavaScript 中有一個稱爲 Sweetjs
的強大類庫。不過在 Clojure 中,因爲代碼自己就是一個列表,你能夠在編譯時審查代碼,轉換代碼,最後計算!這對於編寫連續重複的東西很方便,最大限度的容許你建立你想要表達的語法。若是在 JavaScript 中作這件事,你須要有一個相似 Babel 的插件以及 JavaScript 抽象語法樹(AST)而且要建立你本身的編譯器。不過在 Clojure 中,AST 僅僅是一個列表!
Clojure 最大的特性之一就是用於處理異步通訊的 core.async
庫,而且用一個很是優雅的方式來使用宏。在下面的示例中,咱們建立了一個 channel,裏面的 go 函數實際上就是一個宏。
(def echo-chan (chan)) (go (println (<! echo-chan))) (>!! echo-chan "ketchup") ; prints ketchup
這基本是我近幾個月所學習到關於函數式編程的所有內容。我很但願能幫助到他人(尤爲是 JavaScript 社區中的)編寫更好的代碼帶來更加經驗的東西!
就像關於 Haskell 和 Clojure 永無休止的爭論同樣,我認爲很難說哪個更好,由於它們是不一樣的。Haskell 是函數式編程的根本。使用 Haskell 的人會直接稱本身是編程的基要派。Haskell 是苛嚴的、明確的、堅如盤石的,而且難以想象的快和可靠。Clojure 更具延展性、抽象和賦權使能的。你能夠在 Clojure 中作任何事情,由於它寫在 JVM 上(作你可以在 Java 中作的任何事)。在 Clojure 中,你能夠構建 Java 這十多年來留下的全部東西以及檢測 Java 算法。Clojure 具有了獨特的開發者文化,在它背後有很酷的類庫,像 Overtone 和 Quill。
在 JavaScript 世界,我很期待讓事情朝向純函數的領域發展。我不再想看到 「this」了。還有讓咱們養成值使用 const 類型的習慣,而不是使用可變的 var 或者 let。
我很是喜歡的2個 JavaScript 庫是 Ramda 和 Flyd。不過 Ramda 不是延時計算的而且對 Immutable.js 不友好。我很期待看到一個結合了全部這些概念的類庫——持久化、共享、不可變數據結構,而且柯里化、延時運算,有不少有用的功能。
固然,我更但願可以看到一個類庫使用一個更加連續的語言來闡述這些東西——新的 ES2015 API,好比,使用 .then()
而不是 .map()
,儘管 Promise 本質上就是一個 monad!也就是說,你無法在原生的 promise 中使用 Ramda 來處理數據,由於 R.map
不會生效。我認爲恰當的命名 Fantasyland 規範頗有必要,由於它嘗試將全部的編程語言的數據結構統一。若是全部的這些類庫,好比 Ramda、Lodash、Lazy.js、Immutable.js,甚至像 promise 這樣的原生數據,都使用了這種語言,咱們將可以複用更多的代碼。你就能夠將咱們原生的 JavaScript 數組使用 Immutable.js 列表來包裹,而沒必要去重寫 Ramda 或者 Lazy.js 中全部的數據處理代碼。