首页 > 编程笔记 > Go语言笔记 阅读:11

Go语言切片用法教程(非常详细)

在 Go语言中数组的长度固定,灵活性差,因此不经常看到,但切片却无处不在,可以说它是 Go语言中最重要的数据类型。因此,切片是我们首选的数据结构

例如,在命令行参数中,os.Args 的值就是一个字符串类型的切片。

切片基于底层数组做了封装,它是底层数组的一个引用。切片具有以下特性:
如果将底层数组看作胶片,那么切片就是在显微镜下聚焦的胶片内容的特定部分。因此,修改了底层数组,切片上也会反映出来。

Go语言切片的设计

切片的定义方式是:

[]T

其中 T 表示切片元素的类型。与数组类型不同的是,切片类型没有指定长度,即切片类型的字面量只有元素的类型,没有长度。切片的长度可以随着元素数量的增加而增长,但不会随着元素数量的减少而变短。

1) 切片底层的数据结构

切片底层的数据结构是一个结构体,它在 runtime/slice.go 中的定义如下:
type slice struct {
   array unsafe.Pointer
   len   int
   cap   int
}
切片由指向数组的指针(ptr)、长度(len)和容量(cap)三大元素组成,其组成示意图如下图所示。


图 1 切片的组成示意图


切片的下标不能超过 len,向后扩展时容量不能超过 cap。各个切片之间可以共享底层数组,但起始位置、长度都可以不同。

2) 切片和数组的关系

让我们使用下面的代码来展示切片和数组之间的关系:
var intArr [6]int = [...]int{100, 200, -1, 0, 999, -1}
 
slice := intArr[1:4]
fmt.Println("intArr=", intArr) // [100 200 -1 0 999 -1]
fmt.Println("slice=", slice)  // [200 -1 0]
fmt.Println("slice 的元素个数 =", len(slice)) // 3
fmt.Println("slice 的容量 =", cap(slice))   // 5
 
fmt.Printf("intArr[1]的地址=%p\n", &intArr[1]) // 0xc00000c338
fmt.Printf("slice[0]的地址=%p ", &slice[0]]) //0xc00000c338
fmt.Printf("slice[1]的地址=%p ", &slice[1]]) //0xc00000c340
fmt.Printf("slice[2]的地址=%p ", &slice[2]]) //0xc00000c348
在上面的代码中,展示了如何基于现有的数组创建切片。本例中底层数组 intArr 是 {100, 200, −1, 0, 999, −1},切片的生成方式是 intArr[1:4]。因此,切片的第一个元素指向数组 intArr下标为 1 的元素 200,地址是 0xc00000c338。

同时,切片底层数组的指针指向元素 200 的地址。intArr[1:4] 表示该切片的元素是由底层数组 intArr 中第 1 个到第 4 个(不包含第 4 个)元素组成的,所以切片的值是 [200 −1 0],切片的长度是 3。

切片的容量与底层数组 intArr 有关,底层数组 intArr 的长度是 6,而切片是从第 1 个元素开始的,所以容量是 5(即 6−1)。

另外,切片元素的地址和数组一样,也是连续可预测的,且因为是 int 类型,所以值的间隔是 8字节。

下图是根据切片的定义画出的上述切片的内存结构示意图:


图 2 切片的内存结构示意图

Go语言切片的创建与初始化

我们可以基于一个已存在的数组或者切片创建切片,也可以使用 make() 函数创建切片。

1) 基于一个已存在的数组创建切片

可以使用整个数组或者数组的一部分元素来创建切片,也可以创建一个比底层数组还要大的切片。示例代码如下:
// 定义一个数组
var myArray = [...]int{1, 2, 3, 4, 5} //输出: 1 2 3 4 5
// 基于数组创建一个数组切片
mySlice := myArray[:3] //输出: 1 2 3
mySliceFromArray := myArray[:] // 算是一个语法糖,直接将数组转换为切片

2) 使用make()函数创建切片

