【Go】P8 Go 语言核心数据结构:深入解析切片 (Slice)

目录

前言

大家好! 在上一篇博文中,我们详细分享了数组(Array)的内容。我们知道,数组是一个拥有相同类型元素固定长度 序列。但在实际开发中,我们更常需要处理可变长度 的序列。今天,我们就来深入探讨 Go 语言中一个极其重要且强大的数据结构------切片(Slice)

切片是 Go 语言的灵魂之一,它提供了比数组更强大、更灵活的序列操作能力。理解切片,是成为一名合格 Go 程序员的必经之路。


什么是切片?

简单来说,切片(Slice)是一个拥有相同类型元素可变长度序列。

它听起来和数组很像,但关键区别在于"可变长度"。切片是对数组的一层封装,它本身并不存储任何数据,而是"引用"一个底层的数组。

切片是一个引用类型,它的内部结构包含了三个核心字段:

  • 指针 (ptr): 指向底层数组中该切片引用的第一个元素。
  • 长度 (len): 切片中实际包含的元素个数。
  • 容量 (cap): 从切片的第一个元素开始,到底层数组末尾的元素个数。

切片的声明与初始化

声明切片的基本语法如下:

go 复制代码
var name []T

其中 T 是切片中元素的类型。

注意: 仅仅声明一个切片时,它的默认值为 nil。一个 nil 切片的长度和容量都是 0,并且没有指向任何底层数组。

go 复制代码
package main

import "fmt"

func main() {
    // 1. 声明一个 nil 切片
    var s1 []int
    fmt.Printf("s1: %v, len: %d, cap: %d, is nil: %t\n", s1, len(s1), cap(s1), s1 == nil)
    // 输出: s1: [], len: 0, cap: 0, is nil: true

    // 2. 使用字面量初始化 (最常用)
    s2 := []int{1, 2, 3, 4}
    fmt.Printf("s2: %v, len: %d, cap: %d, is nil: %t\n", s2, len(s2), cap(s2), s2 == nil)
    // 输出: s2: [1 2 3 4], len: 4, cap: 4, is nil: false

    // 3. 声明一个空切片 (与 nil 切片不同)
    s3 := []int{}
    fmt.Printf("s3: %v, len: %d, cap: %d, is nil: %t\n", s3, len(s3), cap(s3), s3 == nil)
    // 输出: s3: [], len: 0, cap: 0, is nil: false
}

nil 切片和空切片虽然在 lencap 上都为 0,但 nil 切片不指向任何底层内存,而空切片则指向了一个底层的空数组。

基于数组定义切片

切片的核心在于它是数组的"视图"。我们可以通过"切片表达式"从一个数组或另一个切片中创建新切片。

语法:a[low : high]

  • low:起始索引(包含)
  • high:结束索引(不包含)

这将创建一个新切片,其长度为 high - low

go 复制代码
package main

import "fmt"

func main() {
    // 1. 先定义一个数组
    arr := [5]int{10, 20, 30, 40, 50}

    // 2. 基于数组创建切片
    // 取 arr[1] 到 arr[3] (不包括 arr[4])
    s1 := arr[1:4] 

    fmt.Printf("s1: %v, len: %d, cap: %d\n", s1, len(s1), cap(s1))
    // 输出: s1: [20 30 40], len: 3, cap: 4

    // 为什么 cap 是 4?
    // 因为切片的容量是从它的第一个元素(arr[1])
    // 一直到底层数组的最后一个元素(arr[4])。
    // 个数是 20(arr[1]), 30(arr[2]), 40(arr[3]), 50(arr[4]),总共 4 个。
}

重要: 切片是引用类型 。修改切片中的元素,会直接修改底层数组中对应的值。

go 复制代码
package main

import "fmt"

func main() {
    arr := [5]int{10, 20, 30, 40, 50}
    s1 := arr[1:4] // s1 = [20, 30, 40]

    fmt.Println("修改前: arr =", arr) // arr = [10 20 30 40 50]

    // 修改切片 s1 的第一个元素 (对应 arr[1])
    s1[0] = 200

    fmt.Println("修改后: arr =", arr) // arr = [10 200 30 40 50]
    fmt.Println("修改后: s1 =", s1) // s1 = [200 30 40]
}

切片再切片

我们也可以在一个已有的切片上再次进行切片操作,创建子切片。

go 复制代码
package main

import "fmt"

