Golang切片与函数参数“陷阱”

线性结构是计算机最常用的数据结构之一。无论是数组(arrary)还是链表(list),在编程中不可或缺。golang也有数组,不同于别的语言,golang还提供了切片(slice)。切片比数组有更好的灵活性,具有某些动态特性。然而切片又不像动态语言的列表(Python list)。不明白切片的基本实现,写程序的时候容易掉“坑”里。

slice参数

本来写一个堆排序,使用了golang的slice来做堆,可是发现在pop数据的时候,切片不改变。进而引发了golang函数切片的参数,是传值还是传引用呢?我们知道slice相比array是引用类型。那么直觉上告诉我们如果函数修改了参数的切片,那么外层的切片变量也会变啦。

func main() {

    slice := []int{0, 1, 2, 3}
    fmt.Printf("slice: %v slice addr %p \n", slice, &slice)

    ret := changeSlice(slice)
    fmt.Printf("slice: %v ret: %v slice addr %p \n", slice, &slice, ret)
}

func changeSlice(s []int) []int {
    s[1] = 111
    return s
}

结果和假设的一样:

slice: [0 1 2 3], slice addr: 0xc4200660c0 
slice: [0 111 2 3], ret: [0 111 2 3], slice addr: 0xc4200660c0

changeSlice函数修改了切片,变量 slice也跟着修改了。可是如果轻易就下结论,切片参数是按照引用传递,那么下面的现象就需要一种说法了:

func changeSlice(s []int) []int {
    fmt.Printf("func: %p \n", &s)
    s[1] = 111
    return s
}

我们在函数中打出参数 s 的地址,可以看见这个地址和main函数中的slice竟然不是同一个。为了了解这个,我们需要了解golang中的slice基本实现。

slice基本实现

Golang中的slice,是一个看似array却不是array的复合结构。切片顾名思义,就是数组切下来的一个片段。slice结构大致存储了三个部分,第一部分为指向底层数组的指针ptr,其次是切片的大小len和切片的容量cap

image

有一个数组arr是一个包含五个int类型的结构,它的切片slice只是从其取了 1到3这几个数字。我们同样可以再生成一个切片 slice2 := arr[2:5], 所取的就是数组后面的连续块。他们共同使用arr作为底层的结构,可以看见共用了数字的第3,4个元素。修改其中任何一个,都能改变两个切片的值。

func main() {

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

    slice := arr[1:4]
    slice2 := arr[2:5]

    fmt.Printf("arr %v, slice1 %v, slice2 %v, %p %p %p\n", arr, slice, slice2, &arr, &slice, &slice2)

    fmt.Printf("arr[2]%p slice[1] %p slice2[0]%p\n", &arr[2], &slice[1], &slice2[0])

    arr[2] = 2222

    fmt.Printf("arr %v, slice1 %v, slice2 %v\n", arr, slice, slice2)

    slice[1] = 1111

    fmt.Printf("arr %v, slice1 %v, slice2 %v\n", arr, slice, slice2)

}

输出的值为:

[0 1 2 3 4]
arr [0 1 2 3 4], slice1 [1 2 3], slice2 [2 3 4], 0xc42006e0c0 0xc4200660c0 0xc4200660e0
arr[2]0xc42006e0d0 slice[1] 0xc42006e0d0 slice2[0]0xc42006e0d0
arr [0 1 2222 3 4], slice1 [1 2222 3], slice2 [2222 3 4]
arr [0 1 1111 3 4], slice1 [1 1111 3], slice2 [1111 3 4]

由此可见,数组的切片,只是从数组上切一段数据下来,不同的切片,其实是共享这些底层的数据数据。不过这些切片本身是不一样的对象,其内存地址都不一样。

从数组中切一块下来形成切片很好理解,有时候我们用make函数创建切片,实际上golang会在底层创建一个匿名的数组。如果从新的slice再切,那么新创建的两个切片都共享这个底层的匿名数组。

func main() {

    slice := make([]int, 5)
    for i:=0; i<len(slice);i++{
        slice[i] = i
    }
    fmt.Printf("slice %v \n", slice)

    slice2 := slice[1:4]
    fmt.Printf("slice %v, slice2 %v \n", slice, slice2)

    slice[1] = 1111
    fmt.Printf("slice %v, slice2 %v \n", slice, slice2)
}

输出如下:

slice [0 1 2 3 4] 
slice [0 1 2 3 4], slice2 [1 2 3] 
slice [0 1111 2 3 4], slice2 [1111 2 3]