使用 make() 函数创建切片的示例代码如下:
s := make([]int, 2)
s := make([]int, 2, 4)
如果使用 make() 函数时不指定 cap 的值,那么 cap 的默认值为 len。当使用 append() 函数扩容时,如果新的 len 超过了原来的 len,那么 cap 会乘以系数 2。当 cap 大于 1000 时,扩容时乘以系数 1.25。如果新的 len 没有超过原来的 len,则 cap 不变。

上面两个方法创建切片时有如下区别:

3) 通过切片来生成新的切片

除了前面介绍的两种方法,还可以基于切片生成新的切片,示例代码如下:
s := make([]string, 3)
l := s[2:5]
l = s[:5]
l = s[2:]
下面对切片的创建及初始化进行补充说明和总结:
1) 初始化切片时,可使用 var slice = arr[startIndex:endIndex] 这种方式,在这种方式下,切片的范围是从数组 arr 的下标 startIndex 开始,到下标 endIndex 的元素结束,不包含元素 arr[endIndex]。

2) 初始化切片时不能越界,切片范围在 [0-len(arr)] 之间,但可以动态增长。

3) 切片的简写方法如下:
var slice = arr[0:end] 可以简写为:var slice = arr[:end]
var slice = arr[start:len(arr)] 可以简写为: var slice = arr[start:]
var slice = arr[0:len(arr)] 可以简写为: var slice = arr[:]

4) 若只声明切片,此切片还不能使用,因为其本身是空的。需要让切片引用一个数组,或者使用 make() 函数开辟一个空间赋零值后才能使用。

5) 切片可以赋值给新切片,也可以再次被切片。

Go语言切片的长度与容量

使用 make() 函数构建切片时,如果只提供了长度值,如:
s := make([]int, 2)
那么容量的值该是多少?切片的长度与容量又有什么关系呢?

切片的长度是从指针所指的位置开始到所能访问到的元素数量的总和,这里的长度实际上起到了划定边界的作用。

切片的容量是从指针所指的位置算起,底层数组里存在的元素总量。因此,容量有可能比长度大,但长度不可能超过容量。

设定切片的容量主要是考虑到它以后可能会增长。可以使用内置函数 len() 和 cap() 分别获取长度和容量。示例代码如下:
mySlice := make([]int, 5, 10)
len(mySlice) //输出:5
cap(mySlice) //输出:10

切片容量的计算方法是总长度减去切片开始位置的下标,示例代码如下:
s := []int{1, 2, 3, 4, 5, 6} // 构建一个长度和容量都为6的切片
 
// 切片s1的长度为0,容量为6
// 因为切片开始的位置没有变化(仍然是原切片的开始位置),所以容量等于原切片的长度
// s1的容量 = s的长度 - s1的开始位置在s中的下标,即6 – 0,所以s1的容量为6
s[:0] ——> cap = len - 0 = 6 - 0 = 6
 
// 切片s2的长度为4,容量为4
// 因为切片开始的位置向右移动了两位,所以容量等于原切片的长度减去移动的位置
// s2的容量 = s的长度 - s2的开始位置在s中的下标,即6 – 2,所以s2的容量为4
s[2:] ——> cap = len - 2 = 6 - 2 = 4

Go语言nil切片和空切片

在 Go语言中,使用 var 方式声明的变量,如果后面没有具体的初始化值,则为零值。

对于 int 类型的切片来说,切片的指针、长度和容量的零值分别是 nil、0 和 0。这种切片称作 nil 切片,常用在标准库和内置函数中。

下图描述了 nil 切片的状态:


图 3 nil切片的状态

容易与 nil 切片混淆的是空切片。空切片与 nil 切片不同,空切片的第一部分(指向数组的指针)并非 nil,而是一个空白结构体 struct,常被用来表示空集合。空切片的底层数组为空,但底层数组的指针非空。

可以通过 make() 函数和字面量这两种方式构建空切片,示例代码如下:
num := make([]int, 0) // 使用make()函数构建空的整型切片
num := []int{} // 使用切片字面量构建空的整型切片

下图描述了空切片的状态:


图 4 空切片的状态

空切片中的指针指向的是空白结构体。空白结构体是 Go 语言中的一种特殊类型,其特殊之处在于它是零分配类型。我们可以根据这种空白结构体创建成千上万个值,且不会发生任何分配。

