字符串拼接的6种方式及原理

原生拼接方式"+"

Go语言原生支持使用+操作符直接对两个字符串进行拼接,使用例子如下:

1
2
3
var s string
s += "asong"
s += "真帅"

这种方式使用起来最简单,基本所有语言都有提供这种方式,使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。

拼接过程:

  1. 编译器将字符串转换成字符数组后调用 runtime/string.go 的 concatstrings() 函数
  2. 在函数内遍历字符数组,得到总长度
  3. 如果字符数组总长度未超过预留 buf(32字节),使用预留,反之,生成新的字符数组,根据总长度一次性分配内存空间
  4. 将字符串逐个拷贝到新数组,并销毁旧数组

字符串格式化函数fmt.Sprintf

Go语言中默认使用函数fmt.Sprintf进行字符串格式化,所以也可使用这种方式进行字符串拼接:

1
2
str := "asong"
str = fmt.Sprintf("%s%s", str, str)

拼接过程:

  1. 创建对象
  2. 字符串格式化操作
  3. 将格式化后的字符串通过 append 方式放到[] byte 中
  4. 最后将字节数组转换成 string 返回

fmt.Sprintf实现原理主要是使用到了反射,返回使用 format 格式化的参数。除了字符串拼接,函数内还有很多格式方面的判断,性能不高,但它可以拼接多种类型,字符串或数字等。

Strings.builder

Go语言提供了一个专门操作字符串的库strings,使用strings.Builder可以进行字符串拼接,提供了writeString方法拼接字符串,使用方式如下:

1
2
3
var builder strings.Builder
builder.WriteString("asong")
builder.String()

拼接过程:

  1. 创建 []byte,用于缓存需要拼接的字符串
  2. 通过 append 将数据填充到前面创建的 []byte 中
  3. append 时,如果字符串超过初始容量 8 且小于 1024 字节时,按乘以 2 的容量创建新的字节数组,超过 1024 字节时,按 1/4 增加
  4. 将老数据复制到新创建的字节数组中
  5. 追加新数据并返回

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()

拼接过程:

  1. 创建 []byte ,用于缓存需要拼接的字符串
  2. 首次使用 WriteString() 填充字符串时,由于字节数组容量为 0 ,最少会发生 1 次内存分配
  3. 待拼接字符串长度小于 64 字节,make 一个长度为字符串总长度,容量为 64 字节的新数组
  4. 待拼接字符串超过 64 字节时动态扩容,按 2* 当前容量 + 待拼接字符长度 make 新字节数组
  5. 将字节数组转换成 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, "")

拼接过程:

  1. 接收的是一个字符切片
  2. 遍历字符切片得到总长度,据此通过 builder.Grow 分配内存
  3. 底层使用了 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:如何高效地拼接字符串