如何在Go语言中高效地拼接字符串

966
在Go语言中,string是一种原始类型,这意味着它是只读的,对其进行任何操作都会创建一个新字符串。因此,如果我想要多次连接字符串而不知道结果字符串的长度,最好的方法是什么?幼稚的方式是:
var s string
for i := 0; i < 1000; i++ {
    s += getShortStringFromSomewhere()
}
return s

但那似乎不是很高效。


12
还需要一个长凳 - Ivan Black
1
注意:此问题和大多数答案似乎是在append()进入语言之前编写的,这是一个很好的解决方案。它将像copy()一样快速执行,但即使这意味着需要分配一个新的后备数组来增加切片的容量,它也会先增加切片。如果你想使用其附加的便利方法或你正在使用的包期望它,bytes.Buffer仍然是有意义的。 - thomasrutter
10
它不仅仅是“看起来非常低效”,而是存在一个特定的问题,每个我们雇佣的新的非计算机科学专业人员在工作的前几周都会遇到。这是二次的 - O(n*n)。想一下数字序列: 1 + 2 + 3 + 4 + ...。它是n*(n+1)/2,即基于n的三角形面积。当您在循环中附加不可变字符串时,您分配大小为1,然后大小为2,然后大小为3等等。这种二次资源消耗表现方式不仅限于此。 - Rob
20个回答

1052

新方法:

从Go 1.10开始,有一个strings.Builder类型,请参考此答案以获取更多细节

旧方法:

使用bytes包。它有一个Buffer类型,实现了io.Writer接口。

package main

import (
    "bytes"
    "fmt"
)

func main() {
    var buffer bytes.Buffer

    for i := 0; i < 1000; i++ {
        buffer.WriteString("a")
    }

    fmt.Println(buffer.String())
}

这个算法的时间复杂度为O(n)。


31
不使用println(string(buffer.Bytes())),而是使用println(buffer.String())。 - FigmentEngine
30
您可以使用 var buffer bytes.Buffer 替换 buffer := bytes.NewBufferString("")。您还不需要任何分号 : )。 - crazy2be
79
速度惊人。在我的程序中做了一些天真的“+”字符串连接,将运行时间从3分钟缩短到了1.3秒。 - Malcolm
21
+1 表示“O(n) 时间复杂度”,我认为增加类似这样的注释很重要。 - contradictioned
13
Go 1.10 新增了 strings.Builder ,它类似于 bytes.Buffer,但在目标字符串时速度更快。 - Josh Bleecher Snyder
显示剩余6条评论

559
在 Go 1.10+ 中,有一个名为 strings.Builder 的工具,在这里

使用 Builder 可以高效地构建字符串,它通过 Write 方法最小化了内存复制。零值可以直接使用。


例子

它几乎与bytes.Buffer相同。

package main

import (
    "strings"
    "fmt"
)

func main() {
    // ZERO-VALUE:
    //
    // It's ready to use from the get-go.
    // You don't need to initialize it.
    var sb strings.Builder

    for i := 0; i < 1000; i++ {
        sb.WriteString("a")
    }

    fmt.Println(sb.String())
}

点击查看在游乐场上的演示


支持的接口

strings.Builder 的方法是按照现有接口进行实现的,以便您可以轻松地在代码中切换到新的 Builder 类型。

方法签名 接口 描述
Grow(int) bytes.Buffer 按指定的数量增加缓冲区的容量。有关更多信息,请参见bytes.Buffer#Grow
Len() int bytes.Buffer 返回缓冲区中的字节数。有关更多信息,请参见bytes.Buffer#Len
Reset() bytes.Buffer 将缓冲区重置为空。有关更多信息,请参见bytes.Buffer#Reset
String() string fmt.Stringer 将缓冲区的内容作为字符串返回。有关更多信息,请参见fmt.Stringer
Write([]byte) (int, error) io.Writer 将给定的字节写入缓冲区。有关更多信息,请参见io.Writer
WriteByte(byte) error io.ByteWriter 将给定的字节写入缓冲区。有关更多信息,请参见io.ByteWriter
WriteRune(rune) (int, error) bufio.Writerbytes.Buffer 将给定的符文写入缓冲区。有关更多信息,请参见bufio.Writer#WriteRunebytes.Buffer#WriteRune
WriteString(string) (int, error) io.stringWriter 将给定的字符串写入缓冲区。有关更多信息,请参见io.stringWriter