在 runtime 包中有一个 8 字节的固定值,它就像一个全局变量一样,可以让空白结构体引用。因此,无论有多少个空白结构体,它们都会指向同一个地址。空切片内的指针指向的就是这个空白结构体,这是 Go语言一个很巧妙的设计。

Go语言切片的共享底层数组

切片的三个元素之一是指针,它指向底层支撑数组。

与之前提到的活动帧相结合来分析可知,如果将切片作为参数传递,则复制到边界另一侧的只是切片的副本。因此,复制的开销总是固定的(总是 24 字节),唯一需要存放在堆中的是切片共享的底层数组。

切片的这个特性使我们能最大限度地减少分配到堆上的内容,只需要把必须这样处理的内容(即每个指针必须共享的内容)放入堆中即可。

也正是因为有多个切片可以共享同一个底层数组这一特性,所以会产生一些副作用。比如,修改任意一个共享的数组元素时,会影响到共享这个数组的所有切片,这可能不是我们想要的,这一点值得我们注意。

接下来,将通过一段代码来说明切片是如何共享底层数组的。
strSlice := []string{"0","1", "2", "3", "4", "5", "6", "7"}
str2_5 := strSlice[2:5]
fmt.Println("str2_5:",str2_5, len(str2_5), cap(str2_5))
str4_5 := strSlice[4:5]
fmt.Println("str4_5:",str4_5, len(str4_5), cap(str4_5))
str4_5[0] = "999"
fmt.Println("str2_5:",str2_5)
fmt.Println("strSlice:",strSlice)
 
//输出
str2_5: [2 3 4] 3 6
str4_5: [4] 1 4
str2_5: [2 3 999]
strSlice: [0 1 2 3 999 5 6 7]
我们将上面的代码用示意图来表示,如下图所示:


图 5 切片共享底层数组的示意图

可以看到,上述代码定义了 3 个切片 strSlice、str2_5 和 str4_5:
3 个切片共享一个底层数组,所以当修改 str4_5 的第一个元素时,也会影响 strSlice 的第 5 个元素和 str2_5 的第 3 个元素。

Go语言append函数与切片的扩容

1) 向切片中追加元素

Go 提供了内置函数“append(被操作的切片,追加的值)”,用于向切片中追加元素。执行此函数后,会返回与原切片元素完全相同但在尾部追加了新元素的更大新切片。

append() 函数总是会增加新切片的长度,但其容量是否改变取决于被操作的切片的可用容量。要特别注意的是,append() 函数返回的是“新切片”!

关于 append() 函数的示例代码如下:
// 创建一个整型切片,其长度和容量都是5个元素
slice := make([]string, 5)
slice[0] = "Oracle"
slice[1] = "MySQL"
 
// 创建一个新切片,其长度为2个元素,容量为4(即5-1)个元素
newSlice := slice[1:3]
 
// 在原有的容量上追加新元素,赋值为"SQL Server"
newSlice = append(newSlice, "SQL Server")
对示例中的切片进行追加操作后,两个切片和底层数组的布局如下图所示:


图 6 切片和底层数组的布局示意图

切片 newSlice 的长度为 2、容量为 4,使用 append() 函数操作后其长度变为 3(即 2+1),没有超过容量 4,因此容量大小不变。

需要注意的是,因为切片 newSlice 和切片 slice 共享同一个底层数组,所以切片 slice 中索引为 3 的元素已被更改为切片 newSlice 中索引为 2 的元素,即“SQL Server”,我们可以通过运行程序查看输出结果进行验证。

2) 切片的扩容

在进行扩容操作时,如果切片的底层数组没有足够的容量,则会创建一个新的底层数组,此时会先将被引用的现有的值复制到新数组中,然后再添加新值。

