可變性與參考

閱讀時間約 8 分鐘

函數式編程跟物件導向一個很大的差異在於對資料可變性(mutability)的態度,函數式編程不鼓勵修改原有的資料,有些語言甚至沒有修改的概念;而物件導向專注於狀態的改變,物件作為閉包就已經假設資料是可變的。這種對於可變性的態度注定物件導向比較容易得到關注,因為這個模型比較符合電腦底層的運作邏輯,而我們似乎比較擅長以物件思考。在這裡我們將可以修改的「東西」稱作物件,反之稱為資料。這種分法或許與其他語言有一些微妙的差異,但在這篇文章將如此指代。可變性跟參考、複製、相等性等概念有關,以下將分析它們如何互相影響。


可變性會影響我們對變數身份(identity)的認知。身份代表一種唯一的概念,當兩個變數代表同一個身份時,這時我們會說它們「參考」了同一個東西。參考就像指代某種特定東西的別名,使用這個參考就如同使用這個東西。如果不可能有多個變數參考同一個東西,那麼身份這個概念就沒有意義。具有相同內容的物件不能代表它們是同一個東西,還有一種內在的特性把它們區分開來。關鍵在於:當修改了一個變數內容,其他參考同一個東西的變數也會改變,因此具有身份的東西可以擁有可變性。更進一步地,具有可變性的東西一定需要唯一的身份,因為「修改」只有在會影響其它地方時才有意義,否則只要覆蓋(shadowing)原有的變數就能達到同樣的效果。例如修改區域變數的操作可以改成覆蓋區域變數,然而這有時會需要重構程式碼後才有辦法做到。尤其是遇到迴圈時,就需要將函式改寫成遞迴,這會大大地增加程式碼的複雜度,因此比較寬鬆的函數式程式語言會允許使用可變的區域變數。


身份是可變性的必要條件,可變的物件必定具有唯一的身份。相反地,身份對不可變的資料沒有意義,畢竟如果它的內容不能改變,也就不用在意會不會因為參考同一個東西而被其他操作影響。更嚴格地,我們甚至不允許比較變數是否參考同一個東西,因此不可變的資料本質上是沒有身份的,我們沒辦法以此作為資料結構的一部分。這個限制是來自參考透明性(referential transparency),它要求參考不能有意義,甚至不能比較參考的位址。對於Haskell這種嚴格遵守參考透明性的純函數式程式語言,資料結構的正確性不能依賴於參考,甚至不能藉由比較參考位址加速比較的操作。參考透明性使得純函數式編程非常數學,畢竟數學物件本來就沒有可變的概念。反過來說,函數式編程的不可變特性就源自這個原則,但事實上大部分的函數式編程都只有著重在不可變的特性上,並不特別在意參考透明性。需要強調的是「不可變」的定義其實非常嚴格,不可變的資料只能包含不可變的成員,不能包含任何可變的參考,否則它就不是真正不可變的。然而很多程式語言的不可變特性都只有一層,例如JavaScript的const arr = [1,2,3]仍然可以修改陣列內容,Python的tuple是不可變的,但([1],[2])的內部列表仍是可變的。ocaml雖然標榜以不可變為主,但仍然能夠在資料結構裡修改標示為可變的成員,因此嚴格來說它也不是不可變的。而rust則使用borrowing rule讓共享參考的不可變特性傳遞到底下所有成員,但仍然可以藉由Cell, RefCell等具有內部可變性的結構打破規則。


如果不可變的資料只能包含不可變的參考,那麽把這個參考取代成相同內容但不同身份的參考並不會有什麼問題,當然反過來也是可以的。然而參考對於不可變的資料仍有一些非常微妙的作用。就算是非常看重參考透明性的Haskell,有時候我們仍必須打破規則使用不符合規則的方法除錯,例如利用trace等方法印出訊息,這樣就不用為了除錯而把所有東西都改寫成Monad。然而這時理解不可變的參考就特別重要,尤其是對於Haskell這種惰性求值的程式語言,而且參考的概念對於優化惰性求值的效能來說非常重要。因此即使不可變的參考不會影響資料的正確性,仍是非常重要的概念。如果不可變的資料包含指向自我的參考,問題就變得複雜了。一般來說這種結構是沒辦法構造出來的,但對於惰性求值的Haskell來說是家常便飯。這種結構代表了遞迴的資料結構,把它展開會得到無窮大的資料。把參考視覺化來看,它形成了有環有向圖的資料結構。你如果想要藉由檢查參考位址判斷是否是同一個節點,就必須打破參考透明性,對於Haskell來說這不是個好方法,因此這種結構在Haskell並不是如此應用的。在Haskell,只有在需要無窮大的資料時才會使用這種自我參考構造資料,因此不應該把參考當作資料結構的一部分來理解它,它只是「剛好」共享參考而已。


