likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 27

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

🖋️作者: 千石🌟 🌐个人网站:cnqs.moe🚀 🤝支持方式:👍点赞、💖收藏、💬评论 💬欢迎各位在评论区🗣️交流,期待你的灵感碰撞🌈

前言

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

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

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

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

思维导图

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

正文

错误处理机制

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

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

try:
  # 调用有错误的函数
  func_with_error()   
except Exception as e:
  print(e)

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

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。当我最初尝试这样做时,我认为下面的代码应该能够正常运行:

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

但每次都没有任何输出!

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

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

// 正确示例
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语言的新手,我一开始并不理解空结构体的作用,总是希望它能像其他语言中的类一样能包含字段和方法。比如,我会这样错误地定义:

type Empty struct {
    name string 
}

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

type Empty struct{}

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

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

未初始化的映射使用

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

var m map[string]int
m["key"] = 1 // 运行时panic

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

m := make(map[string]int)
m["key"] = 1

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对象并没有被修改。

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

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时,我经常会直接使用 == 来比较两个浮点数的值,例如:

a := 1.2 
b := 1.2

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

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

func isEqual(x, y, z float64) bool {
	return x+y == z
}

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

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

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

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{}表示一个空的接口类型,它可以匹配任何数据类型。可以用它来存储任何值:

var x interface{}
x = 1
x = "hello"
x = struct{}{}	

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

var x []interface{} 
x = append(x, 1, "hello", struct{}{})

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

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

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

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来确保资源释放等操作。比如文件操作:

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

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

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

func writeFile() {
  f := openFile("file.txt")
  defer f.Close()
  write(f)
}

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

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

mu.Lock()
defer mu.Unlock()

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

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

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

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

通过值传递大型结构体

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

type Data struct {
  // 大量字段
}

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

d := Data{}
process(d)

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

基准代码:

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

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

BenchmarkpassStruct-8   	 1000000	      2180 ns/op	     480 B/op	       9 allocs/op

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

func process(d *Data) {
  // 对原结构体进行操作
} 

d := Data{}
process(&d)

这样可以大幅改善性能:

BenchmarkpassPointer-8   	200000000	        6.34 ns/op	       0 B/op	       0 allocs/op

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

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

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

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

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

切片的引用语义

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

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

arr := [3]int{1, 2, 3}
slice := arr[:]

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

slice[0] = 4 
fmt.Println(arr) // [4, 2, 3]

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

slice[0] = 5
fmt.Println(arr) // [5, 2, 3]

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

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

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

slice = append(slice, 5)
fmt.Println(arr) // [4, 2, 3, 5]

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

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

func process(s []int) {
  s[0] = 6
}

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

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

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

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

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

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

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

type data struct {
  name string
}

m := map[string]data{}

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

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

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

m := map[string]data{}

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

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

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

p := &m["key"]
p.name = "new" 

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

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

m := map[string]*data{}
m["key"] = &data{name: "old"}
p := m["key"]
p.name = "new"

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

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

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

误用copy函数

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

让我们来看一个例子:

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

import "github.com/jinzhu/copier"

copier.Copy(&b, &a)

总结

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

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

转载自:https://juejin.cn/post/7271176998025592886
评论
请登录