在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
维基百科对「坑」的定义(原文中叫
Go 语言有一些我们常说的「坑」,有不少优秀的文章讨论过这些「坑」。这些文章所讨论的东西非常重要,尤其对 Go 的初学者来说,一不小心就掉进了这些「坑」里。 但有个问题让我困惑了很久,为什么我几乎没碰到过这些文章里讨论的大部分「坑」?真的,大多数比较知名的比如 “nil interface” 或者 “slice append” 等我从来就没觉得困惑过。我从开始使用 Go 一直到现在总是以某种方式避开了这些形形色色的问题。 后来发现,我足够幸运的读了不少解释 Go 数据结构内部实现的文章并且学习了一些 Go 内部运行原理的基础知识。这些知识足够让我对 Go 有了深刻的认识,同时也避免了掉进各种各样的坑里。
记住维基百科的定义,「坑 是…有效的构造…但同时是反直觉的」
第二种显然是更好的选择,一旦你脑中有了一副清晰的图像描绘了切片或者接口在底层是如何运作的,根本不可能再掉进那些陷阱里。 这样的学习方式对我而言是有用的,我想对其他人也同样适用。这也是为什么我决定在这篇文章里整理一些关于 Go 内部实现的基础知识,希望能帮助其他人对各种数据结构在内存中的表示建立起清晰的直觉。 让我们从一些基础的开始: 指针 (Pointers)
Go 实际上是一门在层次上非常接近硬件的语言。当你创建一个 64 位的整形变量( 我经常用可视化的内存块来「看」这些变量、数组和数据结构的大小。视觉上的展示可以让人更直观的理解这些类型,也便于解释一些行为和性能上的问题。 作为热身,我们先对 Go 里最基础的类型做可视化:
假设你在一台 32 位的机器上 (我知道现在你可能只有 64 位的机器了…), 可以清楚的看到 指针的内部表示稍微复杂一点,它占用一块内存,包含了一个指向其他内存块的内存地址,这个地址存储着实际的数据。有个术语叫 『引用指针』 ,实际上它指的是 『通过存储在指针变量里的地址取到实际指向的内存块』。可以想象一下指针在内存中的表示: 地址在内存里通常用十六进制表示,像图中标识的 “0x…” 这样。先记住,『指针的值』存放在一个地方,『指针指向的数据』存放在另一个地方,这一点会有助于我们后面的理解。
对于没有指针相关知识的 Go 新手来说,很容混淆值函数参数的『值传递』。你可能已经知道,Go 里所有的传参都是『按值』传递,也就是通过复制来实现传参。 在第一个例子里,复制了所有的内存块 - 但实际情况里用到的变量基本都会超过 2 个甚至 200 万个内存块,如果全部都复制一份的话将会是成本非常高的操作。而在第二个例子里,只需要复制包含了实际数据内存地址的那一块内存,这样做非常高效而且成本很低。
显然,第一个例子里如果改变 理解了关键的内部实现将会帮你避开大多数的坑,接下来让我们再深入一点。 数组和切片 (Arrays and Slices)初学的时候一般都会对切片和数组感到混淆和困惑。所以我们先来看看数组。 数组 (Arrays)
数组只是连续的内存块,如果你去阅读 Go 运行时的源码(src/runtime/malloc.go),你会发现创建一个数组本质上就是分配了一块指定大小的内存。是不是想到了经典的
这意味着我们可以简单的用一组通过指针连接起来的内存块来表示一个数组:
数组元素总是会初始化为指定类型的 零值,在我们的例子里, 当你通过下标索引到数组里的某个元素并且做下面这样的操作时:
你会取到第五个(4+1)元素并且改变它的值: 现在,我们已经准备好来探索一下切片。 Slices切片一眼看上去和数组很像,就连声明的语法也差不多:
但如果我们阅读一下 Go 的源码就会发现(src/runtime/slice.go)实际上切片的数据结构包括 3 个部分 - 指向数组的指针、切片的长度和切片的容量:
当创建一个新的切片时,Go 运行时会在内存里创建这样一个包含 3 块区域的对象,并且会把数组指针初始化为
可以用
这段代码会创建一个切片,包含了一个 5 个元素的数组,每个元素的初值为 0,
下面是两个例子: 如果你要更改切片中某些元素的值,实际上是在改变切片指向的数组元素的值。
这很好理解。让我们把情况弄得稍微复杂一点,在原切片的基础上创建一个子切片,然后改变子切片里元素的值?
通过图示可以看到,我们更改了
假设我们读取了 这关于 Go 切片的一个很常见的坑,你很可能无法准确的预料为了使用这个切片到底耗费了多少内存。但一旦你脑海里有了关于切片内部实现的可视化表示,我敢打赌几乎下次遇到这样的场景时你会信心十足。 Append
聊完切片本身,接下来我们看看切片的内置函数 看看下面这段代码:
还记得 这里令人困惑的是,由于各种原因,通常情况下分配更多的内存意味着首先在一个不同的内存地址申请一块新的足够大的内存空间,然后从当前的内存地址把数据复制到新的内存块中。也就是说切片所指向的数组的地址也会被改变。可视化表示如下:
很显然这样就会存在两个被指向的数组,原有的和新分配的。是不是嗅到了一丝「坑」的味道?原来的数组如果没有被其他的切片指向的话稍后就会被垃圾回收机制释放掉。在这个例子里,实际上就存在一个 append 操作引发的坑。如果我们创建了一个子切片
(译者注:图中
通过图示结合我们上面所说的,切片 append 对切片扩容时,如果大小在 1024 字节以内,每次都会以双倍的大小来申请内存,但如果超过了 1024 字节则会使用所谓的 memory
size classes 来保证增长的容量不会大于当前容量的 12.5%。因为对于大小为 32 字节的数组一次请求 64 字节的内存是没什么问题的,但如果切片的容量为 4GB 或更多,这时候添加一个新元素如果直接多分配出 4GB 的内存则显得代价太大,上面的规则就是考虑到了这样的情况。接口(Interfaces)
这是对很多人来说最容易困惑的部分。需要花费不少时间来掌握和理解如何在 Go 里正确的使用接口,尤其是对在其他面向对象语言里有着惨痛经验的程序员来说。造成这种困惑的一个根源就是
为了理解这一部分,让我们再来看看源码。
我们并不打算深究接口类型断言的实现逻辑,但重要的是要理解 interface 是接口和静态类型信息加上指向实际变量的指针的复合体(
这张图里所展示的东西实际上就是传说中的 nil interface。当你在方法里返回
一个广为人知的「坑」就是当你返回一个值为
除非清楚的知道内存里接口的内部结构是什么样,否则上面这两段代码看起来几乎没有区别。现在来看看
可以清楚的看到
在上面两个例子里,我们都创建了 现在应该对类似的问题不会再感到困惑了。 空接口(Empty interface)
接下来我们来说说 空接口(empty interface) -
它和
空接口
这段代码将导致一个编译错误:
一开始这会很令人困惑。为什么我们可以在单个变量时直接做转换,而在切片类型里却不行?一旦我们知道了空接口本质上是什么(再看一眼上面的图示),就会十分清楚缘由,这样的『转换』实在是一个成本非常高的操作,涉及到分配大量的内存以及 O(n) 左右的时间和空间复杂度。而且 Go 的设计原则中有一条就是 如果需要做一些开销很大的操作 - 光明正大的做(显式而非隐式的做)。 结论不是每个坑都需要通过学习 Go 的内部实现来了解透彻。有一些仅仅只是因为过去的经验和 Go 的玩法有些不一样,毕竟我们每个人或多或少都有着不同的背景和经验。不过,只要稍微深入的理解 Go 的内部工作原理,就能避免掉进绝大多数陷阱里。 |
请发表评论