likes
comments
collection
share

重学Go语言 | Slice全面讲解

作者站长头像
站长
· 阅读数 22

Go语言中,切片是一种以数组为底层结构的引用数据类型,相比于数组,切片更常用,更好用,但也更复杂,在这篇文章中,我们就来全面讲解一下Go语言的切片数据类型。

什么是切片

切片,英文为slice

切片是一个会自动增长的相同数据类型元素的集合,一般使用[]T来表示一个切片,其中T表示切片中元素的数据类型:

var i []int
var s []string

在上面代码中,[]int表示一个元素为整型的切片,[]string表示一个元素为字符串的切片。

创建切片

可以像声明其他数据类型一样,直接声明一个切片类型的变量:

var si []int
var st []string

此时切片s1st的值是nil,表示未初始化,即该切片未分配任何内存空间,因不能直接使用:

var s []int
s[0] = 10 //错误

如果要使用切片,就必须对切片进行初始化切片,即为切片分配内存空间,有几种不同的方式:

make函数

使用函数make初始化时,其第一个参数用于指定切片的类型,第二个参数用于指定切片的长度:

//make函数的第二个参数表示数组的长度
s1 := make([]int, 3) 
s1[0] = 10

fmt.Println(s1[0]) 
//输出:10

var s2 []int = make([]int,10)

make函数的第三个参数用于指定切片的容量:

//声明一个长度为3,容量为10的切片
s := make([]int,3,10) 

直接声明

使用make方法没办法直接为切片的元素赋初始值,而通过字面量的方式可直接初始化切片的元素值:

//长度和容量都为10
s := []int{1,2,3,4,5,6,7,8,9,10} 

直接声明的话,切片的长度由声明时初始化的元素数量决定,容量与长度相等。

通过其他切片或数组生成

也可以通过已经初始化的切片或数组来生成一个新的切片,其语法为:

e[low:high]

其中,lowhigh的范围是0~cap(cap表示切片的容量),表示截取切片索引从lowhigh-1之间的元素:

s1 = []int{1,2,3,4,5}
s2 := s1[0:2]
s3 := s[1:10] //报错,超过容量

low<=highlowhigh可以单独省略,也可以全部省略,全部省略表示截取全部元素:

//省略high,表示截取到切片的最后一个元素
s4 := s1[2:] 
 //省略low,表示从索引0开始截取
s5 := s1[:3]
//表示截取切片的全元素
s6 := s1[:] 

//报错,low大于high
s7 := [3:2] 

通过截取切片的方式来生成切片时,会把源切片数据复制一份给目标切片吗?我们可以通过下面的例子来验证:

weeks := [7]string{"Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"}

 //通过weeks数组生成work切片
work := weeks[0:6]

weeks[0] = "Mon"

fmt.Println(work) 
// 输出:[Mon Tuesday Wednesday Thursday Friday Saturday]

从上面的例子中可以看出,当修改源切片时,通过截取生成的新切片,也会受影响,这是因为他们指向同一个底层数组。

切片的长度与容量

切片的长度为正整数,通过内置的len函数可以获得切片的长度:

s := []int{1,2,3}
fmt.Println(len(s)) //输出:3

切片的容量为正整数,通过内置的cap函数可以获得切片的容量:

fmt.Println(cap(s)) //输出:3

容量(cap)表示切片当前最多可以存储多少个元素,当超出容量后,再往切片里添加元素,切片会扩容,长度(len)表示切片当前已经存储元素数量,切片的容量总是大于长度的。

而通过截取切片或数组生成的新切片,其长度与容量的计算方式:

len = high-low
cap2 = cap1-low //cap2表示截取后切片的容量,cap1表示源切片的容量

使用示例:

 //cap1 = 5
s := []int{1,2,3,4,5}
//high=4,low=1
d := s[1:4] 
//输出:4,cap2=cap1-1
fmt.Println(cap(d)) 
//输出:3,len=high-low
fmt.Println(len(d)) 

切片的结构

一个初始化好的切片由三个部分组成:长度、容量和指向底层数组的指针,如下图所示:

重学Go语言 | Slice全面讲解

当我们初始化切片后,Go会根据初始化时的信息初始化一个底层数组,并计算好长度与容量,切片中的指针会指向一个底层的数组的第一个元素,如下图所示:

重学Go语言 | Slice全面讲解

前面我们演示了通过截取一个切片或数组的方式生成新的切片时,新的切片会受到源切片的影响,这是因为新生成的切片与源切片都指向同一个底层数组:

重学Go语言 | Slice全面讲解

从上面的示意图可以看出,截取后的新切片d与源切片s指向同一个数组,由于我们是从索引1截到索引3,因为切片d指向数组的第2个元素,此时如果我们修改切片s的元素,切片d相会受影响:

