Wiki LogoWiki - The Power of Many

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)  // true

1.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=10

2.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+ 的扩容策略:

  1. 如果目标容量 > 旧容量 * 2, 直接扩容到目标容量.
  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, 16

2.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

两者在 lenappend 行为上相同, 但 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. 等量扩容 (溢出桶过多, 整理碎片)

扩容是渐进式的, 每次写操作迁移 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)   // 26

4.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)  // China

4.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{}))  // 16

4.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)   // 20

5.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)   // 20

5.3 new 函数

new 分配内存并返回指针, 初始化为零值:

p := new(int)    // *int, 指向 0
fmt.Println(*p)  // 0

*p = 42
fmt.Println(*p)  // 42

5.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. 思考题

  1. 为什么 append 必须接收返回值?
  2. 切片引用底层数组会导致什么内存问题? (提示: 大数组的小切片)
  3. Map 的遍历为什么是无序的?
  4. 结构体字段顺序为什么会影响内存大小?
  5. new(T)&T{} 有什么区别?

8. 本周小结

  • 数组: 固定长度, 值类型 (赋值会复制).
  • 切片: 动态数组, 引用类型, 包含指针、长度、容量.
  • 映射: 哈希表, 无序, 并发不安全.
  • 结构体: 复合类型, 支持嵌入, 需注意内存对齐.
  • 指针: 引用语义, 无指针运算.

复合类型是 Go 数据组织的核心. 理解切片的底层结构, 是避免性能陷阱和内存泄漏的关键.

On this page