示例代码如下:
// 其长度和容量都是4个元素
slice := []string{"Oracle", "MySQL" , "SQL Server", "Redis"}
newSlice := append(slice, "TiDB")
fmt.Println("len(slice):", len(slice))//输出: 4
fmt.Println("cap(slice):", cap(slice))//输出: 4
fmt.Println("len(newSlice):", len(newSlice))//输出: 5
fmt.Println("cap(newSlice):", cap(newSlice))//输出: 8
可以看到,由于切片的底层数组没有足够的可用容量,因此在进行扩容操作时,长度增加了 1,而容量扩大到了原来的 2 倍,从 4 变成了 8。

示例中的切片和底层数组的布局如下图所示:


图 7 进行扩容操作后,切片和底层数组的布局示意图

如果切片的底层数组没有足够的可用容量时,进行扩容操作时会动态扩容。当切片的元素个数小于 1000 时,它会成倍地扩大容量。当元素个数超过 1000 时,容量的增长因子为 1.25,即每次增加 25% 的容量。

函数会根据长度和容量是否相等来确定是否需要扩展底层数组。如果我们能预估最终结果的长度,那么就建议在使用 make() 函数时设置好长度和容量,而不让切片动态扩容。

注意,在进行扩容操作时,重新分配底层数组并不是必然的,只有当满足条件 len(slice1)+len(slice2)> cap(slice1) 时才会发生。

Go语言append() 函数引发的内存泄漏

前面提到,切片会共享底层数组。但使用 append() 函数追加元素时,如果底层数组没有足够的可用容量,就会生成一个新的切片。而此时底层指向的数组是一个扩容后的新数组,在这种情况下,将不再共享底层数组。

这时问题就来了,让我们看看下面的代码:
func main() {
   intSlice := make([]int, 2)
   intSlice[0] = 0
   intSlice[1] = 0
   fmt.Println(intSlice, &intSlice[0], len(intSlice), cap(intSlice))
 
   shareInt1 := &intSlice[1]
   *shareInt1++
   fmt.Println(intSlice, &intSlice[0], len(intSlice), cap(intSlice))//第1次输出
 
   intSlice = append(intSlice, 0)
   fmt.Println(intSlice, &intSlice[0], len(intSlice), cap(intSlice))//第2次输出
 
   *shareInt1++
   fmt.Println(intSlice, &intSlice[0], len(intSlice), cap(intSlice))//第3次输出
}
 
//输出
[0 0] 0xc00018c010 2 2
[0 1] 0xc00018c010 2 2 //第1次输出结果
[0 1 0] 0xc00019e000 3 4 //第2次输出结果
[0 1 0] 0xc00019e000 3 4 //第3次输出结果
代码分析:
1) 在上面的代码中,初始化了一个长度为 2 的切片,并为其赋值。

2) 首先,使用一个变量 shareInt1 共享切片下标为 1 的元素,并且做加 1 操作,第 1 次得出的结果是 [0 1] 0xc00018c010 2 2,intSlice[1] 的值变为 1,符合预期。

3) 接着,使用 append() 函数为这个切片追加一个元素,并输出结果。第 2 次得到的结果是 [0 1 0] 0xc00019e000 3 4,可以看到,底层数组的地址发生了变化,切片的长度和容量分别是 3 和 4。这是因为原切片的长度和容量是 2,根据切片扩容的算法,此时会创建一个新的长度为 4 的底层数组,并将老的切片的值复制过去。

4) 最后又对变量 shareInt1做加1操作,并输出结果。第 3 次得到的结果是 [0 1 0] 0xc00019e000 3 4。这个结果出乎意料,这是因为 intSlice[1] 的值并非 2,之前修改的数据和现在的新切片没有关系了,于是造成了数据的丢失。

5) 由于切片扩容,造成了地址为 0xc00018c010 的底层数组不能再被操作,但又因为变量 shareInt1 之前在引用这个底层数组,所以底层数组也不能被释放,从而导致内存泄漏。

上面这个例子提醒我们,在对切片追加元素时,如果有指向同一个底层数组的变量,则在对切片扩容时必须考虑数据共享和内存重新分配的问题。

综上所述,如果切片的长度等于容量,append() 函数会在向切片中添加新元素时复制原始数据,然后使用复制后的副本数据进行操作,底层数组不再指向老的数组。在这种情况下,之前修改的数据就和现在新的切片没有关系了,这就会造成数据丢失。