slice的复制

既然slice的创建依赖于数组,有时候新生成的slice会修改,但是又不想修改原来的切片或者数组。此时就需要针对原来的切片进行复制了。

func main() {

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

    slice2 := slice[1:4]

    slice3 := make([]int, len(slice2))

    for i, e := range slice2 {
        slice3[i] = e
    }

    fmt.Printf("slice %v, slice3 %v \n", slice, slice3)

    slice[1] = 1111

    fmt.Printf("slice %v, slice3 %v \n", slice, slice3)
}

输出:

slice [0 1 2 3 4], slice3 [1 2 3] 
slice [0 1111 2 3 4], slice3 [1 2 3]

由此可见,新创建的slice3,不会因为slice和slice2的修改而改变slice3。复制很有用,因此golang实现了一个内建的函数copy, copy有两个参数,第一个参数是复制后的对象,第二个是复制前的数组切片对象。

func main() {

    slice := []int{0, 1, 2, 3, 4}
    slice2 := slice[1:4]

    slice4 := make([]int, len(slice2))

    copy(slice4, slice2)

    fmt.Printf("slice %v, slice4 %v \n", slice, slice4)
    slice[1] = 1111
    fmt.Printf("slice %v, slice4 %v \n", slice, slice4)
}

slice4是从slice2中copy生成,slice和slice4底层的匿名数组是不一样的。因此修改他们不会影响彼此。

slice 追加

append 简介

创建复制切片都是常用的操作,还有一个追加元素或者追加数组也是很常用的功能。golang提供了append函数用于给切片追加元素。append第一个参数为原切片,随后是一些可变参数,用于将要追加的元素或多个元素。

func main() {

    slice := make([]int, 1, 2)
    slice[0] = 111

    fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

    slice = append(slice, 222)
    fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

    slice = append(slice, 333)
    fmt.Printf("slice %v, slice addr %p, len %d, cap %d \n", slice, &slice, len(slice), cap(slice))

}

输出结果为:

slice [111], slice addr 0xc4200660c0, len 1, cap 2 
slice [111 222], slice addr 0xc4200660c0, len 2, cap 2 
slice [111 222 333], slice addr 0xc4200660c0, len 3, cap 4

切片容量

无论数组还是切片,都有长度限制。也就是追加切片的时候,如果元素正好在切片的容量范围内,直接在尾部追加一个元素即可。如果超出了最大容量,再追加元素就需要针对底层的数组进行复制和扩容操作了。

这里有一个切片容量的概念,从数组中切数据,切片的容量应该是切片的最后一个数据,和数组剩下元素的大小,再加上现有切片的大小。

数组 [0, 1, 2, 3, 4] 中,数组有5个元素。如果切片 s = [1, 2, 3],那么3在数组的索引为3,也就是数组还剩最后一个元素的大小,加上s已经有3个元素,因此最后s的容量为 1 + 3 = 4。如果切片是s1 = [4],4的索引再数组中是最大的了,数组空余的元素为0,那么s1的容量为 0 + 1 = 1。具体如下表:

切片 切片字面量 数组剩下空间 长度 容量
s[1:3] [1 2] 2 2 4
s[1:1] [] 4 0 4
s[4:4] [] 1 0 1
s[4:5] [4] 0 1 1

尽管上面的第二个和第三个切片的长度一样,但是他们的容量不一样。容量与最终append的策略有关系。

append简单实现

我们已经知道,切片都依赖底层的数组结构,即使是直接创建的切片,也会生成一个匿名的数组。使用append时候,本质上是针对底层依赖的数组进行操作。如果切片的容量大于长度,给切片追加元素其实是修改底层数中,切片元素后面的元素。如果容量满了,就不能在原来的数组上修改,而是要创建一个新的数组,当然golang是通过创建一个新的切片实现的,因为新切片必然也有一个新的数组,并且这个数组的长度是原来的2倍,使用动态规划算法的简单实现。

