目录

Go-07 字符串与字节处理

字符串是几乎所有程序都绕不开的类型。很多人只把它当作"一串字符"来用,但在 Go 里,string 的底层结构、不可变性、UTF-8 编码、与 []byte 的转换开销,都会直接影响程序的性能与正确性。本章从底层结构讲起,逐层拆开字符串与字节处理的每一个细节。


1. string 的底层结构

Go 的 string 本质上是一个只读的字节序列。它在运行时对应的结构体是 runtime.stringStruct

// runtime/string.go 中的定义(简化)
type stringStruct struct {
    str unsafe.Pointer // 指向底层字节数组的指针
    len int            // 字节长度(注意:不是字符数)
}

也就是说,一个 string 变量在内存里只占用两个机器字(在 64 位平台上是 16 字节):一个指针 + 一个长度。真正的字节数据存放在指针所指向的地方。

string 变量 s = "hello"

  s (栈上 16 字节)
┌──────────────┬──────────────┐
│  str ptr     │   len = 5    │
└──────┬───────┴──────────────┘
       │
       ▼
┌───┬───┬───┬───┬───┐
│ h │ e │ l │ l │ o │   底层字节数组(只读)
└───┴───┴───┴───┴───┘
  0   1   2   3   4

与 slice 结构对比

slice 的结构是三个字:指针、长度、容量。string 少了一个 cap,因为字符串不可增长。

// slice 的底层结构 runtime.slice
type slice struct {
    array unsafe.Pointer // 指向底层数组
    len   int            // 长度
    cap   int            // 容量
}

对比如下:

特性 string []byte / slice
底层结构 指针 + len(2 字) 指针 + len + cap(3 字)
是否可变 只读,不可变 可变
是否可增长 是(append 可扩容)
零值 ""(指针为 nil) nil
是否可比较 可以用 == 比较 不能直接用 ==

我们可以用 unsafe 打印出 string 的头部结构来验证:

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    s := "hello"
    // reflect.StringHeader 的等价结构
    type stringHeader struct {
        Data uintptr
        Len  int
    }
    hdr := (*stringHeader)(unsafe.Pointer(&s))
    fmt.Printf("data 指针地址: %#x\n", hdr.Data)
    fmt.Printf("len 长度:     %d\n", hdr.Len) // 输出 5
}

2. 不可变性(immutability)

Go 规范明确规定:字符串的内容一旦创建就不可修改。下面的代码无法通过编译:

s := "hello"
s[0] = 'H' // 编译错误:cannot assign to s[0] (strings are immutable)

为什么设计成不可变

  1. 字符串字面量存放在只读数据段。编译期确定的字符串常量会被放入二进制的只读段(.rodata),操作系统会把这一段内存标记为只读。若尝试写入,会触发段错误。多个相同的字面量还能共享同一份底层数据,节省内存。

  2. 并发安全。因为内容不可变,多个 goroutine 可以同时读同一个字符串而无需加锁,天然线程安全。

  3. 可作为 map 的 key、可安全比较。不可变保证了哈希值和比较结果的稳定。

  4. 切片共享底层数据零成本s[1:3] 这样的子串切片不需要拷贝底层数据,只需生成一个新的指针+长度,因为反正谁都改不了。

s := "hello world"
sub := s[6:]   // "world",不拷贝底层数据

       s.str
        │
        ▼
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ h │ e │ l │ l │ o │   │ w │ o │ r │ l │ d │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
                        ▲
                     sub.str(复用同一块内存,len=5)

需要修改时:转成 []byte

如果确实要"修改"字符串,必须先转成 []byte(或 []rune),改完再转回来。转换会发生拷贝,得到的是一份可写副本:

package main

import "fmt"

func main() {
    s := "hello"
    b := []byte(s) // 拷贝出一份可写的字节切片
    b[0] = 'H'
    s2 := string(b) // 再拷贝回 string
    fmt.Println(s)  // hello(原始不变)
    fmt.Println(s2) // Hello
}

3. UTF-8 与 Unicode

Go 源码文件默认使用 UTF-8 编码,字符串字面量存储的也是 UTF-8 字节序列。这是理解字符串长度、遍历的关键。

