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

323 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第三章:数据结构详解 —— 像搭积木一样组织数据
> **👋 回顾一下:**
>
> 前两章,我们学会了 Go 的基础语法:怎么声明变量、怎么判断、怎么循环。现在,你已经能写一些简单的程序了,比如计算器、猜数字游戏。
>
> **🤔 但是,现实世界的数据往往更复杂……**
>
> 想象一下,你要管理一个班级的学生信息:
> * 一个学生有:姓名、年龄、成绩……(这需要**结构体**
> * 一个班级有50 个学生……(这需要**数组/切片**
> * 你想快速通过学号查找学生……(这需要**映射**
>
> 如果只用简单的变量,代码会变得非常臃肿、难以维护。这时候,我们就需要**数据结构**来帮我们**组织、存储、查找**数据。
>
> **🎯 这一章,我们要认识 Go 的四大“数据容器”:**
> 1. **数组 (Array)**:固定长度的“盒子”。
> 2. **切片 (Slice)**:动态长度的“魔盒”(**重点Go 里最常用的**)。
> 3. **映射 (Map)**:键值对“字典”,快速查找。
> 4. **结构体 (Struct)**:自定义“包裹”,把相关数据打包。
>
> **别担心,我会用大量的生活类比和图解,让你轻松理解!**
---
## 3.1 数组 (Array) —— 固定长度的“盒子”
> **💡 想象一下:**
> 你有一个**固定大小**的鞋盒,只能放 5 双鞋。
> * 如果你只放了 3 双,剩下 2 个格子是空的(零值)。
> * 如果你放了 6 双,**放不下了!** 必须换个大盒子。
>
> 在 Go 中,**数组**就是这种**固定长度**的容器。
### 📝 数组的声明与初始化
```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)**:从指针开始,到底层数组末尾还能装多少个元素。
```mermaid
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**:你还能往里面塞几个元素,不用重新分配内存。
> * **指针**:切片只是底层数组的“视图”,修改切片会影响底层数组!
### 📝 切片的创建
```go
// 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 会**重新分配一个更大的数组**,把旧数据复制过去。
```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**不复制底层数组**。所以修改切片会影响原数据!
### ⚠️ 常见陷阱:共享底层数组
```go
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` 创建新切片**
> ```go
> sNew := append([]int(nil), s1...) // 复制一份
> ```
> **🎮 动手练一练:**
> 1. 创建一个长度为 0、容量为 10 的切片。
> 2. 循环 append 1 到 15观察 len 和 cap 的变化。
> 3. 尝试修改切片中的元素,观察对原数组的影响。
---
## 3.3 映射 (Map) —— 快速查找的“字典”
> **💡 想象一下:**
> 你想查“苹果”的价格,如果有一张表:
> * 水果 -> 价格
> * 苹果 -> 5 元
> * 香蕉 -> 3 元
>
> 你只需要知道“苹果”,就能立刻找到价格,不用一个个遍历。这就是**映射 (Map)**
### 📝 映射的创建与操作
```go
// 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 中,用**结构体**来打包。
### 📝 结构体的定义与初始化
```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
```
### 🔧 结构体方法
```go
// 给结构体添加方法
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 没有“继承”,但可以用**嵌入**实现类似效果。
```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 的核心!理解 `len`、`cap`、底层数组。
> 3. **映射**:键值对,快速查找,注意无序和并发安全。
> 4. **结构体**:自定义类型,组合数据,支持方法。
>
> **🚀 下一步预告:**
> 学会了数据结构,我们怎么让这些数据“动起来”?怎么复用代码?怎么实现多态?
>
> **下一章,我们要进入 Go 的“灵魂”:函数与接口!** 我们会学习闭包、defer、panic/recover以及接口如何实现“多态”。准备好迎接更强大的功能了吗