原文链接:Go 语言如何高效的进行字符串拼接(6 种形式进行比照剖析)
前言
哈喽,大家好,我是
asong
日常业务开发中离不开字符串的拼接操作,不同语言的字符串实现形式都不同,在
Go
语言中就提供了 6 种形式进行字符串拼接,那这几种拼接形式该如何抉择呢?应用那个更高效呢?本文咱们就一起来剖析一下。本文应用 Go 语言版本:1.17.1
string 类型
咱们首先来理解一下 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
类型的知识点就形容这么多,不便咱们前面剖析字符串拼接。
字符串拼接的 6 种形式及原理
原生拼接形式 ”+”
Go
语言原生反对应用 +
操作符间接对两个字符串进行拼接,应用例子如下:
var s string
s += "asong"
s += "真帅"
这种形式应用起来最简略,根本所有语言都有提供这种形式,应用 +
操作符进行拼接时,会对字符串进行遍历,计算并开拓一个新的空间来存储原来的两个字符串。
字符串格式化函数fmt.Sprintf
Go
语言中默认应用函数 fmt.Sprintf
进行字符串格式化,所以也可应用这种形式进行字符串拼接:
str := "asong"
str = fmt.Sprintf("%s%s", str, str)
fmt.Sprintf
实现原理次要是应用到了反射,具体源码剖析因为篇幅的起因就不在这里详细分析了,看到反射,就会产生性能的损耗,你们懂得!!!
Strings.builder
Go
语言提供了一个专门操作字符串的库 strings
,应用strings.Builder
能够进行字符串拼接,提供了 writeString
办法拼接字符串,应用形式如下:
var builder strings.Builder
builder.WriteString("asong")
builder.String()
strings.builder
的实现原理很简略,构造如下:
type Builder struct {
addr *Builder // of receiver, to detect copies by value
buf []byte // 1}
addr
字段次要是做 copycheck
,buf
字段是一个 byte
类型的切片,这个就是用来寄存字符串内容的,提供的 writeString()
办法就是像切片 buf
中追加数据:
func (b *Builder) WriteString(s string) (int, error) {b.copyCheck()
b.buf = append(b.buf, s...)
return len(s), nil
}
提供的 String
办法就是将 []]byte
转换为 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("asong")
buf.String()
bytes.buffer
底层也是一个 []byte
切片,构造体如下:
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
办法是如何拼接字符串的:
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
类型仍旧应用了规范类型,所以会产生内存调配:
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
类型的切片拼接成一个字符串,能够定义连贯操作符,应用如下:
baseSlice := []string{"asong", "真帅"}
strings.Join(baseSlice, "")
strings.join
也是基于 strings.builder
来实现的,代码如下:
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
进行字符串拼接,应用形式如下:
buf := make([]byte, 0)
base = "asong"
buf = append(buf, base...)
string(base)
如果想缩小内存调配,在将 []byte
转换为 string
类型时能够思考应用强制转换。
Benchmark 比照
下面咱们总共提供了 6 种办法,原理咱们根本晓得了,那么咱们就应用 Go
语言中的 Benchmark
来剖析一下到底哪种字符串拼接形式更高效。咱们次要分两种状况进行剖析:
- 大量字符串拼接
- 大量字符串拼接
因为代码量有点多,上面只贴出剖析后果,具体代码曾经上传github
:https://github.com/asong2020/…
咱们先定义一个根底字符串:
var base = "123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASFGHJKLZXCVBNM"
大量字符串拼接的测试咱们就采纳拼接一次的形式验证,base 拼接 base,因而得出 benckmark 后果:
goos: darwin
goarch: amd64
pkg: asong.cloud/Golang_Dream/code_demo/string_join/once
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkSumString-16 21338802 49.19 ns/op 128 B/op 1 allocs/op
BenchmarkSprintfString-16 7887808 140.5 ns/op 160 B/op 3 allocs/op
BenchmarkBuilderString-16 27084855 41.39 ns/op 128 B/op 1 allocs/op
BenchmarkBytesBuffString-16 9546277 126.0 ns/op 384 B/op 3 allocs/op
BenchmarkJoinstring-16 24617538 48.21 ns/op 128 B/op 1 allocs/op
BenchmarkByteSliceString-16 10347416 112.7 ns/op 320 B/op 3 allocs/op
PASS
ok asong.cloud/Golang_Dream/code_demo/string_join/once 8.412s
大量字符串拼接的测试咱们先构建一个长度为 200 的字符串切片:
var baseSlice []string
for i := 0; i < 200; i++ {baseSlice = append(baseSlice, base)
}
而后遍历这个切片一直的进行拼接,因为能够得出benchmark
:
goos: darwin
goarch: amd64
pkg: asong.cloud/Golang_Dream/code_demo/string_join/muliti
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkSumString-16 7396 163612 ns/op 1277713 B/op 199 allocs/op
BenchmarkSprintfString-16 5946 202230 ns/op 1288552 B/op 600 allocs/op
BenchmarkBuilderString-16 262525 4638 ns/op 40960 B/op 1 allocs/op
BenchmarkBytesBufferString-16 183492 6568 ns/op 44736 B/op 9 allocs/op
BenchmarkJoinstring-16 398923 3035 ns/op 12288 B/op 1 allocs/op
BenchmarkByteSliceString-16 144554 8205 ns/op 60736 B/op 15 allocs/op
PASS
ok asong.cloud/Golang_Dream/code_demo/string_join/muliti 10.699s
论断
通过两次 benchmark
比照,咱们能够看到当进行大量字符串拼接时,间接应用 +
操作符进行拼接字符串,效率还是挺高的,然而当要拼接的字符串数量上来时,+
操作符的性能就比拟低了;函数 fmt.Sprintf
还是不适宜进行字符串拼接,无论拼接字符串数量多少,性能损耗都很大,还是老老实实做他的字符串格式化就好了;strings.Builder
无论是大量字符串的拼接还是大量的字符串拼接,性能始终都能稳固,这也是为什么 Go
语言官网举荐应用 strings.builder
进行字符串拼接的起因,在应用 strings.builder
时最好应用 Grow
办法进行初步的容量调配,察看 strings.join
办法的 benchmark 就能够发现,因为应用了 grow
办法,提前调配好内存,在字符串拼接的过程中,不须要进行字符串的拷贝,也不须要调配新的内存,这样应用 strings.builder
性能最好,且内存耗费最小。bytes.Buffer
办法性能是低于 strings.builder
的,bytes.Buffer
转化为字符串时从新申请了一块空间,寄存生成的字符串变量,不像 strings.buidler
这样间接将底层的 []byte
转换成了字符串类型返回,这就占用了更多的空间。
同步最初剖析的论断:
无论什么状况下应用 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
:https://github.com/asong2020/…
好啦,本文到这里就完结了,我是asong
,咱们下期见。
欢送关注公众号:Golang 梦工厂