使用JavaScript實現“真·函數語言程式設計”

發表於2016-01-18

其實這篇文章有點標題黨了,因為函數語言程式設計是一個非常大的課題。而標題裡的“真”聽起來就有一股濃濃的中二氣息。

沒錯,這篇文章是函式式裝逼系列(1)(2)的進階版。函數語言程式設計是很火的,然而現在網上很多入門級的JS函數語言程式設計教程(甚至書)都太水了,以為用用forEach用用map用用reduce用用immutable就叫函數語言程式設計了。

Too young. Too simple.

本著搞一個大新聞的目的,我又開了這個無底天坑。當然一切都是從學習與娛樂的目的出發(這兩件事其實並不衝突嘛),請勿評論本文中程式碼的實用價值。

瑞迪?黑喂狗!

 

0. 開篇

背景

要實現“真·函數語言程式設計”,就要玩的徹底一點,必須揮刀自宮先禁用JavaScript裡的大量語言特性,目前我能想到的有:

  1. 禁用var/let,所有東西都用const定義,也就是說無變數,強制immutable。
  2. 禁用分號,也就是不讓“順序執行”,解除程式式程式設計正規化你不是不愛寫分號嗎,這次徹底不需要寫了
  3. 禁用if/else,但允許用條件表示式condition ? expr1 : expr2
  4. 禁用for/while/do-while
  5. 禁用prototypethis解除JS中的物件導向程式設計正規化
  6. 禁用functionreturn關鍵字,僅用lambda表示式來程式設計(JS裡叫箭頭函式)。
  7. 禁用多引數函式,只允許使用單個引數,相當於強行curry化

但是為了更可讀(其實也可讀不到哪去),我們允許使用大量ES6的新特性,比如箭頭函式(lambda表示式)、解構、引數解構等等。如果你想玩這些特性,建議使用最新版的Firefox,或者node.js 4.0或更高版本加上--harmony --harmony_modules --harmony_destructuring等引數。

因為文中會用到一些ES6的特性,可能有的同學還不太清楚,所以這裡花一小點篇幅簡單的挑重點介紹一下:

箭頭函式

其他語言裡面一般叫做lambda表示式,其實我個人當然是喜歡這個名字,但是因為ES6的語言規範裡就把它管叫箭頭函式,自然文中還是會盡量這麼說。

箭頭函式的基本定義方式是:

當只有一個引數的時候,可以省略括號,寫成

當函式體是一個表示式(而不是段落)的時候,可以隱式return,寫成

由於我們的“真·函數語言程式設計”是禁用程式式程式設計的,不存在段落,於是你可以見到下文中幾乎所有的箭頭函式都是最簡單的形式,例如x => x * 2

箭頭函式可以返回函式,並且在返回函式的時候,它也可以隱式return,因此可以像haskell一樣構建curry風格的函式,如

用傳統的風格來“翻譯”上面的add函式,就是

呼叫的時候自然也是使用curry風格的逐個傳參add(5)(3),結果就是8。

解構

解構是現代程式語言當中一個非常非常甜的語法糖,有時候我們為了實現多返回值,可能會返回一個陣列,或者一個KV,這裡以陣列為例

我們可以用解構一次性將陣列中的元素分別賦值到多個變數如

引數結構就是在定義函式引數的時候使用結構

add函式裡面,陣列[5, 3]可以被自動解構成ab兩個值。陣列解構有一個高階的“剩餘值”用法:

可以把“剩餘值”解構到一個陣列,這裡叫rest

關於解構語法的更多趣聞,可以看看我之前的一篇部落格


OK,前戲就到這裡,下面進入主題。

1. 實現迴圈

實現for迴圈遍歷陣列

指令式程式設計當中,迴圈是最基本的控制流結構之一了,基本的for迴圈大概是這樣:

看見了var ii++了嗎?因為不讓用變數,所以在“真·函數語言程式設計”當中,這樣做是行不通的。

函式式語言當中使用遞迴實現迴圈。首先拆解一下迴圈的要素:

使用遞迴來實現的話,無外乎也就是把迭代終止換成遞迴終止。也就是說,只要有上面4個要素,就可以構造出for迴圈。首先將問題簡化,我們只想遍歷一個陣列,首先定義一個迭代函式loop

上面的函式有幾個地方不滿足“真·函數語言程式設計”的需要:

  1. 使用了function定義:這個最簡單,改成箭頭函式就行了
  2. 使用了多個引數:這個可以簡單的通過curry化來解決
  3. 使用了if/else:這個可以簡單的通過條件表示式來解決
  4. 使用了順序執行,也就是body(i)loop(arr, body, i + 1)這兩句程式碼使用了順序執行

為了解除順序執行,我們可以使用像“回撥函式”一樣的思路來解決這個問題,也就是說讓body多接收一個引數next,表示它執行完後的下一步操作,body將會把自己的返回值以引數的形式傳給next

這樣需要修改body是不爽的,因此可以將其進行抽象,我們寫一個two_steps函式來組合兩步操作

