📚 Golang 教程系列

  1. 入门与基础类型
  2. 字符串、变量与常量(本文)
  3. 流程控制
  4. 集合类型
  5. 函数、指针与类型
  6. 结构体
  7. 接口
  8. 错误处理、并发与泛型
  9. 测试与工程实践
  10. 杂项:make 与 select
  11. 杂项:embed 与资源嵌入

深入 string 与 []byte 的底层关系,掌握变量声明、遮蔽陷阱与常量用法。

string 的底层结构:一段只读的字节序列

抛弃“字符串是一串字符”的直觉:string一串字节,字节代表什么字符取决于解读方式。Go 源码默认按 UTF-8 解读,但 string 类型本身不强制内容是合法 UTF-8–它就是一个字节序列。

stringHeader:指针 + 长度

运行时层面,string 本质是这样一个二元组:

1
2
3
4
5
// 运行时内部结构(runtime.stringStruct 的等价表示,仅作示意)
type stringHeader struct {
Data uintptr // 指向底层字节数组的指针
Len int // 字节长度(注意:是字节数,不是字符数)
}

reflect.StringHeader 在现代 Go 中已 deprecated(实现细节的镜像,直接使用既不安全也无必要)。真正定义在 runtime 包内部,叫 stringStruct,字段是 str *bytelen int,语义与上面一致。两条结论:

  1. len(s) 返回字节数,O(1)Len 就是个字段,读取不需遍历。"hello" 返回 5,"你好" 返回 6(每汉字 UTF-8 占 3 字节)。
  2. string 不含结尾的 \0。与 C 不同,长度信息已显式存储,无需 NUL 终止符,可安全包含 \0 字节本身。
1
2
3
s := "a\x00b"
fmt.Println(len(s)) // 3
fmt.Println(s[1] == 0) // true,中间有个 \0 字节,完全合法

不可变性是设计核心

string不可变的(immutable):一旦创建,底层字节永不再改变,任何合法 Go 语法都无法修改 s[i]

1
2
s := "hello"
s[0] = 'H' // 编译错误:cannot assign to s[0]

这是类型契约的基石,带来一连串好处:

  • 并发安全:多 goroutine 同时读同一字符串无需同步原语,读永不引发数据竞争。
  • 可作 map 键map[string]V 可行,因为哈希值可缓存且键生命周期内不变;可变字符串会让哈希失效、map 损坏。
  • 切片零拷贝s[a:b] 可直接共享底层字节数组而不拷贝(下节详解)。这只有在底层只读时才安全。
  • 内存安全:字面量存储在二进制文件的只读数据段(.rodata),操作系统可映射为只读页,任何写入都会触发段错误(SIGSEGV)。
1
2
3
// 字面量映射在只读段,编译期就确定了
s := "hello, world"
// 运行时无法修改;任何尝试都会在编译期或运行期被拒

string 与 []byte 的关系

[]byte(即 []uint8)是 string 的“可变表亲”,底层也是 (指针, 长度, 容量) 三元组,指向可读写字节内存。两者互转是 Go 高频操作,也是理解性能的关键。

1
2
3
s := "hello"
b := []byte(s) // string -> []byte:拷贝
s2 := string(b) // []byte -> string:拷贝

为什么转换要拷贝?因为不可变性:[]byte 可写、string 只读。不拷贝的话,拿到 []byte 就能改底层内存、破坏源字符串契约;反之把可变 []bytestring,后续修改会“泄漏”进字符串。所以标准转换必须拷贝。下节会看到编译器在某些场景会省掉这次拷贝,以及 unsafe 手动零拷贝–一把双刃剑。

小结:字符串三定律

  1. string 是字节序列,不是字符序列。
  2. len(s) 是字节数,O(1)。
  3. string 不可变;与 []byte 的标准转换会拷贝。

string 与 []byte 的转换:拷贝、优化与 unsafe 零拷贝

标准转换的代价

1
2
3
4
5
6
7
func toBytes(s string) []byte {
return []byte(s) // 分配一块新的 [len(s)]byte,逐字节拷贝
}

func toString(b []byte) string {
return string(b) // 分配一块新的内存,拷贝,返回指向它的 string
}

一次转换 = 一次堆分配 + 一次 memmove。短字符串微不足道,但热路径上反复转换大字符串(如几 MB 的 JSON),GC 压力和延迟会立刻显现:

1
2
3
4
5
6
// 反复在 string 和 []byte 之间转换,每次都分配
func process(s string) string {
b := []byte(s) // 拷贝 1:string -> []byte
// ... 对 b 做一些字节级处理 ...
return string(b) // 拷贝 2:[]byte -> string
}

process 在循环里被调用上千次,累计分配量可观。

编译器的“免拷贝”优化

Go 编译器对几个特定模式会避免分配,不改变语义,只省掉拷贝:

  1. string(byteSlice) 立即用作 map 键或比较:编译器知道结果不会被长期持有,可直接比较底层字节而不分配新字符串。

    1
    2
    3
    m := map[string]int{}
    key := []byte("foo")
    v, ok := m[string(key)] // 这里 string(key) 通常不会分配
  2. []byte(stringConstant) 用于只读场景:若字符串是编译期常量,且 []byte 只被读取(如 len、比较),编译器可直接引用常量所在的只读段,不分配。

  3. string(byteSlice) 用于 fmt.Sprintf / 字符串比较:类似的逃逸分析优化。

这些优化是“脆弱”的:稍微改代码(如把结果存到变量里再用)就可能失效。所以不要在代码风格上依赖编译器优化,要用基准测试验证。

1
2
3
4
5
6
// 优化可能生效
if string(b) == "expected" { ... }

// 优化很可能失效:先存起来再比较
s := string(b)
if s == "expected" { ... }

验证是否分配最直接的办法是用 testing.Bb.ReportAllocs()

1
2
3
4
5
6
7
func BenchmarkStringFromBytes(b *testing.B) {
data := []byte("hello, world")
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = string(data)
}
}

unsafe 零拷贝:原理

当你确实在热路径上且能保证语义安全时,可用 unsafe 包实现零拷贝转换。Go 1.20+ 提供了更安全的 unsafe.Stringunsafe.Slice(比直接操作 reflect.StringHeader 可靠得多)。

[]byte -> string(零拷贝)

1
2
3
4
5
6
func b2s(b []byte) string {
if len(b) == 0 {
return ""
}
return unsafe.String(&b[0], len(b))
}

