前言
函式是我們在開發過程中,用來區分與代表某些功能或邏輯的盒子,每一個函式都裝著對應的功能讓我們去重複使用。但如果只是單就一些想法或功能就草草寫成一個函式包著,在常態的開發過程中馬上會發現,不論是可讀性或修改性上,可能都不會是如此理想
而今天就要借助 Clean Code 這本書的力量,來幫助我們學習如何設計一個又好又乾淨的函式
簡短
第一項準則就是簡短,而為什麼簡短如此重要呢?想像一下,如果函式中包山包海,一定一眼沒辦法看清楚,這就容易導致除了開發者本身,其他的閱讀者要花費更多時間去理解這些邏輯與應用,就像是在解謎一樣
所以最好的函式,應是能簡短就簡短,最好是一到四行之間,而且要能透露
之中的意圖,或是能帶領你前往下一層級的函式。若是你的函式超過四行以上,也許應該想辦法盡可能的去簡化
只做一件事情
一個函式只能做一件事情,一開始在書中看到這行話有點不太能接受,因為我誤以為是只能執行一個功能或是一段簡短的程式碼。但這一件事情的意思是指抽象層面的,舉例來說,我們使用電腦開機和關機,在意圖的抽象層面上是一件事情,但開機與關機對電腦來說肯定不是簡單的事情,在開機時有很多初始化的過程要進行,在關機時要確保資料的保存與設定等等
fun open() {
init()
connectInternet()
getDataFromDB()
}
fun close(){
saveSetting()
saveData()
disconnectInternet()
}
簡單來說,如果這函式只做了函式名稱下「同一層抽象概念」的幾個步驟,那麼,這個函式就算是只做了一件事情。畢竟我們撰寫這個函式的原因,是因為想將一個較大的概念 ( 也就是函數的名稱 ),做為人所認知能感受的概念,將其分解成多個步驟來告訴電腦幫助我們達到
因此,觀察函式是否做超過「一件事情」的另一種方法,是看你是否能夠從此函式中,提煉出另外一個新函式,但此新函式不能只是重新詮釋原函式的實現過程而已
所以當你在此函式中,特別去切分執行區塊或段落時,很明顯是做超過一件事情的徵兆。簡單說,當你在函式中,特別為了理解去拆分裡面的功能時,就應該把他提出來作為一個新的函式
每個函式只能有一個抽象概念
為了完善我們想對一個函式只能做一件事的設計,那就要秉持著一個函式裡面只能有「一個抽象概念」,這些概念之間可能會有高低之分,例如簡單的執行一個 UI 動畫或是複雜的資料運算
要是把這些不同層次的抽象概念混合在一個函式中,會讓人很難理解,也不利於我們的思考模式。這就像是當你在開車時,要你計算數學題目一樣,只能專注於思考一件事情 (一個抽象概念)
所以當發現一個函式中有多種不同抽象概念,應該把這些概念各自抽離成一個函式,讓每個函式遵守只能有一個抽象概念 (只做一件事情)的原則
由上而下的閱讀程式碼 ( 降層準則 )
上面我們已經知道,函式只能做一件事情,所以在閱讀的過程中當然會希望這些事情是有連貫與前後關係的,就像是我們在安排家事的處理順序清單
會希望每個函式後面都緊接著「下一層次的抽象概念」,所以就會接著一連串的函式,對應著抽象層次往下閱讀,這就稱之為「降層準則」
這概念其實廣泛出現在我們的生活之中,例如,我們開車並不需要知道汽車發動的原理,所應用的抽象層級是當前的方向盤、煞車和油門,如果今天汽車故障,就需要理解下一層的抽象概念才能解決,就如同我們在應用函式庫的時候,出現問題就要往下挖更深層的原始碼。降層原則的設計方式,得以讓我們有跡可循,不會迷失方向
使用具備描述能力或意圖的名稱
當每個你看到的程式,執行結果都與你想的差不多,你會查覺到你正工作在 Clean code 之上
在我們設計完讓函式只做一件事情後,函式的命名也就更加簡單了,因為你會清楚的知道此函式的抽象概念,而通常這抽象概念也就等同於其命名
雖然我們希望函式越簡短越好,但不代表其命名越短越好,所以在命名上不要避諱使「用較長的名稱」。一個雖然較長但具有敘述性質的名稱,一定比一個較短但難以理解的名稱來得好。例如使用一些自定義的簡寫或稱呼,除非是此領域的專有名詞,否則都會讓其他人誤會,寧願用長的名稱來詳細描述功能
命名上可以使用一些規則或慣例,可以使用多個易讀懂的字詞組合,使函式能自我說明意圖。別害怕花過多的時間命名,選擇一個具有描述性質的名稱,能再往後回顧或改良時,能快速反應。也盡量在單一模組中,使用一致性的命名、片語、名詞或動詞
函式的參數
函式的參數數量,最理想的是零個,再來是一個,依序類推。可以的話盡量避免使用三個以上。這是因為參數會透露此函式的概念或邏輯,會讓使用者花更多的時間去閱讀理解,所有最理想的情況就是沒有任何參數。用以下例子來說明:
fun getUser(id:Int,name:Int,password:String):User
假如有一個方法可已拿到 User 的資料,但必須傳遞三個相關的參數來獲得,雖然感覺起來很合理,但以參數的方式傳遞它,而不是使用實體變數,那麼,讀者每次看到它,都得重新詮釋它,參數和函式處於不同的抽象層次,而且參數強迫你去了解目前並不那麼重要的細節
從測試角度來看,使用參數是件更困難的事情。想像一下,要寫出一個測試案例,在所有參數可能的組合時,都能順利運作,是多麽困難的一件事。如果沒有參數的話,就能直接執行且簡單。隨著參數的多寡,會影響到我們測試函式的難度
而輸出型的參數比輸入型的參數更難以理解。當我們閱讀一個函式時,我們習慣於「參數是輸入到函式」的概念,而輸出值則是透過回傳值 ( return value ) 來傳遞。我們並不會預期回傳的資訊會透過參數來傳遞,所以輸出型的參數往往讓我們得反覆細看才能理解
單一參數
通常有兩個情況下,會使用到單一參數的形式
- 與這個參數有關的問題或需要此參數的動作
- 對這個參數進行某種操作,將該參數轉換成某種東西,然後回傳
這兩種用法,在看到函式時,所預期的結果。應該選擇能明顯區分這兩種理由的名稱,而且總在一致的上下文裡使用這樣的命名原則
有一個比較不普遍,但非常有用的單一參數型別,就是事件。在這種形式中,會有一種輸入型參數
,沒有任何的輸出型參數。整個程式刻意將函式呼叫看作是一個事件,並利用參數去修改系統的狀態。例如:用來代表密碼輸入失敗次數的方法,小心使用這樣的形式,並且必須讓讀者清楚了解到這是一個事件,謹慎地選擇名稱和上下文資訊
當函式需要帶入參數,且沒有回傳值,可稱作一個 Event 來命名
Boolean 參數
將一個布林值變數傳遞給函式,是非常恐怖的事情。馬上會使得方法的意圖變得複雜,等同於大聲宣布此函式做了不只一件事情。因為當布林值為 true 做了一件事情,當布林為 false 又做了另一件事情
對面這種情況,應該把 true 與 false 拆分為兩個函式,並在情境不同時執行
兩到三個參數的函式
上面提到,越多參數越讓我們難以理解函式,甚至其順序也會多次搞錯,像是我在撰寫單元測試時,多次使用 assertEqual的當下,搞錯了左右順序,直到熟悉後才習慣第一個參數是 expected 第二個是 actula,甚至某些方法上會忽略了某些參數,這樣就會導致預期外的狀況,也大多是 bug 的藏身之處
但有些情況反而是適合的,例如要設計一個登入功能的函式,登入在常態認知上都是需要帳號與密碼的,所以當登入功能只需要一個參數時,我們反而會疑惑,這種情境下就應該毫無顧忌的使用,反而讓使用者更加理解此函式
但如果真的有機會將函數縮減至一個甚至 0,都應該是我們努力的方向
物件型態的參數
通常現實中很難讓函式遵守在三個參數以下的規範之中,這時後就能將這些包裹成一個物件,濃縮成一個參數的形式。雖然看起來很像是作弊,只是在形式上看似縮減成一個參數,但其實不然。當一堆變數一起被傳遞時,他們是某種概念裡的相似部分,而這個概念應該獲得一個屬於它的名稱
輸出型的參數
參數在大部分的情況下,都自然地被解讀成函式的輸入。在遇到一個輸出型的參數時,通常會仔細再三解讀和檢查。例如 :
val s = "string"
appendFoolter(s)
這個函式真的是 s 接在某個東西後面嗎?還是這個函式會把某些頁尾加到 s 的後面?回頭去看這個函式的宣告署名,並不會花太多時間,請看 :
fun appendFoolter(report:StringBuffer)
只有當我們花時間去查看函式的宣告時,才能釐清我們的疑慮。任何迫使你查看函式署名的情況,都等同於 「再三檢查」。這中斷了我們的思考,要盡可能避免
整體而言,應該要避免使用輸出型參數。如果函式必須要改變物件的某種型態,就讓該物件改變其本身的狀態吧
函式命名上用動詞與關鍵字
替函式選一個好名稱,可以產生許多良好的附加價值,而如何寫出一個好函式名稱,就需要對應的動詞或關鍵字,來解釋函式的意圖與參數順序性。例如在單一參數的形式中,函式和參數要形成一個動詞、名詞的良好配對。舉例來說:寫入名稱 write(name) 函式就有這樣的效果。不管名稱是什麼,都會被寫入。我們還可以使用一個更好的函式名稱,將名稱寫入欄位 writeField( name ),更能告訴我們 「名稱 ( name ) 」是個「欄位 ( field )」
再來是關於關鍵字型式的函式命名。使用這樣的型式,代表我們也將「參數的名稱」編碼加入到函式名稱裡
舉例來說:assertEquals 的名稱改為 assertExpectedEqualsActual ( expected , actual ),會是更好的選擇,雖然名稱會較長,但這樣子就減輕了需要記住參數順序的負擔,也能更加快速的理解函式的意圖
函式要無任何副作用 ( Side Effect )
什麼是副作用?簡單來說,副作用是指函數在執行過程中對外界造成的影響。此規範是希望我們的函式是真的只做一件事而已,但通常都會在暗地偷偷也做了其他事情,我們毫無察覺,產生出非預期的結果
以下是常見的例子
- 修改外部變量或數據
- 使用輸入/輸出(I/O)操作,例如讀寫文件、打開/關閉網絡連接
- 使用全局變量
- 調用其他函數,特別是會導致副作用的函數
- 使用系統資源,例如網絡連接、數據庫連接等
這些都會導致在執行過程中,出現奇怪的順序或時空耦合,導致我們沒辦法快速理解,那些變數或屬性會因為那些函式的意圖而改變。在修改或查詢的過程中,通常都會耗費大量的時間跑完整個邏輯順序,才能找到這些 bug
而並不是所有有副作用的函數都是不好的設計。在某些情況下,副作用是必要的。例如,如果你要編寫一個函數來保存數據到數據庫,那麼這個函數必須對數據庫進行寫操作,否則就沒有意義。這樣的函數必然會導致副作用(即修改數據庫中的數據),但這是必要的
同樣地,如果你要編寫一個函數來從網絡下載文件,那麼這個函數必須使用網絡連接,否則就沒有意義。這也是一個必要的副作用
但是,應該盡量避免不必要的副作用,以使代碼更易於測試和維護
函式中的指令和查詢要分離
函式能夠做某件事情,或能回答某個問題,但兩者不該同時發生,我們依舊要專注在讓函「式只能做一件事情上」。那指令和查詢是兩件事情就必須分離,你的函式應該修改某物件的狀態,或回傳某些與物件有關的資訊,如果想同時完成這兩個目標,就會讓人感到困惑,如以下例子
fun set (attribute:String,value:String):Boolean
這個函式設定了某個屬性的值,當設定成功就會回傳true。若回傳 false 時,代表該屬性並不存在。
這樣子就出現了以下詭異的敘述 :
if( set ("username","Uncle Roger"))
這段敘述,是在表示說是否設定成功?還是已經被設定完成了?因為不知道 「set」是動詞還是形容詞,所以很難從這段呼叫中去推敲真正的意義
雖然本意上是要將 set 當作動詞,但內文的 if 敘述卻使之感覺像是一個形容詞。所以這段敘述像是在說 「如果 username 屬性已經在之前被設定成 “Uncle Roger ” 時」,而非 「將 username 設定成 Uncle Roger ,如果設定成功的話,接下來就….」
或許我們可以利用重新命名 set 函式來解決問題,但此舉對於增加 if 敘述的可讀性,沒有太大幫助。真正的解決辦法,是將指令 ( command ) 和查詢 ( Query )分開,才能避免模稜兩可的狀態
所以應該事先檢查此屬性是否存在,確認存在後在 set 改變 value,也就是將 set 所做的功能,與 if 判斷的過程拆分為兩個函式來表達
使用例外處理取代回傳錯誤碼
要指令型函式回傳錯誤碼,有點違反指令和查詢分離的原則,這代表鼓勵在 if 敘述的判別處,將指令型函式當作判斷表達式使用
這樣的用法雖然不會引起動詞、形容詞的困惑,但會導致更深層的巢狀結構。當你回傳一個錯誤碼,就是要求呼叫者馬上處理這個錯誤
從另一方面來說,如果使用「例外處理」取代回傳「錯誤結果」,那錯誤處理的程式碼就能從正常愉快的主要路徑中抽離出來,也簡化了程式碼
提取 Try / Catch 區塊
要把例外處理提取出來,那就是使用 try / catch ,但其區塊本身是難看的,在正常的程式運作中混入了錯誤處理,會混淆程式的結構。所以比較好的做法是,從函式中將 try 和 catch 區塊提取出來。假如今天要做一個 CRUD 的功能,使用 try / catch 捕捉,應該額外包裹成一個函式執行,如此作法提供了良好的區隔,使得程式更容易理解
錯誤處理就是一件事
上述我們一直提到函式應該只做一件事情,而錯誤處理就是一件事。所以,一個處理錯誤的函式,應該不能再做其他事情。這暗示著,如果函式中有 try / catch 存在,那就是錯誤處理的函式,而且在 catch / finally 區塊之後,理當不應有其他任何的程式碼。例如我們要從網路上下載資料
fun downloadFile(url: String) {
try {
val connection = URL(url).openConnection() as HttpURLConnection
// Download file using the connection
} catch (e: Exception) {
// Exception handling code
} finally {
connection.disconnect()
}
}
結構化程式設計
荷蘭電腦科學家狄克斯特拉(Dijkstra)說過,每個函式與函式中的區塊,都應該只有一個進入點與一個離開點。要遵守這個原則,代表在一個函式裡,只能有一個 return 敘述,迴圈內不能有任何的 break 和 continue 敘述,而且永遠不可以有 goto 敘述
在這個概念下,我們就能知道函式中的結構相當重要,在清楚規範了進出入的點,才能理解函式的運作流程。雖然大多數這個準則只有在遇到大型函式時才會體現出其益處
所以如果能保持函式的短小,那麼偶爾出現 return 、 break 或 continue 敘述並沒有壞處,而且有時候比單一進入點,單一離開點的準則更具有表達力。另一方面,goto 敘述只有在大型函式裡才有用,所以應該避免使用
要如何寫出一個好的函式?
寫軟體就如同其他任何寫作一樣,當寫一篇論文或文章時,是為了傳達一個抽象概念或觀點,會先直接把想法寫下來,然後開始用各種方式來敘述,有了雛形後,之後便將其中的語病和排序修改,直到讀起來很順通。第一份初稿通常是粗糙而雜亂無章的,所以你開始修改,重新組織整個文章段落,將文章改善至想要的樣子
寫程式的過程就像是將作為人的概念與想法,條列式的分為各種函式讓電腦理解我們的意圖與想要做到的功能。這一開始一定是又複雜又長,有很多的縮排和巢狀迴圈,有很長的參數串列,有著隨意的命名,也有重複的程式碼
但之後就開始重構和改善程式碼,將函式分開,重新命名,減少重複。縮短方法並重新安排順序。甚至打散整個類別,並持續保持單元測試能夠通過
最後,當函式符合上述所提及的準則時,就會結束這個函式的修改。我們不可能一開始就寫出完美的函式,甚至之後還要面臨各種重構,所以千萬不要覺得寫的好像很粗糙就不下手,就像璞玉一樣,要在你不斷的琢磨之後才會變成閃閃發光的鑽石
總結
每個系統都是由某個特定領域的語言設計而成的,而這種特定領域語言則是「程式設計師為了描述系統所設計的」。函式是這個語言裡的動詞,類別則是這個語言裡的名詞。系統的函式和類別,是需求文件裡名詞和動詞的猜測,這並不是突然冒出的可怕古代見解,更確切的說,這是更古老就存在的事實。程式設計的藝術,是,也永遠是,一種語言設計的藝術
程式設計大師在撰寫程式時,並不認為自己是在寫程式,而是在說故事。他們利用所選定的程式語言相關工具,幫助他們建造更豐富更有表達力的語言,讓這個語言可以用來說故事。這個特定領域語言的一部分是函式階段架構,也就是描述所有關於該系統所發生的行為。利用一些巧妙的遞迴技巧,這些行為使用它們定義的特定領域語言,來描述它們自己的那一小部分故事
如果遵照這些準則,你的函式會簡短,且有良好的命名以及漂亮的結構。但永遠不要忘記你真正的目標是在描述系統的故事,而你撰寫的函式必須要整潔地結合在一起,形成一種清楚又精確的語言,來幫助你講述故事