JavaScript 函數式編程(二)

slide 地址javascript

3、能夠,這很函數式~

this-is-very-fp

3.1.函數是一等公民!

3.1.1.濫用匿名函數

其實常常寫 JavaScript 的人可能潛移默化地已經接受了這個觀念,例如你能夠像對待任何其餘數據類型同樣對待函數——把它們存在數組裏,看成參數傳遞,賦值給變量.等等。html

然而,經常能夠看到濫用匿名函數的現象...java

// 太傻了
const getServerStuff = function (callback) {
  return ajaxCall(function (json) {
    return callback(json)
  })
}

// 這纔像樣
const getServerStuff = ajaxCall

// 下面來推導一下...
const getServerStuff
  === callback => ajaxCall(json => callback(json))
  === callback => ajaxCall(callback)
  === ajaxCall

// from JS函數式編程指南

再來看一個例子...git

const BlogController = (function () {
  const index = function (posts) {
    return Views.index(posts)
  }

  const show = function (post) {
    return Views.show(post)
  }

  const create = function (attrs) {
    return Db.create(attrs)
  }

  const update = function (post, attrs) {
    return Db.update(post, attrs)
  }

  const destroy = function (post) {
    return Db.destroy(post)
  }

  return { index, show, create, update, destroy }
})()

// 以上代碼 99% 都是多餘的...

const BlogController = {
  index: Views.index,
  show: Views.show,
  create: Db.create,
  update: Db.update,
  destroy: Db.destroy,
}

// ...或者直接所有刪掉
// 由於它的做用僅僅就是把視圖(Views)和數據庫(Db)打包在一塊兒而已。

// from JS函數式編程指南

3.1.2.爲什麼鍾愛一等公民?

以上那種多包一層的寫法最大的問題就是,一旦內部函數須要新增或修改參數,那麼包裹它的函數也要改...ajax

// 原始函數
httpGet('/post/2', function (json) {
  return renderPost(json)
})

// 假如須要多傳遞一個 err 參數
httpGet('/post/2', function (json, err) {
  return renderPost(json, err)
})

// renderPost 將會在 httpGet 中調用,
// 想要多少參數,想怎麼改都行
httpGet('/post/2', renderPost)

3.1.3.提升函數複用率

除了上面說的避免使用沒必要要的中間函數包裹之外,對於函數參數的起名也很重要,儘可能編寫通用參數的函數。數據庫

// 只針對當前的博客
const validArticles = function (articles) {
  return articles.filter(function (article) {
    return article !== null && article !== undefined
  })
}

// 通用性好太多
const compact = function(xs) {
  return xs.filter(function (x) {
    return x !== null && x !== undefined
  })
}

以上例子說明了在命名的時候,咱們特別容易把本身限定在特定的數據上(本例中是 articles)。這種現象很常見,也是重複造輪子的一大緣由。編程

3.1.4.this

this-js

在函數式編程中,其實根本用不到 this...json

但這裏並非說要避免使用 this
(江來報道上出了誤差...識得唔識得?)redux

3.2.柯里化(curry)

3.2.1.柯里化概念

把接受多個參數的函數變換成一系列接受單一參數(從最初函數的第一個參數開始)的函數的技術。(注意是單一參數)
import { curry } from 'lodash'

const add = (x, y) => x + y
const curriedAdd = curry(add)

const increment = curriedAdd(1)
const addTen = curriedAdd(10)

increment(2) // 3
addTen(2) // 12
柯里化是由 Christopher Strachey 以邏輯學家 Haskell Curry 命名的,
固然編程語言 Haskell 也是源自他的名字,
雖然柯里化是由 Moses Schnfinkel 和 Gottlob Frege 發明的。

3.2.2.柯里化 VS 偏函數應用(partial application)

In computer science, partial application (or partial function application) refers to the process of fixing a number of arguments to a function, producing another function of smaller arity.

by wikipediasegmentfault

偏函數應用簡單來講就是:一個函數,接受一個多參數的函數且傳入部分參數後,返回一個須要更少參數的新函數。

柯里化通常和偏函數應用相伴出現,但這二者是不一樣的概念:

import { curry, partial } from 'lodash'

const add = (x, y, z) => x + y + z

const curriedAdd = curry(add)       // <- 只接受一個函數

const addThree = partial(add, 1, 2) // <- 不只接受函數,還接受至少一個參數
  === curriedAdd(1)(2)              // <- 柯里化每次都返回一個單參函數

簡單來講,一個多參函數(n-ary),柯里化後就變成了 n * 1-ary,而偏函數應用了 x 個參數後就變成了 (n-x)-ary