几个核心概念先厘清:

  • byteuint8 的别名,表示一个字节。
  • runeint32 的别名,表示一个 Unicode 码点(code point)。
  • UTF-8:一种变长编码,一个 Unicode 码点用 1~4 个字节表示。ASCII 字符占 1 字节,常见汉字占 3 字节。
package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "Go语言"
    fmt.Println(len(s))                    // 8:字节数(G、o 各1字节 + 语、言 各3字节 = 2+6)
    fmt.Println(utf8.RuneCountInString(s)) // 4:真正的字符(rune)个数
}

len() 返回的是字节数,不是字符数

这是新手最容易踩的坑。len(s) 返回底层字节数组的长度,对纯 ASCII 字符串来说等于字符数,但对含中文的字符串就不是了。

s := "你好"
fmt.Println(len(s)) // 6,不是 2!每个汉字 3 字节

一个 UTF-8 字符究竟占几个字节,取决于码点范围:

码点范围(十六进制) 字节数 典型字符
U+0000 ~ U+007F 1 ASCII(英文、数字)
U+0080 ~ U+07FF 2 拉丁扩展、希腊、西里尔
U+0800 ~ U+FFFF 3 中日韩汉字、大部分符号
U+10000 ~ U+10FFFF 4 Emoji、生僻字

range 遍历按 rune,索引访问按 byte

用下标 s[i] 访问,得到的是字节(byte);而用 for range 遍历,Go 会自动按 UTF-8 解码,得到的是码点起始索引 + rune

package main

import "fmt"

func main() {
    s := "Go语言"

    // 方式一:下标访问 —— 得到 byte,中文会被拆散乱码
    fmt.Println("按 byte 遍历:")
    for i := 0; i < len(s); i++ {
        fmt.Printf("  索引 %d: %d (%c)\n", i, s[i], s[i])
    }

    // 方式二:range 遍历 —— 得到 rune,正确处理中文
    fmt.Println("按 rune 遍历:")
    for i, r := range s {
        // i 是该 rune 在字节序列中的起始下标
        fmt.Printf("  字节索引 %d: 码点 U+%04X (%c)\n", i, r, r)
    }
}

输出(rune 遍历部分):

按 rune 遍历:
  字节索引 0: 码点 U+0047 (G)
  字节索引 1: 码点 U+006F (o)
  字节索引 2: 码点 U+8BED (语)   // 注意索引从 2 直接跳到 5
  字节索引 5: 码点 U+8A00 (言)

可以看到 range 遍历时索引不是连续的 0,1,2,3,而是按每个字符的字节宽度跳跃(语占 3 字节,所以下一个字符从索引 5 开始)。

三种"长度"要分清

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "héllo😀" // e带音标 + emoji

    fmt.Println("字节数 len:            ", len(s))                    // 9
    fmt.Println("rune 数 RuneCount:     ", utf8.RuneCountInString(s)) // 6
    fmt.Println("[]rune 长度:           ", len([]rune(s)))            // 6
}

4. string 与 []byte / []rune 的转换

转换会发生拷贝

string[]byte[]rune 之间的标准转换([]byte(s)string(b))都会分配新内存并拷贝数据。原因很简单:string 不可变而 []byte 可变,如果共享同一块内存,改了 []byte 就会破坏 string 的不可变契约。

s := "hello"
b := []byte(s)   // 分配 5 字节新内存 + memmove 拷贝
s2 := string(b)  // 再次分配 5 字节 + 拷贝
   s "hello"          转换拷贝            b []byte
┌───┬───┬───┬───┬───┐   ───────►   ┌───┬───┬───┬───┬───┐
│ h │ e │ l │ l │ o │ (read-only)  │ h │ e │ l │ l │ o │ (堆上可写)
└───┴───┴───┴───┴───┘              └───┴───┴───┴───┴───┘
       两块独立内存,互不影响

对于大字符串,这种拷贝的开销不可忽视。不过编译器对某些场景做了优化,例如 for i, c := range []byte(s)m[string(b)](用 []byte 查 map)等临时转换不会真正分配内存。

[]rune 转换

