Go语言--使用“陷阱1”

Go语言语法简单,类型系统设计得“短小精悍”,但也不是完美无瑕的。Go语言也有一些特性让初学者感到困惑。本篇博客就是逐一介绍Go语言的使用“陷阱”,解除初学者容易犯的错误。Go 语言提供了 go fmt,能够保证代码格式的一致性,在一些代码的书写方式上,Go语言同样有自己的惯用写法,只是这些规则是“潜规则”。

<备注> 博文中引用的Go源码基于的Go发布版本:go version go1.15.4 linux/amd64。

1 多值赋值和短变量声明

Go语言支持多值赋值,在函数或方法内部也支持短变量声明并赋值,同时Go语言依据类型字面量的值能够自动进行类型推断。

1.1 多值赋值

可以一次性声明多个变量,并可以在声明时赋值,而且可以省略类型,但必须遵守一定的规则要求。

  • 以下都是合法的
//相同类型的变量声明可以只在末尾带上类型名
var x, y int
var x, y int = 1, 2

//如果不带类型名,编译器则可以根据赋的初始字面值直接进行类型推断
var x, y = 1, 2
var x, y = 1, "tata"

//不同类型的变量声明和隐式初始化可以使用如下用法
var (
    x int       //默认初始值 0
    y string    //默认初始值 空
)
  • 以下都是非法的
//多值赋值语句中每个变量后面不能都带上类型名
var x int, y int = 1, 2             //非法赋值语句
var x int, y string = 1, "tata"     //非法赋值语句
var x int, y int                    //非法声明语句
var x int, y string                 //非法声明语句

多值赋值的两种格式

(1)赋值符号(=)右边是一个返回多值的表达式,可以是返回多值的函数调用,也可以是 range 对 map、slice 等容器的操作,还可以是类型断言。例如:

// 函数调用
x, y = f()

// range 表达式
for k, v := range map {

}

//type assertion
v, ok := i.(xxx)    //xxx自带类型名,如 int, float, string等

(2)赋值符号(=)左边操作数和左边的单一返回值的表达式的个数一样,逐个从左向右依次对左边的操作数赋值。例如:

var x, y, z int = 1, 2, 3  //赋值完成后,x的值为1, y的值为2, z的值为3

多值赋值的语义

多值赋值看似简化了代码,但相互作用会产生让人困惑的结果。关键是要理解多值赋值的语义,才能消除这种困惑。多值赋值包括两层语义:

(1)先对左侧操作数中的表达式、索引值进行计算和确定,从而确定左侧的操作数的地址;然后对右侧操作数的赋值表达式进行计算,如果发现右侧的表达式计算引用了左侧的变量,则创建临时变量进行值拷贝,最后完成计算。

(2)从左至右的顺序依次赋值。

示例:多值赋值的例子。

func main(){
    x := []int{1, 2, 3}
    i := 0
    i, x[i] = 1, 2    //set i=1, x[0]=2
    fmt.Println(i, x)
    
    x = []int{1, 2, 3}
    i = 0
    x[i], i = 2, 1   //set x[0]=2, i=1
    fmt.Println(i, x)
    
    x = []int{1, 2, 3}
    i = 0
    x[i], i = 2, x[0]  //set tmp=x[0], x[0]=2, i=tmp ==>i=1,使用了临时变量
    fmt.Println(i, x)
    
    x[0], x[0] = 1, 2  //set x[0]=1, then x[0]=2 (so x[0] == 2 at end)
    fmt.Println(x[0])
}

运行结果:

1 [2 2 3]
1 [2 2 3]
1 [2 2 3]
2

《代码说明》根据多值赋值的语义,先计算左侧的操作数中的表达式、索引值以确定左操作数的变量地址,然后再计算右侧操作数的值,最后按照从左到右的顺序依次赋值。

  • i, x[i] = 1, 2  先计算左侧 x[i] 中的数组索引 i 的值,此时 i=0,两个被赋值变量是 i 和 x[0],然后从左至右进行赋值操作 i=1, x[0]=2。
  • x[i], i = 2, 1  逻辑和上面的一样。
  • x[i], i = 2, x[0]  先计算左侧 x[i] 中的数组索引 i 的值,此时 i=0, 两个被赋值变量是 x[0] 和 i,右侧两个赋值变量分别是 2、x[0]。由于 x[0] 是左操作数,所以编译器会创建一个临时变量tmp,将其赋值为x[0](此时x[0]的值=1,即tmp=x[0]=1),然后从左到右依次进行赋值操作,x[0]=2, i=tmp ==> i 的值为1。
  • x[0], x[0] = 1, 2  按照从左到右的顺序赋值,先执行 x[0]=1, 然后执行 x[0]=2,所以最后 x[0] 的值为2。