string -> []byte(零拷贝)

1
2
3
4
5
6
func s2b(s string) []byte {
if len(s) == 0 {
return nil
}
return unsafe.Slice(unsafe.StringData(s), len(s))
}

unsafe 零拷贝的风险

零拷贝放弃类型系统的安全保证,你必须自担以下风险:

  1. s2b 返回的 []byte 指向只读内存:若字符串是字面量,底层在 .rodata 段,写入触发段错误(SIGSEGV)。即使字符串非字面量,写入也会破坏字符串不可变性,违反 Go 内存模型,可能引发无法预测的并发 bug。

    1
    2
    b := s2b("literal")
    b[0] = 'L' // 运行时崩溃:signal SIGSEGV: segmentation violation
  2. b2s 返回的 stringb 共享底层数组:之后对 b 的修改会反映到 string 上;若 bappend 扩容导致底层数组迁移,s 会指向已释放的旧内存–典型的悬挂指针。

    1
    2
    3
    4
    b := []byte("abc")
    s := b2s(b)
    b[0] = 'X'
    fmt.Println(s) // "Xbc"!s 被意外改动了
  3. 逃逸分析失效unsafe 转换可能让本该在栈上的数据逃逸到堆上,破坏 GC 对对象生命周期的判断。

⚠️ 注意:除非已用基准测试确认标准转换是性能瓶颈,且能完全控制底层内存的生命周期,否则不要使用 unsafe 转换。绝大多数业务代码用 []byte(s) / string(b) 就够了。strings.Builder.String() 内部用了一次 unsafe 零拷贝返回结果,那是经过精心设计、保证 Builder 之后不再被写入的安全场景。

什么时候用 unsafe

合理场景包括:解析大体积文本(JSON、日志、CSV)时临时把 []bytestring 传给只读函数、框架层边界适配且已审计内存生命周期、性能敏感的序列化库(如 json-iterator/goprotobuf)。即便如此,也请封装在 b2s/s2b 小函数里并配大量注释和测试。直接散落 unsafe 调用是代码异味。

string 的切片操作与 UTF-8 陷阱

字符串支持切片语法 s[a:b],但语义与 Python/JS 有重要差异–它是按字节切,不是按字符切。

切片是 O(1) 共享底层

1
2
3
4
5
s := "hello, world"
sub := s[0:5] // "hello"
sub2 := s[7:] // "world"
sub3 := s[:5] // "hello"
sub4 := s[:] // "hello, world"

sub 不是新拷贝,而是一个新的 stringHeaderData 指向原字节数组第 0 字节,Len 为 5。即子串与原串共享底层内存,切片 O(1),不分配、不拷贝。这是不可变性的直接收益。

按字节切片的乱码陷阱

若字符串含多字节 UTF-8 字符(如中文),按字节切就可能切到字符中间,产生乱码:

1
2
3
4
5
6
s := "你好,Go"
fmt.Println(len(s)) // 9:你(3) + 好(3) + ,(1) + G(1) + o(1)

sub := s[0:2] // 切了 "你" 的前 2 字节,不是合法 UTF-8
fmt.Println(sub) // 输出乱码:��
fmt.Println([]rune(sub)) // [65533] 即 U+FFFD(替换字符)

s[0:2] 取的是“你”字 UTF-8 编码(E4 BD A0)的前两字节 E4 BD,不是合法 UTF-8 序列。Go 遇到非法字节会输出替换字符 U+FFFD()。更隐蔽的坑:s[1] 这类单字节索引在中文上几乎必错,因为它落在多字节字符中间。

安全的按字符切片:转 []rune

runeint32 的别名,表示一个 Unicode 码点。把字符串转成 []rune 会逐个解码 UTF-8 字节为码点:

1
2
3
4
5
s := "你好,Go"
runes := []rune(s) // [20320, 22909, 44, 71, 111]
fmt.Println(len(runes)) // 5:码点数

sub := string(runes[0:2]) // "你好",按字符切,安全

代价是 []rune(s)分配一个 []int32 并完整解码,时间和空间都是 O(n)。大字符串(如几 MB 文本)是笔不小开销。

不分配的按字符切片:手动遍历

若只需偶尔按 rune 取一段,又不想付出 O(n) 分配,可用 utf8.DecodeRuneInString 手动按字节推进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 按 rune 索引取子串,不分配 []rune
func substringByRune(s string, start, end int) string {
if start < 0 || end < 0 || start > end {
return ""
}
i := 0 // 当前 rune 计数
byteIdx := 0 // 当前字节位置
for i < start && byteIdx < len(s) {
_, size := utf8.DecodeRuneInString(s[byteIdx:])
byteIdx += size
i++
}
startByte := byteIdx
for i < end && byteIdx < len(s) {
_, size := utf8.DecodeRuneInString(s[byteIdx:])
byteIdx += size
i++
}
return s[startByte:byteIdx]
}

只做一次扫描,不分配 []rune,最后用字节切片返回 O(1) 子串。适合处理大字符串时偶尔取片段。

子串引用导致的“内存泄漏”

切片共享底层是双刃剑:

1
2
3
4
5
6
7
8
9
10
11
12
13
func firstLine(bigLog string) string {
idx := strings.Index(bigLog, "\n")
if idx < 0 {
return bigLog
}
return bigLog[:idx] // 只取第一行,但底层仍指向整个 bigLog
}

// 调用方
huge := readHugeFile() // 假设 100MB
line := firstLine(huge)
// 此后 huge 不再被引用,但 line 的底层 Data 还指向那 100MB
// 那 100MB 内存不会被 GC 回收,直到 line 本身也被释放

line 表面只有几十字节,但其 stringHeader.Data 指向 100MB 底层数组,只要 line 还活着,这 100MB 就不会被回收。这是隐性内存泄漏,处理大日志、大响应体时很常见。解决办法是显式拷贝:

1
2
// Go 1.18+ 提供了 strings.Clone,专门解决这个问题
return strings.Clone(bigLog[:idx])

strings.Clone 分配一块刚好容纳子串的新内存并拷贝,让返回值与原字符串彻底脱钩。子串远小于原串时,这点拷贝代价非常划算。

提示:当函数返回字符串子串、子串可能被长期持有、而原字符串可能很大时,用 strings.Clone 是好习惯。

字节索引 vs 字符索引速查