[]rune(s) 会把 UTF-8 解码成码点数组,每个元素 int32(4 字节)。这在需要按字符处理(如反转中文字符串、取第 N 个字符)时非常有用:

package main

import "fmt"

// 正确反转含中文的字符串:必须按 rune
func reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

func main() {
    fmt.Println(reverse("你好abc")) // cba好你
}

如果误用 []byte 反转,中文就会被拆成半个字符导致乱码。

unsafe 零拷贝转换(Go 1.20+)

在明确知道不会修改数据的前提下,可以用 unsafe 实现零拷贝转换,避免内存分配。Go 1.20 提供了官方的安全 API:unsafe.Stringunsafe.StringData,取代了旧版本手写 reflect.StringHeader/SliceHeader 的写法。

package main

import (
    "fmt"
    "unsafe"
)

// []byte -> string,零拷贝
func bytesToString(b []byte) string {
    if len(b) == 0 {
        return ""
    }
    return unsafe.String(&b[0], len(b))
}

// string -> []byte,零拷贝(危险!返回的切片绝对不能修改)
func stringToBytes(s string) []byte {
    if s == "" {
        return nil
    }
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

func main() {
    b := []byte("hello")
    s := bytesToString(b) // 不分配新内存,s 和 b 共享底层数组
    fmt.Println(s)        // hello
}

风险提示

  1. 零拷贝转换后,string[]byte 共享同一块内存。若之后修改了 []byte(或 []byte 底层数组被复用),会破坏 string 的不可变性,导致难以排查的诡异 bug。
  2. unsafe.StringData 返回的指针指向的内存不可写,写入即未定义行为(可能崩溃)。
  3. 生命周期问题:必须保证原始数据在转换结果使用期间一直存活,否则可能访问到已回收内存。

一句话:只在只读、性能敏感的热点路径上用,且要能确保数据不被修改。 普通业务代码老老实实用标准转换。


5. 字符串拼接大比拼

拼接是字符串处理里最常见、也最容易写出性能问题的操作。Go 里常见的拼接方式有六种。

逐一介绍

方式一:+ 拼接。 最直观,但每次 + 都会生成一个新字符串(分配新内存 + 拷贝双方)。在循环里用 += 是典型的性能杀手,时间复杂度退化为 O(n²)。

s := ""
for i := 0; i < n; i++ {
    s += "x" // 每次都重新分配整个字符串!
}

方式二:fmt.Sprintf 灵活支持格式化,但内部有反射和接口装箱开销,是最慢的一种,不适合纯拼接。

s := fmt.Sprintf("%s%s%s", a, b, c)

方式三:strings.Builder(推荐)。 内部维护一个 []byte,写入时 append,避免中间字符串的重复分配。最后 String() 用零拷贝返回结果。

var sb strings.Builder
for i := 0; i < n; i++ {
    sb.WriteString("x")
}
s := sb.String()

方式四:bytes.Buffer 思路和 Builder 类似,但 Buffer.String() 会拷贝一份(因为 Buffer 可继续读写),略慢于 Builder。

var buf bytes.Buffer
buf.WriteString("x")
s := buf.String()

方式五:[]byte + append 手动管理字节切片,配合预分配容量非常高效,是 Builder 的底层原理。

b := make([]byte, 0, n)
for i := 0; i < n; i++ {
    b = append(b, 'x')
}
s := string(b) // 这一步会拷贝

方式六:strings.Join 当元素已经在一个 slice 里时最方便,内部会一次性计算总长度并预分配,效率很高。

parts := []string{"a", "b", "c"}
s := strings.Join(parts, "-") // "a-b-c"

Benchmark 对比

下面是一个拼接 10000 个短字符串的基准测试(示意):

package main

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

const n = 10000

func BenchmarkPlus(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < n; j++ {
            s += "x"
        }
        _ = s
    }
}

func BenchmarkSprintf(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < n; j++ {
            s = fmt.Sprintf("%sx", s)
        }
        _ = s
    }
}

func BenchmarkBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < n; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}

func BenchmarkBuilderGrow(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        sb.Grow(n) // 预分配
        for j := 0; j < n; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}

func BenchmarkBuffer(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var buf bytes.Buffer
        for j := 0; j < n; j++ {
            buf.WriteString("x")
        }
        _ = buf.String()
    }
}

