使用Golang向切片中添加元素

90

为什么切片 a 保持不变?append() 是否会生成新的切片?

package main

import (
    "fmt"
)

var a = make([]int, 7, 8)

func Test(slice []int) {
    slice = append(slice, 100)
    fmt.Println(slice)
}

func main() {
    for i := 0; i < 7; i++ {
        a[i] = i
    }

    Test(a)
    fmt.Println(a)
}

输出:

[0 1 2 3 4 5 6 100]
[0 1 2 3 4 5 6]

3
这段代码将解释发生了什么:https://play.golang.org/p/eJYq65jeqwn。函数 func Test(slice []int) 接收了 a 的一个值的副本,而它指向了和 a 指向同一个数组。 - gihanchanuka
1
slice 是一个按值传递而不是按引用或指针传递的结构体。等号只是打破了 Test 中这些 slice 的链条。 - Izana
与 C 家族中的所有语言一样,Go 中的所有内容都是按值传递的。这意味着对于切片类型,传递指针值会复制指针,但不会复制它所指向的数据。特别重要的是要返回切片,因为当它重新分配时,结果切片描述了一个完全不同的数组。 - Jerry An
14个回答

68
在你的例子中,Test函数的slice参数接收了调用者作用域中变量a副本
由于切片变量保存的是一个"切片描述符",仅仅是对底层数组的引用,因此在你的Test函数中,你多次修改了slice变量中保存的切片描述符,但这并不会影响调用者及其a变量。

Test函数内,第一个append重新分配了slice变量下的后备数组,将其原始内容复制过来,将100添加到其中,这就是您所观察到的。退出Test后,slice变量会超出范围,因此它所引用的(新)底层数组也会超出范围。Jeff Lee正确指出这不是真正发生的事情,因此以下更新版本;正如他正确指出的那样,this answer是正确的,但可能有点太简洁了。)

Test函数外部,分配了一个长度为7、容量为8的切片,并填充了其中的7个元素。
Test函数内部,第一个append看到切片的容量仍然比其长度大一个元素,也就是说,还有一个元素可以添加而无需重新分配。 因此,它“消耗”了剩余的那个元素,并将100放入其中,之后它会调整切片描述符的副本中的长度,使其等于切片的容量。 这不会影响调用者作用域中的切片描述符。
这就是您观察到的现象。退出Test后,slice变量会超出作用域,因此所引用的(新的)基础数组也会超出作用域。
如果要使Testappend一样运行,必须从中返回新的切片,就像append一样,并要求Test的调用者以与使用append相同的方式使用它:
func Test(slice []int) []int {
    slice = append(slice, 100)

    fmt.Println(slice)

    return slice
}

a = Test(a)

请仔细阅读这篇文章,因为它基本上向您展示了如何手动实现append,在解释切片内部工作原理之后。然后阅读这篇文章

7
我认为这个描述在微妙的方面上是不正确的。@doun在下面的答案实际上更准确地表示了内部正在发生的事情:Test中的append不会重新分配任何内存,因为支持切片a的数组的原始分配仍然可以容纳一个额外的元素。换句话说,按照这个程序的编写方式,Test(a)a的返回值是具有不同长度的不同切片头,但它们指向完全相同的基础数组。在main函数的最后一行打印fmt.Println(a[:cap(a)])可以澄清这一点。 - Jeff Lee
为什么不是所有指向旧数组的切片都被更新到新数组呢?由于Go是垃圾回收的,它应该有一个包含某个给定值的所有指针的列表,或者这太简化了吗? - fweth
@fweth,这有点复杂,从多个层面来看。首先,我不知道任何非玩具语言实现会为了进行GC而保留指向给定(共享)值的指针列表;相反,这些实现通常采用“引用计数”——每个值都带有一个隐藏的计数器,每当一个值获得新的活动引用时,该计数器就会增加,每当这样的引用丢失时,该计数器就会减少,并在引用计数从1降至0时被销毁——或某种“扫描”方法,当GC从一组“根”值开始时... - kostix
@fweth,其次,Go中切片的实现实际上在一组相互排斥的要求之间取得平衡。最好从理解切片主要是一个数组的视图开始——一种窗口,通过它可以方便地访问底层数组内存的单个(连续的)子集,而无需显式地携带指针和长度(或偏移量和长度)——就像在C等语言中通常做的那样。 - kostix
@fweth,这就是为什么所有从同一后备数组创建的切片(或通过其他切片查看相同数组的视图)共享同一数组(查看相同数据)。但是,语言中也有一些方法可以轻松管理可增长的内存块,并以某种简单的方式增长,以便不编写许多条件语句并显式调用“重新分配”函数。这种方法也被集成到切片的实现中:如果您向切片添加内容,并且其后备数组中没有更多数据的空间,... - kostix
显示剩余9条评论

