目录

Go 切片截取详解:三下标语法与底层原理

为什么要搞懂切片截取

在 Go 语言日常开发中,切片(slice)是使用频率最高的数据结构之一。我们几乎天天都在用 s[1:3] 这样的写法来截取子切片,但你是否真正理解过截取之后新切片的容量是怎么来的?修改新切片的元素,原切片会不会跟着变?

如果你对这些问题没有十足的把握,那这篇文章就值得你花几分钟认真读完。

吃透切片截取的底层逻辑,能帮你在实际项目中规避不少隐蔽的 bug。

切片截取的基本语法

Go 提供了两种切片截取方式:两下标三下标

两下标形式 s[i:j]

这是最常见的截取方式,从切片 s 中取出下标 ij-1 的元素:

package main

import "fmt"

func main() {
    s := []int{10, 20, 30, 40, 50}
    sub := s[1:3]
    fmt.Println(sub)      // [20 30]
    fmt.Println(len(sub)) // 2
    fmt.Println(cap(sub)) // 4
}

这里 sub 的长度是 3 - 1 = 2,容量是 cap(s) - 1 = 4。容量之所以是 4,是因为新切片从原切片下标 1 开始,后面还剩 4 个位置可用。

三下标形式 s[i:j:k]

Go 1.2 引入了三下标截取语法 s[i:j:k],第三个参数 k 用来显式限制新切片的容量

  • i:起始下标(包含),默认为 0
  • j:结束下标(不包含),默认为 len(s)
  • k:容量上限下标,必须满足 i <= j <= k <= cap(s)
package main

import "fmt"

func main() {
    s := []int{10, 20, 30, 40, 50}
    sub := s[1:3:4]
    fmt.Println(sub)      // [20 30]
    fmt.Println(len(sub)) // 2
    fmt.Println(cap(sub)) // 3
}

对比两下标截取,这里 sub 的容量从 4 变成了 4 - 1 = 3。多出来的那个参数 k 就是用来"收紧"容量的。

长度和容量的计算规则

记住这个核心公式:

属性 计算方式
新切片长度 len j - i
新切片容量 cap k - i(两下标时 k 默认为 cap(s)

用一个更直观的例子来验证:

package main

import "fmt"

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

    a := s[:0]
    b := s[:2]
    c := s[1:2:cap(s)]
    d := s[2:4:4]

    fmt.Println(len(a), cap(a)) // 0 5
    fmt.Println(len(b), cap(b)) // 2 5
    fmt.Println(len(c), cap(c)) // 1 4
    fmt.Println(len(d), cap(d)) // 2 2
}

逐行拆解:

  • s[:0] 等价于 s[0:0:5],长度 = 0,容量 = 5
  • s[:2] 等价于 s[0:2:5],长度 = 2,容量 = 5
  • s[1:2:5],长度 = 1,容量 = 4
  • s[2:4:4],长度 = 2,容量 = 2

底层数组共享:截取后的"隐形关联"

截取产生的新切片和原切片共享同一个底层数组。 这意味着修改其中一个切片的元素,另一个也会受到影响:

package main

import "fmt"

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

    sub[0] = 99

    fmt.Println(s)   // [1 99 3 4 5]
    fmt.Println(sub) // [99 3]
}

sub[0] 改成 99 后,s[1] 也变成了 99。这就是底层数组共享带来的副作用,在实际项目中一定要留意。

如果想要一个完全独立的切片,可以用 copy 函数:

s := []int{1, 2, 3, 4, 5}
sub := make([]int, 2)
copy(sub, s[1:3])
// 现在 sub 和 s 没有任何关联

append 扩容陷阱:一个经典的坑

底层数组共享在遇到 append 时会变得更加微妙。看这段代码:

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4, 5}
    sub := s[1:3] // sub = [2, 3], len=2, cap=4

    sub = append(sub, 100)

    fmt.Println(s)   // [1 2 3 100 5]
    fmt.Println(sub) // [2 3 100]
}

appendsub 追加了 100,但由于 sub 的容量还够用(cap=4,len=2),Go 不会分配新数组,而是直接在原底层数组上写入。于是 s[3] 被悄悄覆盖成了 100。

三下标截取正是为了解决这个问题。 通过限制容量,让 append 尽早触发扩容,从而和原切片断开关联:

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4, 5}
    sub := s[1:3:3] // sub = [2, 3], len=2, cap=2

    sub = append(sub, 100)

    fmt.Println(s)   // [1 2 3 4 5]  原切片不受影响
    fmt.Println(sub) // [2 3 100]
}

这次用 s[1:3:3] 把容量限制为 2,append 时发现容量不够,就分配了新的底层数组。原切片安然无恙。

三下标截取的实际应用场景

什么时候应该用三下标?总结起来就是一句话:当你不希望对子切片的操作影响到原切片时,用三下标把容量"卡死"。

典型场景包括:

  1. 函数参数传递:把切片的一部分传给某个函数,又不想函数内部的 append 操作污染原数据
  2. 并发安全:多个 goroutine 各自拿到一个子切片,如果共享底层数组,并发写入会出问题
  3. 数据隔离:从一个大切片中切出一段作为独立数据使用,需要确保后续修改互不干扰

常见问题

Q1:两下标截取和三下标截取最大的区别是什么?

两下标截取 s[i:j] 生成的新切片,容量会从 i 一直延伸到原切片的末尾(即 cap(s) - i)。三下标截取 s[i:j:k] 可以通过 k 手动限制容量为 k - i,避免新切片"越界"操作到原切片后面的元素。

Q2:三下标中的 k 能大于 cap(s) 吗?

不能。k 的取值范围是 j <= k <= cap(s),如果超出这个范围,运行时会直接 panic。

Q3:截取之后对新切片做 append,什么时候会影响原切片?

当新切片的容量还有剩余空间时,append 会直接在原底层数组上写入,从而影响原切片。如果容量不够触发了扩容,Go 会分配一个全新的数组,此时原切片就不会受影响了。用三下标把容量"卡紧"是防止这类问题的好办法。

Q4:对数组(而非切片)做截取,规则一样吗?

一样的。对数组做截取也会生成一个切片,这个切片同样引用原数组作为底层存储。长度和容量的计算方式完全相同。

Q5:如何创建一个和原切片完全无关的副本?

使用内置的 copy 函数,或者通过 append 的方式:newSlice := append([]int{}, oldSlice...)。两种方式都会分配新的底层数组,修改副本不会影响原切片。

总结

Go 切片截取是一个看似简单、实则暗藏玄机的知识点。核心要点回顾:

  • s[i:j] 截取下标 ij-1 的元素,长度为 j - i,容量为 cap(s) - i
  • s[i:j:k] 在此基础上用 k 限制容量为 k - i,是 Go 1.2 引入的特性
  • 截取产生的新切片和原切片共享底层数组,修改会互相影响
  • append 在容量足够时不会扩容,可能意外覆盖原切片的数据
  • 三下标截取可以有效防止 append 带来的数据污染问题
  • 需要完全独立的切片时,使用 copyappend([]T{}, s...) 来深拷贝

掌握这些知识,你在处理切片相关的代码时会更加从容,也能写出更健壮的 Go 程序。

如果大家对 Go 切片截取还有什么疑问,或者在项目中遇到过切片相关的"诡异" bug,欢迎在评论区分享交流~~~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/go-slice-subslice-syntax/

备用原文链接: https://blog.fiveyoboy.com/articles/go-slice-subslice-syntax/