在 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 自動化
理論、感悟、影片