func main() {
    s1 := []int{1, 2, 3, 4, 5, 6}

    // s2 是 s1 的子切片
    s2 := s1[1:3] // [2, 3]
    fmt.Printf("s2: %v, len: %d, cap: %d\n", s2, len(s2), cap(s2))
    // 输出: s2: [2 3], len: 2, cap: 5  (容量从 s1[1] 到 s1[5] 共 5 个)

    // s3 是 s2 的子切片
    s3 := s2[1:] // [3]  (从 s2[1] 到末尾)
    fmt.Printf("s3: %v, len: %d, cap: %d\n", s3, len(s3), cap(s3))
    // 输出: s3: [3], len: 1, cap: 4 (容量从 s2[1] -> s1[2] 到 s1[5] 共 4 个)

    // 它们都引用同一个底层数组
    s3[0] = 300
    fmt.Println("s1:", s1) // s1: [1 2 300 4 5 6]
    fmt.Println("s2:", s2) // s2: [2 300]
    fmt.Println("s3:", s3) // s3: [300]
}

切片表达式的完整语法: a[low : high : max]

  • low:起始索引(包含)
  • high:结束索引(不包含)
  • max:容量上限(不包含,用于限制新切片的 cap)

新切片的 len = high - lowcap = max - low。这是一种高级用法,可以用来防止子切片通过 append 操作意外地覆盖父切片的数据。

使用 make() 函数构造切片

如果我们不想依赖一个已存在的数组,而是想直接创建一个动态的切片,make() 函数是最好的选择。

make() 函数会分配一个底层的数组,并返回一个指向它的切片。

go 复制代码
// 格式1: make([]T, len)
// 创建一个类型为 T,长度和容量都为 len 的切片
s1 := make([]int, 5) 
// s1: [0 0 0 0 0], len: 5, cap: 5

// 格式2: make([]T, len, cap)
// 创建一个类型为 T,长度为 len,容量为 cap 的切片
s2 := make([]int, 3, 10)
// s2: [0 0 0], len: 3, cap: 10

切片的操作

使用 append() 函数追加元素

append() 是切片最常用的函数,用于向切片末尾追加一个或多个元素。

go 复制代码
package main

import "fmt"

func main() {
    var s []int // s 是一个 nil 切片
    
    // 1. 追加单个元素
    s = append(s, 1) // s = [1]
    
    // 2. 追加多个元素
    s = append(s, 2, 3, 4) // s = [1, 2, 3, 4]
    
    // 3. 追加另一个切片 (注意...语法)
    s2 := []int{5, 6}
    s = append(s, s2...) // s = [1, 2, 3, 4, 5, 6]

    fmt.Println(s)
}

重点: append() 的返回值
append 函数必须 将结果重新赋值给原切片,即 s = append(s, ...)。 为什么?这就涉及到了切片的扩容策略

切片的扩容策略

当我们使用 append 时,如果切片的 len 小于 cap,元素会直接添加到底层数组中,len 增加,切片头结构 (ptr, len, cap) 中的 len 被更新。

但是,当 len 等于 cap 时,切片已满,无法再容纳新元素。此时,Go 的运行时会:

  • 分配一个新数组: 这个新数组的容量会比旧数组大。
  • 复制数据: 将旧数组中的所有元素复制到新数组中。
  • 添加新元素: 在新数组末尾添加要 append 的元素。
  • 返回新切片: append 函数返回一个指向这个新数组的新切片。

这就是为什么我们必须 s = append(s, ...),因为 s 可能已经指向了一个全新的底层数组。

仅需了解:
扩容的规则(大致策略,不同 Go 版本可能微调)

  • 如果切片容量小于 1024(在某些版本是 256),新容量会翻倍(cap * 2)。
  • 如果切片容量大于等于 1024,新容量会增长 1.25 倍(cap * 1.25),以避免内存的过度浪费。
go 复制代码
package main

import "fmt"

func main() {
    s := make([]int, 0, 1) // len=0, cap=1
    
    fmt.Printf("len: %d, cap: %d, ptr: %p\n", len(s), cap(s), s)
    
    s = append(s, 1) // len=1, cap=1
    fmt.Printf("len: %d, cap: %d, ptr: %p\n", len(s), cap(s), s)
    
    // 此时 cap=1, len=1,再 append 会触发扩容 (cap 翻倍到 2)
    s = append(s, 2) // len=2, cap=2
    fmt.Printf("len: %d, cap: %d, ptr: %p\n", len(s), cap(s), s) // 注意看 ptr 地址变化
    
    // 此时 cap=2, len=2,再 append 会触发扩容 (cap 翻倍到 4)
    s = append(s, 3) // len=3, cap=4
    fmt.Printf("len: %d, cap: %d, ptr: %p\n", len(s), cap(s), s) // ptr 再次变化
}

