JavaScript 函數語言程式設計(二)

佯真愚發表於2019-03-03

slide 地址

三、可以,這很函式式~

this-is-very-fp

3.1.函式是一等公民!

3.1.1.濫用匿名函式

其實經常寫 JavaScript 的人可能潛移默化地已經接受了這個觀念,例如你可以像對待任何其他資料型別一樣對待函式——把它們存在陣列裡,當作引數傳遞,賦值給變數.等等。

然而,常常可以看到濫用匿名函式的現象...

// 太傻了
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函數語言程式設計指南
複製程式碼

再來看一個例子...

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.為何鍾愛一等公民?

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

// 原始函式
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...

但這裡並不是說要避免使用 this (江來報導上出了偏差...識得唔識得?)

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 wikipedia

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

柯里化一般和偏函式應用相伴出現,但這兩者是不同的概念:

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;這都是不可能的。

那它可以排序麼?我覺得不行,我覺得很普通~,沒有足夠的資訊讓它去為每一個可能的型別排序。

i-don't-think-so

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

i-think-it-is-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...

相關文章