--- 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]]