golang学习之七:for 语句的常见“坑”与避坑方法

for循环的两种方式

for-range 常见“坑”与避坑方法

坑1:循环变量的重用

下面这个示例是对一个整型切片进行遍历,并且在每次循环体的迭代中都会创建一个新的,Goroutine(Go 中的轻量级协程),输出这次迭代的元素的下标值与元素值。

package main

import (
	"fmt"
	"time"
)

func main() {
	var m = []int{1, 2, 3, 4, 5}
	for i, v := range m {
		go func() {
			time.Sleep(time.Second * 3)
			fmt.Println(i, v)
		}()
	}
	time.Sleep(time.Second * 10)
}

控制台

go run test4.go
4 5
4 5
4 5
4 5
4 5

但是以上打印结果与我们的预期不符,这是为啥。
基于golang隐式代码块的规则(大家可以自行百度,这里不在赘述),我们可以将上面的 for range 语句做一个等价转换,这样可以帮助你理解 for range 的工作原理。等价转换后的结果是这样的:

func main() {
	var m = []int{1, 2, 3, 4, 5}
	{
		i, v := 0, 0 // 其实代码应该是这样的,for range循环执行的时候,会在每次遍历的时候将值赋值给隐士代码块里的v,而不是重新声明一个变量v
		for i, v = range m {
			// 由于
			go func() {
				time.Sleep(time.Second * 3)
				// 这里每个函数都是一个闭包且没有参数传递,所以当闭包里的代码执行的之后,闭包没有的变量它就会引用了作用域之外的变量,并且所有的协程都是睡眠3s后执行,确保for循环结束之前,所有的协程不会运行,又因为for range的循环每次都是公用的同一个变量,于是当睡眠时间过了之后,所有的i,v就都是最后一次运行时的i,v的值
				fmt.Println(i, v)
			}()
		}
	}
	time.Sleep(time.Second * 10)
}

同样的情况,如果把切片里的元素为引用类型,则打印结果会是啥呢?
通过等价转换后的代码,我们可以清晰地看到循环变量 i 和 v 在每次迭代时的重用。而
Goroutine 执行的闭包函数引用了它的外层包裹函数中的变量 i、v,这样,变量 i、v 在主
Goroutine 和新启动的 Goroutine 之间实现了共享,而 i, v 值在整个循环过程中是重用的,
仅有一份。在 for range 循环结束后,i = 4, v = 5,因此各个 Goroutine 在等待 3 秒后进行
输出的时候,输出的是 i, v 的最终值。
闭包函数:在 Golang 中,闭包是一个引用了作用域之外的变量的函数。闭包的存在时间可以超过创建它的作用域,因此它可以访问该作用域中的变量,即使在该作用域被销毁之后。

那么如何修改代码,可以让实际输出和我们最初的预期输出一致呢?我们可以为闭包函数增加
参数,并且在创建 Goroutine 时将参数与 i、v 的当时值进行绑定,看下面的修正代码:

func main() {
	var m = []int{1, 2, 3, 4, 5}
	for i, v := range m {
		// 这里在闭包函数里传递了参数,所以在for循环结束之时,开始运行协程里的代码,所以每次循环传递到闭包函数里的就都是i,v的副本,又因为这里传递参数为不是指针类型,所以不受外部函数的i,v值的影响。所以每个闭包函数注册到函数栈上的都是参数的副本。当for循环完毕之后,运行的就都是每个副本的具体的值。
		go func(i, v int) {
			time.Sleep(time.Second * 3)
			fmt.Println(i, v)
		}(i, v)
	}
	time.Sleep(time.Second * 10)
}

控制台

go run test4.go
2 3
3 4
1 2
0 1
4 5

坑2:参与循环的是 range 表达式的副本

我们知道在 for range 语句中,range 后面接受的表达式的类型可以是数组、指向数
组的指针、切片、字符串,还有 map 和 channel(需具有读权限)。我们以数组为例来看一
个简单的例子:

package main

import (
	"fmt"
)

