🖋️作者: 千石🌟
🌐个人网站: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语言中还有很多类似的设计选择需要在实践中感受。当我们对语言有了更深入的理解,就可以利用它的优势来编写简洁
、高效
、可维护
的程序。所以,遇到"坑"时 不要灰心,而是充满好奇心地去探索,然后站在更高的视角来理解语言设计者的用意。这就是编程学习中的乐趣所在。