Files
cs-note/hzh/EXAM/Week02.md
T

487 lines
16 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [Go, 后端开发, 武科大, Week02, 错题精讲]
create time: 2026-05-02 14:30
---
# 金山考试 - 武科大服务端 - 第二次课 · 错题精讲
## 概述
本次测试涵盖 **切片与数组、map、结构体与方法、接口、JSON、标准库(fmt/os/time)、内存分配 new vs make、并发(channel/sync)** 八大模块,共 37 道题(21 单选 + 3 多选)。其中 **36 道答对,1 道错题**,集中在 `map` 的引用语义与 `json.Unmarshal` 参数传递规则上。本笔记以错题为切入点,逐层展开相关知识。
> [!tip] 使用建议
> 先看错题本身的「为什么错 → 为什么对」,再顺着带出的知识点体系理解关联概念,最后用「高频考点速览」串联记忆薄弱点。
---
## 一、map 的引用语义与 json.Unmarshal 必须传地址
### 单6:关于 map 的正确说法
| 项目 | 内容 |
|------|------|
| ❌ 你的答案 | B |
| ✅ 正确答案 | **A** |
```go
// 【题目】关于 map,下面说法正确的是?
// A. map 反序列化时 json.Unmarshal() 的入参必须为 map 的地址 ✅
// B. 在函数调用中传递 map,则子函数中对 map 元素的增加不会导致父函数中 map 的修改 ❌
// C. 不能使用内置函数 delete() 删除 map 的元素 ❌
// D. 以上都不对 ❌
```
> [!error] 你的误区根源
> 你选了 **B**,认为「map 传进去后子函数的增删改不会影响外部」。这是把 map 当成了**值类型**。实际上 map 是 Go 中的**引用类型**——它的底层是一个指向哈希表的指针(runtime hmap),无论怎么传,大家都指向同一个数据源。
### 为什么 A 对:Unmarshal 必须传地址
```go
m := make(map[string]int)
// ❌ 错误:传值,内部修改只在局部生效
// json.Unmarshal([]byte(`{"a":1}`), m) // panic: assignment to entry in nil map
// ✅ 正确:传地址 &m,Unmarshal 才能往里写
json.Unmarshal([]byte(`{"a":1,"b":2}`), &m)
fmt.Println(m) // map[a:1 b:2]
```
> [!abstract] Unmarshal 的设计原理
```mermaid
flowchart TD
A["json.Unmarshal data"] --> B{入参是 *any}
B -->|"传的是 &m"| C["*map 解引用 → 拿到原始 map 指针"]
C --> D["向原始 map 写入 key-value ✅"]
B -->|"传的是 m(值)"| E["m 本身是指针,传给 any 后丢失"]
E --> F["无法获取原始引用,写入失败或无效果 ❌"]
style C fill:#9f9
style F fill:#f99
```
核心逻辑:`json.Unmarshal` 的签名是 `func Unmarshal(data []byte, v any)`,第二个参数接收 `any` 类型。当传入 `&m` 时,内部通过反射检查到这是一个指向 map 的指针,于是能直接向原 map 写入数据。如果传 `m`,由于 map 本身就是引用类型但不满足 `Settable` 条件(reflect 层面 value 不可取址),写入会静默失败或直接 panic。
### 为什么 B 错:map 是引用类型,子函数可以修改父函数
```go
func addElement(m map[string]int) {
m["newKey"] = 999 // ✅ 直接往原 map 里添加
}
func main() {
m := make(map[string]int)
m["a"] = 1
addElement(m)
fmt.Println(m) // map[a:1 newKey:999] ← 父函数可见!
}
```
> [!memory] map 不是"副本"
> map 变量本质上就是一个指针(准确说是 runtime hmap 的引用)。赋值、传参都是共享同一个底层数据结构。
### 对比验证:切片 vs array 的行为差异
> [!note] 这个对比帮你彻底分清引用类型和值类型
>
> ```go
> // --- slice:引用类型,子函数内增删改对外可见 ---
> func modifySlice(s []int) {
> s[0] = 99 // 修改元素 → 外可见 ✅
> s = append(s, 100) // append 可能换底层数组 → 外不可见(取决于是否扩容)
> }
>
> // --- array:值类型,子函数拿到完整副本 ---
> func modifyArray(a [3]int) {
> a[0] = 99 // 只改了副本 → 外不可见 ❌
> }
>
> func main() {
> arr := [3]int{1, 2, 3}
> modifyArray(arr)
> fmt.Println(arr) // [1 2 3] ← 没变
>
> sl := []int{1, 2, 3}
> modifySlice(sl)
> fmt.Println(sl) // [99 2 3] ← 变了
> }
> ```
### C 选项为什么错:delete 可以正常删除
```go
m := map[string]int{"a": 1, "b": 2, "c": 3}
delete(m, "b") // ✅ 成功删除
fmt.Println(m) // map[a:1 c:3]
delete(m, "z") // ✅ key 不存在也没事,不 panic
```
---
## 二、由 map 带出:切片的引用语义
### 单4 & 多选3:切片与数组的核心区别
既然上面提到了切片和 map 一样是引用类型,这里一并梳理清楚。
> [!summary] slice header 结构
```mermaid
flowchart LR
A["slice header"] --> B["pointer → 底层数组首元素"]
A --> C["len → 当前长度"]
A --> D["cap → 容量"]
E["底层数组 [10 3 4 5 ...]"] -.->|"pointer 指向"| B
style A fill:#e1d5e
```
```go
// 单选题4:切片操作改变底层数组
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // slice = [2, 3, 4], 底层共享 arr[1..4]
slice[0] = 10 // slice[0] → arr[1]
fmt.Println(arr) // [1 10 3 4 5] ✅ 选 B
```
> [!question] 💡 思考
> 以下代码输出什么?为什么和上一题结果不同?
```go
src := []int{1, 2, 3} // cap = 3
dst := append(src, 4) // cap 不够,翻倍分配到新底层数组
src[0] = 99 // 只改 src 的底层
fmt.Println(dst[0]) // ? → 1(因为 dst 已脱离共享)
```
### 多选题3:数组和切片的四组说法全辨析
> [!error] 这道题你全选了 ABCD —— 恭喜答对了,巩固一下记忆:
| 说法 | 对错 | 解析 |
|------|------|------|
| A. 数组定义时必须指定长度,且长度是类型的一部分 | ✅ | `[5]int` 和 `[6]int` 是**不同类型** |
| B. 切片定义不需要指定长度,长度运行时动态变化 | ✅ | `[]int` 没有固定大小 |
| C. 数组作为函数参数传的是副本 | ✅ | 整个数组会被拷贝,代价大 |
| D. 切片传参传的是 header 副本,但对内容的修改反映到原始切片 | ✅ | 拷贝的是 pointer/len/cap,不改这三个就行 |
---
## 三、声明方式:new vs make
### 单选题1 & 单选题14
> [!abstract] new 和 make 是 Go 新手最容易混淆的概念之一
```go
// make — 仅用于 slice / map / channel,返回类型本身
s := make([]int, 0, 10) // 创建一个 len=0, cap=10 的整型切片 ✅ 单选题2 答案
m := make(map[string]int) // 初始化一个可用的 map
ch := make(chan int, 5) // 创建缓冲大小为 5 的 channel
// new — 适用于任何类型,返回 *T(指向零值的指针)
p := new(int) // *int,p 指向 0
per := new(Person) // *Person,per 指向 Person{} 零值实例 ✅ 单选题14 答案
```
> [!warning] ⭐ 单选题1的经典陷阱
> `new([]int)` **不是**一个切片,而是一个 `*[]int`(指向 nil 切片的指针)!所以单选题1 选 D 是错误的声明方式。
| 维度 | make(T, args) | new(T) |
|------|---------------|--------|
| 返回类型 | T 本身 | *T |
| 适用类型 | 仅限 slice/map/channel | 任意类型 |
| 初始化 | 完成内部数据结构分配 | 仅分配零值内存 |
---
## 四、结构体与方法
### 单选题7:字段访问语法
> [!note] Go 中没有 `->` 运算符
```go
type Person struct {
Name string
Age int
}
p := Person{Name: "Alice", Age: 20}
p.Name // ✅ 值访问
pp := &p
pp.Name // ✅ 指针也直接用 .(编译器自动解引用)
(*pp).Name // ✅ 手动解引用,等价于上一行
// pp->Name // ❌ Go 不支持这种语法
```
### 单选题8 & 单选题20 & 单选题21:值接收者 vs 指针接收者
> [!memory] 三条铁律
>
> 1. **方法需要修改对象 → 必须用指针接收者**
> 2. **结构体很大 → 优先用指针接收者避免拷贝开销**
> 3. **同一类型的部分方法用了指针接收者 → 其余方法也应统一用指针**
```go
// 单选题21 原代码解析
type Person struct {
Name string
Age int
}
// 值接收者 — p 是副本,修改不影响原对象
func (p Person) ChangeName(newName string) {
p.Name = newName // 只改了副本
}
func main() {
p := Person{Name: "Alice"}
p.ChangeName("Bob")
fmt.Println(p.Name) // Alice ❌ 没有被修改(选 B)
}
// 如果用指针接收者,就会修改原对象
func (p *Person) SetName(newName string) {
p.Name = newName // 修改原对象
}
func main() {
p := Person{Name: "Alice"}
p.SetName("Bob") // 编译器自动转 (&p).SetName("Bob")
fmt.Println(p.Name) // Bob ✅
}
```
> [!question] 💡 单选题8 为什么选 C 是错的?
> C 说「值接收者在传递参数时效率更高」——这不一定!对于小结构体差别不大,但对于大结构体,值拷贝反而更慢。**值接收者的真正用途是保证方法不修改对象**,而非性能优化。因此 C 是错误的说法。
---
## 五、接口实现
### 单选题9:如何实现一个接口
> [!abstract] 鸭子类型(Duck Typing)
>
> "如果一个东西走起来像鸭子、叫起来像鸭子,那它就是鸭子。"
```go
type Writer interface {
Write(data []byte) (n int, err error)
}
// 只需实现接口中的所有方法,无需显式声明 implements
type MyWriter struct { buf []byte }
func (w MyWriter) Write(data []byte) (n int, err error) {
w.buf = append(w.buf, data...)
return len(data), nil
}
var _ Writer = MyWriter{} // 编译期校验:MyWriter 确实实现了 Writer
```
> [!memory] 一句话口诀
> **"只需要声明并实现接口中的所有方法 —— 编译器自动完成适配。"**
>
> B 错在「部分实现」,A 不完整(还需要「实现」),D 错在接口不能被继承(Go 没有继承概念)。
---
## 六、标准库用法
### 单选题10:JSON 处理
```go
import "encoding/json"
data := map[string]int{"a": 1, "b": 2}
bytes, err := json.Marshal(data) // 序列化
var result map[string]int
err = json.Unmarshal(bytes, &result) // 反序列化(注意 &result)
```
> [!tip] 优先使用 `encoding/json` 标准库即可,除非有特殊性能需求才考虑 jsoniter 等第三方包。单选题10 选 A。
### 单选题11:格式化输出
```go
fmt.Print("hello") // 无换行
fmt.Println("hello") // 自动换行
fmt.Printf("%d %s", 42, "hi") // 格式化输出 ✅ 单选题11 答案
// fmt.Format() 不存在
```
### 单选题12:读取文件
```go
// ✅ 现代写法(Go 1.16+)
data, err := os.ReadFile("config.json")
// ⚠️ ioutil 已在 Go 1.16 标记废弃
// data, err := ioutil.ReadFile("config.json") // 旧写法
```
### 单选题13:时间处理
```go
import "time"
now := time.Now() // 当前时间
formatted := now.Format("2006-01-02 15:04:05") // 格式化
```
> [!note] Go 的时间格式固定使用 reference time:`2006-01-02 15:04:05`(即 2006-1-2 15:04:05 MST)
---
## 七、并发编程
### 单选题15:无缓冲 channel 的特点
> [!abstract] 无缓冲 channel = 同步通信
```go
ch := make(chan int) // 无缓冲
go func() { ch <- 42 }() // 启动发送方
x := <-ch // 接收方等待 → 收发同时发生 ✅ 单选题15 答案
// 如果只有发送方在运行,<-ch 会永久阻塞(goroutine leak)
```
> [!warning] ⭐ 核心概念
> 无缓冲 channel 的发送和接收**必须同时就绪**,缺一不可。这是一种 goroutine 之间的握手同步机制。
### 单选题16:Select 语句
```go
select {
case msg := <-ch1:
fmt.Println(msg)
case ch2 <- data:
fmt.Println("sent")
default:
fmt.Println("none ready") // 都不阻塞时的兜底
}
```
> [!memory] select = switch for channels
> 监听多个 channel 操作,哪个准备好了执行哪个;都不就绪且有 default 则立即走 default。单选题16 选 C(监听多个 Channel 操作)。
### 单选题17:sync.WaitGroup 确保 goroutine 同步
```go
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done() // 退出时计数器 -1
doWork(id)
}(i)
}
wg.Wait() // 阻塞直到所有 goroutine 完成 ✅ 单选题17 答案
```
> [!note] Mutex 更适合保护临界区,Atomic 适合简单计数,Context 适合取消信号传递。WaitGroup 是唯一专门用于「等一组 goroutine 完成」的原语。
### 单选题19:RWMutex vs Mutex
```mermaid
flowchart LR
subgraph Mutex["sync.Mutex"]
M1["同一时刻只能有\n一个 goroutine\n进入临界区"]
end
subgraph RWMutex["sync.RWMutex"]
R1["多个读锁\n可同时持有"]
R2["或一个写锁\n独占访问"]
end
Mutex -->|"读少写多时更高效"| W1
RWMutex -->|"读多写少时优势明显"| W2
style R1 fill:#9f9
```
> [!memory] 一句话区分
> **Mutex 一人进出,RWMutex 多人同读一人独写。**
---
## 八、基础语法补遗
### 单选题18:取地址运算符
```go
x := 42
px := &x // & 获取地址 → *int
fmt.Println(*px) // * 解引用 → 42
// 结构体同理
p := Person{Name: "Alice"}
pp := &p
pp.Name // Go 自动解引用(等价于 (*p).Name)
```
> [!memory] Go 中关于地址的两个符号
>
> | 符号 | 名称 | 作用 |
> |------|------|------|
> | `&` | 取地址符 | `px := &x` |
> | `*` | 解引用 / 指针类型前缀 | `val := *px`, `var px *int` |
### 多选题1:通过指针访问成员变量的方式
> [!abstract] 多选题1 全辨析
| 选项 | 代码 | 对错 | 解析 |
|------|------|------|------|
| A | `p.name` | ✅ | Go 的语法糖,自动解引用 |
| B | `(&p).name` | ❌ | `p` 已经是变量名,再加 `&` 变成指向指针的指针 |
| C | `(*p).name` | ✅ | 先解引用拿到结构体,再访问字段 |
| D | `p->name` | ❌ | Go 没有 `->` 运算符 |
> [!note] 注意区分:如果是 `p` 已经是 `*Person` 指针类型,则 `p.name` 和 `(*p).name` 都合法。`&p` 会得到 `**Person`,多了一层无意义间接。
---
## 错题自测表
> [!summary] 遮住右侧,自己检验是否真正理解了
| 题目 | 考查点 | ❌ 你的原答案 | ✅ 正确答案 | 关键概念 |
|------|--------|-------------|------------|---------|
| 单6 | map 的引用语义 + json.Unmarshal 参数 | B | **A** | Unmarshal 需传地址;map 是引用类型 |
---
## 高频考点速览
> [!tip] 考前快速过一遍
| 类别 | 考点 | 一句话口诀 |
|------|------|-----------|
| make vs new | make 初始化引用类型,new 分配零值内存 | **"make 建结构,new 清空白"** |
| slice 共享底层 | 修改切片元素 = 修改原数组对应位置 | **"切片是视图,底层是一家的"** |
| map 引用语义 | 传 map 不拷贝数据,内外同步可见 | **"map 传指针,改哪都看见"** |
| json.Unmarshal 参数 | 必须传 `&mapVar` 才能写入 | **"没地址写不进"** |
| 结构体字段访问 | Go 统一用 `.` | **Go 里没有 `->`** |
| 值 vs 指针接收者 | 要修改对象必须用指针 | **要改就用指针** |
| 接口隐式实现 | 实现全部方法即满足 | **鸭子类型,不用 declare** |
| 无缓冲 channel | 收发必须同时就绪 | **"等你,你等我"** |
| select | 多路复用 channel 操作 | **"谁先好谁先上"** |
| WaitGroup | 等待一组 goroutine 完成 | **加 1 → 起任务 → Done → Wait** |
| RWMutex | 读共享、写独占 | **多人同读,一人独写** |
| 取地址运算符 | `&` 取地址,`*` 解引用 | **"& 取 *" |
| ioutil 废弃 | 被 os/io 包取代 | **Go 1.16+ 别再用 ioutil** |
---
## 关联笔记
- [[hzh/EXAM]]