在 Go 语言开发中,字符串拼接是最基础也最常用的操作之一。

从接口返回数据构造、日志打印,到配置文件生成,几乎每个项目都会涉及。但很多开发者可能没意识到,不同的拼接方式在性能上能差出几百倍,不当的选择甚至会成为系统性能瓶颈。

本文会从 Go 字符串的底层特性出发,详细讲解 6 种主流的字符串拼接方式,对比差异,最后给出不同场景下的选型建议,让你既能理解原理,又能在实际开发中快速做出最优选择。

为什么 Go 字符串拼接要关注性能?

在讲具体拼接方式前,必须先明确一个核心知识点:Go 语言的字符串是不可变的

什么是 “不可变”?简单说,就是字符串一旦创建,就不能修改它的内容。比如执行s = "hello"后,你无法直接把s中的h改成H,只能创建一个新的字符串。

当你拼接字符串时,Go 不能在原来的字节数组上直接添加内容,只能做三件事:

  1. 计算新字符串的总长度;

  2. 分配一块新的内存空间;

  3. 把原来的字符串和要拼接的内容,一起复制到新内存中。

这个 “分配内存 + 复制数据” 的过程,就是性能消耗的关键。如果拼接操作频繁,或者数据量大,不当的拼接方式会导致大量无效的内存分配和复制,拖慢程序运行速度。

常见字符串拼接方式

1. 加号(+)拼接:最简单但有坑

用法:直接用+把字符串连起来,这是最直观的方式。

func plusConcat() string {
    a := "Go"
    b := "语言"
    c := "开发"
    return a + b + c // 结果:Go语言开发
}

原理

  • 如果是固定数量的字符串(比如上面的 3 个),编译器会帮你优化:先算总长度,一次性分配内存,再复制数据,性能还不错;

  • 但如果在循环里用+拼接,编译器没法提前知道总长度,每次循环都会分配新内存、复制数据,比如循环 10 万次,就会分配 10 万次内存,性能直接 “爆炸”。

优缺点

  • 优点:代码简单,不用记额外函数;

  • 缺点:循环拼接时性能极差。

适用场景:只能用于 “固定数量且少量” 的字符串拼接,比如"https://" + "xxx.com" + "/api"这种。

2. fmt.Sprintf:灵活但慢

用法:通过格式化字符串实现拼接,支持各种数据类型(字符串、数字、布尔值等)。

func sprintfConcat() string {
    name := "小明"
    age := 3
    score := 98.5 
    // 格式化拼接,%s对应字符串,%d对应整数,%f对应浮点数
    return fmt.Sprintf("姓名:%s,年龄:%d,分数:%f", name, age, score)
}

原理

fmt.Sprintf的底层逻辑很复杂:先解析你写的格式化字符串(比如"姓名:%s..."),识别里面的占位符(%s%d),然后把对应的参数转换成字符串,最后再拼接起来。这个过程要做大量的类型判断和数据转换,性能自然不好。

优缺点

  • 优点:支持多种数据类型,不用手动转换,灵活性高;

  • 缺点:性能差,比+拼接还慢。

适用场景:需要格式化输出的场景(比如打印日志时包含数字),且对性能要求不高的地方。比如调试日志打印,代码灵活比性能重要。

3. strings.Builder:推荐首选

用法:专门用来构建字符串的工具,需要调用WriteString方法添加内容,最后用String方法获取结果。

func builderConcat() string {
    // 1. 初始化一个Builder
    var builder strings.Builder
    // 2. 预分配内存(关键优化!)
    // 假设要拼接10个长度为5的字符串,总长度约50,提前分配50字节
    builder.Grow(50)
    // 3. 循环拼接
    for i := 0; i < 10; i++ {
        builder.WriteString(fmt.Sprintf("内容%d,", i))
    }
    // 4. 转换为最终字符串
    return builder.String()
}

原理

strings.Builder底层维护了一个可动态扩展的字节切片([]byte),拼接时直接把新内容追加到切片里,避免了频繁分配内存。

  • 动态扩容:当切片容量不够时,会按 “2 倍” 的比例扩容(比如容量 10 不够了,就扩到 20,再不够扩到 40),减少扩容次数;

  • 预分配内存:通过Grow方法提前分配足够的内存,能完全避免扩容,性能直接拉满;

  • 零复制转换:调用String方法时,直接把底层字节切片转换成字符串(用了unsafe包复用指针),不用复制数据。

优缺点

  • 优点:性能优秀,代码简洁,支持预分配优化;

  • 缺点:不支持直接拼接非字符串类型(比如数字要先转成字符串),不是线程安全的。

适用场景:绝大多数动态拼接场景,尤其是循环拼接大量字符串(比如构造接口响应、拼接日志内容)。

4. bytes.Buffer:兼容 io.Writer 的通用选择

用法:和strings.Builder很像,但功能更通用,支持写入字节切片、数字等多种数据。

func bufferConcat() string {
    // 1. 初始化Buffer
    var buf bytes.Buffer
    // 2. 预分配内存
    buf.Grow(50)
    // 3. 拼接内容(支持WriteString、WriteByte等多种方法)
    buf.WriteString("用户ID:")
    buf.WriteInt(123, 10) // 写入整数123,第二个参数是进制(10进制)
    buf.WriteString(",状态:正常")
    // 4. 转换为字符串
    return buf.String()
}

