函數程式設計 Functional Programming
函數程式設計是一種程式設計方法。函數程式設計的核心裡面是用純粹的函數來表達所有的程式流程,而不是物件(OOP)。
函數程式設計其實離我們並不遙遠,並且廣泛存在深度學習框架之中。在大部分的深度學習框架都會將每個運算定義成一個函數(Function),而深度學習網路就是一堆函數的組合。深度學習網路的輸出就是由網路的輸入所決定,中間的所有權重也不會因為 inference 推理而改變。
用函數程式設計寫出來的程式有幾個好處:
- 程式碼更簡潔
- 可維護性高
- 容易測試
- 容易擴展成平行運算
- 比物件導向 OOP 更容易抽象化
函數程式設計的核心概念
函數程式設計的核心概念可以參考 GeeksForGeeks 的介紹,我覺得說得很清楚。我這裡從實務上的觀點來說明一下幾個核心概念。
1. Pure Functions
Pure Function(純函數)的意思就是沒有 Side-Effect(副作用)的函數。一個純函數一定會返回輸出,而且這個輸出完全是由輸入來決定的,同時不會有其他副作用,例如改變輸入變數的值。
大部分的數學函數都是標準的 Pure Function。因為數學函數一定是由輸入經過數學運算而組成輸出。反過來說,容器類的函數就絕對不是 Pure Function,因為對容器操作之後,容器本身的內容一定會被改變。
下面舉個例子,pure_function.py 裡面示範了如何寫出一個增加 List 元素的 Pure Function,輸入與輸出都是可預期的;相反的,side_effect.py 裡面改變了輸入的變數 (foo),這使得程式碼產生了預期之外的結果,且可能產生 bug。
如果你有過大型的程式設計經驗,你可能會發現大部分的 bug 都是由於 Side-Effect 產生的;這也讓程式碼變得非常「不可信任」,你必須追根究柢的深入每一行才能確保程式的運行正確。遵守 Pure Function 的概念可以降低 bug 的產生,也可以增加開發效率。
在雲端服務的程式設計中非常推崇 Pure Function,因為這種設計使得同一個函數可以被同時部屬到多個雲端服務器上,可以輕易完成平行運算與服務擴容。
2. Recursion
在函數程式設計裡面會盡量減少迴圈的使用,反過來我們會使用 Recursion(遞迴)。在我的經驗中,如果在程式碼裡面看到很長的 for 迴圈,那就很有可能藏著 bug。因為 for 迴圈中通常包含迴圈以外的其他變數,如果把迴圈內的程式碼視為一個函數,那麼這個函數很有可能具備 Side-Effect,導致預期外的 bug 發生。
老實說,使用遞迴對於新手 Programmer 而言是比較困難的,而且可能會比使用 for 迴圈更容易引入 bug。在函數程式設計裡面,我覺得並不一定要用 Recursion 來實作,相反的你也可以用類似 C++ 的 for_each 或是 Python 的 map 來完成,一樣符合函數程式設計的理念。
3. Referential transparency.
這個概念認為所有的變數初始化之後就應該不被改變,所有被改變的變數都應該成為一個新的變數。這個想法其實是 Pure Function 的概念延伸到變數的層級。
雖然從程式碼而言,這種寫法會增加記憶體的消耗,但是現在的編譯器都會針對這種情況做優化,因此對效能的影響其實很小。
4. Functions are First-Class and can be Higher-Order
在函數程式設計裡面,函數可以被視為一個 Class,也可以被視為一個變數。這個概念讓函數程式設計可以很簡單的定義複雜的流程,並且可以在 runtime 變更程式碼的流程 — — 這個是傳統 OOP 很難做到的。
下面是一個用遞迴來實現神經網路 forward propagation 的範例。在這個範例中,Conv2D, ReLU等等的運算函數都被宣告一個 Class 被建立與初始化、被定義成變數存在 list 內,並傳送到另一個函數 (forward) 裡面。
從這個範例可以看出,深度學習框架之中其實都存在著函數程式設計的影子,甚至可以說是深度學習框架的核心設計理念。
5. Variables are Immutable
這個概念與第三點我覺得是相同的,只是換句算說。但是 Immutable 這個字在平行程式設計裡面會讓仍更有體悟。
在平行程式設計裡面,如果所有變數都是 Immutable,則代表這段程式必定為 Thread-Safe。反過來,只要有任何一個 mutable 的變數出現,就需要去關心這個變數的同步、存取、上鎖等問題,當有多個變數與同步鎖出現的時候還要提防死結(deadlock)的出現。
狀態機 State Machine
狀態機是邏輯程式控制裡面非常基礎的一個模型,它可以根據輸入的數值與當前的狀態來控制輸出的狀態。我們的生活之中有不少控制元件都可以利用狀態機來表示,例如:多段電燈開關、紅綠燈、甚至是人的狀態。
一個功能完備的狀態機可以做得非常複雜,例如 XState 就是 JS 裡面非常完善的狀態機引擎。但是在大多數的情況下,我們可能不需要這麼厚重的狀態機。因此我來用函數程式設計來挑戰一下最簡單卻也最通用的狀態機引擎。
最簡單的狀態機引擎
直接上 Source Code,總共只有 9 行:
在這個狀態機 FSM 實作中,分為兩種狀態切換方法:一種是列舉狀態切換,一種是通用狀態切換。
- 列舉狀態切換:self.transition 是一個基於 key-value 映射定義的狀態切換表。在這個設計之中,輸入與當前狀態會組成一個 key,並且透過 value 直接得到對應的狀態輸出。
- 通用狀態切換:對於列舉沒有辦法涵蓋的範圍,可以透過self.general_transition 定義應有的狀態切換。self.general_transition 被定義成一個函數,而這個函數可以透過外部傳入 function 來定義。
值得注意的地方是,FSM 一個 stateless(無狀態)的狀態機,這確保它的 member function 都符合函數程式設計的 Pure Function 概念。
範例:紅綠燈燈號控制
透過函數程式設計的好處是,當你在建立一個實體物件的時候,並不需要透過繼承的方式來定義函數行為,而可以直接插入函數。下面是一個紅綠燈狀態控制的狀態機實體:
第 2 行到第 6 行展示了如何用 key-value map 來定義列舉狀態切換。在這個例子中,定義了當各種燈號秒數歸零時所應該切換到的燈號與秒數。
第 8 行到第 11 行展示了如何使用函數程式設計來定義通用狀態切換。在這個例子中,通用狀態負責秒數的遞減。
第 14 行到第 17 行則示範了如何定義初始狀態,並且如何進行狀態切換。
在 C++, Java 等靜態語言中,使用函數程式設計通常比 OOP 的 override virtual function 更具有自由度。在 OOP 中,如果發現中間的 virtual function 概念不正確,回頭改動往往需要牽一髮動全身;而在函數程式設計裡面,變更函數通常都很簡單,因為我們不需要繼承父類的 Class 來實現自訂函數。
範例:走過紅綠燈的行人
接續上面的範例,行人也可以描述成一個狀態機。在這個範例中,行人的狀態(停、走、跑)會根據紅綠燈目前的狀態而改變:
第 2 行到第 8 行展示了如何用 key-value map 來定義列舉狀態切換。在這個例子中,雖然燈號+行人狀態總共有 9 種排列組合,但是其中需要定義的狀態切換只有 4 種,其他的則交給通用狀態切換處理。
第 9 行展示的通用狀態切換,其實就是忽略紅綠燈狀態(args[0])並返回當前的行人狀態(args[1])而已。
第 12 行到第 19 行示範了紅綠燈與行人的狀態如何互相影響。這裡展現出了stateless 狀態機的優點,light 變數可以直接從 control_light 傳送到 people.process 中。
如果是使用 C++/Java 等靜態語言中,狀態機的狀態獲取與傳遞可能會變得更加困難,甚至還要考慮變數型別轉換的問題;然而透過函數程式設計實作的 stateless 狀態機讓我們完全解決了這些問題。這個範例顯示了函數程式設計的高擴展性、易讀性、可測試性與可維護性。