Files
cs-note/hzh/EXAM/Week02.md
T
2026-05-24 11:42:38 +08:00

16 KiB
Raw Blame History

tags, create time
tags create time
Go
后端开发
武科大
Week02
错题精讲
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] 三条铁律

  1. 方法需要修改对象 → 必须用指针接收者
  2. 结构体很大 → 优先用指针接收者避免拷贝开销
  3. 同一类型的部分方法用了指针接收者 → 其余方法也应统一用指针
// 单选题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

关联笔记