操作单位复杂度备注
len(s)字节O(1)返回字节数
s[i]字节O(1)返回 byte,不是 rune
s[a:b]字节O(1)共享底层,可能切坏 UTF-8
utf8.RuneCountInString(s)runeO(n)返回码点数
[]rune(s)runeO(n)分配 []int32
range sruneO(n)按码点遍历,见下节

遍历字符串:range 与索引的本质差异

Go 两种遍历字符串的方式,遍历单位完全不同,是易踩坑处。

range 按 rune 遍历

1
2
3
4
5
6
7
8
9
s := "你好Go"
for i, r := range s {
fmt.Printf("i=%d r=%c (U+%04X)\n", i, r, r)
}
// 输出:
// i=0 r=你 (U+4F60)
// i=3 r=好 (U+597D)
// i=6 r=G (U+0047)
// i=7 r=o (U+006F)

注意 i 的取值 0, 3, 6, 7i当前 rune 的字节偏移量,不是 rune 序号。range 在底层调用 UTF-8 解码器,每次推进一个完整 UTF-8 字符(1~4 字节),把解码出的码点赋给 r。因此 i 不一定连续–遇到多字节字符会跳跃。

若字符串里有非法 UTF-8 字节,range 会返回 U+FFFD)并推进 1 字节:

1
2
3
4
5
6
7
s := "a\xffb" // \xff 不是合法 UTF-8 起始字节
for i, r := range s {
fmt.Printf("i=%d r=%q\n", i, r)
}
// i=0 r='a'
// i=1 r='�' // 替换字符
// i=2 r='b'

索引按字节遍历

1
2
3
4
5
6
7
8
9
10
11
12
s := "你好Go"
for i := 0; i < len(s); i++ {
fmt.Printf("i=%d byte=%#x\n", i, s[i])
}
// i=0 byte=0xe4
// i=1 byte=0xbd
// i=2 byte=0xa0
// i=3 byte=0xe5
// i=4 byte=0xbd
// i=5 byte=0x91
// i=6 byte=0x47 ('G')
// i=7 byte=0x6f ('o')

纯字节视角:s[i]byte 类型(uint8),i 从 0 到 len(s)-1。中文每个汉字被拆成 3 个独立字节,直接打印就是乱码。

两种遍历的选择

你想要什么用什么
逐个字符(码点)处理for i, r := range s
逐字节处理(如哈希、加密)for i := 0; i < len(s); i++
同时需要字符和字节位置range(i 是字节位置,r 是码点)
只需要字符数utf8.RuneCountInString(s)

常见误区:用 for i := 0; i < len(s); i++ 然后把 s[i] 当字符用。纯 ASCII 下能工作,遇到中文就全错。C 语言背景者尤其警惕–Go 的 string 不是 char[]

字符数怎么数

1
2
3
4
s := "你好,Go"
fmt.Println(len(s)) // 9,字节数
fmt.Println(utf8.RuneCountInString(s)) // 5,码点数
fmt.Println(len([]rune(s))) // 5,码点数(但会分配)

utf8.RuneCountInString 是标准库里数字符的正确方式,内部就是一次 range 扫描,不分配。len([]rune(s)) 也对,但会分配 []rune,没必要。

⚠️ 注意utf8.RuneCountInString 返回的也只是“码点数”,不等于“用户看到的字形数”。比如 é 可以是单个码点 U+00E9,也可以是 e + 组合重音 U+0301 两个码点。涉及精确字形(grapheme cluster)计数要用 golang.org/x/text/unicode/norm 或专门的断字库,标准库不直接提供。大多数业务场景码点数就够用。

strings 包与 strings.Builder

strings 包是 Go 标准库字符串操作核心,既提供无状态查询/变换函数,也提供 BuilderReaderReplacer 三个有状态工具。

查询与变换函数

这些函数都返回新字符串(字符串不可变,所有“修改”都是产生新串):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import "strings"

s := "Hello, World"

// 包含判断
strings.Contains(s, "World") // true
strings.ContainsAny(s, "aeiou") // true,是否含任一字符
strings.ContainsRune(s, 'W') // true
strings.HasPrefix(s, "Hello") // true
strings.HasSuffix(s, "World") // true

// 定位
strings.Index(s, "World") // 7,首次出现的字节位置
strings.LastIndex(s, "l") // 10,最后一次出现
strings.IndexByte(s, 'l') // 2
strings.IndexRune(s, 'W') // 7
strings.IndexAny(s, "aeiou") // 首个元音的位置

// 大小写
strings.ToUpper(s) // "HELLO, WORLD"
strings.ToLower(s) // "hello, world"
strings.ToTitle(s) // "HELLO, WORLD"

// 修剪
strings.TrimSpace(" hi ") // "hi",去首尾空白
strings.Trim(s, "Held") // "o, Wor",去首尾在集合中的字符
strings.TrimPrefix(s, "Hello, ") // "World"
strings.TrimSuffix(s, "World") // "Hello, "

// 分割与合并
strings.Split("a,b,c", ",") // ["a","b","c"]
strings.SplitAfter("a,b,c", ",") // ["a,","b,","c"]
strings.SplitN("a,b,c", ",", 2) // ["a","b,c"]
strings.Fields(" a b\tc\n") // ["a","b","c"],按空白分割
strings.Join([]string{"a","b","c"}, "-") // "a-b-c"

// 替换与重复
strings.Replace(s, "l", "L", 1) // "HeLlo, World",替换 1 次
strings.ReplaceAll(s, "l", "L") // "HeLLo, WorLd"
strings.Repeat("ab", 3) // "ababab"

// 统计与比较
strings.Count("aabb", "b") // 2
strings.Compare("a", "b") // -1(<),0(=),1(>)
strings.EqualFold("Go", "gO") // true,忽略大小写

这些函数大多 O(n),且大部分返回新字符串会分配。注意 strings.Replace(s, old, new, n)n 是替换次数,n < 0 表示全部替换(等价于 ReplaceAll)。

strings.Builder:高效拼接

strings.Builder(Go 1.10)是大量字符串拼接的首选工具。内部维护一个 []byte 缓冲区,所有 Write* 方法只是向缓冲区追加,最后 String() 用一次 unsafe 零拷贝把 []byte 转成 string 返回。

1
2
3
4
5
6
var b strings.Builder
b.WriteString("Hello")
b.WriteByte(',')
b.WriteRune('世') // 写入一个 rune(UTF-8 编码 3 字节)
b.WriteString("界")
result := b.String() // "Hello,世界"

