Go 语言 slice(切片) 的使用
序言
在许多开发语言中,动态数组是必不可少的一个组成部分。在实际的开发中很少会使用到数组,因为对于数组的大小大多数情况下我们是不能事先就确定好的,所以他不够灵活。动态数组通过提供自动扩容的机制,极大地提升了开发效率。这篇文章将介绍 Go 语言中的动态数组 — slice(切片)
。
1. 数据结构
切片的组成如下, 每一个字段的含义如下:
Data
:指向存储元素数组的指针;Len
:该数组中元素的个数Cap
:该数组的容量大小
type SliceHeader struct {Data uintptrLen intCap int
}
如果你之前了解过 C++ 中的 vector 你会发现其实他们的思路是一样的。一个实际存储元素的切片如下:
2. 切片的初始化
a. 声明但不初始化
在 Go 语言中,如果你声明一个切片但不初始化它,它的默认值是 nil
。这意味着该切片没有指向任何底层数组,长度和容量都为 0:
func main() {var slice []intsliceHeader := (*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. 带初始值初始化
比起第一种方式,这个会在声明的时候带上字面值来初始化一个切片:
slice := []int{1, 2, 3}
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
此时,该切片的 len
和 cap
会和元素数量保持一致,程序输出:
data = [1 2 3], len = 3, cap = 3
c. 使用 make 初始化
使用 make
来初始化一个切片也有两者方式,首先是第一种:
slice := make([]int, 5)
fmt.Printf("data = %v, len = %d, cap = %d\n", slice, len(slice), cap(slice))
这代表创建一个切片,并且切片的 len
和 cap
都是 5,切片的元素的值采用该类型的默认值:
data = [0 0 0 0 0], len = 5, cap = 5
第二种是将 len
和 cap
分别赋值:
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 所不具备的能力,举个例子:
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4]
fmt.Println(slice) // [2 3 4]
这里有一个长度为 5 的数组,现在使用索引范围 [1, 4) 「左闭右开」
来初始化一个切片,甚至还可以这样表达:
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
操作来在切片最后追加元素,追加方式也有多种,举个栗子:
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
操作前就会进行一次扩容操作,扩容的逻辑如下:
func growslice(et *_type, old slice, cap int) slice {...newcap := old.capdoublecap := newcap + newcapif 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++
当中的值传递和引用传递,就比如:
int main() {vector<int> vec = { 1, 2, 3, 4, 5 }funcJustForRead(vec)return 0;
}void funcJustForRead(vector<int> &vec) {...
}
对于某些只读的场景,我们一般会传引用,这样就大大减少了拷贝带来的开销。在 Go 语言中好像并没有 引用
的概念?但是仔细思考一下,Go 真的需要吗:
func main() {slice := []int{ 1, 2, 3, 4, 5 }funcJustForRead(slice)
}func funcJustForRead(slice []int) {...
}
形参是实参的拷贝,slice
中指向元素的是 data
指针,即使形参和实参的 data
不一样,但是两者是指向的同一个数组,所以不需要引用。
现在,这里有一个函数会对切片进行追加操作,我依然是值传递是否还是可行呢?举个栗子(假设这里不涉及扩容操作):
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
中使用的还是原来的切片。怎么解决呢?传递指针:
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 傻傻分不清
之前我们谈到过,可以预先分配好空间,可以避免后续的频繁扩容操作,但是是否会有以下的误解呢:
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 个空间,并且每一个空间使用该类型的默认值填充,当我们新加入元素时,是在已有的基础上往后添加而不是从前开始覆盖。正确的姿势应该是这样子的:
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 的世界吧。