原理

底层也是维护了一个字节切片,拼接逻辑和strings.Builder类似,但有个关键区别:bytes.Buffer实现了io.Writer接口,所以能和很多支持io.Writer的函数配合使用(比如从文件、网络流中读取数据后直接写入拼接)。

不过,bytes.BufferString方法有个小缺点:会把底层字节切片复制一份再转成字符串,所以性能比strings.Builder略差一点。

和 strings.Builder 的核心区别

特性 strings.Builder bytes.Buffer
支持 io.Writer 接口 仅支持 WriteString 完全支持
String () 方法性能 零复制(直接转) 需复制底层切片

适用场景:需要和io.Writer接口交互的场景(比如读取文件后拼接内容),或者需要拼接非字符串类型(比如二进制数据)的情况。如果只是纯字符串拼接,优先选strings.Builder

5. strings.Join:专门拼接字符串切片

用法:专门用来拼接字符串切片([]string),还能指定分隔符。

func joinConcat() string {
    // 1. 准备一个字符串切片
    parts := \[]string{"Go", "字符串", "拼接", "指南"}
    // 2. 拼接,第二个参数是分隔符(空字符串表示无分隔符)
    return strings.Join(parts, "-") // 结果:Go-字符串-拼接-指南
}

原理

strings.Join的性能非常好,因为它做了两步优化:

  1. 先遍历切片,算出所有字符串和分隔符的总长度;

  2. 一次性分配足够的内存,然后把所有内容复制进去。

相当于 “一次分配,一次复制”,没有多余的内存操作,所以性能甚至比没预分配的strings.Builder还好。

优缺点

  • 优点:性能优秀,不用手动预分配,支持分隔符,代码简洁;

  • 缺点:只能拼接字符串切片,不能动态添加内容(比如循环中逐个加)。

适用场景:当要拼接的字符串已经是切片形式时(比如从配置文件读取的多个字段、批量处理的字符串列表),这是最优选择。比如拼接 URL 路径:strings.Join([]string{"/api", "v1", "user"}, ""),结果就是/api/v1/user

6. 字节切片([] byte)拼接:底层手动控制

用法:直接用字节切片的append方法拼接,最后转成字符串。

func byteSliceConcat() string {
    // 1. 预分配字节切片(容量设为总长度,避免扩容)
    // 假设要拼接10个长度为5的字符串,总长度50,所以容量设为50
    buf := make([]byte, 0, 50)
    // 2. 循环append拼接
    for i := 0; i < 10; i++ {
        // 把字符串转成字节切片,用...展开后append
        buf = append(buf, fmt.Sprintf("内容%d,", i)...)
    }
    // 3. 转成字符串
    return string(buf)
}

原理

这是最底层的拼接方式,完全手动控制内存。通过make([]byte, 0, 容量)预分配足够的内存,然后用append把新内容追加到切片里,最后转成字符串。

性能和strings.Join、预分配的strings.Builder差不多,但需要自己手动估算总长度,代码也比前两者繁琐。

优缺点

  • 优点:性能极致,没有额外封装开销;

  • 缺点:需要手动估算内存,代码繁琐,转字符串时可能有复制(Go 1.20 + 对未逃逸的切片有优化,但不确定时不建议依赖)。

适用场景:对性能要求极高,且能精确控制内存的场景(比如高频调用的核心算法、处理大数据量的字符串)。大多数业务场景下,strings.Builder已经足够,不用折腾这个。

总结:不同场景的选型指南

看完原理,总结一下不同场景下的选择:

1. 优先选这几种(覆盖 90% 场景)

场景 推荐方式 理由
固定数量的少量字符串拼接 加号(+) 代码最简单,编译器自动优化,性能够用
动态拼接(循环 / 不定数量) strings.Builder 性能优秀,支持预分配,代码简洁
已存在字符串切片([] string) strings.Join 性能最优,不用手动预分配,支持分隔符
需要和 io.Writer 交互(读文件 / 网络流) bytes.Buffer 实现 io.Writer 接口,能直接写入多种数据

2. 尽量要避免的 2 种情况

  • 不要在循环中用+拼接字符串;

  • 除非需要格式化(比如包含数字),否则不要用fmt.Sprintf拼接纯字符串。

3. 实用优化技巧

  1. 预分配内存:用strings.Builder时,尽量调用Grow方法提前分配内存(估算总长度即可,不用精确);

  2. 利用编译器优化:固定数量的字符串拼接直接用+,比如"a"+"b"+"c",编译器会优化成一次分配;

  3. 别过度优化:如果不是高频调用的核心代码(比如一天只执行几次的脚本),用fmt.Sprintf也没关系,代码简洁比性能重要。

最后

字符串拼接看似简单,但却是 Go 性能优化的 “小细节,大影响”。很多时候,系统的性能瓶颈不是复杂的算法,而是这些基础操作的不当使用。希望这篇文章能帮你避开字符串拼接的 “坑”,写出更高效的 Go 代码!