返回首页

Go 陷阱:切片 通道 nil

文章分析了 Go 中切片、通道、字符串和 nil 的意外行为。提供代码示例并解释内部实现。适合中高级开发者。

Go 秘密:为什么切片和通道会失败
Advertisement 728x90

Go中切片、通道与nil的陷阱:深入实现细节剖析

Go中的切片看似简单,但要确保行为可预测,必须理解其内部机制。由于切片引用底层数组,意外的修改和内存泄漏可能随之发生。

我们先看数组复用的基本情况:

a := []int{1, 2, 3, 4}
b := a[1:3] // b = [2, 3]
b[0] = 99
fmt.Println(a)

输出:[1 99 3 4]。修改 b 会影响原始数组 a

Google AdInline article slot

现在向 a 添加 append 操作:

a := []int{1, 2, 3, 4}
b := a[1:3]
a = append(a, 5)
b[0] = 99 
fmt.Println(a)

输出:[1 2 3 4 5]append 操作分配了新数组,因此对 b 的修改不再影响原数组。

append 与容量的微妙之处

func main() {
    a := []int{1, 2, 3, 4}
    _ = append(a[:3], 5)
    fmt.Println(a)
}

输出:[1 2 3 5]。容量足够,未触发重新分配。

Google AdInline article slot

显式限制容量时:

func main() {
    a := []int{1, 2, 3, 4}
    _ = append(a[:3:3], 5)
    fmt.Println(a)
}

输出:[1 2 3 4]。容量限制触发了重新分配。

将切片扩展至长度之外:

Google AdInline article slot
func main() {
    s := []int{1, 2, 3, 4, 5}[1:3]
    fmt.Println(s)
    extendedSlice := s[:4]
    fmt.Println(extendedSlice)
}

输出:

[2 3]

[2 3 4 5]

内存泄漏与切片传值

从大数组中提取的小切片会持有整个缓冲区:

bigArray := make([]int, 1e6)
smallSlice := bigArray[:10]

按值传递切片会修改底层数组,但不会改变容量:

func modifySlice(s []int) {
    s[0] = 99
    s = append(s, 100)
}

func main() {
    s := []int{1, 2, 3}
    modifySlice(s)
    fmt.Println(s)
}

输出:[99 2 3]

循环中引用可变元素:

func main() {
    s := []int{}
    refs := []*int{}

    for i := 0; i < 5; i++ {
        s = append(s, i)
        refs = append(refs, &s[0])
    }

    *refs[4] = 4
    *refs[0] = 99999
    fmt.Println(s)
}

输出:[4 1 2 3 4]

nil在切片与映射中的不一致性

var s []int
fmt.Println(len(s)) // 0
s = append(s, 1)

var m map[string]string
fmt.Println(len(m)) // 0
m["key"] = "value" // panic

nil切片可以正常工作,但nil映射在赋值时会引发恐慌。

字符串即字节序列

字符串存储的是字节:

func main() {
    str := "å"
    fmt.Println(str[1]) // 165
}
func main() {
    str := "Three"
    fmt.Println(len(str)) // 6
}

隐藏预声明标识符

func main() {
    true := false
    uint := "bob"
    string := 0
    fmt.Printf("%v, %v, %v", true, uint, string)
}

输出:false, bob, 0

通道:读取与关闭

读取前需检查通道状态:

ch := getCountChannel[int]()

if v, ok := <-ch; ok {
    fmt.Println(v)
} else {
    fmt.Println("channel closed")
}

将通道设为 nil 可禁用 select 分支:

var in <-chan int = ch
if paused {
    in = nil
}

select {
    case v := <-in:
        fmt.Println("got", v)
    case <-ctx.Done():
        return
}

关闭后,通道会持续返回零值:

func main() {
    ch := make(chan int, 1)
    ch <- 0
    close(ch)

    fmt.Println(<-ch) // 0 true
    fmt.Println(<-ch) // 0 false
    fmt.Println(<-ch) // 0 false
}

带检查版本:

v, ok := <-ch
fmt.Println(v, ok)

接口中的类型化nil

type MyErr struct{}

func (MyErr) Error() string { return "boom" }

func f() error {
    var e *MyErr = nil
    return e
}

func main() {
    err := f()
    fmt.Println(err == nil) // false
}

接口同时保存类型与值。正确做法是:

func f() error {
    var e *MyErr = nil
    if e == nil {
        return nil
    }
    return e
}

for range与指针的问题

vals := []int{1, 2, 3}
ptrs := []*int{}

for _, v := range vals {
    ptrs = append(ptrs, &v)
}

fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 3 3 3

变量 v 在每次迭代中被重用。

核心要点:

  • 切片共享底层数组:修改对所有引用可见。
  • append 在容量不足时会重新分配内存。
  • nil 切片支持 appendnil 映射不支持。
  • 字符串存储字节,len() 计算的是字节数。
  • 接口中类型化的 nil 不等于 nil
  • for range 使用单一循环变量。

— Editorial Team

Advertisement 728x90

继续阅读