字符串拼接的6种方式及原理
原生拼接方式"+"
Go语言原生支持使用+操作符直接对两个字符串进行拼接,使用例子如下:
1
2
3
|
var s string
s += "asong"
s += "真帅"
|
这种方式使用起来最简单,基本所有语言都有提供这种方式,使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
拼接过程:
- 编译器将字符串转换成字符数组后调用 runtime/string.go 的 concatstrings() 函数
- 在函数内遍历字符数组,得到总长度
- 如果字符数组总长度未超过预留 buf(32字节),使用预留,反之,生成新的字符数组,根据总长度一次性分配内存空间
- 将字符串逐个拷贝到新数组,并销毁旧数组
字符串格式化函数fmt.Sprintf
Go语言中默认使用函数fmt.Sprintf
进行字符串格式化,所以也可使用这种方式进行字符串拼接:
1
2
|
str := "asong"
str = fmt.Sprintf("%s%s", str, str)
|
拼接过程:
- 创建对象
- 字符串格式化操作
- 将格式化后的字符串通过 append 方式放到[] byte 中
- 最后将字节数组转换成 string 返回
fmt.Sprintf实现原理主要是使用到了反射,返回使用 format 格式化的参数。除了字符串拼接,函数内还有很多格式方面的判断,性能不高,但它可以拼接多种类型,字符串或数字等。
Strings.builder
Go语言提供了一个专门操作字符串的库strings,使用strings.Builder可以进行字符串拼接,提供了writeString方法拼接字符串,使用方式如下:
1
2
3
|
var builder strings.Builder
builder.WriteString("asong")
builder.String()
|
拼接过程:
- 创建 []byte,用于缓存需要拼接的字符串
- 通过 append 将数据填充到前面创建的 []byte 中
- append 时,如果字符串超过初始容量 8 且小于 1024 字节时,按乘以 2 的容量创建新的字节数组,超过 1024 字节时,按 1/4 增加
- 将老数据复制到新创建的字节数组中
- 追加新数据并返回
strings.builder的实现原理很简单,结构如下:
1
2
3
4
|
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte // 1
}
|
addr字段主要是做copycheck,buf字段是一个byte类型的切片,这个就是用来存放字符串内容的,提供的writeString()方法就是像切片buf中追加数据:
1
2
3
4
5
|
func (b *Builder) WriteString(s string) (int, error) {
b.copyCheck()
b.buf = append(b.buf, s...)
return len(s), nil
}
|
提供的String方法就是将[]byte
转换为string类型,这里为了避免内存拷贝的问题,使用了强制转换来避免内存拷贝:
1
2
3
|
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
|
bytes.Buffer
因为string类型底层就是一个byte数组,所以我们就可以Go语言的bytes.Buffer进行字符串拼接。bytes.Buffer是一个一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是byte。使用方式如下:
1
2
3
|
buf := new(bytes.Buffer)
buf.WriteString("asong")
buf.String()
|
拼接过程:
- 创建 []byte ,用于缓存需要拼接的字符串
- 首次使用 WriteString() 填充字符串时,由于字节数组容量为 0 ,最少会发生 1 次内存分配
- 待拼接字符串长度小于 64 字节,make 一个长度为字符串总长度,容量为 64 字节的新数组
- 待拼接字符串超过 64 字节时动态扩容,按 2* 当前容量 + 待拼接字符长度 make 新字节数组
- 将字节数组转换成 string 类型返回
bytes.buffer底层也是一个[]byte切片,结构体如下:
1
2
3
4
5
|
type Buffer struct {
buf []byte // contents are the bytes buf[off : len(buf)]
off int // read at &buf[off], write at &buf[len(buf)]
lastRead readOp // last read operation, so that Unread* can work correctly.
}
|
因为bytes.Buffer可以持续向Buffer尾部写入数据,从Buffer头部读取数据,所以off字段用来记录读取位置,再利用切片的cap特性来知道写入位置,这个不是本次的重点,重点看一下WriteString方法是如何拼接字符串的:
1
2
3
4
5
6
7
8
|
func (b *Buffer) WriteString(s string) (n int, err error) {
b.lastRead = opInvalid
m, ok := b.tryGrowByReslice(len(s))
if !ok {
m = b.grow(len(s))
}
return copy(b.buf[m:], s), nil
}
|
切片在创建时并不会申请内存块,只有在往里写数据时才会申请,首次申请的大小即为写入数据的大小。如果写入的数据小于64字节,则按64字节申请。采用动态扩展slice的机制,字符串追加采用copy的方式将追加的部分拷贝到尾部,copy是内置的拷贝函数,可以减少内存分配。
但是在将[]byte转换为string类型依旧使用了标准类型,所以会发生内存分配:
1
2
3
4
5
6
7
|
func (b *Buffer) String() string {
if b == nil {
// Special case, useful in debugging.
return "<nil>"
}
return string(b.buf[b.off:])
}
|
strings.Join
Strings.Join方法可以将一个string类型的切片拼接成一个字符串,可以定义连接操作符,使用如下:
1
2
|
baseSlice := []string{"asong", "真帅"}
strings.Join(baseSlice, "")
|
拼接过程:
- 接收的是一个字符切片
- 遍历字符切片得到总长度,据此通过 builder.Grow 分配内存
- 底层使用了 strings.Builder,每使用一次 strings.Join() ,都会创建新的 builder 对象
strings.Join() 主要适用于以指定分隔符方式连接成一个新字符串,分隔符可以为空,在字符串一次拼接操作中,性能仅次于 + 操作符。
strings.join也是基于strings.builder来实现的,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func Join(elems []string, sep string) string {
switch len(elems) {
case 0:
return ""
case 1:
return elems[0]
}
n := len(sep) * (len(elems) - 1)
for i := 0; i < len(elems); i++ {
n += len(elems[i])
}
var b Builder
b.Grow(n)
b.WriteString(elems[0])
for _, s := range elems[1:] {
b.WriteString(sep)
b.WriteString(s)
}
return b.String()
}
|
唯一不同在于在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。
切片append
因为string类型底层也是byte类型数组,所以我们可以重新声明一个切片,使用append进行字符串拼接,使用方式如下:
1
2
3
4
|
buf := make([]byte, 0)
base = "asong"
buf = append(buf, base...)
string(base)
|
如果想减少内存分配,在将[]byte转换为string类型时可以考虑使用强制转换。
性能对比
上面我们总共提供了6种方法,原理我们基本知道了,那么我们就使用Go语言中的Benchmark来分析一下到底哪种字符串拼接方式更高效。我们主要分两种情况进行分析:
待拼接字符串长度、次数已知,可一次完成字符串拼接
我们先定义一个长度为60字节的基础字符串:
1
|
var base = "123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASFGHJKLZXCVBNM"
|
代码:
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
46
47
48
49
50
51
52
53
|
package main
import (
"bytes"
"fmt"
"strings"
)
var base = "123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASFGHJKLZXCVBNM"
func main() {
fmt.Println(len(base))
fmt.Println(SumString(base))
fmt.Println(SprintfString(base))
fmt.Println(BuilderString(base))
fmt.Println(bytesString(base))
fmt.Println(byteSliceString(base))
fmt.Println(Joinstring())
}
func SumString(str string) string {
return base + str
}
func SprintfString(str string) string {
return fmt.Sprintf("%s%s", base, str)
}
func BuilderString(str string) string {
var builder strings.Builder
builder.Grow(2 * len(str))
builder.WriteString(base)
builder.WriteString(str)
return builder.String()
}
func bytesString(str string) string {
buf := new(bytes.Buffer)
buf.WriteString(base)
buf.WriteString(str)
return buf.String()
}
func byteSliceString(str string) string {
buf := make([]byte, 0)
buf = append(buf, base...)
buf = append(buf, str...)
return string(buf)
}
func Joinstring() string {
return strings.Join([]string{base, base}, "")
}
|
benchmark:
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
46
47
48
49
50
51
52
53
|
package main
import (
"testing"
)
func BenchmarkSumString(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
SumString(base)
}
b.StopTimer()
}
func BenchmarkSprintfString(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
SprintfString(base)
}
b.StopTimer()
}
func BenchmarkBuilderString(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
BuilderString(base)
}
b.StopTimer()
}
func BenchmarkBytesBuffString(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
bytesString(base)
}
b.StopTimer()
}
func BenchmarkJoinstring(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
Joinstring()
}
b.StopTimer()
}
func BenchmarkByteSliceString(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
byteSliceString(base)
}
b.StopTimer()
}
|
结果:
1
2
3
4
5
6
7
8
9
10
11
12
|
BenchmarkSumString
BenchmarkSumString-8 29272575 37.77 ns/op 128 B/op 1 allocs/op
BenchmarkSprintfString
BenchmarkSprintfString-8 11321613 110.7 ns/op 160 B/op 3 allocs/op
BenchmarkBuilderString
BenchmarkBuilderString-8 35814213 33.33 ns/op 128 B/op 1 allocs/op
BenchmarkBytesBuffString
BenchmarkBytesBuffString-8 13787127 86.76 ns/op 384 B/op 3 allocs/op
BenchmarkJoinstring
BenchmarkJoinstring-8 29880229 40.14 ns/op 128 B/op 1 allocs/op
BenchmarkByteSliceString
BenchmarkByteSliceString-8 16286276 73.92 ns/op 320 B/op 3 allocs/op
|
如果字符串长度是180字节,结果:
1
2
3
4
5
6
7
8
9
10
11
12
|
BenchmarkSumString
BenchmarkSumString-8 18065362 62.70 ns/op 384 B/op 1 allocs/op
BenchmarkSprintfString
BenchmarkSprintfString-8 8494684 140.4 ns/op 416 B/op 3 allocs/op
BenchmarkBuilderString
BenchmarkBuilderString-8 20904981 57.86 ns/op 384 B/op 1 allocs/op
BenchmarkBytesBuffString
BenchmarkBytesBuffString-8 6720218 166.3 ns/op 1152 B/op 3 allocs/op
BenchmarkJoinstring
BenchmarkJoinstring-8 18531339 64.94 ns/op 384 B/op 1 allocs/op
BenchmarkByteSliceString
BenchmarkByteSliceString-8 9053972 133.1 ns/op 960 B/op 3 allocs/op
|
如果string builder
没有提前grow,在180字节的压测结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
|
BenchmarkSumString
BenchmarkSumString-8 18853608 64.02 ns/op 384 B/op 1 allocs/op
BenchmarkSprintfString
BenchmarkSprintfString-8 8496699 140.8 ns/op 416 B/op 3 allocs/op
BenchmarkBuilderString
BenchmarkBuilderString-8 12908581 92.90 ns/op 576 B/op 2 allocs/op
BenchmarkBytesBuffString
BenchmarkBytesBuffString-8 7239698 169.1 ns/op 1152 B/op 3 allocs/op
BenchmarkJoinstring
BenchmarkJoinstring-8 18498451 64.88 ns/op 384 B/op 1 allocs/op
BenchmarkByteSliceString
BenchmarkByteSliceString-8 9004070 133.3 ns/op 960 B/op 3 allocs/op
|
在单次拼接的情况下,+
和string join
和提前执行Grow方法的string builder
性能基本一致.
待拼接字符串长度、次数未知,需要循环追加完成拼接操作
大量字符串拼接的测试我们先构建一个长度为200的字符串切片:
1
2
3
4
|
var baseSlice []string
for i := 0; i < 200; i++ {
baseSlice = append(baseSlice, base)
}
|
代码:
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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
package main
import (
"bytes"
"fmt"
"strings"
)
const base = "123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASFGHJKLZXCVBNM"
var baseSlice []string
func init() {
for i := 0; i < 200; i++ {
baseSlice = append(baseSlice, base)
}
}
func main() {
}
func SumString() string {
res := ""
for _, val := range baseSlice {
res += val
}
return res
}
func SprintfString() string {
res := ""
for _, val := range baseSlice {
res = fmt.Sprintf("%s%s", res, val)
}
return res
}
func BuilderString() string {
var builder strings.Builder
builder.Grow(200 * len(baseSlice))
for _, val := range baseSlice {
builder.WriteString(val)
}
return builder.String()
}
func bytesString() string {
buf := new(bytes.Buffer)
for _, val := range baseSlice {
buf.WriteString(val)
}
return buf.String()
}
func byteSliceString() string {
buf := make([]byte, 0)
for _, val := range baseSlice {
buf = append(buf, val...)
}
return string(buf)
}
func Joinstring() string {
return strings.Join(baseSlice, "")
}
|
benchmark:
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
46
47
48
49
50
51
52
53
54
|
package main
import (
"testing"
)
func BenchmarkSumString(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
SumString()
}
b.StopTimer()
}
func BenchmarkSprintfString(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
SprintfString()
}
b.StopTimer()
}
func BenchmarkBuilderString(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
BuilderString()
}
b.StopTimer()
}
func BenchmarkBytesBufferString(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
bytesString()
}
b.StopTimer()
}
func BenchmarkJoinstring(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
Joinstring()
}
b.StopTimer()
}
func BenchmarkByteSliceString(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
byteSliceString()
}
b.StopTimer()
}
|
压测结果:
1
2
3
4
5
6
7
8
9
10
11
12
|
BenchmarkSumString
BenchmarkSumString-8 10000 103266 ns/op 1277714 B/op 199 allocs/op
BenchmarkSprintfString
BenchmarkSprintfString-8 8962 138726 ns/op 1288279 B/op 600 allocs/op
BenchmarkBuilderString
BenchmarkBuilderString-8 465858 2557 ns/op 40960 B/op 1 allocs/op
BenchmarkBytesBufferString
BenchmarkBytesBufferString-8 291108 4209 ns/op 44736 B/op 9 allocs/op
BenchmarkJoinstring
BenchmarkJoinstring-8 535389 2238 ns/op 12288 B/op 1 allocs/op
BenchmarkByteSliceString
BenchmarkByteSliceString-8 227782 5109 ns/op 60736 B/op 15 allocs/op
|
在多次拼接的情况下,string join
和提前执行Grow方法的string builder
性能基本一致.
在string builder
不提前执行Grow方法的情况下,结果是:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
BenchmarkSumString
BenchmarkSumString-8 10000 105840 ns/op 1277713 B/op 199 allocs/op
BenchmarkSprintfString
BenchmarkSprintfString-8 8520 136832 ns/op 1288426 B/op 600 allocs/op
BenchmarkBuilderString
BenchmarkBuilderString-8 270588 4336 ns/op 48448 B/op 14 allocs/op
BenchmarkBytesBufferString
BenchmarkBytesBufferString-8 281696 4182 ns/op 44736 B/op 9 allocs/op
BenchmarkJoinstring
BenchmarkJoinstring-8 463172 2301 ns/op 12288 B/op 1 allocs/op
BenchmarkByteSliceString
BenchmarkByteSliceString-8 237044 5031 ns/op 60736 B/op 15 allocs/op
PASS
|
总结
- 当进行少量字符串拼接时,直接使用+操作符进行拼接字符串,效率还是挺高的,但是当要拼接的字符串数量上来时,+操作符的性能就比较低了;
- 函数fmt.Sprintf还是不适合进行字符串拼接,无论拼接字符串数量多少,性能损耗都很大,还是老老实实做他的字符串格式化就好了
- strings.Builder无论是少量字符串的拼接还是大量的字符串拼接,性能一直都比较好,这也是为什么Go语言官方推荐使用strings.builder进行字符串拼接的原因,在使用strings.builder时最好使用Grow方法进行初步的容量分配.
- strings.join的性能约等于strings.builder,在已经字符串slice的时候可以使用,未知时需要构造切片,如果切片数量较大,也要考虑性能损耗.
- bytes.Buffer方法性能是低于strings.builder的,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,不像strings.buidler这样直接将底层的 []byte 转换成了字符串类型返回,这就占用了更多的空间。
参考
Go语言如何高效的进行字符串拼接(6种方式进行对比分析)
Go:如何高效地拼接字符串