为了验证在多值赋值语句中分配临时变量的事实,我们以最简单的两个变量交换的赋值语句 a, b = b, a 为例,看一下其汇编代码。

package main

import(
    "fmt"
)

func main(){
    a, b := 1, 2       //set a=1, b=2
    a, b = b, a        //set tmp=a, a=b, a=tmp 从而实现两数的交换
    fmt.Println(a, b)  // 2 1
}

获取反汇编代码的命令:go tool compile -N -l -S demo.go > demo.s 2>&1

# -N参数代表禁止优化

# -l参数代表禁止内联

# -S参数表示打印汇编代码

# >demo.s表示重定向到demo.s文件中

# 2>&1 表示将标准错误重定向到标准输出

命令执行成功后,会生成:demo.s 和 demo.o 两个文件,查看demo.s文件中的汇编关键代码如下:

"".main STEXT size=351 args=0x0 locals=0xa0
	0x0000 00000 (demo.go:7)	TEXT	"".main(SB), ABIInternal, $160-0
	0x0000 00000 (demo.go:7)	MOVQ	(TLS), CX
	0x0009 00009 (demo.go:7)	LEAQ	-32(SP), AX
	0x000e 00014 (demo.go:7)	CMPQ	AX, 16(CX)
	0x0012 00018 (demo.go:7)	PCDATA	$0, $-2
	0x0012 00018 (demo.go:7)	JLS	341
	0x0018 00024 (demo.go:7)	PCDATA	$0, $-1
	0x0018 00024 (demo.go:7)	SUBQ	$160, SP
	0x001f 00031 (demo.go:7)	MOVQ	BP, 152(SP)
	0x0027 00039 (demo.go:7)	LEAQ	152(SP), BP
	0x002f 00047 (demo.go:7)	FUNCDATA	$0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
	0x002f 00047 (demo.go:7)	FUNCDATA	$1, gclocals·6e7c9f3c45c01365ed2372585f4ac9ea(SB)
	0x002f 00047 (demo.go:7)	FUNCDATA	$3, "".main.stkobj(SB)
	0x002f 00047 (demo.go:8)	MOVQ	$1, "".a+56(SP)
	0x0038 00056 (demo.go:8)	MOVQ	$2, "".b+48(SP)
	0x0041 00065 (demo.go:9)	MOVQ	"".a+56(SP), AX
	0x0046 00070 (demo.go:9)	MOVQ	AX, ""..autotmp_3+64(SP)
	0x004b 00075 (demo.go:9)	MOVQ	"".b+48(SP), AX
	0x0050 00080 (demo.go:9)	MOVQ	AX, "".a+56(SP)
	0x0055 00085 (demo.go:9)	MOVQ	""..autotmp_3+64(SP), AX
	0x005a 00090 (demo.go:9)	MOVQ	AX, "".b+48(SP)

 结果分析:

(1)第1~14行是初始化环境。

(2)第15~16行是使用立即数1,2 分别初始化变量a、b。

(3)第17~18行是创建临时变量 autotmp_3+64(SP),并将变量a的值赋值给它。

(4)第19~20行是将变量b的值赋值给变量a。

(5)第21~22行是将临时变量 autotmp_3+64(SP) 的值赋值给变量a。

由此可见,多值赋值过程中确实会使用到临时变量。

<备注> 使用的golang版本是:go version go1.15.4 linux/amd64。

1.2 短变量的声明和赋值

短变量的声明和赋值是指在Go函数或类型方法内部使用 ":=" 声明并初始化变量,支持多值赋值,格式如下:

a := va
a, b := va, vb

短变量的声明和赋值的语法规则:

(1)使用 ":=" 操作符,变量的定义和初始化同时完成。

(2)变量名后不要跟任何类型名,Go编译器完全靠右边的字面量值自动进行类型推断。

(3)支持多值短变量声明赋值。

(4)只能在函数和类型方法的内部使用短变量声明和赋值。

