Go 切片常用操作与使用技巧

1.什么是切片

在 Go 语言中的切片(slice)是一种灵活的动态数组,它可以自动扩展和收缩,是 Go 语言中非常重要的数据结构之一。切片是基于数组实现的,它的底层是数组,可以理解为对底层数组的抽象。它会生成一个指向数组的指针,并通过切片长度关联到底层数组部分或者全部元素。

2.切片底层结构

在 Go 语言的源码包中,src/runtime/slice.go 定义了切片的数据结构:

Go 复制代码
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

切片占用 24 个字节:

  • array:指向底层数组的指针,占用 8 个字节。当你创建一个切片时,Go 会分配一个底层数组,并将切片的指针指向该数组。

  • len:切片中元素的数量,占用 8 个字节。

  • cap:切片的容量,cap 总是大于等于 len,占用 8 个字节。

3.初始化方式

通常用以下三种初始化切片:

  • 通过下标的方式获得数组的一部分;
Go 复制代码
slice := arr[1:3]
  • 使用字面量初始化新的切片;
Go 复制代码
slice := []int{1, 2, 3}
  • 使用关键字 make 创建切片:
Go 复制代码
slice := make([]int, 10)
//或
slice := make([]int, 3, 5)

make函数有三个参数:

  1. 第一个参数为切片类型,可以是[]int,[]string,[]float32等。
  2. 第二个参数为切片初始长度。
  3. 第三个为切片容量,该参数为可选参数。

注意点:

复制代码
var slice1 []int 这是声明,此时切片为nil

例如:

Go 复制代码
package main

import "fmt"

func main() {
	var slice1 []int
	if slice1 == nil {
		fmt.Println("slice is nil!")
	}
	// slice1[0] = 9 报错panic: runtime error: index out of range [0] with length 0
	slice1 = append(slice1, 2) //可以
	fmt.Println(slice1)
}

结果:

Go 复制代码
slice is nil!
[2]

4.追加元素

append函数是Go语言内置的一个函数,用于向切片中追加元素。

Go 复制代码
s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(s) // 输出:[1 2 3 4]

Go 复制代码
//如果你要追加的元素是另一个切片,那么可以使用...运算符,这样可以把那个切片的所有元素都追加进来。
s := []int{1, 2, 3}
t := []int{4, 5, 6}
s = append(s, t...)
fmt.Println(s) // 输出:[1 2 3 4 5 6]

5.切片表达式

Golang 中通常的 slice 语法是 a[low:high],您可能很熟悉。还有另一种切片语法,形式为 a[low:high:max],它采用三个索引而不是两个索引。第三索引 max 是做什么的?

提示: 不是 Python 切片语法 a[low:high:step] 中的 step 步长索引。

答: 第三个索引用于设置切片的容量!在 Golang 规范中称为 "全切片表达式"。

简单切片表达式的格式[low:high]

如下面例子,n为一个切片,当用这个表达式[1:4]表示的是左闭右开[low, high)区间截取一个新的切片(例子中结果是[2 3 4]),切片被截取之后,截取的长度是high-low

Go 复制代码
n := []int{1, 2, 3, 4, 5, 6}
fmt.Println(n[1:4]) // [2 3 4]

切片表达式的开始low和结束索引high是可选的;它们分别默认为零和切片的长度:

Go 复制代码
n := []int{1, 2, 3, 4, 5, 6}
fmt.Println(n[:4]) // [1 2 3 4],:前面没有值,默认表示0
fmt.Println(n[1:]) // [2 3 4 5 6],:后面没有值,默认表示切片的长度
边界问题
  • 1、当n为数组或字符串表达式n[low:high]中low和high的取值关系:

0 <= low <=high <= len(n)

  • 2、当n为切片的时候,表达式n[low:high]中high最大值变成了cap(n),low和high的取值关系:

0 <= low <=high <= cap(n)

不满足以上条件会发送越界panic。

全切片表达式

n[low:high:max]

max表示新生成切片的容量,新切片容量等于max-low,表达式中low、high、max关系:

0 <= low <= high <= max <= cap(n)

继续刚才的例子,当计算n[1:4]的容量,用cap得到值等于5,用扩展表达式n[1:4:5],用cap重新计算得到新的容量值(5-1)等于4:

Go 复制代码
 fmt.Println(cap(n[1:4])) // 5
 fmt.Println(cap(n[1:4:5])) // 4
关于容量

n[1:4]的长度是3好理解(4-1),容量为什么是5?

因为切片n[1:4]和切片n是共享底层空间 ,所以它的容量并不等于他的长度3,根据1等于索引1的位置(等于值2),从值2这个元素开始到末尾元素6,共5个,所以n[1:4]容量是5。