可變的物件因為隨時會被修改,因此當需要描述某些獨立的資訊時就需要複製。更麻煩的是當他由好幾層可變的物件組成時,例如包含可變列表的物件,這時就必須深度複製所有的東西。使用像是JavaScript/TypeScript這種物件導向的程式語言時,又想在它上面用函數式的風格寫程式,就會需要做一堆複製,因為幾乎所有東西都沒有辦法保證是不變的。就算是只有readonly成員的物件也不是不可變的,這只代表你不能改變它,而不是它不會改變。最好的方法是用真正不可變的資料描述資訊,如此一來就不用煩惱複製的問題了。複製本身並不一定是簡單自然的,尤其是牽涉到參考時。有時我們會希望保留參考不深度複製,例如複製以參考表示的圖的節點時,參考位址描述了節點的身份,這時複製就必須考慮到參考相等性。但對於參考本身無意義的資料,自動實作複製是非常自然的,或是可以直接引用相同的資料,畢竟它不可能被修改。Java, JavaScript等語言預設結構是可變的且只會複製參考,但又缺少簡單複製結構和比較結構的方法。這些限制使得我們難以利用資料來思考,因而傾向利用可變的物件和參考構造模型。像是Haskell, rust則是利用巨集輔助定義一些資料應有的特性。


在Java等物件導向的程式語言中,原始類型與物件都具有預設的相等運算子,但它們比較的東西是不同的:原始類型因為是不可變的,因此比較的是數值內容,而物件則比較身份,也就是參考位址是否相等。相等性(equality)固化了我們對變數類型的思考方式,也就是我們應該要把它們當成資料還是物件,而這種選擇顯示了Java想要以物件為主但又必須使用原始類型進行基本運算的矛盾。ocaml則擁有結構相等與參考相等兩種,結構相等性比較定義的資料結構是否相等,而參考相等性則比較身份。但因為ocaml會對不可變的資料做一些優化,因此參考相等性在資料上會有一些奇怪的結果。參考相等性可以顯現物件的身份,然而這並不是必要的。如果說要讓資料擁有可比較的身份,只要在他上面附加一個唯一的標示符就能做到,並不一定要由位址決定,這顯示了參考相等性不是身份的必要條件。相等性可以用來顯現這個類型代表什麼,資料實質意義上的相等有時並不代表結構相等。例如利用二元搜尋樹實作的無序集合會根據插入順序不同而有不同的結構,而它們仍會被當作相同的資料,因為它代表的不是二元搜尋樹而是無序集合。而比較圖的相等性不是簡單的操作,它需要計算複雜性非常高的演算法。程序式編程常常以相等性判斷數值,並以此進行流程控制,這似乎代表相等性是非常重要的操作。然而對於函數式程式語言,流程控制是基於模式比對完成。這種模式比對是根據結構控制流程,而這只對應了結構相等一種,其它的相等性對於流程控制並沒有直接的聯繫。因此相等性不應被當作理所當然的運算,它其實只是具有某種特性的二元關係而已。


流程控制在程序式編程與函數式編程之間有很大的差異,不只是因為對於相等性的使用與否,可變性的差別也是原因。程序式編程的流程控制依賴於可變性,例如if-else陳述式常常會通過修改已有的變數把每個分支的結果帶到這個區塊後面,這是因為分支的區域變數是沒辦法直接被外面使用的。而迴圈基本上只能在有可變的變數下運作,同樣地,迴圈內的區域變數只能藉由修改外部變數帶出來。而rust則把if-else和loop當成表達式,讓它們可以「回傳」結果給外部使用,這非常像函數式編程。這種回傳結果而非修改變數的風格使得方法得以串聯起來,其中iterator很常使用這種方式,這比起使用迴圈易讀許多。純函數式程式語言因為不能修改變數,因此這種基於修改的流程控制是不可能實現的。對於流程控制的差異使得函數式編程與程序式編程的思考方式有很大的不同,而函數式的思考方式很多時候對於可變的情境仍然是非常有用的,因此混合風格的編程方式才能在現今展露頭角。


3會員
23內容數
這不是教你如何從物件導向到函數式編程的入門教程。我會深入探討物件導向與函數式編程的差異,並討論為什麼你應該使用函數式編程並徹底放棄物件導向。
留言0
查看全部
發表第一個留言支持創作者!