go 踩坑系列之「结构体和for循环迭代器」的坑,赶紧看看你踩了吗?

写作背景

最近打算写踩坑系列,把我在开发中遇到的坑总结出来,希望对大家有一些帮助。上一篇我写的 golang map 踩坑系列,建议大家回头看看。 golangmap 我在开发中踩过的坑,你是不是都踩了? - 掘金

本文主要讲结构体和 for 循环的坑。

新手必踩几个坑

结构体的空指针和方法覆盖

结构体是实实在在数据结构,一个结构体可以包含若干个字段,每个字段都有确切的名称和数据类型;另外也包含若干个方法。

结构体的坑,常见有以下 2 个。

空指针异常

如果我们定义一个值为 nil 的结构体变量,那么在这个变量上仍然可以调用该结构体的方法吗?你先先思考下?代码如下

go 复制代码
type Dog struct {
	name string
}

func (d *Dog) Print() {
	fmt.Println("hello dog...")
}

func (d *Dog) WithName(name string) {
	d.name = name
}

上面这段代码比较简单,我们来看看使用方式

go 复制代码
func TestNilStruct(t *testing.T) {
	var u *Dog
	u.Print()
	u.WithName("dog")
}

定义了指针变量,执行上面这段代码,结果输出如下

go 复制代码
=== RUN   TestNilStruct
hello dog...
--- FAIL: TestNilStruct (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
	panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x10f88f8]

goroutine 6 [running]:
testing.tRunner.func1.2({0x110bf20, 0x1208b50})

为什么会 panic 呢?并且提示是空指针,空指针位置是 "d.name = name"。

如果细心的同学会发现结果输出打印了"hello dog...",说明值为 nil 的时候也可以调用该结构体的方法,只是有一些限制。

调用的方法不能依赖该对象的属性,panic 原因是因为依赖了 name属性。

所以,大家定义的指针变量一定要初始化呀,避免零值。我在 go 入门阶段做业务时,被这个问题坑了好久。

结构体嵌套
go 复制代码
type Class struct {
	Name string
	ID   string
}

func (c *Class) String() string {
	return fmt.Sprintf("班级ID:%s,班级名称:%s", c.ID, c.Name)
}

type Student struct {
	*Class
	Name string
	ID   string
}

func (s *Student) String() string {
	return fmt.Sprintf("学生ID:%s,学生名称:%s", s.ID, s.Name)
}

我申明了 2 个结构体,Class 代表班级,它有 2 个字段,ID 代表班级编号,Name 代表班级名称;另外一个接口题 Student 代表学生,它有 3 个字段,ID 代表学生编号,Name 代表学生名称,另外它嵌套了 Class,这也是我们常用的对象组合方式「对象组合不是继承,跟继承没有半毛钱关系」,他们分别都有一个方法 String()。

第一个坑:方法屏蔽问题

css 复制代码
func main() {
	s := &Student{
		Class: &Class{
			Name: "一班",
			ID:   "1",
		},
		Name: "李四",
		ID:   "2",
	}
	fmt.Println(s.String())
}

当我执行上面这段代码,输出的结果是什么?你可以思考下

学生ID:2,学生名称:李四

从结果可以看出 Student 的 String 方法会被调用。嵌入字段Class 的 String 方法被"屏蔽"了。

如果你要调用 Class 的方法代码应该怎么写呢?

css 复制代码
func main() {
	s := &Student{
		Class: &Class{
			Name: "一班",
			ID:   "1",
		},
		Name: "李四",
		ID:   "2",
	}
	fmt.Println(s.Class.String())
}

可以改成 s.Class.String()。

第二个坑:嵌套字段是指针,并且使用时不初始化

css 复制代码
func main() {
	s := &Student{
		Name: "李四",
		ID:   "2",
	}
	fmt.Println(s.Class.String())
}

上段代码执行会报 Panic,所以 struct 嵌套是指针时,一定要初始化,为了保险你可以换成非指针方式。

知识点普及

刚好讲了结构体,顺带把结构体的小知识给大家普及了吧,字面量 struct{} 代表了什么?有什么用处?

我举一个场景,开发中你有若干个主键 ID,通过这批 ID 查询了主键 ID 对应的数据。如果你想判断返回的数据是否有缺失?你肯定会构造一个主键 ID 的 map,遍历返回数据,对比这个 map 就可以了。

我看过太多代码,大家喜欢下面这种方式

go 复制代码
m := map[string]bool{"xxx":true}

如果你是这么写的,那我今天可以告诉你一个更优雅的写法了,看下面这段代码

go 复制代码
m := map[string]struct{}{"123": {}}

字面量 struct{} 代表空结构体,它是不占内存的,它不占内存。

for 循环下的闭包

在日常开发中,使用Go语言处理迭代时,为了提高处理效率大家可能会尝试使用协程来并行处理数据(包括我在内),那下面这个坑我敢肯定你一定踩过。

