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

这篇具有很好参考价值的文章主要介绍了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的时候,如果调用闭包千千万万要看清在将函数注册到函数栈上时有没有注册参数:
若有:则注意参数的类型是指针还是非指针。
若没有:那么函数运行时,就找闭包函数之外的变量运行了,注意此时变量的值。文章来源地址https://www.toymoban.com/news/detail-600819.html

到了这里,关于golang学习之七:for 语句的常见“坑”与避坑方法的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • ubuntu 22.04安装mysql 8.0与避坑指南

    MySQL 是一个开源数据库管理系统,可作为流行的 LAMP(Linux、Apache、MySQL、PHP/Python/Perl)堆栈的一部分安装。 它实现了关系模型并使用结构化查询语言( SQL)来管理其数据。 本教程将介绍如何在 Ubuntu 22.04 服务器上安装 MySQL 8.0 版。 通过完成它,你将拥有一个可用的关系数据

    2024年02月15日
    浏览(39)
  • AI绘图cuda与stable diffusion安装部署始末与避坑

    stable diffusion的安装说起来很讽刺,最难的不是stable diffusion,而是下载安装cuda。下来我就来分享一下我的安装过程,失败了好几次,几近放弃。 我们都知道cuda是显卡CPU工作的驱动(或者安装官网的解释为一个GPU加速器app),这里么有什么介绍的,既然是驱动就要安装对版本

    2024年04月10日
    浏览(39)
  • golang中一种不常见的switch语句写法

    最近翻开源代码的时候看到了一种很有意思的switch用法,分享一下。 注意这里讨论的不是 typed switch ,也就是case语句后面是类型的那种。 直接看代码: 你也可以在这找到它:代码链接 简单解释下这段代码在做什么:调用systemctl命令检查指定的服务的运行状态,具体做法是过

    2024年02月02日
    浏览(38)
  • 【python基础】循环语句-for循环

    for循环可以遍历任何可迭代对象,如一个列表或者一个字符串。这里可迭代对象的概念我们后期介绍,先知道这个名词就好了。 其语法格式之一: 比如我们遍历学员名单,编写程序如下所示: for循环如果放在生产生活中的话,也类似于循环处理,但较while循环有区别,其区

    2024年02月08日
    浏览(38)
  • 【Python基础】- for/while循环语句

      🤵‍♂️ 个人主页:@艾派森的个人主页 ✍🏻作者简介:Python学习者 🐋 希望大家多多支持,我们一起进步!😄 如果文章对你有帮助的话, 欢迎评论 💬点赞👍🏻 收藏 📂加关注+ 目录 Python循环语句 while循环 无限循环 while 循环使用 else 语句 for 循环 range对象 列表推导

    2024年02月09日
    浏览(49)
  • 【python】Python基础语法详细教程以及案例教学之 while循环语句、while语句嵌套应用、for循环语句、for语句嵌套应用、循环中断

    目录  前言 一、while循环的基础语法  1)什么是while语句?  2)如何具体实现while语句? 二、while循环的基础案例 1)案例一:  2)案例二: 三、while循环的嵌套应用 1)学习目标: 2)什么是while循环的嵌套 3)如何实现while嵌套? 四、while循环的嵌套案例 1)学习目标 2)补充

    2024年01月25日
    浏览(60)
  • 【Python基础】- for/while循环语句(文末送书)

      🤵‍♂️ 个人主页:@艾派森的个人主页 ✍🏻作者简介:Python学习者 🐋 希望大家多多支持,我们一起进步!😄 如果文章对你有帮助的话, 欢迎评论 💬点赞👍🏻 收藏 📂加关注+ 目录 Python循环语句 while循环 无限循环 while 循环使用 else 语句 for 循环 range对象 列表推导

    2024年02月08日
    浏览(54)
  • 【Python入门篇】——Python中循环语句(for循环的基础语法)

    作者简介: 辭七七,目前大一,正在学习C/C++,Java,Python等 作者主页: 七七的个人主页 文章收录专栏: Python入门,本专栏主要内容为Python的基础语法,Python中的选择循环语句,Python函数,Python的数据容器等。 欢迎大家点赞 👍 收藏 ⭐ 加关注哦!💖💖 除了while循环语句外

    2024年02月06日
    浏览(48)
  • 【C# 基础精讲】循环语句:for、while、do-while

    循环语句是C#编程中用于重复执行一段代码块的关键结构。C#支持 for 、 while 和 do-while 三种常见的循环语句,它们允许根据条件来控制代码块的重复执行。在本文中,我们将详细介绍这三种循环语句的语法和使用方法。 for 循环是一种常见的循环结构,用于在给定条件下重复执

    2024年02月13日
    浏览(37)
  • 〖大前端 - 基础入门三大核心之JS篇⑯〗- JavaScript的流程控制语句「for循环语句及算法题」

    当前子专栏 基础入门三大核心篇 是免费开放阶段 。 推荐他人订阅,可获取扣除平台费用后的35%收益,文末名片加V! 说明:该文属于 大前端全栈架构白宝书专栏, 目前阶段免费开放 , 购买任意白宝书体系化专栏可加入 TFS-CLUB 私域社区。 福利:除了通过订阅\\\"白宝书系列专

    2024年02月07日
    浏览(50)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包