Go 字符串拼接最佳实践

字符串是一个常见的数据类型,在 Go 语言在内的很多语言中,为了安全,都把字符串设计为不可变。每生成一个字符串都是在创建一个新的字符串,而不是在原有字符串的基础上修改。

在 Go 中,字符串拼接的方式很多,可以直接使用 +,也可以使用 fmt.SPrintf,还可以使用 strings.Builder 和 bytes.Buffer。

在这篇文章中,来讨论一下在代码中如何做字符串拼接效率最好。

1. 做一个基准测试

在开始分析每种拼接方法的优劣之前,先跑一个简单的基准测试,来看一下每种字符串拼接方法的性能。

Go 中提供了基准测试框架,测试文件需要以 test 结尾,然后每个测试方法以 Benchmark 开头,这次对加号、fmt.SPrintf、和 strings.Builder 三种方式进行基准测试,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func BenchmarkPlus(b *testing.B) {
str := "this is just a string"

for i := 0; i < b.N; i++ {
stringPlus(str)
}
}

func BenchmarkSPrintf(b *testing.B) {
str := "this is just a string"
for i := 0; i < b.N; i++ {
stringSprintf(str)
}
}

func BenchmarkStringBuilder(b *testing.B) {
str := "this is just a string"
for i := 0; i < b.N; i++ {
stringBuilder(str)
}
}

func stringPlus(str string) string {
s := ""
for i := 0; i < 10000; i++ {
s += str
}
return s
}

func stringSprintf(str string) string {
s := ""
for i :=0; i < 10000; i++ {
s += str
}
return s
}

func stringBuilder(str string) string {
builder := strings.Builder{}
for i := 0; i < 100000; i++ {
builder.WriteString(str)
}
return builder.String()
}

基准测试需要使用 *testing.B ,其中 b.N 不是一个固定的值,这个值的大小由框架自己来决定。

在这里,我们分别测试用不同的方式拼接一个固定的字符串 10000 次,然后统计平均的代码执行时间,内存消耗情况。使用如下的命令运行基准测试:

1
go test -bench=. -benchmem

-bench=. 参数运行当前包中所有基准测试,-benchmem 表示对测试的内存使用情况进行统计。运行上面的命令之后,输出结果如下:

1
2
3
4
5
6
7
8
goos: darwin
goarch: amd64
pkg: zxin.com/zx-demo/string_benchmark
BenchmarkPlus-12 12 96586447 ns/op 1086401355 B/op 10057 allocs/op
BenchmarkSPrintf-12 12 97037216 ns/op 1086402698 B/op 10065 allocs/op
BenchmarkStringBuilder-12 655 1713353 ns/op 11671537 B/op 35 allocs/op
PASS
ok zxin.com/zx-demo/string_benchmark 6.186s

第一列表示基准测试的方法名称和所用的 GOMAXPROCS 的值,第二列表示这次测试循环的次数,第三列表示平均每次测试所用的时间,单位为纳秒,第四列表示平均每次运行所分配的内存,第五列表示每次运行所分配内存的次数。

通过上面的测试,可以发现 strings.Builder 的表现是最好的,比直接使用加号来拼接字符串的内存消耗要小 100 倍。

2. 为什么性能的差异这么大

通过上面的基准测试可以发现,使用不同的方式来拼接字符串,性能差异很大。

Go 的字符串是不可变的,如果使用加号的方式来拼接字符串,那么每次拼接都需要重新分配内存。而 strings.Builder 会对内存预分配,在字符串不断写入的过程中,会自动扩容长度。

strings.Builder 的底层存储使用的是 []byte,初始的长度分配是 32,然后每次扩容时都会翻一倍。

1
2
3
4
type Builder struct {
addr *Builder
buf []byte
}

当长度到大 2048 时,再扩容就不会直接翻倍,而是每次增加 640 的倍数,第一次增加 640,第二次增加 1280,以此类推。

在大量拼接字符串的时候 strings.Builder 会比直接拼接的效率更高。

bytes.Buffer 是另一个类似的库,与 strings.Builder 性能相当,但如果是对于纯拼接字符串的场景,还是推荐使用 strings.Builder。

3. 拼字符串的最佳实践

虽然 strings.Builder 的性能很高,但并不是所有的场景都是合这个。如果只是一次简单的字符串拼接,直接使用加号就够了。

如果涉及到一些字符串的格式化,那么使用 fmt.Sprintf 就更合适了。

那么在大量拼接字符串的场景,直接使用 strings.Builder 就完事了么,其实还可以继续优化一下。在使用 strings.Builder 时,如果字符串在不断的增加,底层的存储还是要不断的扩容。如果可以预估字符串的长度,就可以提前分配好内存。减少扩容的次数。

增加一个测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func BenchmarkStringBuilderPre(b *testing.B) {
str := "this is just a string"
for i := 0; i < b.N; i++ {
stringBuilderPre(str)
}
}

func stringBuilderPre(str string) string {
builder := strings.Builder{}
builder.Grow(1000000)
for i := 0; i < 100000; i++ {
builder.WriteString(str)
}
return builder.String()
}

下面是基准测试的结果:

1
2
3
4
5
pkg: zxin.com/zx-demo/string_benchmark
BenchmarkPlus-12 12 96676019 ns/op 1086401676 B/op 10057 allocs/op
BenchmarkSPrintf-12 12 96693407 ns/op 1086402022 B/op 10058 allocs/op
BenchmarkStringBuilder-12 607 1822282 ns/op 11671543 B/op 35 allocs/op
BenchmarkStringBuilderPre-12 860 1393689 ns/op 8257539 B/op 5 allocs/op

可以看到,在提前指定长度的情况下,性能又提升了不少,内存的占用量和分配次数下降了不少,运行时间也有所提升。

文 / Rayjun

© 2020 Rayjun    PowerBy Hexo    京ICP备16051220号-1