Week 03: 复合类型与内存布局
深入理解数组、切片、映射和结构体的内存结构, 掌握 Go 数据组织的核心方法.
1. 数组 (Array)
1.1 数组定义
数组是固定长度的同类型元素序列. 长度是类型的一部分.
// 声明并初始化
var arr [5]int // [0 0 0 0 0]
arr := [5]int{1, 2, 3, 4, 5} // [1 2 3 4 5]
arr := [...]int{1, 2, 3} // 编译器推断长度为 3
arr := [5]int{0: 1, 4: 5} // 索引初始化: [1 0 0 0 5]1.2 数组是值类型
与 C 不同, Go 的数组是值类型. 赋值和传参会复制整个数组:
a := [3]int{1, 2, 3}
b := a // 完整复制
b[0] = 100
fmt.Println(a) // [1 2 3] (a 不受影响)
fmt.Println(b) // [100 2 3]这在大数组传参时会有性能问题, 因此实际开发中更常使用切片或指针.
1.3 数组比较
相同类型的数组可以直接比较:
a := [3]int{1, 2, 3}
b := [3]int{1, 2, 3}
fmt.Println(a == b) // true1.4 数组长度是类型的一部分
var a [3]int
var b [5]int
// a = b // 编译错误! [3]int 和 [5]int 是不同类型2. 切片 (Slice)
切片是 Go 中最常用的数据结构, 它是对底层数组的引用视图.
2.1 切片创建
// 从数组创建
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4] // [2 3 4]
// 字面量 (隐式创建底层数组)
s := []int{1, 2, 3}
// make 函数
s := make([]int, 5) // len=5, cap=5, 初始化为零值
s := make([]int, 3, 10) // len=3, cap=102.2 切片的内存结构
切片在运行时是一个描述符 (Descriptor):
// 类似于 reflect.SliceHeader
type SliceHeader struct {
Data uintptr // 指向底层数组的指针
Len int // 当前长度
Cap int // 容量 (从 Data 到底层数组末尾的元素数)
}可视化:
切片 s: {Data: 0x..., Len: 3, Cap: 5}
↓
┌───┬───┬───┬───┬───┐
底层数组: │ 1 │ 2 │ 3 │ 4 │ 5 │
└───┴───┴───┴───┴───┘
↑ ↑
s[0] s[2]2.3 切片操作
s := []int{0, 1, 2, 3, 4}
len(s) // 长度: 5
cap(s) // 容量: 5
s[2] // 索引: 2
s[1:3] // 切片: [1 2] (不包括索引 3)
s[:3] // [0 1 2]
s[2:] // [2 3 4]
s[:] // 完整切片2.4 切片共享底层数组
重要: 从同一个数组创建的多个切片共享底层数据:
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:4] // [2 3 4]
s2 := arr[2:5] // [3 4 5]
s1[1] = 100 // 修改底层数组
fmt.Println(arr) // [1 2 100 4 5]
fmt.Println(s2) // [100 4 5] (s2 也受影响!)解决方案: 如果需要独立副本, 使用 copy:
s1 := []int{1, 2, 3}
s2 := make([]int, len(s1))
copy(s2, s1) // s2 是独立副本2.5 append 追加
append 向切片末尾追加元素, 可能触发扩容:
s := []int{1, 2, 3}
s = append(s, 4) // [1 2 3 4]
s = append(s, 5, 6, 7) // [1 2 3 4 5 6 7]
// 追加另一个切片
s2 := []int{8, 9}
s = append(s, s2...) // 展开 s2必须接收返回值: append 可能会分配新的底层数组, 必须用变量接收返回值.
2.6 切片扩容机制
当 len > cap 时, 运行时会分配更大的底层数组并复制数据.
Go 1.18+ 的扩容策略:
- 如果目标容量 > 旧容量 * 2, 直接扩容到目标容量.
- 否则:
- 如果旧容量 < 256, 翻倍 (2x).
- 如果旧容量 >= 256, 按照
newcap += (newcap + 3*threshold) / 4渐进式增加 (约 1.25x).
s := make([]int, 0)
for i := 0; i < 10; i++ {
s = append(s, i)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// 输出 cap: 1, 2, 4, 8, 8, 8, 8, 8, 16, 162.7 nil 切片 vs 空切片
var s1 []int // nil 切片: Data=nil, Len=0, Cap=0
s2 := []int{} // 空切片: Data≠nil, Len=0, Cap=0
s3 := make([]int, 0) // 空切片
fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(len(s1) == 0) // true
fmt.Println(len(s2) == 0) // true两者在 len 和 append 行为上相同, 但 JSON 序列化时有区别:
nil切片 →null- 空切片 →
[]
2.8 切片引用导致的内存泄漏
小切片引用大数组时, 大数组无法被 GC 回收:
// 问题: 小切片引用大数组
func getHeader(data []byte) []byte {
return data[:10] // 仍然引用整个 data 底层数组 (可能很大)
}
// 解决: 复制数据
func getHeader(data []byte) []byte {
header := make([]byte, 10)
copy(header, data[:10])
return header
}2.9 slices 和 maps 包 (Go 1.21+)
Go 1.21 引入了泛型版本的切片和映射工具包:
import (
"slices"
"maps"
)
// slices 包
s := []int{3, 1, 4, 1, 5}
slices.Sort(s) // 原地排序
slices.Reverse(s) // 原地反转
slices.Contains(s, 3) // 是否包含: true
idx := slices.Index(s, 4) // 查找索引
s2 := slices.Clone(s) // 克隆 (深拷贝)
s3 := slices.Compact(s) // 去除连续重复
// maps 包
m := map[string]int{"a": 1, "b": 2}
keys := slices.Collect(maps.Keys(m)) // 获取所有 key
values := slices.Collect(maps.Values(m)) // 获取所有 value
m2 := maps.Clone(m) // 克隆2.10 clear 内置函数 (Go 1.21+)
// 清空切片 (保留容量, 元素置零)
s := []int{1, 2, 3}
clear(s) // s = [0 0 0], len=3, cap 不变
// 清空 map (删除所有键值对)
m := map[string]int{"a": 1}
clear(m) // m = {}3. 映射 (Map)
Map 是无序的键值对集合 (哈希表).
3.1 创建映射
// make 创建
m := make(map[string]int)
// 字面量
m := map[string]int{
"apple": 1,
"banana": 2,
}3.2 基本操作
m := make(map[string]int)
// 写入
m["apple"] = 1
m["banana"] = 2
// 读取
v := m["apple"] // 1
v := m["unknown"] // 0 (零值, 不是错误)
// 检查是否存在
v, ok := m["apple"]
if ok {
fmt.Println("存在:", v)
} else {
fmt.Println("不存在")
}
// 删除
delete(m, "apple")
// 获取大小
fmt.Println(len(m))
// 遍历 (顺序不固定!)
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}3.3 Map 的内存结构
Map 底层是复杂的哈希表结构 (hmap):
type hmap struct {
count int // 元素个数
B uint8 // 桶数量的对数 (2^B 个桶)
hash0 uint32 // 哈希种子 (随机化)
buckets unsafe.Pointer // 桶数组
// ...
}每个桶 (bucket) 存储 8 个键值对:
+------------------+
| tophash [8]uint8 | // 哈希高 8 位, 快速筛选
+------------------+
| keys [8]Key |
+------------------+
| values [8]Value |
+------------------+
| overflow *bucket | // 溢出桶链表
+------------------+3.4 Map 特性
- 无序遍历: Go 故意随机化遍历顺序, 防止依赖顺序.
- 并发不安全: 多 goroutine 同时读写会 panic. 使用
sync.Map或加锁. - 不可比较: Map 不能用
==比较, 只能与nil比较.
3.5 Map 扩容
当负载因子 (元素数 / 桶数) 超过阈值 (6.5), 会触发扩容:
- 翻倍扩容 (负载因子过高)
- 等量扩容 (溢出桶过多, 整理碎片)
扩容是渐进式的, 每次写操作迁移 1-2 个桶, 避免一次性迁移导致卡顿.
4. 结构体 (Struct)
4.1 结构体定义
type Person struct {
Name string
Age int
City string
}4.2 创建结构体
// 方式 1: 按字段名 (推荐)
p1 := Person{Name: "Alice", Age: 25, City: "Beijing"}
// 方式 2: 按顺序 (不推荐, 依赖字段顺序)
p2 := Person{"Bob", 30, "Shanghai"}
// 方式 3: 零值
var p3 Person // {Name:"", Age:0, City:""}
// 方式 4: 指针
p4 := &Person{Name: "Charlie", Age: 35}4.3 访问字段
p := Person{Name: "Alice", Age: 25}
fmt.Println(p.Name) // Alice
p.Age = 26
fmt.Println(p.Age) // 264.4 匿名字段与嵌入
type Address struct {
City string
Country string
}
type Person struct {
Name string
Age int
Address // 匿名嵌入
}
p := Person{
Name: "Alice",
Age: 25,
Address: Address{
City: "Beijing",
Country: "China",
},
}
// 可以直接访问嵌入字段
fmt.Println(p.City) // Beijing (等价于 p.Address.City)
fmt.Println(p.Country) // China4.5 结构体内存对齐
为了 CPU 访问效率, 编译器会在字段之间插入填充字节 (Padding):
type BadStruct struct {
A int8 // 1 byte
B int64 // 8 bytes
C int8 // 1 byte
}
// 实际大小: 24 bytes (1 + 7(padding) + 8 + 1 + 7(padding))
type GoodStruct struct {
B int64 // 8 bytes
A int8 // 1 byte
C int8 // 1 byte
}
// 实际大小: 16 bytes (8 + 1 + 1 + 6(padding))优化原则: 将大字段放在前面, 小字段放在一起.
import "unsafe"
fmt.Println(unsafe.Sizeof(BadStruct{})) // 24
fmt.Println(unsafe.Sizeof(GoodStruct{})) // 164.6 空结构体 struct
struct{} 大小为 0, 不占用内存:
fmt.Println(unsafe.Sizeof(struct{}{})) // 0
// 用途 1: Set 模式 (只关心 key)
set := make(map[string]struct{})
set["apple"] = struct{}{}
// 用途 2: Channel 信号 (只传递事件, 不传递数据)
done := make(chan struct{})
close(done) // 发送完成信号4.7 结构体比较
如果所有字段都可比较, 则结构体可比较:
type Point struct {
X, Y int
}
p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true包含切片、映射或函数字段的结构体不可比较.
5. 指针
5.1 指针基础
x := 10
p := &x // 取地址
fmt.Println(p) // 0xc000018090 (内存地址)
fmt.Println(*p) // 10 (解引用)
*p = 20 // 通过指针修改值
fmt.Println(x) // 205.2 指针与函数
// 值传递 (复制)
func double(x int) {
x = x * 2 // 不影响原值
}
// 指针传递
func doublePtr(x *int) {
*x = *x * 2 // 修改原值
}
n := 10
double(n)
fmt.Println(n) // 10 (未变)
doublePtr(&n)
fmt.Println(n) // 205.3 new 函数
new 分配内存并返回指针, 初始化为零值:
p := new(int) // *int, 指向 0
fmt.Println(*p) // 0
*p = 42
fmt.Println(*p) // 425.4 Go 没有指针运算
与 C 不同, Go 不支持指针算术 (p++, p + 1 等). 这提高了内存安全性.
6. 练习
6.1 切片去重
func unique(nums []int) []int {
seen := make(map[int]bool)
result := make([]int, 0, len(nums))
for _, n := range nums {
if !seen[n] {
seen[n] = true
result = append(result, n)
}
}
return result
}6.2 单词计数
func wordCount(s string) map[string]int {
words := strings.Fields(s)
count := make(map[string]int)
for _, w := range words {
count[w]++
}
return count
}6.3 结构体排序
type Person struct {
Name string
Age int
}
people := []Person{
{"Alice", 30},
{"Bob", 25},
{"Charlie", 35},
}
// 按年龄排序
sort.Slice(people, func(i, j int) bool {
return people[i].Age < people[j].Age
})6.4 矩阵转置
func transpose(matrix [][]int) [][]int {
if len(matrix) == 0 {
return nil
}
rows, cols := len(matrix), len(matrix[0])
result := make([][]int, cols)
for i := range result {
result[i] = make([]int, rows)
}
for i := 0; i < rows; i++ {
for j := 0; j < cols; j++ {
result[j][i] = matrix[i][j]
}
}
return result
}7. 思考题
- 为什么
append必须接收返回值? - 切片引用底层数组会导致什么内存问题? (提示: 大数组的小切片)
- Map 的遍历为什么是无序的?
- 结构体字段顺序为什么会影响内存大小?
new(T)和&T{}有什么区别?
8. 本周小结
- 数组: 固定长度, 值类型 (赋值会复制).
- 切片: 动态数组, 引用类型, 包含指针、长度、容量.
- 映射: 哈希表, 无序, 并发不安全.
- 结构体: 复合类型, 支持嵌入, 需注意内存对齐.
- 指针: 引用语义, 无指针运算.
复合类型是 Go 数据组织的核心. 理解切片的底层结构, 是避免性能陷阱和内存泄漏的关键.