此外,程序里可能还会有一些指针指向旧的数据结构,若没有把这个引用关系给断掉,很有可能会导致总有变量在引用旧的底层数组。换句话说,即使旧的底层数组没有可能再进行任何操作了,但是分配在堆中的某个数组一直在引用它,那么垃圾回收也不会清理掉它,这样一来就会造成内存泄漏。

Go语言三下标切片

前面介绍了切片共享底层数组时可能会遇到的问题。那么怎样解决这个问题呢?

可以利用扩容可能生成新副本的特性,即让操作底层数组的这个切片在每次操作时都生成新的切片,这样就不会影响到其他共享这个数组的切片了。

具体的做法是创建一个长度和容量相等的切片,这就相当于新切片的长度最多只能延伸到底层数组当前可访问的最后一个元素的位置。当使用 append() 函数时,会先去检查当前有没有空间存放新的元素,答案自然是没有,所以必定会发生写时复制。

示例代码如下:
slice2 := slice1[2:4:4] //注意这个地方使用的是三下标。这个是 slice 的一个特性。
利用三下标参数特意制作出来的三索引或三下标切片,有助于减少对其他切片的影响(当多个切片共享同一个底层数组时,修改其中一个切片可能会影响到其他切片),让我们既可以在新切片的尾部追加元素,又不会影响到使用原来那个底层数组的其他切片。

Go语言切片的复制

我们可以使用内置函数 copy(dst, src []Type) 复制切片。它的作用是把原切片中的元素复制到目标切片上。复制时,如果两个数组的切片大小不一样,则按其中较小的切片的元素个数进行复制。

示例代码如下:
slice1 := []string{"Oracle", "MySQL", "SQLite"}
slice2 := []string{"Redis","Mangodb","xxx","yyyy"}
copy(slice2, slice1) //因为slice2长度为4,slice1长度为3,所以会将slice1的所有元素复制到slice2中
 
fmt.Println(slice2) //复制后的slice2为 [Oracle MySQL SQLite yyyy]
在上面的示例代码中,切片 slice2 的长度为 4,切片 slice1 的长度为 3,使用 copy() 函数将切片 slice1 复制到切片 slice2 上时,会将 slice1 的所有元素复制到 slice2 中。

copy() 函数还有一个妙用,是可以对切片进行缩容。因为切片要引用底层数组,当切片的容量小到一定程度时,会导致大量无用的内容无法被垃圾回收,从而造成空间浪费。此时,可以用 copy() 函数生成一个新切片,新切片的容量依照原切片成比例缩小。每一次缩容都会生成新的切片。

Go语言切片的比较

切片只能与 nil 进行比较,否则编译时会报错。示例代码如下:
a := []int{1, 2, 3, 4}
b := []int{1, 2, 3, 4}
if a == b { //切片只能与nil比较
        fmt.Println("equal")
}
 
//编译时报错
invalid operation: a == b (slice can only be compared to nil)
如果需要比较切片,可以使用反射包中的函数 reflect.DeepEqual 或自己实现相应的方法。

Go语言删除切片中的元素

Go 语言没有为数组或者切片提供专门的删除操作,所以要从切片中删除指定的元素需要自己实现,下面介绍几种基本思路。

1) 截取,修改原切片

这种方法以被删除元素为边界,且会将其前后两个部分的内存重新连接起来。关键代码如下:
func DeleteSliceEle1(sourceSlice []int, elem int) []int {
   for i := 0; i < len(sourceSlice); i++ {
          if sourceSlice[i] == elem {
               sourceSlice = append(sourceSlice[:i], sourceSlice[i+1:]...)
               i--
          }
   }
   return sourceSlice
}

2) 复制,不修改原切片

这种方法会重新创建一个新的切片,并将要删除的元素过滤掉。这种方法的优点是容易理解,不会修改原切片;缺点是需要开辟新的切片空间。