s := []string{"A","B","C","D","E"}
d := s[1:3]
s[1] = "test" 
fmt.Println(d) 
// 输出:[test,C]

切片的特征

从上面切片的概念以及结构中,我们可以总结出切片有以下几个特征:

  • 切片的长度不需要在编译时就确定。
  • 切片的所有元素数据类型相同。
  • 切片底层有一个引用数组。
  • 与数组相同,每个切片的每个元素都有自己的索引,索引长度从0开始,索引最大值为切片长度减1。
  • 每个切片由容量、长度和指针组成。
  • 切片可以动态扩容。

遍历切片

切片的遍历方式与数组一样,使用for语句,有两种形式:

  • 使用for-range语句遍历切片:
for k,v := range s {
	fmt.Println(k,v)
}

for _,v := range s {
  fmt.Println(v)
}

for k,_ := range s {
  fmt.Println(k)
}
  • 使用for语句遍历切片:
for i := 0;i < len(s);i++ {
	fmt.Println(s[i])
}

将切片作为函数参数

当将数组作为函数的参数时,会发生值传递,数组会被复制一遍,如果数组太大,会非常耗时,且数组形参与实参的长度要求一致,因此一般不会把数组作为函数的参数。

func changeSlice(s [100]int, index int, element int) error {
	if index >= len(s) || index < 0 {
		return errors.New("索引越界")
	}

	s[index] = element
	return nil
}

由于数组的局限性,因此在需要把数组作为函数参数的地方往往采用切片来代替:

func changeSlice(s []int, index int, element int) error {
	if index >= len(s) || index < 0 {
		return errors.New("索引越界")
	}

	s[index] = element
	return nil
}

把切片作为函数的参数时,切片不会像数组一样把整个切片复制一遍吗?

答案是会。

那既然切片也会发生复制,那使用切片代替数组有什么优势呢?

首先,切片不用指定长度,因此传参比较自由,比如下面的两个函数,函数findOne的形参是切片类型,可以传进去任意长度的切片,只要元素类型一致即可,而findTwo函数的形参是数组,因此传进去实参必须与形参元素类型和长度一致,所以数组作为函数参数有很大的局限性。

func findOne([]int,value int)int{

}

findOne([]int{1,2,3})
findOne([]int{1,2,3,4,5,6})

func findTwo([3]int,value int)int{

}

findTwo([3]int{1,2,3})
findTwo([4]int{1,2,3,4}) //报错

其次,切片虽然会发生复制,但只是把切片的长度、容量和指向底层数组的指针复制一遍而已,因此非常快速:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3, 4}
	fmt.Printf("源切片:%p\n", s)
	checkFromSlice(s)

	a := [3]int{1, 2, 3}
	fmt.Printf("源数组:%p\n", &a)
	checkFromArray(a)
}

func checkFromSlice(s []int) {
	fmt.Printf("函数切片:%p\n", s)
}

func checkFromArray(a [3]int) {
	fmt.Printf("函数数组:%p\n", &a)
}

上面代码的运行结果输出如下:

源切片:0xc000020060
函数切片:0xc000020060
源数组:0xc00001e0c0
函数数组:0xc00001e0d8

从输出结果也可以看出,当把数组作为参数传给函数时,会复制整个数组,而切片只会复制容量、长度和指针,底层的数组不会复制。

复制切片

内置函数copy可用于复制切片,该函数的格式如下所示:

func copy(dst []Type, src []Type) int

复制时源切片与目标切片都必须是初始化好的,不然无法复制:

s1 := []int{1, 2, 3}
s2 := []int{4, 5, 6, 7, 8, 9}
copy(s1, s2)
fmt.Println(s1) //输出:[4,5,6]

var s3 []int
copy(s3,s2) //s3未初始化,无法完成复制

将元素添加到切片

切片有容量和长度的限制,但切片是可以动态扩容的,在切片容量用完的时候,要怎么往切片添加元素呢?

可以使用内置append函数,将一个或多个元素添加到切片中:

languages := []string{"PHP","Java","Go","Javascript","C++","C#"}

languages := append(languages,"Python")

切片合并

使用...将数组或切片展开后,也可以作为append函数的参数,将一个切片或数组合并到另一个切片中:

s1 := []int{1,2,3}
s2 := []int{4,5,6,7}
s2 = append(s1,s2...) 
//输出 [1,2,3,4,5,6,7]

也可以将切片字面量直接合并生成新的切片:

s := append([]int{1,2,3,4},[]int{5,6,7,8}...) 
fmt.Println(s)

切片的增长

我们知道切片是会增长的集合,当我们使用append()函数向切片添加元素时,实际上是把元素放到切片的底层数组,如果底层数组满了,没有空间,这时再向切片添加元素,Go只能创建一个更大的新数组,将原来的数组复制到新数组,并把新数组作为切片的底层数组。