3.2.3.柯里化的實現

雖然從理論上說柯里化應該返回的是一系列的單參函數,但在實際的使用過程當中爲了像偏函數應用那樣方便的調用,因此這裏柯里化後的函數也能接受多個參數。

// 實現一個函數 curry 知足如下調用、
const f = (a, b, c, d) => { ... }
const curried = curry(f)

curried(a, b, c, d)
curried(a, b, c)(d)
curried(a)(b, c, d)
curried(a, b)(c, d)
curried(a)(b, c)(d)
curried(a)(b)(c, d)
curried(a, b)(c)(d)

很明顯第一反應是須要使用遞歸,這樣才能返回一系列的函數。而遞歸的結束條件就是接受了原函數數量的參數,因此重點就是參數的傳遞~

// ES5
var curry = function curry (fn, arr) {
  arr = arr || []

  return function () {
    var args = [].slice.call(arguments)
    var arg = arr.concat(args)

    return arg.length >= fn.length
      ? fn.apply(null, arg)
      : curry(fn, arg)
  }
}

// ES6
const curry = (fn, arr = []) => (...args) => (
  arg => arg.length >= fn.length
    ? fn(...arg)
    : curry(fn, arg)
)([...arr, ...args])

3.2.4.柯里化的意義

寫習慣了傳統編程語言的人的第一反應通常都是,柯里化這玩意兒有啥用咧?

柯里化和偏函數應用的主要意義就是固定一些咱們已知的參數,而後返回一個函數繼續等待接收那些未知的參數。

因此常見的使用場景之一就是高級抽象後的代碼複用。例如首先編寫一個多參數的通用函數,將其柯里化後,就能夠基於偏函數應用將其綁定不一樣的業務代碼。

// 定義通用函數
const converter = (
  toUnit,
  factor,
  offset = 0,
  input
) => ([
  ((offset + input) * factor).toFixed(2),
  toUnit,
].join(' '))

// 分別綁定不一樣參數
const milesToKm =
  curry(converter)('km', 1.60936, undefined)
const poundsToKg =
  curry(converter)('kg', 0.45460, undefined)
const farenheitToCelsius =
  curry(converter)('degrees C', 0.5556, -32)

-- from https://stackoverflow.com/a/6861858

你可能會反駁說其實也能夠不使用這些花裏胡哨的柯里化啊,偏函數應用啊什麼的東東,我就鐵頭娃愣頭青地直接懟也能實現以上的邏輯。(這一手皮的嘛,就不談了...)

function converter (ratio, symbol, input) {
  return (input * ratio).toFixed(2) + ' ' + symbol
}

converter(2.2, 'lbs', 4)
converter(1.62, 'km', 34)
converter(1.98, 'US pints', 2.4)
converter(1.75, 'imperial pints', 2.4)

-- from https://stackoverflow.com/a/32379766

然而二者的區別在於,假如函數 converter 所需的參數沒法同時獲得,對柯里化的方式來講沒有影響,由於已經用閉包保存住了已知參數。然後者可能就須要使用變量暫存或其餘方法來保證同時獲得全部參數

3.3.函數組合(compose)

3.3.1.組合的概念

函數組合就是將兩個或多個函數結合起來造成一個新函數。

就好像將一節一節的管道鏈接起來,原始數據通過這一節一節的管道處理以後獲得最終結果。

提及來很玄乎,其實就是假設有一個函數 f 和另外一個函數 g,還有數據 x,通過計算最終結果就是 f(g(x))。

composition-of-functions

在高中數學中咱們應該都學到過複合函數。

若是 y 是 w 的函數,w 又是 x 的函數,即 y = f(w), w = g(x),那麼 y 關於 x 的函數 y = f[g(x)] 叫作函數 y = f(w) 和 w = g(x) 的複合函數。其中 w 是中間變量,x 是自變量,y 是函數值。

此外在離散數學裏,應該還學過複合函數 f(g(h(x))) 可記爲 (f ○ g ○ h)(x)。(其實這就是函數組合)

3.3.2.組合的實現

function-composition

const add1 = x => x + 1
const mul3 = x => x * 3
const div2 = x => x / 2

div2(mul3(add1(add1(0)))) // 結果是 3,但這樣寫可讀性太差了

const operate = compose(div2, mul3, add1, add1)
operate(0) // => 至關於 div2(mul3(add1(add1(0))))
operate(2) // => 至關於 div2(mul3(add1(add1(2))))

