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(
"&", "&",
"<", "<",
">", ">",
`"`, """,
)
escaped := replacer.Replace(`<div class="test">Hello & Co</div>`)
// <div class="test">Hello & Co</div>包文档对匹配规则写得很精确:“Replacements are performed in the order they appear in the target string, without overlapping matches. The old string comparisons are done in argument order."——同位置上,参数里先列出的模式优先;而且替换结果不会再被扫描。第二条实测能直接看到:把顺序写成 "<"→"<" 在前、"&"→"&" 在后,输入 a < b & c 得到 a < b & c,新生成的 < 里那个 & 没有被二次替换。
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) // 把已读区间写到 WriterReadFrom/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 编码与字节序)。