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的空切片,采用分配给定容量或给定长度对切片进行初始化是最佳选择。具体是采用给定容量还是给定长度,要根据实际情况进行选择,采用给定容量代码的可阅读性更好,采用给定长度程序的性能往往更高。

相关推荐
涡能增压发动积12 小时前
同样的代码循环 10次正常 循环 100次就抛异常?自定义 Comparator 的 bug 让我丢尽颜面
后端
Wenweno0o12 小时前
0基础Go语言Eino框架智能体实战-chatModel
开发语言·后端·golang
于慨12 小时前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz12 小时前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
swg32132112 小时前
Spring Boot 3.X Oauth2 认证服务与资源服务
java·spring boot·后端
从前慢丶12 小时前
前端交互规范(Web 端)
前端
tyung12 小时前
一个 main.go 搞定协作白板:你画一笔,全世界都看见
后端·go
gelald12 小时前
SpringBoot - 自动配置原理
java·spring boot·后端
CHU72903512 小时前
便捷约玩,沉浸推理:线上剧本杀APP功能版块设计详解
前端·小程序
GISer_Jing12 小时前
Page-agent MCP结构
前端·人工智能