這樣,上面的兩行順序執行程式碼就變成了

注意中間那個引數它是一個函式,而並不是直接loop(arr, body, i + 1),它所接收的是body(arr[i])的結果,但是它並不需要這個結果。函式式語言當中常常用_來表示忽略不用的引數,我們的“真·函數語言程式設計”也會保留這個習慣。

這樣通過two_steps來讓兩步操作能夠順序執行了,我們可以完成遍歷陣列的函式了

呼叫的時候就是

但是你會發現最後那個(0)其實是很醜的對吧,畢竟它總是0,還不能省略,所以我們還是可以通過構造一個新的函式來抽取遞迴內容

實現map

在上面的遍歷的程式碼裡,我們用for迴圈的套路來實現了對一個陣列的遍歷。這個思想其實還不算特別functional,要讓它逼格更高,不妨從map這個函式來考慮。

map就是把一個陣列arr通過函式f對映成另一個陣列result,在Haskell裡面map的經典定義方式是

簡單的說就是:

  1. 對於空陣列,map返回的結果是空陣列
  2. 對於非空陣列,將第一個元素使用f進行對映,結果作為返回值(陣列)的第一個元素,再對後面的剩餘陣列遞迴呼叫map f xs,作為返回值(陣列)的剩餘部分

直接將上面的程式碼“翻譯”成JS的話,大概是這個樣子

利用解構語法來簡化的話大概是這個樣子

至於map的用法大家其實都是比較熟悉的了,這裡就只做一個簡單的例子

實現sum

接下來需要實現一個sum函式,對一個陣列中的所有元素求和,有了map的遞迴思想,很容易寫出來sum

依然會發現那個(0)傳參是無比醜陋的,用一開始那個loop_on_array相同的思想提取一個函式

計劃通。

實現reduce

比較mapsum可以發現事實上他們是非常相似的:

  1. 都是把陣列拆解為(頭,剩餘)這個模式
  2. 都有一個“累加器”,在sum中體現為一個用來不斷求和的數值,在map中體現為一個不斷被擴充的陣列
  3. 都通過“對頭部執行操作,將結果與累加器進行結合”這樣的模式來進行迭代
  4. 都以空陣列為迭代的終點

也許你覺得上面的map實現並不是這個模式的,事實上它是的,不放把map按照這個模式重新實現一下

sum的模式驚人的一致對麼?這就是所謂的foldrfoldr是一個對這種迭代模式的抽象,我們把它簡單的描述成:

其中f是一個“fold函式”,接收兩個引數,第一個引數是“當前值”,第二個引數是“累加器”,f返回一個更新後的“累加器”。foldr會在陣列上迭代,不斷呼叫f以更新累加器,直到遇到空陣列,迭代完成,則返回累加器的最後值。

下面我們用foldr來分別實現mapsum

這時候你會發現foldr的定義其實就是JavaScript裡自帶的reduce函式,沒錯這倆定義是一樣的,通過foldr或者說叫reduce抽象,我們實現了對陣列的“有狀態遍歷”,相比於上面的loop_on_array則是“無狀態遍歷”,因為“累加器”作為狀態,是在不斷的被修改的(嚴格的說它不是被修改了,而是用一個新值取代了它)。

foldr實現的sum非常形象,就像把攤成一列的撲克牌一張一張疊起來一樣。

“有狀態”當然可以實現“無狀態”,不care狀態不就行了嗎,所以使用foldr來實現loop_on_array也是完全沒問題的

呃,等等,為什麼輸出順序是反的?是54321呢?很明顯foldr中的r就表示它是“右摺疊”,從遞迴的角度很好理解,無外乎先進後出嘛。所以要實現“左摺疊”自然也有foldl函式(這裡的左摺疊右摺疊表示摺疊的起始方向,就跟東風北風一個道理):

用它重新實現loop_on_array,注意這次f的引數順序和foldr是相反的,這次是accumulator在前、x在後,這樣能更形象的表達“左摺疊”的概念

迴圈小結

在第一個for迴圈的例子中,我們使用了指令式程式設計的思路,通過構造“順序執行”組合函式來讓“迴圈體”和“下一次迭代”這兩個操作能夠順序執行。

這個思路毫無疑問是行得通的,但是似乎又有點指令式程式設計思想根深蒂固的感覺,於是在後面的例子裡面,通過mapsum抽象出foldrfoldl函式,實現了“有狀態遍歷”。

foldr/foldl是對陣列(列表)操作的一個高度抽象,它非常非常強大。

而在第一個例子實現for迴圈的過程中,我們費了老鼻子勁才構造出的“順序執行”難道就這麼被拋棄了?其實並沒有,因為foldr/foldl抽象的是對列表的操作,而“順序執行”則是更為普適的將兩個操作的順序進行安排的方式。至於它又有什麼進一步的應用,看來只能下一篇文章才能繼續寫了。

下集預告

不小心發現光是迴圈已經寫了這麼多……所以我覺得還是分開寫吧,下一篇文章將會介紹如何實現“區域性變數”和“狀態”。

相關文章