s1 := []int{1, 2, 3}
fmt.Printf("长度:%d,容量:%d,数组地址:%p\n", len(s1), cap(s1), s1)
s1 = append(s1, 4)
fmt.Printf("长度:%d,容量:%d,数组地址:%p\n", len(s1), cap(s1), s1)
s1 = append(s1, 5)
fmt.Printf("长度:%d,容量:%d,数组地址:%p\n", len(s1), cap(s1), s1)
s1 = append(s1, 6)
fmt.Printf("长度:%d,容量:%d,数组地址:%p\n", len(s1), cap(s1), s1)
s1 = append(s1, 7)
fmt.Printf("长度:%d,容量:%d,数组地址:%p\n", len(s1), cap(s1), s1)
s1 = append(s1, 8)
fmt.Printf("长度:%d,容量:%d,数组地址:%p\n", len(s1), cap(s1), s1)

上面代码的运行结果为:

长度:3,容量:3,数组地址:0xc00001e0c0
长度:4,容量:6,数组地址:0xc00001a120
长度:5,容量:6,数组地址:0xc00001a120
长度:6,容量:6,数组地址:0xc00001a120
长度:7,容量:12,数组地址:0xc00005c060
长度:8,容量:12,数组地址:0xc00005c060

此时切片的变化如下图所示:

重学Go语言 | Slice全面讲解

从示意图中可以看出来,切片的数组经过两次扩容,如果我们把切片指向的数组第一个元素地址打印出来,也可以看出数组的改变。

切片的移除操作

有时候,我们需要移除切片中的元素,这要分两种情况:一种是移除切片中的一个或多个元素,一种是移除切片的全部元素。

移除一个或多个元素

Go语言并没有提供删除元素的函数,我们可以通过分断截取再合并切片的方式来删除切片中的连续的一个或多个元素,比如我们要删除索引为i的元素,可以这样做:

//i表示要删除的索引,n表示要连续删除的元素
a := append(a[:i],a[i+n:]...) 

根据上面的代码,如果要删除索引为3的元素,如下所示:

s1 := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
s1 = append(s1[:3], s1[4:]...)
fmt.Println(s1) 
//输出:[0 1 2 4 5 6 7 8 9 10]

如果要删除索引35的元素,如下所示:

s1 := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
s1 = append(s1[:3], s1[6:]...)
fmt.Println(s1)
// 输出:[0 1 2 6 7 8 9 10]

移除切片所有元素

要移除切片所有元素有两种方法,第一种方法:

s1 := []int{1, 2, 3, 4, 5}
fmt.Printf("数组起点:%p,长度:%d,容量:%d\n", s1, len(s1), cap(s1))

s1 = s1[:0]
fmt.Printf("数组起点:%p,长度:%d,容量:%d\n", s1, len(s1), cap(s1))

上面代码运行结果为:

数组起点:0xc000116030,长度:5,容量:5
数组起点:0xc000116030,长度:0,容量:5

第二种方法是给切片赋值nil,可以移除整个切片的数据:

s1 = nil
fmt.Println(cap(s1)) 
//输出:0
fmt.Println(len(s1)) 
//输出:0

在切片中查找元素

Go并没有提供内置函数用于查找切片中的元素,因此,与数组一样,想要查找切片中的元素,就必须遍历整个切片:

languages := []string{"PHP", "Java", "Python", "Go"}
for k, v := range languages {
    if v == "PHP" {
        fmt.Println("PHP found at index", k)
    }
}

在切片中查找元素的时间复度度为O(n)

二维与多维切片

切片与数组相同,也支持二维和多维切片。

二维切片:

ss1 := [][]int{{1,2},{3,4}}

多维切片:

sm := [][][]int{{{1}, {1}}, {{1}, {1, 4}}}

切片的比较

切片之间不能进行比较,切片只能与nil进行比较:

if s1 == nil {
	fmt.Println("Equal")
}else{
	fmt.Println("No equal")
}

当把两个切片进行直接比较时,无法通过编译:

//不能通过编译
if s1 == s2{ 
  fmt.Println("Equal")
}else{
  fmt.Println("No equal")
}

小结

至此我们已经完全了解了Go的切片这种数据类型,总结起来有以下几个要点:

  • 什么是切片,创建切片的几种方式。
  • make,copy,append,cap,len等内置函数的使用。
  • 切片的内部结构,以及切片与底层数组的关系。
  • 明白切片在容量满了之后是如何增长。
  • 了解相对于数组,把切片作为函数参数的优势。
  • 切片的相关操作:比较、查找元素、删除元素、遍历、复制以及向切片添加元素。
  • 二维与多维切片的创建。