GO语言基础篇(十二)- 数组&切片(slice)详解

516 阅读11分钟

这是我参与8月更文挑战的第 12 天,活动详情查看: 8月更文挑战

数组

数组是具有固定长度且拥有零个或者多个相同数据类型元素的序列。由于数组长度固定,所以在Go里边很少直接使用数组。slice的长度可以增长和缩短,在很多场合下使用的更多

数组中的元素是通过下标来访问的,索引从0到数组长度减1。Go的内置函数len可以返回数组中元素的个数

var a [3]int //声明一个长度为3的整数数组(未给初始值,元素使用它的零值)
fmt.Println(a[0]) // 取数组第一个元素
fmt.Println(a[len(a)-1]) //去数组最后一个元素

//输出索引和元素
for i, v := range a {
    fmt.Printf("%d  %d\n", i, v)
}

//仅输出元素
for _, v := range a {
    fmt.Printf("%d\n", v)
}

默认情况下,一个新数组中的元素初始值为元素类型的零值,对于数字来说就是0。也可以使用数组字面量根据一组值来初始化数组

var q [3]int = [3]int{1,2,3}
var r [3]int = [3]int{1,2}
fmt.Println(r[2]) // 0

在数组的长度位置,如果使用了"...",那么数组的长度则由初始化数组元素的个数决定,比如上边数组q,还可以这样初始化

q := [...]int{1,2,3}
fmt.Printf("%T\n", q) // [3]int

数组长度是数组的一部分。所以[3]int和[4]int是两种不同的数组类型。数组的长度必须是常量表达式,也就是说,这个表达式的值,在程序编译时就可以确定

q := [3]int{1,2,3}
q = [4]int{1,2,3,4} // 编译错误,不可以把[4]int赋值给[3]int

如果一个数组的元素类型是可以比较的,那么这个数组就是可以比较的。也就意味着可以使用==,来对两个数组进行比较,比较的结果是两边元素的值是否完全相同

a := [2]int{1,2}
b := [...]int{1,2}
c := [2]int{1,3}
fmt.Println(a==b,a==c,b==c) // true false false

Slice

slice表示一个拥有相同类型元素的可变长度的序列。slice通常写成[]T,其中元素的类型都是T;它看上去像没有长度的数组类型

数组和slice是紧密关联的。slice是一种轻量级的数据结构,可以用来访问数组的部分或者全部的元素,而这个数组称为slice的底层数组。slice有三个属性:指针、长度和容量指针指向数组的第一个可以从slice中访问的元素,这个元素并不一定是数组的第一个元素。 长度是指slice中的元素个数,它不能超过slice的容量。容量的大小通常是从slice的起始元素到底层数组的最后一个元素间元素的个数。Go的内置函数len和cap用来返回slice的长 度和容量

一个底层数组可以对应多个slice,这些slice可以引用数组的任何位置,彼此之间的元素还可以重叠

现在声明如下数组

months := [...]string{
    1:"January", 2:"February", 3:"March", 4:"April", 
    5:"May", 6:"June", 7:"July", 8:"August", 
    9:"September", 10:"October", 11:"Novemver", 12:"December"
}

slice操作符s[i:j](其中0≤ i ≤ j ≤ cap(s))创建了一个新的slice,这个新的slice引用了序列s中从 i 到j-1索引位置的所有元素,这里的s既可以是数组或者指向数组的指针,也可以是slice。新slice的元素个数是j-i个。如果上面的表达式中省略了 i(s[:j]),那么新slice 的起始索引位置就是0,即i=0;如果省略了 j(s[i:]),那么新slice的结束索引位置是len(s)-1,即j=len(s)。因此months [1:13]引用了所有的有效月份,同样的写法可以是months[1:]。 months[:]引用了整个数组

Q2 := months[4:7]
summer := months[6:9]
fmt.Println(Q2) // ["April","May","June"]
fmt.Println(summer) // ["June","July","August"]

Untitled.png

如果slice的引用超过了被引用对象的容量,即cap(s),那么会导致程序宕机。但是如果slice的引用超出了被引用对象的长度,即len(s),那么最终slice会比原slice长

