思考一下,为什么 Go 语言只提供了 array,slice 和 map 给我们用,为什么没有提供 栈,队列,堆等?
怎样高效的处理数据 ? 怎样写出清晰易懂的代码? 怎样才能把代码所引发的开销给强调出来?
CPU 缓存
CPU 一般有三级缓存和主内存。l1 > l2 > l3 > 主内存,访问速度是越来越慢的,内存空间是越来越大。
一个缓存行的长度,访问缓存行中的其他元素,基本是免费的。昂贵的是访问一个新的缓存,只有我们访问更少的缓存行时,我们的程序会更高效。
当下的高速缓存行是 32 或 64 字节宽,具体取决于硬件。硬件喜欢沿着缓存线线性遍历数据和指令。
数组
应该培养一种特质,知道自己正在做什么,搞清楚为什么这样做,这样做的道理和效果,这样可以做出更好的设计决策。
Go 语言中数组的各个元素地址是连续的,更符合 CPU 预测,使用数组的程序会更快。
CPU 有三级缓存,为了更快,当你读取数据的时候,它会预测你接下来要读什么,提前放入缓存中。
|
|
|
|
切片
这将是你的首选数据结构。除非你真能确定自己遇到特殊情况,需要用到链表等结构,否则还是应该优先考虑使用切片。而且要尽可能使用由值构成的切片,而不是由指针构成的切片。
使用 make
创建,这是 Go 语言内置函数,让我们可以创建出 3 种引用类型。
目前我们用到的类型分钟为两种:
- 内置类型
- 结构体类型(用户自定义)
- 引用类型 ( slice / map / channel / 函数 / 接口 )。这是带有指针的数据结构,如果将该类型变量设置零值,相当于
nil
,好比指针设置为nil
。字符串实际很接近于引用类型,但是零值是空串,而不是nil
,所以不能归类到引用类型。
slice 跟 字符串的结构有点像,但是 slice 比 字符串多一个 cap
字段,表示切片容量。
长度和容量有什么区别呢?
长度表示从当前位置开始,可能访问多少个元素。
容量表示将来有可能增加到多少个元素,为考虑以后的扩充而设计。
如果访问超过切片长度的内容,会发生 runtime error。
|
|
这个结构作为函数参数传递时,非常高效。 数组会将每一个元素拷贝,而切片只会拷贝这样一个结构,占用 24 字节。切片本来就应该采用值语义操作,本来就应该留在使用它的那个栈里。
|
|
切片结构中 array 是数组指针,意味着通过修改切片会产生副作用。
nil slice,empty slice 与 nil
Go 语言可以通过 nil 与 empty 来表达式不同的意思 你可以返回零值切片来表示错误 empty 切片可以表示顺利,但是没有数据
|
|
零分配的空白结构体
可以根据这样的结构体,创建上百万个值,但不会引发任何分配。
因为在运行时环境里面,有一个 8 字节的固定值,它就像一个全局变量一样,可以让这种空白的结构体引用。所以无论有多少个空白结构体,它们都可以指向这同一个地址。
比如 nil slice 里面的指针,就是指向这个空白结构体。
|
|
扩容
如果提前知道需要多少容量,在创建时指定,这能减少需要扩容时的内存分配。这是非常高效的!! 即想要写出高效的代码,避免使用 append
函数,直接操作对应位置的元素。
但有可能我们并不知道到底需要操作多少数据,可能是 0 或更多,没办法提前把数组分配出来,即便分配了也可能浪费。为了尽量降低程序消耗的资源量,我们可能要稍微损耗一点性能,这样做,跟真正有可能影响性能的地方相比,这种损耗算不了什么。这时,只好从 nil slice 开始。
|
|
通过 append 扩容,将新数据添加到切片的尾部。注意其调用方式,这个函数的 API 用的是值语义,这是很好的设计。这样的 API 可以叫做通过 值语义 进行修改的 API。它通过对我们传入的切片值复制,在副本上做修改,并返回。这很重要,将修改效果隔离起来,无副作用。这是一种优雅,安全而又清晰的做法。
|
|
append 会导致内存泄漏吗?
如果资源不释放,在内存里滚雪球慢慢增大,这就是内存泄漏了。可以通过 Go trace
工具检查,检查 GC 后内存是否并没有减少,而是增大。
典型的内存泄漏:
- 是否每个 goroutine 无法自行终止,持有的引用无法释放。
- map 只添加数据,因未清理而一直膨胀。
- 某些 API 用完后需要主动 close,但却忘记调用。
- append 的返回值未赋给充当参数的变量,原有支持结构的引用数量未清零。
当切片的 len 和 cap 相同时,此时调用 append 会创建一个容量翻倍的支撑数组,然后将原数组和参数复制过去,让切片的指针指向新的数组。
扩容规则是什么?
查看源码 ,注意要区分 1.18 版本 和 小于其的版本
1.18
开始
- 所需容量大于容量翻倍,预估容量等于所需容量
- 否则
< 256
时,2倍扩容- 从 2 倍扩容到 1.25 倍平滑过度扩容,公式:
newcap += (newcap + 3*256) / 4
- 最后,匹配内存规格,公式为 8 * (2*x),x 为递增变量。
<= 1.17
以前是,仅作为了解。
- 所需容量大于容量翻倍,预估容量等于所需容量
- 否则
< 1024
时,双倍扩容>= 1024
时,1.25 倍扩容
- 最后,匹配内存规格。
切片的高效操作
|
|
上面的写法,可以高效的创建出新的切片值,而且新值和原值可以高效的共享同一个支撑数组。这意味着需要分配在堆上面的至多只有原来的那个支撑数组。
注意,通过 append 对 slice2 添加元素,会影响到 slice1。因为上面提到的是同一个支撑数组。
|
|
在创建新切片值时,可以指定容量与长度相等,此时使用 append 会创建一个新的数组,并将旧数组值复制过去,这样就消除了上面的副作用。三下标制作切片的方法,让你既可以在尾部追加元素,同时又不会影响使用原来那个支持数组的其它切片。这样当然很棒,但有时候,可能需要自己复制,当然还是应该尽量少用复制操作。
Go 语言提供了内置函数copy
,它能够将源切片中的元素复制到目标切片。
|
|
函数式编程
如果不注意防范副作用,那么可能会产生想当危险的结果。通过指针操纵切片时,修改的是切片的支持数组所在的内存,这当然会让程序写起来困难一些。假如使用函数式编程,就不用担心这种问题了。
函数式编程,所有内容都通过值语义来操纵,每一段代码每一个函数,操纵的都是它自己的那份数据。可是这样,就不能根据需要把程序速度优化到最快,这提现了 Go 语言的一项优势,它让我们自己决定,是操纵数值还是指针。
必须注意,采用指针语义操作,必须当心这种做法在修改数据时,是否会引发什么问题。
|
|
字符串和切片
字符串归根结底还是由一系列字节组成,最底层是字节,中间一层叫做代码点,每个代码点都可以当做一个 32 位(4 字节)值,在代码点上方是字符层。代码点有可能1 个字节就能表示,也可能需要 4 个字节才能表示。
- 汉字需要 3 个字节
- 英文字母需要 1 个字节。
字符串可以用 for range
来遍历,遍历的是代码点,string(v)
可以将代码点转为字符。值遍历 v 是 rune 类型,这个类型并不是真正的类型,而是 int32 的别名,实际上,byte 类型是 uint8 的别名。
对字符串切片操作,要小心不同字符的长度。可以转为[]rune
切片来处理。
for range
for range 是值语义,也就是遍历拷贝的副本。在遍历中操作切片不影响遍历。这样的写法一个切片发生变化,不会影响到使用另一个切片所涉及的程序。
|
|
是指针语义时,它访问的就是原来的切片,如下面的代码,可能会发生数组下标溢出。
|
|