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 這個模式為出發點,後續還會出現許多模式,而這些模式有些進入了命令式的現式語言之中,在不少語言中都會看到一些影子,這就是後續要討論的東西了…