[TOC]
0x01 Go语言基础之错误处理 描述: Go语言中目前(1.16 版本中)是没有异常处理机制(Tips :说是在2.x版本中将会加入异常处理机制
),但我们可以使用error接口定义
以及panic
/recover
函数来进行异常错误处理。
1.error 接口定义 描述: 在Golang中利用error类型
实现了error接口,并且可以通过errors.New
或者fmt.Errorf
来快速创建错误实例。
主要应用场景: 在 Go 语言中,错误是可以预期的,并且不是非常严重,不会影响程序的运行。对于这类问题可以用返回错误给调用者的方法,让调用者自己决定如何处理,通常采用 error 接口进行实现。
error接口定义:1 2 3 type error interface { Error() string }
Go语言的标准库代码包errors方法:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 package errorstype errorString struct { text string } func (e *errorString) Error () string { return e.text } func New (text string ) error { return &errorString{text} } package fmtimport "errors" func Errorf (format string , args ...interface {}) error { return errors.New(Sprintf(format,args...)) }
采用 errors 包中装饰一个错误;1 2 3 errors.Unwrap(err error) errors.Is(err, target error) errors.As(err error, target interface {})
实际示例1: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 package mainimport ( "errors" "fmt" "math" ) func demo1 () { var errNew error = errors.New("# 错误信息来自 errors.New 方法。" ) fmt.Println(errNew) errorfFun := fmt.Errorf("- %s" , "错误信息来自 fmt.Errorf 方法。" ) fmt.Println(errorfFun) result, err := func (a, b float64 ) (ret float64 , err error) { err = nil if b == 0 { err = errors.New("此处幂指数不能为0值,其结果都为1" ) ret = 1 } else { ret = math.Pow(a, b) } return }(5 , 0 ) if err != nil { fmt.Println("# 输出错误信息:" , err) fmt.Printf("5 ^ 0 = %v" , result) } else { fmt.Printf("5 ^ 2 = %v" , result) } } func main () { demo1() }
执行结果:1 2 3 4 # 错误信息来自 errors.New 方法。 - 错误信息来自 fmt.Errorf 方法。 # 输出错误信息: 此处幂指数不能为0 值,其结果都为1 5 ^ 0 = 1
实际示例2: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 package mainimport ( "fmt" ) type DivideError struct { dividee int divider int } func (de *DivideError) Error () string { strFormat := ` Cannot proceed, the divider is zero. dividee: %d divider: 0 ` return fmt.Sprintf(strFormat, de.dividee) } func Divide (varDividee int , varDivider int ) (result int , errorMsg string ) { if varDivider == 0 { dData := DivideError{ dividee: varDividee, divider: varDivider, } errorMsg = dData.Error() return } else { return varDividee / varDivider, "" } } func main () { if result, errorMsg := Divide(100 , 10 ); errorMsg == "" { fmt.Println("100/10 = " , result) } if _, errorMsg := Divide(100 , 0 ); errorMsg != "" { fmt.Println("errorMsg is: " , errorMsg) } }
执行结果:1 2 3 4 5 100 /10 = 10 errorMsg is: Cannot proceed, the divider is zero. dividee: 100 divider: 0
2.panic 函数 描述: 当遇到某种严重的问题时需要直接退出程序时,应该调用panic函数从而引发的panic异常, 所以panic用于不可恢复的错误类似于Java的Error。
具体流程:是当panic异常发生时,程序会中断运行,并立即执行在该goroutine,随后程序崩溃并输出日志信息。日志信息包括panic、以及value的函数调用的堆栈跟踪信息。
panic 函数语法定义:1 func panic (v interface {})
Tips : panic函数接受任何值作为参数
示例1.数组越界会自动调用panic 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func TestA () { fmt.Println("func TestA{}" ) } func TestB (x int ) { var a [10 ]int a[x] = 111 } func TestC () { fmt.Println("func TestC()" ) } func main () {TestA() TestB(20 ) TestC() }
执行结果:1 2 >>> func TestA{} panic: runtime error: index out of rang
示例2.调用panic函数引发的panic异常 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 func A () { fmt.Println("我是A函数 - 正常执行" ) } func B () { fmt.Println("我是B函数 - 正在执行" ) panic ("func B():panic" ) fmt.Println("我是B函数 - 结束执行" ) } func C () { fmt.Println("我是c函数 - 正在执行" ) } func demo2 () { A() B() C() }
执行结果:1 2 3 4 5 6 7 8 9 10 11 我是A函数 - 正常执行 我是B函数 - 正在执行 发生异常: panic "func B():panic" Stack: 2 0x00000000004b69a5 in main.B at /home/weiyigeek/app/project/go /src/weiyigeek.top/studygo/Day02/05 error.go :47 3 0x00000000004b6a8a in main.demo2 at /home/weiyigeek/app/project/go /src/weiyigeek.top/studygo/Day02/05 error.go :57 4 0x00000000004b6ac5 in main.main at /home/weiyigeek/app/project/go /src/weiyigeek.top/studygo/Day02/05 error.go :63
weiyigeek.top-panic异常
Q: 什么时候使用Error,什么时候使用Panic?
对于真正意外的情况,那些表示不可恢复的程序错误,例如索引越界、不可恢复的环境问题、栈溢出、数据库连接后需操作
,我们才使用 panic。
对于其他的错误情况,我们应该是期望使用 error 来进行判定。
3.recover 函数 描述: panic异常会导致程序崩溃,而recover函数专门用于“捕获”运行时的panic异常,它可以是当前程序从运行时panic的状态中恢复并重新获得流程控制权。
通常我们会使用 Recover 捕获 Panic 异常,例如Java中利用Catch Throwable
来进行捕获异常。1 2 3 4 5 6 7 8 9 10 11 12 13 try { ... } catch (Throwable t) { ... } try { ... } catch () { }
panic 函数语法定义:1 func recover () interface {}
Tips: 在未发生panic时调用recover会返回nil。
流程说明: 如果调用了内置函数recover,并且定义该defer语句的函数发生了panic异常,recover会使程序从panic中恢复,并返回panic value。导致panic异常的函数不会继续运行,但能正常返回。
示例1:panic与recover联合使用,此处采用 panic 演示的代码中的B函数进行继续修改 描述: 在Go语言中可以通过defer定义的函数去执行一些错误恢复的行为1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 func recoverB () (err error) { fmt.Println("我是recoverB 函数 - 正在执行" ) defer func () { x := recover () if x != nil { err = fmt.Errorf("# 1.进行 recover(恢复) Panic 导致的程序异常,从此之后将会继续执行后续代码:\n%v" , x) } }() panic (errors.New("# 2.recoverB 函数中出现 Panic" )) fmt.Println("我是recoverB 函数 - 结束执行" ) return } func demo3 () { A() err := recoverB() if err != nil { fmt.Println("#recoverB 输出的信息:" , err) } C() }
执行结果:1 2 3 4 5 我是A函数 - 正常执行 我是recoverB 函数 - 正在执行 我是c函数 - 正在执行
示例 2.recover捕获异常后的异常,不能再次被recover捕获。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 func demo4 () { defer func () { fmt.Println("----调用 defer func1 start----" ) err := recover () fmt.Printf("# 第二次 捕获 : %#v \n" , err) if err != nil { fmt.Println(err) } fmt.Println("----调用 defer func1 end----" ) }() defer func () { fmt.Println("----调用 defer func2 start----" ) if err := recover (); err != nil { fmt.Println("# 第一次 捕获:" , err) } fmt.Println("----调用 defer func2 end----" ) }() panic ("panic 异常 抛出 测试!" ) }
执行结果:1 2 3 4 5 6 ----调用 defer func2 start---- # 第一次 捕获: panic 异常 抛出 测试! ----调用 defer func2 end---- ----调用 defer func1 start---- # 第二次 捕获 : <nil > ----调用 defer func1 end----
Q: panic() 与 recover() 位置区别? 答: panic函数可以在任何地方引发(但panic退出前会执行defer指定的内容),但recover函数只有在defer调用的函数中有效
并且一定要位于panic语句之前
。
TIPS : 非常注意下面这种“错误方式”, 他可能会形成僵尸服务进程,导致Health Check
失效。1 2 3 4 5 defer func () { if err := recover(); err != nil { Log.Error("Recovered Panic" , err) } }()
Q: panic 和 os.Exit 联用时对recover的影响
os.Exit 退出时不会调用defer指定的函数.
os.Exit 退出时不会输出当前调用栈信息.
4.错误处理最佳实践
1、预定义错误,code里判断
2、及早失败,避免嵌套
0x02 Go语言基础之结构体 描述: Go语言中没有“类”
的概念,也不支持“类”的继承
等面向对象的概念。但 Go语言中通过结构体的内嵌
再配合接口
比面向对象具有更高的扩展性
和灵活性
。
Go语言中的基础数据类型可以表示一些事物的基本属性
,但是当我们想表达一个事物的全部或部分属性时,这时候再用单一的基本数据类型明显就无法满足需求了(局限性)。
Go语言提供了一种自定义数据类型,可以封装多个基本数据类型,这种数据类型叫结构体(英文名称struct), 我们可以通过struct来定义自己的类型
了。
简单得说: 结构体时一种数据类型,一种我们自己可以保持多个维度数据的类型。 所以与其他高级编程语言一样,Go语言也可以采用结构体的特性, 并且Go语言通过struct来实现面向对象
。
1.类型定义 描述: 在Go语言中有一些基本的数据类型,如string、int{}整型、float{}浮点型、boolean布尔
等数据类型, Go语言中可以使用type关键字来定义自定义类型
(实际上定义了一个全新的类型)。
Tips : 我们可以基于内置的基本类型定义,也可以通过struct定义。
示例演示:
通过type关键字的定义,MyInt就是一种新的类型,它具有int的特性。
2.类型别名 描述: 类型别名从字面意义上都很好理解,即类型别名本章上与原类型一样, 就比如像一个孩子小时候有小名、乳名,上学后用学名,英语老师又会给他起英文名,但这些名字都指的是他本人。
示例演示:
我们之前见过的rune
和byte
就是类型别名,他们的定义如下:1 2 type byte = uint8type rune = int32
Tips: 采用int32别名创建一个变量的几种方式。1 2 3 4 5 6 7 8 9 10 type MyInt32 = int32 var i MyInt32i = 1024 var j MyInt32 = 1024 var k = MyInt32(1024 )l := MyInt32(1024 )
Q: 类型定义和类型别名有何区别?
答: 类型别名与类型定义表面上看只有一个等号的差异,我们通过下面的这段代码来理解它们之间的区别。
示例演示1: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 type NewInt int type MyInt = int func demo1 () { var i NewInt i = 1024 fmt.Printf("Type of i: %T, Value:%v \n" , i, i) var j MyInt j = 2048 fmt.Printf("Type of j: %T, Value:%v \n" , j, j) var k rune k = '中' fmt.Printf("Type of j: %T, Value:%c \n" , k, k) }
执行结果:1 2 3 Type of i: main.NewInt, Value:1024 Type of j: int , Value:2048 Type of j: int32 , Value:中
结果显示说明:
i
变量的类型是main.NewInt,表示main包下定义的NewInt类型。
j
变量的类型是int,因MyInt类型只会在代码中存在,编译完成时并不会有MyInt类型。
3.结构体的定义 描述: 语言内置的基础数据类型是用来描述一个值的,而结构体是用来描述一组值的。比如一个人有名字、年龄和居住城市等,本质上是一种聚合型的数据类型
。
使用type
和struct
关键字来定义结构体,具体代码格式如下:1 2 3 4 5 type 类型名 struct { 字段名 字段类型 字段名 字段类型 … }
其中:
类型名:标识自定义结构体的名称,在同一个包内不能重复。
字段名:表示结构体字段名。结构体中的字段名必须唯一。
字段类型:表示结构体字段的具体类型。
举例说明: 以定义一个Person(人)结构体为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var v struct {}type person struct { name string city string age int8 } type person1 struct { name, city string age int8 }
Tips : 上面创建了结构体一个person的自定义类型,它有name、city、age
三个字段,分别表示姓名、城市和年龄。这样我们使用这个person结构体就能够很方便的在程序中表示和存储人信息了。
4.结构体实例化 描述: 只有当结构体实例化时,才会真正地分配内存。也就是必须实例化后才能使用结构体的字段。
Tips :结构体本身也是一种类型,我们可以像声明内置类型一样使用var
关键字声明结构体类型。例如:var 结构体实例 结构体类型
。
描述: 结构体初始化是非常必要,因为没有初始化的结构体,其成员变量都是对应其类型的零值。
结构体示例化的三种语法格式: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 type demo struct { username string city string } var m1 demodemo.username = "WeiyiGeek" m2 := demo {username: "WeiyiGeek" ,city:"重庆" ,} m2 := &demo {username: "WeiyiGeek" ,city:"重庆" ,} m3 := demo { "WeiyiGeek" , "重庆" } m3 := &demo { "WeiyiGeek" , "重庆" }
Tips : 特别注意在使用值的列表初始化
这种格式初始化时, (1)必须初始化结构体的所有字段
,(2)初始值的填充顺序必须与字段在结构体中的声明顺序一致
,(3) 该方式不能和键值初始化方式混用。
示例演示: 下述演示三种基础方式进行结构体的实例化。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 type Person struct { name string age uint8 sex bool hobby []string } func demo1 () { var x Person x.name = "WeiyiGeek" x.age = 20 x.sex = true x.hobby = []string {"Basketball" , "乒乓球" , "羽毛球" } fmt.Printf("Type of x : %T, Value : %v \n" , x, x) x.name = "WeiyiGeeker" fmt.Printf("My Name is %v \n" , x.name) var y = Person{ name: "Go" , age: 16 , sex: false , hobby: []string {"Computer" , "ProgramDevelopment" }, } fmt.Printf("Type of y : %T, Value : %v \n" , y, y) z := Person{ "WeiyiGeek" , 10 , true , []string {}, } fmt.Printf("Type of z : %T, Value : %v \n" , z, z) }
执行结果:1 2 3 4 Type of x : main.Person, Value : {WeiyiGeek 20 true [Basketball 乒乓球 羽毛球]} My Name is WeiyiGeeker Type of y : main.Person, Value : {Go 16 false [Computer ProgramDevelopment]} Type of z : main.Person, Value : {WeiyiGeek 10 true []}
Tips : 如果没有给结构体中的属性赋值,则默认采用该类型的零值。
5.结构体内存布局 描述: 结构体占用一块连续的内存,但是需要注意空结构体是不占用空间的。
连续内存空间 示例演示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 var v struct {}fmt.Println(unsafe.Sizeof(v)) type test struct { a int8 b int8 c int8 d int8 } n := test{ 1 , 2 , 3 , 4 , } fmt.Printf("n.a %p, int8 size: %d\n" , &n.a, unsafe.Sizeof(bool (true ))) fmt.Printf("n.b %p\n" , &n.b) fmt.Printf("n.c %p\n" , &n.c) fmt.Printf("n.d %p\n" , &n.d) n.a 0xc0000a0060 n.b 0xc0000a0061 n.c 0xc0000a0062 n.d 0xc0000a0063
内存对齐分析 [进阶知识点] 关于在 Go 语言中恰到好处的内存对齐 描述: 在讲解前内存对齐前, 我们先丢出两个struct结构体引发思考:
示例1. 注意两个结构体中声明不同元素类型的顺序。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type Part1 struct { a bool b int32 c int8 d int64 e byte } type Part2 struct { e byte c int8 a bool b int32 d int64 }
在开始之前,希望你计算一下 Part1 与 Part2 两个结构体分别占用的大小是多少呢?1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func typeSize () { fmt.Printf("bool size: %d\n" , unsafe.Sizeof(bool (true ))) fmt.Printf("int32 size: %d\n" , unsafe.Sizeof(int32 (0 ))) fmt.Printf("int8 size: %d\n" , unsafe.Sizeof(int8 (0 ))) fmt.Printf("int64 size: %d\n" , unsafe.Sizeof(int64 (0 ))) fmt.Printf("byte size: %d\n" , unsafe.Sizeof(byte (0 ))) fmt.Printf("string size: %d\n" , unsafe.Sizeof("WeiyiGeek" )) } bool size: 1 int32 size: 4 int8 size: 1 int64 size: 8 byte size: 1 string size: 16
这么一算 Part1/Part2 结构体的占用内存大小为 1+4+1+8+1 = 15
个字节。相信有的小伙伴是这么算的,看上去也没什么毛病
真实情况是怎么样的呢?我们实际调用看看,如下:1 2 3 4 5 6 7 func main () { part1 := Part1{} fmt.Printf("part1 size: %d, align: %d\n" , unsafe.Sizeof(part1), unsafe.Alignof(part1)) fmt.Println() part2 := Part2{} fmt.Printf("part2 size: %d, align: %d\n" , unsafe.Sizeof(part2), unsafe.Alignof(part2)) }
执行结果:1 2 3 4 5 part1 size: 32 , align: 8 part2 size: 16 , align: 8 Tips : `unsafe.Sizeof` 来返回相应类型的空间占用大小 Tips : `unsafe.Alignof` 来返回相应类型的对齐系数
从上述结果中可以看见 part1
占用32个字节而 part2
占用16字节,此时 part1 比我们上面计算结构体占用字节数多了16 Byte, 并且相同的元素类型但顺序不同的 part2 是正确的只占用了 16 Byte, 那为什么会出现这样的情况呢?同时这充分地说明了先前的计算方式是错误的。
在这里要提到 “内存对齐” 这一概念,才能够用正确的姿势去计算,接下来我们详细的讲讲它是什么
Q: What 什么是内存对齐? 答:有的小伙伴可能会认为内存读取,就是一个简单的字节数组摆放(例图1) 表示一个坑一个萝卜的内存读取方式
。但实际上 CPU 并不会以一个一个字节去读取和写入内存, 相反 CPU 读取内存是一块一块读取的
,块的大小可以为 2、4、6、8、16 字节等大小, 块大小我们称其为内存访问粒度(例图2):
weiyigeek.top-内存对齐
在样例中,假设访问粒度为 4。 CPU 是以每 4 个字节大小的访问粒度去读取和写入内存的。这才是正确的姿势
Q: Why 为什么要关心对齐?
你正在编写的代码在性能(CPU、Memory)方面有一定的要求
你正在处理向量方面的指令
某些硬件平台(ARM)体系不支持未对齐的内存访问
Q: Why 为什么要做对齐?
平台(移植性)原因:不是所有的硬件平台都能够访问任意地址上的任意数据。例如:特定的硬件平台只允许在特定地址获取特定类型的数据,否则会导致异常情况
性能原因:若访问未对齐的内存,将会导致 CPU 进行两次内存访问,并且要花费额外的时钟周期来处理对齐及运算。而本身就对齐的内存仅需要一次访问就可以完成读取动作
weiyigeek.top-内存申请
在上图中,假设从 Index 1 开始读取,将会出现很崩溃的问题, 因为它的内存访问边界是不对齐的
。因此 CPU 会做一些额外的处理工作。如下:
1.CPU 首次读取未对齐地址的第一个内存块,读取 0-3 字节。并移除不需要的字节 0
2.CPU 再次读取未对齐地址的第二个内存块,读取 4-7 字节。并移除不需要的字节 5、6、7 字节
3.合并 1-4 字节的数据
4.合并后放入寄存器
从上述流程可得出,不做 “内存对齐” 是一件有点 “麻烦” 的事。因为它会增加许多耗费时间的动作, 而假设做了内存对齐,从 Index 0 开始读取 4 个字节,只需要读取一次
,也不需要额外的运算。这显然高效很多,是标准的空间换时间做法
默认系数 描述: 在不同平台上的编译器都有自己默认的 “对齐系数”,可通过预编译命令 #pragma pack(n) 进行变更,n 就是代指 “对齐系数”。一般来讲,我们常用的平台的系数如下:32 位:4, 64 位:8
, 例如, 前面示例中的对齐系数是8验证了我们系统是64位的。
另外要注意不同硬件平台占用的大小和对齐值都可能是不一样的。因此本文的值不是唯一的,调试的时候需按本机的实际情况考虑
不同数据类型的对齐系数 1 2 3 4 5 6 7 8 9 func main () { fmt.Printf("bool align: %d\n" , unsafe.Alignof(bool(true ))) fmt.Printf("byte align: %d\n" , unsafe.Alignof(byte(0))) fmt.Printf("int8 align: %d\n" , unsafe.Alignof(int8(0))) fmt.Printf("int32 align: %d\n" , unsafe.Alignof(int32(0))) fmt.Printf("int64 align: %d\n" , unsafe.Alignof(int64(0))) fmt.Printf("string align: %d\n" , unsafe.Alignof("WeiyiGeek" )) fmt.Printf("map align: %d\n" , unsafe.Alignof(map[string]string{})) }
执行结果:1 2 3 4 5 6 7 bool align: 1 byte align: 1 int8 align: 1 int32 align: 4 int64 align: 8 string align: 8 map align: 8
通过观察输出结果,可得知基本都是 2^n,最大也不会超过 8。这是因为我手提(64 位)编译器默认对齐系数是 8,因此最大值不会超过这个数。
Tips: 在上小节中提到了结构体中的成员变量要做字节对齐。那么想当然身为最终结果的结构体,也是需要做字节对齐的
对齐规则
1.结构体的成员变量
,第一个成员变量的偏移量为 0。往后的每个成员变量的对齐值必须为编译器默认对齐长度(#pragma pack(n))或当前成员变量类型的长度(unsafe.Sizeof),取最小值作为当前类型的对齐值。其偏移量必须为对齐值的整数倍
2.结构体本身
,对齐值必须为编译器默认对齐长度(#pragma pack(n))或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值
3.结合以上两点,可得知若编译器默认对齐长度(#pragma pack(n))超过结构体内成员变量的类型最大长度时,默认对齐长度是没有任何意义的
分析流程
Step 1.首先我们先来分析 part1 结构体
到底经历了些什么,影响了 “预期” 结果
成员变量
类型
偏移量
自身占用
a
bool
0
1
字节对齐
无
1
3
b
int32
4
4
c
int8
8
1
字节对齐
无
9
7
d
int64
16
8
e
byte
24
1
字节对齐
无
25
7
总占用大小
-
-
32
成员对齐步骤
第一个成员 a
类型为 bool
大小/对齐值为 1 字节
初始地址,偏移量为 0。占用了第 1 位
第二个成员 b
类型为 int32
大小/对齐值为 4 字节
根据规则 1,其偏移量必须为 4 的整数倍。确定偏移量为 4,因此 2-4 位为 Padding
(理解点)。而当前数值从第 5 位开始填充,到第 8 位。如下:axxx|bbbb
第三个成员 c
类型为 int8
大小/对齐值为 1 字节
根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 8。不需要额外对齐,填充 1 个字节到第 9 位。如下:axxx|bbbb|c…
第四个成员 d
类型为 int64
大小/对齐值为 8 字节
根据规则 1,其偏移量必须为 8 的整数倍。确定偏移量为 16,因此 9-16 位为 Padding。而当前数值从第 17 位开始写入,到第 24 位。如下:axxx|bbbb|cxxx|xxxx|dddd|dddd
第五个成员 e
类型为 byte
大小/对齐值为 1 字节
根据规则 1,其偏移量必须为 1 的整数倍。当前偏移量为 24。不需要额外对齐,填充 1 个字节到第 25 位。如下:axxx|bbbb|cxxx|xxxx|dddd|dddd|e…
整体对齐步骤
在每个成员变量进行对齐后,根据规则 2,整个结构体本身也要进行字节对齐,因为可发现它可能并不是 2^n,不是偶数倍。显然不符合对齐的规则
根据规则 2,可得出对齐值为 8。现在的偏移量为 25,不是 8 的整倍数。因此确定偏移量为 32。对结构体进行对齐
结果说明:
最终 Part1 内存布局 axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx
通过本节的分析,可得知先前的 “推算” 为什么错误? 是因为实际内存管理并非 “一个萝卜一个坑” 的思想。而是一块一块。通过空间换时间(效率)的思想来完成这块读取、写入。另外也需要兼顾不同平台的内存操作情况
Step 2.通过上述我们可知根据成员变量的类型不同,其结构体的内存会产生对齐等动作。而像 part2
结构体一样,按照变量类型对齐值从小到大,进行依次排序进行占用内存空间的结果分析。
通过开头的示例我们可知,只是 “简单” 对成员变量的字段顺序(类型占用字节数从小到大排序
)进行改变,就改变了结构体占用大小。
成员变量
类型
偏移量
自身占用
e
byte
0
1
c
int8
1
1
a
bool
2
1
字节对齐
无
3
1
b
int32
4
4
d
int64
8
8
总占用大小
-
-
16
成员对齐
第一个成员 e
类型为 byte
大小/对齐值为 1 字节
初始地址,偏移量为 0。占用了第 1 位
第二个成员 c
类型为 int8
大小/对齐值为 1 字节
根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 2。不需要额外对齐
第三个成员 a
类型为 bool
大小/对齐值为 1 字节
根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 3。不需要额外对齐
第四个成员 b
类型为 int32
大小/对齐值为 4 字节
根据规则1,其偏移量必须为 4 的整数倍。确定偏移量为 4,因此第 3 位为 Padding
(理解点)。而当前数值从第 4 位开始填充,到第 8 位。如下:ecax|bbbb
第五个成员 d
类型为 int64
大小/对齐值为 8 字节
根据规则1,其偏移量必须为 8 的整数倍。当前偏移量为 8。不需要额外对齐,从 9-16 位填充 8 个字节。如下:ecax|bbbb|dddd|dddd
整体对齐: 由于符合规则 2,则不需要额外对齐。
结果说明:
Part2 内存布局:ecax|bbbb|dddd|dddd
总结
通过对比 Part1
和 Part2
的内存布局,你会发现两者有很大的不同。如下:
Part1:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx
Part2:ecax|bbbb|dddd|dddd
仔细一看,Part1
存在许多 Padding。显然它占据了不少空间,那么 Padding 是怎么出现的呢?
通过本文的介绍,可得知是由于不同类型导致需要进行字节对齐,以此保证内存的访问边界
那么也不难理解,为什么调整结构体内成员变量的字段顺序 就能达到缩小结构体占用大小的疑问了,是因为巧妙地减少了 Padding 的存在。让它们更 “紧凑” 了。这一点对于加深 Go 的内存布局印象和大对象的优化非常有帮
当然了,没什么特殊问题,你可以不关注这一块。但你要知道这块知识点 😄
6.指针类型结构体 结构体指针实例化 描述: 我们还可以通过使用new
关键字(对基础类型进行实例化
)对结构体进行实例化,得到的是结构体的地址。
创建一个结构体指针格式:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 var p2 = new (person) fmt.Printf("%T\n" , p2) fmt.Printf("p2=%#v\n" , p2) p2.name = "WeiyiGeek" p2.age = 22 p2.city = "重庆" fmt.Printf("p2=%#v\n" , p2) p3 := &person{} fmt.Printf("%T\n" , p3) fmt.Printf("p3=%#v\n" , p3) p3.name = "WeiyiGeek" p3.age = 30 p3.city = "重庆" fmt.Printf("p3=%#v\n" , p3)
Tips :p3.name = "WeiyiGeek"
其实在底层是(*p3).name = "Geeker"
,这是Go语言帮我们实现的语法糖。
示例演示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 type Person struct { name string age uint8 sex bool hobby []string } func demo3 () { var p1 = new (Person) (*p1).name = "WeiyiGeek" p1.age = 20 fmt.Printf("Type of p1 : %T, Struct 实例化结果: %#v\n" , p1, p1) p2 := &Person{} (*p2).name = "Golang" p2.age = 12 p2.sex = true fmt.Printf("Type of p2 : %T, Struct 实例化结果: %#v\n" , p2, p2) p3 := &Person{ name: "北京" , } fmt.Printf("p3 Value = %#v \n" , p3) p4 := &Person{ "WeiyiGeek" , 20 , false , []string {}, } fmt.Printf("p4 Value = %#v \n" , p4) fmt.Printf("*p2 size of = %d, p2 align of = %d \n" , unsafe.Sizeof(*p2), unsafe.Alignof(p2)) fmt.Printf("Pointer p2 = %p, \name = %p,p2.name size of = %d \nnage = %p, p2.age size of = %d\nsex = %p, p2.sex size of = %d\nhobby = %p,p2.hobby size of = %d \n" , p2, &p2.name, unsafe.Sizeof((*p2).name), &p2.age, unsafe.Sizeof(p2.age), &p2.sex, unsafe.Sizeof(p2.sex), &p2.hobby, unsafe.Sizeof(p2.hobby)) }
执行结果:1 2 3 4 5 6 7 8 9 10 11 Type of p1 : *main.Person, Struct 实例化结果: &main.Person{name:"WeiyiGeek" , age:0x14 , sex:false , hobby:[]string (nil )} Type of p2 : *main.Person, Struct 实例化结果: &main.Person{name:"Golang" , age:0xc , sex:true , hobby:[]string (nil )} p3 Value = &main.Person{name:"北京" , age:0x0 , sex:false , hobby:[]string (nil )} p4 Value = &main.Person{name:"WeiyiGeek" , age:0x14 , sex:false , hobby:[]string {}} *p2 size of = 48 , p2 align of = 8 Pointer p2 = 0xc0001181b0 , name = 0xc0001181b0 ,p2.name size of = 16 age = 0xc0001181c0 , p2.age size of = 1 sex = 0xc0001181c1 , p2.sex size of = 1 hobby = 0xc0001181c8 ,p2.hobby size of = 24
从上述Person 结构体指针 p2 内存对齐结果中可知,元素类型占用的大小 16 + 1 + 1 + 24 = 42 Byte
, 但是收到整体对齐的规则约束,该 p2 指针类型的结构体
占用的内存空间大小为 48 Byte。
结构体指针函数传递 描述: 我们可以将指针类型的结构体进行地址传递在函数中修改其元素属性内容。
示例演示:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func personChange (p Person) { p.name = "Change" } func personPointerChange (p *Person) { p.name = "PointerChange" } func demo4 () { p4 := &Person{ name: "WeiyiGeek" , } personChange(*p4) fmt.Printf("personChange(*p4) -> name = %v \n" , p4.name) personPointerChange(p4) fmt.Printf("personPointerChange(*p4) -> name = %v" , p4.name) }
执行结果:1 2 personChange(*p4) -> name = WeiyiGeek personPointerChange(*p4) -> name = PointerChange
Tips : Go 语言中函数传的参数永远传的是拷贝, 如果要修改原数据必须进行取地址传递并修改。
结构体指针构造函数 描述: Go语言的结构体没有构造函数,但我们可以自己实现一个。
Tips: Go语言构造函数约定俗成用new
进行开头,例如 newDog()
。
例如: 下方的代码就实现了一个person的构造函数。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 type Person struct { name, city string age uint8 } func newPerson (name, city string , age uint8 ) Person { return Person{ name: name, city: city, age: age, } } func newPointerPerson (name, city string , age uint8 ) *Person { return &Person{ name: name, city: city, age: age, } } func demo1 () { var person = newPerson("WeiyiGeek" , "重庆" , 20 ) fmt.Printf("newPerson Type : %T, Value : %v\n" , person, person) var pointerperson = newPointerPerson("Go" , "world" , 12 ) fmt.Printf("newPointerPerson Type : %T, Value : %v\n" , pointerperson, pointerperson) }
执行结果:1 2 newPerson Type : main.Person, Value : {WeiyiGeek 重庆 20} newPointerPerson Type : *main.Person, Value : &{Go world 12}
Tips :因为struct是值类型,如果结构体比较复杂的话,值拷贝性能开销会比较大,所以该构造函数返回的是结构体指针类型。
7.结构体方法与接收者 描述: Go语言中的方法(Method)
是一种作用于特定类型变量的函数, 这种特定类型变量叫做接收者(Receiver)
, 接收者的概念就类似于其他语言中的 this
或者 self
。
结构体方法 定义格式: 1 2 3 func (接收者变量 接收者类型) 方法名(参数列表) (返回参数) { 函数体 }
其中,
接收者变量
:接收者中的参数变量名在命名时,官方建议使用接收者类型名称首字母的小写,而不是self、this之类的命名。例如 Person类型 的接收者变量应该命名为 p,Connector类型的接收者变量应该命名为c等。
接收者类型
:接收者类型和参数类似,可以是指针类型
和非指针类型
。
方法名、参数列表、返回参数
:具体格式与函数定义相同。
Tips : 结构体方法名称写法约束规定,如果其标识符首字母是大写的就表示对外部包可见(例如 java 中 public 指定的函数或者是类
公共的),如果其标识符首字母是小写的表示对外部不可见(不能直接调用
), 当然这是一种开发习惯非强制必须的。
示例演示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 type Person struct { name string age int8 } func NewPerson (name string , age int8 ) *Person { return &Person{ name: name, age: age, } } func (p Person) Dream () { fmt.Printf("%s的梦想是学好Go语言!\n" , p.name) } func main () { p1 := NewPerson("WeiyiGeek" , 25 ) p1.Dream() }
Tips : 方法与函数的区别是,函数不属于任何类型,方法属于特定的类型。
值类型的接收者 描述: 当方法作用于值类型接收者时,Go语言会在代码运行时将接收者的值复制一份。
在值类型接收者的方法中可以获取接收者的成员值,但修改操作只是针对副本,无法修改接收者变量本身
。
例如: 我们为 Person 添加一个SetAge方法,来修改实例变量的年龄, 验证是否可被修改。1 2 3 4 5 6 7 8 9 10 11 func (p Person) SetAge2 (newAge int8 ) { p.age = newAge } func main () { p1 := NewPerson("WeiyiGeek" , 25 ) p1.Dream() fmt.Println(p1.age) p1.SetAge2(30 ) fmt.Println(p1.age) }
指针类型的接收者 描述: 指针类型的接收者由一个结构体的指针组成,由于指针的特性,调用方法时修改接收者指针的任意成员变量,在方法结束后,修改都是有效的。此种方式就十分接近于其他语言中面向对象中的this或者self达到的效果。
例如:我们为 Person 添加一个SetAge方法,来修改实例变量的年龄。1 2 3 4 5 6 7 8 9 10 11 func (p *Person) SetAge (newAge int8 ) { p.age = newAge } func main () { p1 := NewPerson("WeiyiGeek" , 25 ) fmt.Println(p1.age) p1.SetAge(30 ) fmt.Println(p1.age) }
Q: 什么时候应该使用指针类型接收者?
一是、需要修改接收者中的值。
二是、接收者是拷贝代价比较大的大对象。
三是、保证一致性,如果有某个方法使用了指针接收者,那么其他的方法也应该使用指针接收者。
案例演示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func (p Person) ChangePersonName (name string ) { p.name = name fmt.Printf("# 执行 -> ChangePersonName 方法 -> p Ptr : %p ,value : %v\n" , &p, p.name) } func (p *Person) ChangePointerPersonName (name string , age uint8 ) { p.name = name p.age = age fmt.Printf("# 执行 -> ChangePointerPersonName 方法 -> p Ptr : %p (关键点),value : %v\n" , p, p.name) } func demo2 () { p1 := newPerson("小黄" , "Beijing" , 20 ) fmt.Printf("p1 Pointer : %p , Struct : %+v \n" , &p1, p1) p1.ChangePersonName("小黑" ) fmt.Printf(" p1 Pointer : %p , Struct : %+v \n" , &p1, p1) p1.ChangePointerPersonName("小白" , 30 ) fmt.Printf(" p1 Pointer : %p , Struct : %+v \n" , &p1, p1) }
执行结果:1 2 3 4 5 p1 Pointer : 0xc00010c150 , Struct : {name:小黄 city:Beijing age:20} p1 Pointer : 0xc00010c150 , Struct : {name:小黄 city:Beijing age:20} p1 Pointer : 0xc00010c150 , Struct : {name:小白 city:Beijing age:30}
任意类型的接收者 描述: 在Go语言中接收者的类型可以是任何类型,不仅仅是结构体,任何类型都可以拥有方法。
举个例子,我们基于内置的int类型使用type关键字可以定义新的自定义类型,然后为我们的自定义类型添加方法。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 type MyInt int func (m MyInt) SayHello (s string ) { fmt.Printf("Hello, 我是一个int, %s" , s) } func (m *MyInt) ChangeM (newm MyInt) { fmt.Printf("# Start old m : %d -> new m : %d \n" , *m, newm) *m = newm fmt.Printf("# End old m : %d -> new m : %d \n" , *m, newm) } func demo3 () { var m1 MyInt m1 = 100 m2 := MyInt(255 ) m1.SayHello("Let'Go" ) fmt.Printf("SayHello -> Type m1 : %T, value : %+v \n" , m1, m1) m1.ChangeM(1024 ) fmt.Printf("ChangeM -> Type m1 : %T, value : %+v \n" , m1, m1) }
执行结果:1 2 3 4 Hello, 我是一个int, Let'GoSayHello -> Type m1 : main.MyInt, value : 100 # Start old m : 100 -> new m : 1024 # End old m : 1024 -> new m : 1024 ChangeM -> Type m1 : main.MyInt, value : 1024
Tips : 非常注意,非本地类型不能定义方法,也就是说我们不能给别的包的类型定义方法。
8.匿名结构体与匿名字段 描述: 在定义一些临时数据结构等场景下还可以使用匿名结构体。
示例演示: 1 2 3 4 5 6 7 8 func demo2 () { var temp struct {title string ;address []string } temp.title = "地址信息" temp.address = []string {"中国" , "重庆" , "江北区" } fmt.Printf("Type of temp : %T\nStruct define: %#v \nValue : %v\n" , temp, temp, temp) }
执行结果:1 2 3 Type of temp : struct { title string ; address []string } Struct define: struct { title string ; address []string }{title:"地址信息" , address:[]string {"中国" , "重庆" , "江北区" }} Value : {地址信息 [中国 重庆 江北区]}
描述: 结构体允许其成员字段在声明时没有字段名而只有类型,这种没有名字的字段就称为匿名字段。
Tips: 这里匿名字段的说法并不代表没有字段名,而是默认会采用类型名作为字段名,结构体要求字段名称必须唯一,因此一个结构体中同种类型的匿名字段只能有一个。
示例演示:1 2 3 4 5 6 7 8 type Anonymous struct { string int } func demo4 () { a1 := Anonymous{"WeiyiGeek" , 18 } fmt.Printf("Struct: %#v ,字段1: %v , 字段2: %v \n" , a1, a1.string , a1.int ) }
执行结果:1 Struct: main.Anonymous{string:"WeiyiGeek" , int:18} ,字段1: WeiyiGeek , 字段2: 18
9.嵌套结构体与匿名字段 描述: 结构体中可以嵌套包含另一个结构体或结构体指针, 并且上面user结构体中嵌套的Address结构体也可以采用匿名字段的方式。
并且为了防止嵌套结构体的相同的字段名冲突,所以在这种情况下为了避免歧义需要通过指定具体的内嵌结构体字段名。
示例演示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 type Address struct { Province string City string } type Email struct { Account string CreateTime string } type User struct { Name string Gender string Address Address } type AnonUser struct { Name string Gender string Address Email } func demo1 () { user := User{ Name: "WeiyiGeek" , Gender: "男" , Address: Address{ Province: "重庆" , City: "重庆" , }, } fmt.Printf("Struct : %#v \n" , user) fmt.Printf("Name = %v, Address City = %v \n" , user.Name, user.Address.City) } func demo2 () { var anonuser = AnonUser{ Name: "WeiyiGeek" , Gender: "男" , Address: Address{ "重庆" , "重庆" , }, Email: Email{ "Master@weiyigeek.top" , "2021年8月23日 10:21:36" , }, } fmt.Printf("Struct : %#v\n" , anonuser) fmt.Printf("Name = %v,Address Province = %v, Email Account = %v \n" , anonuser.Name, anonuser.Address.Province, anonuser.Email.Account) }
执行结果: 1 2 3 4 5 6 7 Struct : main.User{Name:"WeiyiGeek" , Gender:"男" , Address:main.Address{Province:"重庆" , City:"重庆" }} Name = WeiyiGeek, Address City = 重庆 Struct : main.AnonUser{Name:"WeiyiGeek" , Gender:"男" , Address:main.Address{Province:"重庆" , City:"重庆" }, Email:main.Email{Account:"Master@weiyigeek.top" , CreateTime:"2021年8月23日 10:21:36" }} Name = WeiyiGeek,Address Province = 重庆, Email Account = Master@weiyigeek.top
Tips : 当访问结构体成员时会先在结构体中查找该字段,找不到再去嵌套的匿名字段中查找。
10.结构体的“继承” 描述: Go语言中使用结构体也可以实现其他编程语言中面向对象的继承。
示例演示:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 package mainimport "fmt" type Animal struct { name string }func (a *Animal) voice (v string ) { fmt.Printf("我是动物,我叫 %v, 我会叫 %s," , a.name, v) } type Dog struct { eat string *Animal } func (d *Dog) love () { fmt.Printf("狗狗喜欢吃的食物是 %v.\n" , d.eat) } type Cat struct { eat string *Animal } func (c *Cat) love () { fmt.Printf("猫猫喜欢吃的食物是 %v.\n" , c.eat) } func main () { d1 := &Dog{ Animal: &Animal{ name: "小黄" , }, eat: "bone" , } d1.voice("汪汪.汪汪." ) d1.love() c1 := &Cat{ Animal: &Animal{ name: "小白" , }, eat: "fish" , } c1.voice("喵喵.喵喵." ) c1.love() }
执行结果:1 2 我是动物,我叫 小黄, 我会叫 汪汪.汪汪.,狗狗喜欢吃的食物是 bone. 我是动物,我叫 小白, 我会叫 喵喵.喵喵.,猫猫喜欢吃的食物是 fish.
11.结构体与“JSON” 描述: JSON(JavaScript Object Notation) 是一种轻量级的数据交换格式,其优点是易于人阅读和编写,同时也易于机器解析和生成。
Tips : JSON键值对是用来保存JS对象的一种方式,键/值对组合中的键名写在前面并用双引号””包裹,使用冒号:分隔,然后紧接着值;多个键值之间使用英文,分隔。
在Go中我们可以通过结构体序列号生成json字符串
,同时也能通过json字符串反序列化为结构体得实例化对象
,在使用json字符串转换时, 我们需要用到"encoding/json"
包。
结构体标签(Tag) 描述: Tag是结构体的元信息,可以在运行的时候通过反射的机制读取出来,Tag在结构体字段的后方定义,由一对反引号包裹起来,具体的格式如下:key1:"value1" key2:"value2"
,可以看到它由一个或多个键值对组成。键与值使用冒号分隔,值用双引号括起来。同一个结构体字段可以设置多个键值对tag,不同的键值对之间使用空格分隔。
例如: 我们为Student
结构体的每个字段定义json序列化时使用的Tag。1 2 3 4 5 type Student struct { ID int `json:"id"` Gender string name string }
注意事项: 为结构体编写Tag时,必须严格遵守键值对的规则。结构体标签的解析代码的容错能力很差,一旦格式写错,编译和运行时都不会提示任何错误,通过反射也无法正确取值。例如不要在key和value之间添加空格。
示例演示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 package mainimport ( "encoding/json" "fmt" ) type Person1 struct { name, sex string }type Person2 struct { Name, Sex string }type Person3 struct { Name string `json:"name"` Sex string `json:"age"` } func serialize () { person1 := &Person1{"weiyigeek" , "男孩" } person2 := &Person2{"WeiyiGeek" , "男生" } person3 := &Person3{"WeiyiGeek" , "男人" } p1, err := json.Marshal(person1) p2, err := json.Marshal(person2) p3, err := json.Marshal(person3) if err != nil { fmt.Printf("Marshal Failed :%v" , err) return } fmt.Printf("person1 -> %v\nperson2 -> %v\nperson3 -> %v\n" , string (p1), string (p2), string (p3)) } type Person4 struct { Name string `json:"name"` Sex string `json:"sex"` Addr [3 ]string `json:"addr"` } func unserialize () { jsonStr := `{"name": "WeiyiGeek","sex": "man","addr": ["中国","重庆","渝北"]}` p4 := Person4{} err := json.Unmarshal([]byte (jsonStr), &p4) if err != nil { fmt.Printf("Unmarhal Failed: %v" , err) return } fmt.Printf("jsonStr -> Person4 : %#v\nPerson4.name : %v\n" , p4, p4.Name) } func main () { serialize() unserialize() }
执行结果:1 2 3 4 5 person1 -> {} person2 -> {"Name" :"WeiyiGeek" ,"Sex" :"男生" } person3 -> {"name" :"WeiyiGeek" ,"age" :"男人" } jsonStr -> Person4 : main.Person4{Name:"WeiyiGeek" , Sex:"man" , Addr:[3]string{"中国" , "重庆" , "渝北" }} Person4.name : WeiyiGeek
12.结构体和方法补充知识点 描述: 因为slice和map这两种数据类型都包含了指向底层数据的指针,因此我们在需要复制它们时要特别注意。
示例演示: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 package mainimport "fmt" type Person struct { name string age int8 dreams []string } func (p *Person) SetDreams (dreams []string ) { p.dreams = dreams } func (p *Person) NewSetDreams (dreams []string ) { p.dreams = make ([]string , len (dreams)) copy (p.dreams, dreams) } func main () { p1 := Person{name: "小王子" , age: 18 } data := []string {"吃饭" , "睡觉" , "打豆豆" } p1.SetDreams(data) data[1 ] = "不睡觉" fmt.Println(p1.dreams) p2 := Person{name: "WeiyiGeek" , age: 18 } data2 := []string {"计算机" , "网络" , "编程" } p2.NewSetDreams(data2) data2[1 ] = "NewMethod" fmt.Println(p2.dreams) }
执行结果: 1 2 [吃饭 不睡觉 打豆豆] [计算机 网络 编程]
Tips: 同样的问题也存在于返回值slice和map的情况,在实际编码过程中一定要注意这个问题。