学习Go语言,这些坑你都遇到过吗?

🖋️作者: 千石🌟

🌐个人网站:cnqs.moe🚀

🤝支持方式:👍点赞、💖收藏、💬评论

💬欢迎各位在评论区🗣️交流,期待你的灵感碰撞🌈

前言

在学习Go语言的过程中,我曾跌过不少坑,遇到过很多棘手的问题。这些问题部分源于对语法的理解偏差,也有些是因为未能完全掌握Go语言的特点。

为了帮助大家避免类似的困扰,我总结了在学习Go语言过程中常见的十二个坑,都是我一步一个脚印走出来的经验。我也曾经在这些坑里栽过跟头,希望通过分享自己的经历,能够帮助大家掌握处理这些坑的技巧和方法。

虽然这十二个坑并不能代表Go语言学习中的所有疑难点,但它们应该足以警示初学者在学习过程中需要注意的地方。如果能够避开这些坑,相信Go语言的学习之路会变得更加顺利和愉快。

我衷心希望这些经验能够帮助大家减少不必要的障碍,也欢迎与大家一起讨论,共同进步。让我们一起来看看,这十二个我在Go语言学习路上"积累"的坑吧!

思维导图

正文

错误处理机制

刚接触Go语言时,我对它的错误处理方式感到非常困惑。

我之前主要使用Python,它利用 try-except块 来捕获和处理异常错误:

python 复制代码
try:
  # 调用有错误的函数
  func_with_error()   
except Exception as e:
  print(e)

但在Go语言中,需要通过defer+recover来捕获和恢复panic错误:

go 复制代码
func badFunc() {
  panic("crash")  
}

func main() {
  defer func() {
    if err := recover(); err != nil {
      print(err)
    }
  }()

  badFunc()
}

一开始,我经常会忘记编写defer语句进行错误恢复,导致panic直接崩溃。

后来即使编写了defer,也常常忘记在里面调用recover,导致无法捕获panic

通过与Python错误处理方式的对比,我意识到Go语言错误处理有自身的设计思想和方式,需要重新建立编程思维。

for循环的迭代变量

在我学习 Go 语言并使用 for 循环时,我曾多次遇到一个令人困惑的问题:如何在 goroutine 中正确使用循环变量 i。当我最初尝试这样做时,我认为下面的代码应该能够正常运行:

go 复制代码
// 我的初始尝试
for i := 0; i < 5; i++ {
  go func() {
    fmt.Println(i)
  }(i)
}

但每次都没有任何输出!

后来我查阅了相关资料,才知道这是因为主goroutine在启动子goroutine后立即结束,而没有给子goroutine足够的时间执行。除了这个问题,for循环的迭代变量会被 goroutine 共享,所以需要特别注意。

为了解决这两个问题,我们可以通过在函数参数中传递i的值,并使用sync.WaitGroup来确保所有goroutine都执行完毕:

go 复制代码
// 正确示例
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
  wg.Add(1)
  go func(i int) {
    defer wg.Done()
    fmt.Println(i) 
  }(i)
}
wg.Wait()

这样不仅能确保每个goroutine都有自己的循环变量,而且也确保了主goroutine会等待所有的子goroutine完成后再退出。

误用空结构体

作为Go语言的新手,我一开始并不理解空结构体的作用,总是希望它能像其他语言中的类一样能包含字段和方法。比如,我会这样错误地定义:

go 复制代码
type Empty struct {
    name string 
}

后来经过反复尝试和查询资料,我才发现空结构体在Go语言中有其特殊的用途。它通常用于表示一个不需要任何内部状态的值,并且不占用任何内存空间。例如,当需要一个集合来存储唯一的键,并且不关心与这些键关联的值时,可以使用map[T]struct{}

go 复制代码
type Empty struct{}

set := make(map[Empty]struct{})

空结构体并不常用于"标记实现了某个接口的类型"。在Go语言中,只要一个类型实现了接口定义的所有方法,它就隐式地实现了该接口。空结构体的主要特点在于它的零内存占用,这使得它在某些特定的场景下非常有用。

未初始化的映射使用

