zl程序教程

您现在的位置是:首页 >  其他

当前栏目

Go常见错误集锦之range常踩的那些坑

2023-02-26 09:48:30 时间

在Go语言中,for-range是常用的循环控制语句。本文就带你一起来踩踩使用range时的那些坑。

  • range的迭代值是拷贝还是引用
  • 忽略了range表达式的计算逻辑
  • range表达式中有指针类型的元素时需要注意什么
  • 总结

01 range的迭代值是拷贝还是引用

range是对集合数据结构进行遍历的一种方便、快捷的方法。我们不必处理索引初始化和终止条件。首先,我们先回顾下range的用法;然后我们深入研究range是如何给循环变量赋值的。

1.1 range循环基础使用

range可以对以下数据类型进行遍历:string、array、数组指针、slice、map、channel

由于for-range的语法简单,同时我们不用手动处理循环中的条件表达式和迭代计数器变量,所以该方式比起传统的for循环是不容易出错的。下面是一个使用range对string类型的切片进行迭代的例子:

s := []string{"a", "b", "c"}
for i, v := range s {
  //range产生的索引i和迭代元素v
  fmt.Printf("index=%d,value=%s\n", i, v)
}

//在对索引不感兴趣的场景中,我们使用"_" 符号来替代索引变量,这样就不用处理索引了
for _, v := range s {
  fmt.Printf("value=%s\n", v)
}

通常情况下,除了channel外,range对于其他的数据结构在循环的时候都会产生两个值。

1.2 值拷贝

for-range比起传统的for循环虽然简单,但要想要正确使用range循环,理解每个迭代值是如何被处理的是至关重要的。迭代的值是被引用还是被拷贝的,也是Go开发人员容易混淆的一个地方。我们通过一个例子来看一下。

首先,创建一个account结构体,包含一个balance字段

type account struct {
  balance float32
}

然后,创建一个account类型的切片,并使用range来遍历每一个元素。在每一次迭代期间,我们会将balance字段进行+1000操作:

accounts := []account{
  {balance: 100},
  {balance: 200},
  {balance: 300},
}
for _, a := range accounts {
  a.balance += 1000
}

那么运行这段代码后,accounts切片的结果是下面哪个呢?

  • [{100} {200} {300}]
  • [{1100} {1200} {1300}]

结果是第一个:[{100} {200} {300}]。+1000的操作没有生效。在该示例中,range循环的操作未影响slice的原有内容。我们解释下为什么。

因为在Go中,一切赋值操作都是拷贝

例如,如果我们将函数返回的结果赋值给以下变量:

  • 一个结构体,我们得到的是这个结构体的拷贝
  • 一个指针,我们将得到这个指针的拷贝( 虽然两个指针变量指向的是同一个对象,但仍然是一个指针的拷贝)

这点很重要,能够避免常见的错误,包括和这些相关range循环。实际上,当一个range循环一个数据结构的时候,它会对每一个元素拷贝一份,然后赋值给value变量(也就是range中的第二个接收变量)

再回到我们的例子中,当我们遍历每一个account的元素的时候,实际上是将一个struct的拷贝赋值给了a变量。因此,当我们使用account.balance += 1000对balance进行改变的时候,它仅仅影响值变量,而不是切片中的account元素。

如果我们想更新切片里的元素该怎么办呢?有两种方案。

  • 方案一:根据索引进行更新

第一种方案是使用切片索引来访问元素。通过经典的for循环或for-range循环都可以做到:

for i := range accounts { ①
  accounts[i].balance += 1000
}
for i := 0; i < len(accounts); i++ { ②
  accounts[i].balance += 1000
}

① 使用索引变量来访问slice的元素

② 使用经典的for循环来访问变量

这两种方式都能够对切片中的变量进行更新,而非对本地的拷贝变量进行更新。哪种方式更合适呢?一般来说常用的是for-range形式,但也没有规定,只要达到目的就好。

  • 方案二:指针切片

第二种方式就是通过指针类型的切片来更新切片中的元素。

accounts := []*account{ ①
  {balance: 100.},
  {balance: 200.},
  {balance: 300.},
}
for _, a := range accounts {
  a.balance += 1000 ②
}
① 要更新的切片类型[]*account② 直接更新切片元素

