Go 語言中常見的幾種反模式

本文翻譯自 Saif Sadiq 的文章《Common anti-patterns in Go》[1]。

“程序員和詩人一樣,工作時只是稍稍脫離了純粹的思維定式。他在空氣中建造他的城堡,通過發揮想象力進行創作。很少有一種創作媒介是如此靈活,如此容易打磨和重做,如此容易實現宏大的概念結構”。

圖片來源:https://xkcd.com/844

這篇文章試圖探索上面漫畫中大問號的答案。編寫良好代碼的最簡單方法是避免在我們編寫的代碼中包含反模式。

0. 什麼是反模式

一個簡單的反模式示例就是編寫一個 API,而無需考慮該 API 的使用者如何使用它,如下面的示例 1 所述。意識到反模式並有意識地避免在編程時使用它們,這無疑是朝着更具可讀性和可維護性的代碼庫邁出的重要一步。在本文中,讓我們看一下 Go 中一些常見的反模式。

當編寫代碼時沒有未來的因素做出考慮時,就會出現反模式。反模式最初可能看起來是一個適當的問題解決方案,但是,實際上,隨着代碼庫的擴大,這些反模式會變得模糊不清,並給我們的代碼庫添加 “技術債務”。

反模式的一個簡單例子是,在編寫 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 循環中使用空白標識符,Go 規範中提到:

如果最後一個迭代變量是空白標識符,則 range 子句等效於沒有該標識符的同一子句。

// 反模式
for _ = range sequence { 
 run()
} 
x, _ := someMap[key] 
_ = <-ch 

// 推薦
for range something { 
 run()
} 

x := someMap[key] 
<-ch

3. 使用循環 / 多次 append 連接兩個切片

將多個切片附加到一個切片時,無需遍歷切片並一個接一個地附加 (append) 每個元素。相反,使用一個 append 語句執行此操作會更好,更有效率。

例如,下面的代碼段通過迭代遍歷元素逐個附加元素來連串連接 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 的這些參數已經具有默認值:

所以,

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 語句,則執行將進入下一個 case 語句中。但是,人們發現,fallthrough 的邏輯在 switch-case 中很少使用,並且經常會導致錯誤。因此,包括 Go 在內的許多現代編程語言都將 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 中實現 fallthrough 機制,我們可以使用 fallthrough 語句。例如,下面給出的代碼段將打印 23。

switch 2 {
case 1: 
 fmt.Print("1") 
 fallthrough
case 2: 
 fmt.Print("2") 
 fallthrough
case 3: fmt.Print("3")
}

7. 不使用輔助函數執行常見任務

對於一組特定的參數,某些函數具有一些特定表達方式,可以用來簡化效率,並帶來更好的理解 / 可讀性。

例如,在 Go 中,要等待多個 goroutine 完成,可以使用 sync.WaitGroup。通過將計數器的值 - 1 直至 0,以表示所有 goroutine 都已經執行完畢:

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 操作即可。如果我們打算在不阻塞地發送或接收操作的情況處理 channel 通信,則建議在 select 中添加一個 default case 以使該 select 語句變爲非阻塞狀態。

// 反模式
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 代碼中很多函數的(非常)常用參數,由於在邏輯上把常用參數放在參數列表的第一個或最後一個比較好。爲什麼這麼說呢?因爲它的使用模式統一,可以幫助我們記住包含該參數。在 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
}

參考資料

[1] 

《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 實戰:高可用集羣搭建、配置、運維與應用: https://coding.imooc.com/class/284.html

[6] 

我愛發短信: https://51smspush.com/

[7] 

鏈接地址: https://m.do.co/c/bff6eed92687

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KmKvwc0eeUuKAWASnTHBRA