与bytes.Buffer的区别

  • 它只能增长或重置。
  • 它内置了一个copyCheck机制,可以防止意外复制。在bytes.Buffer中,可以像这样访问底层字节:(*Buffer).Bytes()strings.Builder解决了这个问题。但有时,这不是问题,反而是所期望的。例如:当字节被传递给io.Reader等时,需要查看其行为。
  • bytes.Buffer.Reset() 重新定位并重用底层缓冲区,而strings.Builder.Reset()不会,它会分离缓冲区。

注意

  • 不要复制strings.Builder 值,因为它会缓存底层数据。
  • 如果您想共享一个strings.Builder 值,请使用指向它的指针。

查看其源代码以获取更多详细信息,在这里


5
“escape” 是什么意思?您是指字符串中的转义,还是指底层字节可以被暴露出来? - makhdumi
1
@makhdumi 是的,第二个是暴露底层字节。 - Inanc Gumus
1
另一个可能很重要的区别是:strings.Builder.Reset()将底层切片设置为nil(不重用内存)。而bytes.Buffer.Reset()[]bytes设置为空长度,保持底层数组分配。当我在sync.Pool中重用strings.Builder时,这个问题困扰了我,看起来完全没有用处。 - Tim
尽管听起来有点学究,但在某些情况下,OP的简单字符串连接比使用strings.Builder更高效,例如当迭代次数为1000(甚至更多)时。因此,更高效取决于上下文。 - bdutta74
始终要对您的代码进行基准测试,以决定使用什么。 - Inanc Gumus
显示剩余2条评论

293

如果你知道预先分配字符串的总长度,那么最高效的字符串拼接方式可能是使用内置函数copy。如果你事先不知道总长度,请勿使用copy,请阅读其他答案。

在我的测试中,这种方法比使用bytes.Buffer要快大约3倍,并且比使用操作符+要快得多(约为12000倍)。此外,它使用的内存更少。

我创建了一个测试用例来证明这一点,以下是结果:

BenchmarkConcat  1000000    64497 ns/op   502018 B/op   0 allocs/op
BenchmarkBuffer  100000000  15.5  ns/op   2 B/op        0 allocs/op
BenchmarkCopy    500000000  5.39  ns/op   0 B/op        0 allocs/op

以下是用于测试的代码:

package main

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

func BenchmarkConcat(b *testing.B) {
    var str string
    for n := 0; n < b.N; n++ {
        str += "x"
    }
    b.StopTimer()

    if s := strings.Repeat("x", b.N); str != s {
        b.Errorf("unexpected result; got=%s, want=%s", str, s)
    }
}

func BenchmarkBuffer(b *testing.B) {
    var buffer bytes.Buffer
    for n := 0; n < b.N; n++ {
        buffer.WriteString("x")
    }
    b.StopTimer()

    if s := strings.Repeat("x", b.N); buffer.String() != s {
        b.Errorf("unexpected result; got=%s, want=%s", buffer.String(), s)
    }
}

func BenchmarkCopy(b *testing.B) {
    bs := make([]byte, b.N)
    bl := 0

    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        bl += copy(bs[bl:], "x")
    }
    b.StopTimer()

    if s := strings.Repeat("x", b.N); string(bs) != s {
        b.Errorf("unexpected result; got=%s, want=%s", string(bs), s)
    }
}