func main() {
	var a = [5]int{1, 2, 3, 4, 5}
	var r [5]int
	fmt.Println("original a =", a)
	for i, v := range a {
		if i == 0 {
			a[1] = 12
			a[2] = 13
		}
		r[i] = v
	}
	fmt.Println("after for range loop, r =", r)
	fmt.Println("after for range loop, a =", a)
}

控制台

go run test4.go
original a = [1 2 3 4 5]
after for range loop, r = [1 2 3 4 5]
after for range loop, a = [1 12 13 4 5]

解析:
我们原以为在第一次迭代过程,也就是 i = 0 时,我们对 a 的修改 (a[1] =12,a[2] = 13) 会在
第二次、第三次迭代中被 v 取出,但从结果来看,v 取出的依旧是 a 被修改前的值:2 和 3。
为什么会是这种情况呢?原因就是参与 for range 循环的是 range 表达式的副本。也就是
说,在上面这个例子中,真正参与循环的是 a 的副本,而不是真正的 a。
为了方便你理解,我们将上面的例子中的 for range 循环,用一个等价的伪代码形式重写一
下:


func main() {

	for i, v := range a' { //a'是a的一个值拷贝
		if i == 0 {
			a[1] = 12
			a[2] = 13
		}
		r[i] = v
	}
}

现在真相终于揭开了:这个例子中,每次迭代的都是从数组 a 的值拷贝 a’中得到的元素。
a’是 Go 临时分配的连续字节序列,与 a 完全不是一块内存区域。因此无论 a 被如何修改,
它参与循环的副本 a’依旧保持原值,因此 v 从 a’中取出的仍旧是 a 的原值,而不是修改后
的值。
那么应该如何解决这个问题,让输出结果符合我们前面的预期呢?我们前面说过,在 Go 中,
大多数应用数组的场景我们都可以用切片替代,这里我们也用切片来试试看:

package main

import "fmt"

func main() {
	var a = [5]int{1, 2, 3, 4, 5}
	var r [5]int
	fmt.Println("original a =", a)
	for i, v := range a[:] {
		if i == 0 {
			a[1] = 12
			a[2] = 13
		}
		r[i] = v
	}
	fmt.Println("after for range loop, r =", r)
	fmt.Println("after for range loop, a =", a)
}

在 range 表达式中,我们用了 a[:]替代了原先的 a,也就是将数组 a 转换为一个
切片,作为 range 表达式的循环对象。运行这个修改后的例子,结果是这样的:
控制台

go run test4.go
original a = [1 2 3 4 5]
after for range loop, r = [1 12 13 4 5]
after for range loop, a = [1 12 13 4 5]

我们看到输出的结果与最初的预期终于一致了,显然用切片能实现我们的要求。
那切片是如何做到的呢?因为切片在 Go 内部表示为一个结
构体,由(array, len, cap)组成,其中 array 是指向切片对应的底层数组的指针,len 是切
片当前长度,cap 为切片的最大容量。
所以,当进行 range 表达式复制时,我们实际上复制的是一个切片,也就是表示切片的结构
体。表示切片副本的结构体中的 array,依旧指向原切片对应的底层数组,所以我们对切片副
本的修改也都会反映到底层数组 a 上去。而 v 再从切片副本结构体中 array 指向的底层数组中,获取数组元素,也就得到了被修改后的元素的值。

坑3:方法中使用for-range

我敢出一题,打赌在做的各位有一半人要写错(狗头保命)
请各位看下以下代码输出是啥

package main

import (
    "fmt"
    "time"
)

type field struct {
    name string
}

func (p *field) print() {
    fmt.Println(p.name)
}

func main() {
    data1 := []*field{{"one"}, {"two"}, {"three"}}
    for _, v := range data1 {
        go v.print()
    }

    data2 := []field{{"four"}, {"five"}, {"six"}}
    for _, v := range data2 {
        go v.print()
    }

    time.Sleep(3 * time.Second)
}

控制台

go run test4.go
one
two
three
six
six
six

