不可變資料:用 map 取代原地修改,認識持久化資料結構
這一篇進入新章節 Immutability。前面的章節一路強調純函數與無狀態,這一節把同樣的要求延伸到資料本身:不要修改資料。
什麼是修改資料
函數式程式設計希望程式具有決定性,盡量避免「東西在背後被改掉、自己卻沒預料到」的情況。純函數是這個想法的一部分,而它的另一個結果,就是要避免修改(mutate)資料。
講師開玩笑說,這裡的 mutate 不是指資料被放射性蜘蛛咬了、獲得超能力,而是指原地(in-place)修改資料:同一個實體,例如同一個陣列,裡面的值變得不一樣了。這種可以原地修改的性質就是 mutability。
在不可變(immutable)的世界裡,不做這件事。假設函數接收了一個陣列,絕對不去動那個原始陣列,而是用一個更新過的新版本取代它,原始版本保持原樣。
範例:替城市改名
講師用一個很簡單的陣列當例子,裡面是幾個印度城市的名字。其中有些名字非常過時,帶有殖民色彩,已經不再使用。例如講師父親長大的城市,在講師小時候叫 Bombay,現在則叫 Mumbai。所以這份資料需要更新。
原地修改的寫法
會修改資料的寫法,是直接把新值 'Mumbai' 指派給陣列索引 1 的元素。這在 JavaScript 裡應該很常見:
js
let cities = ['Delhi', 'Bombay', 'Bangalore'];
cities[1] = 'Mumbai';
cities; // ['Delhi', 'Mumbai', 'Bangalore']現在 cities 不一樣了:之前裡面有 Bombay,現在沒有,換成了 Mumbai。但對 JavaScript 來說,cities 本身還是同一個陣列,指向的是記憶體裡的同一個東西。
問題出在狀態
一個陣列或任何值在背後被改掉,就代表又出現了狀態:一個會隨時間改變的值。同一個 cities,現在指的卻是不同的資料。
如果有個函數在更新前、更新後各用了一次 cities,就會遇到有狀態程式帶來的頭痛問題。這正是迭代與遞迴那篇說函數式要避免狀態的原因:值會隨時間改變,就沒辦法光看輸入預測結果。
不修改的寫法:用 map 產生新陣列
比較好的做法是不更新舊的物件(這裡是陣列),而是建立一個裝著正確、更新後資料的新陣列。其中一種方式是用內建的 map,它相當純粹,會回傳新陣列、不會修改原陣列。也可以用前面自己實作的 map:
js
const oldCities = ['Delhi', 'Bombay', 'Bangalore'];
const newCities = oldCities.map((city) => {
if (city === 'Bombay') return 'Mumbai';
if (city === 'Bangalore') return 'Bengaluru';
return city;
});
newCities; // ['Delhi', 'Mumbai', 'Bengaluru']
oldCities; // ['Delhi', 'Bombay', 'Bangalore']map 會拿 oldCities 裡的每個元素,判斷要不要更新,需要的話就回傳對應的新值:
- Delhi:還是 Delhi,照原樣回傳
- Bombay:改名了,回傳 Mumbai
- Bangalore:改名了,回傳 Bengaluru
傳進高階函數 map 的內層函數就是轉換函數,只負責接收舊資料、回傳新資料。map 會對陣列裡每個元素做這件事,最後回傳一個套用了轉換的新陣列。
舊資料完全不變
關鍵在於 oldCities 從頭到尾都沒變。假設同事正在用 oldCities,而且要是所有值都被換掉,同事的測試就會失敗,現在這些測試依然沒問題。oldCities 還是 oldCities,newCities 則是以舊資料為基礎產生、可以拿來用的新資料。
一直複製的代價
這時可能會有疑問:陣列只有三個元素時當然沒問題,但每次用 map、filter 或 reduce 都會回傳新陣列,代表要騰出更多空間放更多資料。舊資料留著、新資料又加進程式裡,全部都得記在記憶體中,會開始佔掉不少空間。
資料很複雜時,效率也可能變差。這跟遞迴那篇遇到的狀況類似:輸入越來越大時,就開始出現效能問題。「做一份修改過的新副本,而不是修改原始資料」這個策略,在時間與空間上都可能變得沒效率。
不可變資料結構
許多函數式語言與工具都有一種策略(或者說一種東西)來處理這個問題,通常稱為不可變資料結構(immutable data structures),有時也叫持久化資料結構(persistent data structures)。
以這個例子來說,它是一種特殊的陣列,也可以想成特殊的物件之類的。替它建立副本時,比每次把陣列裡的每個元素全部複製一遍更有效率。講師覺得這是非常酷、非常有趣的實作細節,但這次不會花太多時間在上面。
用一般的資料結構時,更新一個有很多元素的陣列,就得配置一整個同樣長度的新陣列。不可變資料結構則內建了特殊的最佳化,只需要複製有變動的部分。
表面是陣列,底下是樹
持久化資料結構表面上看起來像陣列,底層其實是一棵樹。講師用一個動物園的例子說明:
- 有一個叫
zoo的陣列,依序是猴子、兔子、熊貓、熊,後面還有其他動物 - 一架 UFO 降落,把兔子壓扁了,但地球上多了一隻新動物:外星人
- 想做出一個只改一個值(兔子變外星人)的新陣列
new,但不想把所有沒變的東西都重新複製一份
持久化資料結構會把值分組,因為對每個值個別處理不一定有效率。假設這裡是兩個一組,建立 new 時,只要更新樹裡那個裝著兔子的節點,換成外星人。過程中還是有一點點複製,同一組的猴子被複製了一份。
除此之外,新的資料結構直接指向記憶體裡原本就有的其他舊資料,不必再建立一份新副本。
省下的是什麼
靠著底層這些很聰明的技巧,複製一個只改了一個值的新陣列會快很多。原陣列裡沒變的那些元素,不再需要花時間一個個配置新的記憶體,可以跳過所有沒變的部分,只更新需要更新的地方。講師說這個主題還有很多可以講,課程的延伸閱讀裡有相關連結。
JavaScript 沒有內建
有些函數式語言內建了這種資料結構,例如 Clojure,但 JavaScript 不是其中之一,沒有開箱即用的功能。
幸運的是,JavaScript 有一個龐大的生態系,很多人也想在 JavaScript 裡寫函數式程式,所以已經有人實作了這類東西,可以直接拿來用。
複習
程式設計裡的資料可變性(mutability)是什麼?
原地修改資料,例如直接修改陣列裡的值,而不是建立新陣列。在不可變的程式設計裡會避免這種做法,改成建立一個更新過的新版本資料。
函數式程式設計怎麼更新陣列裡的值?
不原地修改原始陣列,而是用 map() 這類方法建立一個包含所需變更的新陣列。map() 會回傳新陣列,不會修改原本的資料。
什麼是不可變資料結構或持久化資料結構?
一種特殊的資料結構,建立更新過的資料副本時,只複製並更新有變動的部分,藉此最佳化記憶體用量。通常實作成樹狀結構,共用沒有變動的資料參照。
為什麼在函數式程式設計裡,避免修改資料很重要?
修改資料可能造成難以預測的狀態變化,讓程式比較不具決定性。同一份資料在程式不同地方、不同時間點被使用時,也可能因此出問題。
持久化資料結構怎麼在產生更新資料時提升效能?
它利用巧妙的樹狀結構,只複製並更新有變動的元素,不需要配置與複製整個資料結構,因此減少了記憶體配置與處理時間。
在不可變資料結構的脈絡下,要用什麼策略有效率地建立更新後的陣列?
只複製資料結構中有變動的部分。
持久化資料結構的關鍵特性是什麼?
透過參照沒有變動的資料,把記憶體配置降到最低。
更新陣列裡的城市名稱時,示範了哪一種函數式程式設計技巧?
用 map 建立新陣列,而不是修改原本的陣列。
不可變資料結構幫忙解決了函數式程式設計裡的哪個挑戰?
建立新資料結構帶來的效能負擔。
小測驗
程式設計裡的資料可變性(mutability)是什麼?
原地修改資料,例如直接修改陣列裡的值,而不是建立新陣列。在不可變的程式設計裡會避免這種做法,改成建立一個更新過的新版本資料函數式程式設計怎麼更新陣列裡的值?
不原地修改原始陣列,而是用map() 這類方法建立一個包含所需變更的新陣列。map() 會回傳新陣列,不會修改原本的資料 什麼是不可變資料結構或持久化資料結構?
一種特殊的資料結構,建立更新過的資料副本時,只複製並更新有變動的部分,藉此最佳化記憶體用量。通常實作成樹狀結構,共用沒有變動的資料參照為什麼在函數式程式設計裡,避免修改資料很重要?
修改資料可能造成難以預測的狀態變化,讓程式比較不具決定性。同一份資料在程式不同地方、不同時間點被使用時,也可能因此出問題持久化資料結構怎麼在產生更新資料時提升效能?
它利用巧妙的樹狀結構,只複製並更新有變動的元素,不需要配置與複製整個資料結構,因此減少了記憶體配置與處理時間在不可變資料結構的脈絡下,要用什麼策略有效率地建立更新後的陣列?
只複製資料結構中有變動的部分持久化資料結構的關鍵特性是什麼?
透過參照沒有變動的資料,把記憶體配置降到最低更新陣列裡的城市名稱時,示範了哪一種函數式程式設計技巧?
用map 建立新陣列,而不是修改原本的陣列 不可變資料結構幫忙解決了函數式程式設計裡的哪個挑戰?
建立新資料結構帶來的效能負擔此文章是 FrontendMasters 上的 Functional JavaScript First Steps, v2 課程筆記