典型结果(数量级示意,具体值因机器而异):

拼接方式 ns/op(越小越快) 内存分配 B/op 分配次数 allocs/op 结论
+= 循环拼接 ~50,000,000 ~50 MB ~10000 最慢,O(n²)
fmt.Sprintf 循环 ~80,000,000 ~80 MB ~20000 最慢,禁用
bytes.Buffer ~60,000 ~50 KB ~15
strings.Builder ~45,000 ~40 KB ~13 更快
strings.Builder+Grow ~20,000 ~10 KB ~1 最快
strings.Join ~15,000 ~10 KB ~1 已有 slice 时最优

结论:

  • 循环拼接绝对不要用 +=fmt.Sprintf,会退化成 O(n²)。
  • 首选 strings.Builder,能预估长度时务必调用 Grow 预分配,可把分配次数降到 1 次。
  • 已有字符串切片时,strings.Join 最简洁高效。
  • 少量(2~3 个)固定字符串拼接,直接用 + 反而最简单,编译器会优化。

6. strings 包常用函数

strings 包提供了字符串查找、切分、替换、修剪等一站式工具。以下是最高频的一批:

package main

import (
    "fmt"
    "strings"
)

func main() {
    s := "Hello, Go World"

    // 包含 / 查找
    fmt.Println(strings.Contains(s, "Go"))     // true
    fmt.Println(strings.Index(s, "Go"))        // 7(首次出现的字节下标,不存在返回 -1)
    fmt.Println(strings.LastIndex(s, "o"))     // 13
    fmt.Println(strings.Count(s, "o"))         // 3

    // 前缀 / 后缀
    fmt.Println(strings.HasPrefix(s, "Hello")) // true
    fmt.Println(strings.HasSuffix(s, "World")) // true

    // 大小写
    fmt.Println(strings.ToUpper(s))            // HELLO, GO WORLD
    fmt.Println(strings.ToLower(s))            // hello, go world

    // 切分
    fmt.Println(strings.Split("a,b,c", ","))   // [a b c]
    fmt.Println(strings.SplitN("a,b,c", ",", 2)) // [a b,c]
    fmt.Println(strings.Fields("  a  b   c "))   // [a b c](按连续空白切分,自动去空)

    // 替换
    fmt.Println(strings.Replace("aaa", "a", "b", 2)) // bba(只替换前 2 个)
    fmt.Println(strings.ReplaceAll("aaa", "a", "b")) // bbb

    // 修剪
    fmt.Println(strings.TrimSpace("  hi  "))        // "hi"
    fmt.Println(strings.Trim("__hi__", "_"))        // "hi"
    fmt.Println(strings.TrimPrefix("go.mod", "go.")) // "mod"
    fmt.Println(strings.TrimSuffix("main.go", ".go")) // "main"

    // 重复 / 连接
    fmt.Println(strings.Repeat("ab", 3))            // ababab
    fmt.Println(strings.Join([]string{"a", "b"}, "-")) // a-b
}

Split vs Fields 的区别值得单独强调:

strings.Split("a  b", " ")  // ["a", "", "b"]  —— 连续分隔符会产生空串
strings.Fields("a  b")      // ["a", "b"]      —— 按空白切分并自动忽略空串

处理用户输入、命令行参数时,Fields 通常更符合直觉。


7. strconv 包:字符串与数值互转

strconv(string conversion)负责字符串和基本类型(整数、浮点、布尔)之间的转换。这是解析配置、处理网络协议时的必备工具。

package main

import (
    "fmt"
    "strconv"
)

