Go 语言类型 切片

基本概念

切片(Slice)是对数组一个连续片段的引用,属于引用类型。它会生成一个指向数组的指针,并通过切片长度关联到底层数组部分或全部元素。切片长度和容量可以按需动态调整。

在 Go 语言 runtime 包中,可以找到 slice.go 源文件,里面包含切片定义:

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

切片结构包含三部分:

  • 指针:指向底层数组中切片第一个元素的指针。
  • 长度:切片中元素数量,不能超过切片容量。
  • 容量:从切片起始元素到底层数组末尾元素的数量。

在 64 位操作系统中,这些字段分别占用 8 个字节,因此传递切片非常高效。

创建切片

切片初始化时,会自动创建对应底层数组。一个底层数组可以对应多个切片。

声明和初始化

切片声明看起来像没有长度的数组,声明和初始化切片常用 4 种方式:

package main

import "fmt"

func main() {
	// 声明一个 nil 切片,长度和容量都为 0。后续可以正常使用
	var a []int

	// 直接初始化切片,支持索引赋值方式
	var b = []int{0, 2: 2, 3}

	// 短变量声明初始化切片
	c := []string{"a", "bc"}

	// 使用 make 函数创建切片,必须指定长度和容量
    // 长度数量的元素初始化为类型零值
    // 如果切片长度和容量相同,可以只传一个参数
    // 如果不确定元素数量,可将长度设为 0,创建空切片
	d := make([]int, 3, 5)

	// 输出:[] [0 0 2 3] [a bc] [0 0 0]
	fmt.Println(a, b, c, d)
}

nil 切片

nil 切片代表切片不存在,用于在函数发生异常时返回;空切片代表没有数据,用于在函数正常运行时返回。在使用上,nil 切片和空切片没有区别:

package main

import (
	"errors"
	"fmt"
)

// fetchData 返回空切片,表示函数正常执行但没有数据。
func fetchData(condition bool) ([]string, error) {
	if condition {
		// 模拟没有数据,返回一个空切片
		return []string{}, nil
	} else {
		// 模拟发生错误,直接返回 nil
		return nil, errors.New("发送错误,返回 nil")
	}
}

func main() {
	// 示例调用
	result, err := fetchData(false)
	if err != nil {
		fmt.Println("错误:", err)
	} else if len(result) == 0 {
		fmt.Println("结果为空切片")
	} else {
		fmt.Println("结果为:", result)
	}

	// 示范对 nil 切片和空切片插入
	fmt.Println(append(result, "a"))   // 输出:[a]
	fmt.Println(append([]rune{}, 'a')) // 输出:[97]
}

一般直接返回 nil 代表 nil 切片,所有默认零值为 nil 的类型都可以这么处理。

切片容量

由于切片扩容时会进行数据复制操作,如果能在建立切片时预估好容量,可以减少复制操作次数,提升程序性能:

package main

import (
	"testing"
)

const sliceSize = 100000

// 测试不带容量初始化切片性能
func BenchmarkSlice(b *testing.B) {
	for n := 0; n < b.N; n++ {
		sl := make([]int, 0)
		for i := 0; i < sliceSize; i++ {
			sl = append(sl, i)
		}
	}
}

// 测试带容量初始化切片性能
func BenchmarkSliceCap(b *testing.B) {
	for n := 0; n < b.N; n++ {
		sl := make([]int, 0, sliceSize)
		for i := 0; i < sliceSize; i++ {
			sl = append(sl, i)
		}
	}
}

运行基准测试:

D:\Software\Programming\Go\new>go test -bench=. -benchmem
goos: windows
goarch: amd64
pkg: new
cpu: AMD Ryzen Threadripper 2990WX 32-Core Processor
BenchmarkSlice-64          1021     1077339 ns/op    4101406 B/op   28 allocs/op
BenchmarkSliceCap-64       7056      170504 ns/op     802816 B/op   1 allocs/op
PASS
ok      new     2.483s

从测试报告可知,切片预设容量后速度提升巨大,占用内存更少。

切片指针

切片头结构中存放的指针地址,可以用下面方式查看:

package main

import (
	"fmt"
)

