16 KiB
tags, create time
| tags | 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 |
// 【题目】关于 map,下面说法正确的是?
// A. map 反序列化时 json.Unmarshal() 的入参必须为 map 的地址 ✅
// B. 在函数调用中传递 map,则子函数中对 map 元素的增加不会导致父函数中 map 的修改 ❌
// C. 不能使用内置函数 delete() 删除 map 的元素 ❌
// D. 以上都不对 ❌
[!error] 你的误区根源 你选了 B,认为「map 传进去后子函数的增删改不会影响外部」。这是把 map 当成了值类型。实际上 map 是 Go 中的引用类型——它的底层是一个指向哈希表的指针(runtime hmap),无论怎么传,大家都指向同一个数据源。
为什么 A 对:Unmarshal 必须传地址
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 的设计原理
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 是引用类型,子函数可以修改父函数
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] 这个对比帮你彻底分清引用类型和值类型
// --- 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 可以正常删除
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 结构
flowchart LR
A["slice header"] --> B["pointer → 底层数组首元素"]
A --> C["len → 当前长度"]
A --> D["cap → 容量"]
E["底层数组 [10 3 4 5 ...]"] -.->|"pointer 指向"| B
style A fill:#e1d5e
// 单选题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] 💡 思考 以下代码输出什么?为什么和上一题结果不同?
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 新手最容易混淆的概念之一
// 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 中没有
->运算符
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] 三条铁律
- 方法需要修改对象 → 必须用指针接收者
- 结构体很大 → 优先用指针接收者避免拷贝开销
- 同一类型的部分方法用了指针接收者 → 其余方法也应统一用指针
// 单选题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)
"如果一个东西走起来像鸭子、叫起来像鸭子,那它就是鸭子。"
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 处理
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:格式化输出
fmt.Print("hello") // 无换行
fmt.Println("hello") // 自动换行
fmt.Printf("%d %s", 42, "hi") // 格式化输出 ✅ 单选题11 答案
// fmt.Format() 不存在
单选题12:读取文件
// ✅ 现代写法(Go 1.16+)
data, err := os.ReadFile("config.json")
// ⚠️ ioutil 已在 Go 1.16 标记废弃
// data, err := ioutil.ReadFile("config.json") // 旧写法
单选题13:时间处理
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 = 同步通信
ch := make(chan int) // 无缓冲
go func() { ch <- 42 }() // 启动发送方
x := <-ch // 接收方等待 → 收发同时发生 ✅ 单选题15 答案
// 如果只有发送方在运行,<-ch 会永久阻塞(goroutine leak)
[!warning] ⭐ 核心概念 无缓冲 channel 的发送和接收必须同时就绪,缺一不可。这是一种 goroutine 之间的握手同步机制。
单选题16:Select 语句
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 同步
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
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:取地址运算符
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 |