在这一章中,我们将探讨与基本类型、切片 Slice
和映射 Map
相关的常见错误。但是,关于字符串的内容将在专门讨论该特定数据类型的章节中介绍。
前面几个介绍了常见的整型溢出,浮点数计算 IEEE754 相关的内容,这些其实和 go 关系不大,这里不再进行赘述了。
1. Slice 相关
1.1 不理解切片的长度和容量
1.2 对 nil 和 empty slices 感到疑惑
func main() {
var s []string
log(1, s)
s = []string(nil)
log(2, s)
s = []string{} // 被推荐用于创建带有初始元素的切片
log(3, s)
s = make([]string, 0) // 在需要生成已知长度的切片的情况下,建议使用 make 的方式 s := make([]string, length)
log(4, s)
}
func log(i int, s []string) {
fmt.Printf("%d: empty=%t\tnil=%t\n", i, len(s) == 0, s == nil)
}
上面的输出结果是:
1: empty=true nil=true 2: empty=true nil=true 3: empty=true nil=false 4: empty=true nil=false
其实这里我们也可以看出来,如果想判断一个切片是否为空,可以使用 len 函数进行判断,而不能通过判断是否为 nil,因为 nil 的 slice 长度也是 0,但有两点需要注意:
- nil切片和空切片之间的主要区别之一涉及内存分配。初始化一个nil切片不需要进行任何内存分配,而对于空切片则不然。
- 无论切片是nil还是空,调用append内置函数都可以正常工作。例如,
var s1 []string
fmt.Println(append(s1, "foo")) // [foo]
1.3 错误的切片复制
copy 内置函数允许将源切片的元素复制到目标切片中。虽然它是一个方便的内置函数,但是 Go 开发人员有时会误解它。让我们看一个常见的错误,导致复制错误数量的元素。
在下面的示例中,我们创建了一个切片,并将其元素复制到另一个切片中。这段代码的输出应该是什么?
src := []int{0, 1, 2}
var dst []int // correct: dst := make([]int, len(src))
copy(dst, src)
fmt.Println(dst)
如果我们运行这个示例,它会打印出 [],而不是 [0 1 2]。我们错过了什么?
要有效地使用 copy 函数,重要的是要理解复制到目标切片的元素数量取决于以下两者中的最小值:
- 源切片的长度
- 目标切片的长度
在上面的示例中,源切片 src
的长度是 3,但目标切片 dst
的长度是零,因为它被初始化为其零值。因此,copy 函数会复制最小数量的元素(在 3 和 0 之间取最小值为 0),导致结果切片为空。
1.4 切片 append 引发的副作用
对slice的切分实际上是作用在slice的底层数组上的操作。对一个已存在的slice进行切分操作会创建一个新的slice,但都会指向相同的底层数组。
这一节讨论了在使用 append
时常见的一个错误,它可能在某些情况下产生意想不到的副作用。在下面的例子中,我们初始化了一个 s1
切片,通过对 s1
进行切片创建了 s2
,然后通过在 s2
上追加一个元素创建了 s3
:
s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)
我们初始化了一个包含三个元素的 s1
切片,然后通过对 s1
进行切片创建了 s2
。然后我们在 s3
上调用了 append
。你能猜到在这段代码结束时这三个切片的状态是什么吗?
在第二行之后,s2
被创建后,图显示了内存中这两个切片的状态。s1
是一个长度为三、容量为三的切片,s2
是一个长度为一、容量为二的切片,两者都由我们之前提到的同一个数组支持。使用 append
添加一个元素时,会检查切片是否已满(即长度等于容量)。如果切片未满,append
函数会通过更新支持数组来添加元素,并返回一个长度增加了 1 的切片。
在这个例子中,s2
并不是满的,它可以接受一个更多的元素。下图显示了这三个切片的最终状态。
在支持数组中,我们将最后一个元素更新为 10。因此,如果我们打印所有的切片,将会得到以下输出:
s1=[1 2 10], s2=[2], s3=[2 10]
即使我们没有直接更新 s1[2]
或 s2[1]
,s1
切片的内容也被修改了。我们应该牢记这一点,以避免意外的后果。
让我们通过将切片操作的结果传递给一个函数来看一下这个原则的影响。 在下面的例子中,我们使用三个元素初始化了一个切片,并只将前两个元素传递给一个函数:
func main() {
s := []int{1, 2, 3}
f(s[:2])
// 使用 s
}
func f(s []int) {
// 更新 s
}
在这个实现中,如果 f
更新了前两个元素,这些更改将对 main
中的切片可见。然而,如果 f
调用了 append
,它会更新切片的第三个元素,即使我们只传递了两个元素。例如:
func main() {
s := []int{1, 2, 3}
f(s[:2])
fmt.Println(s) // [1 2 10]
}
func f(s []int) {
_ = append(s, 10)
}
如果出于防御目的,我们希望保护第三个元素,即确保 f
不会对其进行更新,我们有两个选择。
第一种方法是传递切片的副本,然后构造结果切片:
func main() {
s := []int{1, 2, 3}
sCopy := make([]int, 2)
copy(sCopy, s) // 将 s 的前两个元素复制到 sCopy
f(sCopy)
result := append(sCopy, s[2])
// 使用 result
}
func f(s []int) {
// 更新 s
}
因为我们向 f
传递了一个副本,即使这个函数调用了 append
,它也不会在第三个元素范围之外产生副作用。这种方法的缺点是使代码更复杂且阅读起来更困难,并且会添加额外的复制操作,如果切片很大,可能会成为问题。
第二种选择是将潜在副作用的范围限制在前两个元素之内。这种选项涉及所谓的完整切片表达式:s[low:high:max]
。该语句创建一个类似于使用 s[low:high]
创建的切片,但是结果切片的容量等于 max - low
。下面是调用 f
时的示例:
func main() {
s := []int{1, 2, 3}
f(s[:2:2])
// Use s
}
func f(s []int) {
// Update s
}
在这个例子中,传递给 f
的切片不是 s[:2]
而是 s[:2:2]
。因此,切片的容量是 2 - 0 = 2,如下图所示。
当我们传递
s[:2:2]
时,我们可以将副作用的范围限制在前两个元素内,这样做也避免了进行切片复制。
使用切片时,我们必须记住可能导致意外副作用的情况。如果结果切片的长度小于其容量,append
可能会修改原始切片。如果我们想限制可能的副作用范围,可以使用切片复制或完整切片表达式来避免进行复制。
在下一节中,我们将继续讨论切片可能存在内存泄露的问题。
1.5 切片和内存泄露
本节将展示在某些情况下,对现有切片或数组进行切片操作可能会导致内存泄漏。我们将讨论两种情况:一种是容量泄漏,另一种与指针有关。
1.5.1 容量泄漏
对于容量泄漏的情况,让我们想象一下实现一个自定义的二进制协议。一个消息可以包含 100 万字节,其中前 5 个字节表示消息类型。在我们的代码中,我们接收这些消息,并且出于审计目的,我们想要在内存中存储最新的 1,000 个消息类型。以下是我们函数的框架:
func consumeMessages() {
for {
msg := receiveMessage()
// 处理消息
storeMessageType(getMessageType(msg))
}
}
func getMessageType(msg []byte) []byte {
return msg[:5]
}
getMessageType
函数通过对输入切片进行切片操作来计算消息类型。我们测试了这个实现,一切正常。然而,当我们部署应用程序时,我们注意到应用程序消耗了大约 1GB 的内存。这是为什么呢?
在使用 msg[:5]
对 msg
进行切片操作时,会创建一个长度为 5 的切片。然而,它的容量仍然与初始切片相同。剩余的元素仍然在内存中分配,即使最终 msg
不再被引用。让我们看一个消息长度为 100 万字节的示例,如下图所示:
要解决这个问题,我们可以在获取消息类型时进行切片复制,而不是对 msg 进行切片操作:
func getMessageType(msg []byte) []byte {
msgType := make([]byte, 5)
copy(msgType, msg)
return msgType
}
由于我们进行了复制操作,无论收到的消息大小如何,msgType 都是一个长度为 5、容量为 5 的切片。因此,我们每个消息类型只存储了 5 个字节。
那我们是否可以通过完整切片表达式来解决这个问题呢?让我们看一下这个例子:
func getMessageType(msg []byte) []byte {
return msg[:5:5]
}
在这里,getMessageType
返回的是初始切片的缩小版本:一个长度为 5、容量为 5 的切片。但是 GC 能否回收第 5 个字节之后的不可访问空间呢?Go 规范并没有官方指定的行为。然而,通过使用 runtime.Memstats
,我们可以记录内存分配器的统计信息,比如在堆上分配的字节数:
func printAlloc() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%d KB\n", m.Alloc/1024)
}
如果我们在调用 getMessageType
后调用这个函数,并使用 runtime.GC()
强制进行垃圾回收,那么不可访问的空间不会被回收。整个底层数组仍然存在于内存中。因此,使用完整切片表达式不是一个有效的选项(除非未来的 Go 更新解决了这个问题)。
1.5.2切片和指针
我们已经看到,切片的容量可能会导致内存泄漏。但是那些仍然属于底层数组但超出长度范围的元素呢?垃圾回收器是否会对它们进行回收?
让我们通过一个包含字节切片的Foo结构体来探讨这个问题: type Foo struct { v []byte }
我们想要在每个步骤之后检查内存分配情况,步骤如下:
- 分配一个包含1,000个Foo元素的切片。
- 遍历每个Foo元素,为v切片分配1 MB的内存。
- 调用keepFirstTwoElementsOnly函数,该函数使用切片仅返回前两个元素,并进行垃圾回收。
我们想要观察在调用keepFirstTwoElementsOnly函数和进行垃圾回收后内存的行为。以下是在Go中实现该场景的代码(我们重用之前提到的printAlloc函数):
func main() {
foos := make([]Foo, 1000)
printAlloc()
for i := 0; i < len(foos); i++ {
foos[i] = Foo{
v: make([]byte, 1024*1024),
}
}
printAlloc()
two := keepFirstTwoElementsOnly(foos)
runtime.GC()
printAlloc()
runtime.KeepAlive(two)
}
func keepFirstTwoElementsOnly(foos []Foo) []Foo {
return foos[:2]
}
在这个例子中,我们分配了foos
切片,为每个元素分配了1 MB的切片,然后调用了keepFirstTwoElementsOnly
函数并进行了垃圾回收。最后,我们使用runtime.KeepAlive
来保持对这两个变量的引用,以防止它们被垃圾回收。
我们可能期望垃圾回收器回收剩下的998个Foo
元素和为切片分配的数据,因为这些元素无法再被访问。然而,事实并非如此。例如,代码可能输出以下结果:
83 KB
1024072 KB
第一个输出分配了大约83 KB的数据。确实,我们分配了1,000个Foo
的零值。第二个结果每个切片分配了1 MB的内存,从而增加了内存消耗。然而,注意到垃圾回收器在最后一步之后并没有回收剩下的998个元素。原因是什么呢?
在使用切片时,我们需要记住这条规则:如果元素是指针或包含指针字段的结构体,那么这些元素不会被垃圾回收器回收。在我们的例子中,由于Foo
包含一个切片(而切片本质上是对底层数组的指针),剩下的998个Foo
元素及其切片不会被回收。因此,即使这些998个元素无法被访问,只要keepFirstTwoElementsOnly
返回的变量被引用,它们就会一直存在于内存中。
确保不会泄漏剩余的 Foo
元素有两种选择。
第一种选择是创建切片的副本:
func keepFirstTwoElementsOnly(foos []Foo) []Foo {
res := make([]Foo, 2)
copy(res, foos)
return res
}
通过复制切片的前两个元素,GC 知道剩下的 998 个元素不会再被引用,因此可以回收它们。
第二种选择是,如果我们希望保留底层的 1,000 个元素的容量,可以显式将剩余元素的切片标记为 nil
:
func keepFirstTwoElementsOnly(foos []Foo) []Foo {
for i := 2; i < len(foos); i++ {
foos[i].v = nil
}
return foos[:2]
}
在这种情况下,我们返回一个长度为 2、容量为 1,000 的切片,但是我们将剩余元素的切片设置为 nil
。因此,GC 可以回收这 998 个背后的数组。
1.6
1.7
??
- Defer 底层是一个链表