(注意:%p 打印出的地址在每次运行时都可能不同)

切片的循环遍历

和数组一样,切片主要有两种遍历方式:

go 复制代码
package main

import "fmt"

func main() {
    s := []string{"Apple", "Banana", "Cherry"}

    // 方式一: for 循环 (传统方式)
    fmt.Println("--- for 循环 ---")
    for i := 0; i < len(s); i++ {
        fmt.Printf("Index: %d, Value: %s\n", i, s[i])
    }

    // 方式二: for range 循环 (Go 推荐)
    fmt.Println("--- for range 循环 ---")
    for index, value := range s {
        fmt.Printf("Index: %d, Value: %s\n", index, value)
    }

    // 如果你只关心值 (value)
    fmt.Println("--- for range (仅 value) ---")
    for _, value := range s {
        fmt.Println("Value:", value)
    }
}

切片中的拷贝 (copy 函数)

因为切片是引用类型 ,我们不能简单地用 s2 := s1 来创建一个独立的切片。

  • s2 := s1 这只是浅拷贝(Shallow Copy)s1s2 共享同一个底层数组。修改 s2 会影响 s1

要想创建两个完全独立的切片(互不影响),我们需要使用内置的 copy() 函数进行深拷贝(Deep Copy)

  • copy(dst, src)src 切片中的元素复制到 dst 切片中。
go 复制代码
package main

import "fmt"

func main() {
    s1 := []int{1, 2, 3}

    // 陷阱:浅拷贝
    s2 := s1
    s2[0] = 100
    fmt.Println("s1 (浅拷贝后):", s1) // s1: [100 2 3] (s1 被改变了!)
    
    // 正确:深拷贝
    s3 := []int{1, 2, 3}
    // 1. 创建一个和 s3 相同长度的目标切片
    s4 := make([]int, len(s3))
    // 2. 拷贝元素
    copy(s4, s3)
    
    // 3. 修改 s4
    s4[0] = 999
    fmt.Println("s3 (深拷贝后):", s3) // s3: [1 2 3] (s3 未受影响)
    fmt.Println("s4 (深拷贝后):", s4) // s4: [999 2 3]
}

从切片中删除元素

Go 语言中没有提供直接的 "delete" 语法来删除切片中的元素。但我们可以利用 append 的特性来实现删除。

其原理是:将 "被删除元素" 之后的所有元素,拼接到 "被删除元素" 之前的所有元素上

go 复制代码
// 假设要从 s 中删除索引为 index 的元素
s = append(s[:index], s[index+1:]...)
  • s[:index] 获取 0index-1 的所有元素。
  • s[index+1:]... 获取 index+1 到末尾的所有元素(并使用 ... 展开)。
go 复制代码
package main

import "fmt"

func main() {
    s := []string{"A", "B", "C", "D", "E"}

    // 假设我们要删除索引 2 的元素 ("C")
    indexToRemove := 2

    s = append(s[:indexToRemove], s[indexToRemove+1:]...)

    fmt.Println("删除后:", s) // 输出: [A B D E]
}

警告: 这种删除方式会修改底层数组(被删除元素后面的元素会前移)。


数组切片的排序算法

最后,我们来看看如何对切片进行排序。我们介绍三种方法:两种经典的排序算法实现,以及 Go 的标准库 sort

选择排序 (Selection Sort)

逻辑:

  1. 找到切片中最小的元素。
  2. 将它与切片的第一个元素交换位置。
  3. 在剩下的元素中,找到最小的元素。
  4. 将它与切片的第二个元素交换位置。
  5. ...以此类推,直到整个切片排序完成。
go 复制代码
package main

import "fmt"

func selectionSort(items []int) {
    n := len(items)
    for i := 0; i < n; i++ {
        // 假定当前 i 是最小值的索引
        minIndex := i
        // 遍历 i 后面的元素,找到真正的最小值索引
        for j := i + 1; j < n; j++ {
            if items[j] < items[minIndex] {
                minIndex = j
            }
        }
        // 将找到的最小值与 i 位置交换
        items[i], items[minIndex] = items[minIndex], items[i]
    }
}

func main() {
    s := []int{5, 2, 8, 1, 9, 4}
    selectionSort(s)
    fmt.Println("选择排序后:", s) // [1 2 4 5 8 9]
}

冒泡排序 (Bubble Sort)

