前言

在Go语言中,Slice作为一个比数组更加灵活的数据结构被广泛应用。但也正是由于它的灵活性,导致使用时常常容易犯错。笔者昨天做到了一道很有意思的题目,在这里与大家分享。

题目

下面的函数输出什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
)

func SliceRise(s []int) {
s = append(s, 0)
for i := range s {
s[i]++
}
}

func main() {
s1 := []int{1, 2}
s2 := s1
s2 = append(s2, 3)
SliceRise(s1)
SliceRise(s2)
fmt.Println(s1, s2)
}

A : [2, 3][][2, 3][2, 3, 4]

B : [1, 2][1, 2, 3]

C : [1, 2][2, 3, 4]

D : [2, 3, 1][2, 3, 4, 1]

给大家一些思考时间,为防不小心看到答案,此处空出几行。

答案是C。这道题比较综合地考察了append()操作切片时的一些细节。

首先,让我们看一下函数内外的切片在append操作前后发生了怎样的变化。在每一步打印出底层数组起始处的地址,代码如下:

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
package main

import (
"fmt"
)

func SliceRise(s []int) {
fmt.Printf("%p, len: %d, cap: %d\n", &s[0], len(s), cap(s))
s = append(s, 0)
fmt.Printf("%p, len: %d, cap: %d\n", &s[0], len(s), cap(s))
for i := range s {
s[i]++
}
}

func main() {
s1 := []int{1, 2}
s2 := s1
fmt.Printf("%p, len_s1: %d, cap_s1: %d\n", &s1[0], len(s1), cap(s1))
fmt.Printf("%p, len_s2: %d, cap_s2: %d\n", &s2[0], len(s2), cap(s2))
s2 = append(s2, 3)
fmt.Printf("%p, len_s2: %d, cap_s2: %d\n", &s2[0], len(s2), cap(s2))
SliceRise(s1)
SliceRise(s2)
fmt.Println(s1, s2)
}

运行后,结果如下。

1
2
3
4
5
6
7
8
0xc0000ac070, len_s1: 2, cap_s1: 2
0xc0000ac070, len_s2: 2, cap_s2: 2
0xc0000aa080, len_s2: 3, cap_s2: 4
0xc0000ac070, len: 2, cap: 2
0xc0000aa0a0, len: 3, cap: 4
0xc0000aa080, len: 3, cap: 4
0xc0000aa080, len: 4, cap: 4
[1 2] [2 3 4]

由下面的结果我们可以得到以下几个结论:

  1. 直接使用字面量初始化切片,容量默认等于长度。
  2. 形如s2 := s1此类的初始化方式,会共用底层数组。
  3. 在append后超出原切片长度时,Go会新建一个新的切片,并将容量扩充为原来的两倍(实际上,在容量小于1024时每次以两倍速度扩充,大于等于1024时以1.25倍扩充),再将原数据搬迁过去。
  4. 在传入切片为函数参数时,虽然Go中传参为值传递,但由于切片中指向底层数组的是指针,所以对切片底层数组值的改动依然有效。事实上为引用传递。(但函数内部对len和cap的变化并不会影响到原切片)
  5. 如果append后未超出原切片长度,则直接从原切片上更改值。

所以,为什么答案是C也就很明显了。首先函数外对于s2的append超出了原切片的容量,所以新建了一个切片。此时s1与s2不再是同一个切片。在s1传入函数时,由于append后长度超出容量,所以新建一个切片并在新切片上做出改动,后续操作都未能影响s1本身,故输出为[1, 2]。而s2传入函数时,append后长度未超过容量,函数内s依旧与s2指向同一个底层数组,所以值的自增有效。但由于s2的len依然为3,所以输出为[2, 3, 4]。