以上代码因为有多协程,所以输出顺序可能不尽相同,但是都有一个疑惑,那就是第二个for循环里为啥输出了六个six,而不是four、five、six?,是因为这段代码666吗?

我们来分析一下:
我们根据 Go方法的本质,也就是 一个以方法的receiver参数作为第一个参数的普通函数,对这个程序做个 等价变换。这里我们利用Method Expression方式,等价变换后的源码如下:

type field struct {
    name string
}

func (p *field) print() {
    fmt.Println(p.name)
}

func main() {
    data1 := []*field{{"one"}, {"two"}, {"three"}}
    for _, v := range data1 {
        go (*field).print(v)
    }

    data2 := []field{{"four"}, {"five"}, {"six"}}
    for _, v := range data2 {
        go (*field).print(&v)
    }

    time.Sleep(3 * time.Second)
}

由此我们看到,其实for循环里的代码go协程部分其实就是一个闭包函数
我们来分析代码

package main

import (
	"fmt"
	"time"
)

type field struct {
	name string
}

func (p *field) print() {
	fmt.Println(p.name)
}

func main() {
	data1 := []*field{{"one"}, {"two"}, {"three"}}
	{
		// 隐式代码块
		v := obj
		for _, v := range data1 {
			// 这里相当于每次循环都调用了一个函数,并且将每个元素以参数传递进去
			// 然后呢,因为for-range循环的是range表达式的副本,所以这里循环的是data1的一个拷贝,但是
			// 因为data1里的每个元素都是指针类型的,所以这些元素里存储的都是元素对应的地址
			// 所以拷贝的副本相当于是一个个新的指针变量,这些新变量里存储的还是原先每个元素的地址
			// 相当于是新元素和旧元素都是一个地址,他们指向的是同一个内存里的东西
			// 又因为 Go方法的本质,也就是 一个以方法的receiver参数作为第一个参数的普通函数注意此处(方法的reciver就是*field而非field,这点很重要)
			// 所以go v.print()等价于go (*field).print(v),其中v是指针类型(因为方法的reciver就是*field而非field,这点很重要)
			// 好了所有的条件都清楚了,那么我们分析每一次for循环的逻辑
			// 第一次for循环,第一个元素在隐式代码块里将值赋值给了循环变量v,然后创建协程,将v作为函数的参数注册到函数栈上(其实就是元素的地址注册到了函数栈上,又因为go所有函数/方法传参都是拷贝副本传参数,但是此处的参数是一个指针类型,那么拷贝的新变量是一个指针类型的变量它指向的还是原先元素的地址,所以每次注册在函数上的都是不同的地址)
			// 第二次for循环,第二个元素在隐式代码块里将值赋值给了循环变量v,然后创建协程,将v作为函数的参数注册到函数栈上(其实就是元素的地址注册到了函数栈上,又因为go所有函数/方法传参都是拷贝副本传参数,但是此处的参数是一个指针类型,那么拷贝的新变量是一个指针类型的变量它指向的还是原先元素的地址,所以每次注册在函数上的都是不同的地址)
			// 第三次for循环,第三个元素在隐式代码块里将值赋值给了循环变量v,然后创建协程,将v作为函数的参数注册到函数栈上(其实就是元素的地址注册到了函数栈上,又因为go所有函数/方法传参都是拷贝副本传参数,但是此处的参数是一个指针类型,那么拷贝的新变量是一个指针类型的变量它指向的还是原先元素的地址,所以每次注册在函数上的都是不同的地址)
			// 然后等到函数在函数栈上运行的时候,每个print函数打印的就都是分别不同的元素的name字段的值,于是输出的就是one、two、three
			go (*field).print(v)

		}
	}

	data2 := []field{{"four"}, {"five"}, {"six"}}
	for _, v := range data2 {
		// 这里相当于每次循环都调用了一个函数,并且将每个元素以参数传递进去
		// 然后呢,因为for-range循环的是range表达式的副本,所以这里循环的是data1的一个拷贝,但是
		// 因为data2里的每个元素都是非指针类型的,所以每个元素就都是一个新的元素与data2的元素就没有关联了
		// 又因为 Go方法的本质,也就是 一个以方法的receiver参数作为第一个参数的普通函数,(因为方法的reciver就是*field而非field,这点很重要)
		// 但是我们的元素都是非指针类型的,所以这里要传递指针类型的参数才可以,go帮我们隐士转换了
		// 所以 go v.print()等价于 (*field).print(&v),本质上就是一个普通的函数,参数为指针类型的field,其中v是指针类型(因为方法的reciver就是*field而非field,这点很重要)
		// 好了所有的条件都清楚了,那么我们分析每一次for循环的逻辑
		// 第一次for循环,第一个元素在隐式代码块里将值赋值给了循环变量v,然后创建协程, 将v作为函数的参数注册到函数栈上(其实就是元素的地址注册到了函数栈上,
		// 但是函数要求参数必须是指针类型的(reciver为函数的第一个参数),所以这里我们要传递指针进去所以取的就是循环变量的v的值,
		// 但是此处数组元素都为非指针,所以每次循环都只是将元素的值赋给了v,v的地址并没有发生变化,然后函数传参时传递的是参数的拷贝,但是此处参数是个指针类型,拷贝出来的参数也是一个指针类型,它指向的v的地址,相当于3次for循环注册到函数上的是同一个地址
		// 所以3次for循环里通过go调用print函数传递的都是同一个v对象,又因为go协程为异步,所以for循环完毕之后才执行了协程,然后执行的之后就是for循环最后一个元素的值,即six,six,six)(所以这里如果不是异步,是同步,那么你在每次for循环里让协程sleep 1秒,那么输出的就是4,5,6了)
		// 然后等到函数在函数栈上运行的时候,每个print函数打印的就是最后一次循环的v的值,于是输出的就是six、six、six
		go (*field).print(&v)
	}

	time.Sleep(3 * time.Second)
}