逻辑:

  1. 比较相邻的两个元素。
  2. 如果第一个比第二个大,就交换它们。
  3. 对切片中的每一对相邻元素做同样的工作,从开始到结束。这会使最大的元素"冒泡"到最后。
  4. 重复以上步骤,但每次比较的范围缩小 1(因为最大的已经归位了)。
  5. ...直到没有元素需要交换。
go 复制代码
package main

import "fmt"

func bubbleSort(items []int) {
    n := len(items)
    for i := 0; i < n; i++ {
        // 标记这一轮是否发生了交换
        swapped := false
        // (n-1-i) 是因为每轮都会把最大的放到最后,所以比较范围缩小
        for j := 0; j < n-1-i; j++ {
            if items[j] > items[j+1] {
                // 交换
                items[j], items[j+1] = items[j+1], items[j]
                swapped = true
            }
        }
        // 如果这一轮没有发生交换,说明已经排好序了
        if !swapped {
            break
        }
    }
}

func main() {
    s := []int{5, 2, 8, 1, 9, 4}
    bubbleSort(s)
    fmt.Println("冒泡排序后:", s) // [1 2 4 5 8 9]
}

sort 包 (Go 语言标准库)

虽然理解冒泡和选择排序很重要,但在实际工作中,我们永远应该使用 Go 标准库 sort 包。它实现了更高效的排序算法(如快速排序)。

sort 包为 []int, []string, []float64 提供了开箱即用的排序方法。

go 复制代码
package main

import (
    "fmt"
    "sort"
)

func main() {
    // 1. 排序 []int
    sInts := []int{5, 2, 8, 1, 9, 4}
    sort.Ints(sInts)
    fmt.Println("sort.Ints:", sInts) // [1 2 4 5 8 9]

    // 2. 排序 []string
    sStrings := []string{"Banana", "Apple", "Cherry"}
    sort.Strings(sStrings)
    fmt.Println("sort.Strings:", sStrings) // [Apple Banana Cherry]

    // 3. 自定义排序 (例如,按长度排序字符串)
    sort.Slice(sStrings, func(i, j int) bool {
        // 定义 "小于" 规则:
        // 如果 sStrings[i] 的长度小于 sStrings[j] 的长度,
        // 那么 sStrings[i] 应该排在前面。
        return len(sStrings[i]) < len(sStrings[j])
    })
    fmt.Println("sort.Slice (by len):", sStrings) // [Apple Cherry Banana]
}

sort.Slice 极其强大,它允许你根据任何自定义逻辑对任意类型的切片进行排序,读者请在实际项目中尝试并理解。


总结

切片是 Go 语言中最灵活、最常用的数据结构。掌握它,你的 Go 编程能力将更上一层楼。

关键点回顾:

  • 切片是引用类型: 它包含 指针、长度 (len) 和 容量 (cap)。
  • 基于数组创建: arr[low:high] 创建的切片与原数组共享内存
  • make() 创建: make([]T, len, cap) 是创建动态切片的标准方式。
  • append() 扩容:len == cap 时,append 会分配新数组,必须 s = append(s, ...)
  • copy() 拷贝: copy(dst, src) 是实现 深拷贝(独立切片) 的唯一途径。
  • sort 包排序: 始终优先使用标准库 sort.Intssort.Slice 进行排序。

希望这篇博文能帮助你彻底理解 Go 语言的切片!在下一篇博文中,我们将探讨 Go 的另一个核心数据结构:map。

感谢阅读!


2025.10.20 金融街

相关推荐
hweiyu003 小时前
Gradle 构建脚本迁移:从 Groovy DSL 到 Kotlin DSL,语法与技巧对比
开发语言·kotlin·gradle
Tony Bai3 小时前
【Go 网络编程全解】13 从 HTTP/1.1 到 gRPC:Web API 与微服务的演进
开发语言·网络·http·微服务·golang
峥嵘life3 小时前
Android EDLA开发认证说明和开发流程
开发语言·1024程序员节
刘新明19894 小时前
算法还原案例4-OLLVM_MD5
开发语言·前端·javascript·1024程序员节
wjs20244 小时前
空对象模式(Null Object Pattern)
开发语言
Cherry Zack4 小时前
FastAPI 入门指南 :基础概念与核心特性
开发语言·python·fastapi·1024程序员节
蒙奇D索大5 小时前
【数据结构】数据结构核心考点:AVL树删除操作详解(附平衡旋转实例)
数据结构·笔记·考研·学习方法·改行学it·1024程序员节
没有bug.的程序员5 小时前
Spring Boot 起步:自动装配的魔法
java·开发语言·spring boot·后端·spring·1024程序员节
面向星辰6 小时前
windows配置hadoop环境
java·开发语言