Go常见错误集锦之range常踩的那些坑
在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] = ¤t //引用新拷贝的变量地址
}
}
- 方法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的元素进行添加,不会影响到循环的次数
相关文章
- mongodb之使用explain和hint性能分析和优化
- mongodb 3.x 之实用新功能窥看[2] ——使用$lookup做多表关联处理
- mongodb 3.x 之实用新功能窥看[1] ——使用TTLIndex做Cache处理
- 双十一来了,别让你的mongodb宕机了
- GO语言开发环境搭建笔记
- PHP判断网络连通
- 开启phpMyAdmin的远程登录
- PHP_cURL初始化和执行方法
- PHP经典函数收集
- PHP所有函数列表
- php bbcode过滤
- php不使用中间变量交换两个变量的值
- 嵌入式:ARM异常中断指令SWI、BKPT、CLZ详解
- 嵌入式:ARM协处理器指令总结
- C++ 中的卷积神经网络 (CNN)
- 一个git仓库多个项目配置pre-commit代码校验
- 搭建PHP开发环境(PHPStorm+PHPStudy)
- 记一次git丢失代码找回
- 记 ThinkPHP 项目部署
- MongoDB按时间分组