go 复制代码
type User struct {
	Name string
}

func TestLoop(t *testing.T) {
	var (
		wg    sync.WaitGroup
		users = []*User{&User{Name: "a"}, &User{Name: "b"}, &User{Name: "c"}, &User{Name: "d"}}
	)

	for _, val := range users {
		wg.Add(1)

		go func() {
			defer wg.Done()
			fmt.Println(val.Name)
		}()
	}

	wg.Wait()
}

上面代码结果输出如下

diff 复制代码
=== RUN   TestLoop
d
d
d
d
--- PASS: TestLoop (0.00s)
PASS

在循环变量迭代结束后仍然保留对循环变量的引用,此时它会接受一个你不想要的新值。简单概述下,val 变量是在 for 循环开始迭代遍历就创建好了,后面每次迭代遍历都沿用这个变量,下一次迭代就会覆盖上一次变量的值。

要解决这个问题很简单,我常用的 3 种方案

方案一 通过传参

代码如下

go 复制代码
func TestLoop(t *testing.T) {
	var (
		wg    sync.WaitGroup
		users = []*User{&User{Name: "a"}, &User{Name: "b"}, &User{Name: "c"}, &User{Name: "d"}}
	)

	for _, val := range users {
		wg.Add(1)

		go func(param *User) {
			defer wg.Done()
			fmt.Println(param.Name)
		}(val)
	}

	wg.Wait()
}
方案二 定义临时变量

代码如下

go 复制代码
func TestLoop(t *testing.T) {
	var (
		wg    sync.WaitGroup
		users = []*User{&User{Name: "a"}, &User{Name: "b"}, &User{Name: "c"}, &User{Name: "d"}}
	)

	for _, val := range users {
		wg.Add(1)

		temVal := val // 定时临时变量
		go func() {
			defer wg.Done()
			fmt.Println(temVal.Name)// 通过临时变量处理数据
		}()
	}

	wg.Wait()
}

上面两种方案都能得到正确的结果

css 复制代码
=== RUN   TestLoop
d
a
b
c
--- PASS: TestLoop (0.00s)
PASS
方案 3 官方标准库的支持

前面两种方案以后要过时了,官方在 Go 1.22 版本修复这个问题了。

简单解释下,在之前的版本,for 循环声明的变量只创建一次,并在每次迭代中更新。在Go 1.22 中,循环的每次迭代都会创建新变量,以避免意外的共享错误,那我必须测试下,没有下载最新 go sdk 不打紧,官方提供了在线调试平台,地址:Go Playground - The Go Programming Language

你看看,go 团队还是很贴心,把我们遇到的问题都解决了。

另外,如果你是细心的网友,for 循环还升级一个特性

sql 复制代码
"For" loops may now range over integers

for 循环可以迭代整数了,案例如下

go 复制代码
package main

import "fmt"

func main() {
	for i := range 4 {
		fmt.Println(10 - i)
	}
	fmt.Println("go1.22 has lift-off!")
}

算我愚钝,这个场景我暂时用不上了。

最后总结

本文主要讲了 go 结构体、for 循环的坑,我觉得有几点比较重要。

  1. 对于结构体,我们要小心方法"屏蔽"现象,尤其是存在多个嵌入字段或者多层嵌入的时候。"屏蔽"现象可能会导致代码结果与你的预期不符。

  2. 对于结构体的指针方法,使用时一定要初始化变量,否则容易造成空指针 Panic;或者你在使用时不要引用结构体内部的变量。

  3. for 循环下的闭包问题非常常见,官方在1.22已经修复了,以后在升级版本的时候不用开发者关心了。

  4. 大家一定要善用 struct{} 空结构体,因为它不占内存。

相关推荐
尘浮生1 小时前
Java项目实战II基于SpringBoot的共享单车管理系统开发文档+数据库+源码)
java·开发语言·数据库·spring boot·后端·微信小程序·小程序
huaxiaorong1 小时前
如何将旧的Android手机改造为家用服务器
后端
2401_857439691 小时前
社团管理新工具:SpringBoot框架
java·spring boot·后端
2401_857610031 小时前
Spring Boot OA:企业办公自动化的创新之路
spring boot·后端·mfc
难念的码1 小时前
Skill 语言语法基础
人工智能·后端
逸风尊者1 小时前
开发也能看懂的大模型:FNN
人工智能·后端·算法
2401_854391081 小时前
Spring Boot OA:企业数字化转型的利器
java·spring boot·后端
山山而川粤2 小时前
废品买卖回收管理系统|Java|SSM|Vue| 前后端分离
java·开发语言·后端·学习·mysql
2301_811274312 小时前
基于Spring Boot的同城宠物照看系统的设计与实现
spring boot·后端·宠物
2301_811274312 小时前
springboot嗨玩旅游网站
spring boot·后端·旅游