开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2天,点击查看活动详情
“既生瑜何生亮” ,Go的数组和切片,相爱相杀、相辅相成。
前言
PHP的数组,功能非常强大,除了基础的增删改查操作外,还支持截取合并翻转一系列的操作。
刚从PHP转Go的朋友,一定会有疑问,Go既然已经有了数组,为啥还要有切片这个类型呢?
先说结论
Go的数组长度是固定,在声明数组时长度就已经确定,并且长度也是数组类型的一部分。这样就会导致不支持添加元素等一系列操作问题,很不灵活。所以,Go在语言设计上,基于数组做了一层封装,增强数组的操作能力,非常灵活,并且还自动扩容,这就是切片。
接下来,再一块具体学习一下Go的数组与切片
数组
定义
数组是相同数据类型的元素集合,分配的内存是连续的
特点
定长,长度是数组类型的一部分,不能修改长度,只能通过下标访问或修改元素
声明和初始化
三种方式:
func main() {
//方式1:指定长度
var nums [3]int //声明
fmt.Println(nums) //[0 0 0] 默认初始化为零值
nums = [3]int{1, 2, 3} //初始化(手动赋值)
fmt.Println(nums) //[1 2 3]
var nums2 = [3]int{1, 2, 3} //声明+初始化
fmt.Println(nums2) //[1 2 3]
//方式2:使用...,go做长度推断
var names = [...]int{1, 2, 3}
fmt.Println(names, len(names), fmt.Sprintf("%T", names)) //[1 2 3] 3 [3]int
//方式3:指定下标
a := [...]int{1: 1, 3: 4}
fmt.Println(a, len(a), fmt.Sprintf("%T", a)) //[0 1 0 4] 4 [4]int
}
多维数组
两种定义方法:
//方式1:指定长度
arr := [3][2]int{
{1, 2},
{3, 4},
}
fmt.Println(arr) //[[1 2] [3 4] [0 0]]
for k, v := range arr {
for k2, v2 := range v {
fmt.Println(k, k2, v2)
}
}
//输出
//0 0 1
//0 1 2
//1 0 3
//1 1 4
//2 0 0
//2 1 0
//方式2:使用...,go做长度推断,注意只有第一个[]才能使用...
arr := [...][2]int{
{1, 2},
{3, 4},
}
fmt.Println(arr) //[[1 2] [3 4]]
for k, v := range arr {
for k2, v2 := range v {
fmt.Println(k, k2, v2)
}
}
//输出
//0 0 1
//0 1 2
//1 0 3
//1 1 4
值类型,产生副本
数组是值类型,赋值或传参时,会产生副本,修改副本不会影响原数组的值
a1 := [3]int{1, 2, 3}
b1 := a1
b1[0] = 100
fmt.Println(a1) //[1 2 3]
fmt.Println(b1) //[100 2 3]
可做对比
数组支持 “==“、”!=” 操作符,因为内存总是被初始化过的
注意:数组类型要一致才能对比,因为长度也是数组类型的一部分,长度不同不能对比,会编译失败
aa := [3]int{1, 2, 3}
bb := [3]int{1, 2, 1}
fmt.Println(aa == bb) //false
cc := [4]int{1, 2, 3, 4}
fmt.Println(aa == cc) //编译失败,invalid operation: aa == cc (mismatched types [3]int and [4]int)
切片
定义
切片是相同数据类型的元素的可变长度的序列,它是基于数组的一层封装,非常灵活、支持自动扩容(跟PHP的array索引数组更像)
内部结构包含地址、长度和容量
//GOROOT/src/runtime/slice.go 13行
type slice struct {
array unsafe.Pointer
len int
cap int
}
注意:切片是引用类型,必须初始化后才能使用
声明和初始化
两种方式:
//方式1:普通写法
var a []int //声明
fmt.Println(a, a == nil) //[] true
a = []int{1, 2, 3} //初始化
fmt.Println(a, a == nil) //[1 2 3] false (所以说只要切片初始化后就等于nil)
var b = []int{} //声明+初始化
fmt.Println(b, b == nil) //[] false
//方式2:使用make构造切片
c := make([]int, 0, 5) //声明+初始化,构造出一个len=0 cap=5的切片(len必填 cap非必填)
fmt.Println(c, c == nil) //[] false
切片表达式 [:]
作用:指定两个下标,截取切片中的第几位到第几位的元素。要领:前闭后开
切一次、切两次,各有学问,好好看下面示例(带理解说明):
a := []int{1, 2, 3, 4, 5, 6, 7, 8}
fmt.Println(fmt.Sprintf("%p", a)) //0xc000022080
//切一次:基于a切
s1 := a[1:5]
fmt.Println(fmt.Sprintf("s1:%v len:%v cap:%v", s1, len(s1), cap(s1))) //s1:[2 3 4 5] len:4 cap:7
fmt.Println(fmt.Sprintf("%p", s1)) //0xc000022088
// 帮忙理解:从下标1开始截取5-1个元素,所以长度=4,但容量为len(a)-1=7,也就是a的总长度-开始下标
// 注意:a的左边界为0,右边界为len(a)=8。别超过,否则panic
//切两次:基于s1再切一次,也就是切片的切片(大有学问)
s2 := s1[1:5]
fmt.Println(fmt.Sprintf("s2:%v len:%v cap:%v", s2, len(s2), cap(s2))) //s2:[3 4 5 6] len:4 cap:6
fmt.Println(fmt.Sprintf("%p", s2)) //0xc000022090
// 帮忙理解:首先,s1:[2 3 4 5] len:4 cap:7。这时s1的左边界也是=0,但右边界得为cap(s1)=7
// 从s1的下标1开始截取5-1个元素,所以长度=4,但容量为cap(s1)-1=6,也就是s1的容量-开始下标
//异常case:切片操作,边界越界,就会panic
s3 := s1[1:len(a)] //panic: runtime error: slice bounds out of range [:8] with capacity 7
//s3 := s1[1:cap(s1)] //这样才是正确写法,会输出:s3:[3 4 5 6 7 8] len:6 cap:6
fmt.Println(fmt.Sprintf("s3:%v len:%v cap:%v", s3, len(s3), cap(s3)))
同时,为了方便起见,也支持省略切片表达式中的任何索引
a[2:] // 等同于 a[2:len(a)]
a[:3] // 等同于 a[0:3]
a[:] // 等同于 a[0:len(a)]
引用类型,修改原值
由于切片是引用类型,赋值或切片操作后,修改元素的值,都会相互影响生效,因为操作的都是同一块内存空间
q1 := []int{1, 2, 3}
q3 := q1
q3[1] = 100
fmt.Println(q1, q3) //[1 100 3] [1 100 3]
不可对比
切片只能和nil对比;切片和切片之间不能对比
q1 := []int{1, 2, 3}
q2 := []int{1, 2, 3}
fmt.Println(q1 == nil, q2 == nil) //false false
//fmt.Println(q1 == q2) //编译失败:invalid operation: q1 == q2 (slice can only be compared to nil)
判空
请始终使用len(s) == 0来判断,而不应该使用s == nil来判断
原因: 一个nil值的切片并没有底层数组,长度和容量都是0。但我们不能说一个长度和容量都是0的切片一定是nil,也有可能是已初始化过的空切片(有指向底层数组,不等于nil)
append添加元素
动态添加元素,可以是1个、多个、或者整个切片(记得后面加...)
var nums []int
//nums = []int{} //下面用append,这么没必要初始化
nums = append(nums, 1)
nums = append(nums, 2, 3, 4)
nums2 := []int{5, 6, 7}
nums = append(nums, nums2...)
fmt.Println(nums) //[1 2 3 4 5 6 7]
扩容策略
往切片里添加元素,在容量不足的情况下,会触发自动扩容策略。每次扩容,都会重新申请一块内存空间,用于存储,所以此时切片指针也会发生变化,重新指向新地址
func main() {
var nums []int
fmt.Println(fmt.Sprintf("nums:%v ptr:%p len:%d cap:%d", nums, nums, len(nums), cap(nums)))
for i := 0; i < 10; i++ {
nums = append(nums, i)
fmt.Println(fmt.Sprintf("nums:%v ptr:%p len:%d cap:%d", nums, nums, len(nums), cap(nums)))
}
}
输出:
nums:[] ptr:0x0 len:0 cap:0
nums:[0] ptr:0xc0000ae020 len:1 cap:1
nums:[0 1] ptr:0xc0000ae030 len:2 cap:2
nums:[0 1 2] ptr:0xc0000b8040 len:3 cap:4
nums:[0 1 2 3] ptr:0xc0000b8040 len:4 cap:4
nums:[0 1 2 3 4] ptr:0xc0000ba040 len:5 cap:8
nums:[0 1 2 3 4 5] ptr:0xc0000ba040 len:6 cap:8
nums:[0 1 2 3 4 5 6] ptr:0xc0000ba040 len:7 cap:8
nums:[0 1 2 3 4 5 6 7] ptr:0xc0000ba040 len:8 cap:8
nums:[0 1 2 3 4 5 6 7 8] ptr:0xc0000bc000 len:9 cap:16
nums:[0 1 2 3 4 5 6 7 8 9] ptr:0xc0000bc000 len:10 cap:16
规律:0 1 2 4 8 16 ...
扩容策略底层源码:
//GOROOT/src/runtime/slice.go 144行
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.cap < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}
简单描述:容量小于1024,要扩容则按2倍规律进行扩容;容量超过1024之后,则按1.25倍的规律进行扩容
所以说,能确定切片容量大小,make构造切片时最好指定cap值进行初始化,可避免自动扩容不断申请新内存空间而带来的性能开销
copy产生切片副本
有时候我们不希望,修改了一个切片元素值,导致指向相同底层数组的其他切片相互影响,这时我们就得使用copy函数,来产生副本,隔离开相互影响
src := []int{1, 2, 3}
dest := make([]int, len(src)) //注意:len必须指定
copy(dest, src)
dest[0] = 100
fmt.Println(src, dest, len(dest), cap(dest)) //[1 2 3] [100 2 3] 3 3
删除切片元素
go没有专门的方法做删除,可以使用切片特性(切片表达式)来完成
注意:删除后切片的长度会减小,但容量不变
func main() {
a := []int{1, 2, 3, 4, 5}
//要删除下标2的元素
a = append(a[:2], a[3:]...)
fmt.Println(a, len(a), cap(a)) //[1 2 4 5] 4 5
fmt.Println(fmt.Sprintf("%p", a)) //0xc000018150
//拓展一下:移除值为1的元素
b := removeElement(a, 1)
fmt.Println(a, len(a), cap(a)) //[2 4 5 5] 4 5
fmt.Println(b, len(b), cap(b)) //[2 4 5] 3 5 //有坑:看上一行结果,a也受影响了。好理解,引用类型...
fmt.Println(fmt.Sprintf("%p", b)) //0xc000018150
}
//使用双指针法,移除指定元素
func removeElement(s []int, ele int) []int {
fmt.Println(fmt.Sprintf("%p", s)) //0xc000018150
i := 0
for _, v := range s {
if v == ele {
continue
}
s[i] = v
i++
}
s = s[:i]
return s
}
数组和切片对比
- 长度:数组定长,长度是类型的一部分;切片变长,自动扩容,操作灵活;
- 类型:数组是值类型,值传递会产生副本,修改互不影响;切片是引用类型,值传递不会产生副本,修改会相互影响,操作同一块内存空间(想要产生副本得用copy函数);
- 初始化:数组声明就会自动初始化为零值;切片必须手动初始化后才能使用,否则panic;
- 做对比:数组可对比,但需要注意类型必须一致;切片只能和nil对比,两个切片不能对比,会编译失败;
总结
Go的数组和切片相辅相成,深入学习了数组与切片的用法和注意点之后,最初的问题(“Go既然有数组为啥还要有切片”)游刃而解。并且对两者进行了相互对比,进一步加深差异理解。
如果本文对你有帮助,欢迎点赞收藏加关注,如果本文有错误的地方,欢迎指出!