Skip to content
Go
bytes & strings — 字节与字符串操作

bytes & strings — 字节与字符串操作

bytes 和 strings 是 API 几乎完全对称的两个包:同样的 Index/Contains/Split/Trim/Replace 一套,strings 面向 string,bytes 面向 []byte。选择标准很简单——处理文本用 strings,处理二进制或已在内存里的数据块用 bytes。真正值得单独讲的只有两块:拼接的性能差距(数据说话)和 string/[]byte 转换的成本边界。


1. 拼接:差距比直觉大得多

先给结论,再给实测数据。下面这段代码在本机(go1.27.0 / windows / 12 逻辑核,-benchtime=200x,每个基准拼 1000 次 "hello")的结果:

写法 ns/op B/op allocs/op
s += p 循环 859,023 2,684,293 999
strings.Builder 10,326 17,912 13
strings.Builder + Grow(5000) 5,043 5,376 1
bytes.Buffer 16,762 21,696 9
strings.Join(parts, "") 9,493 5,376 1
fmt.Sprintf 循环 1,324,792 2,707,427 3,002
// 方式 1:+ 操作符(只适合少量、编译期可知的拼接)
s := "hello" + " " + "world"

// 方式 2:strings.Builder(循环拼接的标准答案)
var builder strings.Builder
builder.Grow(1024) // 能估算就先 Grow,这一步让分配数降到 1
for i := 0; i < 1000; i++ {
    builder.WriteString("hello")
}
result := builder.String()

// 方式 3:bytes.Buffer(需要同时做 IO / Read / UnreadByte 时)
var buf bytes.Buffer
for i := 0; i < 1000; i++ {
    buf.WriteString("hello")
}

// 方式 4:strings.Join(手里已经是 []string 时最省事)
result := strings.Join(parts, ", ")

// 方式 5:fmt.Sprintf(要格式化才用它,成本约为 Builder 的百倍)
result := fmt.Sprintf("%s-%d", "user", 42)

从表里能读出几条实用结论:

  • 循环里用 += 是 O(n²):1000 次拼接触发 999 次分配、复制了 2.6 MB。字符串不可变,每次 + 都新建一个并把前面全部搬一遍。
  • Builder 比 Buffer 快约 1.6 倍,因为它只做追加、没有读游标和缓冲区整理逻辑。
  • Grow 是免费的收益:从 13 次分配降到 1 次,时间直接砍半。能估算长度就一定估算。
  • strings.Join 和 Builder+Grow 基本同级。如果你本来就在收集 []string,Join 更清晰,不必为了性能改写。
  • fmt.Sprintf 慢两个数量级。热路径里用 strconv 追加到 Builder(b.WriteString(strconv.Itoa(n)))代替 fmt.Fprintf(&b, ...)。

底层原理:为什么 Builder 的 String() 更便宜

strings.Builder 内部维护一个 []byte,String() 返回的是指向内部缓冲区的字符串(零拷贝);而 bytes.Buffer.String() 每次都会把内容复制一份。实测可以看清这个差别:

var buf bytes.Buffer
buf.WriteString("hello")
s := buf.String()   // 复制:之后继续写不影响 s
bs := buf.Bytes()   // 直接暴露内部缓冲区
buf.WriteString("!")
// s = "hello",bs 仍是 "hello"(切片长度没变),buf.String() = "hello!"

代价是 Builder 有不可复制的约定:它内部靠一个自引用指针检测拷贝,复制后使用会 panic(实测):

var b strings.Builder
b.WriteString("x")
b2 := b            // 值复制
b2.WriteString("y")// panic: strings: illegal use of non-zero Builder copied by value

陷阱:这个 panic 在已经写过内容的 Builder 被复制时才会触发,sync.Pool 里放 strings.Builder、或者把 Builder 当结构体字段传来传去,都是经典翻车点。正确姿势是传指针 *strings.Builder,或每次在新作用域里从零构造。bytes.Buffer 可以复制(没有这个检测),但复制后两个缓冲区共享底层数组,同样是坑,别把它当福利。


2. 查找、切分与替换

2.1 常用 API(够用即可,不展开)

strings.Contains(s, "sub")   strings.ContainsAny(s, "abc")
strings.ContainsRune(s, '你') strings.HasPrefix(s, "http://")
strings.Index(s, "sub")      strings.LastIndex(s, "sub")
strings.IndexByte(s, 'a')    strings.IndexFunc(s, unicode.IsSpace)

单字节查找用 IndexByte(比 Index 少一次字符串比较逻辑);要找分隔符位置又知道是单个字符,IndexByte/LastIndexByte 是默认选择。

