Go 语言 slice(切片) 的使用

序言

在许多开发语言中,动态数组是必不可少的一个组成部分。在实际的开发中很少会使用到数组,因为对于数组的大小大多数情况下我们是不能事先就确定好的,所以他不够灵活。动态数组通过提供自动扩容的机制,极大地提升了开发效率。这篇文章将介绍 Go 语言中的动态数组 --- slice(切片)


1. 数据结构

切片的组成如下, 每一个字段的含义如下:

  • Data:指向存储元素数组的指针;
  • Len:该数组中元素的个数
  • Cap:该数组的容量大小
go 复制代码
type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

如果你之前了解过 C++ 中的 vector 你会发现其实他们的思路是一样的。一个实际存储元素的切片如下:


2. 切片的初始化

a. 声明但不初始化

Go 语言中,如果你声明一个切片但不初始化它,它的默认值是 nil。这意味着该切片没有指向任何底层数组,长度和容量都为 0:

go 复制代码
func main() {
	var slice []int

	sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&slice))
	dataPtr := unsafe.Pointer(sliceHeader.Data)
	fmt.Printf("data = %v, len = %d, cap = %d\n", dataPtr, len(slice), cap(slice))
}

这里程序的输出是:

data = , len = 0, cap = 0

b. 带初始值初始化

比起第一种方式,这个会在声明的时候带上字面值来初始化一个切片:

go 复制代码
slice := []int{1, 2, 3} 
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))

此时,该切片的 lencap 会和元素数量保持一致,程序输出:

data = [1 2 3], len = 3, cap = 3

c. 使用 make 初始化

使用 make 来初始化一个切片也有两者方式,首先是第一种:

go 复制代码
slice := make([]int, 5)
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))

这代表创建一个切片,并且切片的 lencap 都是 5,切片的元素的值采用该类型的默认值:

data = [0 0 0 0 0], len = 5, cap = 5

第二种是将 lencap 分别赋值:

go 复制代码
slice := make([]int, 2, 4)
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))

这代表创建一个切片,并且切片的len 是 2,cap 是 4:

data = [0 0], len = 2, cap = 4

这也是最常用的方式,使用 make 来预先分配内存大小可以避免后续添加元素时频繁进行扩容操作!

d. 下标索引初始化

Go 支持指定一个索引范围来初始化一个切片,这是 C++vector 所不具备的能力,举个例子:

go 复制代码
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4]
fmt.Println(slice)   // [2 3 4]

这里有一个长度为 5 的数组,现在使用索引范围 [1, 4) 「左闭右开」 来初始化一个切片,甚至还可以这样表达:

go 复制代码
slice = arr[:4]   // 等价于 [0:4]
slice = arr[1:]   // 等价于 [1:len(arr) - 1]

现在有一个问题,使用索引初始化的切片和原数组是什么关系呢?换句话说这里是否涉及到了深拷贝呢?上代码:

arr := [5]int{1, 2, 3, 4, 5}

slice := arr[1:4] // [2, 3, 4]

slice[0] = 0 // 修改值

fmt.Printf("arr=%v\n", arr)

fmt.Printf("slice=%v\n", slice)

输出结果是:

arr=[1 0 3 4 5]

slice=[0 3 4]

上文中我们了解到了一个 slice 的结构是怎么样的,结合输出的结果,不难推断出 data 指针指向了该数组的第二个位置,如下:

这里 cap 的大小为什么是 4 怎么得到的呢 --- cap = cap(arr) - 1


3. 切片的追加和扩容

a. 元素追加

我们可以通过 append 操作来在切片最后追加元素,追加方式也有多种,举个栗子:

go 复制代码
slice := []int{1, 2}
slice = append(slice, 3)       // 追加一个元素
slice = append(slice, 4, 5, 6) // 追加多个元素
slice = append(slice, []int{7, 8}...) // 追加一个切片,...表示解包,不能省略

对于追加的操作,大家是否存在疑惑的点呢?我刚开始就不理解为什么在追加操作后对 slice 进行赋值的操作。这是因为 append 函数有一个重要的特性需要特别注意:它可能会返回一个新的底层数组(取决于是否进行扩容操作)。如果没有进行赋值操作,那么 slice 还是指向原来的数组,举个栗子:

b. 切片扩容

当切片的 len 等于 cap 时,在下一次 append 操作前就会进行一次扩容操作,扩容的逻辑如下:

go 复制代码
func growslice(et *_type, old slice, cap int) slice {
	...
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}
	...
}

扩容的策略总结如下:

可以看到 Go 语言增长容量的策略还是比较缓和的。


4. 切片易踩的坑

a. 参数传递类型傻傻分不清

首先,我们先聊聊 C++ 当中的值传递和引用传递,就比如:

cpp 复制代码
int main() {
	vector<int> vec = { 1, 2, 3, 4, 5 }
	funcJustForRead(vec)
	return 0;
}

void funcJustForRead(vector<int> &vec) {
	...
}

对于某些只读的场景,我们一般会传引用,这样就大大减少了拷贝带来的开销。在 Go 语言中好像并没有 引用 的概念?但是仔细思考一下,Go 真的需要吗:

go 复制代码
func main() {
	slice := []int{ 1, 2, 3, 4, 5 }
	funcJustForRead(slice)
}

func funcJustForRead(slice []int) {
	...
}

形参是实参的拷贝,slice 中指向元素的是 data 指针,即使形参和实参的 data 不一样,但是两者是指向的同一个数组,所以不需要引用。

现在,这里有一个函数会对切片进行追加操作,我依然是值传递是否还是可行呢?举个栗子(假设这里不涉及扩容操作):

go 复制代码
func main() {
	slice := []int{1, 2}
	fmt.Println(slice)
	funcForAppend(slice)
	fmt.Println(slice)
}

func funcForAppend(slice []int) {
	slice = append(slice, 3)
}

输出结果是:

1 2

1 2

并没有预想的新增一个值,为什么?上面我们介绍了,append 会返回一个新的切片,我们在 main 中使用的还是原来的切片。怎么解决呢?传递指针:

go 复制代码
func main() {
	slice := make([]int, 0, 2)
	fmt.Println(slice)
	funcForAppend(&slice)
	fmt.Println(slice)
}

func funcForAppend(slice *[]int) {
	*slice = append(*slice, 3)
}

b. len 和 cap 傻傻分不清

之前我们谈到过,可以预先分配好空间,可以避免后续的频繁扩容操作,但是是否会有以下的误解呢:

go 复制代码
func main() {
	slice := make([]int, 5)
	slice = append(slice, 1)
	slice = append(slice, 1)
	slice = append(slice, 1)
	slice = append(slice, 1)
	fmt.Println(slice) // [0 0 0 0 0 1 1 1 1]
}

这里代表预先分配好 5 个空间,并且每一个空间使用该类型的默认值填充,当我们新加入元素时,是在已有的基础上往后添加而不是从前开始覆盖。正确的姿势应该是这样子的:

go 复制代码
func main() {
	slice := make([]int, 0, 5)
	slice = append(slice, 1)
	slice = append(slice, 1)
	slice = append(slice, 1)
	slice = append(slice, 1)
	fmt.Println(slice) // [1 1 1 1]
}

5. 总结

不仅只是会使用,并且知其所以然。我自认为这是非常重要的,这不仅能够很大程度上减小我们在开发中犯错的概念,还能够有效提升代码的质量。所以通过这篇 silce 带我们走入 Go 的世界吧。

相关推荐
明月看潮生1 分钟前
青少年编程与数学 02-019 Rust 编程基础 08课题、字面量、运算符和表达式
开发语言·青少年编程·rust·编程与数学
huohuopro6 分钟前
thinkphp模板文件缺失没有报错/thinkphp无法正常访问控制器
后端·thinkphp
天天打码36 分钟前
Rspack:字节跳动自研 Web 构建工具-基于 Rust打造高性能前端工具链
开发语言·前端·javascript·rust·开源
Petrichorzncu36 分钟前
Lua再学习
开发语言·学习·lua
AA-代码批发V哥37 分钟前
正则表达式: 从基础到进阶的语法指南
java·开发语言·javascript·python·正则表达式
炯哈哈1 小时前
【上位机——MFC】序列化机制
开发语言·c++·mfc·上位机
蓝莓味柯基1 小时前
Python3正则表达式:字符串魔法师的指南[特殊字符]‍♂️
开发语言·python·正则表达式
隐世11 小时前
C++多态讲解
开发语言·c++
码上飞扬2 小时前
Java大师成长计划之第20天:Spring Framework基础
java·开发语言
刚入门的大一新生2 小时前
C++初阶-string类的模拟实现1
开发语言·c++