Common Go Mistakes(Ⅱ 数据类型)

导航

  • [常见的 Go 错误](#常见的 Go 错误)
    • 引言
    • [Ⅱ 数据类型](#Ⅱ 数据类型)
      • [17. 八进制字面量引起的混淆](#17. 八进制字面量引起的混淆)
      • [18. 整数溢出](#18. 整数溢出)
      • [19. 误用浮点数](#19. 误用浮点数)
      • [20. 不理解切片的长度和容量](#20. 不理解切片的长度和容量)
      • [21. 不高效的切片初始化](#21. 不高效的切片初始化)
      • [22. 不理解nil切片和空切片](#22. 不理解nil切片和空切片)
      • [23. 不合适的检查slice是否为空](#23. 不合适的检查slice是否为空)
      • [24. 没有正确的拷贝切片](#24. 没有正确的拷贝切片)
      • [25. slice append 带来的副作用](#25. slice append 带来的副作用)
      • [26. slice 和内存泄漏](#26. slice 和内存泄漏)
      • [27. 低效的 Map 初始化](#27. 低效的 Map 初始化)
      • [28. map 与内存泄漏](#28. map 与内存泄漏)
      • [29. 不正确的比较值](#29. 不正确的比较值)

常见的 Go 错误

参考 100go

引言

  • 使用基本类型时常犯的错误
  • 使用 slice 和 map 时常犯的错误

Ⅱ 数据类型

17. 八进制字面量引起的混淆

在 Go 中以 0 打头的数字是八进制,例如:

go 复制代码
sum := 100 + 010
fmt.Println(sum)

以上代码的输出结果是108。

建议始终使用 0o 前缀来表示八进制。

18. 整数溢出

运行时的整型溢出不会导致panic,所以需要自己检查:

  • 自增
go 复制代码
func Inc32(counter int32) int32 {
	if counter == math.MaxInt32 {
		panic("int32 overflow")
	}
	return counter + 1
}

func IncInt(counter int) int {
	if counter == math.MaxInt {
		panic("int overflow")
	}
	return counter + 1
}

func IncUint(counter uint) uint {
	if counter == math.MaxUint {
		panic("uint overflow")
	}
	return counter + 1
}
  • 加法
go 复制代码
func AddInt(a, b int) int {
	if a > math.MaxInt-b {
		panic("int overflow")
	}

	return a + b
}
  • 乘法
go 复制代码
func MultiplyInt(a, b int) int {
	if a == 0 || b == 0 {
		return 0
	}

	result := a * b
	if a == 1 || b == 1 {
		return result
	}
	if a == math.MinInt || b == math.MinInt {
		panic("integer overflow")
	}
	if result/b != a {
		panic("integer overflow")
	}
	return result
}

对于大数的操作可以使用 math/big

19. 误用浮点数

计算机以二进制的形式来表示浮点数,有些小数无法被精确地表示为有限位的二进制小数。需要注意:

  • 比较浮点数时,通过比较二者的 delta 值是否介于一定的范围内
  • 在进行加法或减法时,将具有相似数量级的操作分成同一组以提高精度
  • 在进行加法和减法之前,应先进行乘法和除法 (加减法误差会被乘除放大)

20. 不理解切片的长度和容量

切片可以看成是底层数组的一段视图,包含三部分:指向底层数组的指针、长度(len)和容量(cap)。
len(s) 表示切片中当前拥有的元素个数。
cap(s) 表示从切片的第一个元素开始,到底层数组末尾所能容纳的最大元素数量。

例如:

go 复制代码
s := make([]int, 3, 5) // 创建一个长度为3,容量为5的切片
// s = [0, 0, 0]
// len(s) == 3
// cap(s) == 5

s2 := make([]int, 100) // len=100, cap=100
// 此时 s2 中所有元素都是零值,你可以直接用 s2[i] = value 赋值

需要注意的点:

  • 容量不足导致的性能浪费

    当使用 append() 函数向切片添加元素时,如果切片的 长度 (len) 等于容量 (cap),Go 运行时就会分配新的底层数组,然后进行数据复制,将旧底层数组中的所有元素复制到新数组中。这种重新分配(reallocation)和复制的操作非常耗时,尤其是在大型切片上,会导致严重的性能下降。

    解决方案:预先分配容量(创建切片时)。

  • 切片操作导致的意外副作用(共享底层数组)

    Go 语言中的切片操作 (s[i:j]) 并没有复制数据,而是创建了一个新的切片头,指向相同的底层数组。如果你通过一个切片修改了底层数组的元素,所有指向该数组区域的其他切片也会看到这个修改。

    解决方案:copy 显示复制切片,而不是共用底层数组。

21. 不高效的切片初始化

这个条款指的是没有为切片预先分配足够的容量,从而导致程序在运行时产生不必要的性能开销。本质是浪费了 CPU 资源和增加了垃圾回收(GC)的压力。

高效的切片初始化原则是:如果能预估切片的最终大小,就应该预先分配足够的容量,以避免在 append 过程中反复进行底层数组的重新分配和数据复制。

22. 不理解nil切片和空切片

Go 语言中存在两种创建"空"切片的方式,但它们在底层内存表示、JSON 序列化上的行为是不一样的。

特性 nil 切片 (Nil Slice) 空切片 (Empty Slice)
创建方式 var s []string 或 s := []string(nil) s := []string{} 或 s = make([]string, 0)
底层指针 nil 底层数组指向一个非nil的内存地址
长度(len(s)) 0 0
容量(cap(s)) 0 0
相等性判断 s == nil return true s == nil return false
json序列化 序列化为null 序列化为[] (空数组)

在json序列化时需要注意。

23. 不合适的检查slice是否为空

  • 总是使用 len(s) == 0 来判断切片是否包含元素。
  • 避免在业务逻辑中依赖 s == nil,除非你是在处理 JSON 序列化(需要精确区分返回 null 还是 [])或非常底层的内存操作。
  • len(s) 的操作是安全的,即使 s 是 nil 也不会导致运行时panic。

24. 没有正确的拷贝切片

使用 copy 拷贝一个 slice 元素到另一个 slice 时,需要记得,实际拷贝的元素数量是二者 slice 长度中的较小值。

go 复制代码
package main

import "fmt"

func bad() {
	src := []int{0, 1, 2}
	var dst []int
	copy(dst, src)
	fmt.Println(dst)  // []

	_ = src
	_ = dst
}

func correct() {
	src := []int{0, 1, 2}
	dst := make([]int, len(src))
	copy(dst, src)
	fmt.Println(dst)  // [0 1 2]

	_ = src
	_ = dst
}

func main() {
	bad()
	correct()
}

也有更加简洁的写法来复制slice的元素:

go 复制代码
src := []int{0, 1, 2}
dst := append([]int(nil), src...)

25. slice append 带来的副作用

这个错误的本质是:当 append 操作没有触发底层数组扩容时,它会覆盖旧切片中的数据 。

例如在一个切片是从另一个切片(或数组)切片操作得来,并且容量充足的情况下。

go 复制代码
s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)
// s1=[1 2 10], s2=[2], s3=[2 10]

如何避免:

  • 使用 copy() 函数来打破共享关系
go 复制代码
// 原始切片
original := []int{1, 2, 3}

// 创建完全独立的新切片
safeCopy := make([]int, len(original))
copy(safeCopy, original)

// 现在对 safeCopy 执行 append 操作,无论是否扩容,都不会影响 original
safeCopy = append(safeCopy, 999) 

// original 保持不变
  • 使用三索引切片(Full Slice Expression) 限制容量
    slice[i:j:k] 表示强制将新切片的容量设置为 k−i,这样在 append 时会触发自动扩容
go 复制代码
s1 := []int{1, 2, 3}
s2 := s1[1:2:2]
s3 := append(s2, 10)

26. slice 和内存泄漏

切片是对底层数组的视图,包含指向底层数组的指针、长度和容量。对大切片进行子切片(slice 操作)时,新切片会共享同一底层数组的内存。

容量泄露

如果从一个非常大的切片中截取出一小部分数据,并且只保留这个小切片,会导致底层大数组不能被回收,从而出现"内存泄漏"式的高内存占用。

例如:

go 复制代码
func getMsgType(msg []byte) []byte {
    // 仅返回前 5 字节的子切片(共享底层数组)
    return msg[:5]
}

如果这些返回值被长期保留,原始大 msg 的整个底层数组将一直保留在内存中。

解决方案:显式拷贝到新的小数组

go 复制代码
func getMsgTypeCopy(msg []byte) []byte {
    b := make([]byte, 5)
    copy(b, msg[:5])
    return b
}
切片和指针的内存泄露风险

如果切片的元素是指针或具有指针字段的结构体,则元素将不会被 GC 回收。

例如:

go 复制代码
package main

import (
        "fmt"
        "runtime"
)

type Foo struct {
        v []byte
}

func main() {
        foos := make([]Foo, 1_000)
        printAlloc()

        for i := 0; i < len(foos); i++ {
                foos[i] = Foo{
                        v: make([]byte, 1024*1024),
                }
        }
        printAlloc()

    two := keepFirstTwoElementsOnlyMarkNil(foos)
        runtime.GC()
        printAlloc()
        runtime.KeepAlive(two)
}

func keepFirstTwoElementsOnly(foos []Foo) []Foo {
        return foos[:2]
}

func keepFirstTwoElementsOnlyCopy(foos []Foo) []Foo {
        res := make([]Foo, 2)
        copy(res, foos)
        return res
}

func keepFirstTwoElementsOnlyMarkNil(foos []Foo) []Foo {
        for i := 2; i < len(foos); i++ {
                foos[i].v = nil
        }
        return foos[:2]
}

func printAlloc() {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        fmt.Printf("%d KB\n", m.Alloc/1024)
}

解决方案有2种,一是可以拷贝到一个新的小切片,断开与原始数组的引用;二是可以显式地将底层数组中不再需要的指针设置为nil。

如果性能很重要,并且 i 更接近 n 相比于接近于 0,可以考虑第二个选项。

27. 低效的 Map 初始化

在创建 Map 时,如果事先知道 Map 将包含的元素大致数量,应该为它预先分配容量。

如果不预先分配容量会导致 Go Runtime 在 Map 增长过程中进行多次昂贵的 哈希表重新分配(Resizing)和元素迁移(Rehashing) 操作,从而降低程序性能。

Map 的扩容是一个高计算成本的操作。它涉及:

  • Resizing: 分配一个新的、更大的桶数组(通常是原来大小的两倍)。
  • Rehashing: 将所有旧桶中的元素重新计算哈希值,并迁移到新桶中。

这个错误与 21(低效的 slice 初始化) 类似:

  • Slice 的问题: 不预分配容量会导致 append 操作频繁触发底层数组的重新分配和数据拷贝。
  • Map 的问题: 不预分配容量会导致元素插入操作频繁触发哈希表的重新分配和元素迁移(更复杂,成本更高)。

28. map 与内存泄漏

Go 语言中 map 的内存不会自动收缩,即使删除所有元素并触发 GC,底层桶(bucket)结构仍保留在内存中,导致潜在的内存泄漏。例如:

go 复制代码
package main

import (
	"fmt"
	"runtime"
)

func main() {
	// Init
	n := 1_000_000
	m := make(map[int][128]byte)
	printAlloc()

	// Add elements
	for i := 0; i < n; i++ {
		m[i] = randBytes()
	}
	printAlloc()

	// Remove elements
	for i := 0; i < n; i++ {
		delete(m, i)
	}

	// End
	runtime.GC()
	printAlloc()
	runtime.KeepAlive(m)
}

func randBytes() [128]byte {
	return [128]byte{}
}

func printAlloc() {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("%d MB\n", m.Alloc/1024/1024)
}

解决方案:

  1. 重新创建 Map,将旧 Map 中需要的元素拷贝过去。
  2. 使用指针作为值类型map[int]*[128]byte。这样 delete(m, key) 操作会清除桶内的指针。Go 的 GC 就可以回收被指针指向的对象。 Map 桶本身占用的内存虽然没变,但它所引用的外部大对象内存被释放了。
  3. 使用 sync.Map 或 LRU 缓存(针对缓存场景)。

29. 不正确的比较值

在 Go 语言中,对于部分类型可以使用 == 和 != 运算符进行值比较。

可比较的类型

大多数基本类型和复合类型都是可比较的,但需要注意它们比较的机制:

  • 基本类型(bool, int, float, string): 使用 == 比较它们的值。

  • Struct(结构体): 只有当结构体的所有字段都是可比较类型时,结构体才可比较。两个结构体只有在它们的对应字段都相等时才相等。

  • Array(数组): 只有当数组的元素类型是可比较类型时,数组才可比较。两个数组只有在它们的长度相同且所有对应元素都相等时才相等。

  • Pointer(指针): 比较指针指向的地址。两个指针相等,当且仅当它们都指向内存中的同一个变量,或都为 nil。

  • Interface(接口): 接口值的比较规则较为复杂。

    • 如果接口的动态值是不可比较的类型(如切片、映射),则运行时会发生 panic(运行时错误)。
    • 两个接口值相等,当且仅当它们具有 相同的动态类型 和 相同的动态值 ,或者它们都为 nil。例如:
go 复制代码
type MyError struct{}

func (e *MyError) Error() string { return "oops" }

func returnsNilError() error {
    var e *MyError = nil // 动态类型是 *MyError,动态值是 nil
    return e             // e 被赋给 error 接口
}

func main() {
    err := returnsNilError()
    fmt.Println(err == nil) // 输出: false (陷阱!)
    // 原因:接口变量 err 的动态类型是 *MyError (非 nil),
    //      动态值是 nil。因此 err 整体上是非 nil 的接口值。
}
不可比较的类型
  • Slice(切片)
  • Map(映射/字典)

可以使用reflect.DeepEqual来比较,但性能差。更好的做法是实现一个自定义的方法。

相关推荐
microrain10 小时前
读数是 1008,用电量是 8:SagooIoT 计算服务的增量口径与七种计算策略
物联网·golang·计算服务·sagooiot
看浪的路人12 小时前
第7讲:大模型后端服务化——Go 实现高并发 LLM Proxy / Router / Fallback
开发语言·后端·golang
灯澜忆梦15 小时前
【Go Zap 日志库】#1 |快速上手 + 配置详解
开发语言·后端·golang
不是万能的豆学长17 小时前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
tachibana217 小时前
Go 语言 Map 的底层实现原理
开发语言·后端·golang·make
tachibana219 小时前
Go语言里slice是怎么扩容的?
后端·golang·go·切片
西瓜很甜哟19 小时前
golang实现大文件分片上传
后端·golang·html
tachibana21 天前
使用for range 的时候,它的地址会发生变化吗?
开发语言·后端·golang·go·语法
王中阳Go1 天前
Go 后端转 AI:非科班怎么补?一张 3 列路线表(可保存)
开发语言·人工智能·后端·python·golang·大语言模型
for_ever_love__2 天前
LangChain Agentic RAG:让 Agent 自己决定要不要检索、检索几次
人工智能·golang·langchain·rag·langgraph·agentic·自适应检索