iThome 網站首載:不可變動性帶來的思維轉換
不可變動性(Immutability)是函數式風格中的基本特性,實際上函數式語言中不存在變數(Variable),程式中定義的名稱實際上是運算式(Expression)的代名詞,如果在命令式(Imperative)語言中導入不可變動性,會立即帶來一連串的思考方式與設計風格轉變。
- 變數不可變動後易於代換與組合
在命令式語言中,宣告變數的本質是在配置記憶體空間,並取適當名稱以方便存取。如果變數值可變動,程式演算設計時就需不斷追蹤變數值,設計焦點容易被轉移至記憶變數的前後狀態。如果從函式為層次來看,變數可變動,代表著容易設計出貫穿函式前後的流程,而不易將函式要解決的問題分解為更小的子問題。如果函式引用了可變動的非區域變數,該函式將會受到副作用(Side effect)影響,也就是除了參數與傳回值外,還會受到不可見的輸入或輸出影響,給予函式相同引數,有可能傳回不同運算結果。
在命令式語言中讓變數不可變動,變數就變化為運算式的代名詞,程式演算設計時就不需追蹤變數值,程式中每行程式將成為單純的變數代換。如果從函式的層次來看,變數不可變動時,每個程式片段就易於分解為更小的片段,因為既然能夠代換運算式中的變數,也就可以將數個相關運算式集合成另一個子函式,被代換的變數就成了函式的參數,原運算式的結果就成了函式的傳回值。
變數一旦不可變動,即使引用了非區域變數,函式也不會有副作用,函式的運算結果可單純看傳入引數為何,引用的函式不需擔心將來哪個時間點行為會改變,開發者容易測試函式的正確性,由於具備前述的引用透明度(Referential transparency),代表著使用子函式組合為更複雜的函式時,行為上更容易預測與驗證。
- 物件不可變動後免於追蹤複雜的副作用集合
如果從物件的層次來看,值域(Field)變數若可變動,引用相關值域變數的方法就會具有副作用,可變動的值域變數相對於方法,就像是可變動的全域變數,此類物件將會是副作用集合體,副作用的形式會以變化多端的物件狀態來呈現,追蹤變數的難度提昇至追蹤數個值域變數組成的物件狀態,在多執行緒共用存取的情況下,維持物件狀態的同步將會更為困難。
如果物件值域變數不可變動,不可變動性就會從變數層次提昇為不可變動物件(Immutable object)。物件狀態不會變化,就沒有追蹤物件狀態的問題,也不會有多執行緒下共用存取的問題。在具備物件特性的語言中,不可變動性不單是指區域變數無法改變,還包括了物件狀態無法改變。例如宣告final int[] nums = {1, 2, 3},而後進行nums[0] = 10,這並不符合不可變動的要求,因為其副作用就是改變了nums參考的陣列物件狀態。
如果物件不可變動,那物件操作後的狀態變數如何保存?答案是產生新物件來保有操作後的結果。舉例來說,Java中Collections的sort方法,會直接改變傳入List的狀態來保有排序結果,因此該方法對傳入的陣列會有副作用,其方法簽署亦無定義傳回值。如果要以不可變動概念設計,應該產生新的List並傳回,該List中包括已排序結果。
- 導入不可變動性後帶來的流程控制轉換
一旦導入變數不可變動或是更高層次的物件不可變動,流程控制語法就會發生變化。最基本的變化就是不再需要迴圈,因為迴圈會調整變數值或物件狀態,其本質上就會產生副作用。有時開發者會在迴圈中對數個變數或物件進行改變,使得演算流程趨於複雜,看不清楚迴圈中其實同時處理了數個子問題。
迴圈語法的存在是為了處理重複性問題,其實每一次的重複操作就是一個子操作,也就代表著那些重複性操作可以獨立出來成為函式,重複操作代表著重複呼叫函式,因此沒有迴圈語法之後,因應的解決方式基本上就是使用遞迴。例如,求List<Integer>中最大值若用命令式風格,基本方式是在迴圈中逐一取得元素,如果比目前max值大,就將max設為該元素。如果不使用迴圈,則每次的子問題簡化為取得首元素,取得剩餘List中最大元素,傳回兩者中較大的值。例如:
public static Integer max(List<Integer> nums) {
if(nums.size() == 1) {
return nums.get(0);
} else {
Integer tailMax = max(nums.subList(1, nums.size())); // 遞迴
return nums.get(0).compareTo(tailMax) > 0 ? nums.get(0) : tailMax;
}
}
if(nums.size() == 1) {
return nums.get(0);
} else {
Integer tailMax = max(nums.subList(1, nums.size())); // 遞迴
return nums.get(0).compareTo(tailMax) > 0 ? nums.get(0) : tailMax;
}
}
雖然可明確地進行遞迴處理來解決迴圈面對的問題,然而許多遞迴處理存在相同流程,因此予以封裝就可隱式地使用遞迴。例如上例實際可使用JDK8的reduce方法撰寫為nums.reduce(nums.get(0), (seed, elem) -> elem.compareTo(seed) > 0 ? elem : seed)。經常地,許多迴圈處理,都可以使用map、filter、reduce、anyMatch、allMatch等事先定義好的遞迴樣版方法,有些語言中還具備List表達式(comprehension),可以用更簡潔的語法達到map、filter的組合作用,避免明確地自行定義遞迴。
引入不可變動性後的流程控制變化,就是if..else必須是運算式需求,不是運算式的if..else語法會產生副作用,像是Java的if..else不是運算式,除了在函式的if..else分支中有return的情況,只要使用到if..else通常就會有副作用產生。雖然使用上不方便,不過Java中的?:運算子,可以達到if..else運算式的作用。函式具有為空(Null)語義的傳回值時,後續進行if..else變數設定也要改變。例如String name = selectBy(id)時,若selectBy有可能傳回null,就容易寫出if(name == null) name = "Guest"。如果要使用不可變動原則,可如下設計:
public static String getOrElse(String original, String replacement) {
return original == null ? replacement : original;
}
return original == null ? replacement : original;
}
之後就可改用String name = getOrElse(selectBy(id), "Guest")來達到不可變動原則。另一種方式是重新設計selectBy方法,使其傳回Option物件來包裹selectBy原本應傳回的值,Option包裹的值若不為null,呼叫Option實例的getOrElse("Guest")則直接傳回包裹的值,否則傳回getOrElse傳入的引數。例如:
String name = selectBy(id).getOrElse("Guest");
- 資料型態與思考方式的轉換
引入不可變動性後,流程的轉換是巨大的,主要原因在於被迫從不同角度來面對問題,往往能從中發現更多問題的本質與解決方式,有時可發現問題其實是由數個子問題所組成,甚至能直接看到解答就潛藏於問題描述之中。
在流程設計的轉換後,可能會更進一步地引發資料型態的轉換,這是因為發現問題本身並不適合使用為命令式設計的資料結構,這在我先前專欄「List處理模式」曾經探討過。對於習慣命令式寫法的開發者,不可變動性的設計方式並不直覺,但也因為這樣的不直覺,強迫開發者必須瞭解問題本身,並進一步於撰寫程式前作更多的規劃設計,而不是急就章地在原始碼檔案中胡亂地敲下程式碼,在編譯或執行之後瘋狂地開始除錯。