// Go 1.10
func BenchmarkStringBuilder(b *testing.B) {
    var strBuilder strings.Builder

    b.ResetTimer()
    for n := 0; n < b.N; n++ {
        strBuilder.WriteString("x")
    }
    b.StopTimer()

    if s := strings.Repeat("x", b.N); strBuilder.String() != s {
        b.Errorf("unexpected result; got=%s, want=%s", strBuilder.String(), s)
    }
}

6
bytes.Buffer 应该基本上与 copy 做相同的事情(可能会有一些额外的簿记),速度差别不大,所以我会使用它 :)。不同之处在于缓冲区从 0 字节开始,因此它必须重新分配内存(这使它看起来有点慢)。不过,使用起来更容易。 - Aktau
6
buffer.Write(字节)比buffer.WriteString快30%。 [如果您可以将数据作为“[]byte”获取,则非常有用] - Dani-Br
37
请注意,基准测试结果是扭曲的并且不真实。不同的基准函数将使用不同的b.N值调用,因此您不能比较执行相同任务所需的时间(例如,一个函数可能会附加1,000个字符串,另一个函数可能会附加10,000个,这可能会对每个附加的平均时间在BenchmarkConcat()中造成很大的差异)。您应该在每种情况下使用相同的附加计数(肯定不是b.N),并在for范围内的体内进行所有串联(也就是说,嵌套2个for循环)。 - icza
19
此外,复制基准测试存在一定偏差,因为它明确忽略了分配所需的时间,而其他基准测试中包括这部分时间。 - danielschemmel
6
此外,复制基准测试依赖于知道结果字符串的长度。 - Skarllot
显示剩余6条评论

152
如果您有一个字符串切片需要高效地转换为字符串,则可以使用此方法。否则,请查看其他答案。
在strings包中有一个库函数叫做Join:http://golang.org/pkg/strings/#Join 查看Join的代码会发现与Kinopiko编写的Append函数类似:https://golang.org/src/strings/strings.go#L420 用法:
import (
    "fmt";
    "strings";
)

func main() {
    s := []string{"this", "is", "a", "joined", "string\n"};
    fmt.Printf(strings.Join(s, " "));
}

$ ./test.bin
this is a joined string

25
当您需要循环遍历不是[]string类型的内容时,它就无法工作。 - Malcolm

47

我刚刚在我的代码中对上面发布的最佳答案进行了基准测试(递归树遍历),发现简单的连接运算符比BufferString更快。

func (r *record) String() string {
    buffer := bytes.NewBufferString("");
    fmt.Fprint(buffer,"(",r.name,"[")
    for i := 0; i < len(r.subs); i++ {
        fmt.Fprint(buffer,"\t",r.subs[i])
    }
    fmt.Fprint(buffer,"]",r.size,")\n")
    return buffer.String()
}

这个操作只需要0.81秒,而以下代码:

func (r *record) String() string {
    s := "(\"" + r.name + "\" ["
    for i := 0; i < len(r.subs); i++ {
        s += r.subs[i].String()
    }
    s += "] " + strconv.FormatInt(r.size,10) + ")\n"
    return s
} 

仅用了0.61秒。这可能是由于创建新的BufferString的开销。

更新:我还对join函数进行了基准测试,其运行时间为0.54秒。

func (r *record) String() string {
    var parts []string
    parts = append(parts, "(\"", r.name, "\" [" )
    for i := 0; i < len(r.subs); i++ {
        parts = append(parts, r.subs[i].String())
    }
    parts = append(parts, strconv.FormatInt(r.size,10), ")\n")
    return strings.Join(parts,"")
}

5
我认为OP更关注的是内存复杂度而不是运行时间复杂度,因为朴素的字符串拼接每次都会导致新的内存分配。 - galaktor
16
使用 fmt.Fprint 而不是 buffer.WriteString("\t");buffer.WriteString(subs[i]); 可能会导致速度变慢。 - Robert Jack Will
我很高兴得知我的首选方法(strings.Join)是最快的,而这篇文章则称(bytes.Buffer)为胜者! - eQ19

32
package main

