iThome 網站首載:用函數式重構程式碼與演算法
自《重構--改善既有程式的設計》發行過後十幾年來,開發者對重構一詞已耳熟能詳,開發工具也普遍地支援重構手法,然而手法是其次,重點在於如何嗅出程式碼的臭味(Bad smell)與重構的方向。除了依賴經驗之外,若能借重函數式思考,不僅有助於程式碼的重構,有時獲得的啟發會連帶重構解題方向,從而影響演算法的設計。
- 無副作用函式分解邏輯泥塊
函數式設計的出發點,多半是將問題分解為子問題,再將子問題分解為更小的子問題,直到子問題容易處理,如此處理子問題的函式,邏輯上就較為簡單,也容易驗證函式的正確性。處理個別子問題的函式一旦完成,就可以在函式中組合這些子函式,而相關函式又會組合為更複雜的函式,從而解決整個問題。
在《重構》書中第一章首個影片出租店案例中,率先進行重構的對象,就是Customer類別中冗長的statement函式。函式中的程式碼越長,就越容易包含複雜邏輯,也越容易包含貫穿函式前後的變數,變數狀態追蹤也就越困難,從而越難理解程式碼的內容。Martin Fowler首先處理函式中的邏輯泥塊(Logical clump),重構既然是改善既有程式碼,若將既有程式碼視為問題本身,那麼從函數式角度來看,糾纏數個邏輯泥塊的冗長函式,絕對是值得大卸八塊而後快的對象。
在純函數式語言中,函式要根據引數計算結果,不能有副作用(Side effect)。將此概念應用在重構上,就是查看某邏輯泥塊前後的變數或物件狀態,泥塊中引用到的變數可考慮作為新函式的參數,泥塊最後的變數值或物件狀態,可以作為函式傳回值或函式執行結果,將泥塊移至函式中,並根據函式執行目的為函式命名,如此就可使用目的明確的函式來取代邏輯泥塊。在不具函數式特徵的語言中,不用強求函式中逐步演算都無副作用,可以函式為單位,僅要求以相同引數呼叫函式多次,都可得到的相同的結果。
冗長函式中可重構的邏輯泥塊,明顯的如if...else或switch組成的區塊、for、while迴圈結構,隱含的如數句相關陳述句(Statement)組成的程式區塊,因為運算式(Expression)會引用變數計算出結果,有時一條運算式也可能是提取為函式的對象。排版良好的程式碼很重要,面對冗長的程式碼,處理縮排於最內層的區塊會是不錯的開始;兩層以上的縮排暗示著可能處理了兩個以上的問題。
- 遞迴地分解迴圈問題
對於重複執行的動作,程式中可使用迴圈來解決。迴圈的本質就是變動的(mutable),也就是說迴圈必然伴隨著變數值、物件狀態變化、輸入輸出等副作用。在使用迴圈迭代一組冗長資料時,常容易基於效能等考量,在迴圈中順便作了些其他動作,使得迴圈常運用於同時處理多個問題,造成迴圈中的邏輯過於複雜而不易理解。經常地,迴圈中的程式碼往往是最難分解的邏輯泥塊。
必須使用迴圈解決的問題,基本上都可轉為遞迴來解決。在純函數式語言中變數或物件狀態不可變動(immutable),因而不存在迴圈語法,重複性問題必須使用遞迴解決,這強制開發者必須思考使用遞迴解決問題時,哪些條件下會停止、哪些單元必須在同一遞迴階段處理、哪些單元必須劃分至下一遞迴階段解決,每個單元需要的資料與傳回值為何,因而強迫開發者必須將問題中的重複動作為分為子問題。面對既有程式碼中具複雜邏輯的迴圈,改從遞迴角度來思考,可讓迴圈中邏輯泥塊間的界線自然浮現。
舉例而言,除了無窮迴圈外,迴圈基本上都有停止條件,轉為遞迴思考時,就必須將迴圈停止條件辨識為遞迴停止條件。如果迴圈中有多個停止條件,遞迴地思考會強制開發者思考,將相關的停止條件群組在一起。如果歸類出兩組以上的停止條件,通常代表此迴圈可以拆解為兩個以上的迴圈,從而設計出兩個以上的遞迴函式,也就是原先的迴圈中同時處理了兩個以上的子問題。一個函式中的程式碼只處理一個問題,子問題應交由子函式來解決,重構要求達成這個目的,函數式也要求如此思考。
如果可用遞迴來思考迴圈問題,那麼既有程式碼中出現巢狀迴圈,就代表它處理了兩個以上的問題,此時可將內層迴圈重構為函式,然後再以其為基礎將外層迴圈重構為函式。如果將迴圈重構後,發現兩個遞迴函式間交相呼叫,這暗示著原本演算法較為複雜而可重新設計。例如a函式呼叫b函式,b函式計算出某值用以呼叫a函式。由於函數式思考重點是將問題分解為子問題,既然b函式作了「計算某值」、「呼叫a函式」兩件事,何不重構演算法,設計b函式將a函式需要的值整組計算出來,讓b只作一件事,僅讓a函式針對計算出來的整組值進行遞迴。
- 分解問題是重構及函數式共同出發點
函數式的重點在於將問題分解為子問題,無副作用與遞迴只是實作時外在的形式。重構的重點在於將程式碼區塊分解為更小的獨立區塊,如此大函式就會生出小函式,如果小函式依舊複雜,運用相同手法就可分解出更多的小函式,這看似簡單的分而治之(Divide and conquer)觀念,其實是重構與函數式的共同出發點。
舉例而言,程式中最難聞的臭味之一,就是重複的程式碼。複製程式碼就是一種訊號,當開發者打算將函式中某段程式碼複製至另一函式時,就意謂著兩個函式處理了相同的子問題。如果函式早以分解出許多子函式,那麼就可用呼叫子函式來取代程式碼複製。反過來說,既有程式碼中兩個函式具有相同程式碼區塊時,就意謂著當初個別面對問題時,沒有仔細思考是否可分解出子問題。
除了完全相同的程式碼重複外,另一種重複是流程結構的重複。有些流程結構重複較易察覺,有些流程結構就不是那麼容易辨識。例如,從一組學生中取得分數列表,以及從一疊專輯中取得各專輯中長度最短的歌曲名稱列表,兩者間其實存在相同的流程結構。
函數式的思考方式有助於函式更徹底的分解,當子函式處理的問題夠集中,程式碼的流程結構就夠簡潔。有時在這些夠簡潔的小函式中才能發現,它們具有相同的流程結構,從而讓開發者意識到這些子問題間,在某些抽象層面上是相同的,只是因為面對的子問題略為不同,使得流程中少部份必須具體處理。如果必須具體處理的部份可以當作函式的引數傳入,那麼這些小函式就可以抽象化為單一函式,獲得更高的重用性,也讓開發者下次面對問題時,可用相同抽象層次來思考解決方式。
- 函數式地思考,命令式地實作
當面對複雜問題不知如何重構時,函數式思考是一種啟發,因為面對問題時都分解為子問題,使得子問題間的同質性顯露出來,察覺這種同質性才是函數式思考的目的。
如果語言具備函數式語言的特徵,結合函數式思考來重構,可更善加語言中的函數式特徵。在不具備函數式特徵的語言,亦不用勉強在實作上全面採用函數式,只要在以函數式思考獲得啟發後,讓實作具備函數式的精神。例如,在基於可讀性或效能等考量下,只要區域變數存在於夠精簡的函式中;就不一定得是不可變動;如果物件狀態改變只限於短函式中,物件也可以是可變的;若迴圈簡短且僅處理單一問題,亦不見得要以遞迴取代。