Go语言中的Slice
数组
几乎所有的编程语言里都存在数组,Go 也不例外。那么为什么 Go 语言除了数组之外又设计了 slice 呢?要想解答这个问题,先来了解数组的局限性。
在下面的示例中,a1、a2 是两个定义好的数组,但是它们的类型不一样。变量 a1 的类型是 [1]string,变量 a2 的类型是 [2]string,也就是说数组的大小属于数组类型的一部分,只有数组内部元素类型和大小一致时,这两个数组才是同一类型。
可以总结为,一个数组由两部分构成:数组的大小和数组内的元素类型。
比如变量 a1 的大小是 1,内部元素的类型是 string,也就是说 a1 最多只能存储 1 个类型为 string 的元素。而 a2 的大小是 2,内部元素的类型也是 string,所以 a2 最多可以存储 2 个类型为 string 的元素。一旦一个数组被声明,它的大小和内部元素的类型就不能改变,你不能随意地向数组添加任意多个元素。这是数组的第一个限制。
既然数组的大小是固定的,如果需要使用数组存储大量的数据,就需要提前指定一个合适的大小,比如 10 万,代码如下所示:
| |
这样虽然可以解决问题,但又带来了另外的问题,那就是内存占用。因为在 Go 语言中,函数间的传参是值传递的,数组作为参数在各个函数之间被传递的时候,同样的内容就会被一遍遍地复制,这就会造成大量的内存浪费,这是数组的第二个限制。
虽然数组有限制,但是它是 Go 非常重要的底层数据结构,比如 slice 切片的底层数据就存储在数组中。
slice 切片
数组虽然也不错,但是在操作上有不少限制,为了解决这些限制,Go 语言创造了 slice,也就是切片。切片是对数组的抽象和封装,它的底层是一个数组存储所有的元素,但是它可以动态地添加元素,容量不足时还可以自动扩容,你完全可以把切片理解为动态数组。在 Go 语言中,除了明确需要指定长度大小的类型需要数组来完成,大多数情况下都是使用切片的。
动态扩容
通过内置的 append 方法,你可以向一个切片中追加任意多个元素,所以这就可以解决数组的第一个限制。
当通过 append 追加元素时,如果切片的容量不够,append 函数会自动扩容。比如上面的例子,打印出使用 append 前后的切片长度和容量,代码如下:
在调用 append 之前,容量是 2,调用之后容量是 4,说明自动扩容了。
小提示:append 自动扩容的原理是新创建一个底层数组,把原来切片内的元素拷贝到新数组中,然后再返回一个指向新数组的切片。
数据结构
在 Go 语言中,切片其实是一个结构体,它的定义如下所示:
SliceHeader 是切片在运行时的表现形式,它有三个字段 Data、Len 和 Cap。
Data 用来指向存储切片元素的数组。
Len 代表切片的长度。
Cap 代表切片的容量。
通过这三个字段,就可以把一个数组抽象成一个切片,便于更好的操作,所以不同切片对应的底层 Data 指向的可能是同一个数组。现在通过一个示例来证明,代码如下:
| |
这两个切片共用一个数组,所以我们在切片赋值、重新进行切片操作时,使用的还是同一个数组,没有复制原来的元素。这样可以减少内存的占用,提高效率。
注意:多个切片共用一个底层数组虽然可以减少内存占用,但是如果有一个切片修改内部的元素,其他切片也会受影响。所以在切片作为参数在函数间传递的时候要小心,尽可能不要修改原切片内的元素。
通过运行结果我们发现 修改s1下标为0的元素时 s2下标为0的元素也被修改了。
切片的本质是 SliceHeader,又因为函数的参数是值传递,所以传递的是 SliceHeader 的副本,而不是底层数组的副本。这时候切片的优势就体现出来了,因为 SliceHeader 的副本内存占用非常少,即使是一个非常大的切片(底层数组有很多元素),也顶多占用 24 个字节的内存,这就解决了大数组在传参时内存浪费的问题。
小提示:SliceHeader 三个字段的类型分别是 uintptr、int 和 int,在 64 位的机器上,这三个字段最多也就是 int64 类型,一个 int64 占 8 个字节,三个 int64 占 24 个字节内存。
高效的原因
如果从集合类型的角度考虑,数组、切片和 map 都是集合类型,因为它们都可以存放元素,但是数组和切片的取值和赋值操作要更高效,因为它们是连续的内存操作,通过索引就可以快速地找到元素存储的地址。
小提示:当然 map 的价值也非常大,因为它的 Key 可以是很多类型,比如 int、int64、string 等,但是数组和切片的索引只能是整数。
进一步对比,在数组和切片中,切片又是高效的,因为它在赋值、函数传参的时候,并不会把所有的元素都复制一遍,而只是复制 SliceHeader 的三个字段就可以了,共用的还是同一个底层数组。
在下面的示例中,我定义了两个函数 arrayF 和 sliceF,分别打印传入的数组和切片底层对应的数组指针。
| |
同一个数组在 main 函数中的指针和在 arrayF 函数中的指针是不一样的,这说明数组在传参的时候被复制了,又产生了一个新数组。而 slice 切片的底层 Data 是一样的,这说明不管是在 main 函数还是 sliceF 函数中,这两个切片共用的还是同一个底层数组,底层数组并没有被复制。
小提示:切片的高效还体现在 for range 循环中,因为循环得到的临时变量也是个值拷贝,所以在遍历大的数组时,切片的效率更高。
切片基于指针的封装是它效率高的根本原因,因为可以减少内存的占用,以及减少内存复制时的时间消耗。
string 和 []byte 互转
把一个 []byte 转为一个 string 字符串,然后再转换回来,示例代码如下:
Go 语言通过先分配一个内存再复制内容的方式,实现 string 和 []byte 之间的强制转换。现在我通过 string 和 []byte 指向的真实内容的内存地址,来验证强制转换是采用重新分配内存的方式。如下面的代码所示:
| |
通过以上的示例代码,你已经知道了 SliceHeader 是什么。其实 StringHeader 和 SliceHeader 一样,代表的是字符串在程序运行时的真实结构,StringHeader 的定义如下所示:
也就是说,在程序运行的时候,字符串和切片本质上就是 StringHeader 和 SliceHeader。这两个结构体都有一个 Data 字段,用于存放指向真实内容的指针。所以我们打印出 Data 这个字段的值,就可以判断 string 和 []byte 强制转换后是不是重新分配了内存。
现在你已经知道了 []byte(s) 和 string(b) 这种强制转换会重新拷贝一份字符串,如果字符串非常大,由于内存开销大,对于有高性能要求的程序来说,这种方式就无法满足了,需要进行性能优化。
如何优化呢?既然是因为内存分配导致内存开销大,那么优化的思路应该是在不重新申请内存的情况下实现类型转换。
仔细观察 StringHeader 和 SliceHeader 这两个结构体,会发现它们的前两个字段一模一样,那么 []byte 转 string,就等于通过 unsafe.Pointer 把 *SliceHeader 转为 *StringHeader,也就是 *[]byte 转 *string,原理和我上面讲的把切片转换成一个自定义的 slice 结构体类似。
在下面的示例中,s4 和 s3 的内容是一样的。不一样的是 s4 没有申请新内存(零拷贝),它和变量 b 使用的是同一块内存,因为它们的底层 Data 字段值相同,这样就节约了内存,也达到了 []byte 转 string 的目的。
SliceHeader 有 Data、Len、Cap 三个字段,StringHeader 有 Data、Len 两个字段,所以 *SliceHeader 通过 unsafe.Pointer 转为 *StringHeader 的时候没有问题,因为 *SliceHeader 可以提供 *StringHeader 所需的 Data 和 Len 字段的值。但是反过来却不行了,因为 *StringHeader 缺少 *SliceHeader 所需的 Cap 字段,需要我们自己补上一个默认值。
在下面的示例中,b1 和 b 的内容是一样的,不一样的是 b1 没有申请新内存,而是和变量 s 使用同一块内存,因为它们底层的 Data 字段相同,所以也节约了内存。
注意:通过 unsafe.Pointer 把 string 转为 []byte 后,不能对 []byte 修改,比如不可以进行 b1[0]=12 这种操作,会报异常,导致程序崩溃。这是因为在 Go 语言中 string 内存是只读的。
通过 unsafe.Pointer 进行类型转换,避免内存拷贝提升性能的方法在 Go 语言标准库中也有使用,比如 strings.Builder 这个结构体,它内部有 buf 字段存储内容,在通过 String 方法把 []byte 类型的 buf 转为 string 的时候,就使用 unsafe.Pointer 提高了效率,代码如下:
string 和 []byte 的互转就是一个很好的利用 SliceHeader 结构体的示例,通过它可以实现零拷贝的类型转换,提升了效率,避免了内存浪费。
小实例分析
| |
解析: 第一行输出是s刚初始化此时值为 2和3 且长度为2 容量也为2 第二行输出是在append2函数中修改了index为0为666且追加4后的结果,此时追加了666导致切片扩容,所以其地址发省了变化 那么如果我们初始化时就给他足够的长度呢?
| |
此时我们在调用append2函数前 切片的容量就够追加一个元素进去,但是在main函数中的s还是没有追加的"5",但是index为0的2却改为了666,而且其len仍然是3
所以可以发现slice在传递时不是完全的引用传递,应该是只传递了指向数组的指针过去,所以在append2中改成了底层数组的元素,但是并没有改变切片本身的len。
总结
通过 slice 切片的分析,可以更深地感受 Go 的魅力,它把底层的指针、数组等进行封装,提供一个切片的概念给开发者,这样既可以方便使用、提高开发效率,又可以提高程序的性能。
Go 语言设计切片的思路非常有借鉴意义,也可以使用 uintptr 或者 slice 类型的字段来提升性能,就像 Go 语言 SliceHeader 里的 Data uintptr 字段一样。