iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 12
1

「不可變性」聽起來就怪怪的

談到函數式編程時,總是會看到 immutable 及它的名詞 immutability。我們今天就試著解釋這個觀念,及它在函數式編程中扮演的角色。

在任何一個語言裡,如果你寫了這樣的程式:

1 + 1

那你預期的,應該就是 2。就該是 2,沒有其它可能。因此可以說「整數」是 immutable 的。假設有一天你看到這樣的結果:

1 + 1
//=> 7

你的反應必定是:嗚哇這壞掉了我沒辦法用

事情總有例外…嗎?

但是當這個問題牽涉到字串、集合,還有物件時,在物件導向的程式語言裡,它逼我們要調整我們的認知:

let ary = [1, 2, 3]
ary.reverse()
ary #=> [3, 2, 1]

等等,那其它在你 reverse 之前使用了這個 ary 的人,等一下不就會發現它處理的東西不一樣了?的確,有非常多時候你的程式正是依賴著改變狀態,進而讓行為發生變化的機制在運行的。但是總有一些時刻,這種機制引進了預期之外的,極難解決的副作用。更進一步,依賴狀態改變運行的機制,會很難平行化,因為當你想要一根香蕉時,你必須要把整座森林拿來,才有辦法得到那根香蕉的正確狀態

Immutability 的兩個層次

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 會阻止你重新綁定,但已綁定的資料可變動 (如果綁定的是可變動的型別的話。)

再多講一點:關於 closure

由於 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

Lazy evaluation

帶著上一節的知識,我們來重看一下昨天的那個很假的示範:

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:

  • Haskell 可以用 [1..] 產生無限長的串列
  • Haskell 的函式預設就是 lazy evaluation

重點回顧

  • Elixir 中資料是 immutable 的,但是可以重新綁定變數
  • Lazy evaluation 的函數都放在 Stream 模組裡

連續兩天比較硬的內容,明天來點大家比較熟悉的輕鬆一下: if, casecond

Happy hacking! 明天見。


上一篇
更泛用的高階函式,與資料轉換的旅程
下一篇
條件分支,還有不是你以為的那個 for
系列文
函數式編程: 從 Elixir & Phoenix 入門。31

1 則留言

0
taiansu
iT邦新手 5 級 ‧ 2017-12-31 23:43:28

今天比較好,申請一小時遲交 XD

taiansu iT邦新手 5 級‧ 2018-01-01 00:31:46 檢舉

已交卷。 XD

我要留言

立即登入留言