关键代码如下:
func DeleteSliceEle2(sourceSlice []int, elem int) []int {
     newSlice := make([]int, len(sourceSlice), len(sourceSlice))
     for i := 0; i < len(sourceSlice); i++ {
         if sourceSlice[i] != elem {
                newSlice = append(newSlice, sourceSlice[i])
         }
     }
     return newSlice
}

3) 位移,修改原切片

这种方法首先要初始化一个变量 index,用于记录下一个有效元素的位置,然后遍历切片的所有元素,当遇到有效元素(如果 v 不等于 elem,则说明它是有效元素,应该保留)时,将其移动到 sourceSlice[index] 处且 index 加 1。最终 index 的位置就是所有有效元素的下一个位置,最后再做一个截取操作返回一个新的切片,该切片包含所有的有效元素。

这种方法是对第一种方法的改进,它虽然会修改原来的切片,但每次只需要移动一个元素,因此性能更好。关键代码如下:
func DeleteSliceEle3(sourceSlice []int, elem int) []int {
    index := 0
    for _, v := range sourceSlice {
        if v != elem {
           sourceSlice[index] = v
           index++
        }
    }
    return sourceSlice[:index]
}

4) 性能比较

下面通过基准测试对以上三种方法进行性能测试:
//创建长度为 n 的切片,并将 per 作为百分比填充元素1
func InitSlice(n int, per int) []int {
        ...
}
 
//将 X 替换为 1、2、3即可测试3种不同的删除方法
//另外,删除的性能与直方图有关
func BenchmarkDeleteSliceX(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = DeleteSliceEleX(InitSlice(sliceSize,per), 1)
       //如:_ = DeleteSliceEle1(getSlice(1000), 50)
    }
}
测试的结果如下表所示:

表:删除切片中元素三种方法的性能测试结果
方法名 切片长度(元素个数) 被删除元素占比 循环次数 每个操作所花费的时间(单位:纳秒)
BenchmarkDeleteSlice1-8 1000 1/10 119310 9823
1000 1/5 76293 14171
1000 1/2 43437 27484
100000 1/2 433 2728454
BenchmarkDeleteSlice2-8 1000 1/10 113635 9910
1000 1/5 114982 9737
1000 1/2 137523 7737
100000 1/2 14252 86121
BenchmarkDeleteSlice3-8 1000 1/10 221662 5402
1000 1/5 229137 5214
1000 1/2 208809 5203
100000 1/2 21379 49645

从上表可以看出,元素的占比对算法是有影响的。在正常情况下,第三种方法的性能最好。切片长度越大,方法一的性能越差。

在某些极端情况下,如果元素的占比非常小甚至没有对应的值,那么方法一的性能反而最好。基准测试的结果如下。
//没有对应值的元素
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: golang-1/structure/slice/deletemethod
cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
BenchmarkDeleteSlice1-8         217840            5244 ns/op
BenchmarkDeleteSlice2-8         114322            9998 ns/op
BenchmarkDeleteSlice3-8         222088            5478 ns/op
PASS
ok     golang-1/structure/slice/deletemethod   3.836s

Go语言特殊的切片:字符串

字符串是一种常用的数据类型,同时它也是一种特殊的切片。字符串底层是一个 byte 数组,因此它可以和 []byte 类型相互转换。

因为字符串是只读不可变的,所以不能通过类似 str[0] = 'X' 的方式直接对其进行修改。修改字符串需要先将其转换为 []byte 或者 []rune 类型才行,修改完以后再重新转回字符串类型。

转换成 []byte 和 []rune 的区别如下:
示例代码如下:
s1:="example"
bystS1:=[]byte(s1)
byteS1[0]:= 'E'
fmt.Println(string(byteS1)) //Example
 
s2:="中文"
runeS2:=[]rune(s2)
runeS2[0]:= '英'
fmt.Println(string(runeS2)) //英文

Go语言数组与切片的对比

数组和切片都属于集合类型,都可以用来存储某一种类型的值(或者说元素)。

切片是对数组的简单封装。在每个切片的底层数据结构中,一定会包含一个数组(即底层数组),切片可以看作是对数组中某个连续片段的引用。因此,Go 语言的切片类型属于引用类型。

数组和切片的对比如下:

相关文章