本文主要從Pungyeon(Lasse Martin Jakobsen)的文章翻譯及增加或調整說明而來,原文在https://github.com/Pungyeon/clean-go-article,有興趣直接看原文的朋友可以連過去~因為覺得對我自己在入門Go語言後,增加及改進自己的Code style有很大的幫助,文中除了會說明相關的觀念外,並會以例子來說明,對於增加讀者的理解幫助很大,因此想翻成中文分享給一起學習golang的朋友。若有理解錯誤的部分也請不吝指正。此文的某些觀念也適用於其他程式語言,但有些則是因為golang的語言特性而有的情況,畢竟golang並非傳統上的OOP(Object-Oriented Programing)。另外,建議可以從程式碼例子先理解,有些概念用文字說明反而覺得更抽象。
正文開始
為甚麼要Clean Code
Code的乾淨最主要的好處就是可以避免別的工程師(及自己)花費許多寶貴的時間在了解自己所寫的program,尤其當系統越來越複雜、參與的人增多、時間拉久遠後,乾淨Code所能省下的工程師成本越高。
We don’t read code, we decode it – Peter Seibel
身為工程師,我們有時候會為了增加開發的速度及方便而沒走遵循最佳實踐(best practice)來寫code,因此而使得測試及code review更加困難。既然在coding,我們就是在將一個邏輯或一件事encode,因此別人讀我們的code就如同在decode,為了讓我們的code可用、可讀、可維護,就必須在寫code的時候使用正確的方式、而非簡單的方式。
甚麼是Clean Code
Clean Code是電腦程式的一個概念,作為推廣可讀、可維護的軟體的一個概念;Clean Code可以建立我們對於codebase的信任及幫助減少一些粗心的(且有時很難發現)bug的產生,同時也幫助工程師在持續擴大系統的同時能維持高效率的產出。
測試導向的開發(Test-Driven Development)
測試導向開發是一種透過較短的開發周期而對程式碼進行較常、較高頻率測試的方式,經由讓開發者去思考每個函示的功能性及各段程式碼的目的來幫助最終達成乾淨的程式碼。為了讓測試比較容易進行,建議開發者去寫較短、且只做一件事情的函數,通常來講,一個只有4行code的函數會比一個40行code的函數容易測試且易於理解。
測試導向開發包含了以下幾步:
- 寫(或執行) 一個測試
- 如果測試失敗了,找出原因並找出修改的方法
- 根據失敗的原因重構程式碼
- 重複上述
在這個過程中,測試與重構交互進行,當你重構程式碼以讓程式碼變得更易於理解或更容易維護,同時也需要測試修改過的程式碼以確保沒有不小心改到了程式或函數原本的功能及行為,而這些如同打地基般的工作是非常有用的,尤其當codebase越長越大時。
命名規則 (Naming Convention)
註解(comments, //)
首先,關於註解,很多人都會在程式碼中加上註解,但使用的方式怪怪的。不必要的註解通常反映出程式有一些問題,例如命名沒有取好,導致要寫註解來說明。當然,一段註解是不是”必要”是很主觀的,且依賴於程式碼是否寫得”清晰”,舉例來說,一段邏輯清晰的程式碼也許因為較複雜仍需要註解來說明該段程式碼進行了那些事情,有些人會認為這時有註解是很有幫助的,也是”必要的”。
根據gofmt,所有的公開變數及函數都應該被註記,這是沒問題的,只要有一致的規則讓我們知道如何進行。但是,這邊講的不是指這些用來自動化產生文件的註記(Annotation)。這邊所說的是有些人會用註解來說明一些程式的邏輯,尤其是複雜的程式。但是,通常這些註解並沒有幫助,很多人直接跳過註解不看,而且覺得這些註解很打擾閱讀程式碼,尤其當程式本身又不清楚乾淨,再加上這一堆註解。需要讀的東西越少就能讓人理解你的程式在做甚麼,越好。說了這些,讓我們來看看一些實際的例子:
你不應該這樣註解你的程式:
// iterate over the range 0 to 9 // and invoke the doSomething function // for each iteration for i := 0; i < 10; i++ { doSomething(i) }
這個例子屬於我稱它為教程式註解( tutorial comment),這樣的註解如同在說明或解釋golang程式的一些語法,也許對於golang的初學者有幫助,但對於一個要來維護此段code的開發者,通常都是不必要的說明,(當然,希望跟我們共事的開發者不是還在剛學golang的初學者),因此像這樣的註解也不應該出現在正式環境的codebase中。身為工程師,我們直接讀code就完全能知道我們從0到9執行10次doSomething。因此有句諺語說:
Document why, not how. – Venkat Subramaniam
照著這樣的思考,我們將上述的註解改成 why 我們要從0到9執行10次: 依序實例化10個執行緒
// instatiate 10 threads to handle upcoming work load for i := 0; i < 10; i++ { doSomething(i) }
上述的說明就比原本的有意義,這樣我們就能理解 why 我們有一個for loop及理解 what (要做甚麼),但是這還不夠clean,因為如果code本身寫得夠清楚的話,也許連這些白話文的註解都不需要。如下,我們使用更有意義的變數命名及函數命名,就能讓我們直接讀code也能完全理解這段code在做甚麼:
for workerID := 0; workerID < 10; workerID++ { instantiateThread(workerID) }
僅是命名的改變就能讓我們直接從程式碼理解這段在做甚麼,而不需要註解,對開發者閱讀程式碼更容易,同時也不需要將註解的散文再比對驗證程式碼,因為讀完這段程式碼的同時也完成了理解。
函數命名(Function Naming)
接著我們來看看函數的命名規則,一般性的規則很簡單: 我們希望從一個非常普通、範圍廣大而且很短的函數名來描述一般的功能性函數,例如Run 或Parse,而隨著函數的功能越特定,則開始用較特定、描述較多的命名;舉例來說,假設我們正在寫一個configuration的parser,我們的高階抽象會看起來如下:
func main() { configPath := flag.String("config-path", "", "configuration file path") flag.Parse() config, err := configuration.Parse(*configPath) ... }
請注意Parse函數,雖然這個函數的名字很短、很一般,但反而很清楚的說明它要幹甚麼。而當往下一層,我們的函數命名開始更特定:
func Parse(filePath string) (Config, error) { switch fileExtension(filePath) { case "json": return parseJSON(filePath) case "yaml": return parseYAML(filePath) case "toml": return parseTOML(filePath) default: return Config{}, ErrUnknownFileExtension } }
這裡,我們很清楚的能區分函數內的嵌套函數(ex parseJSON)與母函數(Parse),同時也能很清楚的感受到這個嵌套函數跟母函數是有關係的(也是個parser),而且更特定,假如我們將該嵌套函數命名為JSON,那我們單從名字就會看不出來這個函數是做甚麼的,是創造? 是解析?或是編碼JSON?
另外注意fileExtension這個函數名,它原本就是比較特定的,然而,這是因為事實上它的功能本質上就是特定的,從名字上也可以完全知道此函數要做甚麼:
func fileExtension(filePath string) string { segments := strings.Split(filePath, ".") return segments[len(segments)-1] }
如上述例子所展示的一個合乎邏輯的往下進展的函數命名-從高階抽象到低階、特定函數,能使程式碼更容易被理解及閱讀。考量一個不一樣的情況:如果我們一開始就對高階抽象函數命名的很特定,那我們得要把此函數所有的相關目標內容都涵蓋進去,那我們上例中的Parse可能就會被命名如: DetermineFileExtensionAndParseConfigurationFile
,你可以感受到這樣的方式不太好,雖然我們希望用命名讓該函數的作用清楚,但我們對高階函數命名的太特定且太快,反而最後導致讀者很辛苦。
變數命名(Variable Naming)
有趣的是,跟函數命名相反的,我們的變數命名隨著越往嵌套深的函數,反而應該是從比較特定到比較不特定。
You shouldn’t name your variables after their types for the same reason you wouldn’t name your pets ‘dog’ or ‘cat’. – Dave Cheney
為什麼我們的變數命名應該隨著我們的深入函數的域(function’s scope)變得更不特定呢?簡單來說,當一個變數的作用域變得更小,它對於讀者來說能更清楚的知道這個變數是幹嘛的,因此也就比較不需要用很特定的長命名來說明,例如前面的fileExtension 函數,我們甚至可以將segments這個變數改為 s 就好,因為它處理的事情及該變數代表的意義都是很清楚的,也就沒必要用特別的命名來描述,以下還有另一個嵌套迴圈的例子:
func PrintBrandsInList(brands []BeerBrand) { for _, b := range brands { fmt.Println(b) } }
在此例中,變數b的作用域很小很小,就在定義的下面一行結束,同時參與的事情也簡單,因此我們幾乎不需要花任何腦袋的力氣去記b是幹嘛的,但是相對的brands這個變數的作用域稍大一些(整個函數會用到),因此還是值得我們將該變數命名為brands這樣比較特定的名字,當我們擴大變數的作用域或函數變複雜時,會讓這件事情的好處更清楚,如下面的例子:
func BeerBrandListToBeerList(beerBrands []BeerBrand) []Beer { var beerList []Beer for _, brand := range beerBrands { for _, beer := range brand { beerList = append(beerList, beer) } } return beerList }
由於命名的好,上面的函數可以容易的知道在做甚麼事情(從多個brands中取得每個brand裡面的beer,然後組成beerList),從下面的反例中,可以更感受到若是以相反的命名規則來命名的話會是怎樣的:
func BeerBrandListToBeerList(b []BeerBrand) []Beer { var bl []Beer for _, beerBrand := range b { for _, beerBrandBeerName := range beerBrand { bl = append(bl, beerBrandBeerName) } } return bl }
雖然我們還是能理解這個函數在做甚麼,但過多的變數用縮寫命名導致我們讀起來變得辛苦,尤其在迴圈越多越深時,更加明顯的需要更多的腦力,而上面的這個例子混合了長命名(beerBrandBeerName)及短命名(b)也讓人更容易混亂。