Go 中常見的幾種反模式
轉載自 TonyBai
本文翻譯自Saif Sadiq的文章《Common anti-patterns in Go》[1]。
眾所周知,編碼是一門藝術,就像每個擁有精湛藝術并為之感到驕傲的工匠一樣,我們作為開發(fā)人員也為我們編寫的代碼感到自豪。為了獲得最佳效果,藝術家不斷尋找可提高其手藝的方法和工具。同樣,作為開發(fā)人員,我們也在不斷提高自己的技能,并對"如何寫出好的代碼"這個最重要的問題的答案保持好奇。
弗雷德里克·布魯克斯(Frederick P. Brooks)在他的書《人月神話》[2]中寫道:
“程序員和詩人一樣,工作時只是稍稍脫離了純粹的思維定式。他在空氣中建造他的城堡,通過發(fā)揮想象力進行創(chuàng)作。很少有一種創(chuàng)作媒介是如此靈活,如此容易打磨和重做,如此容易實現(xiàn)宏大的概念結構”。

這篇文章試圖探索上面漫畫中大問號的答案。編寫良好代碼的最簡單方法是避免在我們編寫的代碼中包含反模式。
0. 什么是反模式
一個簡單的反模式示例就是編寫一個API,而無需考慮該API的使用者如何使用它,如下面的示例1所述。意識到反模式并有意識地避免在編程時使用它們,這無疑是朝著更具可讀性和可維護性的代碼庫邁出的重要一步。在本文中,讓我們看一下Go中一些常見的反模式。
當編寫代碼時沒有未來的因素做出考慮時,就會出現(xiàn)反模式。反模式最初可能看起來是一個適當的問題解決方案,但是,實際上,隨著代碼庫的擴大,這些反模式會變得模糊不清,并給我們的代碼庫添加“技術債務”。
反模式的一個簡單例子是,在編寫API時不考慮API的消費者如何使用它,就如下面例1那樣。意識到反模式,并在編程時有意識地避免使用它們,肯定是邁向更可讀和可維護的代碼庫的重要一步。在這篇文章中,我們來看看Go中常見的幾種反模式。
1. 從導出函數(exported function)返回未導出類型(unexported type)的值
在Go中,要導出(export)任何一個字段(field)或變量(variable),我們都需要確保其名稱是以大寫字母開頭。導出(export)它們的動機是使它們對其他包可見。例如,如果要使用math包中的Pi函數,我們將其定義為math.Pi。而使用math.pi將無法正常工作,并且會報錯。
以小寫字母開頭的名稱(結構字段,函數或變量)不會被導出,并且僅在定義它們的包內可見。
使用返回未導出類型值的導出函數或方法可能會令人沮喪,因為其他包中的該函數的調用者將不得不再次定義一個類型才能使用它。
// 反模式
type unexportedType string
func ExportedFunc() unexportedType {
return unexportedType("some string")
}
// 推薦
type ExportedType string
func ExportedFunc() ExportedType {
return ExportedType("some string")
}
2. 空白標識符的不必要使用
在各種情況下,將值賦值給空白標識符是不需要,也沒有必要的。如果在for循環(huán)中使用空白標識符,Go規(guī)范中提到:
如果最后一個迭代變量是空白標識符,則range子句等效于沒有該標識符的同一子句。
// 反模式
for _ = range sequence {
run()
}
x, _ := someMap[key]
_ = <-ch
// 推薦
for range something {
run()
}
x := someMap[key]
<-ch
3. 使用循環(huán)/多次append連接兩個切片
將多個切片附加到一個切片時,無需遍歷切片并一個接一個地附加(append)每個元素。相反,使用一個append語句執(zhí)行此操作會更好,更有效率。
例如,下面的代碼段通過迭代遍歷元素逐個附加元素來連串連接sliceOne和sliceTwo:
for _, v := range sliceTwo {
sliceOne = append(sliceOne, v)
}
但是,由于我們知道append是一個變長參數函數[3],我們可以使用零個或多個參數來調用它。因此,可以僅使用一個append函數調用來以更簡單的方式重寫上面的示例,如下所示:
sliceOne = append(sliceOne, sliceTwo…)
4. make調用中的冗余參數
該make函數是一個特殊的內置函數,用于分配和初始化map、slice或chan類型的對象。為了使用make初始化切片,我們必須提供切片的類型、切片的長度以及切片的容量作為參數。在使用make初始化map的情況下,我們需要傳遞map的大小作為參數。
但是,make的這些參數已經具有默認值:
對于channel,緩沖區(qū)容量默認為零(不帶緩沖)。 對于map,分配的大小默認為較小的起始大小。 對于切片,如果省略容量,則容量參數的值默認為與長度相等。
所以,
ch = make(chan int, 0)
sl = make([]int, 1, 1)
可以改寫為:
ch = make(chan int)
sl = make([]int, 1)
但是,出于調試或方便數學計算或平臺特定代碼的目的,將具名常量與channel一起使用不被視為反模式。
const c = 0
ch = make(chan int, c) // 不是反模式
5. 函數中無用的return
return在沒有返回值的函數中作為最終語句不是一種好習慣。
// 沒用的return,不推薦
func alwaysPrintFoofoo() {
fmt.Println("foofoo")
return
}
// 推薦
func alwaysPrintFoo() {
fmt.Println("foofoo")
}
但是,具名返回值的return不應與無用的return相混淆。下面的return語句實際上返回了一個值。
func printAndReturnFoofoo() (foofoo string) {
foofoo := "foofoo"
fmt.Println(foofoo)
return
}
6. switch語句中無用的break語句
在Go中,switch語句不會自動fallthrough。在像C這樣的編程語言中,如果前一個case語句塊中缺少break語句,則執(zhí)行將進入下一個case語句中。但是,人們發(fā)現(xiàn),fallthrough的邏輯在switch-case中很少使用,并且經常會導致錯誤。因此,包括Go在內的許多現(xiàn)代編程語言都將switch-case的默認邏輯改為不fallthrough。
因此,在一個case case語句中,不需要將break語句作為最終語句。以下兩個示例的行為相同。
反模式:
switch s {
case 1:
fmt.Println("case one")
break
case 2:
fmt.Println("case two")
}
好的模式:
switch s {
case 1:
fmt.Println("case one")
case 2:
fmt.Println("case two")
}
但是,為了在Go中switch-case中實現(xiàn)fallthrough機制,我們可以使用fallthrough語句。例如,下面給出的代碼段將打印23。
switch 2 {
case 1:
fmt.Print("1")
fallthrough
case 2:
fmt.Print("2")
fallthrough
case 3: fmt.Print("3")
}
7. 不使用輔助函數執(zhí)行常見任務
對于一組特定的參數,某些函數具有一些特定表達方式,可以用來簡化效率,并帶來更好的理解/可讀性。
例如,在Go中,要等待多個goroutine完成,可以使用sync.WaitGroup。通過將計數器的值-1直至0,以表示所有goroutine都已經執(zhí)行完畢:
wg.Add(1) // ...some code
wg.Add(-1)
但使用sync包提供的輔助函數wg.Done()可以使代碼更簡單并容易理解。因為它本身會通知sync.WaitGroup所有goroutine即將完成,而無需我們手動將計數器減到0。
wg.Add(1)
// ...some code
wg.Done()
8. nil切片上的冗余檢查
nil切片的長度為零。因此,在計算切片的長度之前,無需檢查切片是否為nil切片。
例如,下面的nil檢查是不必要的。
if x != nil && len(x) != 0 { // do something
}
上面的代碼可以省略nil檢查,如下所示:
if len(x) != 0 { // do something
}
9. 太復雜的函數字面量
可以刪除僅調用單個函數且對函數內部的值沒有做任何修改的函數字面量,因為它們是多余的。可以改為在外部函數直接調用被調用的內部函數。
例如:
fn := func(x int, y int) int { return add(x, y) }
可以簡化為:
add(x, y)
譯注:原文少了簡化后的代碼,這里根據譯者的理解補充的。
10. 使用僅有一個case語句的select語句
select語句使goroutine等待多個通信操作。但是,如果只有一個case語句,實際上我們不需要使用select語句。在這種情況下,使用簡單send或receive操作即可。如果我們打算在不阻塞地發(fā)送或接收操作的情況處理channel通信,則建議在select中添加一個default case以使該select語句變?yōu)榉亲枞麪顟B(tài)。
// 反模式
select {
case x := <-ch: fmt.Println(x)
}
// 推薦
x := <-ch
fmt.Println(x)
使用default:
select {
case x := <-ch:
fmt.Println(x)
default:
fmt.Println("default")
}
11. context.Context應該是函數的第一個參數
context.Context應該是第一個參數,一般命名為ctx.ctx應該是Go代碼中很多函數的(非常)常用參數,由于在邏輯上把常用參數放在參數列表的第一個或最后一個比較好。為什么這么說呢?因為它的使用模式統(tǒng)一,可以幫助我們記住包含該參數。在Go中,由于變量可能只是參數列表中的最后一個,因此建議將context.Context作為第一個參數。各種項目,甚至Node.js等都有一些約定,比如錯誤先回調。因此,context.Context應該永遠是函數的第一個參數,這是一個慣例。
// 反模式
func badPatternFunc(k favContextKey, ctx context.Context) {
// do something
}
// 推薦
func goodPatternFunc(ctx context.Context, k favContextKey) {
// do something
}參考資料
《Common anti-patterns in Go》: https://deepsourcehq.hashnode.dev/common-anti-patterns-in-go
[2]《人月神話》: https://book.douban.com/subject/26358448/
[3]變長參數函數: https://www.imooc.com/read/87/article/2424
[4]改善Go語?編程質量的50個有效實踐: https://www.imooc.com/read/87
[5]Kubernetes實戰(zhàn):高可用集群搭建、配置、運維與應用: https://coding.imooc.com/class/284.html
[6]我愛發(fā)短信: https://51smspush.com/
[7]鏈接地址: https://m.do.co/c/bff6eed92687

???