在该例子中,a变量是slice中account指针的拷贝。但是,因为两个指针引用的对象是同一个,所以 a.balance += 1000 语句可以直接更新slice的结构体。

我们知道了value是值的拷贝,那接下来我们来看看range 后的表达式是如何被计算的,这个也是Go开发者经常忽略的一个地方。

02 忽略了range表达式的计算逻辑

range循环的语法是这样的:

for i, v := range exp {
  //todo
}

exp就是range的表达式,我们已经知道exp可以是string、array、指针数组、slice、map以及channel。那么,这些表达式在range循环的时候是如何被计算的呢?如果在循环过程中对这些值进行修改会有什么影响呢?

在看具体的例子之前,我们先记住一点:传给range的表达式在循环之前只会被计算一次,且把计算的结果拷贝到临时变量中,range遍历的是临时变量

接下来我们看看exp针对每种类型具体的编译形式是什么样的。

2.1 当range的exp是切片时

我们看一个例子,该例子代码会一直运行下去吗?

s := []int{0, 1, 2}
for _ := range s {
  s = append(s, 10)
}

在这个例子中,每次迭代中都会往s中增加1个元素,那该for-range是不是就无休止的循环下去呢?答案是不会,而是把{0, 1, 2}三个元素循环完就终止了。为什么呢?实际上go编译器会将fo-range转换成传统的for循环:

for_temp := range
len_temp := len(for_temp)
for index_temp = 0; index_temp < len_temp; index_temp++ {
  value_temp = for_temp[index_temp]
  index = index_temp
  value = value_temp
  
  original body
}
由此可见,range循环的次数len_temp是计算的原始的切片的长度,而且只被计算了一次,即使在循环体中再往s切片中增加元素,len_temp也是不变的,依然是3。所以该循环不会无休止的进行下去,而是遍历了3个元素就结束了。

2.2 当range的exp是数组时

当range的exp是数组时 又是怎么样的呢?根据上面的结论,range的表达式在遍历之前只被计算1次,而且是原始表达式的一个拷贝。我们看下面的例子:

a := [3]int{0, 1, 2}
for i, v := range a {
  a[2] = 10
  if i == 2 {
    fmt.Println(v)
  }
}

这段代码意图是将数组的最后一个元素更新成10。然而,这段代码实际上输出的是2,而不是10。我们看下为什么?

对于数组,go编译器会将其转换成以下伪代码:

len_temp := len(range)
range_temp := range
for index_temp = 0; index_temp < len_temp; index_temp++ {
  value_temp = range_temp[index_temp]
  index = index_temp
  value = value_temp
  
  original body
}

由伪代码可以更清楚的看到,在range时,实际上还是将数组a做了一个拷贝,赋值给了一个临时变量。这样,在循环中对a[2]的更新和遍历的最后1个元素v实际上是两个变量。所以,最后输出的v值是2。

如果我们想打印变量a最后一个元素实际的值该怎么办呢?使用数组索引而非遍历中的value值,如下:

a := [3]int{0, 1, 2}
for i := range a {
  a[2] = 10
  if i == 2 {
    fmt.Println(a[2])
  }
}

这样,该代码就会输出10而非2了。

另一种方式是使用数组指针

a := [3]int{0, 1, 2}
for i := range &a {
  a[2] = 10
  if i == 2 {
    fmt.Println(a[2])
  }
}

这样,range的表达式是一个数组指针,在转换成伪代码的时候,虽然也是值的拷贝,但拷贝的是数组a的地址,这样,拷贝的临时变量也同样指向原始数组a,所以,在打印的时候也就能输出更新后的值:10。

2.3 当range的exp是channel时

我们还是通过例子来说明。首先,创建两个协程,每个协程都往各自的channel中发送数据。然后,在主协程中通过range循环channel来消费子协程发来的数据,同时,在遍历的时候会切换到另一个channel上:

ch1 := make(chan int, 3) 
go func() {
  ch1 <- 0
  ch1 <- 1
  ch1 <- 2
  close(ch1)
}()
ch2 := make(chan int, 3) 
go func() {
  ch2 <- 10
  ch2 <- 11
  ch2 <- 12
  close(ch2)
}()
ch := ch1 
for v := range ch { 
  fmt.Println(v)
  //这里将ch切换成ch2,那么v的输出是输出ch2中的元素还是ch1中的元素呢
  ch = ch2 
}