func main() {
	s := []int{1, 2, 3}

	// 切片变量指针地址
	fmt.Printf("%p\n", &s)

	// 切片结构储存的内部指针,指向底层数组首地址
	fmt.Printf("%p\n", s)
	fmt.Printf("%p\n", &s[0])
}

在切片访问元素时,通过这个内部指针地址,加上元素索引乘以元素类型大小来计算元素位置。下面使用 unsafe 包绕过类型安全检查,计算指定切片元素地址:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	a := [3]int8{10, 20, 30}
	s := a[:]

	// 输出第一个元素地址
	p := unsafe.Pointer(&s[0])
	fmt.Printf("Address: %p, Value: %d\n", p, *(*int8)(p))
	fmt.Printf("Pointer: %p\n", &s[0]) // 输出: 0xc00000a0a8

	// 计算第三个元素地址,int8 类型只占用 1 字节
	p = unsafe.Pointer(uintptr(p) + 2*unsafe.Sizeof(a[0]))
	fmt.Printf("Address: %p, Value: %d\n", p, *(*int8)(p))
	fmt.Printf("Pointer: %p\n", &s[2]) // 输出:0xc00000a0aa
}

切分切片

Go 语言允许从已有数组或切片切分来创建新切片,也叫切片派生操作。切分操作不会复制底层数组元素,只是创建一个新切片头,指向同一个数组。

基本语法

切分语法为 slice[low:high:max]slice 是原始切片或数组,所需三个索引值说明如下:

  • low:新切片起始索引。
  • high:新切片结束索引,不包括此索引号的元素。
  • max(可选):新切片容量上限,不能小于 high,不能大于源对象长度。

新切片取源对象从 lowhigh 索引为止的元素。max 用来限制新切片能访问源对象的最大索引,避免引用多余元素:

package main

import "fmt"

func main() {
	a := []int{1, 2, 3, 4}

	// 设定最大容量索引等于结束索引,也叫完全派生表达式
	b := a[1:2:2]
	// 可以安全插入,会导致切片 b 扩容
	b = append(b, 30)
	fmt.Println(a, b) // 输出:[1 2 3 4] [2 30]

	// b 已经不和 a 共用底层数组,后续修改很安全
	b[0] = 10
	fmt.Println(a, b) // 输出:[1 2 3 4] [10 30]
}

切片属性

新切片属性计算方法:

  • 长度等于 high - low
  • 容量等于 max - low
  • 没有指定 low,则起始索引为 0
  • 没有指定 high,则结束索引等于源对象长度;
  • 没有指定 max,新切片容量等于 源对象容量 - low

可以使用内置函数 lencap 来计算切片长度和容量:

package main

import "fmt"