//从上边我们可以知道summer := months[6:9] ,它的len为3,cap为7
fmt.Println(summer[:20])//超过了被引用对象的边界,宕机
endlessSummer := summer[:5]// 在slice summer的容量范围内,但是超过了len
fmt.Println(endlessSummer) // [June July August September October]

可以结合上图去理解

因为slice包含了指向数组元素的指针,所以将一个slice传递给函数的时候,可以在函数内部修改底层数组的元素。换言之,创建一个数组的slice等于为数组创建了一个别名。下边的这个函数就反转了整数slice中的元素

func reverse(s []int) {
    for i,j:=0, len(s)-1;i<j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
}

a := [...]int{0,1,2,3,4,5}
reverse(a[:])
fmt.Println(a)//[5 4 3 2 1 0]

slice不能像数组一样进行比较,所以不能用==来测试两个slice是否拥有相同的元素。为什么slice比较不能够使用==操作符?有两个原因

  • 和数组不同的是,slice的元素是非直接的,有可能slice可以包含它自身。虽然有办法处理这种特殊情况,但是没有一种方法是简单、高效、直观的
  • 因为slice的元素不是直接的,所以如果底层数组元素改变,同一个slice在不通的时间,会有不同的元素。由于散列表(例如Go的map类型)仅对元素的键做浅拷贝,这就要求散列表里面键在散列表的整个生命周期内必须保持不变。因为slice需要深度比较,所以就不能用slice作为map的键。对于引用类型,例如指针和通道,操作符==检查的是引用相等性,即它们是否指向相同的元素。如果有一个相似的slice相等性比较功能,它或许会比较有用,也能解决slice作为map键的问题,但是如果操作符==对slice和数组的行为不一致,会带来困扰

所以最安全的方法就是不允许直接比较slice。slice唯一允许的比较操作是和nil作比较

if summer == nil {
    ......
}

slice 类型的零值是nii值为 nil 的 slice 没有对应的底层数组。值为nil的slice长度和容量都是零但是也有非 nil 的 slice 长度和容量是零,例如[]int{}或make([]int,3)

var s []int  // len(s) == 0, s == nil
s = nil // len(s) == 0, s == nil
s = []int(nil) // len(s) == 0, s == nil
s = []int{} // len(s) == 0, s != nil

所以,如果想检查一个 slice 是否是空,那么使用len(s) == 0,而不是s == nil,因为s≠nil的情况下,slice也有可能是空

make

内置函数make可以创建一个具有指定元素类型、长度和容量的slice。其中容量参数可以省略,在这种情况下,slice的长度和容量相等

make([]T, len)
make([]T, len, cap)

make创建了一个无名数组并返回了它的一个slice,这个数组仅可以通过这个slice来访问。在上边的第一行代码中,所返回的slice引用了整个数组。在第二行代码中,slice只引用了数组的前len个元素,但是它的容量是数组的长度,这为未来的slice元素流出空间

slice原理

深度理解切片的底层实现,看这里

append函数

内置函数append用来将元素追加到slice的后边

var runes []rune //定义一个rune类型的切片,rune类型是int32类型的别名
    for _, r := range "hello, 世界" {
        runes = append(runes, r)
    }
fmt.Printf("%q\n", runes)// ['h' 'e' 'l' 'l' 'o' ',' ' ' '世' '界']

append函数对理解slice的工作原理很重要,下边的示例是为一个[]int数组slice定义的方法appendInt

func appendInt(x []int, y int) []int {
	var z []int
	zlen := len(x) + 1

	if zlen <= cap(x) {
		z = x[:zlen] //将x中的元素全部拷贝到z中
	} else {//说明原来的slice已经没有空间了
		zcap := zlen
		if zcap < 2 * len(x) {
			zcap = 3 *len(x)
		}
		z = make([]int, zlen, zcap)
		copy(z, x) //内置的copy函数
	}
	z[len(x)] = y

	return z
}

每一次appendlnt调用都必须检查slice是否仍有足够容量来存储数组中的新元素。如果 slice 容最足够,那么它就会定义一个新的slice(仍然引用原始底层数组),然后将新元素 y 复制到新的位置,并返回这个新的 slice。输人参数 slice x和函数返回值 slice z拥有相同的底层数组