func main() {
    // 整数 <-> 字符串
    s := strconv.Itoa(42)          // int -> string: "42"
    n, err := strconv.Atoi("42")   // string -> int: 42
    fmt.Println(s, n, err)

    // ParseInt:可指定进制和位宽,返回 int64
    v, err := strconv.ParseInt("ff", 16, 64) // 十六进制 ff -> 255
    fmt.Println(v, err)                       // 255 <nil>
    v2, _ := strconv.ParseInt("1010", 2, 64)  // 二进制 -> 10
    fmt.Println(v2)

    // ParseFloat
    f, _ := strconv.ParseFloat("3.14", 64)
    fmt.Println(f) // 3.14

    // ParseBool:接受 1/t/T/true/0/f/false 等
    ok, _ := strconv.ParseBool("true")
    fmt.Println(ok) // true

    // FormatInt / FormatFloat:更灵活的格式化
    fmt.Println(strconv.FormatInt(255, 16))            // "ff"
    fmt.Println(strconv.FormatFloat(3.14159, 'f', 2, 64)) // "3.14"('f' 定点,保留2位)
    fmt.Println(strconv.FormatFloat(3.14159, 'e', 3, 64)) // "3.142e+00"(科学计数)

    // Quote:给字符串加引号并转义
    fmt.Println(strconv.Quote("he said \"hi\"\n")) // "he said \"hi\"\n"
}

错误处理别忽略

Atoi/ParseXxx 都返回 error,解析失败(非法输入、溢出)时不能当没看见:

n, err := strconv.Atoi("abc")
if err != nil {
    // err 类型是 *strconv.NumError,包含函数名、输入、原因
    fmt.Printf("解析失败: %v\n", err) // 解析失败: strconv.Atoi: parsing "abc": invalid syntax
    return
}
fmt.Println(n)

FormatFloatfmt 参数速查:

verb 含义 示例(3.14159, prec=2)
'f' 定点表示 3.14
'e' 科学计数(小写 e) 3.14e+00
'g' 自动选择 f 或 e,去除末尾0 3.1
'b' 二进制指数 极少用

小贴士:Itoa 其实就是 FormatInt(int64(i), 10) 的简写;AtoiParseInt(s, 10, 0) 的简写。只涉及十进制 int 时用简写更清爽。

fmt 打印动词速查

strconv 相对,fmt.Printf/Sprintf 用「动词(verb)」格式化任意值,是日常最常用的字符串生成方式。常用动词:

分类 动词 含义
通用 %v / %+v / %#v 默认格式 / 带字段名 / Go 语法表示
通用 %T 值的类型;%% 输出一个百分号
布尔 %t truefalse
整数 %d / %b / %o / %x %X 十/二/八/十六进制(%X 用大写 A-F)
整数 %c / %q / %U Unicode 字符 / 带单引号的字面值 / U+1234 格式
浮点 %f / %e %E / %g %G 定点 / 科学计数 / 自动选紧凑格式(去末尾 0)
字符串 %s / %q / %x 原文 / 带双引号转义 / 十六进制字节
指针 %p 地址

宽度与精度:格式为 %[宽度].[精度]verb

fmt.Printf("%9.2f", 3.14159)  // "     3.14":宽度 9(不足补空格),精度 2
fmt.Printf("%-9.2f|", 3.14)   // "3.14     |":- 表示左对齐
fmt.Printf("%09.2f", 3.14)    // "000003.14":0 表示补零
  • 对数值:宽度是最小占位宽度,精度是小数位数
  • 但对 %g/%G:精度指的是有效数字总位数。例如 123.45%6.2f123.45,而 %.4g123.5
  • %e/%f 默认精度为 6;%g 默认精度为"能唯一确定该值的最小位数"。

8. bytes 包与 Builder / Buffer 的区别

bytes 包

bytes 包是 strings 包在 []byte 上的镜像:几乎所有 strings 里的函数(ContainsIndexSplitReplace……)在 bytes 里都有对应版本,只是操作对象换成 []byte

package main

import (
    "bytes"
    "fmt"
)

func main() {
    b := []byte("Hello, Go")
    fmt.Println(bytes.Contains(b, []byte("Go")))  // true
    fmt.Println(bytes.Index(b, []byte("Go")))     // 7
    fmt.Println(bytes.ToUpper(b))                  // [72 69 ...] -> HELLO, GO
    fmt.Println(string(bytes.ReplaceAll(b, []byte("o"), []byte("0")))) // Hell0, G0
}

当你已经手握 []byte(如网络读取、文件内容),用 bytes 包可以避免转成 string 的拷贝开销。

strings.Builder vs bytes.Buffer

两者都能高效构建字符串,但定位不同:

特性 strings.Builder bytes.Buffer
设计目的 只写,专为构建 string 可读可写的字节缓冲区
String() 是否拷贝 否,零拷贝返回 是,会拷贝一份
是否支持读取 不支持 支持 Read/ReadString 等
是否可被拷贝 禁止拷贝(noCopy) 拷贝后会共享底层,需小心
实现 io.Writer
性能 更快(省一次拷贝) 略慢

选择原则:只是拼字符串、最后一次性拿结果 → 用 strings.Builder;需要边写边读、当作 IO 缓冲区 → 用 bytes.Buffer

strings.Builder 的原理

Builder 的核心就是一个 []byte

// strings/builder.go(简化)
type Builder struct {
    addr *Builder // 通过 addr 检测非法拷贝
    buf  []byte   // 底层字节缓冲
}

func (b *Builder) WriteString(s string) (int, error) {
    b.copyCheck()
    b.buf = append(b.buf, s...) // 核心:append 到底层切片
    return len(s), nil
}

func (b *Builder) String() string {
    // 零拷贝:直接把 buf 的底层数组转成 string
    return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}

两个关键设计:

1. Grow 预分配。 提前把底层 []byte 扩容到目标容量,后续 WriteString 就不会反复触发扩容和拷贝:

var sb strings.Builder
sb.Grow(1024) // 预分配 1024 字节,后续写入不再扩容

2. 禁止拷贝(noCopy 机制)。 Builder 内部记录了自身地址 addr。一旦被值拷贝(如作为参数传递或赋值给新变量后继续使用),copyCheck 会检测到地址不一致而 panic。这是为了防止拷贝后两个 Builder 共享同一底层 []byte 造成的数据竞争。

var a strings.Builder
a.WriteString("x")
b := a          // 拷贝了 Builder
b.WriteString("y") // panic: strings: illegal use of non-zero Builder copied by value

所以传递 Builder 一定要用指针 *strings.Builder

String()unsafe.String 零拷贝返回,这也是为什么 Builder 比 Buffer 快——省掉了最后那次拷贝。之所以能安全零拷贝,是因为 Builder 只写不读,返回后一般不再往里写。


9. 性能优化实践

综合前面的内容,字符串处理的优化要点可以归纳为几条:

1. Builder 一定要预分配。 能预估结果长度时调用 Grow,把内存分配次数从 O(log n) 降到 1 次。

// 优化前:多次扩容
var sb strings.Builder
for _, item := range items {
    sb.WriteString(item)
}

// 优化后:先算总长度再一次性预分配
total := 0
for _, item := range items {
    total += len(item)
}
var sb2 strings.Builder
sb2.Grow(total)
for _, item := range items {
    sb2.WriteString(item)
}

2. 循环里绝不用 + / Sprintf 拼接。 前面 benchmark 已经证明这是 O(n²) 陷阱。改用 Builder 或先收集到 slice 再 Join

3. 只读转换用 unsafe,但要克制。 在高频热点(如 JSON 序列化、日志、路由匹配)中,若能保证字节数据只读,用 unsafe.String/unsafe.Slice 消除转换拷贝能显著降低内存分配。但务必:

  • 确保转换后不修改数据;
  • 确保原始数据生命周期足够长;
  • 封装成经过审查的工具函数,别散落各处。

4. 用 map 查询时直接 string(b) 免拷贝。 编译器对 m[string(key)] 这种临时转换做了特殊优化,不会分配新字符串:

var m map[string]int
key := []byte("name")
_ = m[string(key)] // 编译器优化,不分配

5. 遍历中文用 range 或 []rune,别用下标。 下标遍历得到字节,处理多字节字符会出错。

6. 大量字节操作优先 bytes 包。 数据本就是 []byte 时,用 bytes 包避免来回转 string


10. 高频面试题

Q1:string 的底层结构是什么?占多少字节?

string 底层是 runtime.stringStruct,由一个指向只读字节数组的指针 str 和一个长度 len 组成。在 64 位平台上,一个 string 变量本身占 16 字节(指针 8 + len 8),真正的字符数据在指针指向的地方。它比 slice 少一个 cap 字段,因为字符串不可增长。


Q2:string 为什么是不可变的?有什么好处?