func main() {

    arr := [3]int{0, 1, 2}

    slice := arr[1:2]

    fmt.Printf("arr %v len %d, slice %v  len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))

    slice[0] = 333

    fmt.Printf("arr %v len %d, slice %v  len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))

    slice = append(slice, 4444)

    fmt.Printf("arr %v len %d, slice %v  len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))

    slice = append(slice, 5555)

    fmt.Printf("arr %v len %d, slice %v  len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))

    slice[0] = 333

    fmt.Printf("arr %v len %d, slice %v  len %d, cap %d, \n", arr, len(arr), slice, len(slice), cap(slice))
}

输出:

arr [0 1 2] len 3, slice [1]  len 1, cap 2, 
arr [0 333 2] len 3, slice [333]  len 1, cap 2, 
arr [0 333 444] len 3, slice [333 444]  len 2, cap 2, 
arr [0 333 444] len 3, slice [333 444 555]  len 3, cap 4, 
arr [0 333 444] len 3, slice [333 444 555]  len 3, cap 4,

小于容量的append

重输出,我们来画一下这个动态过程的图示:

image.gif

arr 是一个含有三个元素的数组,slice从arr中切了一个元素,由于切片的最后一个元素1是数组的索引是1,距离数组的最大长度还是1,因此slice的容量为2。当修改slice的第一个元素,由于slice底层是arr数组,因此arr的第二个元素也相应被修改。使用append方法给slice追加元素的时候,由于slice的容量还未满,因此等同于扩展了slice指向数组的内容,可以理解为重新切了一个数组内容附给slice,同时修改了数组的内容。

超出容量的append

如果接着append一个元素,那么数组肯定越界。此时append的原理大致如下:

  1. 创建一个新的临时切片t,t的长度和slice切片的长度一样,但是t的容量是slice切片的2倍,一个动态规划的方式。新建切片的时候,底层也创建了一个匿名的数组,数组的长度和切片容量一样。

  2. 复制s里面的元素到t里,即填入匿名数组中。然后把t赋值给slice,现在slice的指向了底层的匿名数组。

  3. 转变成小于容量的append方法。

image.gif

上面的图示描述了大于容量的时候append的操作原理。新生成的切片其依赖的数组和原来的数组就没有关系了,因此在修改新的切片元素,旧的数组也不会有关系。至于临时的切片t,将会被golang的gc回收。当然arr或它衍生的切片都没有应用的时候,也会被gc所回收。

slice和array的关系十分密切,通过两者的合理构建,既能实现动态灵活的线性结构,也能提供访问元素的高效性能。当然,这种结构也不是完美无暇,共用底层数组,在部分修改操作的时候,可能带来副作用,同时如果一个很大的数组,那怕只有一个元素被切片应用,那么剩下的数组都不会被垃圾回收,这往往也会带来额外的问题。

作为函数参数的切片

直接改变切片

回到最开始的问题,当函数的参数是切片的时候,到底是传值还是传引用?从changeSlice函数中打出的参数s的地址,可以看出肯定不是传引用,毕竟引用都是一个地址才对。然而changeSlice函数内改变了s的值,也改变了原始变量slice的值,这个看起来像引用的现象,实际上正是我们前面讨论的切片共享底层数组的实现。

即切片传递的时候,传的是数组的值,等效于从原始切片中再切了一次。原始切片slice和参数s切片的底层数组是一样的。因此修改函数内的切片,也就修改了数组。

image.gif

例如下面的代码:

    slice := make([]int, 2, 3)
    for i := 0; i < len(slice); i++ {
        slice[i] = i
    }

    fmt.Printf("slice %v %p \n", slice, &slice)

    ret := changeSlice(slice)
    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)

    ret[1] = 1111

    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
}

func changeSlice(s []int) []int {
    fmt.Printf("func s %v %p \n", s, &s)
    s = append(s, 3)
    return s
}

输出:

slice [0 1] 0xc42000a1e0 
func s [0 1] 0xc42000a260 
slice [0 1] 0xc42000a1e0, ret [0 1 3] 
slice [0 1111] 0xc42000a1e0, ret [0 1111 3]

从输出可以看出,当slice传递给函数的时候,新建了切片s。在函数中给s进行了append一个元素,由于此时s的容量足够到,并没有生成新的底层数组。当修改返回的ret的时候,ret也共用了底层的数组,因此修改ret的原始,相应的也看到了slice的改变。

append 操作

如果在函数内,append操作超过了原始切片的容量,将会有一个新建底层数组的过程,那么此时再修改函数返回切片,应该不会再影响原始切片。例如下面代码:

 func main() {
    slice := make([]int, 2, 2)
    for i := 0; i < len(slice); i++ {
        slice[i] = i
    }

    fmt.Printf("slice %v %p \n", slice, &slice)

    ret := changeSlice(slice)
    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)

    ret[1] = -1111

    fmt.Printf("slice %v %p, ret %v \n", slice, &slice, ret)
}

func changeSlice(s []int) []int {
    fmt.Printf("func s %v %p \n", s, &s)
    s[0] = -1
    s = append(s, 3)
    s[1] =  1111
    return s
}

输出:

slice [0 1] 0xc42000a1a0 
func s [0 1] 0xc42000a200 
slice [-1 1] 0xc42000a1a0, ret [-1 1111 3] 
slice [-1 1] 0xc42000a1a0, ret [-1 -1111 3]

从输出可以很清楚的看到了我们的猜想。 即函数中先改变s第一个元素的值,由于slice和s都共用了底层数组,因此无论原始切片slice还是ret,第一个元素都是-1.然后append操作之后,因为超出了s的容量,因此会新建底层数组,虽然s变量没变,但是他的底层数组变了,此时修改s第一个元素,并不会影响原始的slice切片。也就是slice[1]还是1,而ret[1]则是-1。最后在外面修改ret[1]为 -1111,也不会影响原始的切片slice。

通过上面的分析,我们大致可以下结论,slice或者array作为函数参数传递的时候,本质是传值而不是传引用。传值的过程复制一个新的切片,这个切片也指向原始变量的底层数组。(个人感觉称之为传切片可能比传值的表述更准确)。函数中无论是直接修改切片,还是append创建新的切片,都是基于共享切片底层数组的情况作为基础。也就是最外面的原始切片是否改变,取决于函数内的操作和切片本身容量。

传引用方式

array和slice作为参数传递的过程基本上是一样的,即传递他们切片。有时候我们需要处理传递引用的形式。golang提供了指针很方便实现类似的功能。

func main() {
    slice := []int{0, 1}
    fmt.Printf("slice %v %p \n", slice, &slice)

    changeSlice(&slice)
    fmt.Printf("slice %v %p \n", slice, &slice)

    slice[1] = -1111

    fmt.Printf("slice %v %p \n", slice, &slice)
}

func changeSlice(s *[]int) {
    fmt.Printf("func s %v %p \n", *s, s)
    (*s)[0] = -1
    *s = append(*s, 3)
    (*s)[1] =  1111
}

输出如下:

slice [0 1] 0xc42000a1e0 
func s [0 1] 0xc42000a1e0 
slice [-1 1111 3] 0xc42000a1e0 
slice [-1 -1111 3] 0xc42000a1e0

从输出可以看到,传递给函数的是slice的指针,函数内对对s的操作本质上都是对slice的操作。并且也可以从函数内打出的s地址看到,至始至终就只有一个切片。虽然在append过程中会出现临时的切片或数组。

总结

golang提供了array和slice两种序列结构。其中array是值类型。slice则是复合类型。slice是基于array实现的。slice的第一个内容为指向数组的指针,然后是其长度和容量。通过array的切片可以切出slice,也可以使用make创建slice,此时golang会生成一个匿名的数组。

因为slice依赖其底层的array,修改slice本质是修改array,而array又是有大小限制,当超过slice的容量,即数组越界的时候,需要通过动态规划的方式创建一个新的数组块。把原有的数据复制到新数组,这个新的array则为slice新的底层依赖。

数组还是切片,在函数中传递的不是引用,是另外一种值类型,即通过原始变量进行切片传入。函数内的操作即对切片的修改操作了。当然,如果为了修改原始变量,可以指定参数的类型为指针类型。传递的就是slice的内存地址。函数内的操作都是根据内存地址找到变量本身。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,185评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,652评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,524评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,339评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,387评论 6 391
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,287评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,130评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,985评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,420评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,617评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,779评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,477评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,088评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,716评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,857评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,876评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,700评论 2 354

推荐阅读更多精彩内容

  • 本文翻译自Rob Pike的文章《Arrays, slices (and strings): The mechan...
    大蟒传奇阅读 4,900评论 2 8
  • 出处---Go编程语言 欢迎来到 Go 编程语言指南。本指南涵盖了该语言的大部分重要特性 Go 语言的交互式简介,...
    Tuberose阅读 18,426评论 1 46
  • 1.安装 https://studygolang.com/dl 2.使用vscode编辑器安装go插件 3.go语...
    go含羞草阅读 1,553评论 0 6
  • 出社会7个年头了,从来没有觉得时间长过,也没正眼看过,或许因为我还在这段青春里,不过没有变成想要得样子。 ...
    且缓缓归矣阅读 233评论 0 1
  • 不久前阅读了IBM前CEO郭士纳的回忆录《谁说大象不能跳舞》,本书回忆了他在IBM公司的十年生涯,1993年他进入...
    星期六早晨阅读 3,259评论 1 3