Go中(slice)切片详解

811 阅读9分钟

前言

切片是一种数据结构,这种数据结构便于使用和管理数据集合。切片是围绕动态数组的概念 构建的,可以按需自动增长和缩小。切片的动态增长是通过内置函数 append 来实现的。这个函数可以快速且高效地增长切片。还可以通过对切片再次切片来缩小一个切片的大小。因为切片的底层内存也是在连续块中分配的,所以切片还能获得索引、迭代以及为垃圾回收优化的好处。

1. 内部实现

切切片有 3 个字段 的数据结构,这 3 个字段分别是

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}
  • 指向底层数组的指针
  • 切片访问的元素的个数(即长度)
  • 切片允许增长到的元素个数(即容量)

image.png

所以切片本质上是对底层数组的操作,只是在声明时[]有所不同

// 创建有 3 个元素的整型数组
array := [3]int{10, 20, 30} 

// 创建长度和容量都是 3 的整型切片
slice := []int{10, 20, 30}

2. 操作

2.1 make

一种创建切片的方法是使用内置的 make 函数。当使用 make 时,需要传入一个参数,指定 切片的长度,

// 创建一个字符串切片
// 其长度和容量都是 5 个元素 
slice := make([]string, 5)

// 创建一个整型切片 
// 其长度为 3 个元素,容量为 5 个元素 
slice := make([]int, 3, 5)

2.2 字面量来声明

这种方法和创建 数组类似,只是不需要指定[]运算符里的值。初始的长度和容量会基于初始化时提供的元素的个数确定

// 创建字符串切片 
// 其长度和容量都是 5 个元素
slice := []string{"Red", "Blue", "Green", "Yellow", "Pink"} 
// 创建一个整型切片
// 其长度和容量都是 3 个元素 
slice := []int{10, 20, 30}

2.3 长度和容量

Go 中,slice 的长度和容量是两个不同的概念。

Slice 的长度指的是其中元素的数量。我们可以使用内置函数 len() 来获取 slice 的长度。

Slice 的容量指的是它底层数组中能够容纳的元素数量。我们可以使用内置函数 cap() 来获取 slice 的容量。

本文目录 2.7 详细解释了容量的概念

func TestSlice(t *testing.T) {

	slice := make([]int, 3, 5)

	fmt.Println("slice length : ", len(slice))
	fmt.Println("slice[0] : ", slice[0])
	fmt.Println("slice[1] : ", slice[1])
	fmt.Println("slice[2] : ", slice[2])

	//panic: runtime error: index out of range [3] with length 3
	// fmt.Println("slice[3] : ", slice[3])

	sliceStr := make([]string, 3, 5)
	fmt.Println("sliceStr length : ", len(sliceStr))
	fmt.Println("sliceStr[0] : ", sliceStr[0])

}

=== RUN   TestSlice
slice length :  3
slice[0] :  0
slice[1] :  0
slice[2] :  0
sliceStr length :  3
sliceStr[0] :
--- PASS: TestSlice (0.00s)
PASS

可以看到,切片在初始化时。切片中的元素是给定的基本类型的初始值,如int为0,string为空 当访问length长度以外的元素时会报下标越界panic

2.4 赋值和切片

对切片里某个索引指向的元素赋值和对数组里某个索引指向的元素赋值的方法完全一样。使 用[]操作符就可以改变某个元素的值,

// 创建一个整型切片 
// 其容量和长度都是 5 个元素 
slice := []int{10, 20, 30, 40, 50} 

// 改变索引为 1 的元素的值 
slice[1] = 2
2.4.1 使用切片创建切片
// 创建一个整型切片 
// 其长度和容量都是 5 个元素 
slice := []int{10, 20, 30, 40, 50}
// 创建一个新切片 
// 其长度为 2 个元素,容量为 4 个元素 
newSlice := slice[1:3]

image.png

newSlice的元素为[20, 30]。它包含了slice切片中从索引位置1开始到索引位置3之前(不包括3)的所有元素。即,2030newSlice的元素,所以newSlice长度为2

newSlice的容量为4,因为它底层指向了原始切片slice中从索引位置1开始到末尾的部分,而该部分共有四个元素,即[20, 30, 40, 50],但是newSlice的长度为2,所以它只能访问到[20,30]

示例代码:

