這些常見的 Go 編碼錯誤,你犯過嗎(一)?

煎魚發表於2022-05-16

大家好,我是煎魚。

在用 Go 程式設計時,總會遇到各種奇奇怪怪的錯誤,國內外已經有許多小夥伴總結過(參考連結見參考),感覺都能湊一桌了。

之前一直想寫,想著五一假期肝一肝。今天給大家分享 Go 裡常見的編碼錯誤(一),希望對大家有所幫助。

Go 常見錯誤

1. nil Map

問題

在程式中宣告(定義)了一個 map,然後直接寫入資料。如下程式碼:

func main() {
    var m map[string]string
    m["煎魚"] = "進腦子了"
}

輸出結果:

panic: assignment to entry in nil map

會直接丟擲一個 panic。

解決方法

解決方法其實就是要宣告並初始化,Go 裡標準寫法是呼叫 make 函式就可以了。如下程式碼:

func main() {
    m := make(map[string]string)
    m["煎魚"] = "下班了"
}

這個問題在初學 Go 時是最容易踩到的錯誤。

2. 空指標的引用

問題

我們在 Go 經常會利用結構體去宣告一系列的方法,他看起來向物件導向中的 ”類“,在業務程式碼中非常常見。

如下程式碼:

type Point struct {
    X, Y float64
}

func (p *Point) Abs() float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

func main() {
    var p *Point
    fmt.Println(p.Abs())
}

這段程式能夠正常執行嗎?正常計算和輸出?

輸出結果:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x10a3143]

goroutine 1 [running]:
main.(*Point).Abs(...)
        /Users/eddycjy/awesomeProject/main.go:13
main.main()
        /Users/eddycj/awesomeProject/main.go:18 +0x23

直接就恐慌了,由於空指標的引用。

解決方法

如果變數 p 是一個指標,則必須要進行初始化才可以進行呼叫。如下程式碼:

func main() {
    var p *Point = new(Point)
    fmt.Println(p.Abs())
}

又或是用值物件的方法來解決:

func main() {
    var p Point // has zero value Point{X:0, Y:0}
    fmt.Println(p.Abs())
}

3. 使用對迴圈迭代器變數的引用

問題

在 Go 中,迴圈迭代器變數是一個單一的變數,在每個迴圈迭代中取不同的值。這如果使用不當,可能會導致非預期的行為。

如下程式碼:

func main() {
    var out []*int
    for i := 0; i < 3; i++ {
        out = append(out, &i)
    }
    fmt.Println("Values:", *out[0], *out[1], *out[2])
    fmt.Println("Addresses:", out[0], out[1], out[2])
}

輸出結果是什麼。大膽猜想值是 1,2,3,地址都是不一樣的。對嗎?

輸出結果:

Values: 3 3 3
Addresses: 0x40e020 0x40e020 0x40e020

值都是 3,地址都是同一個指向。

解決方法

其中一種解決方法是將迴圈變數複製到一個新變數中:

 for i := 0; i < 3; i++ {
     i := i // Copy i into a new variable.
     out = append(out, &i)
 }

輸出結果:

Values: 0 1 2
Addresses: 0x40e020 0x40e024 0x40e028

原因是:在每次迭代中,我們將 i 的地址追加到 out 切片中,但由於它是同一個變數,我們實際上追加的是相同的地址,該地址最終包含分配給 i 的最後一個值。

所以只需要拷貝一份,讓兩者脫離關聯就可以了。

4. 在迴圈迭代器變數上使用 goroutine

問題

在 Go 中進行迴圈時,我們經常會使用 goroutine 來併發處理資料。最經典的就是會結合閉包來編寫業務邏輯。

如下程式碼:

values := []int{1, 2, 3, 4, 5}
for _, val := range values {
    go func() {
        fmt.Println(val)
    }()
}

time.Sleep(time.Second)

但在實際的執行中,上述 for 迴圈可能無法達到您的預期,你想的可能是順序輸出切片中的值。

輸出的結果是:

5
5
4
5
5

你可能會看到每次迭代列印的最後一個元素,甚至你會發現,每次輸出的結果還不一樣...

如果去掉休眠程式碼,會發現 goroutine 可能根本不會開始執行,程式就結束了。

解決方法

這其實就是閉包使用上的一個常見問題,編寫該閉包迴圈的正確方法是:

values := []int{1, 2, 3, 4, 5}
    for _, val := range values {
        go func(val int) {
            fmt.Println(val)
        }(val)
    }

通過將 val 作為引數新增到閉包中,在每次迴圈時,變數 val 都會被儲存在 goroutine 的堆疊中,以確保最終 goroutine 執行時值是對的。

當然,這裡還有一個隱性問題。大家總會以為是按順序輸出 1, 2, 3, 4, 5。其實不然,因為 goroutine 的執行是具有隨機性的,沒法確保順序。

注:經常會變形出現在許多 Go 的面試題當中,一旦複雜起來就容易讓人迷惑。

5. 陣列不會被改變

問題

切片和數字是我們在 Go 程式中應用最廣泛的資料型別,但他常常會有一些奇奇怪怪的問題。

如下程式碼:

func Foo(a [2]int) {
    a[0] = 8
}

func main() {
    a := [2]int{1, 2}
    Foo(a)       
    fmt.Println(a) 
}

輸出結是什麼。是 [8 2],對嗎?

輸出結果:

[1 2]

這是為什麼,函式裡修改了個寂寞?

解決方法

實際上在 Go 中,所有的函式傳遞都是值傳遞。也就是將陣列傳遞給函式時,會複製該陣列。
如果真的是需要傳進函式內修改,可以改用切片。

如下程式碼:

func Foo(a []int) {
    if len(a) > 0 {
        a[0] = 8
    }
}

func main() {
    a := []int{1, 2}
    Foo(a)         
    fmt.Println(a)
}

輸出結果:

[8 2]

原因是:切片不會儲存任何的資料,他的底層 data 會指向一個底層陣列。因此在修改切片的元素時,會修改其底層陣列的相應元素,共享同一個底層陣列的其他切片會一併修改。

你以為這就萬事大吉,解決了?並不。當切片擴容時,Go 底層會重新申請新的更大空間,存在與原有切片分離的場景。

因此還是要及時將變更的值返回出來,在主流程上統一處理後設資料會更好。 

總結

在今天這篇文章中,我們開始了 Go 常見編碼錯誤的第一節,共涉及 5 個案例:

  1. nil Map。
  2. 空指標的引用。
  3. 使用對迴圈迭代器變數的引用。
  4. 在迴圈迭代器變數上使用 goroutine。
  5. 陣列不會被改變。

這些案例非常常見,在單一程式碼上看會比較容易發覺。但一旦混合到應用程式中,在繁雜程式碼裡就比較難看出來。

祝大家吸完後少踩坑,少出 BUG。

文章持續更新,可以微信搜【腦子進煎魚了】閱讀,本文 GitHub github.com/eddycjy/blog 已收錄,學習 Go 語言可以看 Go 學習地圖和路線,歡迎 Star 催更。

推薦閱讀

參考

相關文章