那么还有问题,怎么让第二个for打印4,5,6呢?
其实,我们只需要将field类型print方法的receiver类型由*field改为field就可以了。我们直接来看一下修改后的代码:

type field struct {
    name string
}

func (p field) print() {
    fmt.Println(p.name)
}

func main() {
    data1 := []*field{{"one"}, {"two"}, {"three"}}
    for _, v := range data1 {
        go v.print()
    }

    data2 := []field{{"four"}, {"five"}, {"six"}}
    for _, v := range data2 {
        go v.print()
    }

    time.Sleep(3 * time.Second)
}

为啥将方法print的receiver由指针类型改为非指针类型就可以了呢?
我们简单分析一下

package main

import (
	"fmt"
	"time"
)

type field struct {
    name string
}

func (p field) print() {
    fmt.Println(p.name)
}

func main() {
    data1 := []*field{{"one"}, {"two"}, {"three"}}
    for _, v := range data1 {
        go v.print()
    }

    data2 := []field{{"four"}, {"five"}, {"six"}}
    for _, v := range data2 {
		// 这里不管切片里的元素是不是指针类型,也不管for-range拷贝的副本里的元素是不是指针类型
		// 在每次for循环的时候,调用的都是v.print()方法,相当于是 在函数栈上注册了一个这样的函数
		// 参数为一个field类型的函数,于是每次for循环注册在函数栈上的都直接是for的每次循环的元素的值,就是1,2,3,4,5,6
		// 然后go的所有函数/方法传参,没有引用一说,全部都是拷贝参数的副本传参,不管拷贝多少次,这里都是非指针类型
		// 所以打印出来的当然就是每次元素的值。
        go v.print()
    }

    time.Sleep(3 * time.Second)
}

总结

1.凡是用到for-range的地方一定要小心
2.凡是用到闭包的地方,参数取值或传参一定要小心
3.go方法的本质,也就是 一个以方法的receiver参数作为第一个参数的普通函数
4.for-range的时候,如果调用闭包千千万万要看清在将函数注册到函数栈上时有没有注册参数:
若有:则注意参数的类型是指针还是非指针。
若没有:那么函数运行时,就找闭包函数之外的变量运行了,注意此时变量的值。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值