乐趣区

关于golang:Go语言如何高效的进行字符串拼接6种方式进行对比分析

原文链接: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
}

stringStructslice 还是很类似的,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字段次要是做 copycheckbuf 字段是一个 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.joinstrings.builder > bytes.buffer > []byte转换string > “+” > fmt.sprintf

总结

本文咱们针对 6 种字符串的拼接形式进行介绍,并通过 benckmark 比照了效率,无论什么时候应用 strings.builder 都不会错,然而在大量字符串拼接时,间接 + 也就是更优的形式,具体业务场景具体分析,不要一概而论。

文中代码已上传github:https://github.com/asong2020/…

好啦,本文到这里就完结了,我是asong,咱们下期见。

欢送关注公众号:Golang 梦工厂

退出移动版