Builder+ 快的原因:+ 每次都分配新字符串并拷贝两份内容,而 Builder[]byte 缓冲区按 2 倍扩容,平摊到每次写入是 O(1)。总复杂度从 O(n²) 降到 O(n)。

Builder 的重要方法:

  • WriteString / WriteByte / WriteRune / Write:追加各类数据。
  • String() string:返回最终字符串,零拷贝。
  • Len() int / Cap() int:当前字节长度 / 容量。
  • Grow(n int):确保至少还能容纳 n 字节,避免后续扩容。
  • Reset():清空缓冲区,保留底层数组供复用。

Grow 预分配

缓冲区默认从很小容量开始,随写入不断扩容(类似 slice 的 2 倍增长),每次扩容都分配新数组并拷贝旧数据。若大概知道最终长度,Grow 一次能省掉所有中间扩容:

1
2
3
4
5
6
var b strings.Builder
b.Grow(1024) // 预分配 1KB,避免循环里反复扩容
for i := 0; i < 1000; i++ {
b.WriteString("item\n")
}
result := b.String()

提示:循环拼接前先 Grow 几乎稳赚不赔。预估长度不需精确,数量级对就行–多了浪费一点内存,少了也只是回到动态扩容。

Builder 的使用约束

  1. 不可被复制后继续使用:内部 []byte 指针若被浅拷贝(值传递),两个 Builder 会共享底层,写入会产生数据竞争或损坏。go vet 会检查 Builder 的值拷贝。正确做法是传递 *strings.Builder 指针。

    1
    2
    3
    4
    var b strings.Builder
    b.WriteString("x")
    b2 := b // 错误:值拷贝
    b2.WriteString("y") // 行为未定义
  2. String() 后不要继续写:虽然 String() 零拷贝(返回的 string 与缓冲区共享底层),但官方文档明确:调用 String() 后再调用 Write* 是未定义行为。若需继续构建,应先 String() 取出结果、用 Reset() 重置再继续;或用 strings.Clone(b.String()) 拿一份独立拷贝。

  3. 不要从 Builder 里读Builder 只支持写,没有 Read 方法。需要读写双向用 bytes.Buffer

拼接方式性能对比

Go 拼接字符串有好几种方式,性能差异巨大:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 1. + 操作符
s := ""
for i := 0; i < n; i++ {
s += "x" // 每次分配新 string,拷贝全部已有内容
}

// 2. fmt.Sprintf
s := fmt.Sprintf("%s%s", a, b) // 通过格式化引擎,反射开销大

// 3. strings.Join
parts := []string{"a", "b", "c"}
s := strings.Join(parts, "") // 内部一次性算总长度,一次分配

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

// 5. bytes.Buffer
var buf bytes.Buffer
for i := 0; i < n; i++ {
buf.WriteString("x")
}
s := buf.String()

性能排序(从快到慢,针对大量拼接场景):

方式复杂度适用场景备注
strings.Builder + GrowO(n)循环拼接,已知大概长度首选,零拷贝返回
strings.JoinO(n)已有 []string 切片内部一次算总长
bytes.BufferO(n)需要读写字节比 Builder 略慢,多一次拷贝
+ 操作符O(n²)编译期常量拼接 / 少量(<10)次编译器对常量 + 会优化成一次拼接
fmt.Sprintf较慢需要格式化反射 + 解析格式串

基准测试示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

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

const n = 1000

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

func BenchmarkConcatBuilder(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 BenchmarkConcatSprintf(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < n; j++ {
s = fmt.Sprintf("%s%s", s, "x")
}
_ = s
}
}

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

典型结果(数量级):PlusBuilder 慢 100~1000 倍,SprintfPlus 还慢几倍,Buffer 略慢于 Builder。相对关系稳定。

strings.Reader 与 strings.Replacer

strings.Reader 实现 io.Readerio.ReaderAtio.Seeker 等接口,把 string 包装成可读流,零拷贝。当你有字符串需要喂给接收 io.Reader 的 API(如 json.NewDecoderhttp.NewRequest)时用它:

1
2
3
4
5
r := strings.NewReader(`{"name":"Ada"}`)
var v map[string]any
if err := json.NewDecoder(r).Decode(&v); err != nil {
log.Fatal(err)
}

strings.Replacer 是预编译的多模式替换器。当需对同一组替换规则反复应用时,比多次调用 strings.ReplaceAll 高效得多:

1
2
3
4
5
6
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
)
html := r.Replace(`<div>` + userInput + `</div>`) // 一次扫描完成所有替换

Replacer 内部用有限状态机在单次遍历里完成所有替换,避免了 ReplaceAll 多次扫描的开销。对 HTML 转义这种固定规则集特别合适。

变量声明:从 var 到短变量

Go 的设计哲学:显式优于隐式,但提供短语法降低噪音

var 声明

1
2
3
4
5
var name string          // 只指定类型,初始值为零值
var age int = 30 // 同时指定类型和初值
var pi = 3.14159 // 省略类型,由初值推导
var x, y int = 1, 2 // 多变量同类型
var a, b = 10, "hello" // 多变量不同类型,各自推导

var 可出现在包级和函数级。包级变量在程序初始化时按依赖顺序求值。

零值

Go 没有“未初始化变量”–所有变量声明时若不显式赋值,就被赋予该类型的零值。这消除了“读未初始化内存”这类 bug。

类型类别零值
数值(int/float/complex 等)0
布尔false
字符串""(空串,len=0,Data 通常为 nil)
指针nil
切片nil(len=0, cap=0, Data=nil)
map / channel / functionnil
interfacenil(type 与 value 均为 nil)
数组每个元素各自的零值
结构体每个字段各自的零值

注意 nil 切片和空切片([]int{})不同:nil 切片 Datanil,空切片 Data 指向零长度数组。两者 len 都是 0,大多场景行为一致,但 json.Marshal 会把 nil 切片编码成 null、空切片编码成 []

短变量声明 :=

函数内部(不含包级)可用 := 短声明:

1
2
3
name := "Ada"            // 推导为 string
age := 30 // 推导为 int
x, y := 1, 2 // 多变量

