版权声明:我已加入“维权骑士”(http://rightknights.com)的版权保护计划,知乎专栏“网路行者”下的所有文章均为我本人(知乎ID:弈心)原创,未经允许不得转载。
如果你喜欢我的文章,请关注我的知乎专栏“网路行者”https://zhuanlan.zhihu.com/c_126268929, 里面有更多像本文一样深度讲解计算机网络技术的优质文章。
在《字符串》一篇中已经简单介绍了数组和切片的基本概念,知道了Go中的字符串本质上其实是一组字节切片。本篇将继续深入讲解数组和切片这两个在Go语言中十分重要的数据类型。
数组重要概念
数组(Array)是一种非常常见的数据类型,几乎所有的计算机编程语言中都会用到它,对于只学习过Python的读者来说,我们大体上可以将数组理解为Python中的列表,但是两者在细节上还有不少的差异。在Go语言中,数组有如下的特点:
- 数组里的元素必须全部为同一类型,要嘛全部是字符串,要嘛全部是整数,要嘛全部是byte等等,这点和可以包罗万象,任何数据类型都能混杂在一起作为元素放入的Python中的列表是有本质区别的。
- 声明数组时,必须指定其长度或者大小(英文叫做length或者size),所谓长度就是该数组能包含的元素的最大个数。如果你不确定数组里具体有多少个元素时,可以使用[...]替代具体的长度(比如[1], [2], [3]等等),Go的编译器会自动帮你算出该数组的长度。举例如下:
package main
import "fmt"
func main() {
var array1 = [5]int{0, 1, 2, 3, 4}
array2 := [...]int{5, 6, 7}
var array3 [10]int
fmt.Println("数组array1的长度:", len(array1))
fmt.Println("数组array2的长度:", len(array2))
fmt.Println("数组array3的长度:", len(array3))
}
这里我们分别用标准格式(隐式声明)、简短格式以及标准格式(显示声明)创建了三个整数数组变量array1,array2和array3。针对数组array1我们通过[5]手动指明了它的长度(注意:这里array1里的元素个数刚好为0,1,2,3,4总共5个整数,和array1的长度匹配,其实这里我们不用一定要在数组里放满5个元素不可,只要元素个数不超过我们定义的长度就可以,因为数组的长度(或大小)指的是该数组能容纳的最大元素个数,并不是当前数组里有多少个元素。)
而针对array2,我们使用[...]让Go帮我们自动计算出了该数组的长度,结果为3。
最后我们用显示声明但是不赋值的方式声明了变量array3,该数组的长度为我们手动指定的10,而该数组里的元素则为10个整数0,也就是在声明变量但是不赋值的情况下,Go自动为我们给整数数据类型分配的零值。
- 数组一旦被创建过后,其长度就再也不能被更改(不管是改大还是改小),举例如下:
package main
import "fmt"
func main() {
var array1 = [5]int{0, 1, 2, 3, 4}
array2 := [...]int{5, 6, 7}
var array3 [10]int
fmt.Println("数组array1的长度:", len(array1))
fmt.Println("数组array2的长度:", len(array2))
fmt.Println("数组array3的长度:", len(array3))
array2 = [4]int{5, 6, 7}
}
这里可以看到我们尝试将array2的长度从3改为4,但是程序返回了错误“cannot use [4]int{...} (type [4]int) as type [3]int in assignment”,提醒我们不能改变该数组长度(将数组长度改小也是同理,大家可以自行尝试)。
- 如果将数组作为参数传入一个函数,Go会为该数组创建一个副本,实际传入函数中的是数组的副本而不是源本,因此在函数下面对该数组进行的任何操作都会在函数返回后丢失。
正因为数组有上述几点限制,在Go中数组并不是特别常用,这也是为什么Go为数组开发了一个替代品:切片(Slice)。
切片重要概念
切片(Slice)相较于数组更灵活,因为在声明切片后其长度是可变的,并且在将切片作为参数传入一个函数时,虽然传入的是该切片的切片头部的副本(Slice Header,切片头部的概念会在下面讲到),但是对该切片内元素进行的任何操作都会在函数返回时体现在该切片的源本上,不会丢失。
切片头部
切片不等同于数组,切片的本质是用来描述(或指向)一个底层数组的数据结构,该数据结构又被叫做切片头部(Slice Header),描述切片头部的结构体(struct)如下:
type sliceHeader struct {
Length int
Capacity int
ZerothElement *int
}
注:关于结构体的知识会在后文讲到,这里你可以把它的作用理解为一组数据的集合,将这些数据(数据包括字段(field)和数据类型,比如这里的Length就是字段,而int就是其对应的数据类型)聚集起来,以便我们能够更加便捷地操作这些数据。这里读者需要知道的是切片头部的结构体里Length字段和Capacity字段的数据类型固定为int,也就是整数。而ZerothElement字段的类型为一个指针(*int,在数据类型前加上一个*号表示指针),如果切片所描述的底层数组的类型为整数型数组,那么这里的ZerothElement的类型就为*int,如果切片所描述的底层数组的类型为字符串型数组,那么这里的ZerothElement的类型就为*string,依此类推。关于指针的内容将在后文中讲到。
从上面的sliceHeader这个结构体可以看出:切片的切片头部由三部分组成:1. 长度(Length) 2. 容量(Capacity)3. ZerothElement,下面一一来举例讲解。
长度
切片头部里的长度指的就是切片本身的长度,也就是切片里当前拥有多少个元素。
举例如下:
package main
import "fmt"
func main() {
var array = [5]int{0, 1, 2, 3, 4}
slice := array[0:3]
fmt.Println(slice)
fmt.Println(len(slice))
}
这里我们声明了数组变量array和切片变量slice,该切片变量描述(指向)的是底层数组array的前三个元素 ( slice := array[0:3] ) ,因此该切片的长度为3。
容量
切片头部里的容量(capacity)指的是该切片所指向的底层数组的长度(length),也是该切片其本身所能达到的最大长度,在Go中我们使用cap()函数来得到一个切片的容量,cap()函数的返回值为整数:
package main
import "fmt"
func main() {
array := [5]int{} //声明一个数组变量array,其大小为5。
slice := array[:0] //声明一个指向数组变量array的切片变量slice,该切片为空切片,长度为0
fmt.Println("数组长度: ", len(array))
fmt.Println("切片容量: ", cap(slice))//使用cap()函数来返回一个切片的容量
fmt.Println("第一次切片长度: ", len(slice))
slice = array[:3]
fmt.Println("第二次切片长度: ", len(slice))
slice = array[:5]
fmt.Println("第三次切片长度: ", len(slice))
}
这里我们声明了一个整数数组变量array,其长度(大小)为5,然后我们又声明了一个指向该数组变量的切片变量slice,该切片为空切片(slice := array[:0]),长度为0。因为底层数组array的长度为5,因此切片slice的容量就为5。之后我们打印出来第一次切片的长度,即0。随后我们继续让切片指向数组的前三个元素(slice = array[:3])和所有五个元素(slice = array[:5]),可以看到切片的长度从0变为了3,然后又从3变为了5,证明了“切片的长度是可变的”这一概念。
接下来将代码修改一下,我们尝试第四次修改切片的长度到6:
package main
import "fmt"
func main() {
array := [5]int{} //声明一个数组变量array,其大小为5。
slice := array[:0] //声明一个指向数组变量array的切片变量slice,该切片为空切片,长度为0
fmt.Println("数组长度: ", len(array))
fmt.Println("切片容量: ", cap(slice))
fmt.Println("第一次切片长度: ", len(slice))
slice = array[:3]
fmt.Println("第二次切片长度: ", len(slice))
slice = array[:5]
fmt.Println("第三次切片长度: ", len(slice))
slice = array[:6]
fmt.Println("第四次切片长度: ", len(slice))
}
因为切片的容量为5(也就是底层数组的长度为5),所以第四次修改切片的长度到6时失败,系统返回“invalid slice index 6 (out of bounds for 5-element array)”的错误。
ZerothElement
最后来看组成切片头部的结构体里的最后一组数据ZerothElement。所谓ZerothElement指的是切片所指向(描述)的底层数组的数据里的“第零个元素”(ZerothElement英文的直译,因为索引号是从0开始的,实际上也就是第一个元素)。比如说:
array := [10]int{0,1,2,3,4,5,6,7,8,9}
slice := array[3:7]
这里切片slice指向(或描述)的是数组array里的第4到第7个元素(slice := array[3:7]),也就是整数3,4,5,6,那么此时ZerothElement代表的就是{3,4,5,6}里的“第零个元素”(ZerothElement,即实际意义上的第一个元素),也就是slice[0],即整数3。
理解了切片头部里的长度、容量、ZerothElement后,那么上面例子中的切片slice的切片头部的完整结构体如下:
type sliceHeader struct {
Length int
Capacity int
ZerothElement *int
}
slice := sliceHeader{
Length: 4,
Capacity: 10,
ZerothElement: &array[3],
}
切片和数组的互动
在理解了“切片实际上是用来描述(或指向)一个底层数组的数据结构”以及“切片头部”这两个概念后,我们再来看下切片和数组的互动中会产生的两个有趣的现象:
修改切片的元素
修改切片里的元素会同时修改其指向的底层数组里的元素,举例如下:
package main
import "fmt"
func main() {
array := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
slice := array[5:10]
fmt.Println("\n修改切片元素之前的切片: ", slice)
fmt.Println("修改切片元素之前的数组: ", array)
slice[0] = 6
fmt.Println("\n修改切片元素之后的切片: ", slice)
fmt.Println("修改切片元素之后的数组: ", array)
}
这里我们将切片slice里的元素5改为了6,那么相应地,数组array里的元素5也同时变为了6。
修改切片的容量
和修改切片里的元素不一样,修改切片的容量会直接切断该切片和其指向的底层数组之间的联系。要修改切片的容量需要用到append()函数,和Python一样,Go语言中的append()函数是用来向切片添加元素,举例如下:
package main
import "fmt"
func main() {
chinese_array := [...]string{"网", "络", "工", "程", "师"}
chinese_slice := chinese_array[:]
fmt.Println("修改前的切片: ", chinese_slice)
chinese_slice = append(chinese_slice, "的")
fmt.Println("修改后的切片: ", chinese_slice)
}
由于前面在讲解切片头部时所举的例子中,我都用的是变量名array和slice来分别表示数组和切片,为了不给读者造成一种只能用array和slice来给数组和切片做变量名的“错觉”,这里我特意将变量名改成了chinese_array和chinese_slice,并且还将数组和切片的类型从整数型换成了字符串型。
这里我们创建了一个长度为5的字符串类型的数组[...]string{"网", "络", "工", "程", "师"},然后创建了一个指向该数组里所有元素的切片,并用append函数为该切片添加了一个元素:“的”字。这里注意append()的使用方法和Python中append()有较大区别,不过和Python一样,默认情况下Go中通过append()为切片新添加的元素会被自动加在切片的末尾位置。
知道了append()的使用方法后,我们再来看下在给一个切片添加元素后,其长度和容量是如何变化的,将刚才用到的代码修改如下:
package main
import "fmt"
func main() {
chinese_array := [...]string{"网", "络", "工", "程", "师"}
chinese_slice := chinese_array[:]
fmt.Println("修改前的切片: ", chinese_slice)
fmt.Println("修改前的切片长度: ", len(chinese_slice))
fmt.Println("修改前的切片容量: ", cap(chinese_slice))
chinese_slice = append(chinese_slice, "的")
fmt.Println("\n修改后的切片: ", chinese_slice)
fmt.Println("修改后的切片长度: ", len(chinese_slice))
fmt.Println("修改后的切片容量: ", cap(chinese_slice))
}
在为切片[]string{"网", "络", "工", "程", "师"}添加了1个“的”字后,其长度从5变为了6,这个很好理解,因为现在切片chinese_slice里有"网"、"络"、"工"、"程"、"师"、"的"总共6个字符串元素,但是为什么它的容量却一下从5变成了10呢?这是因为每当一个切片的新容量超过它当前的容量后,Go会自动将其新容量扩大至之前的2倍,这样做的好处是Go可以一次性预留更多的内存给切片,提高Go的效率。
接下来再看切片扩容后,切片是怎样和其指向的底层数组之间断掉联系的:
package main
import "fmt"
func main() {
chinese_array := [...]string{"网络工程师的", "Python"}
fmt.Println("原始数组的内容: ", chinese_array)
chinese_slice := chinese_array[:] //切片指向完整的数组
chinese_slice[1] = "Golang" //将切片的第二个元素从Python改为Golang,切片容量不变
fmt.Println("切片元素改变后的切片(未扩容): ", chinese_slice) //元素改变后的切片
fmt.Println("切片元素改变后的数组(未扩容): ", chinese_slice) //在切片容量未改变的情况下,将切片的元素改变后,数组的元素也跟着改变
chinese_slice = append(chinese_slice, "之路") //为切片新添加一个元素,切片容量现在从2变为4,切片和数组不再有联系
fmt.Println("切片添加元素后的切片(已扩容): ", chinese_slice) //扩容后的切片
fmt.Println("切片添加元素后的数组(已扩容): ", chinese_array) //切片扩容后,数组里的元素不再跟着切片改变
}
切片未扩容前,我们将切片第二个元素"Python"改为"Golang",该切片指向的底层数组里的元素"Python"也随之被改为"Golang"。通过append()函数为切片添加新元素“之路”后,切片的容量从2变为4,切片和数组之间的联系断掉,数组不再随着切片的更新而改变。
切片可独立于数组存在
目前为止,我们所知道的是在Go语言中切片是用来指向(或描述)一个底层数组的数据结构,那么是否说必须有数组才能有切片,切片不能独立于数组存在呢?显然不是,上面我们在给切片扩容后,切片已经与底层数组脱离了联系并独立存在。因此,在Go中我们可以直接创建一个独立存在的切片,而不是一定要等创建了一个数组后,才能创建切片。举例如下:
package main
import "fmt"
func main() {
var vendor = []string{"Cisco","华为","H3C","Juniper"}
fmt.Println(vendor)
fmt.Println(len(vendor))
fmt.Println(cap(vendor))
vendor = append(vendor, "Arista")
fmt.Println(vendor)
fmt.Println(len(vendor))
fmt.Println(cap(vendor))
}
这里我们在没有创建数组的前提下直接创建了字符串型的切片变量vendor,这里注意,独立创建的切片其初始长度和容量是相等的,都等于我们创建它时的元素的个数(这里为4)。在我们为该切片添加了一个元素"Arista"后,长度由4+1变为了5,而容量则由4 x 2变为了8。
其他切片相关操作
知道了切片的基础原理后,下面来看看Go语言中其他和切片相关的操作。
make()函数
除了通过[]type{}的方式来创建切片外,我们还可以使用make()函数来创建切片。make()函数的语法格式为:
make([]type, length, capacity)
make()函数和[]type{}的区别在于:
- 使用[]type{}来声明一个切片时,可以选择是否在该切片里放入具体的元素(如果一个元素都不放,比如a := []int{},那么此时a为空切片),而make()函数不支持在切片里放入具体的元素,也就是说你只能通过make()创建一个所有元素都为零值的切片。
- 但是make()函数允许我们自定义切片的长度和容量,这个和[]type{}不一样,用[]type{}声明的切片无法自定义切片的长度和容量,切片里有多少个元素,那么其长度和容量就为多少,和make()函数相比缺乏灵活度。
下面举例讲解make()函数的用法:
package main
import "fmt"
func main() {
var interfaces = make([]string, 2, 3)
fmt.Println(interfaces)
fmt.Println(len(interfaces))
fmt.Println(cap(interfaces))
}
这里我们通过make()函数创建了一个字符串型的切片interfaces,其长度为2,容量为3。因为用make()函数创建的切片无法给出切片具体的元素,所以这时该切片里有两个值为空的字符串(字符串的零值为空)。
接下来将代码修改如下:
package main
import "fmt"
func main() {
var interfaces = make([]string, 2, 3)
fmt.Println(interfaces)
fmt.Println(len(interfaces))
fmt.Println(cap(interfaces))
interfaces[0] = "gi0/0/1"
interfaces[1] = "gi0/0/2"
interfaces = append(interfaces, "gi0/0/3")
interfaces = append(interfaces, "gi0/0/4")
fmt.Println(interfaces)
fmt.Println(len(interfaces))
fmt.Println(cap(interfaces))
}
因为通过make()创建的切片interfaces的两个元素为空字符串,我们用interfaces[0] = "gi0/0/1"和interfaces[1] = "gi0/0/2"为其赋值,然后通过append()函数继续为该切片添加元素,在第二次使用append()函数时,此时切片里已经有了4个元素("gi0/0/1", "gi0/0/2", "gi0/0/3"和"gi0/0/4"),已经超出了我们在make()函数里定义的容量3,因此扩容后的切片容量为6。
这里你也许会问:为什么interfaces = append(interfaces, "gi0/0/3")不能用interfaces[2] = "gi0/0/3"来替代?原因是虽然切片容量为3,但是目前切片的长度为2,切片里的第三个元素,即interfaces[2]是不存在的,我们只能用append()函数,以添加的方式来向该切片增加第三个元素。
删除切片里的元素
和在Python中删除列表里的元素的方法不一样,Go中没有现成可用的函数来删除切片里的元素,我们必须自己“发明”函数来删除切片元素。这里介绍一种方法:
package main
import "fmt"
func RemoveIndex(s []int, index int) []int {
return append(s[:index], s[index+1:]...)
}
func main() {
slice := []int{0, 1, 2, 3, 4, 5}
fmt.Println(slice)
slice = RemoveIndex(slice, 3)
fmt.Println(slice)
}
这里我们自定义了一个叫做RemoveIndex的函数(关于自定义函数的知识点会在后面讲到,这里了解即可),该函数有两个参数s和index,其中s的数据类型为整数型切片([]int),index的数据类型为整数(int),该函数返回的值为一个新的整数型切片。任何传入该函数里的切片会被分为两个切片,第一个切片包含我们想要删除的元素前面的所有元素(但不包含想要删除的元素其本身),第二个切片包含我们想要删除的元素后面的所有元素(但不包含想要删除的元素其本身),最后我们将两个切片通过append()函数合并(append()函数除了可以为切片添加元素外,也可以用来将两个切片合并,方法是在要添加的切片后面加上"...",即这里s[index+1:]...后面的"..."),便得到了一个新的整数型切片,因为用来合并的两个切片都不包含我们想要删除的元素,因此合并后的新切片即为删除了元素后的切片。
这里我们将切片变量slice作为参数传入RemoveIndex()里,我们想要移除slice里面的第4个元素(slice = RemoveIndex(slice, 3)),即整数3。看回RemoveIndex(),此时return append(s[:index], s[index+1:]...)实际就是return append(slice[:3], slice[4:]...) ,slice[:3]实际就是[]int{0, 1, 2},slice[4:]实际就是[]int{4, 5},将它俩用append()合并后,自然就得到了一个新的,不包含整数3的切片[]int{0, 1, 2, 4, 5}。
切片元素排序
在Python中,我们可以通过sort()来对列表里的元素排序。类似的功能在Go中也能实现,但是和Python中自带sort()的列表不同,Go中需要我们导入标准包sort来完成。举例如下:
package main
import (
"fmt"
"sort"
)
func main() {
ints := []int{2, 5, -1, 30, -9}
fmt.Println("排序前:", ints)
sort.Ints(ints)
fmt.Println("排序后:", ints)
sort.Sort(sort.Reverse(sort.IntSlice(ints)))
fmt.Println("倒序后:", ints)
floats := []float64{1.1, -0.2, 3.5, -5.1}
fmt.Println("\n排序前:", floats)
sort.Float64s(floats)
fmt.Println("排序后:", floats)
sort.Sort(sort.Reverse(sort.Float64Slice(floats)))
fmt.Println("倒序后:", floats)
strings := []string{"aa", "a", "A", "Aa", "aab"}
fmt.Println("\n排序前:", strings)
sort.Strings(strings)
fmt.Println("排序后:", strings)
sort.Sort(sort.Reverse(sort.StringSlice(strings)))
fmt.Println("倒序后:", strings)
}
这里我们针对整数、float64、字符串三个类型的切片做了排序,注意sort包会有不同的方法来对应不同类型的切片,比如对应整数型切片我们用sort.Ints(),对应float64型的切片我们用sort.Float64s,对应字符串型的切片我们用sort.Strings()。另外除了正序排列外,我们还可以通过sort包对切片做倒序排列,不过倒序排列会涉及到Go语言中接口(Interface)的相关知识,接口会在后文讲到,这里大家了解只要倒序代码的写法即可。
下一篇链接: