Go语言 向nil切片追加一个元素可将容量增加两倍

wi3ka0sx  于 2023-03-16  发布在  Go
关注(0)|答案(5)|浏览(434)

我有一个零分:

var s1 []int // len(s1) == 0, cap(s1) == 0

我在其中添加了一个元素:

s2 := append(s1, 1) // len(s2) == 1, cap(s2) == 2

为什么将一个元素附加到nil切片会使容量增加2?

使用fmt.Printf打印切片显示如下:

[] // s1
[1] // s2

我也很困惑为什么重新切片s2[0:2]会显示一个零,而这个零既不在原始切片中,也没有附加到原始切片中:

[1,0] // s2[0:2]
dsekswqp

dsekswqp1#

Go可以自由地为你提供比你请求的更多的空间,这可以通过减少所需的分配(和可能的复制)数量来提高性能,而容量只是在需要另一个分配之前所保留的空间量。
如果你在这个切片上添加5个元素,至少在我的实验中,容量是8。这不应该令人惊讶,但也不应该被依赖。在不同的平台上,或者不同版本的编译器上,实际结果可能会不同,只要容量“足够大”(等于或大于length)。
切片的索引上限定义为其容量:
对于数组或字符串,如果0〈= low〈= high〈= len(a),则索引在范围内,否则超出范围。对于切片,索引上限是切片容量上限(a),而不是长度。常量索引必须为非负数,并且可以用int类型的值表示;对于数组或常量字符串,常量索引也必须在范围内。2如果两个索引都是常量,它们必须满足low〈= high。3如果索引在运行时超出范围,则会发生运行时异常。
这就是为什么读取超过该长度的数据不会导致死机。即使如此,您也不应该将这些零视为切片的一部分。它们可以通过切片索引,但fmt.Printf(s2)不会正确地显示它们,因为它们不是切片的一部分。不要以这种方式添加下标。
一般来说,您希望查看长度而不是容量,容量大多数是可读的,以帮助优化性能。

lawou6xi

lawou6xi2#

我认为这里关于容量和长度有点混乱。当你打印切片并看到切片中零个或一个元素时,你看到的是它的length,也就是切片实际包含的值的数量。底层数组的容量通常是隐藏的,除非你用cap()内置函数查找它。
实际上,切片是固定长度的数组,当切片空间用完时,Go语言必须通过创建一个新的(更长的)数组,并从旧的数组复制所有的值。如果要向一个切片添加很多值,为新值分配内存会非常慢(并复制所有旧的)每一次,所以有时候Go语言会假设你要追加更多的元素,然后分配更多的内存,这样你就不用频繁地拷贝了.这些额外的内存可以在下次调用append时使用,在扩展切片之前,切片 * 可以 * 存储的值的数量称为它的capacity。换句话说,切片的容量就是支持数组的切片的长度。并且切片的长度与容量无关。
当你给切片添加一个值时,Go语言发现它必须为这个值分配空间,所以它分配了两倍于实际需要的空间,长度增加了1,容量增加了2。
您提到的切片是因为切片作用于底层数组:Go语言允许你在切片的长度之外切片,但是不能超出它的容量(底层数组的长度),例如,让我们在一个简单的nil切片上做一些事情:

var s []int
fmt.Println(len(s), cap(s)) // Prints `0 0` because this is a nil slice
s = append(s, 1)
fmt.Println(len(s), cap(s)) // Prints `1 2`
fmt.Println(s[0:2])         // Prints `[1 0]` (only the first value is part of the slice, the second position in the underlying array is a zero value that is waiting to be used when the slices length grows)
fmt.Println(s[0:3])         // panic: slice bounds out of range (because we've exceeded the slices capacity)
gj3fmq9x

gj3fmq9x3#

切片零值

切片的零值是nil,但这并不意味着你不能对它做任何事情。在Go语言中,类型实际上可以在值为nil时使用。例如,即使指针接收器为nil,也可以调用struct方法。下面是一个例子:

package main

import "fmt"

type foo struct {
}

func (f *foo) bar() {
    fmt.Println(1)
}

func main() {
    var f *foo
    fmt.Println(f)
    f.bar()
}

playground
这同样适用于切片。lencapappend都可以工作,即使你传递了一个nil切片。对于append,它基本上为你创建了一个新的切片,指向一个保存值的数组。

切片容量增长

当你添加了一个元素,并且你需要为它分配更多的空间时,你不是只为一个元素分配空间,这是非常低效的,相反你分配的空间比实际需要的要多。
具体分配多少取决于实现,语言规范中没有定义。通常容量是双倍的,但在Go语言中,至少从v1.5开始,它会舍入到分配的内存块。你可以找到源代码here的链接。

切片超过长度

实际上支持超过长度的切片,可以切片超过切片的长度,但不能切片超过容量:
前面我们把s切成比它的容量短的长度,我们可以通过再次把s切成比它的容量大的长度:
切片不能增长到超出其容量。尝试这样做将导致运行时异常,就像在切片或数组的边界之外进行索引一样。同样,切片不能重新切片到零以下以访问数组中的较早元素。
https://blog.golang.org/go-slices-usage-and-internals
在你的例子中,底层数组的容量为2,你只追加了一个元素,所以另一个元素的值为0,当你重新切片超过长度时,Go语言可以识别出这个切片已经有了所需的容量,所以它返回一个新的切片,指向同一个数组,但是长度值设置为2,下面是一个例子来说明它是如何工作的:

package main

import "fmt"

func main() {
    var s []int
    s = append(s, 1, 2)
    fmt.Println(s, cap(s))

    s = s[:1]
    fmt.Println(s, cap(s))

    s = s[:2]
    fmt.Println(s, cap(s))
}

playground
它将打印
[1个2个]
[1]第二章
[1个2个]
您可以看到,即使我重新切片为更小的长度,第二个元素仍然保留。

kyxcudwk

kyxcudwk4#

容量增长不受用户控制

append(s S, x ...T) S  // T is the element type of S

如果s的容量不足以容纳附加值,append将分配一个新的、足够大的基础数组,该数组可以容纳现有的slice元素和附加值。否则,append将重新使用基础数组。
参考:https://golang.org/ref/spec#Appending_and_copying_slices
并参见:https://golang.org/doc/effective_go.html#append

**不增加2(性能优化):

测试样本代码,初始容量为5字节,则为16字节,而不是10字节(参见注解输出):**

package main

import "fmt"

func main() {
    s := []byte{1, 2, 3, 4, 5}
    fmt.Println(cap(s)) // 5
    s = append(s, s...)
    fmt.Println(cap(s)) // 16
}

测试样本代码(带有注解输出):

package main

import (
    "fmt"
)

func main() {
    s := []int{0}
    fmt.Println(cap(s)) // 1
    s = append(s, s...)
    fmt.Println(cap(s)) // 2
}

测试样本代码(带有注解输出):

package main

import (
    "fmt"
)

func main() {
    s := []int{}
    fmt.Println(cap(s)) // 0
    s = append(s, 1)
    fmt.Println(cap(s)) // 1
}

nil切片的测试示例代码(带有注解输出):

package main

import (
    "fmt"
)

func main() {
    var s []int
    fmt.Println(cap(s)) // 0
    s = append(s, 1)
    fmt.Println(cap(s)) // 1
}

您的示例代码(带有注解输出):

package main

import "fmt"

func main() {
    var s1 []int
    s2 := append(s1, 1)
    fmt.Println(cap(s1)) // 0
    fmt.Println(cap(s2)) // 1
}

5个int的测试示例代码(带有注解输出):

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4, 5}
    fmt.Println(cap(s)) // 5
    s = append(s, s...)
    fmt.Println(cap(s)) // 10
}

**不能访问s2[1]:**等片的未初始化索引

死机:运行时错误:切片边界超出范围:
测试样本代码(带有注解输出):

package main

import "fmt"

func main() {
    var s1 []int
    s2 := append(s1, 1)

    fmt.Println(cap(s1)) // 0
    fmt.Println(cap(s2)) // 1
    fmt.Println(s1)      // []
    fmt.Println(s2)      // [1]

    //fmt.Println(s2[0:2]) //panic: runtime error: slice bounds out of range
    //fmt.Println(s2[1])   //panic: runtime error: slice bounds out of range

}

边界检查消除(BCE)是去除冗余边界检查的一个通用术语。通常情况下,当一个切片或一个字符串在其边界之外被访问时,go程序会出现异常。有两种类型的边界检查:用于索引(a[i])和切片(a[i:j]),go编译器在每次访问时都会插入这些边界检查,但在大多数情况下,它们是不需要的,并且基于上下文是冗余的。
边界检查很重要,因为它提供了对缓冲区溢出攻击的防御,并及早捕获常见的编程错误。BCE很重要,因为:它加速了代码,使二进制文件更小。如果二进制文件被边界检查拖慢,那么开发人员将有动机禁用边界检查(使用-gcflags=-B)。
参考

3df52oht

3df52oht5#

这里有很多很棒的答案,但我想给予你一个简短的版本:在这种特殊的情况下,Go语言比程序员更清楚。
忽略规范并关注 * 推理 *,必须在实现级别做出关键的微调决策,因此,分配超出需要的内存块并浪费额外空间比分配新内存块并复制所有数据要好。

  • 内存分配器不太可能处理小于某个边界的大小,比如32或64字节--所以它应该使用整个块。
  • 现在内存很便宜
  • 如果您正在处理大量这样的切片,而浪费内存是一个问题,那么您需要对代码进行微调。

只是为了好玩

下面是一个切片的内部外观:https://go.dev/play/p/upvwbXTPwqw

type GoSlice struct {
    Ptr uintptr
    Len int
    Cap int
}

你可以看到数组和切片是分开存储的,这样当你超出容量时,它只需要分配一个新的数组,复制你的数据并改变指针。

相关问题