短变量的声明和赋值中最容易产生歧义的是多值短变量的声明和赋值,这个问题的根源是Go语言的语法允许多值短变量声明和赋值的多个变量中,只要有一个是新变量就可以使用 ":=" 进行赋值。也就是说,在多值短变量声明和赋值时,至少有一个变量是新创建的局部变量,其他的变量可以复用已经声明过的变量,不过新创建的变量执行的仅仅是赋值。

示例:

//声明一个全局变量n
var n int

func foo()(int, error){
    return 1, nil
}

//访问全局变量
func g(){
    fmt.Println(n)
}

func main(){
    //此时main函数作用域里面没有变量n
    //所以创建新的局部变量n
    n, _ := foo()
    
    //访问的是全局变量n
    g()               //0
    
    //访问的是main函数的局部变量n
    fmt.Println(n)    //1
}

通过上例分析得知,a, b := va, vb 什么时候定义新变量,什么时候复用已存在的变量有以下规则:

(1)如果想编译通过,则 a 和 b 中至少有一个是新定义的局部变量。换句话说,在此语句所在代码块中,不能同时预先声明a、b两个局部变量。

(2)如果在赋值语句 a, b := va, vb 所在的代码块中已经存在一个局部变量a,则赋值语句 a, b := va, vb 不会创建新的变量a,而是直接使用 va 赋值给已经声明过的局部变量a,但是会创建新变量b,并将 vb 赋值给新变量b。

(3)如果在赋值语句 a, b := va, vb 所在的代码块中没有局部变量 a 和 b,但是在全局命令空间有全局变量 a 和 b,则该语句会创建新的局部变量 a 和 b,并使用 va 和 vb 初始化它们。此时赋值语句所在的局部作用域内,全局的 a 和 b 被屏蔽。

赋值操作符 “=” 和 “:=” 的区别

(1)“=” 不会声明并创建新变量,而是在当前赋值语句所在的作用域由内到外逐层去搜寻变量,如果没有搜索到相同变量名,则报编译错误。

(2)“:=” 必须只能出现在函数或类型方法内部。

(3)“:=” 至少要创建一个新的局部变量并初始化。

示例如下:

func f1(){
    var a, b int
    //如下语句不能编译通过,原因是没有创建新变量,无法使用 “:=” 操作符
    a, b := 1, 2
}

func f2(){
    var a int
    //如下语句能编译通过,a是上条语句声明的a, b是新创建的局部变量
    a, b := 1, 2
}

func f3(){
    //如下语句能编译通过,a、b都是新创建的
    a, b := 1, 2
}

如何避免 “:=” 引入的副作用呢?一个好办法就是先声明变量,然后使用 “=” 赋值初始化。例如:

func f() {
    var a, b int
    a, b := 1, 2  //这两条语句等价于 a, b := 1, 2
}

多值短变量声明和赋值 “:=” 的最佳使用场景是在错误处理上。例如:

a, err := f()
if err != nil {
    ...
}

//此时err可以是已存在的err局部变量,只是重新赋值了
b, err := g()

2 range 复用临时变量

先来看一个简单的例子:

package main

import(
    "fmt"
    "sync"
)

func main(){
    wg := sync.WaitGroup{}
    
    si := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    for _, i := range si {
        wg.Add(1)
        go func(){
            fmt.Println(i)
            wg.Done()
        }()
    }
    wg.Wait()
}

运行结果:

10
10
10
10
10
10
10
10
10
10

程序结果并没有如我们预期那样遍历切片 si,而是全部打印10,有两点原因导致这个问题:

(1)for...range 下的迭代变量 i 的值是共用的。而go func() 匿名函数使用闭包的方式访问变量 i 的。

(2)main函数所在的 goroutine 和 后续启动的 goroutine 存在竞争关系。

使用 go run -race 来查看一下数据竞争的情况:

$ go run -race demo.go
10
==================
WARNING: DATA RACE
Read at 0x00c00001a090 by goroutine 8:
  main.main.func1()
      /go_work/src/chapter10/demo.go:15 +0x3c

Previous write at 0x00c00001a090 by main goroutine:
  main.main()
      /go_work/src/chapter10/demo.go:12 +0x164

Goroutine 8 (running) created at:
  main.main()
      /go_work/src/chapter10/demo.go:14 +0x1c4
==================
10
10
10
10
10
10
10
10
10
Found 1 data race(s)
exit status 66