关键规则:

  1. 只能用在函数内部(函数体内的局部作用域)。包级变量必须用 varconst

  2. 左侧至少要有一个新变量。若所有左值都已在当前作用域声明过,:= 退化成普通赋值是不允许的–会编译错误。

    1
    2
    x := 1
    x := 2 // 编译错误:no new variables on left side of :=
  3. 混合新旧变量允许:只要有至少一个新变量,:= 就成立,旧变量会被赋值(类型须兼容)。这条规则在错误处理里极其常用:

    1
    2
    3
    x := 1
    x, y := 2, 3 // x 被赋值为 2,y 是新声明
    fmt.Println(x, y) // 2 3
    1
    2
    3
    f, err := os.Open("a.txt")  // f, err 都是新变量
    // ...
    f2, err := os.Open("b.txt") // f2 新变量,err 复用--合法

短变量的类型推导细节

:= 推导类型遵循几条规则:非数值字面量取默认类型(3.14float64"hi"stringtruebool);整数字面量推导为 int,超范围则取能容纳的最小类型;nil 不能单独用 :=(无法推导类型,x := nil 编译错误)。注意不同平台 int 大小不同(32 位平台 4 字节,64 位平台 8 字节),需明确宽度时用 int32 / int64

批量声明

var (...) 块用于把一组相关变量集中声明,常见于包级:

1
2
3
4
5
6
var (
name string = "Ada"
age int = 4
isAI bool = true
version = "1.0.0" // 类型推导
)

同理 const (...) 也支持批量声明,常配合 iota(见后文)。

多重赋值与交换

Go 原生支持多重赋值,右值先全部求值再赋给左值:

1
2
3
a, b = b, a           // 交换两个变量,无需临时变量
i, j = j, i // 经典 swap
x, y = f() // 函数返回多个值

多重返回是 Go 的特色,配合错误处理:

1
2
3
4
v, err := doSomething()
if err != nil {
return err
}

未使用变量是编译错误

Go 对“未使用”零容忍:局部变量声明后必须被使用,否则编译失败。

1
2
3
4
func main() {
x := 10 // 编译错误:x declared and not used
fmt.Println("hi")
}

解决办法:用 _ 显式丢弃(_ = x),或真的去用它。函数参数和包级变量不受此限制。错误处理中常用 _ 忽略某个返回值:

1
2
_, err := os.Stat(path) // 只关心 err
v, _ := doSomething() // 只关心 v

⚠️ 注意:滥用 _ 会让代码意图模糊。_, _ := doSomething() 这种全部丢弃通常意味着你根本不需要调用这个函数,或函数设计有问题。

变量的作用域与遮蔽陷阱

作用域(scope)决定变量名在哪里可见,遮蔽(shadowing)是内层作用域声明同名变量导致外层被“挡住”的现象。Go 的作用域规则简单,但遮蔽陷阱很多。

作用域规则

Go 是词法作用域(静态作用域),作用域由 {} 显式界定:

1
2
3
4
5
6
7
8
9
10
11
12
package main

var x = 1 // 包级作用域,整个文件可见

func main() {
fmt.Println(x) // 1,引用包级 x
{
x := 2 // 新的局部 x,遮蔽包级
fmt.Println(x) // 2
}
fmt.Println(x) // 1,回到包级
}

作用域种类(从外到内):宇宙块(内置标识符 lencapinttrue 等所在)、包块、文件块(import 和顶层声明)、函数块(参数和函数体)、控制流块(if/for/switch/select 的隐式块)。

控制流块这点容易忽略:if 的初始化语句里声明的变量,作用域是整个 if-else 链:

1
2
3
4
5
6
7
8
if v := compute(); v > 0 {
fmt.Println(v) // 可见
} else if v < -10 {
fmt.Println(v) // 可见
} else {
fmt.Println(v) // 可见
}
// fmt.Println(v) // 编译错误:v 在此处不可见

for 的循环变量作用域是整个 for 循环:

1
2
3
4
for i := 0; i < 3; i++ {
// i 在这里可见
}
// i 在这里不可见

遮蔽:合法但危险

遮蔽指内层声明了与外层同名的变量,内层引用指向新变量,外层变量暂时不可见。这在 Go 里完全合法,但极易引发 bug:

1
2
3
4
5
6
7
x := 1
{
x := 2 // 新 x,遮蔽外层
x++
fmt.Println(x) // 3
}
fmt.Println(x) // 1,外层没变

读者很容易误以为内层修改的是同一个 x,其实是两个独立变量。

经典陷阱 1:err 遮蔽

最常见的遮蔽事故发生在错误处理上:

1
2
3
4
5
6
7
8
9
10
11
12
func process(path string) error {
var err error
if cond {
data, err := os.ReadFile(path) // 短声明!err 被遮蔽
if err != nil {
return err // 返回的是内层 err
}
_ = data
}
// 这里外层 err 仍是 nil,即使内层出过错
return err // 永远是 nil,bug!
}

if 块内的 data, err := ... 因为 data 是新变量,使得整个 := 合法,于是 err 也变成了内层新变量。外层 err 没被赋值。修复方式有两种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 方式 1:用 = 赋值,提前声明 data
var data []byte
data, err = os.ReadFile(path) // 注意是 = 不是 :=
if err != nil {
return err
}

// 方式 2:不预先声明外层 err,直接在每个分支里 return
if cond {
data, err := os.ReadFile(path)
if err != nil {
return err
}
_ = data
return nil // 明确返回,不依赖外层 err
}
return nil

方式 2 更符合 Go 的“早返回”习惯,能从结构上避免遮蔽。

经典陷阱 2:循环变量捕获(Go 1.22 前的著名坑)

在 Go 1.22 之前,for 循环变量在整个循环里是同一个变量,每次迭代只是被重新赋值。配合闭包时是个大坑:

1
2
3
4
5
6
7
8
// Go 1.21 及更早
var fs []func()
for i := 0; i < 3; i++ {
fs = append(fs, func() { fmt.Println(i) })
}
for _, f := range fs {
f() // 输出 3, 3, 3 -- 不是 0, 1, 2
}

所有闭包捕获的是同一个 i,循环结束时 i == 3,所以全打印 3。修复(Go 1.21 及更早):

1
2
3
4
5
for i := 0; i < 3; i++ {
i := i // 在循环体内重新声明一个局部 i
fs = append(fs, func() { fmt.Println(i) })
}
// 现在输出 0, 1, 2

Go 1.22+ 修复了这个语义loopvar 语义正式启用):每次迭代都创建一个新的循环变量。同样代码在 Go 1.22+ 直接输出 0, 1, 2,无需 i := i 这个 hack。