import (
  "fmt"
)

func main() {
    var str1 = "string1"
    var str2 = "string2"
    out := fmt.Sprintf("%s %s ",str1, str2)
    fmt.Println(out)
}

2
欢迎来到 Stack Overflow!花点时间阅读帮助中心中的编辑帮助。在 Stack Overflow 上,格式与其他网站不同。 - Rizier123
2
虽然这段代码片段可能解决了问题,但包括解释真的有助于提高您的帖子质量。请记住,您正在为未来的读者回答问题,而这些人可能不知道您的代码建议原因。同时,请尽量不要在代码中添加过多的解释性注释,这会降低代码和解释的可读性! - Rizier123
6
这完全没有回答问题。fmt.Sprintf在连接简单字符串时效率最差。根据这个benchfmt.Sprintf的速度甚至比非常低效的加号运算符(+)还要慢。 - Coconut

26
这是最快的解决方案,不需要你首先知道或计算整个缓冲区的大小:
var data []byte
for i := 0; i < 1000; i++ {
    data = append(data, getShortStringFromSomewhere()...)
}
return string(data)

根据我的基准测试,使用此方法比复制方案慢20%(每个附加操作需要8.1纳秒而不是6.72纳秒),但仍然比使用bytes.Buffer快55%。


25
你可以创建一个大的字节切片,并使用字符串切片将短字符串的字节复制到其中。在 "Effective Go" 中提供了一个函数:
func Append(slice, data[]byte) []byte {
    l := len(slice);
    if l + len(data) > cap(slice) { // reallocate
        // Allocate double what's needed, for future growth.
        newSlice := make([]byte, (l+len(data))*2);
        // Copy data (could use bytes.Copy()).
        for i, c := range slice {
            newSlice[i] = c
        }
        slice = newSlice;
    }
    slice = slice[0:l+len(data)];
    for i, c := range data {
        slice[l+i] = c
    }
    return slice;
}

然后当操作完成时,使用string()将大块字节转换回字符串。


有趣的是,在Go语言中有很多种方法可以做到这一点。 - Yitzhak
13
在《Effective Go》中,它也说这个想法非常有用,以至于被捕捉到内置函数中。所以你可以用 append(slice, byte...) 来替换你的函数,看起来是这样的。 - Aktau

24

2018年新增注释

从Go 1.10开始,有一个 strings.Builder 类型,请参考这个答案以获取更多详细信息

201x年之前的回答

@cd1 和其他答案的基准测试代码是错误的。b.N 不应该在基准测试函数中设置。它由 go test 工具动态设置,以确定测试的执行时间是否稳定。

基准测试函数应该运行相同的测试 b.N 次,并且循环内部的测试应该对于每次迭代都是相同的。所以我添加了一个内部循环来修复它。我还为其他一些解决方案添加了基准测试:

package main

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

const (
    sss = "xfoasneobfasieongasbg"
    cnt = 10000
)

var (
    bbb      = []byte(sss)
    expected = strings.Repeat(sss, cnt)
)