如果append超过切片的长度会重新生产一个全新的切片,不会覆盖原来的:

Go 复制代码
func main() {
	n := []int{1, 2, 3, 4, 5, 6}
	n2 := n[1:4:5]         // 长度等于3,容量等于4
	fmt.Printf("%p\n", n2) // 0xc0000220c8
	n2 = append(n2, 5)
	fmt.Printf("%p\n", n2) // 0xc0000220c8
	n2 = append(n2, 6)
	fmt.Printf("%p\n", n2) // 地址发生改变,0xc00001e080
}

6.常见使用陷阱

切片作为参数传递陷阱

  • 在函数里修改切片元素的值,原切片的值也会被改变

  • 在函数里通过 append 方法,对切片执行追加元素的操作,可能会引起切片扩容 ,导致内存分配 的问题,可能会对程序的性能 造成影响

  • 在函数里通过 append 函数,对切片执行追加元素的操作,原切片里不存在新元素

看一下代码示例:

Go 复制代码
func main() {
	s := []int{0, 2, 3}
	fmt.Printf("main中1 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) // 3 3 [0, 2, 3]
	sliceOperation(s)
	fmt.Printf("main中2 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) // 3 3 [1, 2, 3]
}

func sliceOperation(s []int) {
	s[0] = 1
	fmt.Printf("sliceOperation中 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) // 3 3 [1, 2, 3]
}

结果:

Go 复制代码
main中1 切片的长度:3, 切片的容量:3, 切片的元素:[0 2 3]
sliceOperation中 切片的长度:3, 切片的容量:3, 切片的元素:[1 2 3]
main中2 切片的长度:3, 切片的容量:3, 切片的元素:[1 2 3]

看到切片中的元素会被改变,就认为改变切片中的元素数据就直接将切片作为参数传过去就可以了,但是如果用了append追加元素,就会发现追加的元素并不会加到原切片中:

Go 复制代码
func main() {
	s := []int{0, 2, 3}
	fmt.Printf("main中1 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) // 3 3 [0, 2, 3]
	sliceOperation(s)
	fmt.Printf("main中2 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) // 3 3 [1, 2, 3]
}

func sliceOperation(s []int) {
	s = append(s, 4)
	fmt.Printf("sliceOperation中 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) // 4 6 [0, 2, 3]
}

结果:

Go 复制代码
main中1 切片的长度:3, 切片的容量:3, 切片的元素:[0 2 3]
sliceOperation中 切片的长度:4, 切片的容量:6, 切片的元素:[0 2 3 4]
main中2 切片的长度:3, 切片的容量:3, 切片的元素:[0 2 3]
  • 若想实现执行 append 函数之后,原切片也能得到新元素;需将函数的参数类型由 切片类型 改成 切片指针类型
Go 复制代码
func main() {
	s := []int{0, 2, 3}
	fmt.Printf("main中1 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) 
	sliceOperation(&s)
	fmt.Printf("main中2 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(s), cap(s), s) 
}

func sliceOperation(s *[]int) {
	*s = append(*s, 4)
	fmt.Printf("sliceOperation中 切片的长度:%d, 切片的容量:%d, 切片的元素:%v\n", len(*s), cap(*s), s) 
}

结果:

Go 复制代码
main中1 切片的长度:3, 切片的容量:3, 切片的元素:[0 2 3]
sliceOperation中 切片的长度:4, 切片的容量:6, 切片的元素:[0 2 3 4]
main中2 切片的长度:4, 切片的容量:6, 切片的元素:[0 2 3 4]

slice 通过 make 函数初始化,后续操作不当所造成的陷阱

1.使用 make 函数初始化切片后,如果在后续操作中没有正确处理切片长度,容易造成以下陷阱:

Go 复制代码
func main() {
	s := make([]int, 0, 4)
	s[0] = 1 // panic: runtime error: index out of range [0] with length 0
}

通过 make([]int, 0, 4) 初始化切片,虽说容量为 4,但是长度为 0,如果通过索引去赋值,会发生panic;为避免 panic,可以通过 s := make([]int, 4)s := make([]int, 4, 4) 对切片进行初始化。

2.切片初始化不当,通过 append 函数追加新元素的位置可能于预料之外

Go 复制代码
func main() {
	s := make([]int, 4)
	s = append(s, 1)
	fmt.Println(s[0]) // 0

	s2 := make([]int, 0, 4)
	s2 = append(s2, 1)
	fmt.Println(s2[0]) // 1
}
  • 通过打印结果可知,对于切片 s,元素 1 没有被放置在第一个位置,而对于切片 s2,元素 1 被放置在切片的第一个位置。这是因为通过 make([]int, 4)make([]int, 0, 4) 初始化切片,底层所指向的数组的值是不一样的:

    • 第一种初始化的方式,切片的长度和容量都为 4,底层所指向的数组长度也是 4,数组的值为 [0, 0, 0, 0],每个位置的元素被赋值为零值s = append(s, 1) 执行后,s 切片的值为 [0, 0, 0, 0, 1]

    • 第二种初始化的方式,切片的长度为 0,容量为 4,底层所指向的数组长度为 0,数组的值为 []s2 = append(s2, 1) 执行后,s2 切片的值为 [1]

    • 通过 append 向切片追加元素,会执行尾插操作。如果我们需要初始化一个空切片,然后从第一个位置开始插入元素,需要避免 make([]int, 4) 这种初始化的方式,否则添加的结果会在预料之外。

性能陷阱

  • 内存泄露

    内存泄露是指程序分配内存后不再使用该内存,但未将其释放,导致内存资源被浪费。

    切片引用切片场景:如果一个切片有大量的元素,而它只有少部分元素被引用,其他元素存在于内存中,但是没有被使用,则会造成内存泄露。代码示例如下:

    Go 复制代码
    var s []int
    
      func main() {
         sliceOperation()
         fmt.Println(s)
      }
    
      func sliceOperation() {
         a := make([]int, 0, 10)
         a = append(a, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
         s = a[0:4]
      }

    上述代码中,切片 a 的元素有 10 个,而切片 s 是基于 a 创建的,它底层所指向的数组与 a 所指向的数组是同一个,只不过范围为前四个元素,而后六个元素依然存在于内存中,却没有被使用,这样会造成内存泄露。为了避免内存泄露,我们可以对代码进行改造:s = a[0:4]s = append(s, a[0:4]...),通过 append 进行元素追加,这样切片 a 底层的数组没有被引用,后面会被 gc

7.切片技巧

Go在Github的官方Wiki上介绍了切片的技巧SliceTricks[4],另外这个项目Go Slice Tricks Cheat Sheet[5]基于SliceTricks做了一系列的图,比较直观。

AppendVector

这个是添加一个切片的操作,上面我们在切片操作中已经介绍过。

a = append(a, b...)
Copy

这边给我们展示了三种copy的写法:

b := make([]T, len(a))
copy(b, a)

// 效率一般比上面的写法慢,但是如果有更多,他们效率更好
b = append([]T(nil), a...)
b = append(a[:0:0], a...)

// 这个实现等价于make+copy。
// 但在Go工具链v1.16上实际上会比较慢。
b = append(make([]T, 0, len(a)), a...)

Cut

截掉切片[i,j)之间的元素:

a = append(a[:i], a[j:]...)

Cut(GC)

上面的Cut如果元素是指针的话,会存在内存泄露,所以我们要对删除的元素设置nil,等待GC。

copy(a[i:], a[j:])
for k, n := len(a)-j+i, len(a); k < n; k++ {
 a[k] = nil // or the zero value of T
}
a = a[:len(a)-j+i]

Delete

删除索引位置i的元素:

a = append(a[:i], a[i+1:]...)
// or
a = a[:i+copy(a[i:], a[i+1:])]

Delete(GC)

删除索引位置i的元素:

copy(a[i:], a[i+1:])
a[len(a)-1] = nil // or the zero value of T
a = a[:len(a)-1]

Delete without preserving order

删除索引位置i的元素,把最后一位放到索引位置i上,然后把最后一位元素删除。这种方式底层并没有发生复制操作。

a[i] = a[len(a)-1] 
a = a[:len(a)-1]

Delete without preserving order(GC)

上面的删除操作,元素是一个指针的类型或结构体指针字段,会存在最后一个元素不能被GC掉,造成泄露,把末尾的元素设置nil,等待GC。

a[i] = a[len(a)-1]
a[len(a)-1] = nil
a = a[:len(a)-1]

Expand

这个本质上是多个append的组合操作。

a = append(a[:i], append(make([]T, j), a[i:]...)...)

Extend

用新列表扩展原来的列表

a = append(a, make([]T, j)...)

Filter (in place)

下面代码演示原地删除Go切片元素:

n := 0
for _, x := range a {
 if keep(x) {
  a[n] = x
  n++
 }
}
a = a[:n]

Insert

a = append(a[:i], append([]T{x}, a[i:]...)...)

第二个append会产生新的切片,产生一次copy,可以用以下代码方式,可免去第二次的copy:

s = append(s, 0 /* 先添加一个0值*/)
copy(s[i+1:], s[i:])
s[i] = x

InsertVector

下面代码演示插入向量(封装了动态大小数组的顺序容器)的实现:

a = append(a[:i], append(b, a[i:]...)...)

func Insert(s []int, k int, vs ...int) []int {
 if n := len(s) + len(vs); n <= cap(s) {
  s2 := s[:n]
  copy(s2[k+len(vs):], s[k:])
  copy(s2[k:], vs)
  return s2
 }
 s2 := make([]int, len(s) + len(vs))
 copy(s2, s[:k])
 copy(s2[k:], vs)
 copy(s2[k+len(vs):], s[k:])
 return s2
}

a = Insert(a, i, b...)

Push

a = append(a, x)

Pop

 x, a = a[len(a)-1], a[:len(a)-1]

Push Front/Unshift

a = append([]T{x}, a...)

Pop Front/Shift

x, a = a[0], a[1:]

7 切片额外技巧

Filtering without allocating

下面例子演示数据过滤的时候,b基于原来的a存储空间来操作,并没有重新生成新的存储空间。

b := a[:0]
for _, x := range a {
 if f(x) {
  b = append(b, x)
 }
}

为了让截取之后没有使用的存储被GC掉,需要设置成nil:

for i := len(b); i < len(a); i++ {
 a[i] = nil // or the zero value of T
}

Reversing

反转操作演示:

for i := len(a)/2-1; i >= 0; i-- {
 opp := len(a)-1-i
 a[i], a[opp] = a[opp], a[i]
}

还有一种方法:

for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 {
 a[left], a[right] = a[right], a[left]
}

Shuffling

洗牌算法。算法思想就是从原始数组中随机抽取一个新的数字到新数组中。

for i := len(a) - 1; i > 0; i-- {
    j := rand.Intn(i + 1)
    a[i], a[j] = a[j], a[i]
}

go1.10之后有内置函数Shuffle[6]

Batching with minimal allocation

做批处理大的切片的时候,这个技巧可以了解下:

actions := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
batchSize := 3
batches := make([][]int, 0, (len(actions) + batchSize - 1) / batchSize)

for batchSize < len(actions) {
    actions, batches = actions[batchSize:], append(batches, actions[0:batchSize:batchSize])
}
batches = append(batches, actions)

// 结果:
// [[0 1 2] [3 4 5] [6 7 8] [9]]

In-place deduplicate (comparable)

删除有序数组中的重复项:

import "sort"

in := []int{3,2,1,4,3,2,1,4,1} // any item can be sorted
sort.Ints(in)
j := 0
for i := 1; i < len(in); i++ {
 if in[j] == in[i] {
  continue
 }
 j++
 // preserve the original data
 // in[i], in[j] = in[j], in[i]
 // only set what is required
 in[j] = in[i]
}
result := in[:j+1]
fmt.Println(result) // [1 2 3 4]

Move to front, or prepend if not present, in place if possible.

下面代码演示移动指定元素到头部:

// moveToFront moves needle to the front of haystack, in place if possible.
func moveToFront(needle string, haystack []string) []string {
 if len(haystack) != 0 && haystack[0] == needle {
  return haystack
 }
 prev := needle
 for i, elem := range haystack {
  switch {
  case i == 0:
   haystack[0] = needle
   prev = elem
  case elem == needle:
   haystack[i] = prev
   return haystack
  default:
   haystack[i] = prev
   prev = elem
  }
 }
 return append(haystack, prev)
}

haystack := []string{"a", "b", "c", "d", "e"} // [a b c d e]
haystack = moveToFront("c", haystack)         // [c a b d e]
haystack = moveToFront("f", haystack)         // [f c a b d e]

Sliding Window

下面实现根据size的滑动窗口输出:

func slidingWindow(size int, input []int) [][]int {
 // returns the input slice as the first element
 if len(input) <= size {
  return [][]int{input}
 }

 // allocate slice at the precise size we need
 r := make([][]int, 0, len(input)-size+1)

 for i, j := 0, size; j <= len(input); i, j = i+1, j+1 {
  r = append(r, input[i:j])
 }

 return r
}

func TestSlidingWindow(t *testing.T) {
 result := slidingWindow(2, []int{1, 2, 3, 4, 5})
 fmt.Println(result) // [[1 2] [2 3] [3 4] [4 5]]
}

最后的技巧章节参考微信公众号太白技术: Go切片与技巧(附图解)

相关推荐
有梦想的咸鱼_1 小时前
go实现并发安全hashtable 拉链法
开发语言·golang·哈希算法
童先生4 小时前
Go 项目中实现类似 Java Shiro 的权限控制中间件?
开发语言·go
杜杜的man5 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*5 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
半桶水专家5 小时前
go语言中package详解
开发语言·golang·xcode
llllinuuu5 小时前
Go语言结构体、方法与接口
开发语言·后端·golang
cookies_s_s5 小时前
Golang--协程和管道
开发语言·后端·golang
王大锤43915 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
产幻少年5 小时前
golang函数
golang
为什么这亚子5 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算