在Go中串联字符串的方法

276 阅读8分钟

在Go中,有几种字符串连接的方法。最简单的方法(但不是最好的方法)是使用加号 (+) 操作符将两个字符串相加。你也可以使用 fmt.Sprintf()函数,如果你有几个字符串想合并成一个的话。一个更有效的串联字符串的方法是使用 strings.Builder结构,这样可以最大限度地减少内存拷贝,建议用于建立较长的结果。另外,如果你知道所有的字符串,你也可以使用 strings.Join()函数,如果你在调用它之前知道所有要连接的字符串和它们的分隔符,或者使用 bytes.Buffer和字节片来直接对字符串的字节进行操作。


没有一个最好的方法来连接字符串,所以了解大多数的方法,它们的优点和缺点,并根据你的使用情况来使用它们,是一个好主意。

下面的例子中的所有基准都是在Go 1.17中运行的。

使用加号 (+) 操作符简单地连接字符串

使用加号 (+) 操作符是最简单的字符串连接方式。但是,在调用这个方法时要注意,因为Go中的字符串是不可变的,每次向现有变量添加字符串时,都会在内存中分配一个新的字符串。因此,如果你需要连接多个字符串,例如在一个循环中,这个方法的效率很低。在这种情况下,你应该使用strings.Builder 方法来建立最终的字符串。

这个例子和下面所有的例子给出的输出是:Hello https://gosamples.dev 😉。

package main
import "fmt"
func main() {
hello := "Hello"
gosamples := "https://gosamples.dev"
result := hello
result += " "
result += gosamples
fmt.Println(result)
}

使用加号 (+) 操作符连接字符串的利弊:

优点

  • 连接字符串的最简单方法
  • 不需要使用外部依赖性

缺点

  • 当用于连接一长串字符串时效率不高
  • 在建立格式化的字符串时,可读性比fmt.Sprintf() 方法差

使用fmt.Sprintf() 函数将多个字符串格式化为一个字符串

fmt.Sprintf()函数接受一个模板和参数,并返回这个模板,同时用参数替换相应的字段。这是创建具有可变值的短字符串的最成文和可读的方法,但当你事先不知道字符串的数量时,不适合串联。

package main
import "fmt"
func main() {
hello := "Hello"
gosamples := "https://gosamples.dev"
result := fmt.Sprintf("%s %s", hello, gosamples)
fmt.Println(result)
}

使用函数连接字符串的优点和缺点 fmt.Sprintf()函数连接字符串的优缺点:

优点

  • 用变值的方式创建字符串的清晰和习惯的方法
  • 允许从不同类型的参数(如字符串、int、bool等)轻松创建字符串,无需明确转换

缺点

  • 当你不知道要连接的元素数量时,不适合。
  • 对于一个较长的参数列表来说是不方便的

使用strings.Builder 来有效地连接字符串

创建 strings.Builder是为了以一种简单有效的方式建立长字符串。这种方法最大限度地减少了拷贝和内存分配的数量,如果你有一个大的字符串列表需要连接,或者建立结果字符串的过程是多步骤的,那么这种方法的效果特别好。如果你需要有效地进行字符串连接,这种方法是Go中最值得推荐和自然的选择。

package main
import (
"fmt"
"log"
"strings"
)
func main() {
data := []string{"Hello", " ", "https://gosamples.dev"}
var builder strings.Builder
for _, s := range data {
_, err := builder.WriteString(s)
if err != nil {
log.Fatal(err)
}
}
fmt.Println(builder.String())
}

如果你事先知道输出的大小,使用 Grow()方法来预先分配所需的内存。这样可以避免不必要的复制部分结果,从而提高连接的速度。

builder.Grow(27)

使用连接法连接字符串的优点和缺点 strings.Builder:

优点

  • 对于连接一长串字符串或分多步建立一个字符串来说是有效的

缺点

  • 使用起来比前几种方法复杂

使用strings.Join() 函数从一个片断中创建一个单一的字符串

使用 strings.Join()通过连接固定的字符串片断,并在它们之间定义一个分隔符,构建一个单一的字符串。它在内部使用 strings.Builder在内部使用。由于要连接的元素的数量是已知的,它分配了必要的内存量,这使得这种方法非常有效。

package main
import (
"fmt"
"strings"
)
func main() {
hello := "Hello"
gosamples := "https://gosamples.dev"
result := strings.Join([]string{hello, gosamples}, " ")
fmt.Println(result)
}

