Go语言必知必会100问题-21 切片初始化方法及最佳实践

切片初始化

切片使用内置的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 开发的开源键值存储(https://github.com/cockroachdb/pebble)。在Pebble中有一个collectAllUserKeys函数,它循环遍历输入的结构体切片,并返回一个[]byte的切片,返回的切片大小是结构体切片的两倍。

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

  • 18
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值