// redux 版
const compose = (...fns) => {
  if (fns.length === 0) return arg => arg
  if (fns.length === 1) return fns[0]

  return fns.reduce((a, b) => (...args) => a(b(...args)))
}

// 一行版,支持多參數,但必須至少傳一個函數
const compose = (...fns) => fns.reduceRight((acc, fn) => (...args) => fn(acc(...args)))

// 一行版,只支持單參數,但支持不傳函數
const compose = (...fns) => arg => fns.reduceRight((acc, fn) => fn(acc), arg)

3.3.3.Pointfree

起名字是一個很麻煩的事兒,而 Pointfree 風格可以有效減小大量中間變量的命名。

Pointfree 即不使用所要處理的值,只合成運算過程。中文能夠譯做"無值"風格。

from Pointfree 編程風格指南

請看下面的例子。(注意理解函數是一等公民和函數組合的概念)

const addOne = x => x + 1
const square = x => x * x

上面是兩個簡單函數 addOnesquare,如今把它們合成一個運算。

const addOneThenSquare = compose(square, addOne)
addOneThenSquare(2) //  9

上面代碼中,addOneThenSquare 是一個合成函數。定義它的時候,根本不須要提到要處理的值,這就是 Pointfree

// 非 Pointfree,由於提到了數據:word
const snakeCase = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_')
}

// Pointfree
const snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase)

然而惋惜的是,以上很 Pointfree 的代碼會報錯,由於在 JavaScript 中 replacetoLowerCase 函數是定義在 String 的原型鏈上的...

此外有的庫(如 Underscore、Lodash...)把須要處理的數據放到了第一個參數。

const square = n => n * n;

_.map([4, 8], square) // 第一個參數是待處理數據

R.map(square, [4, 8]) // 通常函數式庫都將數據放在最後

這樣會有一些很不函數式的問題,即:

1.沒法柯里化後偏函數應用

2.沒法進行函數組合

3.沒法擴展 map(reduce 等方法) 到各類其餘類型

