如何避免golang的坑
坑是系统,程序或编程语言中有效的结构,它可以按指定的方式工作,但是以反直觉的方式工作,并且总是引发错误,因为很容易被调用,并且总是触发异常
Go编程语言有一些坑,有很多好文章解释这些坑。我发现这些文章非常重要,特别是对于go新手来说,因为我看到人们经常踩这些坑。
然而有一个问题困扰了我很长时间 - 为什么我从来没踩过这些坑?其中最有名的,像“nil interface”或“slice append”问题从来没有困扰我。我从第一天写go就在某种程度上避免了这些问题。为什么会这样?
答案其实很简单。我很幸运,我读过一些关于Go数据结构的内部表示的文章,并学习过Go内部工作机制的一些基础知识。这些知识足构建避开那些坑的直觉。
记住,“坑是有效的构造,但是是反直觉的”?这就对了。您只有两个选项:
- “修复”语言
- 修正直觉
第二个实际上视为构建直觉会更好。一旦你有一个清晰的认识,如何interface或slice如何工作,几乎是不可能犯这些错误。
这种方式对我起作用,也应该对别人起作用。这就是为什么我决定在这篇文章中收集一些Go内部运行机制的基础知识,并帮助人们建立关于不同结构的内存表示的直觉。
让我们从基本的了解如何在内存中表示事物开始。以下是我们将要学习的内容:
- 指针
- 数组和切片
- Append
- 接口
- 空接口
指针
Go非常接近硬件。 当创建64位整数(int64)变量时,您确切知道它需要多少内存,您可以使用unsafe.Sizeof()计算任何其他类型的大小。
我经常使用内存块的可视化方式来“看到”变量,数组和数据结构的大小。 视觉表示给你一个简单的方法来获得关于类型的直觉,并通常有助于推理其行为和性能。
让我们以显示golang的大多数基本类型来热身:
假设你使用32位机器(现在可能是false),你可以看到int64的内存是int32的两倍。
还有更复杂指针的内部表示,它是内存中的一个块,其中包含内存中的一些其他区域的地址,而该地址存储实际数据。 当你听到花哨的词如“dereferencing a pointer”时,它实际上意味着“通过存储在指针变量中的地址获得实际的内存块”。 你可以想象它是这样的:
存中的地址通常由十六进制值表示,因此图片中的地址是“0x …”。 但是知道“指针的值”可能在一个地方,而“由指针引用的实际数据” - 在另一个地方,将在未来帮助我们避免错误。
现在,Go中的初学者的“坑”之一,是由于没有带指针语言的先验知识,因为功能参数的“值传递”导致的。 你可能知道,在Go中,一切都是通过“值”,举例来说通过复制。一旦你试图可视化这种复制过程就更容易理解了:
在第一种情况下,你复制所有这些内存块 - 在现实中,内存大小通常远远超过2 - 很可能是200万个内存块,你必须复制它们,这是最昂贵的操作之一。 但在第二种情况下,你只复制一个内存块 - 它包含实际数据的地址,非常快并且开销低。
现在,你可以看到,在函数Foo()中修改p不会在第一种情况下修改原始数据,但是在第二种情况下肯定会修改,因为存储在p中的地址引用了原始数据块。
好吧,如果你知道为何了解go内部表示可以帮助你避免常见问题了吧,让我们深入一点。
数组和切片
新手经常混淆切片与数组。 让我们来看看数组。
数组
var arr [5]int
var arr [5]int{1,2,3,4,5}
var arr [...]int{1,2,3,4,5}
数组只是连续的内存块,如果你检查Go运行时源代码(src / runtime / malloc.go),你可能会看到创建一个数组本质上是分配给定大小的一块内存。 类似malloc,只是更聪明:)
// newarray allocates an array of n elements of type typ.
func newarray(typ *_type, n int) unsafe.Pointer {
if n < 0 || uintptr(n) > maxSliceCap(typ.size) {
panic(plainError("runtime: allocation size out of range"))
}
return mallocgc(typ.size*uintptr(n), typ, true)
}
这对我们意味着什么? 这意味着我们可以简单地将数组表示为一组在内存中相邻的块:
数组元素总是用其类型的零值初始化,在[5] int的情况下为0。 我们可以索引它们,并使用len()内置命令获取长度。 当你通过索引引用数组中的单个元素并执行这样的操作时:
var arr [5]int
arr[4] = 42
你正在获取第五(4 + 1)元素并更改其值:
现在我们来探索切片。
切片
第一眼看到切片与数组类似,声明方式真的类似:
var foo []int
但是如果我们去Go源代码(src/runtime/slice.go),我们会看到,Go的切片是具有三个字段的结构体 - 指向数组的指针,长度和容量:
type slice struct {
array unsafe.Pointer
len int
cap int
}
当你创建一个新的切片,Go运行时将创建这个三块对象在内存中的指针设置为nil,len和cap设置为0.让我们直观地表示:
让我们使用make来初始化给定大小的切片:
foo = make([]int, 5)
将创建一个具有5个元素的底层数组的切片,初始化为0,并将len和cap设置为5. Cap意味着容量,并有助于为未来增长预留更多空间。 您可以使用make([] int,len,cap)语法来指定容量。你几乎没有必要设置cap,但重要的是要了解cap的概念。
foo = make([]int, 3, 5)
我们看看两者的图示:
现在,当您更新切片的一些元素时,实际上是更改底层数组中的值。
foo = make([]int, 5)
foo[3] = 42
foo[4] = 100
很简单。 但是,如果你创建另一个子切片并更改一些元素,会发生什么? 咱们试试吧:
foo = make([]int, 5)
foo[3] = 42
foo[4] = 100
bar := foo[1:4]
bar[1] = 99
通过修改bar,你实际修改了底层数组,它也被slice foo引用。你可能写这样的代码:
var digitRegexp = regexp.MustCompile("[0-9]+")
func FindDigits(filename string) []byte {
b, _ := ioutil.ReadFile(filename)
return digitRegexp.Find(b)
}
读10MB的数据到切片,并且只搜索3位,你可以假设你返回3个字节,但实际上,底层数组将保存在内存中。
这可能是你最常见的Go坑之一。 但是一旦你有这种内部片段表示的图像,我敢打赌,几乎不可能会再踩坑!
Append
有一些坑与内置的通用函数append()相关。 append函数本质上做一个操作 - 添加一个值到切片,但在内部它做了很多复杂的工作,以智能和高效的方式分配内存。
让我们来看下面的代码:
a := make([]int, 32)
a = append(a, 1)
记住cap 代表成长的能力。 append检查该切片是否有更多的容量用于增长,如果没有,则分配更多的内存。 分配内存是一个相当昂贵的操作,因此append尝试对该操作进行预估,一次增加原始容量的两倍。 一次分配较多的内存通常比多次分配较少的内存更高效和更快。
由于许多原因,分配更多的内存通常意味着分配新内存并从旧数组拷贝数据到新数组。 这意味着切片中基础数组的地址也将改变。 让我们想象一下:
很容易看到两个底层数组 - 旧的和新的。 旧的数组将被GC释放,除非另一个slice引用它。 这种情况是append的坑之一。 如果你创建子切片b,然后append一个值到a(假设他们都共享共同的底层数组)?
a := make([]int, 32)
b := a[1:16]
a = append(a, 1)
a[2] = 42
你会得到如下结果:
你会有两个不同的底层数组,这对初学者来说可能是相当不经意的。 所以,作为一个经验法则,当你使用子切片,特别是sublices与append时,要小心。
顺便说一下,append通过将它的容量增加一倍来增加slice,最多只有1024,之后它将使用所谓的内存大小类来保证增长不超过〜12.5%。 请求64字节为32字节数组是确定,但如果你的切片是4GB,分配另一个4GB添加1元素是相当昂贵的,所以这是有道理的。
接口
新手需要一些时间来正确使用Go中的接口,特别是在有基于类的语言经验后。 混乱产生原因之一是在接口的上下文中nil关键字的不同含义。
为了帮助理解这个主题,让我们再来看看Go源代码。 这里是一个来自src/runtime/runtime2.go的代码:
type iface struct {
tab *itab
data unsafe.Pointer
}
itab代表接口表,也是一种保存有关接口和底层类型的所需元信息的结构:
type itab struct {
inter *interfacetype
_type *_type
link *itab
bad int32
unused int32
fun [1]uintptr // variable sized
}
我们不会学习接口类型断言如何工作,重要的是理解接口是接口和静态类型信息的复合,加上指向实际变量(iface中的字段数据)的指针。 让我们创建error接口的变量err并直观地表示它:
var err error
事实上,你在这张图片中看到的是nil接口。 当在返回error类型的函数中返回nil时,将返回此对象。 该对象中有关于接口(itab.inter)的信息,但在data和itab.type字段中为nil。 此对象将在if err == nil {}条件中求值为true。
func foo() error {
var err error // nil
return err
}
err := foo()
if err == nil {...} // true
臭名昭著的坑是返回一个* os.PathError变量,它是nil。
func foo() error {
var err *os.PathError // nil
return err
}
err := foo()
if err == nil {...} // false
这两段代码很相似,除非你知道接口内部是什么结构。 让我们继续用图表示这个 os.PathError类型(包装了error接口)的nil变量:
![](https://cdn-images-1.medium.com/max/800/01cosW9WkQq1AqKHm.png)
你可以清楚地看到* os.PathError变量 - 它只是一个值为nil内存块。 但是我们foo()函数返回值实际错误是一个非常复杂的结构,包含相关接口的信息,该块内存的底层类型和内存地址,并都设置为nil。
在这两种情况下,我们都返回nil,但“有一个变量的值等于nil的接口”和“没有变量的接口”之间有一个巨大的区别。 有了这些接口的内部结构的知识,就可以弄清楚以下两个例子:
现在更难踩坑了吧。
空接口
关于空接口 - interface {}。 在Go源代码(src/runtime/ malloc.go它实现使用自己的结构 - eface:
type eface struct {
_type *_type
data unsafe.Pointer
}
正如你看到的,它类似于iface,但缺乏接口表。 它不需要一个接口表,因为空接口可以是由任何静态类型实现。 所以当你包装一些东西 - 显式或隐式(通过传递作为一个函数的参数,例如) - 到interface {},你实际上使用这个结构:
func foo() interface{} {
foo := int64(42)
return foo
}
interface{}相关的坑之一你不能轻易地分配接口切片的具体类型,反之亦然。 就像是这样:
func foo() []interface{} {
return []int{1,2,3}
}
编译时会报错:
$ go build
cannot use []int literal (type []int) as type []interface {} in return argument
为什么我可以在单变量上做这个转换,但不能切片上做同样的事情? 但是一旦你知道什么是空接口(再看看上面的图片),过程就变得很清楚,这个“转换”实际上是一个相当昂贵的操作,涉及分配一堆内存。 Go设计中常用的方法之一是“如果你想做一些昂贵的操作- 明确地做”。
希望以上内容对你有意义。
结论
不是每个坑都可以通过学习内部机制避免。 其中一些坑只是和你过去的经验不同,我们都有某种不同的背景和经验。 然而,有很多的坑,可以简单地通过了解Go如何工作而成功避免。 我希望这篇文章中的解释将帮助你建立起程序内部机制的直觉,并将使你成为一个更好的开发人员。 Go是你的朋友,对它了解的越多越好。
如果你有兴趣阅读更多关于Go内部机制,这里有一个链接列表帮助你:
有用之物的永恒来源:)
- Go source code
- Effective Go
- GO spec
有疑问加站长微信联系(非本文作者)