漫谈 Slice 切片
❝
可以遗憾,但不要后悔。
我们留在这里,从来不是身不由己。
——— 而是选择在这里经历生活
❞
目录
本文主要围绕
Golang的Slice所展开,介绍了其基本的概念、用法、操作,底层的存储原理及常用的代码实践。
简介
生活中的切片
什么 Go 切片
-
在
Go语言中,鉴于对数组Array的局限性,从而引出了切片Slice的概念,切片是对现有数组的一个连续片段的引用,比数组更方便灵活,还可以追加数据(自动扩容),切片是一个拥有相同类型元素的可变长度的序列。 -
它可以理解为可变长度的数组,也常被称为 “动态数组”,应该是首选的高级数据结构。
-
Go中切片的内部结构包含地址(开始位置)、大小(实际元素的个数)和容量(超过这个阈值就要扩容了)三部分。
Slice 与 Map 对比
相同点
💡 当使用 make 初始化对象时,虽然 cap 是可选参数,但出于对性能的考量,对于大的、极速扩张的 Slice 或 Map 而言,即便只能大概预估出容量,也建议显性的进行标注。
func main() {
// slice 需要指明类型、长度和cap
s := make([]string, 1, 10)
// map 只需要指明类型和cap即可
m := make(map[string]string, 10)
}
不同点
💡 总之,Slice 可以不初始化就直接通过 append 扩容添加元素来使用,而 Map 必须初始化后才能够使用,否则会报错 panic: assignment to entry in nil map!
| 数据类型 | 细节说明 |
|---|---|
Slice | Slice 不可以直接赋值超出它容量本身的数据,否则会报边界错误!Slice 即便是超边界,也可使用 append 扩容的方式处理。Slice 仅声明类型,未初始化变量,零值为 nil,此时也可以进行扩容。 |
Map | Map 不初始化,在 nil 上直接加值,是不被允许的! |
Slice 基础使用
切片的定义
语法
// 声明一个切片
var identifier []type
// 使用make函数创建切片
var slice []type = make([]type, len)
// 可以指定容量,capacity为可选参数
make([]type, length, capacity)
nil 切片
💡 切片的零值为 nil,无论是 nil 切片或空切片,它们的长度、容量均为 0。如果我们要创建长度容量为 0 的切片,更推荐使用 nil 切片。
func main() {
// nil 切片:指仅声明切片类型,而未做初始化的切片,无需分配内存空间(推荐)
var s []int
fmt.Printf("data: %v\n", s) // data: []
fmt.Printf("goData: %#v\n", s) // goData: []int(nil)
fmt.Printf("type: %T\n", s) // type: []int
fmt.Printf("len: %d\n", len(s)) // len: 0
fmt.Printf("cap: %d\n", cap(s)) // cap: 0
fmt.Printf("addr: %p\n", s) // addr: 0x0
if s == nil {
fmt.Println("A nil Slice") // A nil Slice
}
// 空切片:使用 make 创建的长度、容量均为 0 的切片,是需要分配内存空间的。
s2 := make([]int, 0)
fmt.Printf("len: %d\n", len(s2)) // len: 0
fmt.Printf("cap: %d\n", cap(s2)) // cap: 0
fmt.Printf("addr: %p\n", s2) // addr: 0x1006956c8
}
切片的初始化
使用 type{} 方式
💡 直接使用相应的切片类型,最常用的如:[]int、[]string、[]struct 等。
func main() {
var s = []byte{'a', 'b', 'c'}
// [97 98 99], len: 3, cap: 3
fmt.Printf("data: %v, len: %d, cap: %d\n", s, len(s), cap(s))
}
使用 make 函数
💡 make 是内置函数,主要用于初始化 Slice、Map、Channel 数据类型。
func main() {
// 声明切片类型、初始化指定长度、容量
ninja := make([]string, 3, 10)
// 赋值
ninja[0] = "大蛇丸"
ninja[1] = "自来也"
ninja[2] = "纲手"
// data: []string{"大蛇丸", "自来也", "纲手"}, len: 3, cap: 10
fmt.Printf("data: %#v, len: %d, cap: %d\n", ninja, len(ninja), cap(ninja))
}
从 Array 直接生成
💡 对于连续的元素,数组可直接切成目标切片;如果是不连续的元素,可先切再进行拼接。
func main() {
// 定义数组
var arr = [...]rune{'火', '影', '忍', '者', '🌀'}
// 取部分数组元素
s := arr[:2]
// data: [28779 24433], len: 2, cap: 5
fmt.Printf("data: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // 注意cap的大小哦
}
切片的元素访问
💡 可参考 Python 切片用法,语法格式为 slice[开始位置:结束位置],取值范围为 [) 左闭右开区间,Go 中切片算是 Python 的子集。
// Go 支持
fmt.Println(data[0]) // 取第0个元素值
fmt.Println(data[1:4]) // 取第1-3个元素的切片
fmt.Println(data[2:]) // 取第2-结尾元素的切片
fmt.Println(data[:6]) // 取第0-4个元素的切片
fmt.Println(data[:]) // 取全部切片
// Go 不支持
fmt.Println(data[:-1]) // ❌ index -1 (constant of type int) must not be negative
切片的遍历
for 循环索引遍历
func main() {
clone := []string{"影分身1", "影分身2", "影分身3"}
for i := 0; i < len(clone); i++ {
fmt.Printf("clone[%d]: %v\n", i, clone[i])
}
}
for range 循环
func main() {
clone := []string{"影分身1", "影分身2", "影分身3"}
for i, v := range clone {
fmt.Printf("clone[%d]: %v\n", i, v)
}
}
💡 for range 当接收两个值时,分别代表为 index 和 value(若不想输出,可使用匿名变量);当仅接收一个值时,代表 index。
// 接收索引和值
for i, v := range clone {
...
}
// 仅接收值(忽略索引)
for _, v := range clone {
...
}
// 仅接收索引
for i := range clone {
...
}
二维切片的使用
💡 由于 Go 切片是可变长度的,所以可以让每个内部切片都具有不等的长度。
func main() {
// 编辑一个99乘法表的二维数组
multiplicationTable := [][]string{
[]string{"1x1=1"},
[]string{"1x2=2", "2x2=4"},
[]string{"1x3=3", "2x3=6", "3x3=9"},
[]string{"1x4=4", "2x4=8", "3x4=12", "4x4=16"},
[]string{"1x5=5", "2x5=10", "3x5=15", "4x5=20", "5x5=25"},
[]string{"1x6=6", "2x6=12", "3x6=18", "4x6=24", "5x6=30", "6x6=36"},
[]string{"1x7=7", "2x7=14", "3x7=21", "4x7=28", "5x7=35", "6x7=42", "7x7=49"},
[]string{"1x8=8", "2x8=16", "3x8=24", "4x8=32", "5x8=40", "6x8=48", "7x8=56", "8x8=64"},
[]string{"1x9=9", "2x9=18", "3x9=27", "4x9=36", "5x9=45", "6x9=54", "7x9=63", "8x9=72", "9x9=81"},
}
for i, j := range multiplicationTable {
for n := 0; n <= i; n++ {
fmt.Printf("%v ", j[n])
}
fmt.Println()
}
}
Slice 基本操作
添加切片元素
append 添加元素
func main() {
var team []string
// 添加单个元素
team = append(team, "卡卡西")
// 添加多个元素
team = append(team, "鸣人", "佐助", "小樱")
}
append 添加切片
💡 了解 JavaScript 的同学想必对 ... 展开语法糖会感到格外亲切吧。
func main() {
newMembers := []string{"大和", "佐井"}
newTeam := make([]string, 0)
// 将集合中的元素打散开,本质和上个示例一样
newTeam = append(newTeam, newMembers...)
}
删除切片元素
💡 Go 语言中并没有删除切片元素的专用方法,我们可以利用切片本身的拼接特性来删除元素。虽然确实挺麻烦,也不方便,没办法呀 👐
func main() {
knife := []string{
"断刀·斩首大刀",
"大刀·鲛肌",
"长刀·缝针",
"钝刀·兜割", // 删除这个
"爆刀·飞沫",
"雷刀·牙",
"双刀·鲆鲽",
}
newKnife := append(knife[:3], knife[4:]...)
fmt.Println(knife) // 原切片: [断刀·斩首大刀 大刀·鲛肌 长刀·缝针 钝刀·兜割 爆刀·飞沫 雷刀·牙 双刀·鲆鲽]
fmt.Println(newKnife) // 删除后: [断刀·斩首大刀 大刀·鲛肌 长刀·缝针 爆刀·飞沫 雷刀·牙 双刀·鲆鲽]
}
切片的拷贝
赋值操作
💡 Go 中切片操作 [:] 的 = 赋值语句,会创建一个新的切片变量,只进行浅拷贝(也可以说这种方式不配称作拷贝),即只会拷贝切片本身和其元素的值,而不会拷贝元素指向的底层数据。该切片与原切片底层会指向同一块内存地址。因此对新的切片变量进行修改会影响到原切片变量,反之亦然。
func main() {
// 原切片
monsters := []string{"蛤蟆文太", "蛤蟆吉", "蛤蟆龙"}
// 新切片
monstersNew := monsters[:]
// 通过赋值的方式,会修改原有内容
monsters[1] = "万蛇"
monstersNew[2] = "蛞蝓"
// 原切片:data: [蛤蟆文太 万蛇 蛞蝓], addr: &reflect.SliceHeader{Data:0x140004393b0, Len:3, Cap:3}
fmt.Printf("data: %v, addr: %#v\n", monsters, (*reflect.SliceHeader)(unsafe.Pointer(&monsters)))
// 新切片:data: [蛤蟆文太 万蛇 蛞蝓], addr: &reflect.SliceHeader{Data:0x140004393b0, Len:3, Cap:3}
fmt.Printf("data: %v, addr: %#v\n", monstersNew, (*reflect.SliceHeader)(unsafe.Pointer(&monstersNew)))
}
copy 浅拷贝
💡 Go 提供 copy 函数以满足日常切片的拷贝操作,对于普通切片而言,使用 copy 的确可以实现 “深拷贝”,即拷贝后的切片与原切片不会相互影响。这是因为 copy 函数会创建一个新的切片来存储原切片的值,并且两个切片底层会指向不同的内存地址。
func main() {
// 原切片
monsters := []string{"蛤蟆文太", "蛤蟆吉", "蛤蟆龙"}
// 初始化新切片
var monstersCopy = make([]string, len(monsters))
// 拷贝
copy(monstersCopy, monsters)
// 这次赋值只会影响各自切片的元素
monsters[0] = "蛤蟆深作"
monstersCopy[2] = "大蛤蟆仙人"
// 原切片:data: [蛤蟆深作 蛤蟆吉 蛤蟆龙], addr: &{1374394197264 3 3}
fmt.Printf("data: %v, addr: %v\n", monsters, (*reflect.SliceHeader)(unsafe.Pointer(&monsters)))
// 新切片:data: [蛤蟆文太 蛤蟆吉 大蛤蟆仙人], addr: &{1374394197312 3 3}
fmt.Printf("data: %v, addr: %v\n", monstersCopy, (*reflect.SliceHeader)(unsafe.Pointer(&monstersCopy)))
}
💡 值得注意的是,copy 函数的完全拷贝是有前提条件的:
- 首先,必须满足单层切片,如果是多层嵌套结构的复杂切片或多维切片类型可能
copy就无法满足你的深层拷贝的需求。 - 换言之,若切片中的元素是指向其他数据结构的指针或引用类型,那么
copy并不能进行深拷贝。
// 普通切片
var simpleSlice []byte
// 嵌套切片
type NestedSlice struct {
ID uint
Name string
children []NestedSlice
}
// 二维切片
var multiSlice [][]string
深拷贝
💡 对于多层嵌套的切片,copy 函数仍然只进行浅拷贝,无法实现深拷贝。因此,对于多层嵌套的切片,我们只能自行实现,需要手动递归地拷贝每一维的切片来实现深拷贝。
💡 如果多维切片的维数很多,手动递归拷贝可能会变得非常麻烦。在这种情况下,可以使用第三方库来实现深拷贝,如 github.com/mohae/deepc…
Slice 底层存储原理
深入理解
Go语言的Slice底层存储原理,通过函数传切片类型参数的例子,解析Go中切片类型是值类型或引用类型。
引入场景
问题: 观察以下代码,Go 的 Slice 在作为函数参数传递的时候,是值传递还是引用传递?
- a 同学:应该是引用传递,因为在函数体内修改了
“宇智波·鼬”这个元素,结果影响到函数体外的值了。 - b 同学:应该是值传递,因为在函数体内进行扩容操作,而外部却没有扩容变化,所以应该不同的两份数据。
以上这两位同学呢,说的都对,也都不对,下面我们一起来揭开 Slice 的神秘面纱,看看这究竟是怎么回事吧。
package main
import "fmt"
func genjutsu(userList []string) {
// 函数体内进行切片元素值的修改(是否会影响外部呢)
userList[5] = "宇智波·鼬"
// 函数体内进行切片的动态扩容操作(是否会影响外部呢) 那为啥这里没有扩容增加呢?
for i := 1; i <= 10; i++ {
userList = append(userList, fmt.Sprintf("影分身%d", i))
}
}
func main() {
// 源切片
ninja := []string{"自来也", "纲手", "卡卡西", "鸣人", "小樱", "佐助", "香燐", "重吾", "水月", "大蛇丸"}
team1 := ninja[2:6]
team2 := ninja[5:9]
fmt.Println(team1) // 新切片1(函数执行前): [卡卡西 鸣人 小樱 佐助]
fmt.Println(team2) // 新切片2(函数执行前): [佐助 香燐 重吾 水月]
genjutsu(ninja) // 执行函数操作
fmt.Println(team1) // 新切片1(函数执行后): [卡卡西 鸣人 小樱 宇智波·鼬]
fmt.Println(team2) // 新切片2(函数执行后): [宇智波·鼬 香燐 重吾 水月]
}
得出结论
本着结论先行的原则,先让大家看到在 Go 中 Slice 作为函数参数的相关结论。如下:
| 切片的操作 | 是否发生扩容 | 呈现的效果 | 说明 |
|---|---|---|---|
| 修改某元素的值 | 切片无扩容行为 | 引用传递 | 影响源切片,数据指向同一地址 |
append() 操作 | 切片添加值,但无发生扩容行为 | 引用传递 | 影响源切片,数据指向同一地址 |
append() 操作 | 切片添加值,且发生了扩容行为 | 值传递 | 不影响源切片,一旦扩容,数据将指向另外地址,内外会彻底分离开 |
底层结构
Go 的切片是一种特殊的 "动态数组",以 []string 为例,它只是个语法糖的写法,本质上 Slice 底层是个 Struct 结构体。具体如下:
type slice struct {
array unsafe.Pointer // 用来存储实际数据的数组指针,指向一块连续的内存,仅指明 head 元素位置
len int // 切片中元素的数量
cap int // array 数组的长度
}
图解原理
示例代码
建议同学可以实际用 Goland Debug 每一行跑一下以下示例代码,看看具体发生了什么,对深入理解 Slice 很有帮助。其中改变 LIMIT_NUM 常量值,如下代码所示,最终会输出不同的结果。
package main
const (
// LIMIT_NUM = 2 // 不会扩容
LIMIT_NUM = 3 // 会扩容
)
var (
sliceData = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
)
func handler(s []int) {
// 切片(内部)
sliceInner := s[3:8] // sliceInner值:[4, 5, 6, 7, 8] 长度:5 容量:7
// 修改(内部)
sliceInner[0] *= 100 // sliceInner值:[400, 5, 6, 7, 8] 会影响原数据:[1, 2, 3, 400, 5, 6, 7, 8, 10, 20]
// 添加(内部)
for i := 1; i <= LIMIT_NUM; i++ {
sliceInner = append(sliceInner, i*10) // sliceInner值:[400, 5, 6, 7, 8, 10, 20, 30] 长度:8 容量:14(7*2)
}
}
func main() {
// 切片(外部)
sliceOuter := sliceData[2:5] // sliceOuter值:[3, 4, 5] 长度:3 容量:8
// 执行函数
handler(sliceData)
// 修改(外部)
sliceOuter[2] *= 100 // sliceOuter值:[3, 400, 500] 会影响原数据:[1, 2, 3, 400, 500, 6, 7, 8, 10, 20]
}
配套图文
Go 函数参数传递切片,其本质是 “值传递”!
从图中可看到,Go 函数参数传递切片的时候,会从函数外拷贝一份数据到函数内,但真正保存的数据位置指针,其函数内外却又是共用的(类似浅拷贝效果)。
当 cap 无扩容变化时,函数内外指针都是共用;只要 cap 发生扩容变化,则函数内外就会彻底分离开,两份数据相互独立(类似深拷贝效果)。
发表于:ProcessOn
扩容策略
🥱 某天闲来无聊,做了如下的测试,可以观察 Slice 动态扩容 Capacity 值的变化。
运行的测试示例中,初始长度为 1,当元素长度小于 500 时基本会 x2 倍数增长,后续增长会放缓(其实也能想象到,否则多浪费空间呐)。
Slice 代码应用实践
提供了一些常见通用的切片处理场景。
判断元素是否存在切片类型中
Generic 泛型方式
Go 1.18 版本后支持 Generic 泛型,强烈推荐!
func main() {
fruits := []string{"Apple", "Banana", "Strawberry"}
item := "Banana"
if contains[string](fruits, item) {
fmt.Println(item, "found in list")
} else {
fmt.Println(item, "not found in list")
}
}
func contains[T int | string](slice []T, item T) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
传入空接口 + 类型断言
在泛型未被加入之前,较为流行的一种解决方案。函数接收一个空接口的参数,在函数内部使用类型断言和 switch 语句来选择是哪种具体的类型。
func main() {
fruits := []string{"Apple", "Banana", "Strawberry"}
item := "Banana"
if result, err := contains(fruits, item); err != nil {
panic(err)
} else {
if result {
fmt.Println(item, "found in list")
} else {
fmt.Println(item, "not found in list")
}
}
}
func contains(slice, item interface{}) (bool, error) {
if len(slice) == 0 {
return false, errors.New("no values given")
}
switch slice.(type) {
case []int:
for _, s := range slice.([]int) {
if s == item {
return true, nil
}
}
return false, nil
case []string:
for _, s := range slice.([]string) {
if s == item {
return true, nil
}
}
return false, nil
default:
return false, fmt.Errorf("unsupported element type of given slice: %T", slice)
}
}
如何删除切片非连续多个元素
需求: numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} 删除其中的 3 6 9,其它均保留。
append 拼接方法
如果还用之前介绍的那种拼接方式,需要注意一些问题,先来看段低质量代码:
func TestPopSliceElem(t *testing.T) {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
numbers = append(numbers[:2], numbers[3:]...) // 操作:移除元素3
fmt.Println(numbers) // 结果:[1 2 4 5 6 7 8 9 10]
numbers = append(numbers[:4], numbers[5:]...) // 操作:移除元素6
fmt.Println(numbers) // 结果:[1 2 4 5 7 8 9 10]
numbers = append(numbers[:6], numbers[7:]...) // 操作:移除元素9
fmt.Println(numbers) // 结果:[1 2 4 5 7 8 10]
}
注意: 需要考虑到,由于每删除一个元素,索引都有会概率发生错位的。至于为什么呢,是否还记得我们之前提到 Slice 只存储起始的指针位置!因此,若当前目标处于下个目标元素的索引之前,当前元素被移除后,则下个符合条件的元素会发生索引前移的情况。那也正好符合上述运行示例!
当然,永远保持从后向前就完美避免了刚才所说的情况。
func TestPopSliceElem(t *testing.T) {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
numbers = append(numbers[:8], numbers[9:]...) // 操作:移除元素9
fmt.Println(numbers) // 结果:[1 2 3 4 5 6 7 8 10]
numbers = append(numbers[:5], numbers[6:]...) // 操作:移除元素6
fmt.Println(numbers) // 结果:[1 2 3 4 5 7 8 10]
numbers = append(numbers[:2], numbers[3:]...) // 操作:移除元素3
fmt.Println(numbers) // 结果:[1 2 4 5 7 8 10]
}
总结: 正序(从小到大)每出现一次,则会出现索引移位 +1 的情况,而逆序(从大到小)不会影响索引,推荐逆序遍历进行 remove!
/*
使用切片的切片操作和 append() 函数,删除切片中非连续的多个元素的时间复杂度是 O(n^2)。其中 n 是需要删除的元素的数量。
因为每次删除元素之后都需要将其余元素向前移动,这个操作的时间复杂度是 O(n)。
*/
func main() {
// 原始切片
slice := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// 需要删除的元素下标
indexes := []int{3, 6, 9}
// 对下标切片排序,从后往前遍历
sort.Sort(sort.Reverse(sort.IntSlice(indexes)))
for _, i := range indexes {
slice = append(slice[:i], slice[i+1:]...)
}
// 创建新的切片,复制原始切片中使用的元素(删除完成后,原始切片的容量会超过实际使用的容量)
newSlice := make([]int, len(slice))
copy(newSlice, slice)
// 将新的切片赋值给原始切片
slice = newSlice
fmt.Println(slice) // 输出 [1 2 4 5 7 8 10]
}
原切片重新赋值法
介绍: 推荐使用下面这种方式来解决 Go 切片中移除多个非连续元素的问题!只需一次遍历就能完成删除操作,时间复杂度为 O(n)。
解析: 引入一个 int 变量 k,将不符合删除要求的元素对原切片 numbers 进行从 k 开始(也就是 0 啦)的重新赋值,k 进行累加,直到遍历结束,最终不符合删除要求,也就是剩余的切片元素即为 numbers[:k]。
func main() {
var k uint
removed := make([]int, 0)
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
for i := 0; i < len(numbers); i++ {
// 不符合移除元素条件的
if numbers[i]%3 != 0 {
numbers[k] = numbers[i]
k++
} else {
// 符合移除元素条件的
removed = append(removed, numbers[i])
}
}
numbers = numbers[:k]
fmt.Println("剩余的数据:", numbers) // 剩余的数据: [1 2 4 5 7 8]
fmt.Println("删除的数据:", removed) // 删除的数据: [3 6 9]
}
如何深拷贝多层级嵌套的切片
解析: 要进行深拷贝,你需要递归地拷贝 NestedSlice 结构中的每个子 NestedSlice,并将其作为新的 NestedSlice 添加到拷贝的 Children 切片中。
如果当你传入一个 []NestedSlice 类型的切片时,该函数会递归地遍历整个嵌套切片,并拷贝每一个 NestedSlice 对象和其子切片。这样就可以实现一个深拷贝的嵌套切片。
type NestedSlice struct {
ID uint
Name string
Children []NestedSlice
}
// 最外层是单个结构体
func deepCopyNestedStruct(n NestedSlice) NestedSlice {
copy := NestedSlice{
ID: n.ID,
Name: n.Name,
}
if len(n.Children) > 0 {
copy.Children = make([]NestedSlice, len(n.Children))
for i, child := range n.Children {
copy.Children[i] = deepCopyNestedStruct(child)
}
}
return copy
}
// 最外层是个结构体切片
func deepCopyNestedSlice(slice []NestedSlice) []NestedSlice {
copy := make([]NestedSlice, len(slice))
for i, s := range slice {
copy[i] = NestedSlice{
ID: s.ID,
Name: s.Name,
}
if len(s.Children) > 0 {
copy[i].Children = deepCopyNestedSlice(s.Children)
}
}
return copy
}