上一篇文章使用JavaScript實現“真·函數語言程式設計”本來以為可以一次性寫完的,結果話癆本色,沒辦法,繼續填坑,這篇應該可以完結了,講道理嘛。
這篇當中將介紹如何在純函式式的限制之下實現“區域性變數”和“狀態”。
2. 實現區域性變數
首先考慮這樣一段程式碼
1 2 3 4 5 6 7 8 |
function getName() { return 'name from somewhere' } function greeting(word) { var name = getName() return word + ', ' + name } console.log(greeting('Hello')) |
這是一段典型的指令式程式設計的程式碼,它最主要的問題就是區域性變數name
。
在上一篇文章的第一個實現對陣列遍歷的例子當中我們已經對“順序執行”初窺門徑,通過構造了一個two_steps
函式,實現兩個步驟(函式)順序執行。
在這個構造過程當中,我們得到一個重要的思路,就是在函式式語言當中,如果你想“獲得”一個什麼東西,就構造一個新的函式,把它引數化。對於two_steps
的例子而言,“想獲得”的是一個step2
,就把它引數化了。
所以當需要“獲得”區域性變數的時候,自然而然我們會想到,把要拿的東西引數化就OK了,於是我們可以簡單的這麼構造:
1 2 |
// local :: a -> (a -> b) -> b const local = a => f => f(a) |
local
函式接收兩個引數,a
是要“捕獲”的值,f
是接收或者說消費這個值的函式,用它來改造上面的程式碼
1 2 3 |
function greeting(word) { return local(getName())(name => word + ', ' + name) } |
上文當中將getName()
的結果“捕獲”作為後面函式的引數,實現了“區域性變數”name
。把上面的函式按照“真·函數語言程式設計”規則改寫:
1 2 3 4 5 |
const getName = () => 'name from somewhere' const greeting = word => local(getName())(name => word + ', ' + name) console.log(greeting('Hello')) // 結果是'Hello, name from somewhere' |
不難發現我們這個local
其實就是two_steps
的簡化版,區別在於two_steps
的第一個step1
是一個函式,而local
則是一個值,如果用two_steps
實現local
那麼就是:
1 |
const local = value => step2 => two_steps (_ => value) (step2) () |
看,我們這個local
的風格,看起來非常像JS當中的“回撥”的方式——事實上,因為像Haskell這樣的純函式式語言沒有順序執行,你可以認為每一行程式碼執行順序是不一定的,這非常類似於在JS中我們遇到了海量非同步操作的時候:非同步操作的執行順序是不一定的,所以才會用回撥函式來保證“非同步操作->處理結果”這個順序。回撥是一種非常樸素,非常好理解,但寫起來卻反人類的非同步程式設計方式。我一直不批判瀏覽器和node.js裡把API都用回撥風格來定義,因為它很原始,大家都懂,至於後來的如Promise
這些方式,也可以用回撥的API輕鬆封裝出來,鹹甜酸辣,五仁叉燒,任君挑選。
OK,扯遠了,也許你覺得上面的例子太過簡單,下面我們來看這篇文章中真正重點的內容。
3. 實現狀態
以下的例子基本上都源自從陳年譯稿——一個面向Scheme程式設計師的monad介紹文中搬運和改造,我從這篇文章獲得了巨大的啟發,也先對作者和譯者表示感謝。
我們寫程式的過程當中常常回用到自增ID這種東西,如果在JS裡要實現一個自增ID,可能會這麼寫
1 2 3 4 |
var __guid = 0 function guid() { return __guid++ } |
好嘛,繞了一圈,又回到剛才的話題了,區域性變數(這次在閉包裡面而已,本質是一樣的),和二次賦值。但是經過前文的啟發很容易就能用引數化的方式來解決這個問題
1 2 3 4 |
function guid(oldValue) { var newValue = oldValue + 1 return [newValue, newValue] } |
也就是
1 2 |
const guid = oldValue => local(oldValue + 1)(newValue => [newValue, newValue]) |
我們把區域性變數引數化,然後把返回值改成了一個陣列(因為JS裡沒有元組,所以只能用陣列暫代),這樣在需要guid
的時候,需要把之前的返回值的第二個值作為引數傳進去;而整個程式則需要使用一個初始值(我們管叫“種子”)來啟動它。
現在假如我們有3個名字,分別要對它們用guid
來編號並且輸出,也就是說需要連續執行3次guid
,這裡涉及到的就是順序執行以及guid
的狀態引數傳遞:
1 2 3 4 5 6 7 8 9 |
const counting = state => local (guid(state)) (([id1, state1]) => local (guid(state1)) (([id2, state2]) => local (guid(state2)) (([id3, state3]) => `Alice:${id1} Bob:${id2} Chris:${id3}` ) ) ) console.log(counting(0)) // 結果是Alice 0, Bob 1, Chris 2 |
也許你已經被後面謎一般的這一堆括號所惹毛了,如果你能忍著繼續看下去的話也許可以真的獲得這篇文章的樂趣(捂臉
對於沒有副作用的函式(純函式),不需要帶上state
這個引數,而對於有副作用的函式——我們稱之為“操作”——這裡體現為呼叫了guid
函式的,就需要帶上state
這個引數。
看,state
就是我們所說的“狀態”,整個過程中,都把它(用陣列的第二個值)揣著,當一個函式需要狀態的時候就傳給它,它用完了又撿回來揣著。
看起來沒什麼不對,但是guid(state)
這個函式總是給人隱隱的不爽:state
是guid
自身的狀態,卻需要counting
這個消費者在整個呼叫過程當中幫它傳遞,這是不爽的,因此不妨把guid
的state
引數定義為curry
形式:
1 2 |
const guid = () => state => local(state + 1)(state1 => [state1, state1]) |
進而counting
中的local
的第一步不再是一個已算出的值,而是一個curry
了第一個引數(空),需要第二個引數state
的guid
函式,這就是curry
函式的精妙之處,它讓函式可以“部分地”被執行,從而能夠實現演算——我們把整個演算過程像代數推導一樣列出來,最後把值代入就能計算出結果,是不是很像中學的時候解代數題、物理題什麼的?
於是,用了新的guid
以後,local
就不能應用於guid
了,使用two_steps
改寫一下
1 2 3 4 5 6 7 8 |
const counting_by_steps = state => two_steps (guid()) (([id1, state1]) => two_steps (guid()) (([id2, state2]) => two_steps (guid()) (([id3, state3]) => `Alice:${id1} Bob:${id2} Chris:${id3}`) (state2)) (state1)) (state) console.log(counting_by_steps(0)) |
這時候你會覺得更煩了,因為這次雖然我們不需要給guid()
主動傳遞state
了,但在連續多次呼叫two_steps
的時候,卻需要把state1
和state2
給two_steps
傳遞下去,能不能構造一個新的two_steps
函式,讓它能夠透明地傳遞state引數
呢?
答案是顯然的,回顧一下上文中two_steps
的定義和實現:
1 2 3 |
// two_steps :: (a -> b) -> (b -> c) -> a -> c const two_steps = step1 => step2 => param => step2(step1(param)) |
我們想想,two_steps
的param
引數作用無外乎是作為“狀態”傳給step1
,它的定義是curry
化的,如果two_steps
不傳第三個引數,獲得的就是一個內容為step1-then-step2
的“部分函式”這個函式接收param
引數,返回step2
的結果。要讓two_steps
能夠繼續透明地使用這種“部分函式”,就是說two_steps
的結果可以繼續被two_steps
組合,我們可以對step1
和step2
函式的型別進行限定
1 2 3 |
step1 :: State -> [Value, State] step2 :: State -> [Value, State] param :: State |
其中State
是狀態的型別,Value
是返回值的型別,在guid
的例子裡面,這兩者都是Number
。這樣結合起來,新的two_steps
——我們給它一個新名字叫begin
——的型別限定就是
1 |
begin :: (State -> [Value, State]) -> (State -> [Value, State]) -> State -> [Value, State] |
對吧,begin
的兩個引數step1
和step2
都是State -> [Value, State]
型別,這跟它在只curry
前兩個引數,剩餘param
引數時的那個部分函式step3
的型別(函式簽名)是一樣 的。
從中抽取出一個模式:State -> [Value, State]
,我們用一個泛型來抽象它叫做M
,不難發現,guid
是() -> Number -> [Number, Number]
也就是() -> M
型別(其State
也是Number
型別),用這個泛型可以把begin
描述成:
1 |
begin :: M<Value> -> M<Value> -> State -> [Value, State] |
這樣我們可以順利的推出begin
的實現
1 2 3 4 |
const begin = step1 => step2 => state => { const [value1, state1] = step1(state) return step2(state1) } |
簡化之,結果是
1 |
const begin = step1 => step2 => state => step2(step1(state)[1]) |
這和two_steps
如出一轍,區別只在於,對於step2
,它丟棄了step1
所產生的Value
,而只保留了它所產生的State
。
然而我們還是需要Value
啊!說丟就丟這也太不負責任了吧!這時候自然想到“再加一箇中間層”,我們設計這樣一個函式:它的第二個引數消費step1
所產生的Value
,返回step2
,這個step2
再去消費step1
所產生的State
。把這個函式命名為bind
,它的型別描述如下
1 |
bind :: (State -> [Value, State]) -> (Value -> (State -> [Value, State])) -> State -> [Value, State] |
使用M
泛型來描述它就是
1 |
bind :: M<Value> -> (Value -> M<Value>) -> State -> [Value, State] |
看,當使用bind
來結合兩個操作step1
和build_step2
的時候,step1
消費掉State
種子,產生一個返回值Value
(並且可能產生了新的狀態State
)。緊接著build_step2
消費了step1
所返回的Value
,並且它返回一個新的M
也就是step2
,bind
函式會像begin
那樣,把step1
所產生的新State
作為引數傳給step2
,並且返回其結果。於是我們終於構造出了bind
的實現:
1 2 3 4 5 |
const bind = step1 => build_step2 => state => { const [value1, state1] = step1(state) const step2 = build_step2(value1) return step2(state1) } |
轉換成“真·函數語言程式設計”,這裡利用local
實現區域性變數:
1 2 3 4 5 6 |
const bind = step1 => build_step2 => state => local (step1(state)) (([value1, state1]) => local (build_step2(value1)) (step2 => step2(state1) ) ) |
不難發現,begin
是bind
的一個特例,用bind
來構造它的話就是
1 |
const begin = step1 => step2 => bind (step1) (_ => step2) |
非常的直白,忽略step1
產生的Value
,繼續呼叫step2
。現在使用bind
來改進上面的多個帶狀態的順序執行的程式碼
1 2 3 4 5 6 7 8 |
const returns = value => state => [value, state] const main = state => bind (guid()) (id1 => bind (guid()) (id2 => bind (guid()) (id3 => returns(`Alice:${id1} Bob:${id2} Chris:${id3}`) ))) (state) [0] console.log(main(0)) // 結果是'Alice:1 Bob:2 Chris:3' |
注意上面的程式碼裡面我們定義了一個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
是一個“計算”。
對上面的東西做一個小結
returns
函式將一個Value
“提升”成M
型別。begin
和bind
函式將兩個M
繫結順序關係(begin
是bind
的簡化版)。bind
函式中的build_step2
將會有一個“臨時”的機會獲得Value
,但是用完以後又必須回到M
。[0]
將M
“降級”回Value
——這個過程將會不可逆地丟失掉State
。
我們上面的returns
和bind
這一對函式就實現了一個Monad
——準確的說是State Monad
。在Haskell裡面,我們的returns
叫做return
,我們的bind
叫做bind
或者運算子>>=
。這張圖
是我所見到的一個非常形象的描述。
除了State Monad
外還有很多種Monad
,例如Maybe
幫助Haskell實現了非常自然的錯誤處理,IO
幫助Haskell實現了IO。在Monad
的幫助之下Haskell更實現了do notation
這種“順序執行”的語法糖,可謂是Haskell的核心之一。
4. 總結
到現在為止,迴圈有了,區域性變數有了,狀態也有了,可以說基本上已經沒有寫不出的程式了。當然,正經的程式是不可能這麼寫的,所以這兩篇文章也就是分享一下我個人的學習心得,玩玩所謂“真·函數語言程式設計”,以及——最重要的——還是裝逼。
好了,最後,我也不想說什麼了,只能深吸口氣,緊閉眼睛,一言(tú)以蔽之: