golang 数组底层是如何实现的,为什么赋值和传参会复制整个数组,这种设计有什么好处?

在学习go语言时,对于数组的部分有一点困惑,为什么赋值和传参要复制整个数组,这种设计相对于传统方式有什么好处么?

阅读 11.2k
2 个回答

这个要说明白可能得很长的文章,所以,这里就简单点来说吧,关于数组:

  1. 长度是固定的
  2. 不推荐你直接将数组拿去做参数传递,但是可以传指针,这样就不需要复制
  3. Go 还提供了 Slice,这是不会复制的,要说这个 Slice,请接着看:

不像c语言中的数组变量,在golang中,数组变量是值,比如把一个数组传递给一个函数,它传递的是原来数组的拷贝,而不是像c语言中那样传递的是一个指向原来数组的指针,这会导致把大型数组传递给函数的效率比较低,所以官方教程建议我们在编程中更多的使用slice,这个slice更像c中的数组,不过在运行时可以检查是否越界访问,比c中的数组更安全。本文就来分析下golang中slice的实现。我们以一个简单的程序来开始分析:

go$ cat test.go
package main

import "fmt"

func f(s []int, x, y int) []int {
    r := s[x:y]
    return r
}

func main() {
    i := []int{1,2,3,4,5}
    s := f(i, 1, 3)
    fmt.Println(s, len(s), cap(s))
}
$ 8g test.go 
$ 8l test.8
$ ./8.out 
[2 3] 2 4

程序输出 slice s有两个元素23,长度为2,容量(capacity)4

gdb把断点设置在12行s := f(i, 1, 3), 然后运行到断点并反汇编附近代码(我加上了行号):

bash   0x08048c97 <+58>:    movl   $0x5,0x24(%esp)
   0x08048c9f <+66>:    movl   $0x5,0x28(%esp)
   0x08048ca7 <+74>:    mov    %edx,0x20(%esp)
1=> 0x08048cab <+78>:    lea    0x20(%esp),%esi
2  0x08048caf <+82>:    lea    (%esp),%edi
3   0x08048cb2 <+85>:    cld    
4   0x08048cb3 <+86>:    movsl  %ds:(%esi),%es:(%edi)
5   0x08048cb4 <+87>:    movsl  %ds:(%esi),%es:(%edi)
6   0x08048cb5 <+88>:    movsl  %ds:(%esi),%es:(%edi)
7   0x08048cb6 <+89>:    movl   $0x1,0xc(%esp)
8   0x08048cbe <+97>:    movl   $0x3,0x10(%esp)
   0x08048cc6 <+105>:    call   0x8048c00 <main.f>
   0x08048ccb <+110>:    lea    0x14(%esp),%ebx
   0x08048ccf <+114>:    mov    %ebx,%esi
   0x08048cd1 <+116>:    lea    0x2c(%esp),%edi

7,8两行把传递给f()函数的后两个参数1和3放入堆栈, 1-6行把第一个参数i放入堆栈,可以看到slice变量i共占了12个字节, 那我们看看这12个字节里面的内容到底是啥。把断点设置在0x08048cb6:

bash(gdb)break *0x08048cb6
Breakpoint 2 at 0x8048cb6: file /home/tito/gostudy/test.go, line 12.
(gdb) c
Continuing.

Breakpoint 2, 0x08048cb6 in main.main () at /home/tito/gostudy/test.go:12
12        s := f(i, 1, 3)
(gdb)i r
eax            0x98029c00    -1744659456
ecx            0x0    0
edx            0x98029c00    -1744659456
ebx            0x80f7168    135229800
esp            0x151f90    0x151f90
ebp            0x3d    0x3d
esi            0x151fbc    1384380
edi            0x151f9c    1384348
eip            0x8048cb6    0x8048cb6 <main.main+89>
eflags         0x200202    [ IF ID ]
cs             0x73    115
ss             0x7b    123
ds             0x7b    123
es             0x7b    123
fs             0x0    0
gs             0x3f    63
(gdb)

看到esp的值为0x151f90, 这个地方就是变量i的拷贝,看看:

bash(gdb) x /3x 0x151f90
0x151f90:    0x98029c00    0x00000005    0x00000005

从官方教程我们可以知道slice有一个lengh和capacity,再结合上面的go代码可以知道后面两个0x00000005一定lengh和capacity。再看看0x98029c00这个数字,看起来很像一个地址,所以我们看看这个地址开始的几个内存单元中存放的是啥

bash(gdb) x /8x 0x98029c00
0x98029c00:    0x00000001    0x00000002    0x00000003    0x00000004
0x98029c10:    0x00000005    0x00000000    0x00000000    0x00000000

这不就是数组[1,2,3,4,5]吗,所以到现在我们可以确定一个slice在内存中由4部分组成:

  1. 一个数组
  2. 指向这个数组的指针
  3. 长度(length)
  4. 容量(capacity)

这样我们可以用c语言的struct来表示这个slice:

cstruct Slice { 

        int *ptr;

        unsigned len;  

        unsigned cap;

}

ptr一定是第一个成员, lencap的顺序还不敢肯定,后面的分析我们可以确认他们的顺序

从上面的分析还可以得出结论: slice作为参数传递时只传递了指针,长度和容量,而数组却没有copy.

为了验证上面的分析,我们继续反汇编函数f():

bashDump of assembler code for function main.f:
1   0x08048c00 <+0>:    mov    %gs:0x0,%ecx
2   0x08048c07 <+7>:    mov    -0x8(%ecx),%ecx
3   0x08048c0a <+10>:    cmp    (%ecx),%esp
4   0x08048c0c <+12>:    ja     0x8048c1a <main.f+26>
5   0x08048c0e <+14>:    xor    %edx,%edx
6   0x08048c10 <+16>:    mov    $0x20,%eax
7   0x08048c15 <+21>:    call   0x8049a04 <runtime.morestack>
8   0x08048c1a <+26>:    sub    $0xc,%esp
9   0x08048c1d <+29>:    mov    0x18(%esp),%eax
10   0x08048c21 <+33>:    mov    0x1c(%esp),%ecx
11   0x08048c25 <+37>:    mov    0x20(%esp),%edx
12   0x08048c29 <+41>:    cmp    %eax,%edx
13   0x08048c2b <+43>:    jbe    0x8048c32 <main.f+50>
14   0x08048c2d <+45>:    call   0x805566f <runtime.panicslice>
15   0x08048c32 <+50>:    cmp    %edx,%ecx
16   0x08048c34 <+52>:    ja     0x8048c2d <main.f+45>
17   0x08048c36 <+54>:    sub    %ecx,%edx
18   0x08048c38 <+56>:    mov    %edx,0x4(%esp)
19   0x08048c3c <+60>:    mov    %eax,%edx
20   0x08048c3e <+62>:    sub    %ecx,%edx
21   0x08048c40 <+64>:    mov    %edx,0x8(%esp)
22   0x08048c44 <+68>:    imul   $0x4,%ecx,%ecx
23   0x08048c47 <+71>:    add    0x10(%esp),%ecx
24   0x08048c4b <+75>:    mov    %ecx,(%esp)
25   0x08048c4e <+78>:    lea    (%esp),%esi
26   0x08048c51 <+81>:    lea    0x24(%esp),%edi
27   0x08048c55 <+85>:    cld    
28   0x08048c56 <+86>:    movsl  %ds:(%esi),%es:(%edi)
29   0x08048c57 <+87>:    movsl  %ds:(%esi),%es:(%edi)
30   0x08048c58 <+88>:    movsl  %ds:(%esi),%es:(%edi)
31   0x08048c59 <+89>:    add    $0xc,%esp
32   0x08048c5c <+92>:    ret    
End of assembler dump.

为了便于叙述,我把gdb dump出来的汇编代码前面加上了行号。

1 - 7行是函数序言,它是由链接器8l插入的,它跟slice的实现没有关系,这里跳过,以后我们再来专门讨论这个;

