[译]如何避免golang的坑

Posted GoCN

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[译]如何避免golang的坑相关的知识,希望对你有一定的参考价值。

如何避免golang的坑

坑是系统,程序或编程语言中有效的结构,它可以按指定的方式工作,但是以反直觉的方式工作,并且总是引发错误,因为很容易被调用,并且总是触发异常

Go编程语言有一些坑,有很多好文章解释这些坑。我发现这些文章非常重要,特别是对于go新手来说,因为我看到人们经常踩这些坑。

然而有一个问题困扰了我很长时间 - 为什么我从来没踩过这些坑?其中最有名的,像“nil interface”或“slice append”问题从来没有困扰我。我从第一天写go就在某种程度上避免了这些问题。为什么会这样?

答案其实很简单。我很幸运,我读过一些关于Go数据结构的内部表示的文章,并学习过Go内部工作机制的一些基础知识。这些知识足构建避开那些坑的直觉。

记住,“坑是有效的构造,但是是反直觉的”?这就对了。您只有两个选项:

  • “修复”语言
  • 修正直觉

第二个实际上视为构建直觉会更好。一旦你有一个清晰的认识,如何interface或slice如何工作,几乎是不可能犯这些错误。

这种方式对我起作用,也应该对别人起作用。这就是为什么我决定在这篇文章中收集一些Go内部运行机制的基础知识,并帮助人们建立关于不同结构的内存表示的直觉。

让我们从基本的了解如何在内存中表示事物开始。以下是我们将要学习的内容:

  • 指针
  • 数组和切片
  • Append
  • 接口
  • 空接口

指针

Go非常接近硬件。 当创建64位整数(int64)变量时,您确切知道它需要多少内存,您可以使用unsafe.Sizeof()计算任何其他类型的大小。

我经常使用内存块的可视化方式来“看到”变量,数组和数据结构的大小。 视觉表示给你一个简单的方法来获得关于类型的直觉,并通常有助于推理其行为和性能。

让我们以显示golang的大多数基本类型来热身:

假设你使用32位机器(现在可能是false),你可以看到int64的内存是int32的两倍。

现在,Go中的初学者的“坑”之一,是由于没有带指针语言的先验知识,因为功能参数的“值传递”导致的。 你可能知道,在Go中,一切都是通过“值”,举例来说通过复制。一旦你试图可视化这种复制过程就更容易理解了:

好吧,如果你知道为何了解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尝试对该操作进行预估,一次增加原始容量的两倍。 一次分配较多的内存通常比多次分配较少的内存更高效和更快。

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

在这两种情况下,我们都返回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内部机制,这里有一个链接列表帮助你:

有用之物的永恒来源:)