2.2 Cut 与 CutLast

before, after, found := strings.Cut("a=b=c", "=")      // "a", "b=c", true
before, after, found = strings.CutLast("a=b=c", "=")   // "a=b", "c", true  (Go 1.27)

Cut 是 strings.SplitN(s, sep, 2) 的 cleaner 版本(实测:Cut("a=b=c","=") 得 a 与 b=c),而 CutLast 补上了过去必须 LastIndex 手写的那半边(解析 key=value 里 value 含 =、解析 host:port 里 host 含 : 时用哪个见语义,别搞反)。两者都返回三元组,found == false 时 before 是整个串、after 是空串。

2.3 Replacer:一次性、按参数顺序、不重扫

replacer := strings.NewReplacer(
    "&", "&amp;",
    "<", "&lt;",
    ">", "&gt;",
    `"`, "&quot;",
)
escaped := replacer.Replace(`<div class="test">Hello & Co</div>`)
// &lt;div class=&quot;test&quot;&gt;Hello &amp; Co&lt;/div&gt;

包文档对匹配规则写得很精确:“Replacements are performed in the order they appear in the target string, without overlapping matches. The old string comparisons are done in argument order."——同位置上,参数里先列出的模式优先;而且替换结果不会再被扫描。第二条实测能直接看到:把顺序写成 "<"→"&lt;" 在前、"&"→"&amp;" 在后,输入 a < b & c 得到 a &lt; b &amp; c,新生成的 &lt; 里那个 & 没有被二次替换。

Replacer 是并发安全的、可复用的,比连续调用 N 次 strings.Replace 快(一次扫描 vs N 次扫描)。bytes 侧对应 bytes.NewReplacer。

注意:NewReplacer 参数个数为奇数会 panic。另外,手写 HTML 转义本身就是错的做法——上下文相关的转义(属性内、JS 字符串内、URL 内)规则不同,交给 html/template(见 text-template.md)或 template.HTMLEscapeString,不要靠 strings.NewReplacer 挡住 XSS。

2.4 大小写

strings.ToUpper(s)  // 全大写(不做语言敏感处理)
strings.ToLower(s)  // 全小写;比较场景几乎总是用它
strings.EqualFold(a, b) // 不区分大小写比较,别用 ToLower 造临时字符串
strings.ToTitle(s)  // title casing,多数情况下不是你想要的效果

需要语言相关的标题/大小写(比如英语 i → I、土耳其语 i 的特殊规则),要用 golang.org/x/text/cases(cases.Title(language.English).String(s))——这是 x/text,不是标准库。


3. bytes.Buffer 的独门能力

var buf bytes.Buffer

buf.WriteString("hello")
buf.Write([]byte(" world"))
buf.WriteByte('!')

p, err := buf.Peek(2)  // Go 1.26 新增:读前 2 字节但不移动游标(实测 err=nil, "he")
chunk := buf.Next(5)   // 读前 5 字节并移动游标,零拷贝
buf.Truncate(10)       // 保留前 10 字节(会丢弃已读部分)
buf.UnreadByte()       // 回退一个字节(Scanner 类逻辑常用)

n, err := buf.ReadFrom(file) // 从 Reader 一直读到 EOF
m, err := buf.WriteTo(w)     // 把已读区间写到 Writer

ReadFrom/WriteTo 让 bytes.Buffer 直接参与 io.Copy 的短路优化(io.Copy 会优先调用目标 WriteTo 或源 ReadFrom),比手工循环省一次拷贝。

