Go语言中常见100问题-#39 Under-optimized string concatenation

2023-08-17 08:33:37 浏览数 (3)

字符串连接优化

在Go语言中,字符串连接主要有两种方法,其中一种在某些时候是非常低效的,通过本文学习我们应该掌握在不同的场景下选择最合适的方法。

下面的concat函数通过 =将一个字符串切片拼接成一个字符串。具体代码如下,在每轮循环中,通过 =操作符将切片中的字符串value拼接到字符串s中。咋看起来这段代码没有啥问题,但是我们不要忽略了一个重要原则:字符串是不可变的。因此每一轮迭代,不是直接更新s,而是在内存中重新分配一个字符串,这会很影响性能。

代码语言:javascript复制
func concat(values []string) string {
 s := ""
 for _, value := range values {
  s  = value
 }
 return s
}

有什么优化方法吗?可以采用strings包提供的Builder结构体,实现如下。通过strings.Builder创建一个Builder结构体,在每次迭代中调用它的WriteString方法向里面的缓冲区buffer中追加value,减少内存的重新分配和拷贝。尽管WriteString方法第二返回值是一个error类型,但我们通常忽略它,因为该方法返回的error永远都是nil. 那为啥函数签名设计这样,搞一个error返回值呢?因为strings.Builder实现了io.StringWriter接口,该接口只有一个方法: WriteString(s string) (n int, err error),因此为实现该接口,所以strings.Builder的WriteString方法会返回一个error。

代码语言:javascript复制
func concat(values []string) string {
 sb := strings.Builder{}
 for _, value := range values {
  _, _ = sb.WriteString(value)
 }
 return sb.String()
}

strings.Builder除了提供有WriteString方法,还有Write方法,写入一个字节切片,如果是写入单个字节,使用WriteByte方法,如果是写入一个rune字符,使用WriteRune方法。

内部实现上,strings.Builder含有一个字节切片,调用WriteString实际上是向内部的切片中append数据。有两点需要注意,一是strings.Builder不支持并发,内部同时调用append操作会导致数据竞争问题。二是需要设置内部切片的大小,否则如切片满了会重新分配空间,并拷贝原切片中的数据,导致效率低效。所以strings.Builder提供了一个对外方法Grow(n int)确保内部分配的空间有n个字节。

现在使用Grow方法对上面的代码进一步优化,一开始就设置好写入的所有字符串的字节数。实现代码如下。

代码语言:javascript复制
func concat(values []string) string {
 total := 0
 for i := 0; i < len(values); i   {
  total  = len(values[i])
 }

 sb := strings.Builder{}
 sb.Grow(total)
 for _, value := range values {
  _, _ = sb.WriteString(value)
 }
 return sb.String()
}

对上面三种字符串拼接实现做一个benckmark测试,输入的字符串切片包含1000个字符串,每个字符串长度为1000字节。测试结果如下:

代码语言:javascript复制
BenchmarkConcatV1-4             16      72291485 ns/op
BenchmarkConcatV2-4           1188        878962 ns/op
BenchmarkConcatV3-4           5922        190340 ns/op

可以看到,第3个版本比第1个版本快99%,比第2个版本快78%。也许有人会问,第三个版本不是对values进行两次迭代,但为啥它的效率比第二个版本还高呢?答案是没有预初始化切片大小,导致效率地下,在本系列文章Go语言中常见100问题-#21 Inefficient slice initialization有详细分析。如果切片没有分配给定的容量,当切片不断append元素变满时,会导致额外的内存分配和数据拷贝。因此,采用两次迭代先统计占用的空间大小是值得的。

采用strings.Builder来拼接字符串是推荐的方法,这种方法建议在有循环的时候使用。如果没有循环,只是简单的个别字符串拼接,不推荐这种方法,因为性能提升并不明显,但使得代码的可读性比第一个版本差,直接使用 =或者fmt.Sprintf拼接即可。

一般来说,从性能角度触发,我们需要记住如果拼接的字符串数量超过5个,采用strings.Builder进行拼接通常是比 =要快的。即使准确的数量(不一定是5)依赖于很多因素,像待拼接的字符串长度、运行的机器等。但这可以作为一个经验值在我们选择方法时提供一个参考。此外,如果我们预先知道拼接的字符串长度,应该使用Grow预分配足量的内存空间。

0 人点赞