跳到正文

02 复合类型:数组、切片、map、struct ​

一句话:Go 把「值语义」和「共享底层数据」分开:数组和 struct 赋值即复制,切片和 map 复制的是描述符或指针,理解这一点才能解释为什么改一个变量会影响另一个。

适用 Go 1.22+;slices/maps 泛型工具从 Go 1.21 起进入标准库。

为什么需要 ​

业务数据不是单个标量。选数组、切片、map 还是 struct,决定了复制成本、能否比较、并发是否安全和内存怎么排。Go 没有 class,struct 加嵌入承担建模;切片是最常用的容器,但它的 header 和底层数组分离,别名问题也最多。

核心机制 ​

数组:长度是类型的一部分 ​

go
var a [3]int
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3} // 长度由元素个数推断
// [3]int 和 [4]int 是不同类型,不能直接赋值

数组赋值和传参都整体复制,成本与长度成正比,大数组应该传指针或改用切片。元素可比较时数组也可比较,能做 map 的 key。固定长度缓冲区、矩阵的一行适合数组,日常业务更多用切片。

切片:ptr、len、cap 三要素 ​

切片变量本身是一个三字段 header:指向底层数组的指针、长度、容量。赋值和传参复制 header,底层数组仍然共享。

  • s[i:j] 的 len 是 j-i,cap 是 原 cap - i;新切片只要还在 cap 内,append 就写回同一底层数组。
  • append 超出 cap 时分配新数组、复制旧元素、返回新 header;旧切片仍指向旧数组,从此分叉。扩容比例是实现细节,不要依赖。
  • copy(dst, src) 复制 min(len(dst), len(src)) 个元素,不改变长度;要变长用 append。
  • 三索引切片 s[i:j:k] 把 cap 限制为 k-i,防止调用方 append 写到原数组。
  • nil 切片可以 len、range、append;append 时才分配。[]int{} 和 nil 长度都是 0,但 JSON 编码分别是 [] 和 null。
go
a := []int{1, 2, 3}
b := a[:2]         // 共享底层数组
b[0] = 99          // a[0] 也变 99
b = append(b, 100) // cap 够,a[2] 也变 100

map:引用语义、无序、非并发安全 ​

  • map 变量内部指向 hmap,赋值复制的是这个指针,两个变量操作同一张表。
  • 零值是 nil:读、len、delete、range 都安全;写入 nil map 会 panic,必须 make 或字面量初始化。
  • 迭代顺序随机,不能依赖;需要有序输出就先取 key 排序。
  • key 必须可比较:数值、string、指针、可比较的 struct/数组;slice、map、func 不能做 key。
  • 并发读写会触发运行时 fatal error(concurrent map writes),不能被 recover。并发场景用 sync.RWMutex 保护,或 sync.Map(只适合读多写少、key 稳定)。

struct:值类型、tag 与嵌入 ​

  • struct 赋值和传参复制所有字段;包含 slice/map/func 的 struct 不可比较,不能做 map key。
  • 导出看首字母大写;tag 是给反射和序列化看的字符串,本身不改变类型,例如 Name string `json:"name,omitempty"`。
  • 嵌入把字段和方法提升到外层,是组合不是继承;外层同名字段会遮蔽,访问被遮蔽的字段写 c.Point.X。
  • 嵌入指针要求初始化,否则访问提升字段会 panic;嵌入值更安全。
  • 字段按声明顺序布局,合理排列能减少 padding,但不要为省几个字节牺牲可读性,除非它是热结构。

最小上手 ​

go
package main

import "fmt"

type Point struct{ X, Y int }

type Circle struct {
	Point // 嵌入:字段被提升,可写 c.X
	R     int
}

func main() {
	arr := [3]int{1, 2, 3}
	brr := arr
	brr[0] = 100
	fmt.Println(arr, brr) // [1 2 3] [100 2 3]:数组赋值整体复制

	a := []int{1, 2, 3}
	b := a[:2] // len=2 cap=3,共享底层数组
	b[0] = 99
	b = append(b, 100) // cap 够,直接写到 a[2]
	fmt.Println(a, b)  // [99 2 100] [99 2 100]

	c := make([]int, 2, 2)
	copy(c, a)        // 只复制 min(len) 个
	c = append(c, 7)  // cap 不够,分配新数组
	fmt.Println(c, a) // [99 2 7] [99 2 100]

	m := map[string]int{"go": 1}
	n := m
	n["go"] = 2
	fmt.Println(m["go"]) // 2:同一张表

	p := Point{1, 2}
	q := p
	q.X = 9
	fmt.Println(p, q) // {1 2} {9 2}

	cir := Circle{Point: Point{1, 2}, R: 3}
	fmt.Println(cir.X, cir.Point.Y) // 1 2
}

完整版本见 examples/02_composite/main.go。运行:go run examples/02_composite/main.go。

生产动作 ​

  • 预分配:已知长度用 make([]T, n),未知但可估算用 make([]T, 0, n);批量 append 前先算容量。
  • 不要泄漏内部切片:函数返回内部数据时用 slices.Clone,或用三索引限制 cap,避免调用方 append 改到内部状态。
  • map 初始化用 make(map[K]V, n) 或字面量;并发访问加锁;不要用 map 遍历顺序做业务逻辑。
  • struct 传值还是传指针:大 struct、需要修改、含 sync.Mutex 时用指针;小不可变值传值。含锁的 struct 不要复制,go vet 会检查 copylocks。
  • 检查:go test -race ./... 抓并发 map;benchmark 的 allocs/op 看复制和分配;unsafe.Sizeof 只用于确认布局,别过度优化。

边界 ​

  • 切片 header 复制不代表底层数组复制;append 后是否共享取决于 cap,不能凭「看起来是副本」判断。
  • copy 不扩容,目标长度不够会截断。
  • nil map 可读不可写;并发写是 fatal,不是普通 panic。
  • map 迭代顺序随机,且每次运行可能不同。
  • struct 含不可比较字段时不能用 ==;浮点字段比较要容差。
  • 嵌入不是继承:没有虚函数分发,方法集规则见 03。

讲清楚 ​

数组和 struct 是值类型,赋值就把整个内容复制一份,所以改副本不影响原值。切片变量只是一个三字段的描述符,复制描述符不会复制底层数组,两个切片因此可能指向同一块内存;只要 append 没超过 cap,就会写回同一数组,这是最常见的别名 bug。map 变量内部是指向哈希表的指针,赋值后两个变量操作同一张表,nil map 可读不可写,并发写会直接 fatal。struct 用嵌入做组合,字段和方法会被提升,但不是继承。工程上要预分配、限制 cap、别把内部切片直接交出去,并用 -race 验证共享状态。

适用 Go 1.22+ | 初稿 2026-10

把一门技术讲透到既懂也会用