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

bigwhite-github發表於2021-03-31

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

眾所周知,編碼是一門藝術,就像每個擁有精湛藝術併為之感到驕傲的工匠一樣,我們作為開發人員也為我們編寫的程式碼感到自豪。為了獲得最佳效果,藝術家不斷尋找可提高其手藝的方法和工具。同樣,作為開發人員,我們也在不斷提高自己的技能,並對"如何寫出好的程式碼"這個最重要的問題的答案保持好奇。

弗雷德裡克·布魯克斯(Frederick P. Brooks)在他的書《人月神話》中寫道:

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

圖片來源: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 是一個變長引數函式,我們可以使用零個或多個引數來呼叫它。因此,可以僅使用一個 append 函式呼叫來以更簡單的方式重寫上面的示例,如下所示:

sliceOne = append(sliceOne, sliceTwo…)

4. make 呼叫中的冗餘引數

該 make 函式是一個特殊的內建函式,用於分配和初始化 map、slice 或 chan 型別的物件。為了使用 make 初始化切片,我們必須提供切片的型別、切片的長度以及切片的容量作為引數。在使用 make 初始化 map 的情況下,我們需要傳遞 map 的大小作為引數。

但是,make 的這些引數已經具有預設值:

  • 對於 channel,緩衝區容量預設為零(不帶緩衝)。
  • 對於 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 語句,則執行將進入下一個 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
}

Go 技術專欄 “改善 Go 語⾔程式設計質量的 50 個有效實踐” 正在慕課網火熱熱銷中!本專欄主要滿足>廣大 gopher 關於 Go 語言進階的需求,圍繞如何寫出地道且高質量 Go 程式碼給出 50 條有效實踐建議,上線後收到一致好評!歡迎大家訂 閱!

我的網課 “Kubernetes 實戰:高可用叢集搭建、配置、運維與應用” 在慕課網熱賣>中,歡迎小夥伴們訂閱學習!

Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯絡方式:

更多原創文章乾貨分享,請關注公眾號
  • Go 語言中常見的幾種反模式
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章