⚠️ 注意:若项目还支持 Go 1.21 及更早版本,循环+闭包的代码仍需手动 i := i。Go 1.22+ 项目可放心写。for range 同理。

经典陷阱 3:包级变量被局部遮蔽

1
2
3
4
5
6
7
8
var count int // 包级

func inc() {
count := 1 // 意图是给包级 count 赋值,结果声明了局部
count++
}

fmt.Println(count) // 仍是 0

要修改包级变量,函数内必须用 = 而非 :=

1
2
3
4
func inc() {
count = 1 // 赋值给包级
count++
}

检测遮蔽:go vet 与 shadow linter

go vet 内置一系列检查,但默认不包含 shadow 检查(曾经有,因误报多被移出)。要专门检测遮蔽,用 dominikh 的 shadow 工具:

1
2
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
shadow ./...

或通过 golangci-lint

1
golangci-lint run --enable=govet --enable=govetshadow

开启后,上面那些 err 遮蔽、count 遮蔽都会被报告。建议在 CI 里跑 shadow 检查,能拦下大量隐性 bug。

提示:遮蔽本身不是 bug,但意外的遮蔽几乎总是 bug。把 shadow 检查纳入 CI,并养成“看到 := 就想想左侧有没有同名外层变量”的习惯。

常量:编译期的不可变值

const 声明的常量是编译期就确定、运行期不可改的值。与 var 的本质区别:常量不占用运行时存储(除非被转换为变量),在编译期就被内联到使用处。

基础语法

1
2
3
4
5
6
7
8
9
const Pi = 3.14159
const Greeting = "你好,世界"
const MaxRetry = 3

const (
StatusOK = 200
StatusNotFound = 404
StatusInternalErr = 500
)

常量只能是基本类型:数值、布尔、字符串。不能把切片、map、结构体声明为常量:

1
const nums = []int{1, 2, 3} // 编译错误:const initializer []int{...} is not a constant

若需要“不可变的集合”,惯例是用一个暴露只读接口的变量,或用函数返回新切片。

无类型常量:Go 的独门利器

没有显式指定类型的常量叫无类型常量(untyped constant),它保留任意所需的精度,并在使用时根据上下文“临时”取一个类型:

1
2
3
4
5
const Big = 1 << 100  // 2^100,远超 int64 范围
const Small = Big >> 99 // 2,因为 Big 是无类型,运算在编译期高精度完成

var f float64 = Big // OK,Big 被转为 float64(精度损失,但合法)
// var i int = Big // 编译错误:常量 2^100 溢出 int

无类型常量分几子类:无类型整数、无类型浮点、无类型复数、无类型字符(rune)、无类型字符串、无类型布尔。运算时同类自由组合,最终在使用处确定类型。

1
2
3
const x = 1.0     // 无类型浮点
var i int = x // OK:x 在这里被当作 int 1
var f float64 = x // OK:x 在这里被当作 float64 1.0

xconst x float64 = 1.0(有类型),就不能赋给 int 了:

1
2
const x float64 = 1.0
var i int = x // 编译错误:cannot use x (constant 1 of type float64) as int value

无类型常量让数学公式表达非常自然:

1
2
3
4
const (
TwoPi = 2 * Pi
HalfPi = Pi / 2
)

类型化常量

显式指定类型的常量叫类型化常量,一旦有了类型,就遵循该类型的所有规则(包括溢出检查):

1
2
3
4
5
6
const Pi float64 = 3.14159
const Name string = "Ada"
const MaxInt int32 = 1 << 30

const M int8 = 127
// const N int8 = M + 1 // 编译错误:constant 128 overflows int8

编译期求值

常量表达式在编译期求值,因此不能调用大部分函数。允许的内置函数很少:

1
2
3
4
5
6
const n = len("hello")            // OK,5,len 对常量字符串可在编译期求值
const s = "abc"[1] // OK,'b'
const size = unsafe.Sizeof(int(0)) // OK

// const x = math.Sqrt(2) // 编译错误:math.Sqrt not constant
// const t = time.Now() // 编译错误

这条限制是双向的:一方面常量不能依赖运行时计算;另一方面,正因为编译期求值,常量可用来做编译期断言、配置表等:

1
2
3
4
// 编译期断言:如果 BufferSize 不是 2 的幂,编译失败
const BufferSize = 4096
// 利用“数组大小必须常量且不能为 0/负”的特性做静态断言
var _ = [BufferSize & (BufferSize - 1)]int{}

BufferSize & (BufferSize-1) 在 2 的幂时为 0,数组大小 0 合法;非 2 的幂时为非零,仍合法–若要严格断言需更复杂写法,这里仅示意思路。)

常量与变量的转换

无类型常量赋给变量时按目标类型转换;常量参与变量运算时同理:

1
2
3
const Ratio = 0.5
var n int = 10
total := int(float64(n) * Ratio) // Ratio 临时作为 float64

注意常量与变量混合运算时,常量不会“提升”变量的类型,而是被变量类型吸收:

1
2
3
var i int = 5
// fmt.Println(i + 0.5) // 编译错误:constant 0.5 truncated to integer
// 因为 i 是 int,0.5 作为无类型常量被截断为 0 失败(非整数)

这是 Go 强类型的一部分:常量虽然灵活,但不能削弱已有变量的类型约束。

iota:枚举与位运算常量集

iota 是 Go 的常量计数器,表达枚举和位标志的核心机制。原理:iota 在每个 const (...) 块里从 0 开始,每出现一个 ConstSpec(一行常量声明)就加 1

基本机制