可以看到,Goroutine 8 和 main Goroutine 存在数据竞争,更进一步证实了range 共享临时变量。range 在迭代的过程中,多个goroutine 并发地去读取临时变量 i。

正确的写法是使用函数参数做一次数据拷贝,而不是使用闭包。修改后的代码如下:

package main

import(
    "fmt"
    "sync"
)

func main(){
    wg := sync.WaitGroup{}
    
    si := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    for _, i := range si {
        wg.Add(1)
        
        //这里有一个实参到形参的值拷贝过程
        go func(a int){
            fmt.Println(a)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

运行结果:

10
1
2
3
4
5
6
7
8
9

可以看到,修改后的程序的运行结果符合预期。这个不能说是缺陷,而是Go语言设计者为了性能而选择的一种设计方案,因为在大多数情况下,for循环代码块是在同一个 goroutine 里运行的,为了避免空间的浪费和 GC(垃圾回收器) 的压力,复用了 range 迭代的变量。我们Go语言使用者明白了这个规约,在for循环下调用并发时要复制range的迭代临时变量后再使用,不要直接引用for...range 的迭代变量。

3 defer 缺陷

本节讨论 defer 带来的副作用。第一个是副作用是对返回值的影响;第二个副作用是对性能的影响。

3.1 defer 和 函数返回值

defer 中如果引用了函数的返回值,则因引用形式不同会导致不同的结果,这些结果往往给初学者造成很大的困惑,我们先来看一下如下三个函数的执行结果:

package main

import(
    "fmt"
)

//set r=0, r++, RET指令 ==> 1
func f1() (r int) {
    //匿名函数对r是闭包引用
    defer func(){
        r++
    }()
    return 0
}

// set r=t=5, t+=5, RET指令 ==> 5
func f2() (r int) {
    t := 5
    defer func(){
        t += 5
    }()
    return t
}

//set  r=1, r--传参-->r,r+=5, RET指令 ==> 1
func f3() (r int) {
    //匿名函数中的r是外围函数的值拷贝,不是同一个r
    defer func(r int) {
        r += 5
    }(r)
    return 1
}

func main(){
    fmt.Println("f1=", f1())  //f1=1
    fmt.Println("f2=", f2())  //f2=5
    fmt.Println("f3=", f3())  //f3=1
}

运行结果:

f1= 1
f2= 5
f3= 1

为什么会是这样的结果呢?我们接下来逐个分析。f1、f2、f3 三个函数的共同点就是它们都是带命名返回值的函数,返回值都是变量r。根据Go语言函数的特点:

(1)函数调用方负责开辟栈空间,包括形参和返回值的空间。

(2)有名函数的返回值相当于函数的局部变量,被初始化为对应类型的零值。

现在分析一下 f1,defer 语句后面的匿名函数是对函数返回值r的闭包引用,f1 函数的执行逻辑如下:

(1)r 是 f1 函数的有名返回值,分配在栈上,其地址又被称为返回值所在的栈区。首先,r被初始化为0。

(2)“return 0” 会复制0到返回值栈区,返回值 r 被赋值为0。

(3)执行 defer 语句,由于匿名函数对返回值 r 是闭包引用,所以执行 r++ 后,函数返回值被修改为 1。

(4)defer 语句执行完成后,执行汇编指令RET返回,此时函数的返回值仍然为1。

f1 的程序指令序列如下所示:

同理,来分析函数 f2 函数的执行逻辑:

(1)返回值 r 被初始化为0。

(2)引入局部变量 t,并初始化为5。

(3)复制 t 的值 5 到返回值 r 所在的栈空间。

(4)defer 语句后面的匿名函数是对局部变量 t 的闭包引用,t 的值变为10。

(5)函数返回,此时函数返回值 r 的值仍然是5,所以最终 f2 函数的返回值为5。

f2 的程序指令序列如下所示:

最后分析一下 f3 函数的执行逻辑:

(1)返回值 r 被初始化为0。

(2)复制1 到函数返回值 r 所在的栈空间。

(3)执行defer语句,defer 后的匿名函数使用的是传参调用,在注册 defer 后面的匿名函数时将函数返回值 r 作为实参传进去,由于函数调用是值拷贝,所以 defer 函数执行后只是形参值变为5,对实参值没有任何影响。

(4)函数返回,此时函数返回值 r 的值是1。

//为了以示区分:r1 为返回值中的r,r2 为匿名函数形参中的r

f3 的程序指令序列如下所示:

总结

综上所述,对于带 defer语句的函数整体上有三个步骤。

(1)执行 return 的值拷贝,将 return 语句返回值复制到函数返回值栈空间(如果只有一个return,不带任何变量或值,则此步骤不做任何动作)。

(2)执行 defer 语句,多个defer按照先进后出(FILO)顺序执行。

(3)执行调整 RET 汇编指令。

<注意> 当程序按顺序执行到defer 所在行代码的时候,只是注册 defer 关键字后面的延迟函数,如果该延迟函数有参数传入,那么这些参数的值会在当前defer语句执行时求出以确定其值,而不是等到开始执行延迟函数的时候才确定传入的实参值。

在defer 语句中修改函数返回值的做法不是一种明智的编程方式,在实际编程中应尽量避免此种情况。还有一种彻底解决该问题的方法是,在定义函数时使用不带命名返回值的格式。通过这种方式,defer 就不能直接引用返回值的栈区,也就避免了返回值在 defer 中的延迟函数中被修改的问题。看一下下面的示例代码:

package main

import (
    "fmt"
)

//1. return r ==> tmp=r=0 由于没有了命名返回值变量,系统会设置一个临时变量的栈区空间
//2. defer ==> r = 1
//3. 执行RET指令 ==> tmp=0, 所以f4()函数的返回值为0
func f4() int {
    r := 0
    defer func() {
        r++
    }()
    return r
}

//1. return 0 ==> tmp=0
//2. defer    ==> i=1
//3. 执行RET指令
func f5() int {
    r := 0
    defer func(i int) {
        i++
    }(r)
    return 0
}

func main(){
    fmt.Println("f4=", f4())  //f4= 0
    fmt.Println("f5=", f5())  //f5= 0
}

从 f4、f5 的执行结果可以看出,不管defer 如何操作,都不会改变函数的 return 返回的值,这才是一个好的编程模式。

4 切片困惑

分析多个切片因为共享底层数组而导致的不稳定的表现。在介绍切片前先了解一下数组(array)的概念,毕竟数组是切片的实现基础。

4.1 数组

Go语言数组是指有固定长度的相同类型元素的数据结构,底层采用连续的内存空间存放,数组一旦声明后大小就不可改变了。

注意:Go语言中的数组是一种基本类型,数组的类型不仅包括其元素类型,也包括其大小。例如,[2]int 和 [5]int 就是两个完全不同的数组类型。

创建数组

(1)声明时通过字面量进行初始化。

(2)直接声明,不显式地进行初始化。

示例如下:

package main

import (
    "fmt"
)

func main() {
    //指定大小并显式初始化
    a := [3]int{1, 2, 3}
    
    //通过"..."由后面元素个数自动推断出数组大小
    b := [...]int{1, 2, 3}
    
    //指定大小,并通过索引值(从0开始)初始化,未显式初始化的元素被置为类型零值
    c := [3]int{1:1, 2:3}
    
    //指定大小但不显式初始化,数组元素全被置为类型零值
    var d [3]int
    
    fmt.Printf("a-->len=%d, value=%v\n", len(a), a)
    fmt.Printf("b-->len=%d, value=%v\n", len(b), b)
    fmt.Printf("c-->len=%d, value=%v\n", len(c), c)
    fmt.Printf("d-->len=%d, value=%v\n", len(d), d)
}

运行结果:

a-->len=3, value=[1 2 3]
b-->len=3, value=[1 2 3]
c-->len=3, value=[0 1 3]
d-->len=3, value=[0 0 0]

Go语言的数组名无论是作为函数实参,还是作为 struct 嵌入字段,或者数组之间的直接赋值,都是值拷贝,不像C语言数组名因场景不同,可能是值拷贝,也可能是指针传递:C语言数组名作为函数实参传递时,直接退化为指针,int a[10]、int a[]、int *a 在C语言中都是一个意思,就是一个执行 int 类型的指针;但是,当数组内嵌到C语言中的 struct 里面时,又表现为值拷贝的语义。Go语言数组不存在这种歧义,数组的一切传递都是值拷贝,体现在以下三个方面:

(1)数组间的直接赋值。

(2)数组作为函数参数。

(3)数组内嵌到 struct 中。

下面以一个示例来证明上面的三条。

package main

import (
    "fmt"
)

func f(a [3]int) {
    a[2] = 10
    fmt.Printf("f()-->&a=%p, a=%v\n", &a, a)
}


func main() {
    a := [3]int{1, 2, 3}
    
    //直接赋值是值拷贝
    b := a
    
    //修改a元素值并不影响数组b
    a[2] = 4
    fmt.Printf("&a=%p, a=%v\n", &a, a)
    fmt.Printf("&b=%p, b=%v\n", &b, b)
    
    //数组名作为函数参数仍然是值拷贝
    f(a)
    
    c := struct{
        s [3]int
    }{
        s: a,
    }
    //结构体是值拷贝,内部的数组也是值拷贝
    d := c
    
    //修改结构体变量c中的数组元素值并不影响a
    c.s[2] = 20
    
    //修改结构体变量d中的数组元素值并不影响a
    d.s[2] = 30
    
    fmt.Printf("&a=%p, a=%v\n", &a, a)
    fmt.Printf("&c=%p, c=%v\n", &c, c)
    fmt.Printf("&d=%p, d=%v\n", &d, d)
}

运行结果:

&a=0xc00001c140, a=[1 2 4]
&b=0xc00001c160, b=[1 2 3]
f()-->&a=0xc00001c1e0, a=[1 2 10]
&a=0xc00001c140, a=[1 2 4]
&c=0xc00001c220, c={[1 2 20]}
&d=0xc00001c240, d={[1 2 30]}

从运行结果可以看到,数组a 和 数组b 是两个不同的内存空间地址,同时f() 函数中的数组a 也是和main()函数中的数组a的内存地址不同。结构体变量c 和 d 的内存地址也不同,说明它们表示的也是两个不同的内存空间地址。

由于数组大小一旦声明后就不可修改,所以实际使用场景并不广泛,下面介绍使用广泛的切片。

4.2 切片

切片创建

(1)通过数组创建切片

array[b:e] 创建一个包括 e-b 个元素的切片,第一个元素是 array[b],最后一个元素是array[e-1]。e、b表示的是数组的下标索引值,是一个半闭半开区间,即:[e, b)。切片的长度=b-e。

(2)使用make函数创建

通过内置的make() 函数创建,make([]T, len, cap),其中T表示切片元素类型,len表示长度,cap表示底层数组的容量,cap是可选参数。

(3)直接声明

可以直接声明一个切片变量,也可以在声明切片的过程中使用字面量进行初始化,直接声明但不进行初始化的切片变量其值为 nil。例如:

var a []int    //a is nil
var a []int = []int{1, 2, 3, 4}

切片数据结构

通常我们说切片是一种类似的引用类型,原因是其存放数据的数组是通过指针间接引用的。所以切片名作为函数参数和指针传递是一样的效果。切片的底层数据结构如下:

//源码位置: src/runtime/slice.go
//切片的底层数据结构
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

可以看到切片的数据结构有3个成员,分别是指向底层数组的指针、切片当前的大小和底层数组的大小。当len 增长超过 cap 时,会申请一个更大容量的底层数组,并将数据从老数组复制到新申请的底层数组中。

nil 切片 和 空切片

使用 make([]int, 0) 创建的切片与 var a []int 创建的切片是有区别的。前者的切片指针有分配,后者的内部指针为0,。示例如下:

package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

func main() {
    var a []int
    
    b := make([]int, 0)
    
    if a == nil {
        fmt.Println("a is nil")
    }else {
        fmt.Println("a is not nil")
    }
    
    //虽然切片b的底层数组大小为0,但切片并不是nil
    if b == nil {
        fmt.Println("b is nil")
    }else {
        fmt.Println("b is not nil")
    }
    
    //使用反射中的 SliceHeader 来获取切片运行时的数据结构
    as := (*reflect.SliceHeader)(unsafe.Pointer(&a))
    bs := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    
    fmt.Printf("a-->len=%d, cap=%d, type=%d\n", len(a), cap(a), as.Data)
    fmt.Printf("b-->len=%d, cap=%d, type=%d\n", len(b), cap(b), bs.Data)
}

运行结果:

a is nil
b is not nil
a-->len=0, cap=0, type=0
b-->len=0, cap=0, type=824634101392

从运行结果可以看到,var a []int  创建的切片是一个 nil 切片(底层数组没有分配,指针指向nil),其数据结构如下:

同时可以看到,make([]int, 0) 创建的是一个空切片(底层数组指针非空,但底层数组是空的)。数据结构如下:

查阅一下 makeslice() 函数的底层实现代码,就知道为什么 make([]int, 0) 创建的是一个空切片了。

// 源码位置:src/runtime/slice.go
func makeslice(et *_type, len, cap int) unsafe.Pointer {
    mem, overflow := math.MulUintptr(et.size, uintptr(cap))
    if overflow || mem > maxAlloc || len < 0 || len > cap {
        mem, overflow := math.MulUintptr(et.size, uintptr(len))
        if overflow || mem > maxAlloc || len < 0 {
            panicmakeslicelen()
        }
        panicmakeslicecap()
    }
	
    //调用mallocgc分配空间
    return mallocgc(mem, et, true)
}

接下来看一下在 len 和 cap 都为0的情况下,mallocgc()函数的代码片段:

//源码位置: src/runtime/malloc.go
// base address for all 0-byte allocations
var zerobase uintptr

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    if gcphase == _GCmarktermination {
        throw("mallocgc called with gcphase == _GCmarktermination")
    }

    if size == 0 {
        return unsafe.Pointer(&zerobase)
    }
    ......
}

可以看到,如果 len 和 cap 是0,则直接指向一个固定的 zerobase 全局变量的地址。

多个切片引用同一底层数组引发的混乱

切片可以由数组创建,一个底层数组可以创建多个切片,这些切片共享底层数组,使用内置函数 append() 扩展切片过程中可能修改底层数组的元素,间接地影响其他切片的值,也可能发生数组复制重建,共用底层数组的切片,由于其行为不明朗,不推荐使用。接下来看一个示例:

package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

func main() {
    a := []int{0, 1, 2, 3, 4, 5, 6}
    b := a[0:4]
    
    as := (*reflect.SliceHeader)(unsafe.Pointer(&a))
    bs := (*reflect.SliceHeader)(unsafe.Pointer(&b))
    
    //a,b共享底层数组
    fmt.Printf("a=%v, len=%d, cap=%d, type=%d\n", a, len(a), cap(a), as.Data)
    fmt.Printf("b=%v, len=%d, cap=%d, type=%d\n", b, len(b), cap(b), bs.Data)
    
    b = append(b, 10, 11, 12)
    
    //a,b继续共享底层数组,修改b会影响共享的底层数组,间接影响a
    fmt.Printf("a=%v, len=%d, cap=%d\n", a, len(a), cap(a))
    fmt.Printf("b=%v, len=%d, cap=%d\n", b, len(b), cap(b))
    
    //len(b)=7, 底层数组容量是7,此时需要重新分配数组,并将原来的数组元素复制到新数组中
    b = append(b, 13, 14)
    
    as = (*reflect.SliceHeader)(unsafe.Pointer(&a))
    bs = (*reflect.SliceHeader)(unsafe.Pointer(&b))
    
    //可以看到a和b指向的底层数组的指针已经不同了
    fmt.Printf("a=%v, len=%d, cap=%d, type=%d\n", a, len(a), cap(a), as.Data)
    fmt.Printf("b=%v, len=%d, cap=%d, type=%d\n", b, len(b), cap(b), bs.Data)
}

运行结果:

a=[0 1 2 3 4 5 6], len=7, cap=7, type=824633811072
b=[0 1 2 3], len=4, cap=7, type=824633811072
a=[0 1 2 3 10 11 12], len=7, cap=7
b=[0 1 2 3 10 11 12], len=7, cap=7
a=[0 1 2 3 10 11 12], len=7, cap=7, type=824633811072
b=[0 1 2 3 10 11 12 13 14], len=9, cap=14, type=824633999472

从运行结果可以看到,切片a和b共享底层数组的元素,当底层数组的长度不够时,Go语言运行时会重新分配新的底层数组,其长度是按2的倍数进行增长的。

问题总结:多个切片共享一个底层数组,其中一个切片的append操作可能引发如下两种情况。

(1)append 追加的元素没有超过底层数组的容量,此种 append 操作会直接操作共享的底层数组,如果其他切片有引用数组被覆盖的元素,则会导致其他切片的值也隐式地发生改变。

(2)append 追加的元素加上原来的元素如果超过底层数组的长度(或者或是容量),则此种append 操作会重新申请新的底层数组,并将原来数组元素复制到新数组中。

由于有这种二义性,所以在使用切片的过程中应该尽量避免多个切片共享底层数组,可以使用copy 进行显式地复制。

参考

《Go语言核心编程》

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值