func TestSlice(t *testing.T) {

	slice := []int{10, 20, 30, 40, 50}
	newSlice := slice[1:3]
	
	fmt.Println("newSlice[0]:", newSlice[0])
	fmt.Println("newSlice[1]:", newSlice[1])
	fmt.Println("newSlice[2]:", newSlice[2])

}
=== RUN   TestSlice
newSlice[0]: 20
newSlice[1]: 30
--- FAIL: TestSlice (0.00s)
panic: runtime error: index out of range [2] with length 2 [recovered]
        panic: runtime error: index out of range [2] with length 2
2.4.2 计算公式

对底层数组容量是 k 的切片 slice[i:j]来说

  • 长度: j - i
  • 容量: k - i
2.5 共享底层数组问题

需要记住的是,现在两个切片共享同一个底层数组。如果一个切片修改了该底层数组的共享部分,另一个切片也能感知到!

// 创建一个整型切片
	// 其长度和容量都是 5 个元素
	slice := []int{10, 20, 30, 40, 50}
	// 创建一个新切片
	// 其长度是 2 个元素,容量是 4 个元素
	newSlice := slice[1:3]
	// 修改 newSlice 索引为 1 的元素
	// 同时也修改了原来的 slice 的索引为 2 的元素

	for _, value := range slice {
		fmt.Println("slice > ", value)
	}

	newSlice[1] = 35

	for _, value := range slice {
		fmt.Println("修改后slice > ", value)
	}
slice >  10
slice >  20
slice >  30
slice >  40
slice >  50
修改后slice >  10
修改后slice >  20
修改后slice >  35
修改后slice >  40
修改后slice >  50

操作示意图

image.png

2.6 切片增长(append)

Go 语言内置的 append 函数会处理增加长度时的所有操作细节


// 创建一个整型切片
	// 其长度和容量都是 5 个元素
	slice := []int{10, 20, 30, 40, 50}
	// 创建一个新切片
	// 其长度为 2 个元素,容量为 4 个元素
	newSlice := slice[1:3]
	// 使用原有的容量来分配一个新元素
	// 将新元素赋值为 60
	for _, value := range slice {
		fmt.Println("slice > ", value)
	}
	newSlice = append(newSlice, 60)
	for _, value := range slice {
		fmt.Println("修改后slice > ", value)
	}

slice >  10
slice >  20
slice >  30
slice >  40
slice >  50
修改后slice >  10
修改后slice >  20
修改后slice >  30
修改后slice >  60
修改后slice >  50

操作示意图

image.png

函数 append 会智能地处理底层数组的容量增长。在切片的容量小于 1024 个元素时,总是会成倍地增加容量。一旦元素个数超过 1024,容量的增长因子会设为 1.25,也就是会每次增加 25% 的容量。但 newcap 只是预估容量,并不是最终的容量,要计算最终的容量,还需要参考另一个维度,也就是内存分配

2.6.2 slice 扩容机制

根据最新go 1.20 源码分析:

//参数:
//oldPtr=指向切片的后备数组的指针
//newLen=新长度(=oldLen+num),扩充后所需长度
//oldCap=原始切片的容量。
//num=添加的元素数
//et=元件类型