func BenchmarkCopyPreAllocate(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        bs := make([]byte, cnt*len(sss))
        bl := 0
        for i := 0; i < cnt; i++ {
            bl += copy(bs[bl:], sss)
        }
        result = string(bs)
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

func BenchmarkAppendPreAllocate(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        data := make([]byte, 0, cnt*len(sss))
        for i := 0; i < cnt; i++ {
            data = append(data, sss...)
        }
        result = string(data)
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

func BenchmarkBufferPreAllocate(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        buf := bytes.NewBuffer(make([]byte, 0, cnt*len(sss)))
        for i := 0; i < cnt; i++ {
            buf.WriteString(sss)
        }
        result = buf.String()
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

func BenchmarkCopy(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        data := make([]byte, 0, 64) // same size as bootstrap array of bytes.Buffer
        for i := 0; i < cnt; i++ {
            off := len(data)
            if off+len(sss) > cap(data) {
                temp := make([]byte, 2*cap(data)+len(sss))
                copy(temp, data)
                data = temp
            }
            data = data[0 : off+len(sss)]
            copy(data[off:], sss)
        }
        result = string(data)
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

func BenchmarkAppend(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        data := make([]byte, 0, 64)
        for i := 0; i < cnt; i++ {
            data = append(data, sss...)
        }
        result = string(data)
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

func BenchmarkBufferWrite(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        var buf bytes.Buffer
        for i := 0; i < cnt; i++ {
            buf.Write(bbb)
        }
        result = buf.String()
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

func BenchmarkBufferWriteString(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        var buf bytes.Buffer
        for i := 0; i < cnt; i++ {
            buf.WriteString(sss)
        }
        result = buf.String()
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

func BenchmarkConcat(b *testing.B) {
    var result string
    for n := 0; n < b.N; n++ {
        var str string
        for i := 0; i < cnt; i++ {
            str += sss
        }
        result = str
    }
    b.StopTimer()
    if result != expected {
        b.Errorf("unexpected result; got=%s, want=%s", string(result), expected)
    }
}

环境是OS X 10.11.6,2.2 GHz英特尔Core i7

测试结果:

BenchmarkCopyPreAllocate-8         20000             84208 ns/op          425984 B/op          2 allocs/op
BenchmarkAppendPreAllocate-8       10000            102859 ns/op          425984 B/op          2 allocs/op
BenchmarkBufferPreAllocate-8       10000            166407 ns/op          426096 B/op          3 allocs/op
BenchmarkCopy-8                    10000            160923 ns/op          933152 B/op         13 allocs/op
BenchmarkAppend-8                  10000            175508 ns/op         1332096 B/op         24 allocs/op
BenchmarkBufferWrite-8             10000            239886 ns/op          933266 B/op         14 allocs/op
BenchmarkBufferWriteString-8       10000            236432 ns/op          933266 B/op         14 allocs/op
BenchmarkConcat-8                     10         105603419 ns/op        1086685168 B/op    10000 allocs/op

结论:

  1. CopyPreAllocate 是最快的方式;AppendPreAllocate 很接近第一,但它更容易编写代码。
  2. Concat 的性能非常糟糕,无论是速度还是内存使用。不要使用它。
  3. Buffer#WriteBuffer#WriteString 在速度上基本相同,与评论中@Dani-Br所说的相反。考虑到在Go中,string 实际上是[]byte,这是有道理的。
  4. bytes.Buffer 基本上使用与 Copy 相同的解决方案,带有额外的记录和其他内容。
  5. CopyAppend 使用 64 的引导大小,与 bytes.Buffer 相同。
  6. Append 使用更多的内存和分配,我认为这与其使用的增长算法有关。它的内存增长速度没有 bytes.Buffer 快。

建议:

  1. 对于像 OP 需要的简单任务,我会使用AppendAppendPreAllocate。它足够快且易于使用。
  2. 如果需要同时读取和写入缓冲区,请使用bytes.Buffer。那是它的设计初衷。

14

我的原始建议是

s12 := fmt.Sprint(s1,s2)

然而,使用bytes.Buffer - WriteString()的答案是最有效的。

我的初始建议使用了反射和类型开关。请参见(p *pp) doPrint(p *pp) printArg,基本类型没有通用的Stringer()接口,这是我天真的想法。

至少,Sprint()在内部使用了一个bytes.Buffer。因此

`s12 := fmt.Sprint(s1,s2,s3,s4,...,s1000)`

在内存分配方面是可接受的。

=> Sprint()拼接可以用于快速调试输出。
=> 否则使用bytes.Buffer ... WriteString


9
不是内置的,也不是高效的。 - peterSO
导入一个包(比如 fmt)意味着它不是内置的。它在标准库中。 - Malcolm
它之所以慢,仅因为它在参数上使用了反射。它是高效的。否则,它与使用strings.Join连接字符串一样有效。 - mkm

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接