likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 6

切片初始化

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

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


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

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


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.


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函数开销,相比直接赋值,调用函数会慢一点。


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…


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效率更高吗?如果我们使用给定的长度而不是容量,则实现代码如下。因为要通过索引给切片中的元素赋值,程序看起来更复杂些。鉴于此功能对性能不敏感,优先考虑了代码的可读性,所以采用了上面的实现。


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切片的时候,是初始化为空,还是设置为固定大小的长度或容量呢?


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