array
和slice
看似相似,却有着极大的不同。
slice
是动态数组,是基于array
实现的
Array
array
是存储在一段连续的内存中,每个元素的类型相同,即是每个元素的宽度相同,可以根据元素的宽度计算元素存储的位置。- 分配在连续的内存地址上
- 元素类型一致,元素存储宽度一致
- 空间大小固定,不能修改
- 可以通过索引计算出元素对应存储的位置(只需要知道数组内存的起始位置和数据元素宽度即可)
- 会出现数据溢出的问题(下标越界)
Array 不是指针
- Go 语言的数组不同于 C 语言或者其他语言的数组,C 语言的数组变量是指向数组第一个元素的指针;
- 而 Go 语言的数组是一个值,Go 语言中的数组是值类型,一个数组变量就表示着整个数组,意味着 Go 语言的数组在传递的时候,传递的是原数组的拷贝。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15func modifyArr(arr1 [4]int) {
arr1[0] = 23
fmt.Println("in modify arr func, arr = ", arr1) // in modify arr func, arr = [23 2 3 4]
}
func TestArray(t *testing.T) {
arr1 := [4]int{1, 2, 3, 4}
fmt.Println("before modify, arr = ",arr1) // before modify, arr = [1 2 3 4]
modifyArr(arr1)
fmt.Println("after modify, arr = ",arr1) // after modify, arr = [1 2 3 4]
}
// 输出结果如下:
// before modify, arr = [1 2 3 4]
// in modify arr func, arr = [23 2 3 4]
// after modify, arr = [1 2 3 4]
Array 的构造
在程序中数组的初始化有两种方法 arr := [10]int{} 或 var arr [10]int,但是不能使用 make 来创建,数组这节结束时再探讨一下这个问题。
使用 unsafe来看一下在内存中都是如何存储的吧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21package main
import (
"fmt"
"unsafe"
)
func main() {
var arr = [3]int{1, 2, 3}
fmt.Println(unsafe.Sizeof(arr)) // 12
size := unsafe.Sizeof(arr[0])
// 获取数组指定索引元素的值
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size))) // 2
// 设置数组指定索引元素的值
*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) = 10
fmt.Println(arr[1]) // 10
}这段代码的输出如下:
1
2
312
2
10首先说 12 是 fmt.Println(unsafe.Sizeof(arr)) 输出的,unsafe.Sizeof 用来计算当前变量的值在内存中的大小,12 这个代表一个 int 有4个字节,3 * 4 就是 12。
这是在32位平台上运行得出的结果, 如果在64位平台上运行数组的大小是 24。从这里可以看出 [3]int 在内存中由3个连续的 int 类型组成,且有 12 个字节那么长,这就说明了数组在内存中没有存储多余的数据,只存储元素本身。
size := unsafe.Sizeof(arr[0]) 用来计算单个元素的宽度,int在32位平台上就是4个字节,uintptr(unsafe.Pointer(&arr[0])) 用来计算数组起始位置的指针,1size 用来获取索引为1的元素相对数组起始位置的偏移,unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) 获取索引为1的元素指针,(*int) 用来转换指针位置的数据类型, 因为 int 是4个字节,所以只会读取4个字节的数据,由元素类型限制数据宽度,来确定元素的结束位置,因此得到的结果是 2。
上一个步骤获取元素的值,其中先获取了元素的指针,赋值的时候只需要对这个指针位置设置值就可以了, (int)(unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + 1*size)) = 10 就是用来给指定下标元素赋值。
1 | package main |
如上代码,动态的给数组设定长度,会导致编译错误 non-constant array bound n, 由此推导数组的所有操作都是编译时完成的,会转成对应的指令,通过这个特性知道数组的长度是数组类型不可或缺的一部分,并且必须在编写程序时确定。
可以通过 GOOS=linux GOARCH=amd64 go tool compile -S array.go 来获取对应的汇编代码,在 array.go 中做一些数组相关的操作,查看转换对应的指令。之前的疑问,为什么数组不能用 make 创建? 上面分析了解到数组操作是在编译时转换成对应指令的,而 make 是在运行时处理(特殊状态下会做编译器优化,make可以被优化,下面 slice 分析时来讲)。
Slice
- 因为数组是固定长度且是值传递,很不灵活,所以在 Go 程序中很少看到数组的影子。
- 然而 slice 无处不在,slice 以数组为基础,提供强大的功能和遍历性。
- slice 的类型规范是[]T,slice T元素的类型。与数组类型不同,slice 类型没有指定的长度。
slice 本质
1 | type slice struct { |
** slice 申明的几种方法:**
1
2
3
4s := []int{1, 2, 3} 简短的赋值语句
var s []int var 申明
make([]int, 3, 8) 或 make([]int, 3) make 内置方法创建
s := ss[:5] 从切片或者数组创建** slice 有两个内置函数来获取其属性:**
1
2len 获取 slice 的长度
cap 获取 slice 的容量slice 的属性,这东西是什么,还需借助 unsafe 来探究一下。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20package main
import (
"fmt"
"unsafe"
)
func main() {
s := make([]int, 10, 20)
s[2] = 100
s[9] = 200
size := unsafe.Sizeof(0)
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s))) // c00007ce90,底层存储的数组的地址
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size))) // 10
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2))) // 20
fmt.Println(*(*[20]int)(unsafe.Pointer(*(*uintptr)(unsafe.Pointer(&s))))) // [0 0 100 0 0 0 0 0 0 200 0 0 0 0 0 0 0 0 0 0]
}这段输出除了第一个,剩余三个好像都能看出点什么, 10 不是创建 slice 的长度吗,20 不就是指定的容量吗, 最后这个看起来有点像 slice 里面的数据,但是数量貌似有点多,从第三个元素和第十个元素来看,正好是给 slice 索引 2 和 10 指定的值,但是切片不是长度是 10 个吗,难道这个是容量,容量刚好是 20个。
第二和第三个输出很好弄明白,就是 slice 的长度和容量, 最后一个其实是 slice 引用底层数组的数据,因为创建容量为 20,所以底层数组的长度就是 20,从这里了解到切片是引用底层数组上的一段数据,底层数组的长度就是 slice 的容量,由于数组长度不可变的特性,当 slice 的长度达到容量大小之后就需要考虑扩容,不是说数组长度不能变吗,那 slice 怎么实现扩容呢, 其实就是在内存上分配一个更大的数组,把当前数组上的内容拷贝到新的数组上, slice 来引用新的数组,这样就实现扩容了。
1 | package main |
这段输出看起来有点小复杂,第一行输出就不用说了吧,这个是打印整个数组的数据。先分析一下 s1 变量的下面的输出吧,s1 := arr[:] 引用了整个数组,所以在第5、6行输出都是10,因为数组长度为10,所有 s1 的长度和容量都为10,那第3、4行输出是什么呢,他们怎么都一样呢,之前分析数组的时候 通过 uintptr(unsafe.Pointer(&arr[0])) 来获取数组起始位置的指针的,那么第4行打印的就是数组的指针,这么就了解了第三行输出的是上面了吧,就是数组起始位置的指针,所以 (uintptr)(unsafe.Pointer(&s1)) 获取的就是引用数组的指针,但是这个并不是数组起始位置的指针,而是 slice 引用数组元素的指针,为什么这么说呢?
接着看 s2 变量下面的输出吧,s2 := arr[2:8] 引用数组第3~8的元素,那么 s2 的长度就是 6。 根据经验可以知道 s2 变量输出下面第3行就是 slice 的长度,但是为啥第4行是 8 呢,slice 应用数组的指定索引起始位置到数组结尾就是 slice 的容量, 所以 所以从第3个位置到末尾,就是8个容量。在看第1行和第2行的输出,之前分析数组的时候通过 uintptr(unsafe.Pointer(&arr[0]))+size2 来获取数组指定索引位置的指针,那么这段第2行就是数组索引为2的元素指针,(*uintptr)(unsafe.Pointer(&s2)) 是获取切片的指针,第1行和第2行输出一致,所以 slice 实际是引用数组元素位置的指针,并不是数组起始位置的指针。
** 总结:**
- slice 是的起始位置是引用数组元素位置的指针。
- slice 的长度是引用数组元素起始位置到结束位置的长度。
- slice 的容量是引用数组元素起始位置到数组末尾的长度。
经过上面一轮分析了解到 slice 有三个属性,引用数组元素位置指针、长度和容量。实际上 slice 的结构像下图一样:
slice 增长
- slice 是如何增长的,用 unsafe 分析一下看看:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23package main
import (
"fmt"
"unsafe"
)
func main() {
s := make([]int, 9, 10)
// 引用底层的数组地址
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
s = append(s, 1)
// 引用底层的数组地址
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
s = append(s, 1)
// 引用底层的数组地址
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
} - 以上代码的输出
1 | c000082e90 |
从结果上看前两次地址是一样的,初始化一个长度为9,容量为10的 slice,当第一次 append 的时候容量是足够的,所以底层引用数组地址未发生变化,此时 slice 的长度和容量都为10,之后再次 append 的时候发现底层数组的地址不一样了,因为 slice 的长度超过了容量,但是新的 slice 容量并不是11而是20,这要说 slice 的机制了,因为数组长度不可变,想扩容 slice就必须分配一个更大的数组,并把之前的数据拷贝到新数组,如果一次只增加1个长度,那就会那发生大量的内存分配和数据拷贝,这个成本是很大的,所以 slice 是有一个增长策略的。
** 总结 **
当 slice 的长度超过其容量,会分配新的数组,并把旧数组上的值拷贝到新的数组
逐个元素添加到 slice 并操过其容量, 如果 selic 的容量小于1024个元素,那么扩容的时候 slice 的 cap 就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一。
批量添加元素,当新的容量高于旧容量的两倍,就会分配比新容量稍大一些,并不会按上面第二条的规则扩容。
当 slice 发生扩容,引用新数组后,slice 操作不会再影响旧的数组,而是新的数组(社区经常讨论的传递 slice 容量超出后,修改数据不会作用到旧的数据上),所以往往设计函数如果会对长度调整都会返回新的 slice,例如 append 方法。
slice 是引用类型?
slice 不发生扩容,所有的修改都会作用在原数组上,那如果把 slice 传递给一个函数或者赋值给另一个变量会发生什么呢,slice 是引用类型,会有新的内存被分配吗。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57package main
import (
"fmt"
"strings"
"unsafe"
)
func main() {
s := make([]int, 10, 20)
size := unsafe.Sizeof(0)
fmt.Printf("%p\n", &s)
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))
slice(s)
s1 := s
fmt.Printf("%p\n", &s1)
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))
fmt.Println(strings.Repeat("-", 50))
*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)) = 20
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s1)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s1)) + size*2)))
fmt.Println(s)
fmt.Println(s1)
fmt.Println(strings.Repeat("-", 50))
s2 := s
s2 = append(s2, 1)
fmt.Println(len(s), cap(s), s)
fmt.Println(len(s1), cap(s1), s1)
fmt.Println(len(s2), cap(s2), s2)
}
func slice(s []int) {
size := unsafe.Sizeof(0)
fmt.Printf("%p\n", &s)
fmt.Printf("%x\n", *(*uintptr)(unsafe.Pointer(&s)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size)))
fmt.Println(*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + size*2)))
}这个例子比较长就不逐一分析了,在这个例子里面调用函数传递 slice 其变量的地址发生了变化, 但是引用数组的地址,slice 的长度和容量都没有变化, 这说明是对 slice 的浅拷贝,拷贝 slice 的三个属性创建一个新的变量,虽然引用底层数组还是一个,但是变量并不是一个。
第二个创建 s1 变量,使用 s 为其赋值,发现 s1 和函数调用一样也是 s 的浅拷贝,之后修改 s1 的长度发现 s1 的长度发生变化,但是 s 的长度保持不变, 这也说明 s1 就是 s 的浅拷贝。
这样设计有什么优势呢,第三步创建 s2 变量, 并且 append 一个元素, 发现 s2 的长度发生变化了, s 并没有,虽然这个数据就在底层数组上,但是用常规的方法 s 是看不到第11个位置上的数据的, s1 因为长度覆盖到第11个元素,所有能够看到这个数据的变化。这里能看到采用浅拷贝的方式可以使得切片的属性各自独立,而不会相互影响,这样可以有一定的隔离性,缺点也很明显,如果两个变量都引用同一个数组,同时 append, 在不发生扩容的情况下,总是最后一个 append 的结果被保留,可能引起一些编程上疑惑。
** 总结 **
slice 是引用类型,但是和 C 传引用是有区别的, C 里面的传引用是在编译器对原变量数据引用, 并不会发生内存分配,而 Go 里面的引用类型传递和赋值会进行浅拷贝,在32位平台上有12个字节的内存分配, 在64位上有24字节的内存分配。
- 传引用和引用类型是有区别的, slice 是引用类型。*
slice 的三种状态
- slice 有三种状态:零切片、空切片、nil切片。
零切片
所有的类型都有零值,如果 slice 所引用数组元素都没有赋值,就是所有元素都是类型零值,那这就是零切片。
1
2
3
4
5
6
7
8
9
10
11
12
13
14package main
import "fmt"
func main() {
var s = make([]int, 10)
fmt.Println(s)
var s1 = make([]*int, 10)
fmt.Println(s1)
var s2 = make([]string, 10)
fmt.Println(s2)
}以上代码输出
1
2
3[0 0 0 0 0 0 0 0 0 0]
[ ]
[ ]零切片很好理解,数组元素都为类型零值即为零切片,这种状态下的 slice 和正常的 slice 操作没有任何区别。
空切片
空切片可以理解就是切片的长度为0,就是说 slice 没有元素。 社区大多数解释空切片为引用底层数组为 zerobase 这个特殊的指针。但是从操作上看空切片所有的表现就是切片长度为0,如果容量也为零底层数组就会指向 zerobase ,这样就不会发生内存分配, 如果容量不会零就会指向底层数据,会有内存分配。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43package main
import (
"fmt"
"reflect"
"strings"
"unsafe"
)
func main() {
var s []int
s1 := make([]int, 0)
s2 := make([]int, 0, 0)
s3 := make([]int, 0, 100)
arr := [10]int{}
s4 := arr[:0]
fmt.Println(strings.Repeat("--s--", 10))
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s)))
fmt.Println(s)
fmt.Println(s == nil)
fmt.Println(strings.Repeat("--s1--", 10))
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s1)))
fmt.Println(s1)
fmt.Println(s1 == nil)
fmt.Println(strings.Repeat("--s2--", 10))
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s2)))
fmt.Println(s2)
fmt.Println(s2 == nil)
fmt.Println(strings.Repeat("--s3--", 10))
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s3)))
fmt.Println(s3)
fmt.Println(s3 == nil)
fmt.Println(strings.Repeat("--s4--", 10))
fmt.Println(*(*reflect.SliceHeader)(unsafe.Pointer(&s4)))
fmt.Println(s4)
fmt.Println(s4 == nil)
}以上代码输出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15–s—-s—-s—-s—-s—-s—-s—-s—-s—-s–
{0 0 0}
[]
–s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1—-s1–
{18349960 0 0}
[]
–s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2—-s2–
{18349960 0 0}
[]
–s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3—-s3–
{824634269696 0 100}
[]
–s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4—-s4–
{824633835680 0 10}
[]以上示例中除了 s 其它的 slice 都是空切片,打印出来全部都是 [],s 是nil切片下一小节说。要注意 s1 和 s2 的长度和容量都为0,且引用数组指针都是 18349960, 这点太重要了,因为他们都指向 zerobase 这个特殊的指针,是没有内存分配的。
NIL切片
- 什么是nil切片,这个名字说明nil切片没有引用任何底层数组,底层数组的地址为nil就是nil切片。上一小节中的 s 就是一个nil切片,它的底层数组指针为0,代表是一个 nil 指针。
总结
零切片就是其元素值都是元素类型的零值的切片。
空切片就是数组指针不为nil,且 slice 的长度为0。
nil切片就是引用底层数组指针为 nil 的 slice。
操作上零切片、空切片和正常的切片都没有任何区别,但是nil切片会多两个特性,一个nil切片等于 nil 值,且进行 json 序列化时其值为 null,nil切片还可以通过赋值为 nil 获得。
数组与 slice 大比拼
对数组和 slice 做了性能测试。
对不同容量和数组和切片做性能测试,代码如下,分为:100、1000、10000、100000、1000000、10000000
1 | package test |
- 测试结果如下:
1 | goos: darwin |
- 从上面的结果可以发现数组和 slice 在1000以内的容量上时性能机会一致,而且都没有内存分配,这应该是编译器对 slice 的特殊优化。
- 从10000~1000000容量时数组的效率就比slice好了一倍有余,主要原因是数组在没有内存分配做了编译优化,而 slice 有内存分配。
- 但是10000000容量往后数组性能大幅度下降,slice 是数组性能的两倍,两个都在运行时做了内存分配,其实这么大的数组还真是不常见,也没有比较做编译器优化了。
slice 与数组的应用场景总结
slice
和数组有些差别,特别是应用层上,特性差别很大,那什么时间使用数组,什么时间使用切片呢。之前做了性能测试,在1000以内性能几乎一致,只有10000~1000000时才会出现数组性能好于 slice,由于数组在编译时确定长度,也就是再编写程序时必须确认长度,所有往常不会用到更大的数组,大多数都在1000以内的长度。我认为如果在编写程序是就已经确定数据长度,建议用数组,而且竟可能是局部使用的位置建议用数组(避免传递产生值拷贝),比如一天24小时,一小时60分钟,ip是4个 byte这种情况是可以用时数组的。
为什么推荐用数组,只要能在编写程序是确定数据长度我都会用数组,因为其类型会帮助阅读理解程序,dayHour := [24]Data 一眼就知道是按小时切分数据存储的,如要传递数组时可以考虑传递数组的指针,当然会带来一些操作不方便,往常我使用数组都是不需要传递给其它函数的,可能会在 struct 里面保存数组,然后传递 struct 的指针,或者用 unsafe 来反解析数组指针到新的数组,也不会产生数据拷贝,并且只增加一句转换语句。slice 会比数组多存储三个 int 的属性,而且指针引用会增加 GC 扫描的成本,每次传递都会对这三个属性进行拷贝,如果可以也可以考虑传递 slice 的指针,指针只有一个 int 的大小。
** 对于不确定大小的数据只能用
slice
, 否则就要自己做扩容很麻烦, 对于确定大小的集合建议使用数组。**