Peek(Go 1.26)解决的是"嗅探魔数但不消费"的长期痛点:以前只能 Next(n) 之后靠 UnreadByte 一点点退回去,或者读进临时切片再拼接。识别 gzip/zip/png 这类文件头时直接 Peek(4) 即可(配合 ../05-文件与系统IO/compress-archive.md 的魔数表)。两个约束按文档原话记牢:不足 n 字节时同时返回 io.EOF(“少于 n 个"是正常结束而不是错误,别一视同仁当失败);返回的切片与缓冲区内容共享内存,“immediate changes to the slice will affect the result of future reads”——要留存必须复制。

注意:Buffer 一旦从 io.Reader 读过数据,Bytes()/String() 返回的是未读部分。buf.Len() 是剩余可读长度,buf.Cap() 是底层数组容量——很多"Buffer 内容少了"的报告其实是游标语义被误当成整体缓冲区。


4. string 与 []byte 转换的成本

b := []byte(s) // 分配 + 复制(除非编译器能证明不需要)
s := string(b) // 分配 + 复制(同上)

编译器确实会在几类场景里省掉复制——包文档层面的共识是比较和map 索引:

if string(b) == "hello" { ... }     // 通常不分配
v := m[string(b)]                   // 通常不分配
out = append(out, b...)             // 追加到 []byte,不涉及转换

注意:这类优化是实现细节,不该被当作语言保证:换 Go 版本、换调用形状(比如把 m[string(b)] 提成变量)都可能让复制回来。要评估性能就用 benchmark 量当前代码,别量"理论”。真要零拷贝,Go 1.20+ 的 unsafe.String(unsafe.SliceData(b), len(b)) / unsafe.Slice(unsafe.StringData(s), len(s)) 才是显式手段,但前提是源缓冲区之后不再被修改,且生命周期你能兜住——这是"知道自己为什么要这么写"才允许用的写法。

常见热路径建议:整条链路统一类型。解析二进制协议就一路 []byte(配 bytes 包),只在最终输出/落库时转 string;处理文本就一路 string,需要多次切片时用 strings 的索引函数,别来回 []byte(s)。


5. 陷阱汇总

# 现象 原因与对策
1 s[i] 拿到奇怪的东西 string 索引返回 byte(实测中文会拿到 UTF-8 的某个字节)。按字符处理用 for i, r := range s 或 []rune(s)
2 len(s) 与字符数不符 len 是字节数。字符数用 utf8.RuneCountInString(s)
3 range s 的 i 不连续 +1 索引是字节位置,多字节 rune 会跳 2~4 步;要连续下标就先转 []rune
4 strings.Split("", ",") 返回 [""] 长度为 1,不是空切片。判"有没有字段"要先判 s == ""
5 Builder 第二次循环结果叠加 Builder 不清空,需 Reset()(Reset 保留底层缓冲区,正合适)
6 panic: strings: illegal use of non-zero Builder copied by value 复制了已写入过的 Builder(§1 实测)。传 *strings.Builder 或换 bytes.Buffer
7 Buffer.String() 内容比预期短 它只返回未读部分;已 Read/Next 过的不算(§3)
8 想区分 []byte 是否被下游修改 Buffer.Bytes() 暴露内部数组,后续写入/读取会改变它;需要留存请复制
9 Replacer 替换结果不符合预期 同位置按参数顺序取第一个匹配,且不重扫输出(§2.3)
10 TrimSpace 之后仍有不可见字符 它只裁 unicode.IsSpace 认可的空白。实测 NBSP \u00A0、全角空格 \u3000、NEL \u0085 会被裁掉;而 BOM \uFEFF、零宽空格 \u200B 不会(TrimSpace("\uFEFFabc") 原样返回)。BOM 请显式剥:strings.TrimPrefix(s, "\uFEFF"),encoding/csv 不会代你去 BOM
11 大小写比较把德语ß/土耳其语 i 弄错 strings.ToLower 是简单映射;语言敏感处理要 x/text/cases(非标准库)
12 fmt.Sprintf 拖慢热路径 实测比 Builder 慢约两个数量级;改 strconv + Builder

6. 实战建议

默认用 strings.Builder,估算得到就 Grow。 循环拼接一律 Builder;能预估算长度(片段数 × 平均长度)时补一句 Grow,这是本文件里性价比最高的一条。bytes.Buffer 留给"还要读、还要 Peek、还要参与 io.Copy“的场合,纯拼接它比 Builder 慢。

收集完再 Join 往往是最干净的写法。 需要条件性加分隔符、加括号缩进时,Builder 更好;只是把同构片段拼一维串,strings.Join 一行解决,性能同级,可读性更高。

Cut/CutLast 取代 SplitN 手活。 拿第一段用 Cut,拿最后一段用 CutLast(Go 1.27)。它们的三元组语义比"判断切片长度是否为 2"清楚,也少切一次。

别为省一次复制写 unsafe。 编译器已经在比较和 map 索引处帮你省了。真有热点,先跑 -cpuprofile 确认转换在火焰图上,再考虑 unsafe.String;否则换来的是可读性下降和内存安全隐患。相关分析手段见 ../06-系统与测试/pprof.md。

字节与文本不要混用。 一个函数要么收 string(文本语义,保证 UTF-8)、要么收 []byte(原始字节)。用 string 装二进制数据是常见的隐性 bug 来源(Go 允许任意字节序列,但一旦流经 range、ToUpper、JSON 编码就会出问题)。

相关阅读:../05-文件与系统IO/bufio.md(带缓冲读写与 Scanner)、../04-编码与数据处理/regexp.md(Regexp 的 []byte 方法更省拷贝)、../08-计算机基础/计算机基础知识点.md(UTF-8 编码与字节序)。