使用函数连接字符串的优点和缺点 strings.Join()函数连接字符串的优点和缺点:

优点

  • 在连接具有相同分隔符的固定字符串列表时效率极高
  • 简单而容易使用

缺点

  • 当你事先不知道要连接的元素数量,或者你想使用不同的分隔符时,不适合。

使用bytes.Buffer 来有效地建立一个字节缓冲区

在Go 1.10中引入了 strings.Builder是在Go 1.10中引入的。在此之前, bytes.Buffer是用来有效地连接字符串的。它有类似的方法,但速度稍慢,所以在新的代码中,你应该使用 strings.Builder来代替。

package main
import (
"bytes"
"fmt"
"log"
)
func main() {
data := []string{"Hello", " ", "https://gosamples.dev"}
var buffer bytes.Buffer
for _, s := range data {
_, err := buffer.WriteString(s)
if err != nil {
log.Fatal(err)
}
}
fmt.Println(buffer.String())
}

strings.Builder你可以使用 Grow()方法来预先分配所需的内存。

buffer.Grow(27)

使用连接法连接字符串的优点和缺点 bytes.Buffer:

优点

  • 在连接长的字符串列表或分多步建立字符串时效率很高

缺点

  • 从Go 1.10开始,有了 strings.Builder它有类似的方法,而且效率更高

使用字节片来扩展一个字符串

Go中的字符串是只读的字节片,所以通过附加另一个字符串的字节来扩展一个字符串的字节片是没有问题的。结果是,在转换为字符串后,我们得到了串联的输出。然而,这种方法是低级的,而且不如其他方法习惯。在实践中,最好是使用 strings.Builder来代替。

package main
import (
"fmt"
)
func main() {
data := []string{"Hello", " ", "https://gosamples.dev"}
var byteSlice []byte
for _, s := range data {
byteSlice = append(byteSlice, []byte(s)...)
}
fmt.Println(string(byteSlice))
}

使用附加到字节片的方式连接字符串的优点和缺点:

优点

  • 易于使用
  • 不需要任何依赖性

缺点

  • 在字节片而不是字符串上工作--需要最终转换为字符串

  • 效率不如 bytes.Buffer

基准

为了检查哪种连接字符串的方法最快,我们准备了一个简单的基准,对上述所有方法进行比较。每种方法都将399个元素串联成一个结果。我们模拟了两种串联的变体:当我们事先知道结果字符串的大小时(基准名为Benchmark<XXX>KnownSize ),以及当我们不知道结果字符串的确切大小时(基准名为Benchmark<XXX>UnknownSize )。我们这样做是因为有些方法只有在我们知道要连接的元素数量时才适合(strings.Join(), fmt.Sprintf()),有些方法在不考虑元素数量的情况下也能工作(加号(+)运算符),有些方法在两种变体中都能工作(.strings.Builder, bytes.Buffer,字节片)。

  • strings.Join()需要一个字符串片断作为参数,所以在调用这个函数之前必须知道要连接的元素的数量。
  • fmt.Sprintf()对有限的和已知的参数数起作用,等于模板中的参数数。
  • 加号(+)运算符一次只能连接一个参数(result += argument),而且你不能指定你有多少个元素,也不能为结果预先分配固定的内存。
  • strings.Builderbytes.Buffer可以在我们不知道最终结果中的元素数量时建立字符串。必要时,它们为输出分配更多的内存。因此,这些方法对于动态创建字符串很有效,例如,在一个循环中。另一方面,当我们知道要连接的元素数和结果的大小时,我们可以立即用 Grow()方法(可在 strings.Builder以及 bytes.Buffer),避免了不必要的复制部分结果。
  • 字节片,如 strings.Builderbytes.Buffer,可以使用var byteSlice []byte ,初始化为空,或者使用byteSlice := make([]byte, 0, size) ,初始化为指定的容量。这样一来,这个方法既可以用已知的元素数,也可以不指定结果的大小。

最快的方法并不意味着最好。要始终考虑代码的可读性,并尽量使方法适合于用例。

package benchmark

import (
    "bytes"
    "fmt"
    "strings"
    "testing"
)

const (
    hello     = "hello"
    gosamples = "https://gosamples.dev"
)

func generateStrings(withSeparator bool) (data []string, size int) {
    for i := 0; i < 100; i++ {
        data = append(data, hello)
        size += len(hello)
        if withSeparator {
            data = append(data, " ")
            size++
        }
        data = append(data, gosamples)
        size += len(gosamples)
        if withSeparator {
            data = append(data, " ")
            size++
        }
    }
    if withSeparator {
        data = data[:len(data)-1]
        size--
    }

    return data, size
}

