其實這篇文章有點標題黨了,因為函數語言程式設計是一個非常大的課題。而標題裡的“真”聽起來就有一股濃濃的中二氣息。
沒錯,這篇文章是函式式裝逼系列(1)(2)的進階版。函數語言程式設計是很火的,然而現在網上很多入門級的JS函數語言程式設計教程(甚至書)都太水了,以為用用forEach
用用map
用用reduce
用用immutable
就叫函數語言程式設計了。
Too young. Too simple.
本著搞一個大新聞的目的,我又開了這個無底天坑。當然一切都是從學習與娛樂的目的出發(這兩件事其實並不衝突嘛),請勿評論本文中程式碼的實用價值。
瑞迪?黑喂狗!
0. 開篇
背景
要實現“真·函數語言程式設計”,就要玩的徹底一點,必須揮刀自宮先禁用JavaScript裡的大量語言特性,目前我能想到的有:
- 禁用
var
/let
,所有東西都用const
定義,也就是說無變數,強制immutable。 - 禁用分號,也就是不讓“順序執行”,解除程式式程式設計正規化。
你不是不愛寫分號嗎,這次徹底不需要寫了 - 禁用
if/else
,但允許用條件表示式condition ? expr1 : expr2
。 - 禁用
for/while/do-while
。 - 禁用
prototype
和this
來解除JS中的物件導向程式設計正規化。 - 禁用
function
和return
關鍵字,僅用lambda表示式來程式設計(JS裡叫箭頭函式)。 - 禁用多引數函式,只允許使用單個引數,相當於強行curry化
但是為了更可讀(其實也可讀不到哪去),我們允許使用大量ES6的新特性,比如箭頭函式(lambda表示式)、解構、引數解構等等。如果你想玩這些特性,建議使用最新版的Firefox,或者node.js 4.0或更高版本加上--harmony --harmony_modules --harmony_destructuring
等引數。
因為文中會用到一些ES6的特性,可能有的同學還不太清楚,所以這裡花一小點篇幅簡單的挑重點介紹一下:
箭頭函式
其他語言裡面一般叫做lambda表示式,其實我個人當然是喜歡這個名字,但是因為ES6的語言規範裡就把它管叫箭頭函式,自然文中還是會盡量這麼說。
箭頭函式的基本定義方式是:
1 2 3 |
(引數列表) => { 函式體 } |
當只有一個引數的時候,可以省略括號,寫成
1 2 3 |
引數名 => { 函式體 } |
當函式體是一個表示式(而不是段落)的時候,可以隱式return
,寫成
1 |
引數名 => 返回表示式 |
由於我們的“真·函數語言程式設計”是禁用程式式程式設計的,不存在段落,於是你可以見到下文中幾乎所有的箭頭函式都是最簡單的形式,例如x => x * 2
。
箭頭函式可以返回函式,並且在返回函式的時候,它也可以隱式return
,因此可以像haskell一樣構建curry風格的函式,如
1 |
const add = x => y => x + y |
用傳統的風格來“翻譯”上面的add
函式,就是
1 2 3 4 5 |
function add(x) { return function(y) { return x + y } } |
呼叫的時候自然也是使用curry風格的逐個傳參add(5)(3)
,結果就是8。
解構
解構是現代程式語言當中一個非常非常甜的語法糖,有時候我們為了實現多返回值,可能會返回一個陣列,或者一個KV,這裡以陣列為例
1 |
const pair = a => b => [a, b] |
我們可以用解構一次性將陣列中的元素分別賦值到多個變數如
1 |
const [a, b] = pair('first')('second') // a是'first',b是'second' |
引數結構就是在定義函式引數的時候使用結構
1 2 3 |
const add = ([a, b]) => a + b add([5, 3]) |
在add
函式裡面,陣列[5, 3]
可以被自動解構成a
和b
兩個值。陣列解構有一個高階的“剩餘值”用法:
1 |
const [first, ...rest] = [1, 2, 3, 4, 5] // first是1,rest是[2, 3, 4, 5] |
可以把“剩餘值”解構到一個陣列,這裡叫rest
。
關於解構語法的更多趣聞,可以看看我之前的一篇部落格。
OK,前戲就到這裡,下面進入主題。
1. 實現迴圈
實現for迴圈遍歷陣列
指令式程式設計當中,迴圈是最基本的控制流結構之一了,基本的for
迴圈大概是這樣:
1 2 3 |
for (var i = 0; i < arr.length; i++) { // ... } |
看見了var i
和i++
了嗎?因為不讓用變數,所以在“真·函數語言程式設計”當中,這樣做是行不通的。
函式式語言當中使用遞迴實現迴圈。首先拆解一下迴圈的要素:
1 2 3 |
for (初始化; 終止條件; 迭代操作) { 迭代體 } |
使用遞迴來實現的話,無外乎也就是把迭代終止換成遞迴終止。也就是說,只要有上面4個要素,就可以構造出for
迴圈。首先將問題簡化,我們只想遍歷一個陣列,首先定義一個迭代函式loop
1 2 3 4 5 6 |
function loop_on_array(arr, body, i) { if (i < arr.length) { body(arr[i]) loop_on_array(arr, body, i + 1) } } |
上面的函式有幾個地方不滿足“真·函數語言程式設計”的需要:
- 使用了
function
定義:這個最簡單,改成箭頭函式就行了 - 使用了多個引數:這個可以簡單的通過curry化來解決
- 使用了
if/else
:這個可以簡單的通過條件表示式來解決 - 使用了順序執行,也就是
body(i)
和loop(arr, body, i + 1)
這兩句程式碼使用了順序執行
為了解除順序執行,我們可以使用像“回撥函式”一樣的思路來解決這個問題,也就是說讓body
多接收一個引數next
,表示它執行完後的下一步操作,body
將會把自己的返回值以引數的形式傳給next
1 2 |
const body = item => next => next(do_something_with(item)) |
這樣需要修改body
是不爽的,因此可以將其進行抽象,我們寫一個two_steps
函式來組合兩步操作
1 2 |
const two_steps = step1 => step2 => param => step2(step1(param)) |
這樣,上面的兩行順序執行程式碼就變成了
1 |
two_steps (body) (_ => loop_on_array(arr, body, i + 1)) (arr[i]) |
注意中間那個引數它是一個函式,而並不是直接loop(arr, body, i + 1)
,它所接收的是body(arr[i])
的結果,但是它並不需要這個結果。函式式語言當中常常用_
來表示忽略不用的引數,我們的“真·函數語言程式設計”也會保留這個習慣。
這樣通過two_steps
來讓兩步操作能夠順序執行了,我們可以完成遍歷陣列的函式了
1 2 3 4 |
const loop_on_array = arr => body => i => i < arr.length ? two_steps (body) (_ => loop_on_array(arr)(body)(i + 1)) (arr[i]) : undefined |
呼叫的時候就是
1 |
loop_on_array ([1, 2, 3, 4, 5]) (item => console.log(item)) (0) |
但是你會發現最後那個(0)
其實是很醜的對吧,畢竟它總是0,還不能省略,所以我們還是可以通過構造一個新的函式來抽取遞迴內容
1 2 3 4 5 6 7 |
const _loop = arr => body => i => // 原來的loop_on_array的內容 const loop_on_array = arr => body => _loop(arr)(body)(0) // 呼叫 loop_on_array ([1, 2, 3, 4, 5]) (item => console.log(item)) |
實現map
在上面的遍歷的程式碼裡,我們用for
迴圈的套路來實現了對一個陣列的遍歷。這個思想其實還不算特別functional,要讓它逼格更高,不妨從map
這個函式來考慮。
map
就是把一個陣列arr
通過函式f
對映成另一個陣列result
,在Haskell裡面map
的經典定義方式是
1 2 3 |
map :: (a -> b) -> [a] -> [b] map f [] = [] map f (x:xs) = f x : map f xs |
簡單的說就是:
- 對於空陣列,
map
返回的結果是空陣列 - 對於非空陣列,將第一個元素使用f進行對映,結果作為返回值(陣列)的第一個元素,再對後面的剩餘陣列遞迴呼叫
map f xs
,作為返回值(陣列)的剩餘部分
直接將上面的程式碼“翻譯”成JS的話,大概是這個樣子
1 2 3 4 |
const map = f => arr => arr.length === 0 ? [] : [f(arr[0])].concat(map(f)(arr.slice(1))) |
利用解構語法來簡化的話大概是這個樣子
1 2 3 4 |
const map = f => ([x, ...xs]) => x === undefined ? [] : [f(x), ...map(f)(xs)] |
至於map
的用法大家其實都是比較熟悉的了,這裡就只做一個簡單的例子
1 2 3 4 |
const double = arr => map(x => x * 2)(arr) double([1, 2, 3, 4, 5]) // 結果是[2, 4, 6, 8, 10] |
實現sum
接下來需要實現一個sum
函式,對一個陣列中的所有元素求和,有了map
的遞迴思想,很容易寫出來sum
1 2 3 4 5 6 |
const sum = accumulator => ([x, ...xs]) => x === undefined ? accumulator : sum (x + accumulator) (xs) sum (0) ([1, 2, 3, 4, 5]) // 結果是15 |
依然會發現那個(0)
傳參是無比醜陋的,用一開始那個loop_on_array
相同的思想提取一個函式
1 2 3 4 5 6 |
const _sum = accumulator => ([x, ...xs]) => x === undefined ? accumulator : _sum (x + accumulator) (xs) const sum = xs => _sum (0) (xs) |
計劃通。
實現reduce
比較map
和sum
可以發現事實上他們是非常相似的:
- 都是把陣列拆解為(頭,剩餘)這個模式
- 都有一個“累加器”,在
sum
中體現為一個用來不斷求和的數值,在map
中體現為一個不斷被擴充的陣列 - 都通過“對頭部執行操作,將結果與累加器進行結合”這樣的模式來進行迭代
- 都以空陣列為迭代的終點
也許你覺得上面的map
實現並不是這個模式的,事實上它是的,不放把map
按照這個模式重新實現一下
1 2 3 4 5 6 7 |
const _map = f => accumulator => ([x, ...xs]) => x === undefined ? accumulator : _map (f) ([...accumulator, f(x)]) (xs) const map = f => xs => _map (f) ([]) (xs) map(x => x * 2)([1, 2, 3, 4, 5]) |
和sum
的模式驚人的一致對麼?這就是所謂的foldr
,foldr
是一個對這種迭代模式的抽象,我們把它簡單的描述成:
1 2 3 4 5 |
// foldr :: (a -> b -> b) -> b -> [a] -> b const foldr = f => accumulator => ([x, ...xs]) => x === undefined ? accumulator : f (x) (foldr(f)(accumulator)(xs)) |
其中f
是一個“fold函式”,接收兩個引數,第一個引數是“當前值”,第二個引數是“累加器”,f
返回一個更新後的“累加器”。foldr
會在陣列上迭代,不斷呼叫f
以更新累加器,直到遇到空陣列,迭代完成,則返回累加器的最後值。
下面我們用foldr
來分別實現map
和sum
1 2 3 4 5 |
const map = f => foldr (x => acc => [f(x), ...acc]) ([]) const sum = foldr (x => acc => x + acc) (0) map (x => x * 2) ([1, 2, 3, 4, 5]) // 結果是[2, 4, 6, 8, 10] sum ([1, 2, 3, 4, 5]) // 結果是15 |
這時候你會發現foldr的定義其實就是JavaScript裡自帶的reduce
函式,沒錯這倆定義是一樣的,通過foldr
或者說叫reduce
抽象,我們實現了對陣列的“有狀態遍歷”,相比於上面的loop_on_array
則是“無狀態遍歷”,因為“累加器”作為狀態,是在不斷的被修改的(嚴格的說它不是被修改了,而是用一個新值取代了它)。
用foldr
實現的sum
非常形象,就像把攤成一列的撲克牌一張一張疊起來一樣。
“有狀態”當然可以實現“無狀態”,不care狀態不就行了嗎,所以使用foldr
來實現loop_on_array
也是完全沒問題的
1 2 3 |
const loop_on_array = f => foldr(x => _ => f(x)) (undefined) loop_on_array (x => console.log(x)) ([1, 2, 3, 4, 5]) |
呃,等等,為什麼輸出順序是反的?是54321呢?很明顯foldr
中的r
就表示它是“右摺疊”,從遞迴的角度很好理解,無外乎先進後出嘛。所以要實現“左摺疊”自然也有foldl
函式(這裡的左摺疊右摺疊表示摺疊的起始方向,就跟東風北風一個道理):
1 2 3 4 5 |
// foldl :: (b -> a -> b) -> b -> [a] -> b const foldl = f => accumulator => ([x, ...xs]) => x === undefined ? accumulator : foldl (f) (f(accumulator)(x)) (xs) |
用它重新實現loop_on_array
,注意這次f
的引數順序和foldr
是相反的,這次是accumulator
在前、x
在後,這樣能更形象的表達“左摺疊”的概念
1 2 3 |
const loop_on_array = f => foldl(_ => x => f(x)) (undefined) loop_on_array (x => console.log(x)) ([1, 2, 3, 4, 5]) |
迴圈小結
在第一個for
迴圈的例子中,我們使用了指令式程式設計的思路,通過構造“順序執行”組合函式來讓“迴圈體”和“下一次迭代”這兩個操作能夠順序執行。
這個思路毫無疑問是行得通的,但是似乎又有點指令式程式設計思想根深蒂固的感覺,於是在後面的例子裡面,通過map
、sum
抽象出foldr
和foldl
函式,實現了“有狀態遍歷”。
foldr/foldl
是對陣列(列表)操作的一個高度抽象,它非常非常強大。
而在第一個例子實現for
迴圈的過程中,我們費了老鼻子勁才構造出的“順序執行”難道就這麼被拋棄了?其實並沒有,因為foldr/foldl
抽象的是對列表的操作,而“順序執行”則是更為普適的將兩個操作的順序進行安排的方式。至於它又有什麼進一步的應用,看來只能下一篇文章才能繼續寫了。
下集預告
不小心發現光是迴圈已經寫了這麼多……所以我覺得還是分開寫吧,下一篇文章將會介紹如何實現“區域性變數”和“狀態”。