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

發表於2016-01-18

上一篇文章使用JavaScript實現“真·函數語言程式設計”本來以為可以一次性寫完的,結果話癆本色,沒辦法,繼續填坑,這篇應該可以完結了,講道理嘛。

這篇當中將介紹如何在純函式式的限制之下實現“區域性變數”和“狀態”。

2. 實現區域性變數

首先考慮這樣一段程式碼

這是一段典型的指令式程式設計的程式碼,它最主要的問題就是區域性變數name

在上一篇文章的第一個實現對陣列遍歷的例子當中我們已經對“順序執行”初窺門徑,通過構造了一個two_steps函式,實現兩個步驟(函式)順序執行。

在這個構造過程當中,我們得到一個重要的思路,就是在函式式語言當中,如果你想“獲得”一個什麼東西,就構造一個新的函式,把它引數化。對於two_steps的例子而言,“想獲得”的是一個step2,就把它引數化了。

所以當需要“獲得”區域性變數的時候,自然而然我們會想到,把要拿的東西引數化就OK了,於是我們可以簡單的這麼構造:

local函式接收兩個引數,a是要“捕獲”的值,f是接收或者說消費這個值的函式,用它來改造上面的程式碼

上文當中將getName()的結果“捕獲”作為後面函式的引數,實現了“區域性變數”name。把上面的函式按照“真·函數語言程式設計”規則改寫:

不難發現我們這個local其實就是two_steps的簡化版,區別在於two_steps的第一個step1是一個函式,而local則是一個值,如果用two_steps實現local那麼就是:

看,我們這個local的風格,看起來非常像JS當中的“回撥”的方式——事實上,因為像Haskell這樣的純函式式語言沒有順序執行,你可以認為每一行程式碼執行順序是不一定的,這非常類似於在JS中我們遇到了海量非同步操作的時候:非同步操作的執行順序是不一定的,所以才會用回撥函式來保證“非同步操作->處理結果”這個順序。回撥是一種非常樸素,非常好理解,但寫起來卻反人類的非同步程式設計方式。我一直不批判瀏覽器和node.js裡把API都用回撥風格來定義,因為它很原始,大家都懂,至於後來的如Promise這些方式,也可以用回撥的API輕鬆封裝出來,鹹甜酸辣,五仁叉燒,任君挑選。

OK,扯遠了,也許你覺得上面的例子太過簡單,下面我們來看這篇文章中真正重點的內容。

3. 實現狀態

以下的例子基本上都源自從陳年譯稿——一個面向Scheme程式設計師的monad介紹文中搬運和改造,我從這篇文章獲得了巨大的啟發,也先對作者和譯者表示感謝。

我們寫程式的過程當中常常回用到自增ID這種東西,如果在JS裡要實現一個自增ID,可能會這麼寫

好嘛,繞了一圈,又回到剛才的話題了,區域性變數(這次在閉包裡面而已,本質是一樣的),和二次賦值。但是經過前文的啟發很容易就能用引數化的方式來解決這個問題

也就是

我們把區域性變數引數化,然後把返回值改成了一個陣列(因為JS裡沒有元組,所以只能用陣列暫代),這樣在需要guid的時候,需要把之前的返回值的第二個值作為引數傳進去;而整個程式則需要使用一個初始值(我們管叫“種子”)來啟動它。

現在假如我們有3個名字,分別要對它們用guid來編號並且輸出,也就是說需要連續執行3次guid,這裡涉及到的就是順序執行以及guid的狀態引數傳遞:

