Go 字符串拼接6种,最快的方式 -- strings.builder
<p data-tool="mdnice编辑器">我们首先来了解一下<code>Go</code>语言中<code>string</code>类型的结构定义,先来看一下官方定义:</p><pre data-tool="mdnice编辑器"><code>// string is the set of all strings of 8-bit bytes, conventionally but not<br>// necessarily representing UTF-8-encoded text. A string may be empty, but<br>// not nil. Values of string type are immutable.<br>type string string<br></code></pre>
<p data-tool="mdnice编辑器"><code>string</code>是一个<code>8</code>位字节的集合,通常但不一定代表UTF-8编码的文本。string可以为空,但是不能为nil。<strong>string的值是不能改变的</strong>。</p>
<p data-tool="mdnice编辑器"><code>string</code>类型本质也是一个结构体,定义如下:</p>
<pre data-tool="mdnice编辑器"><code>type stringStruct struct {<br> str unsafe.Pointer<br> len int<br>}<br></code></pre>
<p data-tool="mdnice编辑器"><code>stringStruct</code>和<code>slice</code>还是很相似的,<code>str</code>指针指向的是某个数组的首地址,<code>len</code>代表的就是数组长度。怎么和<code>slice</code>这么相似,底层指向的也是数组,是什么数组呢?我们看看他在实例化时调用的方法:</p>
<pre data-tool="mdnice编辑器"><code>//go:nosplit<br>func gostringnocopy(str *byte) string {<br> ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}<br> s := *(*string)(unsafe.Pointer(&ss))<br> return s<br>}<br></code></pre>
<p data-tool="mdnice编辑器">入参是一个<code>byte</code>类型的指针,从这我们可以看出<code>string</code>类型底层是一个<code>byte</code>类型的数组,所以我们可以画出这样一个图片:</p>
<p><img alt="图片" class="rich_pages wxw-img lazyload" data-ratio="0.3633136094674556" data-src="https://mmbiz.qpic.cn/mmbiz_png/CqB2u93NwB8jz2ZBH2msPUWSCLIugWVQ7mxjcKQibyiaUKMphn2pvbul5dCSKqZORuwbpEXJJicnDZHiaRbjMQg0eQ/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1" data-type="png" data-w="845" data-fail="0">图片</p>
<p data-tool="mdnice编辑器"><code>string</code>类型本质上就是一个<code>byte</code>类型的数组,在<code>Go</code>语言中<code>string</code>类型被设计为不可变的,不仅是在<code>Go</code>语言,其他语言中<code>string</code>类型也是被设计为不可变的,这样的好处就是:在并发场景下,我们可以在不加锁的控制下,多次使用同一字符串,在保证高效共享的情况下而不用担心安全问题。</p>
<p data-tool="mdnice编辑器"><code>string</code>类型虽然是不能更改的,但是可以被替换,因为<code>stringStruct</code>中的<code>str</code>指针是可以改变的,只是指针指向的内容是不可以改变的,也就说每一个更改字符串,就需要重新分配一次内存,之前分配的空间会被<code>gc</code>回收。</p>
<p data-tool="mdnice编辑器">关于<code>string</code>类型的知识点就描述这么多,方便我们后面分析字符串拼接。</p>
<h2 data-tool="mdnice编辑器">字符串拼接的6种方式及原理</h2>
<h3 data-tool="mdnice编辑器">原生拼接方式"+"</h3>
<p data-tool="mdnice编辑器"><code>Go</code>语言原生支持使用<code>+</code>操作符直接对两个字符串进行拼接,使用例子如下:</p>
<pre data-tool="mdnice编辑器"><code>var s string<br>s += "asong"<br>s += "真帅"<br></code></pre>
<p data-tool="mdnice编辑器">这种方式使用起来最简单,基本所有语言都有提供这种方式,使用<code>+</code>操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。</p>
<h3 data-tool="mdnice编辑器">字符串格式化函数<code>fmt.Sprintf</code></h3>
<p data-tool="mdnice编辑器"><code>Go</code>语言中默认使用函数<code>fmt.Sprintf</code>进行字符串格式化,所以也可使用这种方式进行字符串拼接:</p>
<pre data-tool="mdnice编辑器"><code>str := "asong"<br>str = fmt.Sprintf("%s%s", str, str)<br></code></pre>
<p data-tool="mdnice编辑器"><code>fmt.Sprintf</code>实现原理主要是使用到了反射,具体源码分析因为篇幅的原因就不在这里详细分析了,看到反射,就会产生性能的损耗,你们懂得!!!</p>
<h3 data-tool="mdnice编辑器">Strings.builder</h3>
<p data-tool="mdnice编辑器"><code>Go</code>语言提供了一个专门操作字符串的库<code>strings</code>,使用<code>strings.Builder</code>可以进行字符串拼接,提供了<code>writeString</code>方法拼接字符串,使用方式如下:</p>
<pre data-tool="mdnice编辑器"><code>var builder strings.Builder<br>builder.WriteString("asong")<br>builder.String()<br></code></pre>
<p data-tool="mdnice编辑器"><code>strings.builder</code>的实现原理很简单,结构如下:</p>
<pre data-tool="mdnice编辑器"><code>type Builder struct {<br> addr *Builder // of receiver, to detect copies by value<br> buf []byte // 1<br>}<br></code></pre>
<p data-tool="mdnice编辑器"><code>addr</code>字段主要是做<code>copycheck</code>,<code>buf</code>字段是一个<code>byte</code>类型的切片,这个就是用来存放字符串内容的,提供的<code>writeString()</code>方法就是像切片<code>buf</code>中追加数据:</p>
<pre data-tool="mdnice编辑器"><code>func (b *Builder) WriteString(s string) (int, error) {<br> b.copyCheck()<br> b.buf = append(b.buf, s...)<br> return len(s), nil<br>}<br></code></pre>
<p data-tool="mdnice编辑器">提供的<code>String</code>方法就是将<code>[]]byte</code>转换为<code>string</code>类型,这里为了避免内存拷贝的问题,使用了强制转换来避免内存拷贝:</p>
<pre data-tool="mdnice编辑器"><code>func (b *Builder) String() string {<br> return *(*string)(unsafe.Pointer(&b.buf))<br>}<br></code></pre>
<h3 data-tool="mdnice编辑器">bytes.Buffer</h3>
<p data-tool="mdnice编辑器">因为<code>string</code>类型底层就是一个<code>byte</code>数组,所以我们就可以<code>Go</code>语言的<code>bytes.Buffer</code>进行字符串拼接。<code>bytes.Buffer</code>是一个一个缓冲<code>byte</code>类型的缓冲器,这个缓冲器里存放着都是<code>byte</code>。使用方式如下:</p>
<pre data-tool="mdnice编辑器"><code>buf := new(bytes.Buffer)<br>buf.WriteString("asong")<br>buf.String()<br></code></pre>
<p data-tool="mdnice编辑器"><code>bytes.buffer</code>底层也是一个<code>[]byte</code>切片,结构体如下:</p>
<pre data-tool="mdnice编辑器"><code>type Buffer struct {<br> buf []byte // contents are the bytes buf<br> off int // read at &buf, write at &buf<br> lastRead readOp // last read operation, so that Unread* can work correctly.<br>}<br></code></pre>
<p data-tool="mdnice编辑器">因为<code>bytes.Buffer</code>可以持续向<code>Buffer</code>尾部写入数据,从<code>Buffer</code>头部读取数据,所以<code>off</code>字段用来记录读取位置,再利用切片的<code>cap</code>特性来知道写入位置,这个不是本次的重点,重点看一下<code>WriteString</code>方法是如何拼接字符串的:</p>
<pre data-tool="mdnice编辑器"><code>func (b *Buffer) WriteString(s string) (n int, err error) {<br> b.lastRead = opInvalid<br> m, ok := b.tryGrowByReslice(len(s))<br> if !ok {<br> m = b.grow(len(s))<br> }<br> return copy(b.buf, s), nil<br>}<br></code></pre>
<p data-tool="mdnice编辑器">切片在创建时并不会申请内存块,只有在往里写数据时才会申请,首次申请的大小即为写入数据的大小。如果写入的数据小于64字节,则按64字节申请。采用动态扩展<code>slice</code>的机制,字符串追加采用<code>copy</code>的方式将追加的部分拷贝到尾部,<code>copy</code>是内置的拷贝函数,可以减少内存分配。</p>
<p data-tool="mdnice编辑器">但是在将<code>[]byte</code>转换为<code>string</code>类型依旧使用了标准类型,所以会发生内存分配:</p>
<pre data-tool="mdnice编辑器"><code>func (b *Buffer) String() string {<br> if b == nil {<br> // Special case, useful in debugging.<br> return "<nil>"<br> }<br> return string(b.buf)<br>}<br></code></pre>
<h3 data-tool="mdnice编辑器">strings.join</h3>
<p data-tool="mdnice编辑器"><code>Strings.join</code>方法可以将一个<code>string</code>类型的切片拼接成一个字符串,可以定义连接操作符,使用如下:</p>
<pre data-tool="mdnice编辑器"><code>baseSlice := []string{"asong", "真帅"}<br>strings.Join(baseSlice, "")<br></code></pre>
<p data-tool="mdnice编辑器"><code>strings.join</code>也是基于<code>strings.builder</code>来实现的,代码如下:</p>
<pre data-tool="mdnice编辑器"><code>func Join(elems []string, sep string) string {<br> switch len(elems) {<br> case 0:<br> return ""<br> case 1:<br> return elems<br> }<br> n := len(sep) * (len(elems) - 1)<br> for i := 0; i < len(elems); i++ {<br> n += len(elems)<br> }<br><br> var b Builder<br> b.Grow(n)<br> b.WriteString(elems)<br> for _, s := range elems {<br> b.WriteString(sep)<br> b.WriteString(s)<br> }<br> return b.String()<br>}<br></code></pre>
<p data-tool="mdnice编辑器">唯一不同在于在<code>join</code>方法内调用了<code>b.Grow(n)</code>方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。</p>
<h3 data-tool="mdnice编辑器">切片<code>append</code></h3>
<p data-tool="mdnice编辑器">因为<code>string</code>类型底层也是<code>byte</code>类型数组,所以我们可以重新声明一个切片,使用<code>append</code>进行字符串拼接,使用方式如下:</p>
<pre data-tool="mdnice编辑器"><code>buf := make([]byte, 0)<br>base = "asong"<br>buf = append(buf, base...)<br>string(base)<br></code></pre>
<p data-tool="mdnice编辑器">如果想减少内存分配,在将<code>[]byte</code>转换为<code>string</code>类型时可以考虑使用强制转换。</p>
<h2 data-tool="mdnice编辑器">Benchmark对比</h2>
<p data-tool="mdnice编辑器">上面我们总共提供了6种方法,原理我们基本知道了,那么我们就使用<code>Go</code>语言中的<code>Benchmark</code>来分析一下到底哪种字符串拼接方式更高效。我们主要分两种情况进行分析:</p>
<ul class="list-paddingleft-2" data-tool="mdnice编辑器">
<li>少量字符串拼接</li>
<li>大量字符串拼接</li>
</ul>
<p data-tool="mdnice编辑器">因为代码量有点多,下面只贴出分析结果,详细代码已经上传<code>github</code>:https://github.com/asong2020/Golang_Dream/tree/master/code_demo/string_join</p>
<p data-tool="mdnice编辑器">我们先定义一个基础字符串:</p>
<pre data-tool="mdnice编辑器"><code>var base = "123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASFGHJKLZXCVBNM"<br></code></pre>
<p data-tool="mdnice编辑器">少量字符串拼接的测试我们就采用拼接一次的方式验证,base拼接base,因此得出benckmark结果:</p>
<pre data-tool="mdnice编辑器"><code>goos: darwin<br>goarch: amd64<br>pkg: asong.cloud/Golang_Dream/code_demo/string_join/once<br>cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz<br>BenchmarkSumString-16 21338802 49.19 ns/op 128 B/op 1 allocs/op<br>BenchmarkSprintfString-16 7887808 140.5 ns/op 160 B/op 3 allocs/op<br>BenchmarkBuilderString-16 27084855 41.39 ns/op 128 B/op 1 allocs/op<br>BenchmarkBytesBuffString-16 9546277 126.0 ns/op 384 B/op 3 allocs/op<br>BenchmarkJoinstring-16 24617538 48.21 ns/op 128 B/op 1 allocs/op<br>BenchmarkByteSliceString-16 10347416 112.7 ns/op 320 B/op 3 allocs/op<br>PASS<br>ok asong.cloud/Golang_Dream/code_demo/string_join/once 8.412s<br></code></pre>
<p data-tool="mdnice编辑器">大量字符串拼接的测试我们先构建一个长度为200的字符串切片:</p>
<pre data-tool="mdnice编辑器"><code>var baseSlice []string<br>for i := 0; i < 200; i++ {<br> baseSlice = append(baseSlice, base)<br>}<br></code></pre>
<p data-tool="mdnice编辑器">然后遍历这个切片不断的进行拼接,因为可以得出<code>benchmark</code>:</p>
<pre data-tool="mdnice编辑器"><code>goos: darwin<br>goarch: amd64<br>pkg: asong.cloud/Golang_Dream/code_demo/string_join/muliti<br>cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz<br>BenchmarkSumString-16 7396 163612 ns/op 1277713 B/op 199 allocs/op<br>BenchmarkSprintfString-16 5946 202230 ns/op 1288552 B/op 600 allocs/op<br>BenchmarkBuilderString-16 262525 4638 ns/op 40960 B/op 1 allocs/op<br>BenchmarkBytesBufferString-16 183492 6568 ns/op 44736 B/op 9 allocs/op<br>BenchmarkJoinstring-16 398923 3035 ns/op 12288 B/op 1 allocs/op<br>BenchmarkByteSliceString-16 144554 8205 ns/op 60736 B/op 15 allocs/op<br>PASS<br>ok asong.cloud/Golang_Dream/code_demo/string_join/muliti 10.699s<br></code></pre>
<h3 data-tool="mdnice编辑器">结论</h3>
<p data-tool="mdnice编辑器">通过两次<code>benchmark</code>对比,我们可以看到</p>
<ul class="list-paddingleft-2" data-tool="mdnice编辑器">
<li>当进行少量字符串拼接时,直接使用<code>+</code>操作符进行拼接字符串,效率还是挺高的,但是当要拼接的字符串数量上来时,<code>+</code>操作符的性能就比较低了;</li>
<li>函数<code>fmt.Sprintf</code>还是不适合进行字符串拼接,无论拼接字符串数量多少,性能损耗都很大,还是老老实实做他的字符串格式化就好了;</li>
<li><code>strings.Builder</code>无论是少量字符串的拼接还是大量的字符串拼接,性能一直都能稳定,这也是为什么<code>Go</code>语言官方推荐使用<code>strings.builder</code>进行字符串拼接的原因,在使用<code>strings.builder</code>时最好使用<code>Grow</code>方法进行初步的容量分配,观察<code>strings.join</code>方法的benchmark就可以发现,因为使用了<code>grow</code>方法,提前分配好内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,这样使用<code>strings.builder</code>性能最好,且内存消耗最小。</li>
<li><code>bytes.Buffer</code>方法性能是低于<code>strings.builder</code>的,<code>bytes.Buffer</code> 转化为字符串时重新申请了一块空间,存放生成的字符串变量,不像<code>strings.buidler</code>这样直接将底层的 <code>[]byte</code> 转换成了字符串类型返回,这就占用了更多的空间。</li>
</ul>
<p data-tool="mdnice编辑器">同步最后分析的结论:</p>
<p data-tool="mdnice编辑器">无论什么情况下使用<code>strings.builder</code>进行字符串拼接都是最高效的,不过要主要使用方法,记得调用<code>grow</code>进行容量分配,才会高效。<code>strings.join</code>的性能约等于<code>strings.builder</code>,在已经字符串slice的时候可以使用,未知时不建议使用,构造切片也是有性能损耗的;如果进行少量的字符串拼接时,直接使用<code>+</code>操作符是最方便也是性能最高的,可以放弃<code>strings.builder</code>的使用。</p>
<p data-tool="mdnice编辑器">综合对比性能排序:</p>
<pre data-tool="mdnice编辑器"><code>strings.join` ≈ `strings.builder` > `bytes.buffer` > `[]byte`转换`string` > "+" > `fmt.sprintf<br></code></pre>
<h2 data-tool="mdnice编辑器">总结</h2>
<p data-tool="mdnice编辑器">本文我们针对<code>6</code>种字符串的拼接方式进行介绍,并通过<code>benckmark</code>对比了效率,无论什么时候使用<code>strings.builder</code>都不会错,但是在少量字符串拼接时,直接<code>+</code>也就是更优的方式,具体业务场景具体分析,不要一概而论</p><br><br>
来源:https://www.cnblogs.com/cheyunhua/p/15769717.html
頁:
[1]