Immutable
January 20, 2022不可變(Immutability)是函數式程式設計的基本特性之一,純函數式語言中的變數不可變(immutable),這邊的「變數」指的不是命令式語言中的變數,而是數學上的變數。
對於命令式語言,變數是可變動的(mutable),也就是 x = 10 的話,能夠有 x = x + 1 這類的動作,這是因為 =
被當成是指定,而 x
可以被重複指定不同的值,以 x = x + 1 為例,會先運算 x + 1 的值,然後將結果值指定給 x。
然而,數學上的變數,令 x = 10,x 就是 10,不會代表別的,不會有 x = x + 1 的可能性,這是因為數學上 =
是等於,數學上不可能有「x 等於 x + 1」的可能性,從命令式語言的角度來看,這就像是限制變數不可重新指定值,也就是變數是不可變(immutable)。
只不過若一門程式語言,能夠允許 x = x + 1,又為何需要令其像數學那樣,不能有 x = x + 1 這類的行為呢?就我來看,最後都可以歸結為一個目的「控制狀態」。
無副作用
控制狀態是指什麼呢?回答這個問題之前,應該先來談談,如果不能使用 x = x + 1 這類行為,會發生什麼事!例如,以下是個典型的命令式風格:
import sys
name = 'Guest'
if len(sys.argv) > 1:
name = sys.argv[1]
print(f'Hello, {name}')
如果要令 name
指定值後不可變,又要能完成以下的任務,方式之一是改為 if/else
運算式:
import sys
name = sys.argv[1] if len(sys.argv) > 1 else 'Guest'
print(f'Hello, {name}')
或者是封裝為函式:
import sys
def username(argv, default):
if len(argv) > 1:
return argv[1]
return default
name = username(sys.argv, 'Guest')
print(f'Hello, {name}')
運算式或者是函式,基本上來自數學,一個運算式 x + 1 不會有副作用(side effect),也就是說,如果 x 是 1,無論執行幾次 x + 1,結果必然還是 1,一個函式 f(x) 不會有副作用,也就是說,若 x 指定為 1,f(x) 傳回 10,無論執行幾次 f(x),結果必然是 10。
也就是說,當你不能有 x = x + 1 的行為時,你的程式碼中有些部份,必然就會是無副作用,要嘛想辦法化為運算式,要嘛想辦法化為函式。
純函式
這邊談的函式,是指數學上的函式,因為命令式語言的函式,若不特別施加有形(像是上例中 Final
型態提示)或無形(成文或非成文約定),命令式語言的函式是可以有副作用的,為了區別,在程式語言中沒有副作用的函式,有人會稱為純函式(pure function)。
以下的 hello_user
函式不是純函式,雖然它沒有改變參數,然而它沒有傳回值(就 Python 來說沒有傳回 None
以外的值):
import sys
def hello_user(argv, default):
if len(argv) > 1:
print(f'Hello, {argv[1]}')
else:
print(f'Hello, {default}')
hello_user(sys.argv, 'Guest')
試著將這簡單的函式改為純函式呢?
import sys
def hello_user(argv, default):
if len(argv) > 1:
return f'Hello, {argv[1]}'
return f'Hello, {default}'
print(hello_user(sys.argv, 'Guest'))
你的程式碼分成兩個部份,一個是無副作用的部份,也就是純函式 hello_user
,一個是有副作用的部份,也就是 print
的部份。
以下的 random_char
不是純函式,因為 name
指定了 'caterpillar'
,然而每次函式傳回值都不同:
import random
def random_char(name):
return random.choice(name)
char = random_char('caterpillar')
以下的 choice_char
是純函式:
import random
def choice_char(name, i):
return name[i]
name = 'caterpillar'
i = random.randint(0, len(name) - 1) # 副作用
char = choice_char(name, i)
你的程式碼分成兩個部份,一個是無副作用的部份,一個是有副作用的部份…我可以舉更多的例子,然而簡單來說,最後你的程式碼必然會被強制區分為兩個部份:有副作用與無副作用。
純與不純
副作用本質上就是難以對付,在一個函式中有副作用與無副作用的邏輯混在一起,整個系統又是基於這類函式建立起來的話,日後遇到了副作用的問題,要追蹤處理就難了。
如果你一開始就運用了不可變動,應用程式在成長的過程中,就會被區分有無副作用兩個部份。
對於無副作用的部份,狀態都會很好預測,畢竟其中每個函式都是純函式,每個純函式中若運用到其他函式,被運用的函式也會是純函式,如果你的語言支援一級函式(first-class function),也就是函式可以作為值傳遞,那麼被傳遞的函式,必然也是純函式。
若作為引數傳入函式是不可變動物件,不用擔心狀態會被變更,在並行(Concurrent)程式設計時,不用擔心執行緒共用競爭的問題。
也就是說,若日後遇到了副作用的問題,要追蹤處理的話,你就只要專心面對有副作用的那部份,當然,副作用本質上還是難以處理,然而,至少不用面對有副作用與無副作用的邏輯混在一起的問題!
有前端開發的領域,早就有這類概念的程式庫或框架了,畢竟前端是最容易面對副作用問題,一些狀態管理框架,不就是建議在運用框架時,某些部份得以無副作用、純函式的概念來實現嗎?至於副作用的處理,往往會由框架包辦,因為框架的維護者們認為,他們處理其領域的副作用,比一般開發者行吧!
這也解答了許多人對不可變動的誤解「應用程式本身就是需要狀態,限制不可變動,不就辦不了事了嗎?」
當你以不可變動為出發點來撰寫程式時,其實並不是整個應用程式都要不可變動,而是要去思考,哪些是可以分解出來成為純函式,哪些會是有副作用的部份,逐一將整個系統分為純綷與不純綷的兩部份,也就是無副作用與有副作用的部份。
如此一來,你就比較容易控制狀態,也就是方才談到的,若日後遇到了副作用的問題,至少你只要專心面對有副作用的部份!
一次處理一件事
有人會說,若不能有 x = x + 1 的行為,那麼重複性的流程怎麼辦?比方說計數呢?
def leng(lt):
count = 0
for _ in lt:
count = count + 1
return count
lt = [1, 3, 2, 5, 8]
length = leng(lt)
那我就要問你了,上面這個 leng
,以白話來描述,你做了什麼呢?你走訪 lt
,有元素時遞增 1,直到沒有元素為止,對吧!也就是說,你知道資料的結構可以從頭至尾走訪,而且你知道該重複什麼流程。
如果不能有 x = x + 1 的行為,那麼就無法使用迴圈來完成以上的任務;然而,走訪 lt
,有元素時遞增 1,直到沒有元素為止,這個動作不依賴迴圈,還是能做到的:
def leng(lt):
if lt == []: # 沒有元素
return 0
else: # 有元素
# 遞增 1
return 1 + leng(lt[1:])
lt = [1, 3, 2, 5, 8]
length = leng(lt)
實際上你做的事情不變,只是改以遞迴實現而已;很多人覺得遞迴不好寫,其實那是習慣的問題。
其一是思考的習慣,就命令式語言來說,許多人習慣用迴圈思考重複流程的問題,不習慣用遞迴思考重複流程的問題,這就像是有兩種工具都能解決事情,你總是習慣用其中一種來解決罷了。
另一個習慣就比較不好了,很多人習慣在迴圈中塞一堆東西,比方說,計數的同時,順便對元素進行運算,然後根據某個條件收集在另一個 list
之類:
lt = [1, 3, 2, 5, 8]
count = 0
collector = []
for n in lt:
count = count + 1
double_n = n * 2
if double_n > 5:
collector.append(double_n)
如果要改以遞迴的方式,用一個函式來實現這個迴圈的任務,會困難的多,因為它摻雜了多個子任務,在命令式語言中,由於能做 x = x + 1 這類動作,很容易就出現這種順便在迴圈裡做個什麼行為的壞習慣。
為什麼是壞習慣?因為你順便在迴圈裡做個什麼,就相當於順便在迴圈裡改變某個狀態,日後要是出現狀態管理上的問題,你得在迴圈裡那垞爛泥裡,找出到哪是哪個狀態變更出了問題。
就命令式語言的使用上,若要避免使用迴圈時的狀態管理問題,就是自我克制,一個迴圈只解決一個任務:
lt = [1, 3, 2, 5, 8]
count = 0
for _ in lt:
count = count + 1
doubled_lt = []
for n in lt:
doubled_lt.append(n * 2)
greaterThan5 = []
for n in doubled_lt:
if n > 5:
greaterThan5.append(n)
然而,若限制不能進行 x = x + 1 之類的動作,為了遞迴思考時的方便,你會漸漸地習慣一次處理一件事;有人會說,這樣不就要跑三次迴圈?效能不會不好嗎?
這就要問了,你所謂的效能是指什麼?很多人談效能時都很籠統,就上例來說,應該就單純只是指運算次數吧!只不過,有更多因素對效能的影響會大於運算次數,根據任務的性質,也有不同的方式可以減少運算次數。
另外,如果不能控制程式碼,想調效能是很困難的,因為你很難知道瓶頸發生在哪個地方,如果能控制程式碼,知道每個階段各從事了哪些子任務,日後想調效能,就能針對各個子任務評測,看看在哪個子任務上調整,根據子任務的特性調整,以獲得更大的效益。
簡單來說,Immutable 本身就是種模式,當你面臨狀態管理上的問題,不知道該往何處去時,先從 Immutable 開始,或者基於 Immutable 來進行重構,往往就會朝好的方向發展。
以 Immutable 這個模式為出發點,後續還會出現許多模式,而這些模式有些進入了命令式的現式語言之中,在不少語言中都會看到一些影子,這就是後續要討論的東西了…