在我刚开始学习Go语言时,经常会忘记显式地初始化一个映射,就直接像其他语言中使用映射一样开始读取和写入操作,比如:

go 复制代码
var m map[string]int
m["key"] = 1 // 运行时panic

结果每次都会在运行时产生一个panic,这让我非常困惑。经过查资料和反复实验,我终于理解了Go语言中的映射需要通过make等方式显式初始化,才可以安全地进行操作。

go 复制代码
m := make(map[string]int)
m["key"] = 1

Go语言中的映射是一个引用类型,如果不初始化就像一个空指针一样,无法进行读写。明白了这一点之后,我现在每次在使用映射时都会注意先进行初始化,避免了很多不必要的运行时错误。

混淆指针和值接收者

在为类型定义方法时,我一开始经常会困惑于应该使用指针接收者还是值接收者。使用指针接收者可以直接修改调用者,避免复制大对象;而值接收者更简单安全。

一开始,我经常会混淆这两者的使用场景:

go 复制代码
type Data struct {
	name string
}

// 使用值接收者
func (d Data) ChangeName() {
	d.name = "new name"
}

func main() {
	d := Data{}
	d.ChangeName() // 这是有效的

	fmt.Println(d.name) // 输出空字符串,因为d的name字段没有被修改
}

上述代码中,我使用了值接收者,但由于它工作在Data的一个副本上,原始的d对象并没有被修改。

正确的做法,当我需要修改调用者时,是使用指针接收者:

go 复制代码
type Data struct {
  name string
}

// 使用指针接收者
func (d *Data) ChangeName() { 
  d.name = "new name"
}

func main() {
  d := Data{} 
  d.ChangeName() // 这是有效的
  fmt.Println(d.name) // 输出 "new name",因为d的name字段已经被修改了
}

这样,方法就可以被Data类型直接调用,并且实际修改了调用者的状态。

经过一段时间的练习后,我逐渐总结何时应该使用指针接收者:

  • 当方法需要修改调用者时,使用指针接收者。
  • 当方法只需要数据的只读访问时,使用值接收者。
  • 如果接受者是一个大对象,可以使用指针接收者来优化性能,避免复制。

使用==比较浮点数

当我从其他语言转到Go时,我经常会直接使用 == 来比较两个浮点数的值,例如:

go 复制代码
a := 1.2 
b := 1.2

if a == b {
  // 进行操作
}

然而,这种直接比较在Go语言中(实际上在大多数语言中)容易产生不正确的结果。这是因为计算机中的浮点数采用 IEEE 754 格式存储,存在精度问题。因此,直接使用 == 可能会得到错误的结果:

go 复制代码
func isEqual(x, y, z float64) bool {
	return x+y == z
}

在使用上述函数进行以下比较时:

go 复制代码
x := 0.1
y := 0.2
z := 0.3

result := isEqual(x, y, z)
if result {
    fmt.Println("x + y is equal to z")
} else {
    fmt.Println("x + y is NOT equal to z")  // 这将会被打印
}

从数学的角度来看,0.1 + 0.2 应该等于 0.3。但由于浮点数在计算机中的表示和舍入误差,x + y 的结果并不完全等于 z,所以 isEqual 函数会返回 false

为了正确地比较浮点数,我们可以引入一个精度值ε,然后比较两个数的差值是否小于ε:

go 复制代码
func nearlyEqual(a, b float64) bool {
  const epsilon = 0.000001 
  return math.Abs(a-b) < epsilon
}

这样就能更准确地比较两个浮点数。

尽管Go语言的math包提供了如 math.IsNaN()math.Float32bits() 的函数,但它们并不直接用于比较浮点数。为了正确地比较浮点数,开发中应该使用精度值或其他方法,而不是简单地使用 ==

误解interface{}与\[\]interface{}

在刚开始学Go语言的时候,我常常会混淆interface{}[]interface{}这两种类型。

interface{}表示一个空的接口类型,它可以匹配任何数据类型。可以用它来存储任何值:

go 复制代码
var x interface{}
x = 1
x = "hello"
x = struct{}{}	

\[\]interface{} 表示一个空接口切片,可以存储任意类型的值:

go 复制代码
var x []interface{} 
x = append(x, 1, "hello", struct{}{})

我曾误以为 \[\]interface{} 有某种特殊要求,但其实它同样可以容纳任意类型的数据:

go 复制代码
var x []interface{}
x = append(x, 1, "hello") // 这是正确的,[]interface{} 同样可以容纳任何类型的数据

只有当我们自定义了具体的接口,并期望某些数据满足这个接口时,才会有特定的要求:

go 复制代码
type Printer interface {
    print()
}

type myInt int

func (mi myInt) print() {
  fmt.Println(mi) 
} 

var x []Printer
x = append(x, myInt(1), myInt(2)) // 正确的,因为 myInt 实现了 Printer 接口

interface{} 和 \[\]interface{} 的差异其实很简单:一个是容器,一个是容器的集合

遗漏defer

在刚开始编写Go代码时,我常常会忘记使用defer来确保资源释放等操作。比如文件操作:

go 复制代码
func writeFile() {
  f := openFile("file.txt")
  // 错过defer导致文件未关闭  
  write(f) 
}

这样就会导致文件对象未关闭,资源泄露。

使用defer可以确保操作执行:

go 复制代码
func writeFile() {
  f := openFile("file.txt")
  defer f.Close()
  write(f)
}

但一开始我并没有养成使用defer的习惯,导致一些隐藏的问题。

不仅是文件相关的操作,解锁互斥锁等操作,都应该搭配defer使用,以免遗漏:

go 复制代码
mu.Lock()
defer mu.Unlock()

这可以避免忘记解锁带来的死锁问题。

说句题外话,为了更好的提升程序的性能,使用defer应该注意以下几点:

  • 函数的 defer 数量少于或者等于 8 个;
  • 函数的 defer 关键字不能在循环中执行;
  • 函数的 return 语句与 defer 语句的乘积小于或者等于 15 个

具体的原因涉及到defer的实现机制,感兴趣的小伙伴可以自行搜索,这里不展开讨论

通过值传递大型结构体

在 Go 语言中,默认使用值传递,这意味着将结构体作为参数传递时,会进行值拷贝:

go 复制代码
type Data struct {
  // 大量字段
}

func process(d Data) {
  // 对拷贝进行操作
}

d := Data{}
process(d)

一开始我没有意识到这点,直接在代码中传递大型结构体。

基准代码:

go 复制代码
func BenchmarkpassStruct(b *testing.B) {
    d := Data{}
    for i := 0; i < b.N; i++ {
        process(d)
    }
}

这样在函数调用时就会发生大量内存拷贝,导致性能问题:

bash 复制代码
BenchmarkpassStruct-8   	 1000000	      2180 ns/op	     480 B/op	       9 allocs/op

后来我了解到可以通过指针来传递结构体,避免值拷贝:

go 复制代码
func process(d *Data) {
  // 对原结构体进行操作
} 

d := Data{}
process(&d)

这样可以大幅改善性能:

bash 复制代码
BenchmarkpassPointer-8   	200000000	        6.34 ns/op	       0 B/op	       0 allocs/op

除了指针,还可以通过切片来传递结构体指针:

go 复制代码
func process(ds []*Data) {
  // 对切片中的原结构体进行操作
}

var d1, d2 Data
process([]*Data{&d1, &d2})

这种传切片的方式也可以减少拷贝。

传递大型结构体时需要注意使用指针或切片,这对Go性能优化很重要。

切片的引用语义

Go语言中切片使用引用语义,这一开始经常会让我困惑。

比如我根据一个数组创建了一个切片:

go 复制代码
arr := [3]int{1, 2, 3}
slice := arr[:]

我错误地认为slice只是数组的一个副本,修改slice不会影响数组:

go 复制代码
slice[0] = 4 
fmt.Println(arr) // [4, 2, 3]

但实际上,修改slice中的元素同样会修改底层数组:

go 复制代码
slice[0] = 5
fmt.Println(arr) // [5, 2, 3]

这是因为slice并没有分配自身的数组空间,而是引用并与底层数组共享存储。

需要注意的是,当我们超出切片的容量并重新分配存储时,它可能不再引用原始数组。