func main() {
	// 创建一个初始数组
	originalArray := [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	// 基于原始数组创建一个切片,指定起始索引为 1,结束索引为 4
	newSlice := originalArray[1:4]
	// 输出新切片内容,为原数组第二到第四个元素:[2 3 4]
	fmt.Println("新切片内容:", newSlice)
	// 输出新切片长度,为结束索引减起始索引:4-1=3
	fmt.Println("新切片长度:", len(newSlice))
	// 输出新切片容量,为原数组容量减起始索引:10-1=9
	fmt.Println("新切片容量:", cap(newSlice))

	// 基于切片创建切片,且只指定结束索引。起始索引为 0
	newSliceFromSlice := newSlice[:2]
	// 输出切片的切片内容、长度和容量,计算方法同上。
	fmt.Println("切片的切片内容:", newSliceFromSlice)      // 打印:[2 3]
	fmt.Println("切片的切片长度:", len(newSliceFromSlice)) // 打印:2
	fmt.Println("切片的切片容量:", cap(newSliceFromSlice)) // 打印:9

	// 创建切片时指定最大容量索引,这里指定为 6
	sliceWithCapacity := originalArray[1:4:6]
	// 输出新切片内容:[2 3 4]
	fmt.Println("容量切片内容:", sliceWithCapacity)
	// 输出新切片容量,为最大容量索引减起始索引:6-1=5
	fmt.Println("容量切片容量:", cap(sliceWithCapacity))

	// 无效容量值,必须满足:起始索引 <= 结束索引 <= 容量
	sliceWithInvalid := originalArray[1:4:2]
	// 无效容量值 11,超出原数组长度界限 10
	sliceWithInvalid := originalArray[1:4:11]
}

切片操作

切片提供比数组更加强大和灵活的功能。

遍历切片

和遍历数组一样,可以使用 for 配合 range 关键字,来迭代切片元素。注意,用 range 遍历会创建每个元素的副本,而不是返回对该元素的引用:

package main

import "fmt"

func main() {
	s := []string{"a", "b", "c"}

	// 使用 for-range 结构遍历切片,打印每个元素信息
	for i, v := range s {
		// 变量 v 的地址和切片中元素的原始地址不一样,每次循环只是对局部变量 v 重复赋值,所以 v 的地址不变
		fmt.Printf("索引: %d, 值: %s, 循环内部变量 v 地址: %p, 原始元素地址: %p\n", i, v, &v, &s[i])
	}
}

由于循环内变量在多次循环中共享,所以 v 始终是同一个地址。但在 Go 1.22 版本中,每次循环会给循环变量 v 分配新的内存地址。

比较切片

由于切片元素类型没有限制,很难同时兼顾值和引用类型对比,所以切片只能与 nil 比较,不能直接比较两个切片内容:

package main

import "fmt"

func main() {
	a := []int{1, 2, 3}
	b := []int{} // 空切片不等于 nil

	// 比较 a 或 b 是否为 nil
	if a == nil || b == nil {
		fmt.Println("切片为 nil")
	}

	// 报错:无效运算
	if a == b {
		fmt.Println("不能比较两个切片是否相等")
	}

	// 自行实现浅比较
	fmt.Println("相等:", equal(a, []int{1, 2, 3}))
}

// 最简单的比较函数,元素类型为基本整型
func equal(x, y []int) bool {
	if len(x) != len(y) {
		return false
	}
	for i := range x {
		if x[i] != y[i] {
			return false
		}
	}
	return true
}

要判断一个切片是否为空,一般检查长度是否为 0,很少与 nil 直接比较。

修改元素值

通过切片索引对元素值修改:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 4, 5}
	fmt.Println("原始切片:", s)

	// 一次性修改切片中两个元素值
	s[2], s[3] = 99, 88
	fmt.Println("修改后的切片:", s)
}

插入元素

内置 append 函数可将一个到多个新元素添加到切片末尾。如果原切片容量足够,元素将直接追加到原切片末尾,返回原切片;如果容量不足,append 将分配一个新切片,并将原切片元素和新元素一起复制到新切片中,返回新切片:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3}
	fmt.Println("原始切片:", s)

	// 向切片末尾添加单个元素
	s = append(s, 4)
	fmt.Println("添加一个元素后:", s)

	// 同时向切片末尾添加多个元素
	s = append(s, 5, 6, 7)
	fmt.Println("添加多个元素后:", s)

	// 调转两个参数位置,变为向切片 s 开头添加单个元素
	s = append([]int{0}, s...)
	fmt.Println("开头添加一个元素后:", s)
}

拼接切片

拼接两或多个切片同样使用 append 函数,需要切片类型一致才能拼接:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3}
	fmt.Println("原始切片:", s)

	// 向切片中追加另一个切片元素,需要使用...来展开另一个切片
	a := []int{4, 5}
	s = append(s, a...)
	fmt.Println("追加另一个切片后:", s) // 输出:[1 2 3 4 5]

	// 拼接多个切片写法,append 只接受两个参数,所以使用多层嵌套
	result := append(append(s, a...), []int{7, 8, 9}...)
	fmt.Println("追加多个切片后:", result) // 输出:[1 2 3 4 5 4 5 7 8 9]
}

其中 append 参数中用到省略号 ,代表将切片展开,把里面元素分别插入到目标。在函数中传递变参列表时,也要采用此种格式。

删除元素