41

典型的append用法是

a = append(a, x)

因为append可能会就地修改其参数,或者返回一个带有额外条目的参数副本,这取决于其输入的大小和容量。使用之前追加过的切片可能会产生意外结果,例如:

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

可以打印

[1 2 3 4]
[1 2 3 5]

1
谢谢,larsmans,我修改了一些代码。 "make"会给足够的容量。结果是相同的,但我仍然感到困惑。 - Pole_Zhang
2
现在需要将其更改为_ = append(a[:3], 5)才能编译 - Josh Hibschman
在以下的hack中,append(a[:3], 5)a[3] = 5是相同的。我猜这是一个意外惊喜的例子。 - Izana

13
为了使您的代码在不必从Test返回切片的情况下工作,您可以像这样传递一个指针:
package main

import (
    "fmt"
)

var a = make([]int, 7, 8)

func Test(slice *[]int) {
    *slice = append(*slice, 100)

    fmt.Println(*slice)
}

func main() {

    for i := 0; i < 7; i++ {
        a[i] = i
    }

    Test(&a)

    fmt.Println(a)
}

6

注意,如果cap不足,则append会生成一个新的切片。@kostix的回答是正确的,或者您可以通过指针传递slice参数!


1
你关于指针的观点是正确的,但我故意没有提到它们,因为切片的发明主要是为了让程序员不必再处理数组指针。在一个参考实现(来自Go)中,一个切片变量包含一个指针和两个整数,所以复制它很便宜,这就是为什么 slice = append(slice, a, b, c) 是惯用的方式,而不是通过指针传递切片变量并“原地”修改它,以便调用者看到更改。 - kostix
2
@kostix 你说得对,代码的目的应该是明确的。但我认为整个故事只是关于传递存储指针的值和传递指向指针的指针。如果我们修改引用,两者都可以工作,但如果我们替换引用,第一个就失去了效果。程序员应该知道自己在做什么。 - Gizak

