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
,不能大于源对象长度。
新切片取源对象从 low
到 high
索引为止的元素。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
;
可以使用内置函数 len
和 cap
来计算切片长度和容量:
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)
}
}