func BenchmarkPlusOperatorUnknownSize(b *testing.B) {
    data, _ := generateStrings(true)
    var s string
    for n := 0; n < b.N; n++ {
        for _, d := range data {
            s += d
        }
        _ = s
    }
}

func BenchmarkSprintfKnownSize(b *testing.B) {
    data, _ := generateStrings(false)
    var interfaceData []interface{}
    for _, d := range data {
        interfaceData = append(interfaceData, d)
    }
    format := strings.Repeat("%s ", len(interfaceData))
    format = strings.TrimSuffix(format, " ")
    var s string
    for n := 0; n < b.N; n++ {
        s = fmt.Sprintf(format, interfaceData...)
        _ = s
    }
}

func BenchmarkStringBuilderUnknownSize(b *testing.B) {
    data, _ := generateStrings(true)
    var s string
    for n := 0; n < b.N; n++ {
        var builder strings.Builder
        for _, s := range data {
            builder.WriteString(s)
        }
        s = builder.String()
        _ = s
    }
}

func BenchmarkStringBuilderKnownSize(b *testing.B) {
    data, size := generateStrings(true)
    var s string
    for n := 0; n < b.N; n++ {
        var builder strings.Builder
        builder.Grow(size)
        for _, s := range data {
            builder.WriteString(s)
        }
        s = builder.String()
        _ = s
    }
}

func BenchmarkJoinKnownSize(b *testing.B) {
    data, _ := generateStrings(false)
    var s string
    for n := 0; n < b.N; n++ {
        s = strings.Join(data, " ")
        _ = s
    }
}

func BenchmarkBytesBufferUnknownSize(b *testing.B) {
    data, _ := generateStrings(true)
    var s string
    for n := 0; n < b.N; n++ {
        var buffer bytes.Buffer
        for _, s := range data {
            buffer.WriteString(s)
        }
        s = buffer.String()
        _ = s
    }
}

func BenchmarkBytesBufferKnownSize(b *testing.B) {
    data, size := generateStrings(true)
    var s string
    for n := 0; n < b.N; n++ {
        var buffer bytes.Buffer
        buffer.Grow(size)
        for _, s := range data {
            buffer.WriteString(s)
        }
        s = buffer.String()
        _ = s
    }
}

func BenchmarkByteSliceUnknownSize(b *testing.B) {
    data, _ := generateStrings(true)
    var s string
    for n := 0; n < b.N; n++ {
        var byteSlice []byte
        for _, s := range data {
            byteSlice = append(byteSlice, []byte(s)...)
        }
        s = string(byteSlice)
        _ = s
    }
}

func BenchmarkByteSliceKnownSize(b *testing.B) {
    data, size := generateStrings(true)
    var s string
    for n := 0; n < b.N; n++ {
        byteSlice := make([]byte, 0, size)
        for _, s := range data {
            byteSlice = append(byteSlice, []byte(s)...)
        }
        s = string(byteSlice)
        _ = s
    }
}

用命令运行基准测试:

go test -bench=.

我们得到了结果:

BenchmarkPlusOperatorUnknownSize-8 166 12008232 ns/op
BenchmarkSprintfKnownSize-8 184053 6421 ns/op
BenchmarkStringBuilderUnknownSize-8 269620 4365 ns/op
BenchmarkStringBuilderKnownSize-8 422790 2735 ns/op
BenchmarkJoinKnownSize-8 475293 2370 ns/op
BenchmarkBytesBufferUnknownSize-8 219260 5441 ns/op
BenchmarkBytesBufferKnownSize-8 321973 3639 ns/op
BenchmarkByteSliceUnknownSize-8 175533 6803 ns/op
BenchmarkByteSliceKnownSize-8 230568 5046 ns/op

该基准也以Github Gist的形式提供。

  • 正如你所看到的,加号(+)运算符比其他方法慢得多,你不应该用它们来连接一个较长的元素列表。
  • 就性能而言,最好是使用 strings.Builderstrings.Join()来连接一个长的字符串列表。在我们的例子中。 strings.Join()的速度甚至比 strings.Builder.
  • 当你预先分配好输出所需的内存量时,你会得到一个很大的加速。
  • bytes.Buffer和byte slice方法更慢,而且不像.NET那样可读。 strings.Builder因为它们操作的是字节而不是字符串。所以,它们不应该是你进行字符串连接的第一选择。