我们看到最后一行,将ch赋值为ch2,那这样会影响range的遍历吗?

我们看下go编译器对range的表达式是channel时转换的伪代码:

range_temp := range
for {
  index_temp, ok_temp = <-range_temp
  if !ok_temp {
    break
  }
  index = index_temp
  original body
}

由此可知,在进行range时,其实真实遍历的是通道ch的一个range_temp,该副本指向通道ch1。当在迭代中再改变通道ch的指向时,对range_temp是没有影响的。所以,循环迭代的还是ch1中的内容。

03 忽视了range表达式中的指针元素

我们知道,指针类型的变量是用来存储内存地址的,例如下面代码,变量p中存储的是变量a的地址。

var a = 1024
var p *int = &a

如图所示指针变量示例图:

实际上变量p中存储的是变量a的内存地址,这样就可以通过p来修改变量a的内容:

*p = 2048
fmt.Println("a:", a) //a: 2048

好了,有了指针的简单基础,我们通过一个range循环指针切片的示例来说明range和指针一起使用时容易犯的一些错误以及如何避免这些错误。

我们定义一个Customer结构体,来代表的是一个可用,然后定义一个Store结构体,该结构体包含一个指针类型的map,如下:

type Customer struct {
  ID string
  Balance float64
}
type Store struct {
  Customers map[string]*Customer
}

然后,我们定义一个存储客户的函数storeCustomers,该函数接收一个Customer类型的切片参数,然后将客户存储在Store.customers中:

func (s *Store) storeCustomers(customers []Customer) {
  for _, customer := range customers {
    s.customers[customer.ID] = &customer
  }
}

//调用storeCustomers函数
s.storeCustomers([]Customer{
 {ID: "1", Balance: 10},
 {ID: "2", Balance: -10},
 {ID: "3", Balance: 0},
})

//这里的输出会是什么?
for i, v := range s.customers {
  fmt.Printf("id=%s,customer=%+v\n", i, v)
}

最后的输出会是我们预期的结果吗?通过运行该示例,我们得到的结果是:

id=1, customer=&main.Customer{ID:"3", Balance:0}
id=2, customer=&main.Customer{ID:"3", Balance:0}
id=3, customer=&main.Customer{ID:"3", Balance:0}

奇怪的是,输出结果和我们存入进去的结果不一样呀,所有的客户端ID都编程了3了。这是为什么呢?

原因是我们在range循环的时候,customer变量只被创建了一次,而s.customers[customer.ID] = &customer 这个是将customer的地址赋值给了s.customers[customer.ID], 所以该map中的元素指向了同一个内存地址,即&customer,我们假设customer的地址是0xc09678,那么,刚才的过程如下图所示:

由图可知,每次遍历,customer指针引用的变量不同,但customer指针自身的地址没变,存储到map中的是同样的内存地址。到遍历的最后,customer指针是对第三个结构体元素的引用,所以,map中三个元素都是第三个结构体元素。

接下来,我们看看该如何解决这个问题呢?

  • 方法1:使用本地局部变量
func (s *Store) storeCustomers(customers []Customer) {
  for _, customer := range customers {
    current := customer //这里,将customer变量copy一份,每次迭代都会创建一个新的current变量
    s.customers[current.ID] = &current //引用新拷贝的变量地址
  }
}

  • 方法2:直接使用slice的索引的地址
func (s *Store) storeCustomers(customers []Customer) {
  for i := range customers {
    customer := &customers[i] 
    s.customers[customer.ID] = customer 
  }
}

这样,其实我们还是利用了局部变量的特性,每次创建一个新的customer指针变量,并通过切片索引的方式将不同的元素地址赋值给customer指针变量,从而达到期望的结果。

总之,当我们使用range循环的时候,我们是将迭代的元素赋值给了一个变量,而该变量只被初始化一次,拥有唯一的内存地址,只不过每次迭代时引用的元素不一样而已。

04 小结

通过本篇文章的内容,我们对range有了一个清晰的认识。

  • for-range在编译器中会被编译成传统的for循环形式
  • for i, value := range exp中的value是指拷贝,改变value的值对原exp没有影响
  • for i, value := range exp中的exp可以是string、array、slice、channel,并且在循环开始前,exp只被计算一次,并且循环的是一个拷贝对象,所以在循环过程中对exp的元素进行添加,不会影响到循环的次数