除了修改元素,向切片传入函数时也需要注意:

go 复制代码
slice = append(slice, 5)
fmt.Println(arr) // [4, 2, 3, 5]

这些行为一开始让我很意外,经过反复调试和查询资料后才明白slice的引用语义。

除了修改元素,向slice传入函数时也需要注意:

go 复制代码
func process(s []int) {
  s[0] = 6
}

slice = []int{1, 2, 3}
process(slice)

fmt.Println(slice) // [6, 2, 3]

切片在函数调用中依然与原始数据共享存储。

注意切片的引用语义,可以避免很多难以发现的bug。

无法修改映射值中的结构体字段

在Go语言中,从映射中取出的结构体是一个值拷贝,无法直接修改其字段。

一开始我不明白这个限制,以为可以这样更新结构体字段:

go 复制代码
type data struct {
  name string
}

m := map[string]data{}

d := m["key"] 
d.name = "new"

但是这只是修改了d的一个本地副本,并没有影响到映射m中原来的结构体。

我通过下面的测试发现无法更新结构体字段:

go 复制代码
m := map[string]data{}

m["key"] = data{name: "old"}
d := m["key"]
d.name = "new"

fmt.Println(m["key"].name) // old

要修改映射中的结构体字段,我们需要重新赋值:

go 复制代码
p := &m["key"]
p.name = "new" 

fmt.Println(m["key"].name) // new

如果真的想使用指针来实现此目的,那么映射的值应该是指向结构体的指针,而不是结构体本身:

go 复制代码
m := map[string]*data{}
m["key"] = &data{name: "old"}
p := m["key"]
p.name = "new"

fmt.Println(m["key"].name) // new

在这种情况下,可以通过指针直接修改映射中的原结构体实例。

理解这一点后,我现在在需要修改结构体字段时,总是记得先取地址或重新赋值给映射。

误用copy函数

Go语言中的copy函数可以高效地复制切片元素。其实,它会创建一个新的底层数组来存储复制的元素,所以可以认为它实现了切片的浅拷贝。

让我们来看一个例子:

go 复制代码
a := []int{1, 2, 3}
b := make([]int, 3)

copy(b, a)

b[0] = 4
fmt.Println(a) // [1, 2, 3] 没有被修改

在上面的例子中,我复制了a切片的内容到b切片。修改b并不会影响a,因为它们的底层数组是不同的。

要注意的是,copy函数只复制底层数组的元素,而不是底层数组本身。这意味着如果您有一个切片的切片或包含其他引用类型的切片,那么copy函数只会复制引用,而不是实际的数据。

当需要深拷贝时,您可以考虑使用外部库,例如github.com/jinzhu/copier

go 复制代码
import "github.com/jinzhu/copier"

copier.Copy(&b, &a)

总结

综上所述,在学习Go语言的过程中,我们难免会遇到一些"坑"。这主要是因为Go语言在某些方面的特殊设计和作用机制,例如 空结构体的实际用法映射的初始化类型系统中的接口 等等。这些设计对于Go语言来说都有其意义,但对于刚接触的人来说可能并不那么直观。不过,只要多加练习和总结,这些"坑"都是可以克服的。在编程学习的道路上, 有时踩坑也是一种收获 。通过这些 踩坑经历,我们可以深入理解Go语言的设计哲学,深入了解各种机制的用途和局限。当再次遇到类似的问题时,经验可以帮助我们迅速地分析和解决。

本文只是列举了很少的例子,Go语言中还有很多类似的设计选择需要在实践中感受。当我们对语言有了更深入的理解,就可以利用它的优势来编写简洁高效可维护的程序。所以,遇到"坑"时 不要灰心,而是充满好奇心地去探索,然后站在更高的视角来理解语言设计者的用意。这就是编程学习中的乐趣所在。

相关推荐
llz_1124 小时前
web-第二次课后作业
前端·后端·web
红尘散仙10 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记12 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆12 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪12 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball61613 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_25183645713 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao13 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒14 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
ayqy贾杰15 小时前
基层管理的三板斧,在AI时代行不通了
前端·后端·团队管理