談到函數式編程時,總是會看到 immutable 及它的名詞 immutability。我們今天就試著解釋這個觀念,及它在函數式編程中扮演的角色。
在任何一個語言裡,如果你寫了這樣的程式:
1 + 1
那你預期的,應該就是 2
。就該是 2
,沒有其它可能。因此可以說「整數」是 immutable 的。假設有一天你看到這樣的結果:
1 + 1
//=> 7
你的反應必定是:嗚哇這壞掉了我沒辦法用
但是當這個問題牽涉到字串、集合,還有物件時,在物件導向的程式語言裡,它逼我們要調整我們的認知:
let ary = [1, 2, 3]
ary.reverse()
ary #=> [3, 2, 1]
等等,那其它在你 reverse
之前使用了這個 ary
的人,等一下不就會發現它處理的東西不一樣了?的確,有非常多時候你的程式正是依賴著改變狀態,進而讓行為發生變化的機制在運行的。但是總有一些時刻,這種機制引進了預期之外的,極難解決的副作用。更進一步,依賴狀態改變運行的機制,會很難平行化,因為當你想要一根香蕉時,你必須要把整座森林拿來,才有辦法得到那根香蕉的正確狀態。
Immutability 還分成兩個層次。第一個層次是資料不可變,任何資料一經宣告,就永遠是那個樣子了。
ary = [1, 2, 3]
Enum.reverse([1, 2, 3])
ary #=> [1, 2, 3]
「等一下,那個 Enum.reverse/1
根本沒有發生作用啊!」
好的,首先恭喜你學會了使用正確的函式名稱,再來你的觀查很仔細,但是,他其實有發生作用。在你呼叫 Enum.reverse/1
的時候,Elixir 會幫你做出一個完全新的串列,內容是原本串列的反向,並且回傳。但因為你沒有用變數去接它,所以它就直接消失了。而原本的串列,如同我們稍早所說的,永遠會是 [1, 2, 3]
。這個規則在 Elixir/Erlang 裡永遠適用,每個操作資料的函式,都不會去改變原先的資料,而是回傳一個新的。
值得一提的是,你在其它語言裡習慣用的一個模式:先在外部宣告一個放結果用的值或是集合,然後在程式運行過程中不斷改變它,最後拿它來做為結果。這模式在函數式編程語言裡,基本上是行不通的。例如我們昨天示範用的那個 for
迴圈。
如果你在 Erlang,注意,是 Erlang ,做了這樣的事:
A = 1.
A = 2.
Erlang 會直接噴錯誤,說 :你這個騙紙,你剛剛不是才說 A 是 1。數學上,一旦你說是 1
,那就是 1
了,講求精確的數學沒有讓你三心二意的空間。
但是 elixir 就比較寬容一點。
ary = [1, 2, 3]
ary = Enum.reverse([1, 2, 3])
ary #=> [3, 2, 1]
當我們用原先的 ary
變數去接串列反向的結果, elixir 大方的接受了。但是在記憶體的層次,原先那個 [1, 2, 3]
還是沒有被改變。我們做出了一個新的串列 (反向的那個) ,接著把變數 ary
綁定 到新的串列的記憶體位址,造成了好像有改變的錯覺。
再重覆一次,Elixir 中資料是 immutable (不可變) 的,但可以重新綁定變數。
Note: 有趣的是,這剛好跟 JavaScript ES6 的新的變數宣告關鍵字 const
恰恰相反。const
會阻止你重新綁定,但已綁定的資料可變動 (如果綁定的是可變動的型別的話。)
由於 immutability 及變數重綁定,Elixir 的 closure 的行為才會是概念上正確的,我們先看看其它的語言:
/* JavaScript */
var a = 1
let addOne = (i) => a + i
a = 10
addOne(1) //=> 11
# Ruby
a = 1
add_one = -> (i) { a + i }
a = 10
add_one.(1) #=> 11
所謂正確的 closure:變數在封進函式中的那一刻,值就保持固定了。
# Elixir
a = 1
add_one = fn i -> a + i end
a = 10
add_one.(1) #=> 2
帶著上一節的知識,我們來重看一下昨天的那個很假的示範:
students
|> Enum.filter(&(&1.age >= 18))
|> Enum.map(fn %{name: name} -> String.split(name, " ") end)
|> Enum.map(fn [_, last_name] -> last_name end)
|> Enum.uniq
現在的新問題是,如果那個 students
是個一億個元素的串列,那麼我們 pipe 過的每一步,都會在記憶體中多生出一組一億個元素的新串列。只要多 pipe 過幾個函式,記憶體很快就會被塞滿了。
你看事情就是這樣,我們用 immutability 來阻止程式產生副作用,代價就是比較難操作跟浪費記憶體。但因為有了 pattern matching 及 function composition,操作在函數式編程語言裡就不算什麼問題。(你試試 OO 語言上的 immutable 方案就知道),但記憶體這問題該怎麼辦呢?解法就是 lazy evaluation:只有當我真的需要那個值的時候,函式才會真的開始進行 apply。
lazy evaluation 的相反就是 eager evaluation,積極求值。為了比較好解釋,請容我公堂之上假設一行語法:
# elixir
[1..無限大] # [假設] 這樣會是個 1 到無限大的串列
|> Enum.filer(&(rem(&1, 2) == 0))
|> Enum.map(&(&1 * 10))
|> Enum.drop(5)
Eager evaluation 就是他在每一步,都會直接去求結果值。在這個例子裡,它連第一步都跑不完。什麼腦洞讓你會有可以放無限大的串列的錯覺的啊!
但是如果上面的例子裡,我們可以找到一種懶惰的函式,讓每一步都只把函式的部份用 function compisiton 組合起來,但一直不要進行 apply ,就可以做出這樣的事:
[1..無限大] # [假設的語法]
|> lazy_filer(&(rem(&1, 2) == 0)) # 一個函式,先放著吧
|> lazy_map(&(&1 * 10)) # 把這個函式跟上面那個組合一下,但繼續放著
|> lazy_drop(5) # 喔又一個函式要組合。但顯然還不是進行計算的時候 (呵欠
|> Enum.take(10) # 好吧,勉為其難幫你算一下,只要前面十個是吧?
當我們明確說出「現在給我算好的串列的前十個」那句的時候,這一切才開始有意義。有了這種工具,我們就可以設定無限的起始值,並進行函式組合,直到程式或是我們覺得那個值是當下必須得到的時候,才開始求值。
而這就是 elixir 裡的 Stream
模組。你會發現他的函式幾乎都是 Enum
裡已經有的,但是他就是那個懶惰的版本:
[1..無限大] # 注意,還是假設的語法
|> Stream.filter(&(rem(&1, 2) == 0))
|> Stream.map(&(&1 * 10))
|> Stream.drop(5)
|> Enum.take(10)
而由於一直到最後才進行求值,每個元素是 apply 進一個已經組合好的函式裡的。這也解了我們一開始的問題,不會產生一堆中間的資料結構,而是只有最後那一份串列會存在記憶體裡。
最後,讓我們移掉那個假設性的語法。改成真的會產生無限大的串列的語法:
Stream.iterate(0, &(&1 + 1))
|> Stream.filter(&(rem(&1, 2) == 0))
|> Stream.map(&(&1 * 10))
|> Stream.drop(5)
|> Enum.take(10)
嗯… 剛說了什麼腦洞來著?
Note:
[1..]
產生無限長的串列Stream
模組裡連續兩天比較硬的內容,明天來點大家比較熟悉的輕鬆一下: if
, case
跟 cond
。
Happy hacking! 明天見。