(詳情參閱參考文獻之《Hey Underscore, You're Doing It Wrong!》)

3.3.4.函數組合的意義

首先讓咱們從抽象的層次來思考一下:一個 app 由什麼組成?( 固然是由 a、p、p 三個字母組成的啦

一個應用其實就是一個長時間運行的進程,並將一系列異步的事件轉換爲對應結果。

start-transform-effect

  • 一個 start 能夠是:

    • 開啓應用
    • DOM 事件(DOMContentLoaded, onClick, onSubmit...)
    • 接收到的 HTTP 請求
    • 返回的 HTTP 響應
    • 查詢數據庫的結果
    • WebSocket 消息
    • ..
  • 一個 end 或者說是 effect 能夠是:

    • 渲染或更新 UI
    • 觸發一個 DOM 事件
    • 建立一個 HTTP 請求
    • 返回一個 HTTP 響應
    • 保存數據到 DB
    • 發送 WebSocket 消息
    • ...

那麼在 start 和 end 之間的東東,咱們能夠看作數據流的變換(transformations)。這些變換具體的說就是一系列的變換動詞的結合。

這些動詞描述了這些變換作了些什麼(而不是怎麼作)如:

  • filter
  • slice
  • map
  • reduce
  • concat
  • zip
  • fork
  • flatten
  • ...

固然平常編寫的程序中通常不會像以前的例子那樣的簡單,它的數據流多是像下面這樣的...

transformation-1
transformation-2
transformation-3
transformation-4

而且,若是這些變換在編寫時,遵照了基本的函數式規則和最佳實踐(純函數,無反作用,引用透明...)。

那麼這些變換能夠被輕易地重用、改寫、維護、測試,這也就意味着編寫的應用能夠很方便地進行擴展,而這些變換結合的基礎正是函數組合

3.4.Hindley-Milner 類型簽名

3.4.1.基本概念

先來看一些例子~

// strLength :: String -> Number
const strLength = s => s.length

// join :: String -> [String] -> String
const join = curry((what, xs) => xs.join(what))

// match :: Regex -> String -> [String]
const match = curry((reg, s) => s.match(reg))

// replace :: Regex -> String -> String -> String
const replace = curry((reg, sub, s) => s.replace(reg, sub))

在 Hindley-Milner 系統中,函數都寫成相似 a -> b 這個樣子,其中 a 和 b 是任意類型的變量。

以上例子中的多參函數,可能看起來比較奇怪,爲啥沒有括號?

例如對於 match 函數,咱們將其柯里化後,徹底能夠把它的類型簽名這樣分組:

// match :: Regex -> (String -> [String])
const match = curry((reg, s) => s.match(reg))

如今咱們能夠看出 match 這個函數首先接受了一個 Regex 做爲參數,返回一個從 String[String] 的函數。

由於柯里化,形成的結果就是這樣:給 match 函數一個 Regex 參數後,獲得一個新函數,它可以接着處理 String 參數。

假設咱們將第一個參數傳入 /holiday/ig,那麼代碼就變成了這樣:

// match :: Regex -> (String -> [String])
const match = curry((reg, s) => s.match(reg))

// onHoliday :: String -> [String]
const onHoliday = match(/holiday/ig)

能夠看出柯里化後每傳一個參數,就會彈出類型簽名最前面的那個類型。因此 onHoliday 就是已經有了 Regex 參數的 match 函數。

// replace :: Regex -> (String -> (String -> String))
const replace = curry((reg, sub, s) => s.replace(reg, sub))

一樣的思路來看最後一個函數 replace,能夠看出爲 replace 加上這麼多括號未免有些多餘。

因此這裏的括號是徹底能夠省略的,若是咱們願意,甚至能夠一次性把全部的參數都傳進來。

再來看幾個例子~

//  id :: a -> a
const id = x => x

//  map :: (a -> b) -> [a] -> [b]
const map = curry((f, xs) => xs.map(f))

這裏的 id 函數接受任意類型的 a 並返回同一個類型的數據(話說 map 的簽名裏爲啥加了括號呢~)。

和普通代碼同樣,咱們也能夠在類型簽名中使用變量。把變量命名爲 a 和 b 只是一種約定俗成的習慣,你可使用任何你喜歡的名稱。但對於相同的變量名,其類型必定相同。

這是很是重要的一個原則,因此咱們必須重申:a -> b 能夠是從任意類型的 a 到任意類型的 b,可是 a -> a 必須是同一個類型。

例如,id 能夠是 String -> String,也能夠是 Number -> Number,但不能是 String -> Bool。

類似地,map 也使用了變量,只不過這裏的 b 可能與 a 類型相同,也可能不相同。

咱們能夠這麼理解:map 接受兩個參數,第一個是從任意類型 a 到任意類型 b 的函數;第二個是一個數組,元素是任意類型的 a;map 最後返回的是一個類型 b 的數組。

辨別類型和它們的含義是一項重要的技能,這項技能可讓你在函數式編程的路上走得更遠。不只論文、博客和文檔等更易理解,類型簽名自己也基本上可以告訴你它的函數性(functionality)。要成爲一個可以熟練讀懂類型簽名的人,你得勤於練習;不過一旦掌握了這項技能,你將會受益無窮,不讀手冊也能獲取大量信息。

最後再舉幾個複雜的例子~~

//  head :: [a] -> a
const head = xs => xs[0]

//  filter :: (a -> Bool) -> [a] -> [a]
const filter = curry((f, xs) => xs.filter(f))

//  reduce :: (b -> a -> b) -> b -> [a] -> b
const reduce = curry((f, x, xs) => xs.reduce(f, x))

reduce 多是以上簽名裏讓人印象最爲深入的一個,同時也是最複雜的一個了,因此若是你理解起來有困難的話,也沒必要氣餒。爲了知足你的好奇心,我仍是試着解釋一下吧;儘管個人解釋遠遠不如你本身經過類型簽名理解其含義來得有教益。

不保證解釋徹底正確...(譯者注:此處原文是「here goes nothing」,通常用於人們在作沒有把握的事情以前說的話。)

注意看 reduce 的簽名,能夠看到它的第一個參數是個函數(因此用了括號),這個函數接受一個 b 和一個 a 並返回一個 b。

那麼這些 a 和 b 是從哪來的呢?

很簡單,簽名中的第二個和第三個參數就是 b 和元素爲 a 的數組,因此惟一合理的假設就是這裏的 b 和每個 a 都將傳給前面說的函數做爲參數。咱們還能夠看到,reduce 函數最後返回的結果是一個 b,也就是說,reduce 的第一個參數函數的輸出就是 reduce 函數的輸出。知道了 reduce 的含義,咱們纔敢說上面關於類型簽名的推理是正確的。

3.4.2.參數態(Parametricity)

一旦引入一個類型變量,就會出現一個奇怪的特性叫作參數態。

這個特性代表,函數將會以一種統一的行爲做用於全部的類型。

// head :: [a] -> a

以 head 函數爲例,能夠看到它接受 [a] 返回 a。咱們除了知道參數是個數組,其餘的一律不知;因此函數的功能就只限於操做這個數組上。

在它對 a 一無所知的狀況下,它可能對 a 作什麼操做呢?

換句話說,a 告訴咱們它不是一個特定的類型,這意味着它能夠是任意類型;那麼咱們的函數對每個可能的類型的操做都必須保持統一,這就是參數態的含義。

要讓咱們來猜想 head 的實現的話,惟一合理的推斷就是它返回數組的第一個,或者最後一個,或者某個隨機的元素;固然,head 這個命名已經告訴咱們了答案。

再看一個例子:

// reverse :: [a] -> [a]

僅從類型簽名來看,reverse 可能的目的是什麼?

再次強調,它不能對 a 作任何特定的事情。它不能把 a 變成另外一個類型,或者引入一個 b;這都是不可能的。

那它能夠排序麼?我以爲不行,我以爲很普通~,沒有足夠的信息讓它去爲每個可能的類型排序。

它能從新排列麼?我以爲還 ok,但它必須以一種可預料的方式達成目標。另外,它也有可能刪除或者重複某一個元素。

重點是,無論在哪一種狀況下,類型 a 的多態性(polymorphism)都會大幅縮小 reverse 函數可能的行爲的範圍。

hoogle

這種「可能性範圍的縮小」(narrowing of possibility)容許咱們利用相似 Hoogle 這樣的類型簽名搜索引擎去搜索咱們想要的函數。類型簽名所能包含的信息量真的很是大。

3.4.3.自由定理(Free Theorems)

類型簽名除了可以幫助咱們推斷函數可能的實現,還可以給咱們帶來自由定理。下面是兩個直接從 Wadler 關於此主題的論文 中隨機選擇的例子:

// head :: [a] -> a
compose(f, head) === compose(head, map(f))

// filter :: (a -> Bool) -> [a] -> [a]
// 其中 f 和 p 是謂詞函數
compose(map(f), filter(compose(p, f))) ===
  compose(filter(p), map(f))

不用寫一行代碼你也能理解這些定理,它們直接來自於類型自己。

第一個例子中,等式左邊說的是,先獲取數組的頭部(譯者注:即第一個元素),而後對它調用函數 f;等式右邊說的是,先對數組中的每個元素調用 f,而後再取其返回結果的頭部。這兩個表達式的做用是相等的,可是前者要快得多。

第二個例子 filter 也是同樣。等式左邊是說,先組合 f 和 p 檢查哪些元素要過濾掉,而後再經過 map 實際調用 f(別忘了 filter 是不會改變數組中元素的,這就保證了 a 將保持不變);等式右邊是說,先用 map 調用 f,而後再根據 p 過濾元素。這二者也是相等的。

你可能會想,這不是常識麼。但計算機是沒有常識的。實際上,計算機必需要有一種形式化方法來自動進行相似的代碼優化。數學提供了這種方法,可以形式化直觀的感受,這無疑對死板的計算機邏輯很是有用。

以上只是兩個例子,但它們傳達的定理倒是普適的,能夠應用到全部的多態性類型簽名上。在 JavaScript 中,你能夠藉助一些工具來聲明重寫規則,也能夠直接使用 compose 函數來定義重寫規則。總之,這麼作的好處是顯而易見且唾手可得的,可能性則是無限的。

3.4.4.類型約束

最後要注意的一點是,簽名也能夠把類型約束爲一個特定的接口(interface)。

// sort :: Ord a => [a] -> [a]

胖箭頭左邊代表的是這樣一個事實:a 必定是個 Ord 對象,或者說 a 必需要實現 Ord 接口。

Ord 究竟是什麼?它是從哪來的?在一門強類型語言中,它可能就是一個自定義的接口,可以讓不一樣的值排序。經過這種方式,咱們不只可以獲取關於 a 的更多信息,瞭解 sort 函數具體要幹什麼,並且還能限制函數的做用範圍。咱們把這種接口聲明叫作類型約束(type constraints)。

// assertEqual :: (Eq a, Show a) => a -> a -> Assertion

這個例子中有兩個約束:Eq 和 Show。它們保證了咱們能夠檢查不一樣的 a 是否相等,並在有不相等的狀況下打印出其中的差別。

3.4.5.類型簽名的做用

總結一下類型簽名的做用就是:

  • 聲明函數的輸入和輸出
  • 讓函數保持通用和抽象
  • 能夠用於編譯時候檢查
  • 代碼最好的文檔

參考資料

相關文章

以上 to be continued...

相關文章
相關標籤/搜索