Go 語言常見錯誤——方法函式

FunTester發表於2025-03-13

在 Go 語言中,方法和函式是核心概念,它們定義了程式的操作邏輯和行為。然而,在使用方法和函式時,開發者常常容易犯一些常見錯誤。例如,方法和函式的傳參方式、接收者的型別選擇、返回值的處理等,都可能因細節疏忽而導致程式的異常行為。

本模組將深入探討 Go 語言在方法與函式使用中常見的錯誤,幫助開發者避免因設計不當而引起的問題。透過對實際案例的分析,讀者將能更清晰地理解如何高效地定義和使用方法與函式,從而編寫出更加穩定和易維護的程式碼。

錯誤四十二:不知道使用哪種接收器型別 (#42)

示例程式碼:

package main

import (
    "fmt"
)

type FunTester struct {
    Name  string
    Count int
}

// 方法使用值接收器
func (ft FunTester) Increment() {
    ft.Count += 1
}

// 方法使用指標接收器
func (ft *FunTester) IncrementPointer() {
    ft.Count += 1
}

func main() {
    ft := FunTester{Name: "FunTester", Count: 0}
    ft.Increment()
    fmt.Printf("FunTester: 經過值接收器後的物件 = %+v\n", ft) // Count 仍然為0

    ft.IncrementPointer()
    fmt.Printf("FunTester: 經過指標接收器後的物件 = %+v\n", ft) // Count 變為1
}

錯誤說明:
在 Go 語言中,方法的接收器可以是值型別或者指標型別。許多開發者不清楚何時應該使用哪種型別,這導致了一些意外的行為。就像是拿錯了工具,不知道該用錘子還是螺絲刀,結果事倍功半。

可能的影響:
使用值接收器時,方法內對接收器的修改不會影響到原始物件。這可能導致開發者期望物件被修改,但實際上沒有效果。特別是在需要修改物件狀態時,使用值接收器會導致邏輯錯誤,影響程式的正確性。

最佳實踐:
選擇接收器型別時,應考慮以下幾點:

  • 是否需要修改接收器的狀態:如果需要,使用指標接收器。
  • 接收器的大小:如果接收器是大物件,使用指標接收器可以避免複製開銷。
  • 不可複製的欄位:如果接收器包含某些不可複製的欄位(如 sync.Mutex),必須使用指標接收器。
  • 一致性:一個型別的大多數方法應使用相同的接收器型別,保持程式碼的一致性和可維護性。

改進後的程式碼:

使用指標接收器以確保對物件的修改能夠反映到原始例項中:

package main

import (
    "fmt"
)

type FunTester struct {
    Name  string
    Count int
}

// 方法使用指標接收器
func (ft *FunTester) Increment() {
    ft.Count += 1
}

func main() {
    ft := &FunTester{Name: "FunTester", Count: 0}
    ft.Increment()
    fmt.Printf("FunTester: 經過指標接收器後的物件 = %+v\n", ft) // Count 變為1
}

輸出結果:

FunTester: 經過指標接收器後的物件 = &{Name:FunTester Count:1}

錯誤四十三:從不使用命名的返回值 (#43)

示例程式碼:

package main

import (
    "fmt"
)

type FunTester struct {
    Name string
    Age  int
}

// 不使用命名返回值
func NewFunTester(name string, age int) FunTester {
    return FunTester{Name: name, Age: age}
}

func main() {
    tester := NewFunTester("FunTester1", 25)
    fmt.Printf("FunTester: 建立的物件 = %+v\n", tester)
}

錯誤說明:
使用命名的返回值,是一種有效改善函式、方法可讀性的方法,特別是在返回值列表中有多個型別相同的引數。另外,因為返回值列表中的引數是經過零值初始化過的,某些場景下也會簡化函式、方法的實現。然而,不正確地使用命名返回值可能會引發一些副作用,比如意外提前返回或遺漏賦值。

可能的影響:
開發者可能因命名返回值帶來的預設初始化,誤將其作為主要邏輯的一部分,導致在某些條件下返回值未被正確賦值或錯誤賦值,進而引發邏輯錯誤或資料不一致。

最佳實踐:

  • 適度使用:在返回值較多或需要文件化返回值時使用命名返回值。
  • 避免副作用:確保在函式邏輯中明確賦值命名返回值,避免依賴其零值。
  • 提高可讀性:使用命名返回值時,確保其名稱具有描述性,便於他人理解程式碼意圖。

改進後的程式碼:

使用命名返回值以提高可讀性,同時確保在函式邏輯中正確賦值:

package main

import (
    "fmt"
)

type FunTester struct {
    Name string
    Age  int
}

// 使用命名返回值
func NewFunTester(name string, age int) (tester FunTester, err error) {
    if age < 0 {
        err = fmt.Errorf("FunTester: 年齡不能為負數")
        return
    }
    tester = FunTester{Name: name, Age: age}
    return
}

func main() {
    tester, err := NewFunTester("FunTester1", 25)
    if err != nil {
        fmt.Println("FunTester: 建立物件時出錯:", err)
        return
    }
    fmt.Printf("FunTester: 建立的物件 = %+v\n", tester)
}

輸出結果:

FunTester: 建立的物件 = {Name:FunTester1 Age:25}

錯誤四十四:使用命名的返回值時預期外的副作用 (#44)

示例程式碼:

package main

import (
    "fmt"
)

type FunTester struct {
    Name string
    Age  int
}

// 使用命名返回值但未正確賦值
func UpdateFunTester(t FunTester) (updated FunTester, err error) {
    if t.Age < 0 {
        err = fmt.Errorf("FunTester: 年齡不能為負數")
        return
    }
    t.Age += 1
    // 忘記賦值給 updated
    return
}

func main() {
    tester := FunTester{Name: "FunTester1", Age: 25}
    updatedTester, err := UpdateFunTester(tester)
    if err != nil {
        fmt.Println("FunTester: 更新物件時出錯:", err)
        return
    }
    fmt.Printf("FunTester: 更新後的物件 = %+v\n", updatedTester) // Age 未被更新
}

錯誤說明:
當使用命名的返回值時,因為返回值已經被初始化為零值,開發者可能會忽略對其賦值,導致函式返回的值與預期不符。特別是在複雜函式中,容易因疏忽忘記賦值,造成資料不一致或邏輯錯誤。

可能的影響:
返回的物件可能未被正確更新,甚至返回了未初始化的值,導致呼叫方接收到錯誤或不完整的資料,進而影響程式的正常執行。

最佳實踐:

  • 明確賦值:確保在所有路徑上都正確賦值命名返回值。
  • 程式碼審查:透過程式碼審查和測試,發現並修復未賦值的問題。
  • 使用覆蓋賦值:在邏輯結束前顯式賦值命名返回值,確保其正確性。

改進後的程式碼:

在所有路徑上明確賦值命名返回值,確保其正確性:

package main

import (
    "fmt"
)

type FunTester struct {
    Name string
    Age  int
}

// 使用命名返回值並正確賦值
func UpdateFunTester(t FunTester) (updated FunTester, err error) {
    if t.Age < 0 {
        err = fmt.Errorf("FunTester: 年齡不能為負數")
        return
    }
    t.Age += 1
    updated = t
    return
}

func main() {
    tester := FunTester{Name: "FunTester1", Age: 25}
    updatedTester, err := UpdateFunTester(tester)
    if err != nil {
        fmt.Println("FunTester: 更新物件時出錯:", err)
        return
    }
    fmt.Printf("FunTester: 更新後的物件 = %+v\n", updatedTester) // Age 被正確更新
}

輸出結果:

FunTester: 更新後的物件 = {Name:FunTester1 Age:26}

錯誤四十五:返回一個 nil 接收器 (#45)

示例程式碼:

package main

import (
    "fmt"
)

type FunTester interface {
    Run()
}

type FunTesterImpl struct {
    Name string
}

func (ft *FunTesterImpl) Run() {
    fmt.Printf("FunTester: %s 正在執行\n", ft.Name)
}

// 返回一個 nil 接收器
func GetFunTester(condition bool) FunTester {
    if condition {
        return &FunTesterImpl{Name: "FunTester1"}
    }
    var ft *FunTesterImpl = nil
    return ft
}

func main() {
    tester := GetFunTester(false)
    if tester == nil {
        fmt.Println("FunTester: tester 為 nil")
    } else {
        fmt.Println("FunTester: tester 不為 nil")
    }
}

錯誤說明:
在 Go 中,介面型別的變數不僅包含具體型別的值,還包含型別資訊。當返回一個具體型別的 nil 指標作為介面值時,介面本身並不為 nil,因為它仍然包含型別資訊。這會導致呼叫方誤以為介面不為 nil,從而引發預期外的問題。

可能的影響:
呼叫方可能認為介面例項有效,嘗試呼叫方法時會引發執行時錯誤(nil pointer dereference)。這種誤解會導致程式崩潰或行為異常,增加除錯難度。

最佳實踐:

  • 顯式返回 nil 介面:當需要返回 nil 介面時,直接返回 nil 而不是具體型別的 nil 指標。
  • 檢查具體型別:在呼叫介面方法前,檢查介面變數的具體型別和是否為 nil,以確保安全呼叫。
  • 使用建構函式:透過建構函式來管理介面的建立和返回,避免直接返回具體型別的 nil 指標。

改進後的程式碼:

顯式返回 nil 介面,確保介面變數正確為 nil:

package main

import (
    "fmt"
)

type FunTester interface {
    Run()
}

type FunTesterImpl struct {
    Name string
}

func (ft *FunTesterImpl) Run() {
    fmt.Printf("FunTester: %s 正在執行\n", ft.Name)
}

// 顯式返回 nil 介面
func GetFunTester(condition bool) FunTester {
    if condition {
        return &FunTesterImpl{Name: "FunTester1"}
    }
    return nil
}

func main() {
    tester := GetFunTester(false)
    if tester == nil {
        fmt.Println("FunTester: tester 為 nil")
    } else {
        fmt.Println("FunTester: tester 不為 nil")
        tester.Run()
    }
}

輸出結果:

FunTester: tester 為 nil

透過顯式返回 nil 介面,確保介面變數在條件不滿足時真正為 nil,避免了不必要的執行時錯誤。

錯誤四十六:使用檔名作為函式入參 (#46)

示例程式碼:

package main

import (
    "fmt"
    "io/ioutil"
    "os"
)

func ReadFunTesterFile(filename string) (string, error) {
    data, err := ioutil.ReadFile(filename)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

func main() {
    content, err := ReadFunTesterFile("FunTester.txt")
    if err != nil {
        fmt.Println("FunTester: 讀取檔案失敗:", err)
        os.Exit(1)
    }
    fmt.Println("FunTester: 檔案內容 =", content)
}

錯誤說明:
在函式設計中,直接使用檔名作為引數會限制函式的靈活性和可複用性。這樣做使得函式只能處理檔案,而無法適用於其他資料來源,如網路、記憶體等。這就像是設計一個只適用於特定地圖的導航儀,無法在其他地圖上使用。

可能的影響:
使用檔名作為引數會導致函式難以進行單元測試,因為測試時需要依賴實際的檔案系統。此外,這種設計限制了函式的適用範圍,降低了程式碼的可複用性和靈活性。

最佳實踐:
採用介面作為函式引數,如 io.Reader,可以大幅提升函式的可複用性和可測試性。透過依賴介面,函式可以處理多種資料來源,而不僅限於檔案系統。這也符合 Go 語言的依賴倒置原則,增強了程式碼的模組化和靈活性。

改進後的程式碼:

使用 io.Reader 作為函式引數,提高函式的靈活性和可測試性:

package main

import (
    "fmt"
    "io"
    "io/ioutil"
    "os"
)

// 使用 io.Reader 作為引數
func ReadFunTester(r io.Reader) (string, error) {
    data, err := ioutil.ReadAll(r)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

func main() {
    file, err := os.Open("FunTester.txt")
    if err != nil {
        fmt.Println("FunTester: 開啟檔案失敗:", err)
        os.Exit(1)
    }
    defer file.Close()

    content, err := ReadFunTester(file)
    if err != nil {
        fmt.Println("FunTester: 讀取檔案失敗:", err)
        os.Exit(1)
    }
    fmt.Println("FunTester: 檔案內容 =", content)
}

輸出結果:

FunTester: 檔案內容 = FunTester演示內容

測試示例:

透過使用 io.Reader,可以輕鬆地進行單元測試,無需依賴實際檔案:

package main

import (
    "strings"
    "testing"
)

func TestReadFunTester(t *testing.T) {
    input := "FunTester測試內容"
    reader := strings.NewReader(input)

    output, err := ReadFunTester(reader)
    if err != nil {
        t.Fatalf("FunTester: 讀取失敗: %v", err)
    }

    if output != input {
        t.Fatalf("FunTester: 預期 %s,實際 %s", input, output)
    }
}

透過這樣的設計,函式 ReadFunTester 不僅能夠處理檔案,還可以處理任何實現了 io.Reader 介面的資料來源,提高了程式碼的靈活性和可測試性。

錯誤四十七:忽略 defer 語句中引數、接收器值的計算方式 (引數值計算, 指標, 和 value 型別接收器) (#47)

示例程式碼:

package main

import (
    "fmt"
    "os"
)

func main() {
    file, err := os.Create("FunTester_output.txt")
    if err != nil {
        fmt.Println("FunTester: 無法建立檔案")
        return
    }
    defer file.Close()

    for i := 0; i < 3; i++ {
        defer fmt.Fprintf(file, "FunTester: 記錄 %d\n", i)
    }

    fmt.Println("FunTester: 迴圈結束")
}

錯誤說明:
在 Go 語言中,defer 語句會在當前函式返回前執行,並且在 defer 語句中傳遞的引數會在 defer 被呼叫時立即計算,而不是在 defer 執行時計算。這可能導致與預期不同的結果,特別是在迴圈中使用 defer 時,引數的值可能不是你想要的。

可能的影響:
開發者可能期望 defer 中的引數在 defer 執行時才被計算,但實際上它們在 defer 宣告時就已經被計算。這可能導致記錄的值不正確,或者引用了不再有效的變數,從而引發錯誤或邏輯不一致。

最佳實踐:

  • 使用閉包:透過閉包捕獲變數的當前值,確保在 defer 執行時使用正確的值。
  • 避免在迴圈中使用 defer:特別是在需要傳遞變數值時,避免在迴圈中頻繁使用 defer,以減少混淆和錯誤。
  • 明確傳遞引數:理解 defer 引數的計算時機,必要時透過傳遞具體值或使用指標來控制行為。

改進後的程式碼:

透過閉包捕獲變數的當前值,確保 defer 使用正確的引數:

package main

import (
    "fmt"
    "os"
)

func main() {
    file, err := os.Create("FunTester_output.txt")
    if err != nil {
        fmt.Println("FunTester: 無法建立檔案")
        return
    }
    defer file.Close()

    for i := 0; i < 3; i++ {
        // 使用閉包捕獲當前的 i 值
        func(n int) {
            defer func() {
                fmt.Fprintf(file, "FunTester: 記錄 %d\n", n)
            }()
        }(i)
    }

    fmt.Println("FunTester: 迴圈結束")
}

輸出結果檔案 FunTester_output.txt 內容:

FunTester: 記錄 0
FunTester: 記錄 1
FunTester: 記錄 2

解釋:
在上述改進後的程式碼中,使用閉包捕獲了每次迴圈迭代的 i 值,確保在 defer 執行時使用的是正確的值。這樣避免了因為引數提前計算而導致的錯誤結果。

FunTester 原創精華
【連載】從 Java 開始效能測試
故障測試與 Web 前端
服務端功能測試
效能測試專題
Java、Groovy、Go
白盒、工具、爬蟲、UI 自動化
理論、感悟、影片
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章