1
2
3
4
5
6
7
8
9
const (
Sunday = iota // 0:iota 在块的第一行,值为 0
Monday // 1:iota 隐式继承表达式 = iota,但 iota 已自增到 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)

关键点:iota 在每个 const 块开头重置为 0;每行(每个 ConstSpec)iota 自增 1,无论是否显式写出 = iota;省略表达式时隐式继承上一行的完整表达式(所以 Monday 等价于 Monday = iota,但此时 iota 已是 1)。因此可以写出更复杂的隐式表达式:

1
2
3
4
5
const (
a = iota * 2 // 0 * 2 = 0
b // 1 * 2 = 2
c // 2 * 2 = 4
)

枚举模式

经典枚举通常配合一个类型和 String() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Weekday int

const (
Sunday Weekday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)

func (d Weekday) String() string {
names := [...]string{"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"}
if d < 0 || int(d) >= len(names) {
return "Unknown"
}
return names[d]
}

Go 没有 enum 关键字,这种“类型 + iota + String 方法”就是标准的枚举实现。

用 _ 跳值

_ 可以占位跳过某些值:

1
2
3
4
5
6
7
8
9
const (
_ = iota // 0,丢弃
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20
GB // 1 << 30
TB // 1 << 40
PB // 1 << 50
EB // 1 << 60
)

_ 占用了 iota=0 的位置,KBiota=1 处,表达式 1 << (10 * iota) 求值为 1 << 10。后续每行继承同一表达式,iota 递增。

位运算常量集

iota 配合 << 是表达位标志(bit flags)的经典手法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Permission uint8

const (
Read Permission = 1 << iota // 1 (0b0001)
Write // 2 (0b0010)
Execute // 4 (0b0100)
Delete // 8 (0b1000)
)

// 组合使用
perm := Read | Write // 3 (0b0011)

// 判断是否有某权限
hasWrite := perm&Write != 0 // true

// 移除权限
perm &^= Execute // 清除 Execute 位(&^ 是 AND NOT)

&^ 是 Go 的“位清除”运算符:a &^ bab 为 1 的位清零。这种模式在标准库里随处可见:os.OpenFile 的标志(O_RDONLYO_CREATEO_TRUNC)、log 包的输出标志、image 包的格式等。

多常量并行声明

iota 支持多常量在同一行并行声明,每行 iota 同步递增:

1
2
3
4
5
const (
a, b = iota, iota * 2 // 0, 0
c, d // 1, 2 (继承表达式 iota, iota*2)
e, f // 2, 4
)

这可以表达“配对”关系。实际中并行声明用得不多,但理解 iota 在并行时也按行递增,能避免误判。

iota 常见错误

错误 1:以为 iota 是行号iota 是 ConstSpec 的序号,从 0 开始:

1
2
3
4
5
const (
A = 1
B = iota // B = 1,不是 0!因为这是块里第 2 个 ConstSpec
C = iota // C = 2
)

B 是 1,因为它是第 2 行(iota 已自增到 1)。A 是 1 只是因为显式赋值 1,与 iota 无关。

错误 2:跨块期望 iota 继续递增。每个 const (...) 块独立计数:

1
2
3
4
5
6
7
8
const (
A = iota // 0
B // 1
)
const (
C = iota // 0,重新开始!
D // 1
)

错误 3:在 const 块外用 iota

1
const x = iota // 编译错误:iota can only be used in a const declaration

iota 只能在 const 块内使用。

错误 4:位标志忘记左移。初学者常写成:

1
2
3
4
5
6
const (
Read = iota // 0
Write // 1
Execute // 2
)
// 0/1/2 不是位标志,Read|Write = 1,丢失了 Read

位标志必须用 1 << iota,让每个值占独立位。直接用 iota 得到的是连续整数,适合枚举但不适合组合。

错误 5:把有类型常量当无类型用

1
2
const Flag int = 1 << iota // int 类型,后续运算受 int 类型约束
const Another = Flag << 1 // 合法但 Flag 有类型后,与无类型常量混合时需注意类型匹配

位标志集通常用无类型常量或显式整数类型,避免在运算中触发类型不匹配。

iota 之外的常量生成器

如果枚举值不是简单递增(如需跳号、字符串映射),可以手写或用代码生成(stringerenumer 工具)。stringergolang.org/x/tools/cmd/stringer)会为枚举类型自动生成 String() 方法:

1
2
//go:generate stringer -type=Weekday
type Weekday int

运行 go generate 后,Weekday(0).String() 返回 "Sunday",避免手写映射表。

横向对比:与 Python/Java/TS 的字符串差异

有其他语言背景的读者,理解 Go 字符串最容易踩的坑往往来自类比错误。本节把 Go 和主流语言的字符串放在一起对比。

存储与编码

语言存储单元内部编码不可变len 含义
Go字节UTF-8字节数
Python 3码点内部灵活(ASCII->1字节/Latin1->2/其他->4)码点数
JavacharUTF-16UTF-16 码元数
JS/TScharUTF-16UTF-16 码元数
Rust字节UTF-8字节数
C字节任意(通常 ASCII/UTF-8)否(char[] 可改)\0 为止

对比要点:Go 和 Rust 选 UTF-8 字节存储,与 Unix 文件/网络/JSON 原生编码一致,无需进出转换,代价是按字符索引不是 O(1);Java 和 JS 选 UTF-16 是历史包袱(90 年代 Unicode 还只有 BMP),BMP 外字符(如 emoji)占 2 个码元,lengthcharAt 行为反直觉;Python 3 选“码点数组”最直观但内存占用最大(中文每字 4 字节),len("你好") == 2;C 没有真正的字符串类型,char* + \0 终止,可变,长度 O(n)。

len 的差异实例

同一个字符串 "你好"(2 个汉字):

1
2
3
// Go
len("你好") // 6(字节)
utf8.RuneCountInString("你好") // 2(码点)
1
2
# Python 3
len("你好") # 2(码点)
1
2
3
// Java
"你好".length() // 2(UTF-16 码元,恰好都在 BMP)
"😀".length() // 2!emoji 在 BMP 外,占 2 个码元(代理对)
1
2
3
// TypeScript/JavaScript
"你好".length // 2
"😀".length // 2,同 Java

Go 程序员从 Java/JS 过来,最容易误以为 len(s) 是字符数。务必记住 Go 里数字符得用 utf8.RuneCountInString

切片语义

1
2
3
// Go:切片共享底层,O(1)
s := "hello, world"
sub := s[:5] // "hello",与 s 共享底层字节
1
2
3
# Python:切片返回新对象
s = "hello, world"
sub = s[:5] # "hello",新字符串(CPython 有时小字符串复用,但语义上是新的)
1
2
3
4
// Java 7+:substring 返回新字符串(拷贝)
// Java 6 及以前:substring 共享底层 char[],可能内存泄漏
String s = "hello, world";
String sub = s.substring(0, 5); // "hello"

Java 6 到 7 的 substring 改动很有名:早期共享底层(O(1) 但有泄漏风险),后来改成拷贝(安全但慢)。Go 选择了共享底层 + 提供 strings.Clone 让用户主动控制,是个折中。

拼接性能

语言推荐拼接方式不推荐(在循环里)
Gostrings.Builder+fmt.Sprintf
Python"".join(parts)+=
JavaStringBuilder+
JSarray.join("") 或模板字符串+=