因为规范禁止修改字符串内容,字面量存放在只读数据段。好处:(1) 并发安全,多 goroutine 可无锁读取;(2) 可作 map key、可安全比较,哈希稳定;(3) 子串切片 s[i:j] 可零成本共享底层数据;(4) 相同字面量可共享内存。修改必须转 []byte 改完再转回,会产生拷贝。


Q3:string 和 []byte 相互转换的开销是什么?如何零拷贝?

标准转换 []byte(s)string(b) 都会分配新内存并拷贝,因为二者可变性不同,共享内存会破坏 string 的不可变契约。对大数据开销明显。

零拷贝可用 Go 1.20+ 的 unsafe.String(&b[0], len(b))unsafe.Slice(unsafe.StringData(s), len(s)),让两者共享底层数组。风险:转换后修改 []byte 会破坏 string 不可变性、写 string 底层内存是未定义行为、要保证生命周期。仅限只读热点路径使用。

此外编译器对 m[string(b)]range []byte(s) 等临时转换有免拷贝优化。


Q4:几种字符串拼接方式哪种最快?

  • 循环拼接:strings.Builder + Grow 预分配最快(分配 1 次),其次 bytes.Buffer
  • +=fmt.Sprintf 在循环里是 O(n²),最慢,禁用。
  • 已有字符串切片:strings.Join 最优(内部预分配 + 一次拷贝)。
  • 少量固定拼接:直接 +,编译器会优化。

Builder 比 Buffer 快的原因:Builder.String()unsafe 零拷贝返回,而 Buffer.String() 要拷贝一份。


Q5:len("你好") 返回多少?为什么?

返回 6,不是 2。len 返回的是底层字节数组的长度(字节数),而 Go 源码用 UTF-8 编码,一个常见汉字占 3 字节,所以两个汉字共 6 字节。要获取字符(rune)个数应该用 utf8.RuneCountInString(s)len([]rune(s)),结果为 2。


Q6:如何正确高效地遍历中文字符串?

for i, r := range s,Go 会自动按 UTF-8 解码,r 是 rune(码点),i 是该字符的起始字节索引。不要用 for i := 0; i < len(s); i++ 下标遍历,因为 s[i] 返回的是单个字节,多字节的中文会被拆散乱码。

若需要按索引随机访问字符或反转,先转 []rune(s)(一次解码为码点数组),但这会分配内存;只是顺序遍历时 range 更省内存,无需额外分配。

for i, r := range "你好a" {
    fmt.Printf("%d: %c\n", i, r) // 0:你  3:好  6:a
}

Q7:strings.Builder 为什么不能被值拷贝?

Builder 内部通过 addr *Builder 字段记录自身地址,copyCheck 在每次写入时校验地址。若被值拷贝,副本的 addr 与新地址不一致,会 panic(illegal use of non-zero Builder copied by value)。这是防止拷贝后两个 Builder 共享同一底层 []byte 引发数据竞争。因此传递 Builder 必须用指针 *strings.Builder


小结

本章围绕 Go 的字符串与字节处理做了系统梳理:

  • 底层结构string = 指针 + len(16 字节),比 slice 少一个 cap,内容不可变。
  • 不可变性:字面量在只读段,带来并发安全、可比较、子串零拷贝等好处;修改需转 []byte
  • UTF-8len 返回字节数,中文占 3 字节;range 按 rune 遍历,utf8.RuneCountInString 数字符。
  • 转换string ↔ []byte/[]rune 标准转换会拷贝;Go 1.20 的 unsafe.String/unsafe.Slice 可零拷贝但有风险。
  • 拼接:循环拼接首选 strings.Builder + Grow,切片用 strings.Join,绝不在循环里用 +Sprintf
  • 标准库strings(字符串工具)、strconv(数值转换,注意错误处理)、bytes[]byte 版镜像)。
  • Builder 原理:底层 []byte + appendGrow 预分配、String() 零拷贝、noCopy 禁止拷贝。

掌握这些,既能写出正确处理多字节字符的代码,也能在性能敏感场景做出恰当的优化选择。下一章我们将进入流程控制与函数,讲清 if/for/switch、for-range 陷阱、闭包与高阶函数。