也許你已經被後面謎一般的這一堆括號所惹毛了,如果你能忍著繼續看下去的話也許可以真的獲得這篇文章的樂趣(捂臉

對於沒有副作用的函式(純函式),不需要帶上state這個引數,而對於有副作用的函式——我們稱之為“操作”——這裡體現為呼叫了guid函式的,就需要帶上state這個引數。

看,state就是我們所說的“狀態”,整個過程中,都把它(用陣列的第二個值)揣著,當一個函式需要狀態的時候就傳給它,它用完了又撿回來揣著。

看起來沒什麼不對,但是guid(state)這個函式總是給人隱隱的不爽:stateguid自身的狀態,卻需要counting這個消費者在整個呼叫過程當中幫它傳遞,這是不爽的,因此不妨把guidstate引數定義為curry形式:

進而counting中的local的第一步不再是一個已算出的值,而是一個curry了第一個引數(空),需要第二個引數stateguid函式,這就是curry函式的精妙之處,它讓函式可以“部分地”被執行,從而能夠實現演算——我們把整個演算過程像代數推導一樣列出來,最後把值代入就能計算出結果,是不是很像中學的時候解代數題、物理題什麼的?

於是,用了新的guid以後,local就不能應用於guid了,使用two_steps改寫一下

這時候你會覺得更煩了,因為這次雖然我們不需要給guid()主動傳遞state了,但在連續多次呼叫two_steps的時候,卻需要把state1state2two_steps傳遞下去,能不能構造一個新的two_steps函式,讓它能夠透明地傳遞state引數呢?

答案是顯然的,回顧一下上文中two_steps的定義和實現:

我們想想,two_stepsparam引數作用無外乎是作為“狀態”傳給step1,它的定義是curry化的,如果two_steps不傳第三個引數,獲得的就是一個內容為step1-then-step2的“部分函式”這個函式接收param引數,返回step2的結果。要讓two_steps能夠繼續透明地使用這種“部分函式”,就是說two_steps的結果可以繼續被two_steps組合,我們可以對step1step2函式的型別進行限定

其中State是狀態的型別,Value是返回值的型別,在guid的例子裡面,這兩者都是Number。這樣結合起來,新的two_steps——我們給它一個新名字叫begin——的型別限定就是

對吧,begin的兩個引數step1step2都是State -> [Value, State]型別,這跟它在只curry前兩個引數,剩餘param引數時的那個部分函式step3的型別(函式簽名)是一樣 的。

從中抽取出一個模式:State -> [Value, State],我們用一個泛型來抽象它叫做M,不難發現,guid() -> Number -> [Number, Number]也就是() -> M型別(其State也是Number型別),用這個泛型可以把begin描述成:

這樣我們可以順利的推出begin的實現

簡化之,結果是

這和two_steps如出一轍,區別只在於,對於step2,它丟棄了step1所產生的Value,而只保留了它所產生的State

然而我們還是需要Value啊!說丟就丟這也太不負責任了吧!這時候自然想到“再加一箇中間層”,我們設計這樣一個函式:它的第二個引數消費step1所產生的Value,返回step2,這個step2再去消費step1所產生的State。把這個函式命名為bind,它的型別描述如下

使用M泛型來描述它就是

看,當使用bind來結合兩個操作step1build_step2的時候,step1消費掉State種子,產生一個返回值Value(並且可能產生了新的狀態State)。緊接著build_step2消費了step1所返回的Value,並且它返回一個新的M也就是step2bind函式會像begin那樣,把step1所產生的新State作為引數傳給step2,並且返回其結果。於是我們終於構造出了bind的實現:

轉換成“真·函數語言程式設計”,這裡利用local實現區域性變數:

不難發現,beginbind的一個特例,用bind來構造它的話就是

非常的直白,忽略step1產生的Value,繼續呼叫step2。現在使用bind來改進上面的多個帶狀態的順序執行的程式碼

注意上面的程式碼裡面我們定義了一個returns函式,它接收一個Value,並且返回一個M。毫無疑問,M是比Value更“高階”的型別,因為M當中含有State,而Value不含。

bind函式作用於M型別,因此需要returns,於是通過returns將一個Value轉成M;而main函式是返回Value型別的,則通過[0]來將一個M拋棄State轉回Value

還記得剛才那句話嗎:

對於沒有副作用的函式(純函式),不需要帶上state這個引數,而對於有副作用的函式——我們稱之為“操作”——這裡體現為呼叫了guid函式的,就需要帶上state這個引數。

因為main函式拋棄了最終的State,所以它不在有副作用,又變成純函式了;而正是因為它拋棄了State,它自身也變成無狀態的了,所以毫無疑問重新呼叫main(0)就會讓guid清零重新開始——區域性變數,作用域,生命週期,有沒有發現指令式程式設計裡面的概念在這裡體現出來了?

<h2 id=”那麼,M到底是什麼?”>那麼,M到底是什麼?

從面相物件的角度去理解,我們可以說,M是一個封裝了State在裡面的“操作”。從函數語言程式設計的角度去理解,我們理解為Value是一個“值”,而M是一個“計算”。

對上面的東西做一個小結

  1. returns函式將一個Value“提升”成M型別。
  2. beginbind函式將兩個M繫結順序關係(beginbind的簡化版)。bind函式中的build_step2將會有一個“臨時”的機會獲得Value,但是用完以後又必須回到M
  3. [0]M“降級”回Value——這個過程將會不可逆地丟失掉State

我們上面的returnsbind這一對函式就實現了一個Monad——準確的說是State Monad。在Haskell裡面,我們的returns叫做return,我們的bind叫做bind或者運算子>>=。這張圖

via《道可叨 | Monad 最簡介紹》

是我所見到的一個非常形象的描述。

除了State Monad外還有很多種Monad,例如Maybe幫助Haskell實現了非常自然的錯誤處理,IO幫助Haskell實現了IO。在Monad的幫助之下Haskell更實現了do notation這種“順序執行”的語法糖,可謂是Haskell的核心之一。

4. 總結

到現在為止,迴圈有了,區域性變數有了,狀態也有了,可以說基本上已經沒有寫不出的程式了。當然,正經的程式是不可能這麼寫的,所以這兩篇文章也就是分享一下我個人的學習心得,玩玩所謂“真·函數語言程式設計”,以及——最重要的——還是裝逼。

好了,最後,我也不想說什麼了,只能深吸口氣,緊閉眼睛,一言(tú)以蔽之:

相關文章