第8行sub $0xc,%esp调整堆栈寄存器esp的值,分配了12个字节的栈空间,这12个字节中包含了局部变量 r 所占的栈空间。这条指令执行完成后,栈布局是这样的:

bashesp -- esp + 0xc: 垃圾数据 (12bytes)

esp + 0xc: 函数f()的返回地址 (4bytes)

esp + 0x10: s.ptr(0x98029c00) (4bytes)

esp + 0x14: s.len(5) (4bytes)

esp + 0x18: s.cap(5) (4bytes)

esp + 0x1c: f()的第二个参数x(1) (4bytes)

esp + 0x20: f()的第三个参数y(3) (4bytes)

继续分析上面的指令:

bash9   0x08048c1d <+29>:    mov    0x18(%esp),%eax
10   0x08048c21 <+33>:    mov    0x1c(%esp),%ecx
11   0x08048c25 <+37>:    mov    0x20(%esp),%edx
12   0x08048c29 <+41>:    cmp    %eax,%edx
13   0x08048c2b <+43>:    jbe    0x8048c32 <main.f+50>
14   0x08048c2d <+45>:    call   0x805566f <runtime.panicslice>
15   0x08048c32 <+50>:    cmp    %edx,%ecx
16   0x08048c34 <+52>:    ja     0x8048c2d <main.f+45>

这几行检查是否满足条件 y <= s.cap && x <= y && x >=0 && y >=0(注意这里用的是无符号比较转移指令jbe和ja),如果不满足则说明越界或不合法的slice操作,需要调用runtime.panicslice()结束程序,否则继续执行后面的指令;

下面这几条指令比较直白,我把对应的c代码直接写在汇编指令后面

bash17   0x08048c36 <+54>:    sub    %ecx,%edx     // y - x, 2
18   0x08048c38 <+56>:    mov    %edx,0x4(%esp)  // r.len = 2
19   0x08048c3c <+60>:    mov    %eax,%edx  
20   0x08048c3e <+62>:    sub    %ecx,%edx  //s.cap - x, 4
21   0x08048c40 <+64>:    mov    %edx,0x8(%esp) //r.cap = 4
22   0x08048c44 <+68>:    imul   $0x4,%ecx,%ecx // x * 4,因为每个数组元素占用4bytes
23   0x08048c47 <+71>:    add    0x10(%esp),%ecx //s.ptr + x * 4
24   0x08048c4b <+75>:    mov    %ecx,(%esp) //r.ptr = s.ptr + x * 4

用c代码来表示上面这几行汇编指令大概就是这个样子 :

bashr.len = y - x;

r.cap = s.cap -x;

r.ptr = s.ptr + x; //  注意这里 ptr 是 int 型指针

从上面的分析可以看到slice操作只有9-24这16条指令,所以说是相当迅速的,这也验证了官方文档说的slice is cheap!

题主的问题:数组fixed-size-array,在go博客http://blog.golang.org/slices里也有一些提及,我自己的观点是:

因为array的长度就是array类型的一部分,go里的array从生产力角度确实应用空间很有限,设计者就是想要我们多用slice吧,如果想灵活些就把底下的内存操作交给slice,而slice底下是通过操作array来帮你实现,注重效率就自己用array。

另外我觉得go的作者们是资深c粉,go目标也是替代google的c++ base的庞大代码库的系统语言。c中栈上数组都是要指定大小的跟go类似,堆上申请内存malloc也当然是要指定大小的,用数组的指针时候也都要手动去注意考虑数组的长度。像为处理c的数组做好接口,也都要啰嗦的多传一个参数指定长度,要么就声明可能会覆盖而把责任抛给程序员,POSIX的也概莫能外,从此有太多太多bug因此而起,所以这个array的设计是很自然的对c的一种改进。

上篇博客对append的详细解释就是应对了一些很容易犯错的地方。slice的具体解释golang blog上有一篇文章:http://blog.golang.org/go-slices-usage-and-internals

推荐问题