//返回值:
//newPtr=指向新后备存储的指针
//newLen=与参数相同的值
//newCap=新后备存储器的容量
//
func growslice(oldPtr unsafe.Pointer, newLen, oldCap, num int, et *_type) slice {
	
    
    //第一步 初步计算预估容量
    .....
    newcap := oldCap 
	doublecap := newcap + newcap 
	if newLen > doublecap {
		newcap = newLen
	} else { 
		const threshold = 256
        if oldCap < threshold {
			newcap = doublecap
		} else {
			// Check 0 < newcap to detect overflow
			// and prevent an infinite loop.
			for 0 < newcap && newcap < newLen {
				// Transition from growing 2x for small slices
				// to growing 1.25x for large slices. This formula
				// gives a smooth-ish transition between the two.
                newcap += (newcap + 3*threshold) / 4 
          
			}
			// Set newcap to the requested cap when
			// the newcap calculation overflowed.
			if newcap <= 0 {
				newcap = newLen
			}
		}
        
      //第二步 根据不同机器平台进行内存分配处理
        switch {
            case et.Size_ == 1:
                lenmem = uintptr(oldLen)
                newlenmem = uintptr(newLen)
                capmem = roundupsize(uintptr(newcap))
                overflow = uintptr(newcap) > maxAlloc
                newcap = int(capmem)
            case et.Size_ == goarch.PtrSize:
                lenmem = uintptr(oldLen) * goarch.PtrSize
                newlenmem = uintptr(newLen) * goarch.PtrSize
                capmem = roundupsize(uintptr(newcap) * goarch.PtrSize)
                overflow = uintptr(newcap) > maxAlloc/goarch.PtrSize
                newcap = int(capmem / goarch.PtrSize)
            case isPowerOfTwo(et.Size_):
                var shift uintptr
                if goarch.PtrSize == 8 {
                    // Mask shift for better code generation.
                    shift = uintptr(sys.TrailingZeros64(uint64(et.Size_))) & 63
                } else {
                    shift = uintptr(sys.TrailingZeros32(uint32(et.Size_))) & 31
                }
                lenmem = uintptr(oldLen) << shift
                newlenmem = uintptr(newLen) << shift
                capmem = roundupsize(uintptr(newcap) << shift)
                overflow = uintptr(newcap) > (maxAlloc >> shift)
                newcap = int(capmem >> shift)
                capmem = uintptr(newcap) << shift
            default:
                lenmem = uintptr(oldLen) * et.Size_
                newlenmem = uintptr(newLen) * et.Size_
                capmem, overflow = math.MulUintptr(et.Size_, uintptr(newcap))
                capmem = roundupsize(capmem)
                newcap = int(capmem / et.Size_)
                capmem = uintptr(newcap) * et.Size_
            }
	}

通过源码可以看到,对应slice的扩充并不只是进行2x、1.25x扩充,这只是第一步。第二步则是根据不同机器平台进行内存分配处理,最终得出新的slice。

2.7 创建切片时的 3 个索引

在创建切片时,还可以使用之前我们没有提及的第三个索引选项。第三个索引可以用来控制新切片的容量。其目的并不是要增加容量,而是要限制容量。可以看到,允许限制新切片的容量 为底层数组提供了一定的保护,可以更好地控制追加操作。

	source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
	slice := source[2:3:4]
	for _, value := range slice {
		fmt.Println("slice > ", value)
	}
slice >  Plum

在 Go 中,slice 可以由一个底层数组派生而来,其语法形式为 a[low:high:max]。其中:

  • a 是一个数组(或指向数组的指针);
  • low 是 slice 的第一个元素的索引;
  • high 是 slice 最后一个元素的后一个位置的索引;
  • max 是 slice 的最大容量,即从 low 开始可以向后扩展的最大长度。

source[2:3:4] 中,2 是 slice 第一个元素 "Plum" 在底层数组中的索引,3 是 slice 最后一个元素的索引(不包括),也就是说它只包含了一个元素,即 "Plum"4 表示这个 slice 的最大容量,即它可以扩展到底层数组中第 4 个元素 "Grape" 的位置。因此,这个 slice 的长度是 1,容量是 2

2.7.1 计算方式

slice[i:j:k] 或 [2:3:4]

  • 长度: j – i 或 3 - 2 = 1
  • 容量: k – i 或 4 - 2 = 2
2.7.2 设置长度和容量一样的好处
// 创建字符串切片
	// 其长度和容量都是 5 个元素
	source := []string{"Apple", "Orange", "Plum", "Banana", "Grape"}
	// 对第三个元素做切片,并限制容量
	// 其长度和容量都是 1 个元素
	slice := source[2:3:3]
	// 向 slice 追加新字符串
	for _, value := range source {
		fmt.Println("source > ", value)
	}

	slice = append(slice, "Kiwi")

	for _, value := range source {
		fmt.Println("操作后source > ", value)
	}

	for _, value := range slice {
		fmt.Println("slice > ", value)
	}
        
        
        fmt.Printf("source地址:%p \n", &source)
	fmt.Printf("slice地址:%p \n", &slice)
        
source >  Apple
source >  Orange
source >  Plum
source >  Banana
source >  Grape
操作后source >  Apple
操作后source >  Orange
操作后source >  Plum
操作后source >  Banana
操作后source >  Grape
slice >  Plum
slice >  Kiwi

source地址:0xc0001000c0
slice地址:0xc0001000d8

可以看到我们限制了 slice 的容量为 1。当我们第一次对 slice 调用 append 的时候,会创建一个新的底层数组,这个数组包 括 2 个元素,并将水果 Plum 复制进来,再追加新水果 Kiwi,并返回一个引用了这个底层数组的新切片

因为新的切片 slice 拥有了自己的底层数组,所以杜绝了可能发生的问题。我们可以继续 向新切片里追加水果,而不用担心会不小心修改了其他切片里的水果。同时,也保持了为切片申 请新的底层数组的简洁。

image.png

2.8 将一个切片追加到另一个切片

果使用...运算符,可以将一个切片的所有元素追加到另一个切片里

// 创建两个切片,并分别用两个整数进行初始化
s1 := []int{1, 2}
s2 := []int{3, 4}
// 将两个切片追加在一起,并显示结果
fmt.Printf("%v\n", append(s1, s2...))
[1 2 3 4]
2.9 迭代切片
// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 迭代每一个元素,并显示其值
for index, value := range slice {
fmt.Printf("Index: %d Value: %d\n", index, value)
} 

Index: 0 Value: 10
Index: 1 Value: 20
Index: 2 Value: 30
Index: 3 Value: 40

需要注意的是,当迭代切片时,关键字 range 会返回两个值。第一个值是当前迭代到的索引位置,第二个 值是该位置对应元素值的一份副本

image.png

使用传统的 for 循环对切片进行迭代

// 创建一个整型切片
// 其长度和容量都是 4 个元素
slice := []int{10, 20, 30, 40}
// 从第三个元素开始迭代每个元素
for index := 2; index < len(slice); index++ {
fmt.Printf("Index: %d Value: %d\n", index, slice[index])
} 

Index: 2 Value: 30
Index: 3 Value: 40
  • 函数 len 返回切片的长度
  • 函数 cap 返回切片的容量

3. 在函数间传递切片

切片在函数参数传递是值传递还是地址传递?

在函数间传递切片就是要在函数间以的方式传递切片。 由于切片的尺寸很小,在函数间复制和传递切片成本也很低

image.png

3.1 在函数中试图修改元素
package slicelearn

import (
	"fmt"
	"testing"
)

func modifyArray(arr []int) {
	arr[0] = 100
	fmt.Println(arr)
	fmt.Printf("modifyArray arr地址%p,arr指向的底层数组地址:%p\n", &arr, arr)
}

func TestModifySilce(t *testing.T) {
	arr := []int{1, 2, 3, 4, 5}
	fmt.Printf("TestModifySilcearr地址 arr地址%p,arr指向的底层数组地址:%p\n", &arr, arr)
	modifyArray(arr)
	fmt.Println(arr)
	return
}


TestModifySilcearr地址 arr地址0xc0000080d8,arr指向的底层数组地址:0xc00000e4e0
[100 2 3 4 5]
modifyArray arr地址0xc000008108,arr指向的底层数组地址:0xc00000e4e0
[100 2 3 4 5]

可以看到函数外和函数内的切片地址是不同的,但它们所指向的底层数组是一样的,所以在函数内部对切片进行修改是会对原始切片产生影响的!

3.2 在函数中试图增加元素
package slicelearn

import (
	"fmt"
	"testing"
)

func addArray(paramArr []int) {
	paramArr = append(paramArr, 25)
	fmt.Println(paramArr)
}

func TestModifySilce(t *testing.T) {
	arr := []int{1, 2, 3, 4, 5}
	addArray(arr)
	fmt.Println(arr)
	return
}


[1 2 3 4 5 25]
[1 2 3 4 5]

可以看到addArray函数并没有修改到arr

这是因为在函数中传递的slice其实是一个副本,如图4-22所示

一个切片的结构是这样的:

type slice struct { 

array unsafe.Pointer 
len int 
cap int 

}

也就是说在addArray中是对副本进行了append操作,len的值会加1,同时底层数组也会改变,但是外层TestModifySilce方法中的arr切片的len却不会改变,所以我们输出的还是跟之前的一样

4. curd

在 Go 语言中,slice 的增删查改操作都可以通过内置函数来实现。下面是一些常用的 slice 操作:

  1. 增加元素:使用 append 内置函数。
s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(s) // 输出 [1 2 3 4]

  1. 删除元素:使用 append 和切片表达式结合来实现。
s := []int{1, 2, 3, 4}
i := 2 // 要删除的元素索引
s = append(s[:i], s[i+1:]...)
fmt.Println(s) // 输出 [1 2 4]

  1. 查找元素:使用 for 循环遍历 slice,并且使用 range 关键字来获取索引和值。
s := []int{1, 2, 3, 4}
for i, v := range s {
    if v == 3 {
        fmt.Printf("元素 %d 的索引为 %d\n", v, i)
        break
    }
}
// 输出 元素 3 的索引为 2

  1. 修改元素:直接使用索引对 slice 中的元素进行修改。
s := []int{1, 2, 3}
s[1] = 4
fmt.Println(s) // 输出 [1 4 3]


需要注意的是,对于删除和修改操作,由于 slice 是一个引用类型,所以对 slice 的操作会直接影响原始变量。因此,在进行修改和删除操作时需要注意不要影响到其他地方对该 slice 的引用。


参考书籍《Go语言实战》