Go语言中常见100问题-#21 切片初始化方法及最佳实践

切片初始化

切片使用内置的make函数进行初始化,初始化需要提供两个参数,分别是切片的长度和容量(可选)。如果这两个参数设置的不合理,会使得后续对切片的操作非常低效。下面来看看怎么设置这两个参数是合适的。

假设我们要实现一个转换函数(convert),将Foo切片映射到Bar切片,并且两个切片将具有相同数量的元素。下面是第一个实现版本:

golang 复制代码
func convert(foos []Foo) []Bar {

bars := make([]Bar, 0)

for _, foo := range foos {

bars = append(bars, fooToBar(foo))

}

return bars

}

问题分析

上述实现中,首先通过make初始化了一个大小为0的空切片,然后通过append向里面添加元素,在添加第一个元素的时候会分配一个大小为1的底层数组。每次当底层数组满时会创建一个容量加倍的数组。所以在添加第三个、第五个和第九个元素时,由于当前数组已满而创建另一个数组的逻辑会重复多次。假设向切片添加1000个元素,根据Go切片扩容算法,差不多要分配10次底层数组,并将总共1000多个元素从一个数组复制到另一个数组。这会导致GC需要付出额外的努力来清理不在使用的数组。如何进行优化呢?有两种不同的方法

如何优化?

方法1

方法一代码逻辑与上述一致,唯一区别是一开始将切片的容量设置为给定要装的元素数量。代码如下:

go 复制代码
func convert(foos []Foo) []Bar {

n := len(foos)

bars := make([]Bar, 0, n)

for _, foo := range foos {

bars = append(bars, fooToBar(foo))

}

return bars

}

对比上述两个版本,可以看到唯一的变化是bars创建时其容量设置为n. 在内部,Go预分配了一个包含n个元素的数组,因此最多可以添加n个元素。从而大大减少分配的数量。

方法2

除了上面的优化方法,还有一种优化方法,就是一开始分配切片的长度为foos的长度,实现代码如下。通过循环给切片bars中每个位置赋值元素,不能通过append向里面添加元素,因为一开始bars中已有了n个元素,并且值为int类型的默认值0.

go 复制代码
func convert(foos []Foo) []Bar {

n := len(foos)

bars := make([]Bar, n)

for i, foo := range foos {

bars[i] = fooToBar(foo)

}

return bars

}

三种方法性能测试对比

上述三种实现,哪种实现方法最优呢?通过benckmark测试,向切片中添加一百万个元素,测试结果如下,分别对应上面三种实现。可看到给定预期大小的容量或长度(上面的实现2和实现3)比不分配任何长度和容量快大约400%。后两种方法相比,给定固定长度的实现比固定容量的块大约4%,因为它避免了频繁的调用append函数开销,相比直接赋值,调用函数会慢一点。

console 复制代码
BenchmarkConvert_EmptySlice-4 22 49739882 ns/op

BenchmarkConvert_GivenCapacity-4 86 13438544 ns/op

BenchmarkConvert_GivenLength-4 91 12800411 ns/op

特殊情况1:优选方法2

既然方法2(给定容量大小)没有方法3(给定长度大小)高效,为什么在Go项目中看到还有方法2在被使用呢?下面的程序是来自Pebble项目中的一个具体示例,Pebble 是 Cockroach Labs 开发的开源键值存储(github.com/cockroachdb...

go 复制代码
func collectAllUserKeys(cmp Compare,

tombstones []tombstoneWithLevel) [][]byte {

keys := make([][]byte, 0, len(tombstones)*2)

for _, t := range tombstones {

keys = append(keys, t.Start.UserKey)

keys = append(keys, t.End)

}

// ...

}

在上面的代码中,有意识选择使用的是给定的容量并通过append向里面添加元素。为啥要选择这种方法呢?前面不是说方法3效率更高吗?如果我们使用给定的长度而不是容量,则实现代码如下。因为要通过索引给切片中的元素赋值,程序看起来更复杂些。鉴于此功能对性能不敏感,优先考虑了代码的可读性,所以采用了上面的实现。

go 复制代码
func collectAllUserKeys(cmp Compare,

tombstones []tombstoneWithLevel) [][]byte {

keys := make([][]byte, len(tombstones)*2)

for i, t := range tombstones {

keys[i*2] = t.Start.UserKey

keys[i*2+1] = t.End

}

// ...

}

特殊情况2:大小不确定如何初始化

有些情况下不能准确知道切片的大小,这时候怎么处理比较好。例如,下面代码中,输出切片的大小依赖于条件函数 something(foo), 这种情况下,在初始化bars切片的时候,是初始化为空,还是设置为固定大小的长度或容量呢?

go 复制代码
func convert(foos []Foo) []Bar {

// bars initialization

for _, foo := range foos {

if something(foo) {

// Add a bar element

}

}

return bars

}

没有严格的规定,这是一个传统的软件问题:需要权衡考虑CPU和内存,是开大一点内存还是多耗一点CPU. 例如,如果 something(foo) 在99%的情况下都为true,考虑使用给定长度或容量来初始化。所以这种情况要具体问题具体分析,没有统一的标准。

思考总结

总结,将一种切片类型转换为另一种切片类型是Go中经常遇到的操作。通过前面的分析,如果提前已知道切片的长度是多少,就不要创建一个大小为0的空切片,采用分配给定容量或给定长度对切片进行初始化是最佳选择。具体是采用给定容量还是给定长度,要根据实际情况进行选择,采用给定容量代码的可阅读性更好,采用给定长度程序的性能往往更高。

相关推荐
盖世英雄酱5813619 分钟前
java深度调试【第三章内存分析和堆内存设置】
java·后端
太过平凡的小蚂蚁25 分钟前
Kotlin 协程中常见的异步返回与控制方式(速览)
开发语言·前端·kotlin
007php00733 分钟前
京东面试题解析:同步方法、线程池、Spring、Dubbo、消息队列、Redis等
开发语言·后端·百度·面试·职场和发展·架构·1024程序员节
程序定小飞42 分钟前
基于springboot的电影评论网站系统设计与实现
java·spring boot·后端
码事漫谈1 小时前
高性能推理引擎的基石:C++与硬件加速的完美融合
后端
码事漫谈1 小时前
C++与边缘AI:在资源荒漠中部署智能的工程艺术
后端
光头闪亮亮1 小时前
curl库应用-c++客户端示例及golang服务端应用示例
c++·go
咖啡の猫1 小时前
Vue初始化脚手架
前端·javascript·vue.js
绝无仅有1 小时前
腾讯面试文章解析:MySQL慢查询,存储引擎,事务,结构算法等总结与实战
后端·面试·github
晨枫阳1 小时前
uniapp兼容问题处理总结
前端·vue.js·uni-app