漫谈 Slice 切片

1,895 阅读9分钟

漫谈 Slice 切片

可以遗憾,但不要后悔。 

我们留在这里,从来不是身不由己。 

——— 而是选择在这里经历生活

目录

本文主要围绕 GolangSlice 所展开,介绍了其基本的概念、用法、操作,底层的存储原理及常用的代码实践。

  1. 简介
  2. 基础知识
  3. 存储原理
  4. 应用技巧

简介

生活中的切片

life.png

什么 Go 切片

  • Go 语言中,鉴于对数组 Array 的局限性,从而引出了切片 Slice 的概念,切片是对现有数组的一个连续片段的引用,比数组更方便灵活,还可以追加数据(自动扩容),切片是一个拥有相同类型元素的可变长度的序列。

  • 它可以理解为可变长度的数组,也常被称为 “动态数组”,应该是首选的高级数据结构。

  • Go 中切片的内部结构包含地址(开始位置)、大小(实际元素的个数)和容量(超过这个阈值就要扩容了)三部分。

Slice 与 Map 对比

相同点

💡 当使用 make 初始化对象时,虽然 cap 是可选参数,但出于对性能的考量,对于大的、极速扩张的 SliceMap 而言,即便只能大概预估出容量,也建议显性的进行标注。

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

数据类型细节说明
SliceSlice 不可以直接赋值超出它容量本身的数据,否则会报边界错误!
Slice 即便是超边界,也可使用 append 扩容的方式处理。
Slice 仅声明类型,未初始化变量,零值为 nil,此时也可以进行扩容。
MapMap 不初始化,在 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 是内置函数,主要用于初始化 SliceMapChannel 数据类型。

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 当接收两个值时,分别代表为 indexvalue(若不想输出,可使用匿名变量);当仅接收一个值时,代表 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 函数的完全拷贝是有前提条件的:

  1. 首先,必须满足单层切片,如果是多层嵌套结构的复杂切片或多维切片类型可能 copy 就无法满足你的深层拷贝的需求。
  2. 换言之,若切片中的元素是指向其他数据结构的指针或引用类型,那么 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 中切片类型是 值类型引用类型

引入场景

问题: 观察以下代码,GoSlice 在作为函数参数传递的时候,是值传递还是引用传递?

  • 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(函数执行后): [宇智波·鼬 香燐 重吾 水月]
}

得出结论

本着结论先行的原则,先让大家看到在 GoSlice 作为函数参数的相关结论。如下:

切片的操作是否发生扩容呈现的效果说明
修改某元素的值切片无扩容行为引用传递影响源切片,数据指向同一地址
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 发生扩容变化,则函数内外就会彻底分离开,两份数据相互独立(类似深拷贝效果)。

Go slice 底层存储原理.png

发表于: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
}