5
尝试这个,我认为它能让你清楚地了解。底层数组已经改变,但是我们的切片没有改变,print 只打印了len()个字符,通过另一个切片到cap(),你可以看到更改后的数组:
func main() {

  for i := 0; i < 7; i++ {
      a[i] = i
  }

  Test(a)

  fmt.Println(a) // prints [0..6]
  fmt.Println(a[:cap(a)] // prints [0..6,100]
}

所以,'a'和'a[:cap(a)]'是不同的切片吗? - Pole_Zhang
2
是的,如果你运行这段代码,你会发现,因为在调用test(a)时,cap(a)被改变了。 - doun

3

说明(请读内联注释):


package main

import (
    "fmt"
)

var a = make([]int, 7, 8)
// A slice is a descriptor of an array segment. 
// It consists of a pointer to the array, the length of the segment, and its capacity (the maximum length of the segment).
// The length is the number of elements referred to by the slice.
// The capacity is the number of elements in the underlying array (beginning at the element referred to by the slice pointer).
// |-> Refer to: https://blog.golang.org/go-slices-usage-and-internals -> "Slice internals" section

func Test(slice []int) {
    // slice receives a copy of slice `a` which point to the same array as slice `a`
    slice[6] = 10
    slice = append(slice, 100)
    // since `slice` capacity is 8 & length is 7, it can add 100 and make the length 8
    fmt.Println(slice, len(slice), cap(slice), " << Test 1")
    slice = append(slice, 200)
    // since `slice` capacity is 8 & length also 8, slice has to make a new slice 
    // - with double of size with point to new array (see Reference 1 below).
    // (I'm also confused, why not (n+1)*2=20). But make a new slice of 16 capacity).
    slice[6] = 13 // make sure, it's a new slice :)
    fmt.Println(slice, len(slice), cap(slice), " << Test 2")
}

func main() {
    for i := 0; i < 7; i++ {
        a[i] = i
    }

    fmt.Println(a, len(a), cap(a))
    Test(a)
    fmt.Println(a, len(a), cap(a))
    fmt.Println(a[:cap(a)], len(a), cap(a))
    // fmt.Println(a[:cap(a)+1], len(a), cap(a)) -> this'll not work
}

输出:

[0 1 2 3 4 5 6] 7 8
[0 1 2 3 4 5 10 100] 8 8  << Test 1
[0 1 2 3 4 5 13 100 200] 9 16  << Test 2
[0 1 2 3 4 5 10] 7 8
[0 1 2 3 4 5 10 100] 7 8

参考文献1:https://blog.golang.org/go-slices-usage-and-internals

这篇文章介绍了Golang中slice的用法和内部实现原理,对于IT技术人员来说是一篇很有价值的文章。
func AppendByte(slice []byte, data ...byte) []byte {
    m := len(slice)
    n := m + len(data)
    if n > cap(slice) { // if necessary, reallocate
        // allocate double what's needed, for future growth.
        newSlice := make([]byte, (n+1)*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:n]
    copy(slice[m:n], data)
    return slice
}

3
答案:如果没有足够的容量,append()会返回新的基础数组。在你的例子中,
var a = make([]int, 7, 8)

您将一个长度为7的切片(底层数组容量为8)分配给a,然后将其作为参数slice传递给函数。当调用append()时,它发现容量为1,然后只需将slicelen从7更新为8,并将值100放入该位置。

切片aslice不同,具有不同的len属性。lencap是切片的属性,而不是底层数组的属性。更多详情请查看:Are slices passed by value?.

运行下面的示例:

package main

import (
    "fmt"
)

var a = make([]int, 7, 8)

func Test(slice []int) {
    fmt.Printf("slice's address is %p\n", &slice)
    fmt.Println("slice: cap=",cap(slice),"len=",len(slice))
    slice = append(slice, 100)
    fmt.Println("slice: cap=",cap(slice),"len=",len(slice))
    fmt.Println(slice)
}

func main() {
    for i := 0; i < 7; i++ {
        a[i] = i
    }
    fmt.Printf("a's address is %p\n", &a)
    fmt.Println("a: cap=",cap(a),"len=",len(a))
    Test(a)
    fmt.Println("a: cap=",cap(a),"len=",len(a))
    fmt.Println(a)
    fmt.Println(a[:8]) // manully extend a's len to cap of 8
}

结果是:

❯❯  Temp  17:33  go run .\test.go
a's address is 0x2cbfc0
a: cap= 8 len= 7
slice's address is 0xc000098060
slice: cap= 8 len= 7
slice: cap= 8 len= 8
[0 1 2 3 4 5 6 100]
a: cap= 8 len= 7
[0 1 2 3 4 5 6]
[0 1 2 3 4 5 6 100]

2
package main

import (
    "fmt"
)

func a() {
    x := []int{}
    x = append(x, 0)
    x = append(x, 1)  // commonTags := labelsToTags(app.Labels)
    y := append(x, 2) // Tags: append(commonTags, labelsToTags(d.Labels)...)
    z := append(x, 3) // Tags: append(commonTags, labelsToTags(d.Labels)...)
    fmt.Println(y, z)
}

func b() {
    x := []int{}
    x = append(x, 0)
    x = append(x, 1)
    x = append(x, 2)  // commonTags := labelsToTags(app.Labels)
    y := append(x, 3) // Tags: append(commonTags, labelsToTags(d.Labels)...)
    z := append(x, 4) // Tags: append(commonTags, labelsToTags(d.Labels)...)
    fmt.Println(y, z)
}

func main() {
    a()
    b()
}

First guess could be

[0, 1, 2] [0, 1, 3]
[0, 1, 2, 3] [0, 1, 2, 4]

but in fact it results in

[0, 1, 2] [0, 1, 3]
[0, 1, 2, 4] [0, 1, 2, 4]

在此输入图片描述

在此输入图片描述

更多细节请参见https://allegro.tech/2017/07/golang-slices-gotcha.html


2
Go采取更加精简和懒惰的方式来处理这个问题。它会不断修改相同的基础数组,直到切片的容量达到上限。
参考:http://criticalindirection.com/2016/02/17/slice-with-a-pinch-of-salt/ 链接中的示例输出解释了Go中切片的行为。
创建切片a。
Slice a len=7 cap=7 [0 0 0 0 0 0 0]

Slice b指的是在slice a中索引为2、3、4的元素,因此其容量为5(=7-2)。
b := a[2:5]
Slice b len=3 cap=5 [0 0 0]

修改切片b也会修改切片a,因为它们指向同一基础数组。
b[0] = 9
Slice a len=7 cap=7 [0 0 9 0 0 0 0]
Slice b len=3 cap=5 [9 0 0]

在切片 b 后添加 1。这将覆盖 a。
Slice a len=7 cap=7 [0 0 9 0 0 1 0]
Slice b len=4 cap=5 [9 0 0 1]

在切片 b 后添加 2。这将覆盖 a。
Slice a len=7 cap=7 [0 0 9 0 0 1 2]
Slice b len=5 cap=5 [9 0 0 1 2]

将数字3添加到切片b中。由于超载了容量,这里会创建一个新的副本。
Slice a len=7 cap=7 [0 0 9 0 0 1 2]
Slice b len=6 cap=12 [9 0 0 1 2 3]

在先前的步骤中,容量超载后,验证切片a和b指向不同的基础数组。
b[1] = 8
Slice a len=7 cap=7 [0 0 9 0 0 1 2]
Slice b len=6 cap=12 [9 8 0 1 2 3]

1
我认为原始答案并不完全正确。即使底层数组已更改但仍由两个切片共享,append()会同时更改切片和底层数组。
根据Go Doc的规定:
“切片不存储任何数据,它只描述底层数组的一部分。”(链接) 切片只是数组的包装器值,意味着它们包含有关如何切片底层数组的信息,用于存储一组数据。因此,默认情况下,传递给另一个方法的切片实际上是按值传递的,而不是引用/指针,尽管它们仍将使用相同的底层数组。通常,数组也是按值传递的,因此我认为切片指向底层数组而不是将其存储为值。关于您的问题,当您将切片传递给以下函数时:
func Test(slice []int) {
    slice = append(slice, 100)
    fmt.Println(slice)
}

你实际上通过将切片的副本与指向同一基础数组的指针一起传递。这意味着,你对 slice 所做的更改并没有影响到 main 函数中的那个切片。它是切片本身存储有关其切割和公开给公众的数组的信息。因此,当你运行 append(slice, 1000) 时,同时扩展了基础数组,也更改了 slice 的切片信息,而这些信息在你的 Test() 函数中被保留为私有。
然而,如果你按照以下方式更改代码,它可能会起作用:
func main() {
    for i := 0; i < 7; i++ {
        a[i] = i
    }

    Test(a)
    fmt.Println(a[:cap(a)])
}

原因是您通过在其已更改的基础数组上使用Test()函数扩展了a,并说出a[:cap(a)]。如此指定:

您可以通过重新切片来扩展切片的长度,前提是它具有足够的容量。(链接)


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接