本小节,我们来讲一下切片
一.切片的回顾
切片(slice)是一个拥有相同数据类型的可变长度的序列,他是基于数组类型的一层封装,非常灵活,支持动态扩容。
切片是一个引用类型,它的内部包括地址,长度和容量。
用于快速操作一块数据集合。
如下图所示:
1.1 切片的定义
var a []T
T 就代表类型。
package mainimport "fmt"func main() {//声明切片var a []stringvar b = []int{}var c = []bool{false, true}//var d = []bool{false, true}fmt.Println(a)fmt.Println(b)fmt.Println(c)fmt.Println(a == nil)fmt.Println(b == nil)fmt.Println(c == nil)// fmt.Println(c == d) 切片是引用,不支持直接比较,只能和nil比较
}
- 这里可以会看到这个 var a []string和var b = []int{},为什么前者是nil而后者不是?
原因是后者已经初始化了,已经给他分配了一个arr的地址,而前者并未初始化,它里面的指针是指向nil
- 其次就是切片不可以进行比较
1.2 make函数构造切片
使用make函数可以构造我们想要的len和cap
make([]T,size,cap)
make函数构造出来的slice,是初始化后的,除了make还是,还可以通过[]int{}的形式,生成的也是一个初始化的slice。
1.3 切片的操作
- 切片支持对切片在进行一个切片
- 切片的赋值是浅拷贝(后续会在底层简介)
- 切片的遍历和数组一样
- append()函数为切片添加元素
func main() {s1 := []string{"shy", "haha", "xixi"}//s1[3]="yiyi" 这样是不可以添加的s1 = append(s1, "succeed")//必须用原来的切片去接收返回值。fmt.Println(s1)}结果
[shy haha xixi succeed]
- 切片的深拷贝:使用copy函数
package mainimport "fmt"func main() {s1 := []int{1, 2, 3}//浅拷贝s2 := s1s3 := make([]int, 5, 10)//var s3 []int // 如果这样复制的话,打印的结果是[]// 因为这个s3并没有被分配对应的内容空间// 深拷贝copy(s3, s1)fmt.Println(s1, s2, s3)
}
1.4 注意事项
那切片可以删除中间的元素嘛?或者说可以向中间添加元素嘛?
先说添加元素
- append()函数之前说过,就是在末尾添加元素。
- 那如何向中间添加元素呢
a := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
a = append(append(a[:1], 1), a[1:]...)
这样不就可以了吗,我取我要添加的位置,然后加上,再把他后面的填进去就ok了。
如果这样,那你就大错特错了,其实这里你已经把底层数组改变了,看下面的具体例子
在你的第一步append的时候就已经把这个底层数组改变了,索引后续的添加是在你改变的这个数组的前提之下进行的一个添加,所以就导致了上述的错误
那有没有方法向中间加入元素呢?
可以,但是需要另开一个新的空间来实现,就像这样
通过一个新开的切片,从而在不改变底层的情况下实现。
package mainimport "fmt"func main() {s := []int{1, 2, 3, 4, 5}index := 2value := 99// 手动调整切片s = append(s, 0) // 扩展切片copy(s[index+1:], s[index:]) // 将 index 位置后的元素整体右移s[index] = value // 插入新值fmt.Println(s) // 输出: [1 2 99 3 4 5]
}
不管是哪种方法,都需要另外开辟空间
删除中间元素
删除中间元素就不会出现上述的问题,可以通过自身的性质来实现
二.底层原理
对于切片的底层原理,我们需要学习和熟记什么内容呢?
接下来做一个简单的总结:
- 首先是了解切片的结构,和数组有什么区别?
- 熟悉切片操作(增删改查,以及特殊的如何添加多个,中间添加,比较大小等等)
- 切片的扩容机制
- 切片的传参需要注意什么?
- nil 和 空 切片的区别是什么?
2.1 切片的底层结构
切片的底层结构
- Pointer:指向实际存储数据的数组的指针。
- Length:切片的当前长度,即切片中元素的数量。
- Capacity:切片的容量,即从切片的起始位置到底层数组的末尾位置可以存储的元素数量。
type slice struct {array unsafe.Pointerlen intcap int
}
不难看出,这是一块连续的内存空间。
2.2 切片和数组的区别
- 数组(Array)
- 固定长度: 数组是一个固定长度的序列,定义时需要指定其长度。例如,var arr [5]int 声明了一个包含 5 个整数的数组。
- 值类型: 数组是值类型,当传递数组给函数或赋值给另一个数组时,会进行一次完整的数组拷贝,包括所有元素。值传递。
- 大小不可变: 数组的长度在声明后无法更改。
- 内存分配: 数组通常在栈上分配内存。
- 基础数据类型: 数组属于基础数据类型,是一块连续的内存区域。
- 切片(Slice)
- 动态长度: 切片是对数组的抽象,它可以动态增长和缩减,长度不固定。切片的长度是可以改变的。
- 引用类型: 切片是引用类型,它包含三个信息:指向底层数组的指针、切片的长度和切片的容量。引用传递。
- 灵活性: 切片可以视情况指向数组的一部分或整个数组。
- 传递效率: 切片作为引用类型传递时,不会复制底层数组,多个切片可以共享底层数组。
- 动态增长: 当向切片追加元素时,如果超出了切片的容量,Go 会重新分配更大的底层数组,容量变为原来的2倍。
2.3 切片的扩容机制
在之前的讲述过程中,并没有说到这个扩容机制,不过大家都肯定知道这个扩容机制,这一小节,就来具体了解一下扩容机制。
- 首先扩容机制并不是单纯的按照倍数扩容,存在一个内存对齐机制。
- 并且扩容机制是会开辟一个新的空间,回收旧的空间
先看一下这个扩容规则吧:
扩容有一个临界值,需要和这个值比较,从而选择不同的扩容。
接下来看下这个扩容机制
1.18之后,Go1.18不再以1024为临界点,而是设定了一个值为256的threshold,以256为临界点;超过256,不再是每次扩容1/4,而是每次增加(旧容量+3*256)/4;其中cap为预期容量,也就是说添加了数据之后需要的容量
- 当新切片需要的容量cap大于两倍扩容的容量,则直接按照新切片需要的容量扩容;
- 当原 slice 容量 < threshold 的时候,新 slice 容量变成原来的 2 倍;
- 当原 slice 容量 > threshold,进入一个循环,每次容量增加(旧容量+3*threshold)/4。
为什么这样设置呢?
首先是双倍容量扩容的最大阈值从1024降为了256,只要超过了256,就开始进行缓慢的增长。其次是增长比例的调整,之前超过了阈值之后,基本为恒定的1.25倍增长,而现在超过了阈值之后,增长比例是会动态调整的,随着切片容量的变大,增长比例逐渐向着1.25进行靠拢
实际不然,还涉及内存对齐的机制
之所以要加入,是为了让扩容时不管内存大小,都比较平和,避免出现扩的太大,或者太少,少了会影响性能,因为容易导致多次扩容
在他的底层还设置的有67个跨度,也就是67个阈值,如果说我们规定的切片溢出进行扩容时,它首先按照他的规则进行扩容,但是他不是完全按照,比如他的阈值是48,你扩容之后他是40字节,他会把你默认变成48,从而做到一个内存对齐的操作。
举个例子吧
s3 := []int{1, 2}
s3 = append(s3, 3, 4, 5)
fmt.Println(cap(s3))
根据前文知,所需容量为5,又因所需容量大于2倍当前容量,故新容量也为5。
又因为int类型大小为8(等于64位平台上的指针大小),所以实际需要的内存大小为5 * 8 = 40字节。而67个跨度中最接近40字节的跨度为48字节,所以实际分配的内存容量为48字节。
最终计算真实的容量为48 / 8 = 6,和实际运行输出一致。这样就应该能很好的理解了
2.4 空切片和nil切片
在之前将数组的时候曾说过nil和空,这一届具体来说一下,所谓的空和nil到底是什么
type slice struct {array unsafe.Pointerlen intcap int
}
所谓的nil,其实就是指array这个指针指向是nil,也就是没有分配内存空间,如下图所示
也就是使用下面这种声明
var s []int // s 现在是 nil
对 nil 切片进行 len() 和 cap() 操作,结果都将返回 0。
你也可以将 nil 切片添加到任何切片的末尾,这等同于什么都没做。但是,与空切片相比,nil 切片在做 append() 操作时有一个重要的区别,那就是当 nil 切片通过 append() 函数追加元素后,它就会产生一个长度和容量都为1的新切片。
那什么又是空切片呢?
所谓的空其实是指这个arr数组是空的,但是这个指针有指向
s1 := []int{} // s1 是一个空切片,通过字面量创建
s2 := make([]int, 0) // s2 也是一个空切片,通过 make 创建
特别的是,对于一个空切片,即使紧接着进行 append() 操作,其地址也不会发生改变,因为切片在创建时就已经预分配了一定的内存空间。
总的来说,可以在需要表示“不存在”和“空集合”两种不同的状态时来区分 nil 切片和空切片。
除此之外,我们判断切片是否为空的时候,建议还是使用len比较好,不要使用nil去判断。
2.5 切片的传参
先说说为什么要收切片的传参,再有了对上述知识的接触,切片和数组都是属于浅拷贝,如果通过函数传参之后,修改这个传来的切片,会影响底层的数组嘛?
接下来我们就来探讨一下
package mainimport "fmt"func test(b []int) (c []int) {c = bc[2] = 999return
}func main() {a := []int{1, 2, 3, 4, 5}d := test(a)fmt.Println(a)fmt.Println(d)
}
//结果
//[1,2,999,4,5]
//[1,2,999,4,5]
运行完之后打印,会发现显然是会改变这个切片,即使你在函数里面赋值给其他变量,原因也很简单,就是因为他们公用了一个底层数组所导致的问题
如果你在传参修改切片,导致了扩容,又是一个怎么样的情况呢?
如果是这样的话,就会导致打印的数组内容不一样,原因是因为扩容会重新申请一个新的内存空间来存储它的扩容,而不是在原地址进行一个扩容
package mainimport "fmt"func test(b []int) []int {b = append(b, 999)return b
}func main() {a := []int{1, 2, 3, 4, 5}d := test(a)fmt.Println(a)fmt.Println(d)
}//[1 2 3 4 5]
//[1 2 3 4 5 999]
2.6 切片的深拷贝
切片的深拷贝是通过使用copy函数实现的,深拷贝和浅拷贝这里就不在展开讲述了。
通过copy生成的切片是一个全新的切片。
具体的内容在上述讲过,这里结合函数传参举一个例子:
package mainimport "fmt"func returnNewSliceUtils(s []int) []int {newSlice := make([]int, len(s))copy(newSlice, s)newSlice[0] = 0return newSlice
}
func returnSlice() {mySlice := []int{1, 2, 3}fmt.Println("调用函数之前的切片", mySlice)newSlice := returnNewSliceUtils(mySlice)fmt.Println("调用函数后的原切片", mySlice)fmt.Println("调用函数后的新切片:", newSlice)
}
func main() {returnSlice()
}结果:
调用函数之前的切片 [1 2 3]
调用函数后的原切片 [1 2 3]
调用函数后的新切片: [0 2 3]