如果slice的容量不够容纳增长的元素,appendInt函数必须创建一个拥有足够容量的新的底层数组来存储新元素,然后将元素从 slice x复制到这个数组,再将新元素 y 追加到数组后面。返回值slice z将和输人参数slice x引用不同的底层数组

使用循环语句来复制元素看上去直观一点,但是使用内置函数 copy 将更简单,copy函数用来为两个拥有相同类型元素的slice复制元素。 copy 函数的第一个参数是目标slice ,第二个参数是源slice , copy数将源 slice 中的元素复制到目标 slice 中,这个和一般的元素赋值有点像,比如 dest = src 。不同的 slice 可能对应相同的底层数组,甚至可能存在元素重叠。copy函数有返回值,它返回实际上复制的元素个数,这个值是两个 slice 长度的较小值。所以这里不存在由于元素复制而导致的索引越界问题

出于效率的考虑,新创建的数组容量会比实际容纳slice x和 slice y所需要的最小长度更大一点。在每次数组容量扩展时,通过扩展一倍的容量来减少内存分配的次数,这样也可以保证追加一个元素所消耗的是固定时间

下边示例可以展示appendInt的效果

func main() {
	var x, y []int
	for i:=0; i< 10; i++ {
		y = appendInt(x, i)
		fmt.Printf("%d\tcap=%d\t%v\n", i, cap(y), y)
		x = y
	}
}

输出结果:
0       cap=1   [0]
1       cap=2   [0 1]
2       cap=6   [0 1 2]
3       cap=6   [0 1 2 3]
4       cap=6   [0 1 2 3 4]
5       cap=6   [0 1 2 3 4 5]
6       cap=18  [0 1 2 3 4 5 6]
7       cap=18  [0 1 2 3 4 5 6 7]
8       cap=18  [0 1 2 3 4 5 6 7 8]
9       cap=18  [0 1 2 3 4 5 6 7 8 9]

内置的append函数使用了比appendInt更复杂的增长策略。通常情况下我们并不知道一次append操作调用,会不会导致一次新的内存分配,所以我们不能假设原始的slice和调用append后的结果slice指向同一个底层数组,也无法证明它们就指向不同的底层数组。也无法假设旧slice上对元素的操作会不会影响新的slice元素。因此,通常将append的调用结果再次赋值给传入append函数的slice

runes = append(runes, r)

append中一次不仅可以追加一个元素,也可以追加多个元素

var a []int
a = append(a, 1)
a = append(a, 2, 3)
a = append(a, 4, 5, 6)
a = append(a, a...)//追加x中的所有元素
fmt.Println(a) //[1 2 3 4 5 6 1 2 3 4 5 6]

slice就地修改

下边的示例是从一个字符串中去除空字符串,并返回一个新的slice

package main

import "fmt"

func nonempty(strings []string) []string {
	i := 0
	for _, s := range strings {
		if s != "" {
			strings[i] = s
			i++
		}
	}
	return  strings[:i]
}

func main() {
	data := []string{"one", "", "three"}
	fmt.Printf("%q\n", nonempty(data)) // ["one" "three"]
	fmt.Printf("%q\n", data) // ["one" "three" "three"]
}

上边的示例中,输入的slice和输出的slice拥有相同的底层数组,这样就避免了在函数中重新分配数组。这种情况下,底层数组的元素只是部分被修改

从上边的打印结果可以看出来,data的值已经变了。所以通常会这样写data = nonempty(data)

slice实现一个栈

stack := []int
stack = append(stack, v) // push v
top := stack(len(stack)-1)// 栈顶
stack = stack[:len(stack)-1] // pop

为了从slice的中间移除一个元素,并保留剩余元素的顺序,可以使用copy函数来将高位索引的元素向前移动来覆盖被移除元素所在位置

func remove(slice []int, i int) []int {
    copy(slice[i:], slice[i+1:])
    return slice[:len(slice)-1]
}

func main() {
    s := []int{5,6,7,8,9}
    fmt.Println(remove(s, 2))//[5 6 8 9]
}

如果不需要维持原来的顺序,就可以简单的将slice的最后一个元素赋值给要移除的元素的那个位置

参考

《Go程序设计语言》—-艾伦 A. A. 多诺万

《Go语言学习笔记》—-雨痕