Go 语言中没有提供删除切片元素方法,可以通过拼接切片来达到目的:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3.0, 4, 5}
	// 指定要删除的元素索引,元素值为 3.0
	i := 2

	// 检查索引是否有效
	if i < 0 || i >= len(s) {
		fmt.Println("索引超出切片范围")
	} else {
		// 通过拼接切片,排除指定索引元素
		s = append(s[:i], s[i+1:]...)
		fmt.Printf("删除指定索引元素后:%v\n", s)
	}

	// 删除开头 1 个元素
	s = s[1:]
	fmt.Printf("删除首部元素后:%v\n", s)

	// 删除结尾 2 个元素
	s = s[:len(s)-2]
	fmt.Printf("删除尾部元素后:%v\n", s)
}

清空切片

清空切片有两种方式,一种是将切片长度设为 0 来快速清空,但保留底层数组;另一种是将切片设置为 nil,以释放其底层数组:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 4, 5}
	// 清空切片,保留底层数组,长度为 0,容量依然为 5
	s = s[:0]
	fmt.Println("切片为空后:", s, "长度:", len(s), "容量:", cap(s))

	// 重新填充切片
	s = append(s, 1, 2, 3, 4, 5)
	// 完全清空切片,释放内存,长度和容量都为 0
	s = nil
	fmt.Println("切片为 nil 后:", s, "长度:", len(s), "容量:", cap(s))
}

复制切片

当多个切片共享同一个底层数组时,修改任何一个切片元素都会影响到底层数组,进而可能影响到其他切片:

package main

import "fmt"

func main() {
	a := [5]int{1, 2, 3, 4, 5}
	// 切片引用数组前三个元素,切片容量等于原数组长度 5
	sliceA := a[:3]
	// 切片引用同一数组后三个元素,从第三个元素开始到结束
	sliceB := a[2:]
	// 打印当前状态
	fmt.Println("初始时 sliceA:", sliceA) // 输出: [1 2 3]
	fmt.Println("初始时 sliceB:", sliceB) // 输出: [3 4 5]

	// 向 sliceA 添加元素,不会发生扩容
	sliceA = append(sliceA, 10)

	// 再次打印看看原数组和切片
	fmt.Println("再次查看原数组:", a)       // 原数组被改变,输出: [1 2 3 10 5]
	fmt.Println("再次查看 sliceA:", sliceA) // 输出: [1 2 3 10]
	fmt.Println("再次查看 sliceB:", sliceB) // 另一个切片也被改变,输出: [3 10 5]
	// 输出同样指针值
	fmt.Println("三个指针:", &a[3], &sliceA[3], &sliceB[1])
}

为确保修改切片操作不会影响其他切片,可以通过复制切片创建一个新切片副本。副本拥有独立底层数组,对其任何修改都是安全的。

另一种情况,当切片原始数组太大,切片实际长度很小时,只要切片还在使用,就会一直占着原始数组,无法释放内存。此时也需要创建切片副本,切断对原始数组的引用。

使用切片语法

在切片语法中,同时忽略起始和结束索引用于快速复制切片,但不会复制底层数组:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 4, 5}
	d := s[:]
	fmt.Println("通过切片语法复制:", d)

	// 切片作为变量有独立指针地址
	fmt.Printf("原切片地址:%p\n", &s)
	fmt.Printf("新切片地址:%p\n", &d)

	// 检查切片元素地址,发现还是同一个
	fmt.Printf("原切片引用地址:%p\n", &s[0])
	fmt.Printf("新切片引用地址:%p\n", &d[0])
}

使用切片语法复制切片,适合在多个函数或方法间共享数据。

使用 copy 函数

使用内置 copy 函数复制切片元素到另一个切片中,可以精确控制复制的元素数量和目标位置,复制成功数量取决于来源和目标中较小的那个切片长度值:

package main

import "fmt"

func main() {
	// 初始化源整数切片
	source := []int{1, 2, 3, 4, 5}

	// 目标切片 targetPartial 长度为 1,容量为 5
	targetPartial := make([]int, 1, 5)
	// 只能复制最多 1 个元素
	copy(targetPartial, source)
	fmt.Println("targetPartial:", targetPartial) // 输出:[1]

	// 目标切片 targetFromFourth,长度为 5,容量为 50
	targetFromFourth := make([]int, 5, 50)
	// 从 source 第四个元素开始复制,只有 2 个元素,将复制成功结果赋值给 num
	num := copy(targetFromFourth, source[3:])
	// 输出复制结果,未被覆盖的元素值保持为 0
	fmt.Println("targetFromFourth:", targetFromFourth, num) // 输出:[4 5 0 0 0] 2

	// 完整复制
	target := make([]int, 5)
	copy(target, source)
	fmt.Println("target:", target)                                       // 输出:[1 2 3 4 5]
	fmt.Printf("target addr:%p\nsource addr:%p", &target[0], &source[0]) // 输出不同地址
}

通常 copy 函数要求目标和来源切片类型相同。但有一个例外,目标为字节切片时,可以接受来源为字符串:

package main

import "fmt"

func main() {
	s := "hello world"
	t := make([]byte, len(s))

	// 使用 copy 函数可以从字符串 s 复制数据到字节切片 t
	n := copy(t, s)

	fmt.Println(string(t), n)
}

使用 append 函数

append 用来复制切片时,可以动态地增加目标切片容量,很常用:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 4, 5}
	
	// 全量复制
	d := append([]int(nil), s...)
	fmt.Println(d)

	// 部分复制
	t := append([]int{}, s[:3]...)
	fmt.Println(t)

	// 检查地址,均不一样
	fmt.Printf("%p, %p, %p", &s[0], &d[0], &t[0])
}

传递切片

切片是引用类型,将切片传给函数,等于在函数内部创建一个切片别名:

package main

import "fmt"

// modify 函数,接受一个切片并修改,返回修改后的切片
func modify(s []int) []int {
	// 修改原切片第一个元素
	s[0] = 999
	// 向切片追加元素,会扩容生成新切片
	s = append(s, 100)
	fmt.Println("函数内修改切片后:", s) // 输出:[999 2 3 100]
	return s
}

func main() {
	o := []int{1, 2, 3}
	fmt.Println("原始切片:", o) // 输出:[1 2 3]

	// 调用函数,传递切片,并接收返回切片
	m := modify(o)

	fmt.Println("调用函数后,原始切片:", o) // 输出:[999 2 3]
	fmt.Println("函数返回切片:", m) // 输出:[999 2 3 100]
	fmt.Printf("对比地址:%p %p", &o[0], &m[0]) // 输出不一样地址
}

上面函数是个反例,既修改了原切片,又返回新切片。如果传入原切片容量够大,那么还是返回原切片,函数行为变得难以琢磨。

只要条件允许,设计函数应尽量避免副作用,也就是要避免直接修改原数据。让任何修改都在数据副本中进行,然后返回这个副本,这样的函数不依赖也不影响外部状态。以性能换安全,尝试修改后如下:

package main

import "fmt"

// modify 函数接受一个切片,返回修改后的新切片,不影响原切片
func modify(s []int) []int {
	// 创建切片副本
	t := append([]int{}, s...)

	t[0] = 999
	t = append(t, 100)
	return t
}

func main() {
	o := make([]int, 1, 8)
	o = append(o, []int{1, 2, 3}...)

	m := modify(o)

	fmt.Println("调用函数后,原始切片:", o)          // 输出:[0 1 2 3]
	fmt.Println("函数返回切片:", m)              // 输出:[999 1 2 3 100]
	fmt.Printf("对比地址:%p %p", &o[0], &m[0]) // 一定输出不同地址
}

多维切片

多维切片声明与使用基本和多维数组一致。下面定义一个二维切片,并新增修改值:

package main

import "fmt"

func main() {
	// 定义一个二维切片,长度为 3
	s := make([][]int, 3)

	// 初始化每一行,每行长度可以不同
	for i := range s {
		s[i] = make([]int, i+2) // 创建不同长度切片,第 i 行有 i+2 个元素
	}

	// 使用循环填充二维切片元素值
	for i := 0; i < len(s); i++ {
		for j := 0; j < len(s[i]); j++ {
			s[i][j] = i + j // 填充每个元素为行索引加列索引的和
		}
	}

	// 打印二维切片填充值
	fmt.Println("初始状态:")
	for _, row := range s {
		fmt.Println(row)
	}

	// 修改二维切片中一个值
	s[1][1] = 10 // 修改第二行第二个元素

	// 再次打印二维切片
	fmt.Println("修改后:")
	for _, row := range s {
		fmt.Println(row)
	}
}