所有现代语言对循环内 + 拼接都有同样的 O(n²) 警告。Go 的 Builder 和 Java 的 StringBuilder 设计上几乎一致(缓冲区 + 扩容),只是 Go 的 Builder 通过 unsafe 让最终 String() 零拷贝。

可变性

Go、Python、Java、JS、Rust 的字符串都是不可变的。C 的 char[] 可变。不可变性是现代语言的主流选择,理由前面讲过:并发安全、可哈希、切片安全。

字符串与数字转换

1
2
3
4
// Go
s := strconv.Itoa(42) // int -> string
n, err := strconv.Atoi("42") // string -> int
f, err := strconv.ParseFloat("3.14", 64)
1
2
3
# Python
s = str(42)
n = int("42")
1
2
3
// Java
s = Integer.toString(42);
n = Integer.parseInt("42");

Go 的转换函数返回 (值, error) 双值,强迫处理错误输入(如 Atoi("abc"))。Python/Java 直接抛异常。Go 的设计在性能关键路径上更可控,但代码更啰嗦。

实战与陷阱总结

本节把前面散落的知识点收敛成可操作的建议,并补几个高频陷阱。

实战 1:循环拼接用 Builder + Grow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func buildReport(items []Item) string {
// 粗估每条约 50 字节
var b strings.Builder
b.Grow(len(items) * 50)
for _, it := range items {
b.WriteString("- ")
b.WriteString(it.Name)
b.WriteString(": ")
// 数字转字符串也用 strconv,不要 fmt.Sprintf
b.WriteString(strconv.Itoa(it.Count))
b.WriteByte('\n')
}
return b.String()
}

strconv.Itoafmt.Sprintf("%d", n) 快约一个数量级,因为它不走格式串解析。热路径上数字转字符串一律用 strconv

实战 2:大字符串子串用 strings.Clone

1
2
3
4
5
6
7
8
9
10
11
12
13
func extractField(line string) string {
// 假设 line 来自 100MB 的缓冲
start := strings.Index(line, "field=")
if start < 0 {
return ""
}
start += len("field=")
end := strings.IndexByte(line[start:], ',')
if end < 0 {
end = len(line) - start
}
return strings.Clone(line[start : start+end]) // 切断对原缓冲的引用
}

实战 3:map 键用 string,不要 []byte

[]byte 不能直接作 map 键(不可比较)。需要用字节切片作键时,转成 string

1
2
3
m := map[string]int{}
key := []byte("foo")
m[string(key)]++ // string(key) 作键

这种模式在 Go 1.20+ 通常不会分配(编译器优化),但别依赖–若担心,先用 b2s(unsafe)并确保 key 之后不被修改。

实战 4:用 strconv 而非 fmt

1
2
3
4
5
// 慢
s := fmt.Sprintf("%d", n)

// 快
s := strconv.Itoa(n)

fmt 系列在热路径上几乎总是错的:它要走格式串解析、反射、interface{} 装箱。strconv 直接操作字节,快一个数量级。

陷阱 1:循环里 s += ...

1
2
3
4
s := ""
for _, line := range lines {
s += line + "\n" // O(n²),10000 行就开始肉眼可见地慢
}

改用 Builder 立刻快几百倍。Code review 时看到 += 在循环里就该警觉。

陷阱 2:[]byte(s) 反复转换

1
2
3
4
5
for _, item := range items {
if bytes.Contains([]byte(item.JSON), []byte("key")) { // 每次都拷贝整个 JSON
// ...
}
}

item.JSON 可能几 MB,每次循环都拷贝。改用 strings.Contains(item.JSON, "key"),直接对字符串操作,零拷贝。

陷阱 3:iota 位标志忘左移

1
2
3
4
5
const (
Read = iota // 0 错!
Write // 1
Execute // 2
)

Read 是 0,Read|Write 还是 Write,权限完全错乱。必须 1 << iota

陷阱 4:err 遮蔽导致错误被吞

见前文“经典陷阱 1”。用 shadow linter 在 CI 里拦截。

陷阱 5:Go 1.22 前的循环变量捕获

见前文。Go 1.22+ 已修复,但跨版本项目仍需注意。

陷阱 6:无类型常量赋给类型不匹配的变量

1
2
const X = 1.5
var n int = X // 编译错误:constant 1.5 truncated to integer

无类型常量虽然灵活,但不能违反目标类型的约束。整数变量不能接收非整数常量。

陷阱 7:字符串比较的陷阱

1
2
3
4
5
// 字符串相等比较直接用 ==,按字节比较,O(n)
if s == "expected" { ... }

// 比较字节切片
if bytes.Equal(a, b) { ... } // 不要用 string(a) == string(b),多两次拷贝

Go 的 == 对字符串是逐字节比较,先比长度(O(1) 短路),长度相同再比内容。不要用 strings.Compare(s1, s2) == 0 替代 ==,前者更慢且啰嗦–strings.Compare 主要用于 sort.Interface

陷阱 8:空字符串与 nil 字符串

Go 的 string 没有“nil 字符串”概念–零值是 ""。但 *string(字符串指针)可以是 nil

1
2
var s string   // ""
var p *string // nil

JSON 处理时要注意:omitempty""nil 行为不同,*string 才能区分“未提供”和“空字符串”。

要点回顾

  • string 是只读字节序列,底层 (Data, Len)len 是字节数,O(1)。
  • 不可变是设计核心:并发安全、可哈希、切片零拷贝。
  • []byte(s) / string(b) 标准转换会拷贝;编译器对部分只读场景优化;unsafe 可零拷贝但风险高。
  • 切片按字节,中文会乱码;按字符切用 []rune 或手动遍历。
  • range 按 rune,索引按字节;选错就出 bug。
  • strings.Builder + Grow 是循环拼接首选;strings.Join 适合已有切片;避免循环里 +Sprintf
  • 变量声明:包级 var,函数内 :=,零值保证,未使用即编译错误。
  • 遮蔽是合法但危险:err 遮蔽最常见,用 shadow linter 拦截;Go 1.22 修复循环变量捕获。
  • 常量是编译期值:无类型常量精度高、灵活;iota 按行递增,位标志用 1 << iota
  • 横向对比:Go 的 len 是字节数(与 Python 的码点数、Java/JS 的 UTF-16 码元数都不同),这是跨语言最易踩的坑。