日常业务开发中离不开字符串的拼接操作,不同语言的字符串实现方式都不同,在Go
语言中就提供了6种方式进行字符串拼接,那这几种拼接方式该如何选择呢?使用那个更高效呢?本文我们就一起来分析一下。
本文使用Go语言版本:1.17.2
我们首先来了解一下Go
语言中string
类型的结构定义,先看一下官方定义:
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string
string
是一个8
位字节的集合,通常但不一定代表UTF-8编码的文本。string可以为空,但是不能为nil。string的值是不能改变的。
string
类型本质也是一个结构体,定义如下:
type stringStruct struct {
str unsafe.Pointer
len int
}
stringStruct
和slice
还是很相似的,str
指针指向的是某个数组的首地址,len
代表的就是数组长度。怎么和slice
这么相似,底层指向的也是数组,是什么数组呢?我们看看他在实例化时调用的方法:
//go:nosplit
func gostringnocopy(str *byte) string {
ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
s := *(*string)(unsafe.Pointer(&ss))
return s
}
入参是一个byte
类型的指针,从这我们可以看出string
类型底层是一个byte
类型的数组。
string
类型本质上就是一个byte
类型的数组,在Go
语言中string
类型被设计为不可变的,不仅是在Go
语言,其他语言中string
类型也是被设计为不可变的,这样的好处就是:在并发场景下,我们可以在不加锁的控制下,多次使用同一字符串,在保证高效共享的情况下而不用担心安全问题。
string
类型虽然是不能更改的,但是可以被替换,因为stringStruct
中的str
指针是可以改变的,只是指针指向的内容是不可以改变的,也就说每一个更改字符串,就需要重新分配一次内存,之前分配的空间会被gc
回收。
关于string
类型的知识点就描述这么多,方便我们后面分析字符串拼接。
Go
语言原生支持使用+
操作符直接对两个字符串进行拼接,使用例子如下:
var s string
s += "Hello"
s += "World"
这种方式使用起来最简单,基本所有语言都有提供这种方式,使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。
fmt.Sprintf
Go
语言中默认使用函数fmt.Sprintf
进行字符串格式化,所以也可使用这种方式进行字符串拼接:
str := "Hello"
str = fmt.Sprintf("%s, World", str, str)
fmt.Sprintf
实现原理主要是使用到了反射,具体源码分析因为篇幅的原因就不在这里详细分析了,看到反射,就会产生性能的损耗,你们懂的!!
Go
语言提供了一个专门操作字符串的库strings
,使用strings.Builder
可以进行字符串拼接,提供了writeString
方法拼接字符串,使用方式如下:
var builder strings.Builder
builder.WriteString("Hello world")
fmt.Println(builder.String())
strings.builder
的实现原理很简单,结构如下:
// A Builder is used to efficiently build a string using Write methods.
// It minimizes memory copying. The zero value is ready to use.
// Do not copy a non-zero Builder.
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte
}
addr
字段主要是做copycheck
,buf
字段是一个byte
类型的切片,这个就是用来存放字符串内容的,提供的writeString()
方法就是像切片buf
中追加数据:
// WriteString appends the contents of s to b's buffer.
// It returns the length of s and a nil error.
func (b *Builder) WriteString(s string) (int, error) {
b.copyCheck()
b.buf = append(b.buf, s...)
return len(s), nil
}
提供的String
方法就是将[]]byte
转换为string
类型,这里为了避免内存拷贝的问题,使用了强制转换来避免内存拷贝:
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf))
}
bytes.Buffer
因为string
类型底层就是一个byte
数组,所以我们就可以Go
语言的bytes.Buffer
进行字符串拼接。bytes.Buffer
是一个一个缓冲byte
类型的缓冲器,这个缓冲器里存放着都是byte
。使用方式如下:
buf := new(bytes.Buffer)
buf.WriteString("Hello World")
fmt.Println(buf.String())
bytes.buffer
底层也是一个[]byte
切片,结构体如下:
// A Buffer is a variable-sized buffer of bytes with Read and Write methods.
// The zero value for Buffer is an empty buffer ready to use.
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
方法是如何拼接字符串的:
// WriteString appends the contents of s to the buffer, growing the buffer as
// needed. The return value n is the length of s; err is always nil. If the
// buffer becomes too large, WriteString will panic with ErrTooLarge.
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
类型依旧使用了标准类型,所以会发生内存分配:
// String returns the contents of the unread portion of the buffer
// as a string. If the Buffer is a nil pointer, it returns "<nil>".
//
// To build strings more efficiently, see the strings.Builder type.
func (b *Buffer) String() string {
if b == nil {
// Special case, useful in debugging.
return "<nil>"
}
return string(b.buf[b.off:])
}
strings.Join
方法可以将一个string
类型的切片拼接成一个字符串,可以定义连接操作符,使用如下:
slice := []string{"Hello", "World"}
fmt.Println(strings.Join(slice, ""))
strings.join
也是基于strings.builder
来实现的,代码如下:
// Join concatenates the elements of its first argument to create a single string. The separator
// string sep is placed between elements in the resulting string.
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的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。
因为string
类型底层也是byte
类型数组,所以我们可以重新声明一个切片,使用append
进行字符串拼接,使用方式如下:
buf := make([]byte, 0)
base := "Hello world"
buf = append(buf, base...)
fmt.Println(string(buf))
如果想减少内存分配,在将[]byte
转换为string
类型时可以考虑使用强制转换。
上面我们总共提供了6种方法,原理我们基本知道了,那么我们就使用Go
语言中的Benchmark
来分析一下到底哪种字符串拼接方式更高效。我们主要分两种情况进行分析:
因为代码量有点多,下面只贴出分析结果,详细代码已经上传github
首先定义一个基础字符串:
const base = "1234567890qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"
少量字符串拼接的测试我们就采用拼接一次的方式验证,base拼接base,大量字符串拼接的测试我们先构建一个长度为200的字符串切片,然后遍历这个切片不断的进行拼接:
var baseSlice []string
for i := 0; i < 200; i++ {
baseSlice = append(baseSlice, base)
}
可以得出benchmark:
goos: windows
goarch: amd64
pkg: string_join
cpu: Intel(R) Xeon(R) CPU E5-1650 v2 @ 3.50GHz
BenchmarkSingleSumString-12 7962480 156.7 ns/op
BenchmarkSingleSprintfString-12 3251448 437.8 ns/op
BenchmarkSingleBuilderString-12 10495051 125.7 ns/op
BenchmarkSingleBytesBuffString-12 5781111 215.5 ns/op
BenchmarkSingleJoinstring-12 14462552 81.15 ns/op
BenchmarkSingleByteSliceString-12 7319353 170.6 ns/op
BenchmarkSumString-12 3871 310673 ns/op
BenchmarkSprintfString-12 3243 359549 ns/op
BenchmarkBuilderString-12 139580 8050 ns/op
BenchmarkBytesBufferString-12 104380 11302 ns/op
BenchmarkJoinstring-12 279152 4417 ns/op
BenchmarkByteSliceString-12 67436 18174 ns/op
PASS
ok string_join 16.712s
通过两次benchmark
对比,我们可以看到:
同步最后分析的结论:
无论什么情况下使用strings.builder
进行字符串拼接都是最高效的,不过要主要使用方法,记得调用grow
进行容量分配,才会高效。strings.join
的性能约等于strings.builder
,在已经字符串slice的时候可以使用,未知时不建议使用,构造切片也是有性能损耗的;如果进行少量的字符串拼接时,直接使用+
操作符是最方便也是性能最高的,可以放弃strings.builder
的使用。
综合对比性能排序:
strings.Join
≈strings.Builder
>bytes.Buffer
>[]byte
转换string
>"+">fmt.Sprintf
本文我们针对6
种字符串的拼接方式进行介绍,并通过benckmark
对比了效率,无论什么时候使用strings.builder
都不会错,但是在少量字符串拼接时,直接+
也就是更优的方式,具体业务场景具体分析,不要一概而论。
文中代码已上传github