Files
test/chapters/chapter-3-revised.md

10 KiB
Raw Permalink Blame History

第三章:数据结构详解 —— 像搭积木一样组织数据

👋 回顾一下:

前两章,我们学会了 Go 的基础语法:怎么声明变量、怎么判断、怎么循环。现在,你已经能写一些简单的程序了,比如计算器、猜数字游戏。

🤔 但是,现实世界的数据往往更复杂……

想象一下,你要管理一个班级的学生信息:

  • 一个学生有:姓名、年龄、成绩……(这需要结构体
  • 一个班级有50 个学生……(这需要数组/切片
  • 你想快速通过学号查找学生……(这需要映射

如果只用简单的变量,代码会变得非常臃肿、难以维护。这时候,我们就需要数据结构来帮我们组织、存储、查找数据。

🎯 这一章,我们要认识 Go 的四大“数据容器”:

  1. 数组 (Array):固定长度的“盒子”。
  2. 切片 (Slice):动态长度的“魔盒”(重点Go 里最常用的)。
  3. 映射 (Map):键值对“字典”,快速查找。
  4. 结构体 (Struct):自定义“包裹”,把相关数据打包。

别担心,我会用大量的生活类比和图解,让你轻松理解!


3.1 数组 (Array) —— 固定长度的“盒子”

💡 想象一下: 你有一个固定大小的鞋盒,只能放 5 双鞋。

  • 如果你只放了 3 双,剩下 2 个格子是空的(零值)。
  • 如果你放了 6 双,放不下了! 必须换个大盒子。

在 Go 中,数组就是这种固定长度的容器。

📝 数组的声明与初始化

// 声明一个长度为 5 的整数数组,默认全是 0
var scores [5]int 

// 初始化时指定值
scores := [5]int{90, 85, 78, 92, 88}

// 让 Go 自动推断长度
nums := [...]int{1, 2, 3, 4, 5} // 长度是 5

⚠️ 老师的小提醒:

  • 数组长度是类型的一部分[5]int[10]int不同类型,不能互相赋值。
  • 数组是值类型:赋值或传参时,会完整复制整个数组(如果数组很大,效率低)。
  • 实际开发中,数组用得很少,因为长度固定太死板了。别急,下一节我们介绍更灵活的“魔盒”——切片!

🎮 动手练一练: 声明一个长度为 3 的字符串数组,存储你的三个好朋友的名字,并打印出来。


3.2 切片 (Slice) —— 动态长度的“魔盒” 核心重点

🤔 为什么需要切片? 数组的固定长度太不灵活了。如果今天来了新同学,数组装不下怎么办?如果退学了,数组空着又浪费。

切片就是为了解决这个问题而生的!它像魔盒一样,长度可以动态变化,而且底层还是基于数组的(高效)。

📖 切片的底层结构(图解)

切片不是数组,它是一个描述符,包含三个部分:

  1. 指针 (ptr):指向底层数组的某个位置。
  2. 长度 (len):当前切片有多少个元素。
  3. 容量 (cap):从指针开始,到底层数组末尾还能装多少个元素。
graph LR
    subgraph "切片描述符"
        S[Slice: len=3, cap=5]
        Ptr[指针]
        Len[长度3]
        Cap[容量5]
        S --- Ptr
        S --- Len
        S --- Cap
    end
    
    subgraph "底层数组"
        A[数组]
        E1[元素 0]
        E2[元素 1]
        E3[元素 2]
        E4[元素 3]
        E5[元素 4]
        A --- E1
        A --- E2
        A --- E3
        A --- E4
        A --- E5
        
        Ptr -.-> E1
    end
    
    style S fill:#ffeb3b,stroke:#333
    style A fill:#2196f3,stroke:#333,color:#fff

💡 老师的小揭秘:

  • len:你目前能看到几个元素。
  • cap:你还能往里面塞几个元素,不用重新分配内存。
  • 指针:切片只是底层数组的“视图”,修改切片会影响底层数组!

📝 切片的创建

// 1. 从数组创建
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[1:4]  // [2, 3, 4], len=3, cap=4 (从索引 1 到末尾)
s2 := arr[:3]   // [1, 2, 3], len=3, cap=5

// 2. 用 make 创建(最常用)
s3 := make([]int, 5)       // len=5, cap=5, 元素全为 0
s4 := make([]int, 3, 10)   // len=3, cap=10, 元素全为 0

// 3. 直接初始化
s5 := []int{10, 20, 30}    // len=3, cap=3

🔄 切片的扩容机制append

当你用 append 添加元素时,如果长度 < 容量,直接放进去;如果长度 == 容量Go 会重新分配一个更大的数组,把旧数据复制过去。

s := make([]int, 0, 5) // 初始容量 5

for i := 1; i <= 10; i++ {
    s = append(s, i)
    fmt.Printf("i=%d, len=%d, cap=%d\n", i, len(s), cap(s))
}

输出示例

i=1, len=1, cap=5
i=2, len=2, cap=5
...
i=5, len=5, cap=5
i=6, len=6, cap=10  <-- 容量翻倍了!
i=7, len=7, cap=10
...

💡 老师的小技巧:

  • 如果你知道大概要存多少个元素,提前指定容量make([]T, 0, expectedSize)),可以避免多次扩容,提升性能。
  • 切片是引用类型赋值、传参时只复制描述符指针、len、cap不复制底层数组。所以修改切片会影响原数据!

⚠️ 常见陷阱:共享底层数组

arr := []int{1, 2, 3, 4, 5}
s1 := arr[1:3]  // [2, 3]
s2 := arr[2:4]  // [3, 4]

s1[0] = 99
fmt.Println(arr) // [1, 99, 3, 4, 5]  <-- arr 也被修改了!
fmt.Println(s2)  // [99, 4]          <-- s2 也受影响!

💡 老师的小提醒: 切片之间如果共享底层数组,修改一个会影响其他!如果不需要共享,用 append 创建新切片

sNew := append([]int(nil), s1...) // 复制一份

🎮 动手练一练:

  1. 创建一个长度为 0、容量为 10 的切片。
  2. 循环 append 1 到 15观察 len 和 cap 的变化。
  3. 尝试修改切片中的元素,观察对原数组的影响。

3.3 映射 (Map) —— 快速查找的“字典”

💡 想象一下: 你想查“苹果”的价格,如果有一张表:

  • 水果 -> 价格
  • 苹果 -> 5 元
  • 香蕉 -> 3 元

你只需要知道“苹果”,就能立刻找到价格,不用一个个遍历。这就是映射 (Map)

📝 映射的创建与操作

// 1. 创建
prices := make(map[string]int)
prices["apple"] = 5
prices["banana"] = 3

// 2. 字面量初始化
scores := map[string]int{
    "Alice": 95,
    "Bob": 88,
}

// 3. 读取
price := prices["apple"]
fmt.Println(price) // 5

// 4. 检查键是否存在(重要!)
if val, ok := prices["orange"]; ok {
    fmt.Println("存在,价格:", val)
} else {
    fmt.Println("不存在")
}

// 5. 删除
delete(prices, "banana")

// 6. 遍历(无序!)
for fruit, price := range prices {
    fmt.Printf("%s: %d\n", fruit, price)
}

⚠️ 老师的小提醒:

  • 无序:每次遍历的顺序可能不同,这是 Go 故意设计的(防止依赖顺序)。
  • nil 映射不能写入var m map[string]int 是 nil写入会 panic。必须 make 或字面量初始化。
  • 并发不安全:多个 Goroutine 同时读写同一个 Map 会 panic。需要加锁或用 sync.Map

🎮 动手练一练:

  1. 创建一个 Map存储你喜欢的 3 种水果和价格。
  2. 尝试读取一个不存在的键,用 ok 模式判断。
  3. 遍历 Map打印所有水果。

3.4 结构体 (Struct) —— 自定义“包裹”

💡 想象一下: 你要描述一个“学生”,有姓名、年龄、成绩……这些属性属于同一个“实体”。在 Go 中,用结构体来打包。

📝 结构体的定义与初始化

// 定义
type Student struct {
    Name  string
    Age   int
    Score float64
}

// 初始化 1字段名初始化推荐
s1 := Student{
    Name:  "Alice",
    Age:   20,
    Score: 95.5,
}

// 初始化 2位置初始化不推荐易错
s2 := Student{"Bob", 22, 88.0}

// 初始化 3部分初始化
s3 := Student{Name: "Charlie"} // Age=0, Score=0

🔧 结构体方法

// 给结构体添加方法
func (s Student) Pass() bool {
    return s.Score >= 60
}

func (s *Student) AddScore(delta float64) {
    s.Score += delta // 指针接收者可以修改原结构体
}

💡 老师的小揭秘:

  • 值接收者func (s Student),方法内修改不影响原结构体。
  • 指针接收者func (s *Student),方法内修改会影响原结构体。
  • 推荐:如果方法需要修改结构体,用指针接收者;如果只读取,用值接收者(小结构体)或指针接收者(大结构体,避免复制)。

🧩 结构体嵌入(组合)

Go 没有“继承”,但可以用嵌入实现类似效果。

type Person struct {
    Name string
    Age  int
}

type Student struct {
    Person // 嵌入 Person
    Score  float64
}

s := Student{
    Person: Person{Name: "Alice", Age: 20},
    Score:  95.5,
}

fmt.Println(s.Name) // 直接访问嵌入字段

🎮 动手练一练:

  1. 定义一个 Book 结构体(书名、作者、价格)。
  2. 添加一个方法 IsExpensive(),判断价格是否大于 50。
  3. 创建几个 Book 实例,调用方法。

3.5 本章小结

🎯 我们学到了什么?

  1. 数组:固定长度,少用。
  2. 切片动态长度Go 的核心!理解 lencap、底层数组。
  3. 映射:键值对,快速查找,注意无序和并发安全。
  4. 结构体:自定义类型,组合数据,支持方法。

🚀 下一步预告: 学会了数据结构,我们怎么让这些数据“动起来”?怎么复用代码?怎么实现多态?

下一章,我们要进入 Go 的“灵魂”:函数与接口! 我们会学习闭包、defer、panic/recover以及接口如何实现“多态”。准备好迎接更强大的功能了吗