第01章 · Go 基础语法(正式版)

2022-12-02T14:11:02+08:00 | 80分钟阅读 | 更新于 2022-12-02T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 01 章。它假设你只学过一点 Go 语法——变量、结构体、函数大概知道怎么写,但没做过完整项目。

这一版做了三件事:

  1. 每一步都能跑。所有代码都标了文件路径、给了运行命令、写了预期输出。你照着敲,看到的应该和我写的一模一样。
  2. 术语不跳过。凡是出现你没见过的词(比如"逃逸分析"“鸭子类型"“开放编码”),当场就解释,不会让你自己去搜。
  3. 结尾有面试问答。每章最后 15 道题,按"基础 → 进阶 → 追问"三档排列,每题告诉你一句话结论、展开怎么答、面试官想听什么词、新手最容易答错在哪。

如果你想要"高密度速查版”,可以看原版第 01 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

我们要从零搭建一个"内容社区"项目——它像微博,也像小红书,会从单体服务一路演进到微服务。但任何一栋高楼都离不开地基,本章就是全书的地基。

为什么把语法单独放在第一章,而不是随用随讲?因为一旦进入工程节奏,我们就没机会停下来解释"为什么这里用指针接收器"“为什么这个切片改了那个也变"这类问题了——它们会以 bug 或面试追问的形式回来找你。

这一章读起来最"碎”,因为语法点天然零散。个别小节如果觉得吃力,不必强求一次消化,先掌握主干,等后面章节实际用到时再回来对照。但第 5 节(切片)和第 6 节(map)建议一次啃透——Go 工程里绝大多数隐蔽 bug 都出在这两个类型上。

开读前先看一眼全局:下面这张图是本章的知识地图——现在看不懂节点名字完全正常,它只是让你在钻进语法细节之前,先知道"这些碎片最终要拼成什么"。往后每学完一节,都可以回到这张图上定位自己走到哪一步。

flowchart LR
    A["语法地基
变量 / 基础类型"] --> B["复合类型
数组 · 切片 · map"] B --> C["函数与方法
值 / 指针接收器"] C --> D["错误处理与 defer"] D --> E["接口与泛型"] E --> F["工程化落地
demo 项目跑通"] A -.-> G["并发基本盘
goroutine / channel / context
本章补充块"] G -.-> H["第 02 章
Gin + GORM 实战"]

这张图怎么读:实线主链 A→B→C→D→E→F 是本章的推进顺序——从"语法地基"(变量/基础类型)出发,依次经过"复合类型"(数组/切片/map)、“函数与方法”、“错误处理与 defer”,汇入"接口与泛型",最后由 F"工程化落地"收口为能跑通的 demo 项目。A→G 的虚线表示"并发基本盘"(goroutine/channel/context)只是本章的补充块、不在必学主链上,G 再用虚线指向 H"第 02 章",说明并发内容会顺势衔接到下一章的 Gin+GORM 实战。读图时先跟实线走主干,再看虚线上的延伸与衔接即可。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 从零初始化一个 Go 模块(go mod init),写出能 go run 和 go build 的最小程序,并说清两者的区别。
  2. 用 []rune 正确截断含中文的字符串,解释为什么 len(s) 和 s[:5] 会出乱码。
  3. 画出切片的"头部 + 底层数组"内存模型,判断任意两句话之后两个切片是否还共享内存。
  4. 独立写出 make 初始化的 map,处理"key 不存在"的两返回值写法,并说出 nil map 能做什么、不能做什么。
  5. 用指针接收器给结构体写方法,用 interface 做抽象,用泛型写一个 Filter[T] 工具函数。

理解(能讲清原理和取舍,不要求背代码)

  1. defer 的"参数传值定义时定,闭包引用执行时定",以及它为什么只能改有名返回值。
  2. 切片扩容系数为什么前期 2 倍、后期 1.25 倍,分界点为什么从 1024 改成 256。
  3. map 遍历顺序为什么是随机的,并发读写为什么会故意 panic。
  4. 组合为什么不是继承(没有多态),以及 Java 转 Go 的同学最容易踩的那个坑。
  5. type MyInt int 和 type MyInt = int 只差一个 =,语义差在哪、各自用在哪。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. defer 的三种实现机制(堆上分配 / 栈上分配 / 开放编码)及其启用条件。
  2. 泛型约束的两种形态:类型集合(~int | ~string)和方法集合(普通接口)。
  3. 内存逃逸的概念,以及它和 defer 优化程度的关系。

前置依赖与补课清单

本章是全书第一章,不要求任何前置章节。但你得先具备这些(本章会就地补齐,不用去别处查):

需要你已掌握本章会不会补补在哪一节
装好 Go 工具链,go version 能跑通✅ 会补,含验证命令3.1 节
任意一门语言的变量 / 函数 / 循环概念❌ 不补,这是真前提—
go mod 模块管理✅ 会补3.1 节 补课胶囊
命令行基础(cd / ls)❌ 不补—

不需要你提前懂的:并发、Web、数据库、Docker。本章只打语法地基。

预计时长与学习成果

  • 预计时长:3.5 小时(精读 + 跟着敲全部代码);如果只求主干,1.5 小时可过一遍。

  • 学完你能跑出来的东西(这是本章的验收标准,做到才算过关):

    一个叫 webook-syntax 的命令行小工具:能用结构体表示用户、用接口抽象存储层、用切片和 map 做内存存储、用泛型写通用过滤函数、用 defer 保证资源释放,并带一组能跑通的单元测试。跑 go test ./... 全绿。

flowchart LR
    A["3.1 环境与第一个程序"] --> B["3.2 类型与字符串"]
    B --> C["3.3 方法与 defer"]
    C --> D["3.4 控制结构"]
    D --> E["3.5 切片底层"]
    E --> F["3.6 map"]
    F --> G["3.7 接口与组合"]
    G --> H["3.8 泛型"]
    H --> I["第四节 跟着写
webook-syntax 小工具"] I --> J["第七节 面试问答"]

这张图怎么读:这是一条单向串行路线——从 3.1"环境与第一个程序"起步,按 3.2 类型与字符串、3.3 方法与 defer、3.4 控制结构、3.5 切片底层、3.6 map、3.7 接口与组合、3.8 泛型的顺序逐节推进,前一节的产物是后一节的输入(切片/map 的语义会直接被接口与泛型两节复用)。主干走到 H 之后进入 I"第四节 跟着写 webook-syntax 小工具",把所有语法点落成一个可运行的项目,最后由 J"第七节 面试问答"收口。全程没有分支,卡在哪一节就退回那一节重读。

Go 基础语法语法与类型复合类型抽象与机制强类型 / string 中文陷阱变量 / 常量 / iota控制结构 / 递归切片:头部 + 底层数组扩容系数 2x / 1.25xmap:随机序 / 非并发安全接口:鸭子类型组合优于继承(无多态)泛型 + defer 三机制
复习提示:怎么读这一章:每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。如果你只想快速过一遍,可以只读每节的"抛问题"和"小收口";如果要真学会,就把每个代码块都敲一遍。

三、正文

3.1 把第一个 Go 程序跑起来

先抛问题

你装好了 Go,打开编辑器,写下人生第一个 Go 程序。然后呢?

  • 为什么每个文件开头都要写 package xxx,不写行不行?
  • 为什么有时候 go run main.go 能跑,有时候却报错说"不是 main 包"?
  • 为什么项目里都有一个 go.mod 文件,它到底是干嘛的?

不解决这三个问题,你后面每一次"照着教程敲代码却跑不起来",都可能是栽在这里。

给个直觉:开一家便利店

写一个 Go 程序,就像开一家便利店:

  • package main = 挂店招。得声明"这是正经门店",不是仓库、不是办公室。
  • func main() = 开门营业的开关。工商局(Go 工具链)只认这个开关才让你对外营业。
  • go.mod = 营业执照。上面写着这家店叫什么名字(模块名)、进货渠道是哪些(依赖)。
  • go run = 试营业,当天现做现卖,不留存货。
  • go build = 把整个店打包进集装箱,之后运到任何地方都能直接开张。

补课胶囊:go mod 和模块

一句话:go.mod 是 Go 项目的"身份证",记录模块名和依赖版本。没有它,Go 不知道你在哪个项目里、该去哪儿找包。

类比:就像 npm 的 package.json、Java 的 pom.xml。

最小示例:

# 新建一个目录当作我们的第一个项目
mkdir -p ~/code/webook-syntax && cd ~/code/webook-syntax

# 初始化模块:模块名通常写成"代码仓库地址/项目名"
go mod init github.com/yourname/webook-syntax

# 看看生成了什么
cat go.mod

预期输出(Go 版本可能不同):

module github.com/yourname/webook-syntax

go 1.22

回到主线:有了 go.mod,这个目录才算一个"模块",之后才能在里面 go run、go build、引入第三方库。

最小可运行示例

// 文件路径: ~/code/webook-syntax/main.go
// 每一个 Go 文件都必须以 package 声明开头
package main

import "fmt"

// main 函数无参数、无返回值,这是 Go 程序的唯一入口
func main() {
	fmt.Println("Hello, Go!")
}

跑起来:

go run main.go

预期输出:

Hello, Go!

如果你看到了这行字,恭喜,第一个 Go 程序跑通了。

逐行讲解

行代码为什么这么写
1-3注释写清文件路径是好习惯,后面章节都会标
4package main声明包名。main 是特殊的:只有 main 包才能编译出可执行程序
6import "fmt"导入标准库的格式化输出包。fmt = format
9func main()程序入口。注意:无参数、无返回值,多一个少一个都报错
10fmt.Println(...)调用 fmt 包的 Println 函数,输出并换行

常见坑:三个必踩的报错

坑 1 · 把 package main 写成了别的名字

package hello // ← 故意写错

import "fmt"

func main() {
	fmt.Println("Hello, Go!")
}

运行 go run main.go,你会看到:

package command-line-arguments is not a main package
复习提示:这就是"店招挂错"的后果:Go 工具链只在 main 包里找 main 函数当入口。你不挂 main 这块招牌,它就不认为你要开门店。现在把它改回 package main,我们再往下走。

坑 2 · 花括号换行写

func main()
{                       // ← 编译报错!
	fmt.Println("hi")
}

报错信息:syntax error: unexpected semicolon or newline before {

原因是 Go 会在换行处自动补分号。你在 func main() 后面一换行,编译器就补了个分号,函数签名到此结束,下面的 { 就成了孤魂野鬼。

复习提示:这是 Go 最反直觉的一条规则:几乎所有其它语言里,花括号换不换行是风格问题;在 Go 里,它是编译能不能过的问题。解决办法只有一个——把 { 写在行尾,养成肌肉记忆。

坑 3 · 用了未使用的变量或导入

import (
	"fmt"
	"os" // ← 导入了但没用
)

func main() {
	x := 1 // ← 声明了但没用
	fmt.Println("hi")
}

报错:"os" imported and not used 和 declared and not used: x

复习提示:别的语言对未使用的变量顶多给个警告,Go 直接拒绝编译。这是 Go 的"代码清洁强迫症":既然没用到,为什么写?这条规则在长期维护的大型项目里非常值钱——它让"删掉一段逻辑后遗留的死变量"根本活不过编译期。

package 的三条硬规则

  1. 一个目录下的所有 .go 文件(不含 _test.go)package 名必须一致。 同一目录出现 package main 和 package util,直接编译报错。
  2. 测试文件可以是 <pkg>_test。 这是 Go 的特殊约定,允许你在"外部包视角"做黑盒测试。第 4 章讲测试时会用到。
  3. package 名可以和目录名不同,但强烈建议保持一致。 否则 import 之后别人根本猜不到该用哪个标识符。

go run vs go build:什么时候用哪个

# 方式一:直接运行(适合开发期)
go run main.go        # 单文件
go run .              # 整个目录

# 方式二:先编译成二进制,再运行(部署用)
go build -o webook .  # 生成可执行文件 webook
./webook              # 运行它
复习提示:

工程视角:go run 本质是"编译到临时目录 + 立即运行",每次都重新编译,慢且不留产物。go build 产出一个独立二进制文件,它把 Go 运行时(goroutine 调度器、GC)一起打包进去,丢到任何同架构的机器上就能跑——那台机器装没装 Go 都无所谓。

所以生产部署、Docker 镜像里跑的,永远是 go build 出来的静态二进制。这也是 Go 部署比 Java/Python 简单太多的根本原因。

还有一个容易忽略的细节:如果 main.go 引用了同目录的其它文件(比如 user.go),必须写成 go run . 或 go run main.go user.go。只写 go run main.go,Go 会认为这个项目只有这一个文件,然后报"找不到 user.go 里定义的符号"。

小收口

这一节我们建立了 Go 工程的最小心智模型:

一个 Go 进程 = 一个 main 包 + 一个 main 函数。目录里有 go.mod 才算一个模块。go run 开发用,go build 部署用。

后面所有章节的代码,都运行在这个模型之上。


3.2 类型与字符串:为什么 int 和 int64 不能相加

先抛问题

你写下这段代码,满怀信心地 go run:

var userId int64 = 100
var offset int = 10
fmt.Println(userId + offset) // 你以为会输出 110

结果编译器无情地告诉你:

invalid operation: userId + offset (mismatched types int64 and int)

为什么?两个都是整数,凭什么不能相加?

给个直觉:人民币和美元

int64 和 int 就像人民币和美元——都是钱,但你不能在收银台直接把"100 人民币 + 10 美元"加起来得到 “110”。你必须先决定:是把美元换成人民币,还是反过来。

Go 的强类型就是这个意思:没有隐式类型转换。你必须显式写出 int64(offset),把转换意图明明白白地写在代码里。

复习提示:

为什么 Go 要这么别扭? 因为隐式转换是 bug 温床。想象一下:用户 ID 是 int64、分页偏移是 int,两者相加若允许隐式转换,在 32 位平台上溢出时就会静默出错——你不会收到任何警告,只会得到一个莫名其妙的负数 ID。Go 强制你写 int64(offset),就把"这里可能溢出"这个决策点暴露在了代码里。

初学觉得啰嗦,用久了会发现它替你挡住了大量隐蔽 bug。

类型家族总览

Go 的基本类型可以按"家族"归类记忆。初学时不必逐个背,先建立一张"选类型时有哪些选项"的地图:

类型族成员工程建议
int 家族int8 / int16 / int32 / int64 / int一般业务默认用 int;要落库、跨服务传输的字段用 int64
uint 家族uint8 / uint16 / uint32 / uint64 / uint位运算、文件偏移量用
float 家族float32 / float64默认用 float64
byteuint8 的别名表示原始字节
runeint32 的别名表示一个 Unicode 字符(下面马上用到)
booltrue / false
string不可变的字节序列,默认 UTF-8 编码

关于 int 的宽度:int 的字节数取决于 CPU——多数现代机器是 8 字节(等价于 int64),但 32 位平台上是 4 字节。这就是为什么写要落库或序列化的结构体时,别用 int 图省事,老老实实写 int64,否则换个平台字段长度就变了。

关于极值常量:数字的极值在 math 包里,比如 math.MaxInt64、math.MaxFloat64。更复杂的数学运算(三角函数、开方、取整)也都在 math 包里——不用死记,需要时去包里找,你只需要记住"复杂运算找 math 包"这一句。

复习提示:一个小陷阱:浮点数只有"最大值"和"最小正数"(math.SmallestNonzeroFloat64),没有"最小值"——因为浮点数可以无限接近 0,还引入了正负无穷(math.Inf)。另外低版本 Go SDK 里部分极值常量可能缺失,遇到编译报错先查一下自己用的版本。

最小可运行示例:强类型与显式转换

// 文件路径: ~/code/webook-syntax/type_demo/main.go
package main

import (
	"fmt"
	"math"
	"strconv"
)

func main() {
	var userId int64 = 100
	var offset int = 10

	// 错误写法:sum := userId + offset  // 编译报错:mismatched types

	// 正确写法:显式转换
	sum := userId + int64(offset)
	fmt.Println("sum =", sum)

	// 看看 int 在你的机器上有多宽(返回 bit 数)
	fmt.Println("int 宽度(bit):", strconv.IntSize) // 64 位机器上是 64

	fmt.Println("MaxInt64 =", int64(math.MaxInt64))
}

预期输出:

sum = 110
int 宽度(bit): 64
MaxInt64 = 9223372036854775807

逐行讲解

  • var userId int64 = 100:var 声明变量,显式指定类型。
  • sum := userId + int64(offset):int64(offset) 是强制类型转换语法,格式为 目标类型(值)。
  • strconv.IntSize:直接返回当前平台 int 的位宽(32 或 64),是判断平台位宽的标准做法。

string:Go 最坑的一个类型

string 在 Go 里的本质是一串字节,而不是一串字符。全是英文时毫无存在感,一旦出现中文就变成 bug。

最小可运行示例:中文长度陷阱

// 文件路径: ~/code/webook-syntax/string_demo/main.go
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "你好,Go"

	// len 返回的是字节长度,不是字符个数
	fmt.Println("len(s) =", len(s))

	// 字符个数要用 utf8.RuneCountInString
	fmt.Println("字符数 =", utf8.RuneCountInString(s))

	// 遍历字符(rune),而不是字节
	fmt.Println("--- 逐字符遍历 ---")
	for i, r := range s {
		fmt.Printf("字节下标 %d: %c\n", i, r)
	}
}

预期输出:

len(s) = 11
字符数 = 5
--- 逐字符遍历 ---
字节下标 0: 你
字节下标 3: 好
字节下标 6: ,
字节下标 9: G
字节下标 10: o

逐行讲解:11 和 5 是怎么来的

字符UTF-8 字节数起始下标
你30
好33
,36
G19
o110

字节总数 = 3 + 3 + 3 + 1 + 1 = 11,这就是 len(s) 的值。 字符总数 = 你、好、,、G、o = 5,这就是 utf8.RuneCountInString(s) 的值。

注意遍历输出里的下标是跳着走的:0、3、6、9、10。这就是"字节下标"和"字符下标"的根本区别——range 遍历字符串时,i 给的是当前字符起始的字节位置,不是"第几个字符"。

复习提示:动手验证:把上面的代码敲进编辑器 go run 一遍,确认你的终端输出和这里完全一致。这类"看起来应该是 5,实际 len 是 11"的落差,只有亲手跑过才会刻进脑子里——这也是本章坚持每步都给预期输出的原因。

常见坑:s[:5] 切出乱码

s := "你好,Go"
fmt.Println(s[:5]) // 危险!

len(s) 是 11,下标 5 落在"好"字的中间(“好"占下标 3、4、5)。所以 s[:5] 只切到了"好"的前两个字节,输出会是 你 加半个字的乱码。

正确做法:

package main

import "fmt"

func main() {
	s := "你好,Go"

	// 正确:先转成 rune 切片,再按"字符"截断
	runes := []rune(s)
	fmt.Println(string(runes[:2])) // 你好
}

预期输出:

你好
复习提示:记住这条规则:处理用户输入(昵称、评论、标题)要截断时,先 []rune(s) 转成字符切片,截断后再 string(...) 转回去。直接对 string 用下标切,等于在赌运气。

string 的两种写法与常用操作

// 文件路径: ~/code/webook-syntax/string_demo/ops.go
package main

import (
	"fmt"
	"strings"
)

func main() {
	// 1. 双引号:内部出现 " 要用 \ 转义
	s1 := "他说:\"你好\""
	fmt.Println(s1)

	// 2. 反引号:可以换行、不用转义,但内部不能出现反引号
	s2 := `第一行
第二行`
	fmt.Println(s2)

	// 3. 拼接只能用 +,且 string 不能和数字直接拼(强类型!)
	fmt.Println("hello, " + "go")
	// fmt.Println("age: " + 18)  // 编译报错!要先转换

	// 4. 日常操作全在 strings 包里
	fmt.Println(strings.Contains("hello", "ell"))      // true
	fmt.Println(strings.ReplaceAll("a-b-c", "-", "+")) // a+b+c
	fmt.Println(strings.ToUpper("go"))                 // GO
	fmt.Println(strings.Split("a,b,c", ","))           // [a b c]
	fmt.Println(strings.TrimSpace("  hi  "))           // hi

	// 5. []byte 和 string 互转
	b := []byte("hello")
	fmt.Println(string(b) == "hello") // true
}

预期输出:

他说:"你好"
第一行
第二行
hello, go
true
a+b+c
GO
[a b c]
hi
true
复习提示:📌 本章演示目录的通用规则(务必先读):正文里 string_demo/、const_demo/、defer_demo/、control/、map_demo/、interface_demo/、compose/、generic/ 这些目录下会连续出现多个带 func main 的代码块。每个代码块都是一个独立的完整程序,请一次只放一个文件(跑完当前示例后,用下一个示例的内容替换它,或给每个示例建独立子目录)。如果把这些文件同时放进同一个目录,会出现 main redeclared / package 冲突,编译直接失败。
复习提示:

⚠️ 两个性能坑:

  1. regexp.MustCompile 要提到包级变量只编译一次,别在循环里反复编译。
  2. string 与 []byte 互转会复制一份数据。高热路径(比如每秒调用几万次的函数)别来回横跳。操作 []byte 的工具在 bytes 包里,可以理解为 strings 的字节版。

变量、常量与 iota

Go 里声明变量的方式比多数语言多一层"工程含义”:首字母大小写直接决定它能否被其它包访问。

// 文件路径: ~/code/webook-syntax/const_demo/main.go
package main

import "fmt"

// 包级变量:首字母大写 = 包外可访问(导出);小写 = 包内私有
var ServerPort int = 8080
var serverSecret = "xxx"

// const 同样遵循大小写控制访问性
const MaxRetry = 3

// iota:常量计数器,每个 const 块重置为 0,每新增一行 +1
type Weekday int

const (
	Sunday Weekday = iota // 0
	Monday                // 1
	Tuesday               // 2
	Wednesday             // 3
	Thursday              // 4
	Friday                // 5
	Saturday              // 6
)

// 位掩码:权限位的经典写法
const (
	Read    = 1 << iota // 1 << 0 = 1
	Write               // 1 << 1 = 2
	Execute             // 1 << 2 = 4
)

func main() {
	// 局部变量两种声明方式
	var a int = 10 // 显式类型
	b := 20        // 短变量声明,只能用在函数内部

	// := 要求左侧至少有一个新变量
	b, c := 30, 40 // b 已存在但 c 是新的,所以合法

	fmt.Println(a, b, c)
	fmt.Println("Friday =", Friday)
	fmt.Println("Read|Write =", Read|Write)
}

预期输出:

10 30 40
Friday = 5
Read|Write = 3

三条容易踩的细节:

  1. 局部变量声明了必须用,否则编译错误(前面坑 3 讲过)。
  2. := 推断数字字面量时默认是 int / float64。你要 int32 就得写 var x int32 = 10,因为 x := 10 只会给你 int。
  3. iota 非常适合定义枚举和位掩码。权限位那种 1 << iota 写法在后面 RBAC、文件权限的场景里会反复见到。

运算符与 bool 逻辑

运算符是强类型约束体现得最直接的地方:

  • 四则运算 + - * /:只有同类型才能运算,int + int64 直接编译报错。
  • 自增自减:Go 只有 i++ / i--,而且它们是语句不是表达式——x := i++ 直接编译报错,也不存在 ++i 这种前置写法。
  • bool 运算:&&(且)、||(或)、!(取反)。
// 文件路径: ~/code/webook-syntax/const_demo/bool.go
package main

import "fmt"

func main() {
	a, b := true, false

	// 德摩根定律:验证组合取反
	fmt.Println(!(a && b) == (!a || !b)) // true
	fmt.Println(!(a || b) == (!a && !b)) // true

	i := 0
	i++ // 合法:i++ 是语句
	// x := i++ // 编译报错:syntax error
	_ = i
}
复习提示:工程视角:!(a && b) 这类组合取反,建议提交前默写一遍德摩根定律——取反号进门,&& 与 || 互换。布尔条件写反是业务 bug 的常客:比如本意是"分页参数任一不合法就拒绝"(||),却写成 page <= 0 && size <= 0(两个都得非法才拒绝)。写单测时专门给边界值(0、-1)留用例。

递归与栈溢出

一个方法内部调用它自己,就是递归。

补课胶囊 · goroutine 与调用栈 一句话:goroutine 是 Go 里极轻量的"线程",你的 main 函数就跑在一个 goroutine 上。 类比:进程像一栋楼,线程像楼里的房间,goroutine 像房间里的人——占地方小得多,可以同时站很多人。 这里只需要知道:每个 goroutine 有自己的调用栈,栈空间有上限。并发章节会专门展开。

// 文件路径: ~/code/webook-syntax/recursion/main.go
package main

import "fmt"

// 递归求阶乘,递归深度 = n
func factorial(n int) int {
	if n <= 1 {
		return 1 // 递归出口
	}
	return n * factorial(n-1)
}

func main() {
	fmt.Println(factorial(5))
}

预期输出:

120

递归的隐藏炸弹:每层调用占用栈的一帧,嵌套太深会栈溢出(stack overflow),程序直接崩溃。

  • 治标:debug.SetMaxStack 调大上限——只是延后爆炸时间。
  • 治本:补上出口条件,或改写成循环。
复习提示:

面试追问点:“什么情况下会出现栈溢出?“答案是无限/过深递归(出口条件写错、树形数据里出现环)。能顺口补一句”goroutine 的栈初始很小(KB 级)但可以按需增长,增长也有上限",就是加分项。

另外注意:尾递归(递归调用是函数最后一步操作)在有些语言里会被自动优化成循环,但 Go 不会,只能手动改。

小收口

这一节的核心是两句话:

  1. Go 是强类型,没有隐式转换。别扭,但替你挡 bug。落库字段用 int64 不用 int。
  2. string 是字节序列不是字符序列。涉及中文一律先 []rune,别直接用下标切。

3.3 方法与 defer:Go 最有个性的两个设计

先抛问题

看这段代码,猜猜输出什么:

func main() {
	x := 1
	defer fmt.Println("参数传值:", x)
	defer func() { fmt.Println("闭包引用:", x) }()
	x = 3
}

两个 defer 都在 x = 3 之前写的,那它们会打印 1 还是 3?

(先想 10 秒钟再往下看——这道题在面试里的答错率超过一半。)

给个直觉:两张便签

defer 就是"函数返回前,把这件事做了"。

想象你出门前在门上贴便签:

  • 参数传值的 defer = 便签上写死了内容:“记得带伞”。无论之后天气怎么变,便签上就是"带伞"。
  • 闭包引用的 defer = 便签上写的是"看一眼今天的天气app再决定"。你真正出门(函数返回)时才去看,那时天气已经是最新值了。

记忆口诀:参数传值定义时定,闭包引用执行时定。

最小可运行示例

// 文件路径: ~/code/webook-syntax/defer_demo/main.go
package main

import "fmt"

func main() {
	x := 1

	// 作为参数传入:定义 defer 时就确定了值
	defer fmt.Println("param x =", x)

	// 作为闭包引用:执行 defer 时才确定值
	defer func() {
		fmt.Println("closure x =", x)
	}()

	x = 2
	fmt.Println("before return, x =", x)
	x = 3
}

预期输出(注意顺序是倒过来的):

before return, x = 2
closure x = 3
param x = 1

逐行讲解

  1. defer fmt.Println("param x =", x):x 作为参数传入,这一刻 x 是 1,就把 1 拷贝进去了。之后 x 变成 2、3 都影响不到它。→ 输出 param x = 1。
  2. defer func(){...}():闭包引用变量 x 本身,真正执行时(main 返回前)才去读,那时 x 已经是 3。→ 输出 closure x = 3。
  3. 执行顺序是后进先出(LIFO):后注册的 closure 先执行,先注册的 param 后执行。就像叠盘子,最后放上去的先拿下来。
复习提示:⚠️ 这不是 bug,是 Go 的语义。但在清理资源时,如果你期望 defer 里用的是"当时的值"却写了闭包,就会在错误的时机关掉错误的东西。面试里"参数传值 vs 闭包引用"这一问,答反了会被一眼看穿。

追问:defer 能不能修改返回值?

答案:能,但前提是返回值有名字。

// 文件路径: ~/code/webook-syntax/defer_demo/return.go
package main

import "fmt"

// 匿名返回值:return 那一刻值就被拷贝出去了,defer 改不到
func anonymous() int {
	var a int
	defer func() {
		a = 100 // 改的只是局部变量 a
	}()
	return a // 返回 0
}

// 有名返回值:result 本身就是"返回值变量",defer 改它就是改返回值
func named() (result int) {
	defer func() {
		result = 100
	}()
	return 1 // 先执行 result = 1,defer 再改成 100
}

func main() {
	fmt.Println(anonymous())
	fmt.Println(named())
}

预期输出:

0
100

为什么会有这个差别

flowchart TD
    subgraph A["匿名返回值"]
        A1["return a"] --> A2["把 a 的值拷贝到返回槽"]
        A2 --> A3["执行 defer:改局部变量 a"]
        A3 --> A4["返回槽里的值不变 → 0"]
    end
    subgraph B["有名返回值"]
        B1["return 1"] --> B2["result = 1"]
        B2 --> B3["执行 defer:result = 100"]
        B3 --> B4["返回 result → 100"]
    end

关键在于:return x 这条语句其实是两步——先把 x 赋值给返回值变量,再执行 defer,最后才真正返回。有名返回值让你有机会在 defer 里改那个"返回值变量";匿名返回值则是在 return 那一刻就把值拷进了你看不到的地方。

复习提示:面试追问点:“defer 内部能不能修改返回值?怎么改?“能,但前提是返回值有名字。这个特性不是玩具——本章后面会看到,用 defer + recover 把 panic 转成 error 返回,正是利用了它。

实战:用 defer + recover 兜住 panic

panic 是 Go 的"崩溃"——不处理的话整个程序挂掉。recover 是唯一能接住它的函数,但必须写在 defer 里才有效。

// 文件路径: ~/code/webook-syntax/defer_demo/divide.go
package main

import "fmt"

// 多返回值 + 有名返回值
func divide(a, b int) (result int, err error) {
	defer func() {
		// recover 捕获 panic,让函数返回 err 而不是崩溃
		if r := recover(); r != nil {
			err = fmt.Errorf("panic: %v", r)
			result = 0
		}
	}()
	if b == 0 {
		return 0, fmt.Errorf("divisor is zero")
	}
	return a / b, nil
}

func main() {
	fmt.Println(divide(10, 2))
	fmt.Println(divide(10, 0))
}

预期输出:

5 <nil>
0 divisor is zero

这个模式在真实项目里很常见:任何可能 panic 的第三方调用,都在入口处加一层 defer recover,把崩溃转成错误返回,避免单个请求把整个服务搞挂。第 2 章讲 Gin 中间件时会再见到它。

defer 的三种实现机制

前面反复说"defer 太多或写在循环里会失去优化",指的就是 Go 编译器对 defer 的三种实现:

机制说明性能启用条件
堆上分配defer 整体分配到堆上,被 GC 管理最慢默认 fallback
栈上分配分配到 goroutine 栈上,无需 GC提升 30%编译期能证明 defer 不逃逸
开放编码内联优化,相当于把 defer 内容挪到函数末尾最快defer ≤ 8 个、不在循环里、return 数 × defer 数 ≤ 15

补课胶囊 · 内存逃逸 一句话:编译器发现一个变量的引用会"逃出"当前函数的生命周期(被返回、被闭包捕获),就把它从栈挪到堆上交给 GC 管理,这叫逃逸。 类比:栈像你办公桌的抽屉,函数结束就自动清空;堆像公司仓库,得有人(GC)专门来收拾。东西放抽屉快,但如果你要把东西带回家(返回给调用方),就只能用仓库。 和 defer 的关系:defer 的优化程度正取决于它引用的数据是否逃逸。

复习提示:面试加分:被问到 defer 性能时,能主动把话题引到"三种实现机制 + 内存逃逸"上,说明你不只是会用它,还知道它为什么快、什么时候会变慢。这是从"会用"到"懂原理"的分水岭。

闭包:会记仇的函数

闭包 = 函数 + 它绑定的运行时上下文。

// 文件路径: ~/code/webook-syntax/defer_demo/closure.go
package main

import "fmt"

// counter 返回一个闭包,它"捕获"了外部的 count 变量
func counter() func() int {
	count := 0
	return func() int {
		count++
		return count
	}
}

func main() {
	c := counter()
	fmt.Println(c(), c(), c()) // 1 2 3

	c2 := counter()
	fmt.Println(c2()) // 1 —— 新的闭包有独立的 count
}

预期输出:

1 2 3
1
复习提示:

关键理解:count 本来是 counter 的局部变量,按理说函数返回就该销毁。但因为返回的闭包还引用着它,它就得继续活着——这就是前面说的逃逸。

工程风险:闭包引用的变量不会被 GC。如果一个长生命周期的闭包(比如注册到全局路由表的处理函数)引用了一个大对象,那这个大对象就永远释放不掉。这是"内存只涨不跌"类问题的常见成因之一。

不定参数的三个细节

package main

import "fmt"

// 不定参数:必须是最后一个参数;方法内部当切片用
func sum(nums ...int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

func main() {
	fmt.Println(sum(1, 2, 3, 4)) // 10
	fmt.Println(sum())           // 0 —— 可以传 0 个值

	nums := []int{1, 2, 3}
	fmt.Println(sum(nums...)) // 6 —— 切片展开传入
}

三个细节:

  1. 可以传 0 个值:sum() 完全合法,内部拿到空切片。
  2. 必须是最后一个参数:func f(a int, nums ...int) 合法;放中间直接编译报错。
  3. 内部当切片用,要继续传给另一个不定参数函数时用 nums... 展开。
复习提示:这个语法有什么用? 后面 Gin、GORM 的配置项里会大量见到 Option 模式——它正是"不定参数 + 函数类型参数"的组合应用。比如 gorm.Open(mysql.Open(dsn), &gorm.Config{}, gormlogger.Info) 最后那个可选参数就是这么来的。

小收口

这一节三个必须记住的结论:

  1. defer 后进先出;参数传值定义时定,闭包引用执行时定。
  2. defer 只能改有名返回值——这是 defer + recover 兜 panic 的基础。
  3. 闭包会让它引用的变量逃逸到堆上,长生命周期闭包别引用大对象。

3.4 控制结构:少即是多

先抛问题

Go 的循环只有 for 一个关键字。没有 while,没有 do-while,没有 foreach。这是语言设计者偷懒吗?

给个直觉:瑞士军刀

Go 把控制结构"砍"到最少——只有 if / for / switch / select(select 后面并发章节讲)。

类比:for 就像一把瑞士军刀。for i := 0; i < n; i++ 是经典三段式;去掉前后两段就是 while;什么都不写就是 while(true);加上 range 就是 foreach。一把刀四种用法,代价是你要认得它的每种形态。

这是 Go “少即是多"的设计哲学:控制流越简单,代码越容易读、越不容易出 bug。你写业务时 99% 的场景,这三个就够用了。

最小可运行示例

// 文件路径: ~/code/webook-syntax/control/main.go
package main

import "fmt"

func main() {
	// if 支持初始化语句,作用域仅限于 if-else 块
	if distance := 100; distance > 50 {
		fmt.Println("far")
	} else {
		fmt.Println("near")
	}
	// distance 在这里不可用(出了作用域)

	// for 形态一:经典三段式
	for i := 0; i < 3; i++ {
		fmt.Println("三段式:", i)
	}

	// for 形态二:while 风格
	n := 0
	for n < 3 {
		fmt.Println("while 风格:", n)
		n++
	}

	// for 形态三:range 遍历
	nums := []int{10, 20, 30}
	for idx, val := range nums {
		fmt.Printf("nums[%d]=%d\n", idx, val)
	}
}

预期输出:

far
三段式: 0
三段式: 1
三段式: 2
while 风格: 0
while 风格: 1
while 风格: 2
nums[0]=10
nums[1]=20
nums[2]=30

常见坑:对 for range 迭代变量取地址

这是 Go 里最高频的隐蔽 bug 之一。先猜猜下面两段代码的输出:

// 文件路径: ~/code/webook-syntax/control/range.go
package main

import "fmt"

func main() {
	nums := []int{10, 20, 30}

	// 错误写法:对迭代变量取地址
	bad := make([]*int, 0, len(nums))
	for _, v := range nums {
		bad = append(bad, &v) // 所有指针都指向同一个地址!
	}
	fmt.Println("bad:", *bad[0], *bad[1], *bad[2])

	// 正确写法:循环体内创建局部变量
	good := make([]*int, 0, len(nums))
	for _, v := range nums {
		v := v // 每次循环创建新的局部变量
		good = append(good, &v)
	}
	fmt.Println("good:", *good[0], *good[1], *good[2])
}

预期输出:先看你的 Go 版本

这个例子是 Go 版本差异最典型的地方,跑之前先确认版本:

go version
你的 Go 版本bad 的输出good 的输出
Go 1.21 及更早30 30 30 ❌10 20 30 ✅
Go 1.22 及以后10 20 30 ✅10 20 30 ✅

也就是说:在 Go 1.22+ 上,两种写法结果是一样的,因为官方已经把循环变量改成"每次迭代新建"了。

复习提示:

那这个坑还用学吗?必须学,三个理由:

  1. 存量代码还在。大量线上项目跑在 Go 1.21 及更早版本,你维护它们时这个坑依然存在。
  2. 闭包捕获仍有问题。Go 1.22 修复的是循环变量的复用,但如果你在循环里启动 goroutine 或注册闭包捕获了外层变量,语义上的坑依然存在——判断标准永远是"你捕获的是变量还是值"。
  3. 面试必问。这道题的正确答法是:先说清旧版本的行为和原因,再补一句 Go 1.22 的修复。只答"加 v := v“会显得没跟上语言演进;只答"新版本已修复"会显得没踩过坑。

为什么旧版本 bad 全是 30

flowchart LR
    subgraph BAD["错误写法:复用同一个 v"]
        V1["v 的地址 0x100"] --> R1["循环1: v=10, &v=0x100"]
        R1 --> R2["循环2: v=20, &v=0x100"]
        R2 --> R3["循环3: v=30, &v=0x100"]
        R3 --> OUT["三个指针都是 0x100
读出来全是 30"] end subgraph GOOD["正确写法:每次新建 v"] G1["循环1: v1 地址 0x200 = 10"] G2["循环2: v2 地址 0x300 = 20"] G3["循环3: v3 地址 0x400 = 30"] G1 --> OUT2["三个不同地址
读出来 10 20 30"] G2 --> OUT2 G3 --> OUT2 end
复习提示:

关键点(针对 Go 1.21 及更早):for range 的迭代变量在内存中只有一份,每次循环只是把新值赋给它。所以 &v 每次拿到的都是同一个地址,循环结束后所有指针都指向最后一次的值。

修复方法:在循环体里写 v := v 创建一个局部副本再取地址。这行看起来多余的赋值,是 Go 工程的经典 idiom——即使在新版本上它不再必要,写上也不会有副作用,还能保证代码在老版本上同样正确。

break、continue 与无限循环

// 文件路径: ~/code/webook-syntax/control/loop.go
package main

import "fmt"

func main() {
	// continue:跳过本次循环剩余部分
	for i := 0; i < 5; i++ {
		if i%2 == 0 {
			continue
		}
		fmt.Println("odd:", i)
	}

	// break:中断整个循环
	for i := 0; i < 5; i++ {
		if i == 2 {
			break
		}
		fmt.Println("i =", i)
	}

	// 无限循环 + break 退出
	n := 0
	for {
		n++
		if n >= 3 {
			break
		}
	}
	fmt.Println("无限循环退出,n =", n)
}

预期输出:

odd: 1
odd: 3
i = 0
i = 1
无限循环退出,n = 3
复习提示:⚠️ for {} 出不来是线上事故常客。无限循环本身合法且常用(消费 channel、重试逻辑全靠它),但你必须有可靠的退出条件(break / return / panic)。忘了退出条件的无限循环,如果里面还有内存分配操作,服务会以肉眼可见的速度把 CPU 和内存吃光。

顺带记住 for range 的省略写法:

  • 只要下标:for i := range nums
  • 只要值:for _, v := range nums
  • 只要 key(map):for k := range m

switch 的两种形态

// 文件路径: ~/code/webook-syntax/control/switch.go
package main

import "fmt"

func main() {
	// 形态一:带值 switch(val 必须是"可比较的")
	switch day := "Mon"; day {
	case "Mon", "Tue", "Wed", "Thu", "Fri":
		fmt.Println("weekday")
	case "Sat", "Sun":
		fmt.Println("weekend")
	default:
		fmt.Println("unknown")
	}

	// 形态二:无值 switch(等价于一长串 if-else if)
	hour := 8
	switch {
	case hour < 9:
		fmt.Println("早高峰")
	case hour < 18:
		fmt.Println("工作时间")
	default:
		fmt.Println("下班了")
	}
}

预期输出:

weekday
早高峰
  • 带值形态:switch val { case xxx: }。val 必须可比较(comparable)——切片、map、函数不能作 switch 值,编译器直接报错。
  • 无值形态:case 后面跟 bool 表达式,从上到下取第一个为真的分支。各 case 条件尽量互斥,否则"谁写在前面执行谁”,容易埋坑。
复习提示:记忆点:Go 的 switch 默认不穿透(case 结束自动跳出,不用写 break);想"故意掉进下一个 case"得显式写 fallthrough——实际工程几乎不用,知道有这个东西即可。这和 C/Java 的默认穿透行为正好相反,是 Go 有意为之的修正。

小收口

  • Go 只有 if / for / switch / select 四个控制关键字,for 一人分饰四角。
  • for range 迭代变量是复用的,取地址前先 v := v(Go 1.22+ 官方已修复循环变量复用,但闭包捕获仍要小心)。
  • switch 默认不穿透,带值形态要求 comparable。

3.5 切片:Go 工程里九成 bug 的源头

先抛问题

看这段代码,猜猜输出什么:

s := []int{1, 2, 3, 4, 5}
sub := s[:2]
sub[0] = 999
fmt.Println(s[0]) // 是 1 还是 999?

如果你答 1,说明你还没建立切片的内存模型。答案是 999——sub 和 s 在内存里根本就是同一块地方。

同样地,为什么把切片传进函数、在函数里 append 之后,外面的切片有时候变了、有时候又没变?

搞清楚这两个问题,你就掌握了 Go 里最容易出 bug 的类型。

给个直觉:切片是"遥控器",不是"电视机"

类比:

  • 数组 = 一台电视机。它是实体,尺寸固定(比如 5 个格子),买来多大就多大。
  • 切片 = 遥控器。遥控器本身很小,但它指向电视机的某个频道范围。

两台遥控器可以对着同一台电视:你用遥控器 A 换台,遥控器 B 看到的画面也变了。这就是"子切片共享底层数组"。

只有当你换了一台新电视(扩容),两台遥控器才各管各的。

数组 vs 切片:五个维度对比

数组在 Go 里很少直接用,但切片的底层就是数组。不理解数组的"固定长度",就理解不了切片为什么要引入 cap 这个概念。

维度数组 [N]T切片 []T
长度固定,编译期确定运行时可变
cap= 长度≥ len,独立概念
make 初始化不支持支持
append不支持支持
内存模型一段连续内存一个头部结构 + 底层数组

最关键的是最后一行。这个"头部 + 底层数组"的双层结构,是后面所有"共享"“扩容"“子切片"现象的根源。

复习提示:工程结论:遇事不决用切片。 数组只在极少场景用(如固定大小的协议头、SHA-256 摘要 [32]byte),日常业务代码里几乎见不到。真正需要记住的是:[3]int 和 []int 是两个完全不同的类型,函数签名里写错一个方括号,传参就编译不过。

切片的内存模型

切片本质上是一个头部结构(SliceHeader):

// 这是 Go 运行时的真实定义(在 reflect 包里)
type SliceHeader struct {
	Data uintptr // 指向底层数组的指针
	Len  int     // 当前元素个数
	Cap  int     // 底层数组的容量
}

只有 3 个字段、24 字节(64 位机器上)。切片变量本身很小,小到传参时拷贝它几乎不要钱——这也是"切片传进函数,改元素会影响外面"的原因:虽然头部被拷贝了,但两个头部的 Data 指针指向同一块底层数组。

flowchart TD
    H["SliceHeader
Data=0x100, Len=3, Cap=5"] H -->|Data 指针| A["底层数组
[1, 2, 3, _, _]
容量 5"] S["子切片 s[:2]
Data=0x100, Len=2, Cap=5"] S -->|共享同一底层数组| A A -.->|"修改 s[0]=999"| CHANGED["底层数组变成
[999, 2, 3, _, _]"] CHANGED -.->|"s[0] 也是 999"| H

最小可运行示例:验证共享

// 文件路径: ~/code/webook-syntax/slice_demo/main.go
package main

import "fmt"

func main() {
	s := make([]int, 3, 5) // len=3, cap=5
	s[0], s[1], s[2] = 1, 2, 3
	fmt.Printf("初始: s=%v len=%d cap=%d\n", s, len(s), cap(s))

	// 步骤 1:取子切片,与原切片共享同一底层数组
	sub := s[:2]
	sub[0] = 999
	fmt.Println("改 sub[0] 后,s[0] =", s[0])

	// 步骤 2:append 没超过 cap,仍在共享的底层数组上写
	sub = append(sub, 100)
	fmt.Println("sub append 后,s[2] =", s[2])

	// 步骤 3:append 超过 cap,触发扩容,不再共享
	big := append(s, 4, 5, 6) // 需要 6 个位置 > cap 5,触发扩容
	big[0] = 0
	fmt.Println("扩容后改 big[0],s[0] =", s[0])
}

预期输出:

初始: s=[1 2 3] len=3 cap=5
改 sub[0] 后,s[0] = 999
sub append 后,s[2] = 100
扩容后改 big[0],s[0] = 999

逐行讲解

步骤发生了什么为什么是这个结果
1sub := s[:2],改 sub[0]sub 和 s 的 Data 指针相同 → s[0] 也变成 999
2sub = append(sub, 100)sub 的 cap 是 5,只用了 2,没超容量,所以直接写进底层数组下标 2 → s[2] 变成 100
3big := append(s, 4, 5, 6)s 的 cap 是 5,要塞 6 个元素 → 触发扩容,分配新数组 → big 和 s 彻底分家
复习提示:

一句话判断共享:任何场景下(取子切片、切片当参数传进函数、塞进结构体),想知道两个切片会不会互相影响,只看一件事——中间有没有发生扩容。没扩容就还共享同一底层数组;扩容了就是两块独立内存。

这是判断内存共享问题的唯一标准,面试也这么答。

子切片的三种省略写法

区间全部是左闭右开 [start, end):

写法含义
s[a:b]取下标 [a, b) 的元素
s[:b]从头取到 b(不含 b)
s[a:]从 a 取到末尾(含末尾)

记住"左闭右开"这一条,绝大多数切片下标 bug 都能避免。

常见坑:子切片导致内存泄露

这是面试高频题,也是"服务内存只涨不跌"的元凶之一。

场景:你有一个 100 万元素的切片,只想返回前 10 个。

// 危险写法
func first10(big []int) []int {
	return big[:10] // 看似只返回 10 个元素
}

问题:返回的子切片和原切片共享底层数组。只要这个小切片还活着,那100 万个元素的底层数组就一个字节都释放不掉——你为了 10 个元素,把 100 万个全钉在内存里。

// 正确写法:copy 出独立切片,切断引用
func first10Safe(big []int) []int {
	small := make([]int, 10)
	copy(small, big[:10])
	return small
}

最小可运行示例:验证内存泄露

// 文件路径: ~/code/webook-syntax/slice_demo/leak.go
package main

import (
	"crypto/rand"
	"fmt"
	"runtime"
)

func printMem(tag string) {
	var m runtime.MemStats
	runtime.GC()
	runtime.ReadMemStats(&m)
	fmt.Printf("%s 堆内存 = %d KB\n", tag, m.HeapAlloc/1024)
}

// 危险:返回子切片,底层大数组无法释放
func leak() []byte {
	big := make([]byte, 50*1024*1024) // 50 MB
	_, _ = rand.Read(big)             // 用真实 IO 填数据,防止编译器把"没人读的部分"优化掉
	return big[:10]
}

// 安全:copy 出独立切片
func safe() []byte {
	big := make([]byte, 50*1024*1024)
	_, _ = rand.Read(big)
	small := make([]byte, 10)
	copy(small, big[:10])
	return small
}

func main() {
	printMem("初始    ")

	kept := leak() // 持有返回的小切片
	printMem("leak 后 ")  // 50MB 依然被钉住
	runtime.KeepAlive(kept) // 让编译器认为 kept 到这一刻仍被使用,否则会被提前回收

	kept = nil      // 释放引用
	runtime.GC()
	printMem("leak 释放")

	kept2 := safe()
	printMem("safe 后 ") // 大数组已被回收
	runtime.KeepAlive(kept2)
}

预期输出(具体数字因机器而异,但趋势一致):

初始     堆内存 = 100 KB
leak 后  堆内存 = 51299 KB
leak 释放 堆内存 = 105 KB
safe 后  堆内存 = 105 KB
复习提示:

看懂这个输出:leak 后 堆内存暴涨到 50 MB,尽管我们只"用"了 10 个字节。safe 后 则是 105 KB——大数组被正常回收了。

两个小技巧为什么必须写:① 用 crypto/rand.Read 填数据,是因为新版 Go 编译器很聪明——如果你只是 big[0] = 1,它会发现"数组里只有前 10 字节可能被读到",直接把 50MB 的分配缩水成 10 字节,示例就失效了;换成真实 IO(编译器看不穿)才能保住这 50MB。② runtime.KeepAlive(kept) 是告诉编译器"这个变量到这一行为止仍然被使用"——否则编译器可能在你最后一次读取之后、printMem 之前就把它判死,GC 提前回收,你同样看不到 50MB 被钉住的现象。

真实案例:这类问题在处理大文件、大 JSON、日志批量读取时特别常见。排查方法是用 pprof 看堆内存分配。第 8 章讲可观测性时会用到。

切片扩容系数(面试要点)

理解了"扩容才分离”,下一个自然问题是:扩容时按什么比例放大?

  • Go 1.18+:cap < 256 时两倍扩容;cap ≥ 256 时按 1.25 倍扩容。
  • Go 1.18 之前:以 1024 为分界点,前两倍后 1.25 倍。

为什么前期 2 倍、后期 1.25 倍? 这是"时间 vs 空间"的权衡:

  • 前期切片小:扩容代价小(复制几十个元素很快),倍数大可以减少扩容次数 → 用空间换时间。
  • 后期切片大:每次扩容都要复制海量数据,如果还 2 倍扩,“已用内存 + 预留内存"最多时约占峰值 2.5 倍,太浪费 → 用时间(多扩几次)换空间。
复习提示:

延伸思考:为什么是 1.25 而不是 1.5? 1.5 倍意味着冗余峰值更高;1.25 倍把冗余压到更低的水平,代价是"多扩几次容"。Go 团队还把分界点从 1024 下调到 256——因为 1024 太大,很多中等切片白白多扩了好几次容,这是基于大规模实测数据做的调整。

面试能讲到这一层很加分:说明你不只是背了数字,还理解了背后的权衡。

最佳实践

初始化切片时预估容量:

// 差:从 0 开始反复 append,可能触发多次扩容 + 数据拷贝
var users []User
for _, u := range source {
	users = append(users, u)
}

// 好:一次分配到位,零扩容
users := make([]User, 0, len(source))
for _, u := range source {
	users = append(users, u)
}

make([]T, 0, cap) 一次性把底层数组分配好,append 时只管往里填,一次扩容都不会发生。在循环次数已知的场景(比如从数据库查出 N 条记录再转 DTO),这是零成本优化。

小收口

切片这一节的四个结论,记住就够用:

  1. 切片 = 头部(Data/Len/Cap)+ 底层数组,是个"遥控器"不是"电视机”。
  2. 判断是否共享,只看有没有扩容——没扩容就共享,扩容了就分家。
  3. 返回子切片会钉住整个底层数组,对外返回前先 copy。
  4. 预估容量用 make([]T, 0, cap),避免反复扩容。

3.6 map:随机序 + 非并发安全的哈希表

先抛问题

三个问题,答对了这节可以跳过:

  1. var m map[string]int 之后直接 m["a"] = 1 会怎样?
  2. 为什么同一个 map 遍历两次,顺序可能不一样?是 Go 的 bug 吗?
  3. 两个 goroutine 同时写一个 map 会怎样?

给个直觉:带锁的公告栏

类比:map 像一个公告栏,上面贴满了"键值对"便签——每张便签有标题(key)和内容(value)。

  • 你可以随时贴新便签、撕掉旧的、查看某张。
  • 但便签的排列顺序是乱的(Go 故意打乱),你不能指望"按贴上去的顺序读出来”。
  • 而且这个公告栏没有锁——两个人同时往上面贴,会直接把公告栏撕烂(panic)。

最小可运行示例

// 文件路径: ~/code/webook-syntax/map_demo/main.go
package main

import "fmt"

func main() {
	// 预估容量能提升性能
	m := make(map[string]int, 64)

	// 赋值
	m["a"] = 1
	m["b"] = 2

	// 读取:两返回值,第二个表示是否存在
	// 不存在的 key 返回零值,不会 panic
	v, ok := m["c"]
	fmt.Println("读不存在的 key:", v, ok)

	// 遍历:顺序随机!
	fmt.Print("遍历: ")
	for k, v := range m {
		fmt.Print(k, "=", v, "  ")
	}
	fmt.Println()

	// 删除
	delete(m, "a")
	fmt.Println("删除后长度:", len(m))
}

预期输出(遍历顺序在你的机器上可能不同):

读不存在的 key: 0 false
遍历: b=2  a=1
删除后长度: 1

逐行讲解

行关键点
make(map[string]int, 64)第二个参数是容量提示。map 会预分配 bucket,避免运行中反复扩容
v, ok := m["c"]两返回值写法。key 不存在时 v 是 value 类型的零值(这里是 0),ok 是 false
for k, v := range m遍历顺序随机,Go 故意的
delete(m, "a")删除不存在的 key 不会报错,是空操作
len(m)map 的 len 返回键值对数量
复习提示:

⚠️ 为什么必须区分"零值"和"不存在":m["c"] 返回 0,但 m["a"] 也可能是 0(如果存的就是 0)。单靠返回值你分不清"key 不存在"和"key 存在但值是零值"。所以判断 key 是否存在,必须用两返回值形式 v, ok := m[key]。

这个区别在业务代码里极其重要——比如"用户是否已点赞",false 到底是"没记录"还是"记录了未点赞",搞混就是线上事故。

map 的两种初始化方式

// 方式一:make 初始化——可以预估容量,避免运行中反复扩容(推荐)
m1 := make(map[string]int, 64)

// 方式二:字面量初始化——适合"内容一开始就确定"的小 map
m2 := map[string]int{
	"apple":  1,
	"banana": 2,
}

两种方式都能直接用。区别在于 make 能预留比初始元素更多的容量(字面量做不到),元素多、写入频繁时 make + 预估容量性能更好。

常见坑 1:nil map 写入会 panic

这是新手栽跟头最多的一个坑,因为切片和 map 在这里不对称:

// 文件路径: ~/code/webook-syntax/map_demo/nilmap.go
package main

import "fmt"

func main() {
	// 切片:不初始化也能 append(Go 内部帮你分配底层数组)
	var s []int
	s = append(s, 1)
	fmt.Println("切片 append OK:", s)

	// map:不初始化就写入,直接 panic
	var m map[string]int
	fmt.Println("nil map 可以读:", m["x"]) // OK,返回 0
	fmt.Println("nil map 可以 len:", len(m)) // OK,返回 0
	for k := range m { // OK,空转
		_ = k
	}

	// m["a"] = 1  // ← 取消注释会 panic: assignment to entry in nil map
}

预期输出:

切片 append OK: [1]
nil map 可以读: 0
nil map 可以 len: 0

取消最后一行注释后运行,你会看到:

panic: assignment to entry in nil map
复习提示:

记忆这张对照表(nil 值能做什么、不能做什么):

操作nil 切片 var s []intnil map var m map[string]int
读 / len / range✅ 可以(0、空转)✅ 可以(零值、空转)
写 / append✅ 可以(自动分配)❌ panic
delete—✅ 可以(空操作)

核心记忆点:nil map 可以读、可以 range、可以 delete,就是不能写。 这个不对称让无数新手栽跟头。

常见坑 2:map 不是并发安全的

多个 goroutine 同时读写同一个 map,Go 运行时会故意崩溃:

fatal error: concurrent map read and map write
flowchart LR
    G1["goroutine 1
写 m['a']=1"] --> M["map
内部哈希桶"] G2["goroutine 2
读 m['b']"] --> M M -->|"运行时检测到并发"| P["fatal error:
concurrent map read
and map write"]
复习提示:

为什么 Go 选择"直接崩溃"而不是"加锁保护"? 因为加锁有性能成本,而绝大多数场景的 map 都不需要并发访问。让少数需要并发的开发者自己加锁(sync.RWMutex)或用 sync.Map,换取所有人的性能。这是 Go “不为你不需要的东西付费"的典型体现。

注意:崩溃是运行时的主动检测,不是必然稳定复现。这很危险——你的代码可能在测试环境跑得好好的,上线后流量一大就崩。所以并发场景从一开始就要用对。

并发场景的两个选择:

import "sync"

// 方案一:自己加锁(适合读多写少、类型明确的场景)
type SafeCache struct {
	mu sync.RWMutex
	m  map[string]int
}

func (c *SafeCache) Get(k string) (int, bool) {
	c.mu.RLock() // 读锁:多个读者可以并行
	defer c.mu.Unlock()
	v, ok := c.m[k]
	return v, ok
}

func (c *SafeCache) Set(k string, v int) {
	c.mu.Lock() // 写锁:独占
	defer c.mu.Unlock()
	c.m[k] = v
}

// 方案二:用 sync.Map。官方推荐的两个场景:
//   ① 某个 key 只写一次、之后以读为主(只增不改的缓存);
//   ② 多个 goroutine 各自读写"不相交"的 key 集合。
// 如果读写都频繁且集中在同一批 key 上,方案一(mutex + map)通常更快。
var sm sync.Map

func demo() {
	sm.Store("a", 1)
	if v, ok := sm.Load("a"); ok {
		fmt.Println(v)
	}
}
复习提示:

怎么选? 简单记:读写都频繁且 key 高度重合 → map + RWMutex(性能更好,类型安全);key 写入后很少再改(只增缓存)、或各 goroutine 写不相交的 key → sync.Map。这是 Go 官方文档给出的两个 sync.Map 适用场景,“读写都很频繁"反而常常是 RWMutex 更占优。

另外 sync.Map 的代价是丢失类型安全(Load/Store 的参数和返回值都是 any),需要自己做类型断言。工程上更推荐方案一。

常见坑 3:遍历顺序随机

flowchart LR
    M["map 内部哈希桶"]
    M --> K1["k1=苹果"]
    M --> K2["k2=香蕉"]
    M --> K3["k3=橙子"]
    R1["第 1 次遍历"] -->|顺序不固定| O1["香蕉, 苹果, 橙子"]
    R2["第 2 次遍历"] -->|顺序不固定| O2["橙子, 香蕉, 苹果"]

这是 Go 故意做的,目的是防止开发者写出"依赖遍历顺序"的代码——如果顺序稳定,就会有人依赖它,然后某天 Go 调整了哈希实现,那些代码就崩了。随机化逼着大家从一开始就不依赖顺序。

需要有序怎么办? 把 key 取出来排序,再按序遍历:

// 文件路径: ~/code/webook-syntax/map_demo/sorted.go
package main

import (
	"fmt"
	"sort"
)

func main() {
	m := map[string]int{"banana": 2, "apple": 1, "orange": 3}

	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys) // 排序

	for _, k := range keys {
		fmt.Println(k, m[k])
	}
}

预期输出(这次是稳定的):

apple 1
banana 2
orange 3

comparable:什么能做 key

map 的 key 必须可比较(comparable):

可以做 key不可以做 key
基本类型(int / string / bool…)切片 []T
指针map
channel函数
接口(运行时看动态类型)包含上述不可比较字段的结构体
数组(元素类型可比较时)
结构体(所有字段都可比较时)
复习提示:

为什么切片不能做 key? 因为"比较"需要能确定两个值是否相等,而切片的相等性没有明确定义(是比长度?比元素?比底层数组地址?)。更根本的原因是:切片是可变的,如果做了 key,之后修改切片内容,哈希桶就找不到了。

实用推论:想用"一组值"当 key(比如坐标 [x, y]),就用数组 [2]int 而不是切片 []int;或者把切片序列化成 string。

实战:用 map 封装 Set

Go 没有内置 Set 类型,但用 map 实现一个只需几行。这里用泛型(下一节的主题)做成通用版本:

// 文件路径: ~/code/webook-syntax/set/set.go
package set

// 用泛型实现一个通用的 Set
// K 必须是 comparable,因为要作为 map 的 key
type Set[K comparable] struct {
	m map[K]struct{} // struct{} 不占内存
}

func New[K comparable]() *Set[K] {
	return &Set[K]{m: make(map[K]struct{})}
}

func (s *Set[K]) Add(k K) {
	s.m[k] = struct{}{}
}

func (s *Set[K]) Delete(k K) {
	delete(s.m, k)
}

func (s *Set[K]) Contains(k K) bool {
	_, ok := s.m[k]
	return ok
}

func (s *Set[K]) Len() int {
	return len(s.m)
}

// Keys 返回所有元素,注意 map 遍历随机,结果顺序不固定
func (s *Set[K]) Keys() []K {
	keys := make([]K, 0, len(s.m))
	for k := range s.m {
		keys = append(keys, k)
	}
	return keys
}

使用示例:

// 文件路径: ~/code/webook-syntax/set_demo/main.go
// 注意:demo 必须放在 set 包目录之外的独立目录(如 set_demo/),
// 否则会和 set/set.go 的 package set 冲突,也无法 import 自己所在的包
package main

import (
	"fmt"
	"sort"

	"github.com/yourname/webook-syntax/set"
)

func main() {
	s := set.New[string]()
	s.Add("go")
	s.Add("gin")
	s.Add("go") // 重复添加,Set 会自动去重

	fmt.Println("元素个数:", s.Len())           // 2
	fmt.Println("包含 go 吗:", s.Contains("go")) // true

	s.Delete("gin")
	fmt.Println("删除后个数:", s.Len()) // 1

	keys := s.Keys()
	sort.Strings(keys) // 排序后输出才稳定
	fmt.Println("剩余元素:", keys)
}

预期输出:

元素个数: 2
包含 go 吗: true
删除后个数: 1
剩余元素: [go]
复习提示:

工程视角:为什么用 map[K]struct{} 而不是 map[K]bool?

因为 struct{} 占 0 字节。百万级元素时能省下可观的内存,而且用 bool 还要纠结"存 true 还是 false",语义上不如"存在即真"直接。这是 Go 里"用空结构体省内存“的经典 idiom,面试常考。

小收口

map 这一节的四个结论:

  1. 判断 key 是否存在用 v, ok := m[key],不能只看返回值(零值和不存在会混淆)。
  2. nil map 能读、能 range、能 delete,就是不能写——写入 panic。这和切片不对称。
  3. map 非并发安全,遍历顺序随机。并发用 sync.RWMutex 或 sync.Map;有序要自己排序 key。
  4. key 必须 comparable;实现 Set 用 map[K]struct{} 省内存。

3.7 接口、结构体与组合

先抛问题

看这段代码——UserRepository 这个接口,没有任何地方写"MySQLRepository 实现了它",但代码能编译通过:

type Repository interface {
	Save(key string, value []byte) error
}

type MySQLRepository struct{ /* ... */ }

func (m *MySQLRepository) Save(key string, value []byte) error {
	// 写 MySQL
	return nil
}

// 这里居然能赋值?
var r Repository = &MySQLRepository{}

没有 implements 关键字,Go 怎么知道 MySQLRepository 实现了 Repository?

给个直觉:鸭子类型

一句话:Go 的接口是隐式实现(鸭子类型)——“走路像鸭子、叫起来像鸭子,那它就是鸭子”。

类比:招聘时不看简历上写没写"会写代码",而是直接考你写不写得出代码。Go 不看声明,只看方法集。

你不用写 MySQLRepository implements Repository,只要结构体把接口里的所有方法都实现了,编译器就认它实现了该接口。

flowchart LR
    I["Repository 接口
Save / Load"] I -. "只要方法集满足" .-> S["MySQLRepository
实现了 Save + Load"] I -. "只要方法集满足" .-> M["MongoRepository
实现了 Save + Load"] B["业务代码"] -->|"只依赖接口"| I

最小可运行示例

// 文件路径: ~/code/webook-syntax/interface_demo/main.go
package main

import "fmt"

// 接口只声明方法,不需要 func 关键字
type Repository interface {
	Save(key string, value []byte) error
	Load(key string) ([]byte, error)
}

// 实现一:内存存储
type MemoryRepository struct {
	data map[string][]byte
}

func NewMemoryRepository() *MemoryRepository {
	return &MemoryRepository{data: make(map[string][]byte)}
}

func (m *MemoryRepository) Save(key string, value []byte) error {
	m.data[key] = value
	return nil
}

func (m *MemoryRepository) Load(key string) ([]byte, error) {
	v, ok := m.data[key]
	if !ok {
		return nil, fmt.Errorf("key %s not found", key)
	}
	return v, nil
}

// 业务代码:只依赖接口,不关心具体实现
func RunBusiness(r Repository) {
	r.Save("name", []byte("大明"))
	v, _ := r.Load("name")
	fmt.Println("读出来:", string(v))
}

func main() {
	// 没有 implements,直接赋值——因为方法集满足
	var repo Repository = NewMemoryRepository()
	RunBusiness(repo)
}

预期输出:

读出来: 大明

为什么要多此一举定义接口

初学时常觉得"直接用一个具体类型不就好了,干嘛多一层接口"。三个理由:

  1. 测试可以 mock。业务代码依赖接口,测试时塞一个假实现进去,不用真的连数据库。第 4 章会大量用到。
  2. 实现可替换。MySQL 换 MongoDB,业务代码一行不改。
  3. 依赖倒置。高层模块不依赖低层模块的具体实现。
复习提示:

工程建议:当你怀疑"这里要不要定义接口"时,加上去。后面 Gin 的 Session Store、GORM 的各种 Driver,全是基于这个设计。

注意一个 Go 的特色:接口应该由"使用方"定义,而不是"实现方"。就是说,不要在每个实现包里定义大接口,而是在调用方的包里,按自己需要的最小方法集定义小接口(“接受接口,返回结构体”)。这是 Go 接口设计的黄金法则。

结构体与指针接收器

// 文件路径: ~/code/webook-syntax/interface_demo/user.go
package main

import "time"

type User struct {
	ID        int64
	Name      string
	Email     string
	CreatedAt time.Time
}

// 指针接收器:方法内修改字段会生效
func (u *User) Rename(newName string) {
	u.Name = newName
}

// 值接收器:方法内拿到的是副本,修改不会生效
func (u User) NameLength() int {
	return len(u.Name)
}

// Go 没有构造函数!通常用 NewXXX 函数代替
func NewUser(name, email string) *User {
	return &User{
		Name:      name,
		Email:     email,
		CreatedAt: time.Now(),
	}
}

生活类比:

  • 值接收器 = 复印件。方法里改的是副本,原件不动。
  • 指针接收器 = 原件。方法里改的就是那一份。

所以:凡是方法里要改结构体字段(如 Rename),或者结构体很大不想每次调用都拷贝一份,都用指针接收器。

复习提示:

工程口诀:遇事不决用指针接收器。

混用风险:如果一个类型同时有值接收器和指针接收器的方法,在做接口判定时只有指针接收器的方法集被认可。更麻烦的是混用会让代码读者困惑"这个调用到底会不会改原对象"。所以工程上一个类型的方法接收器应保持一致——要么全用指针,要么全用值。

结构体初始化的细节与坑

// 文件路径: ~/code/webook-syntax/struct_demo/init.go
// 说明:这是一个独立的演示程序(有自己的 User 和 main),
// 请放在单独目录里运行,不要和 interface_demo/ 下的 main.go、user.go 放一起,
// 否则 User 和 main 会重复声明导致编译失败
package main

import "fmt"

type User struct {
	ID   int64
	Name string
}

func main() {
	// 步骤 1:按字段名初始化(强烈推荐)
	u1 := User{ID: 1, Name: "大明"}

	// 步骤 2:按字段顺序初始化(不推荐)
	u2 := User{1, "小明"}

	// 步骤 3:三种拿到指针的写法
	p1 := &User{ID: 2} // 取字面量地址
	p2 := new(User)    // new 分配内存、所有字段置零值,返回指针

	fmt.Println(u1, u2, p1, p2)

	// 步骤 4:声明了指针但没赋值,它是 nil
	var p3 *User
	fmt.Println("p3 == nil ?", p3 == nil)
}

预期输出:

{1 大明} {1 小明} &{2 } &{0 } true
复习提示:

⚠️ 新手必踩的坑:按字段顺序初始化。User{1, "小明"} 要求你按结构体定义的顺序逐个传值、一个不能少。

后期往结构体中间加一个字段,所有这种写法要么全部编译报错(运气好),要么两个字段类型相同时静默错位(运气差)——比如 User{ID, Age, Name} 加了 Age 之后,原来的 User{1, "小明"} 如果字段类型碰巧兼容,就会把 “小明” 塞进 Age,编译期一声不吭。

团队规范里通常直接禁掉这种写法。

还有一条硬性规则:结构体内部要引用自身(或引用链成环),只能用指针。

type Node struct {
	Value int
	Next  *Node // ✅ 指针,大小固定(8 字节)
	// Next Node // ❌ 编译报错:结构体大小无限递归
}

值类型的自引用会让结构体大小无限递归(一个 Node 里有一个 Node,那个 Node 里又有一个……),编译器直接报错;指针只是一个地址,大小固定。

顺带记住:指针可以调用值接收器方法,值也可以调用指针接收器方法——编译器会自动帮你取地址/解引用(前提是该值可寻址)。所以 u1.NameLength() 和 p1.Rename("x") 都能编译通过。

组合优于继承

Go 没有 extends,只有组合。组合不是继承,没有多态。

// 文件路径: ~/code/webook-syntax/compose/main.go
package main

import "fmt"

// 被组合的组件
type Logger struct{}

func (l Logger) Log(msg string) { fmt.Println("log:", msg) }

// Service 组合了 Logger,可以直接调用 Log 方法
type Service struct {
	Logger // 匿名字段:组合
}

func (s *Service) DoWork() {
	s.Log("starting work") // 方法直达
	fmt.Println("working...")
}

func main() {
	s := &Service{}
	s.DoWork()
}

预期输出:

log: starting work
working...

组合的四种形态

组合不只能"结构体组合结构体",一共四种玩法,而且可以同时组合多个:

// 文件路径: ~/code/webook-syntax/compose/forms.go
package main

import "fmt"

// 准备素材
type Logger struct{ Prefix string }

func (l Logger) Log(msg string) { fmt.Println(l.Prefix, msg) }

type Retrier interface{ Retry() }

type consoleRetrier struct{}

func (consoleRetrier) Retry() { fmt.Println("retrying...") }

// 一个结构体可以同时组合:结构体、结构体指针、接口
type Service struct {
	Logger  // 形态一:结构体组合结构体(最常用,gorm.Model 就是这种)
	Retrier // 形态二:结构体组合接口(要赋具体实现)
}

func main() {
	s := Service{
		Logger:  Logger{Prefix: "[svc]"},
		Retrier: consoleRetrier{},
	}
	s.Log("starting") // 方法直达
	s.Retry()         // 直接调接口字段的方法
}

预期输出:

[svc] starting
retrying...

四种形态:

  1. 结构体组合结构体 ← 最常用
  2. 结构体组合结构体指针
  3. 结构体组合接口 ← 上面演示的
  4. 接口组合接口 ← io.ReadWriter 的真身就是把 Reader、Writer 合并成一个新接口

组合带来的三个特性

  1. 方法直达:A 组合 B 后,可以直接在 A 上调用 B 的方法(s.Log(...))。
  2. 接口继承:B 实现的所有接口,编译器都认为 A 也实现了。
  3. 初始化当普通字段:初始化 A 时,B 就是一个普通字段。
复习提示:

⚠️ Java 转 Go 的同学特别小心:组合不是继承,没有多态。

Service 不会因为"重写"了 Log 而改变 DoWork 里调用的 Log 行为——因为 Go 没有动态分派。DoWork 里写的 s.Log(...) 在编译期就绑定到 Logger.Log 了。

如果你需要"子类改写父类方法"的效果,要用接口字段:在结构体里放一个接口类型的字段,运行时塞不同的实现进去。这是 Go 里实现"可替换行为"的唯一正道。

衍生类型 vs 类型别名

type MyInt int    // 衍生类型:全新类型,有独立方法集
type MyInt2 = int // 类型别名:只是换名字,本质还是 int

只差一个 =,语义天差地别:

type MyInt inttype MyInt = int
本质新类型同一个类型的别名
方法集独立,可以给它加方法和 int 完全一样
和 int 运算❌ 必须显式转换✅ 直接运算
典型用途给第三方类型"加方法"大重构时逐步迁移
package main

import "fmt"

type MyInt int

// 给衍生类型加方法——这是 int 做不到的
func (m MyInt) IsEven() bool {
	return m%2 == 0
}

func main() {
	var a MyInt = 4
	fmt.Println(a.IsEven()) // true

	// var b int = a     // ❌ 编译报错:需要显式转换
	var b int = int(a) // ✅
	fmt.Println(b)
}
复习提示:

工程视角:衍生类型最实用的场景是给第三方库的类型扩展方法——你不能改人家的源码,但可以在自己包里 type MyClient thirdparty.Client,然后给它加你需要的方法。

分不清衍生类型和类型别名,是面试和实战里的常见扣分项。

小收口

这一节三个核心:

  1. 接口是隐式实现(鸭子类型),不看声明看方法集。接口由使用方定义,“接受接口,返回结构体”。
  2. 遇事不决用指针接收器,一个类型的接收器要保持一致。结构体初始化永远用字段名。
  3. 组合不是继承,没有多态。需要可替换行为就用接口字段。

3.8 泛型:一次编写,多类型复用

先抛问题

你要写一个"求切片最大值"的函数。没有泛型时怎么办?

func MaxInt(nums []int) int { /* ... */ }
func MaxInt64(nums []int64) int64 { /* ... */ }
func MaxFloat64(nums []float64) float64 { /* ... */ }
// 每个类型抄一遍……

三份几乎一样的代码,改一个 bug 要改三处。泛型就是来解决这个的:把"类型"也变成参数。

给个直觉:函数有了两个参数列表

类比:普通函数的参数是"值",泛型函数的参数有两类——

  • 普通参数:(nums []int) —— 传的是值
  • 类型参数:[T Number] —— 传的是类型
Max[int]([]int{1, 2, 3})     // 显式指定 T = int
Max([]int{1, 2, 3})          // 或者让编译器自己推断

补课胶囊 · 类型参数与约束

一句话:[T Number] 里的 T 是类型参数,Number 是约束(constraint),规定了 T 允许取哪些类型。

类比:就像函数参数有类型限制(func f(x int) 规定 x 必须是整数),类型参数也有"类型的类型"限制。

两种约束:

  • 类型集合:~int | ~int64 | ~float64 —— 管"能是什么类型"
  • 方法集合:普通接口(带方法的)—— 管"必须有什么方法"
// 文件路径: ~/code/webook-syntax/slice/number.go
// 说明:这个约束是 slice 包的一部分(slice/slice.go 的 Max 依赖它),
// 所以要放在 slice/ 目录下;和 generic/ 下的 demo 文件放一起会 package 冲突
package slice

// 泛型约束:用 ~ 表示"底层类型是该类型的都算"
type Number interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64 |
		~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
		~float32 | ~float64
}

那个 ~ 很关键:没有 ~ 时,MyInt 这种衍生类型不算 int;有 ~ 时,只要底层类型是 int 就都算。

flowchart LR
    T["类型参数 T"] -->|"必须满足"| N["Number 约束"]
    N --> A["int / int64 / float64 ..."]
    N -. "用 ~ 包含底层类型" .-> B["衍生类型 MyInt 也满足"]

最小可运行示例:泛型函数、结构体、方法

// 文件路径: ~/code/webook-syntax/generic/sum.go
package main

import "fmt"

type Number interface {
	~int | ~int64 | ~float64
}

// 泛型函数:求任意数字类型切片的和
func Sum[T Number](nums []T) T {
	var total T
	for _, n := range nums {
		total += n
	}
	return total
}

// 泛型结构体
type Stack[T any] struct {
	data []T
}

// 泛型方法:注意接收器要写成 Stack[T]
func (s *Stack[T]) Push(v T) {
	s.data = append(s.data, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.data) == 0 {
		return zero, false
	}
	v := s.data[len(s.data)-1]
	s.data = s.data[:len(s.data)-1]
	return v, true
}

func main() {
	fmt.Println(Sum([]int{1, 2, 3}))          // 6
	fmt.Println(Sum([]float64{1.5, 2.5}))     // 4

	s := &Stack[string]{}
	s.Push("a")
	s.Push("b")
	v, _ := s.Pop()
	fmt.Println("弹出:", v)
}

预期输出:

6
4
弹出: b

逐行讲解

行要点
func Sum[T Number](nums []T) T[T Number] 是类型参数列表,nums []T 和返回值 T 都用到它
var total T泛型里的零值写法。T 可能是任何类型,不能用 var total int
type Stack[T any] structany 是最宽松的约束,等价于 interface{},任意类型都满足
func (s *Stack[T]) Push(v T)泛型方法的接收器必须写成 Stack[T],不能省略类型参数
复习提示:var zero T 这个写法要记住。在泛型函数里要返回"零值"或"空值"时,你不知道 T 具体是什么,只能 var zero T 让编译器给你一个该类型的零值。上面的 Pop 在栈空时返回 zero, false 就是这个用法。

普通接口也可以做泛型约束

不只是 Number 这种"类型集合",带方法的普通接口同样能做约束——此时 T 必须实现该接口的全部方法,方法体内可以直接调用:

// 文件路径: ~/code/webook-syntax/generic/stringer.go
package main

import "fmt"

// 普通接口做约束:T 必须实现了 String() 方法
type Stringer interface {
	String() string
}

// 方法里可以直接调用 T 的接口方法
func JoinAll[T Stringer](items []T, sep string) string {
	out := ""
	for i, it := range items {
		if i > 0 {
			out += sep
		}
		out += it.String() // 直接调用接口方法
	}
	return out
}

// 自定义类型实现 String(),就能用 JoinAll
type Role int

func (r Role) String() string {
	return []string{"admin", "member", "guest"}[r]
}

func main() {
	fmt.Println(JoinAll([]Role{0, 1, 2}, " | "))
}

预期输出:

admin | member | guest
复习提示:

记忆点:泛型约束只有两种——

  • 类型集合(~int | ~string):管"能是什么类型"
  • 方法集合(普通接口):管"必须有什么方法"

另外两个常用的内置约束:any(最宽松,任意类型)和 comparable(可比较,map 的 key、Set 的元素都要它,见 3.6 节的 Set 实现)。

实战:泛型工具方法

把这一节学到的串起来,写一组切片工具——这也是本项目后续会用到的:

// 文件路径: ~/code/webook-syntax/slice/slice.go
package slice

// Filter 过滤切片:保留满足条件的元素
func Filter[T any](src []T, f func(T) bool) []T {
	dst := make([]T, 0, len(src))
	for _, v := range src {
		if f(v) {
			dst = append(dst, v)
		}
	}
	return dst
}

// Map 转换切片:把每个元素映射成另一个值
func Map[S, D any](src []S, f func(S) D) []D {
	dst := make([]D, 0, len(src))
	for _, v := range src {
		dst = append(dst, f(v))
	}
	return dst
}

// Max 求最大值
func Max[T Number](nums []T) (T, bool) {
	var zero T
	if len(nums) == 0 {
		return zero, false
	}
	max := nums[0]
	for _, n := range nums[1:] {
		if n > max {
			max = n
		}
	}
	return max, true
}

// Insert 在指定位置插入元素
func Insert[T any](src []T, idx int, v T) []T {
	if idx < 0 || idx > len(src) {
		panic("index out of range")
	}
	dst := append(src, v)          // 先扩容一位
	copy(dst[idx+1:], dst[idx:])   // 把 idx 及之后的元素后移
	dst[idx] = v                   // 填入新值
	return dst
}

// Delete 删除指定下标元素
func Delete[T any](src []T, idx int) []T {
	if idx < 0 || idx >= len(src) {
		panic("index out of range")
	}
	return append(src[:idx], src[idx+1:]...) // 后续元素前移覆盖
}

注意 Map 有两个类型参数:[S, D any]——源类型 S 和目标类型 D 可以不同。这就是"把 []int 转成 []string“这类转换能一次搞定的原因。

使用示例

// 文件路径: ~/code/webook-syntax/slice_lib_demo/main.go
// 注意:demo 必须放在 slice 包目录之外的独立目录(如 slice_lib_demo/),
// 否则会和 slice/slice.go 的 package slice 冲突
package main

import (
	"fmt"

	"github.com/yourname/webook-syntax/slice"
)

func main() {
	nums := []int{1, 2, 3, 4, 5}

	// 过滤出偶数
	evens := slice.Filter(nums, func(n int) bool { return n%2 == 0 })
	fmt.Println("偶数:", evens)

	// 每个元素平方
	squares := slice.Map(nums, func(n int) int { return n * n })
	fmt.Println("平方:", squares)

	// int 转 string
	strs := slice.Map(nums, func(n int) string { return fmt.Sprintf("#%d", n) })
	fmt.Println("转字符串:", strs)

	// 求最大值
	if m, ok := slice.Max(nums); ok {
		fmt.Println("最大值:", m)
	}
}

预期输出:

偶数: [2 4]
平方: [1 4 9 16 25]
转字符串: [#1 #2 #3 #4 #5]
最大值: 5

小收口

泛型这一节三个要点:

  1. 类型参数写在 [T 约束] 里,约束分"类型集合"和"方法集合"两种,~ 表示包含底层类型。
  2. 泛型方法的接收器必须写成 Stack[T];返回零值用 var zero T。
  3. 泛型适合封装通用工具库(Filter / Map / Max / Set),不要为了炫技而用——业务代码里过度泛型化会让可读性急剧下降。
复习提示:

一个重要的工程提醒:泛型的引入(Go 1.18)在社区里是有争议的。它的价值在容器类型和通用算法(如本节的 slice/set 工具库);但在业务代码里到处用泛型,会让代码变得难以阅读。

判断标准:如果去掉泛型你需要抄写多份几乎相同的代码,那就用泛型;如果只是"看起来更通用",那就别用。超前设计,但不超前实现——这是本课程反复出现的一条原则。


补充知识 · 进阶延伸【补充:小红书亿级流量项目】

以下内容来自《亿级流量小红书项目》课程对 Go 语言基础的延伸要求——Java 课程不会专门讲、但 Go 工程绕不开的并发与性能基本盘。本章主线仍是语法地基,这一块作为"下一站地图”,建议在第 02 章之前先混个脸熟,等项目推进到缓存与压测章节(第 09 / 14 / 19 章)时再回来自学加深。

1. goroutine 与 channel:Go 并发的心脏

go 关键字一个词就能启动一个轻量执行单元(KB 级栈、由运行时调度,不是操作系统线程);channel 是 goroutine 之间传值的"带锁管道"。最小组合长这样:

// 工作池:3 个 worker 消费同一个任务 channel
func main() {
    jobs := make(chan int, 100)
    for i := 0; i < 3; i++ {
        go func(id int) {        // 每个 worker 一个 goroutine
            for j := range jobs { // channel 关闭后循环自动退出
                _ = j            // 处理任务
            }
        }(i)
    }
    for i := 0; i < 100; i++ {
        jobs <- i
    }
    close(jobs) // 关闭后 worker 的 range 才会结束
}

面试必会的一句话:“不要通过共享内存来通信,而要通过通信来共享内存”——channel 优先,实在要共享状态才用锁。本课程后面 MQ 消费者的并发处理、批量聚合,全是 goroutine + channel 的组合应用。

复习提示:⚠️ 新手必踩的坑:起了 goroutine 却忘了等它。main 本身也是一个 goroutine——它一退出,所有还没跑完的子 goroutine 直接消失,你以为在并发,其实结果根本没等回来。等一组 goroutine 干完活用 WaitGroup,传结果用 channel,两者都要在 main 退出前完成。

2. select 与超时控制

select 是 channel 专用的"多路等待"——多个 channel 谁先就绪处理谁,配合超时就有了一把"掐死慢操作"的刀:

select {
case v := <-ch:
    fmt.Println("拿到", v)
case <-time.After(2 * time.Second):
    fmt.Println("超时了")
}

3. context.Context:全链路的"遥控器"

context 携带取消信号、超时时间和请求级元数据,沿着调用链一路传下去(约定它永远做函数第一个参数)。Web/RPC 框架里每个请求自带一个 context,请求取消或超时,下游所有 goroutine 都能感知并提前退出。第 08 章链路追踪就是靠它在进程内外传递 traceId。

4. sync 原语一族

  • Mutex / RWMutex:互斥锁与读写锁——3.6 节 map 并发 panic 的标准解法就是加锁,读多写少场景可换 sync.Map;
  • WaitGroup:等一组 goroutine 干完活(Add / Done / Wait 三板斧);
  • atomic:int64 / 指针级别的无锁原子操作;
  • sync.Once:单例初始化只执行一次;
  • sync.Pool:对象池,复用临时对象减少 GC 压力——高并发服务里序列化缓冲区的标配。

5. GC 与内存:为什么工业级缓存库都用 []byte

Go 的 GC 要扫描堆上的指针才能判定对象存活:map[int64]*User 里几百万个条目就是几百万个指针,GC 标记耗时随条目数线性增长。所以 bigcache / freecache 这类本地缓存库把所有值序列化后塞进大块 []byte(内部只有一个底层数组指针),索引退化成 map[uint64]uint32(存"偏移量"这个整数而不是指针)——堆上几乎没有指针,GC 几乎不用扫。这是"亿级流量"本地缓存选型的核心原理,第 09 章讲选型时会展开。逃逸分析的地基本章 3.3 节(defer 的栈上分配)已经埋过种子。

6. pprof:性能分析的瑞士军刀

CPU 火焰图、堆内存、goroutine 泄漏排查都靠它,net/http/pprof 一行 import 就能挂到服务上。本章 3.5 节提过它查切片大内存的用法,第 19 章压测会真正用起来。

小收口:语法地基(本章)→ 并发基本盘(本补充块)→ Web 框架(第 02 章),这条路走完,你就具备了跟练整个项目所需的全部 Go 语言储备。


四、跟着写:分步实现 webook-syntax

前面八节是"知识点",这一节是"把它们串起来"。我们将从零搭一个叫 webook-syntax 的小工具——一个内存版的用户管理器。

为什么要有这一节:看懂代码和自己写出代码之间,隔着一道巨大的鸿沟。只有动手敲一遍,知识才是你的。

每一步的纪律:写完就 go run . 或 go test ./...,确认输出和我给的一致再进入下一步。

第 1 步:搭骨架(2 分钟)

mkdir -p ~/code/webook-syntax && cd ~/code/webook-syntax
go mod init github.com/yourname/webook-syntax

创建 main.go:

// 文件路径: ~/code/webook-syntax/main.go
package main

import "fmt"

func main() {
	fmt.Println("webook-syntax 启动")
}

验证:

go run .

预期输出:

webook-syntax 启动

✅ 骨架 OK。现在你有了一个能跑的 Go 模块。

第 2 步:定义 User 结构体与方法

新建 user.go:

// 文件路径: ~/code/webook-syntax/user.go
package main

import "time"

type User struct {
	ID        int64
	Name      string
	Email     string
	CreatedAt time.Time
}

// 指针接收器:方法内修改会生效
func (u *User) Rename(newName string) {
	u.Name = newName
}

// 值接收器:只读方法
func (u User) Nickname() string {
	runes := []rune(u.Name)
	if len(runes) == 0 {
		return "匿名用户"
	}
	return string(runes[:1]) + "同学"
}

// Go 没有构造函数,用 NewXXX 代替
func NewUser(id int64, name, email string) *User {
	return &User{
		ID:        id,
		Name:      name,
		Email:     email,
		CreatedAt: time.Now(),
	}
}

改一下 main.go 验证:

// 文件路径: ~/code/webook-syntax/main.go
package main

import "fmt"

func main() {
	u := NewUser(1, "大明", "daming@example.com")
	fmt.Println("昵称:", u.Nickname())

	u.Rename("小明")
	fmt.Println("改名后:", u.Name, "昵称:", u.Nickname())
}

预期输出:

昵称: 大同学
改名后: 小明 昵称: 小同学
复习提示:注意 Nickname 里为什么用 []rune:如果直接 u.Name[:1],遇到中文会切出乱码(3.2 节的坑)。这里先转 []rune 再取第一个字符,才是正确的"取昵称首字"。

第 3 步:定义 Repository 接口

新建 repository.go——注意我们用接口做抽象,业务代码不依赖具体实现:

// 文件路径: ~/code/webook-syntax/repository.go
package main

// 接口由"使用方"定义,只声明需要的最小方法集
type UserRepository interface {
	Save(u *User) error
	FindByID(id int64) (*User, error)
	FindByName(name string) ([]*User, error)
	List() []*User
}

第 4 步:用 map + 切片实现内存存储

新建 memory.go:

// 文件路径: ~/code/webook-syntax/memory.go
package main

import (
	"fmt"
	"sync"
)

// MemoryUserRepository 是 UserRepository 的内存实现
// 没有 implements 关键字,方法集满足即可
type MemoryUserRepository struct {
	mu    sync.RWMutex          // 并发安全:读写锁
	byID  map[int64]*User       // 主键索引
	order []int64               // 维护插入顺序(map 遍历无序)
}

func NewMemoryUserRepository() *MemoryUserRepository {
	return &MemoryUserRepository{
		byID:  make(map[int64]*User), // 必须初始化!
		order: make([]int64, 0, 64),  // 预估容量
	}
}

func (r *MemoryUserRepository) Save(u *User) error {
	r.mu.Lock()
	defer r.mu.Unlock() // defer 保证一定解锁

	if _, exists := r.byID[u.ID]; !exists {
		r.order = append(r.order, u.ID) // 新用户才记顺序
	}
	r.byID[u.ID] = u
	return nil
}

func (r *MemoryUserRepository) FindByID(id int64) (*User, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	u, ok := r.byID[id]
	if !ok {
		return nil, fmt.Errorf("user %d not found", id)
	}
	return u, nil
}

func (r *MemoryUserRepository) FindByName(name string) ([]*User, error) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	result := make([]*User, 0, len(r.order))
	for _, id := range r.order { // 按插入顺序遍历,结果稳定
		if u := r.byID[id]; u.Name == name {
			result = append(result, u)
		}
	}
	return result, nil
}

func (r *MemoryUserRepository) List() []*User {
	r.mu.RLock()
	defer r.mu.RUnlock()

	result := make([]*User, 0, len(r.order))
	for _, id := range r.order {
		result = append(result, r.byID[id])
	}
	return result
}
复习提示:

这一小步用到了本章的好几个知识点,对照检查一下:

  1. make(map[int64]*User) —— map 必须初始化才能写(3.6 节坑 1)
  2. defer r.mu.Unlock() —— defer 保证任何路径都能解锁(3.3 节)
  3. sync.RWMutex —— 并发安全(3.6 节坑 2)
  4. 用 order 切片维护顺序 —— map 遍历无序,要有稳定顺序就得自己记(3.6 节坑 3)
  5. make([]int64, 0, 64) —— 预估容量避免扩容(3.5 节)
  6. List() 返回新建的 result 切片 —— 不直接返回内部切片,避免外部修改影响内部状态

第 5 步:加泛型工具

新建 slicex/slice.go:

// 文件路径: ~/code/webook-syntax/slicex/slice.go
package slicex

// Number 约束:所有数字类型
type Number interface {
	~int | ~int64 | ~float64
}

// Filter 保留满足条件的元素
func Filter[T any](src []T, f func(T) bool) []T {
	dst := make([]T, 0, len(src))
	for _, v := range src {
		if f(v) {
			dst = append(dst, v)
		}
	}
	return dst
}

// Map 把 []S 转换成 []D
func Map[S, D any](src []S, f func(S) D) []D {
	dst := make([]D, 0, len(src))
	for _, v := range src {
		dst = append(dst, f(v))
	}
	return dst
}

// Max 求最大值,空切片返回 (zero,false)
func Max[T Number](nums []T) (T, bool) {
	var zero T
	if len(nums) == 0 {
		return zero, false
	}
	max := nums[0]
	for _, n := range nums[1:] {
		if n > max {
			max = n
		}
	}
	return max, true
}

第 6 步:组装 main

// 文件路径: ~/code/webook-syntax/main.go
package main

import (
	"fmt"

	"github.com/yourname/webook-syntax/slicex"
)

func main() {
	// 依赖接口,不依赖具体实现
	var repo UserRepository = NewMemoryUserRepository()

	// 存入几个用户
	users := []*User{
		NewUser(1, "大明", "daming@example.com"),
		NewUser(2, "小明", "xiaoming@example.com"),
		NewUser(3, "大明", "daming2@example.com"),
	}
	for _, u := range users {
		if err := repo.Save(u); err != nil {
			panic(err)
		}
	}

	// 1. 按 ID 查
	if u, err := repo.FindByID(2); err == nil {
		fmt.Println("按 ID 查:", u.Name)
	}

	// 2. 按名字查(同名多个)
	daming, _ := repo.FindByName("大明")
	fmt.Println("叫大明的有", len(daming), "个")

	// 3. 用泛型工具:提取所有 ID 并求最大值
	all := repo.List()
	ids := slicex.Map(all, func(u *User) int64 { return u.ID })
	if maxID, ok := slicex.Max(ids); ok {
		fmt.Println("最大 ID:", maxID)
	}

	// 4. 用泛型工具:过滤出 ID > 1 的用户
	bigIDs := slicex.Filter(ids, func(id int64) bool { return id > 1 })
	fmt.Println("ID > 1 的:", bigIDs)

	// 5. 查不存在的用户
	if _, err := repo.FindByID(999); err != nil {
		fmt.Println("错误处理:", err)
	}
}

验证:

go run .

预期输出:

按 ID 查: 小明
叫大明的有 2 个
最大 ID: 3
ID > 1 的: [2 3]
错误处理: user 999 not found

🎉 到这里,本章大纲里"学完你能跑出来的东西"就已经达成了。

第 7 步:加单元测试(可选但强烈建议)

新建 memory_test.go:

// 文件路径: ~/code/webook-syntax/memory_test.go
package main

import "testing"

func TestMemoryUserRepository_SaveAndFind(t *testing.T) {
	repo := NewMemoryUserRepository()
	u := NewUser(1, "大明", "daming@example.com")

	if err := repo.Save(u); err != nil {
		t.Fatal(err)
	}

	got, err := repo.FindByID(1)
	if err != nil {
		t.Fatal(err)
	}
	if got.Name != "大明" {
		t.Errorf("期望 大明,实际 %s", got.Name)
	}
}

func TestMemoryUserRepository_NotFound(t *testing.T) {
	repo := NewMemoryUserRepository()

	if _, err := repo.FindByID(999); err == nil {
		t.Error("查不存在的用户应该返回 error")
	}
}

// 表驱动测试:一次覆盖多种情况
func TestUser_Nickname(t *testing.T) {
	testCases := []struct {
		name string
		in   string
		want string
	}{
		{name: "中文名", in: "大明", want: "大同学"},
		{name: "英文名", in: "Tom", want: "T同学"},
		{name: "空名字", in: "", want: "匿名用户"},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			u := NewUser(1, tc.in, "a@b.com")
			if got := u.Nickname(); got != tc.want {
				t.Errorf("期望 %s,实际 %s", tc.want, got)
			}
		})
	}
}

验证:

go test ./... -v

预期输出(末尾几行):

=== RUN   TestUser_Nickname/中文名
--- PASS: TestUser_Nickname/中文名 (0.00s)
=== RUN   TestUser_Nickname/英文名
--- PASS: TestUser_Nickname/英文名 (0.00s)
=== RUN   TestUser_Nickname/空名字
--- PASS: TestUser_Nickname/空名字 (0.00s)
PASS
ok      github.com/yourname/webook-syntax
复习提示:

表驱动测试(Table Driven)是 Go 测试的标准写法——用一个切片装"输入 + 期望输出",循环跑,每个用例用 t.Run 起子测试。后面第 4 章会深入讲,这里先混个脸熟。

为什么值得现在就写:go test 全绿的那一刻,你对这一章的掌握就从"我看懂了"变成了"我写出来了"。

本章完成后的项目结构

webook-syntax/
├── go.mod                  # 模块定义
├── main.go                 # 入口 + 组装
├── user.go                 # 结构体 + 方法(指针/值接收器)
├── repository.go           # 接口定义
├── memory.go               # 接口的 map + 切片实现(并发安全)
├── memory_test.go          # 单元测试
└── slicex/
    └── slice.go            # 泛型工具库

验证清单

做完这一节,逐条打勾:

  • go run . 输出和上面的一致
  • go test ./... 全部 PASS
  • 我能说出 memory.go 里每一处 defer 和每一处 make 的作用
  • 我把 byID 的 make 删掉后,看到了 panic: assignment to entry in nil map
  • 我把 sync.RWMutex 相关代码删掉,能说出并发写入会报什么错

五、工程实践要点

这一节是"生产环境才体会得到"的经验,新手阶段先建立印象。

5.1 切片使用禁忌

  1. 不要依赖子切片的独立性:子切片与原切片共享内存。需要独立副本就用 copy。
  2. 不要对 for range 迭代变量取地址:会指向同一个地址(Go 1.22+ 已修复循环变量复用,但闭包捕获仍要小心)。
  3. 大切片返回子切片要警惕内存泄露:底层数组无法被 GC。用 copy 截取需要的部分。
  4. 预估容量:make([]T, 0, cap) 比反复 append 扩容性能好得多。

5.2 map 使用注意

  1. map 不是并发安全的:并发读写会 panic。用 sync.Map 或加 sync.RWMutex。
  2. 遍历顺序随机:不要依赖遍历顺序,需要有序就先取 key 排序再遍历。
  3. 删除大 map 元素后内存不会立即释放:delete 只是标记,必要时重建一个 map 让旧的被 GC。
  4. 判断 key 存在用两返回值:v, ok := m[k],别只看 v(零值和不存在会混淆)。

5.3 defer 注意事项

  1. 一个函数内 defer 不超过 8 个:否则失去开放编码优化。
  2. defer 不要放在循环里:循环里 defer 会堆积到函数结束才执行,且开放编码优化失效。循环里的资源释放要显式调用或包一层函数。
  3. defer 适合资源释放:Close()、Unlock()、ResponseWriter.Write() 等。
  4. 闭包引用要小心:闭包引用的变量不会被 GC,长生命周期闭包避免引用大对象。
// ❌ 错误:循环里 defer,1000 次循环攒 1000 个 defer
for _, f := range files {
	fp, _ := os.Open(f)
	defer fp.Close() // 全部堆到函数返回才关闭
}

// ✅ 正确:包一层函数,每次迭代结束就释放
for _, f := range files {
	if err := processFile(f); err != nil {
		return err
	}
}

func processFile(name string) error {
	fp, err := os.Open(name)
	if err != nil {
		return err
	}
	defer fp.Close() // 函数返回就关闭
	// ... 处理
	return nil
}

5.4 命名与可见性

  • Go 用首字母大小写控制可见性,这是 Go 区别于其它语言的关键设计。
  • 大写 = 包外可访问(导出),小写 = 包内私有。
  • 包名应该简洁、小写、单数(不要 users,要 user)。
  • 接口名通常不带 I 前缀(不是 IUserRepository,而是 UserRepository)。
  • 单方法接口的命名习惯是"方法名 + er":Reader、Writer、Stringer。

六、自测题与动手练习

自测题

看着题目能答出来,这一章才算真懂(答案在第七节的面试问答里)。

  1. 一个 Go 程序必须有几个 main 包?把 package main 写成 package hello,go run 报什么错?
  2. len("你好,Go") 是多少?为什么?要取前两个字符应该怎么写?
  3. 切片 s[a:b] 和原切片是什么关系?什么时候不再共享?
  4. var m map[string]int 之后,m["a"] = 1、len(m)、m["x"]、for range m 分别会怎样?
  5. defer fmt.Println(x) 和 defer func(){ fmt.Println(x) }(),在 x 后续被修改时各打印什么?
  6. 切片扩容系数是多少?分界点是多少?为什么前期 2 倍、后期 1.25 倍?
  7. 组合和继承的区别是什么?Java 转 Go 的同学最容易踩什么坑?
  8. type MyInt int 和 type MyInt = int 有什么区别?

动手练习

练习 1 · 修复三个 bug(15 分钟)

下面这段代码有三个 bug,找出来并修复,让它输出预期结果:

package main

import "fmt"

func main() {
	// bug 1:nil map 写入
	var m map[string]int
	m["a"] = 1

	// bug 2:对 for range 迭代变量取地址
	nums := []int{10, 20, 30}
	ptrs := []*int{}
	for _, v := range nums {
		ptrs = append(ptrs, &v)
	}
	fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 期望 10 20 30

	// bug 3:按字节截断中文
	s := "你好世界"
	fmt.Println(s[:2]) // 期望 "你好"
}

参考答案:

#问题修复
1var m map[string]int 只声明不初始化,是 nil map,写入 panic改成 m := make(map[string]int)
2对迭代变量 v 取地址,拿到的是同一个地址循环体内加 v := v 再取地址(Go 1.21 及更早会输出 30 30 30;Go 1.22+ 已修复,输出正常,但为了兼容性和闭包场景仍建议这么写)
3s[:2] 按字节切,“你"占 3 字节,切出乱码改成 string([]rune(s)[:2])
复习提示:怎么验证自己真的修对了:把三个 bug 逐个注释掉、逐个修复,go run 一次看一个变化。特别是 bug 3,修好前后的输出对比会让你一辈子记住"string 是字节序列"这句话。

练习 2 · 实现一个并发安全的计数器(20 分钟)

用 sync.RWMutex + map 实现一个 Counter:

  • Incr(key string) 计数 +1
  • Get(key string) int 读取计数
  • TopN(n int) []KV 返回计数最高的 n 个(按计数降序,计数相同的按 key 升序)

要求:并发安全、写测试验证并发调用下计数正确(提示:用 sync.WaitGroup 起 100 个 goroutine 各加 100 次,最终应该是 10000)。

练习 3 · 泛型实战(20 分钟)

扩展 slicex 包,实现:

  • Contains[T comparable](src []T, target T) bool
  • Unique[T comparable](src []T) []T(去重,保持原顺序)
  • GroupBy[K comparable, V any](src []V, keyFunc func(V) K) map[K][]V

并为每个函数写表驱动测试。


七、模拟面试问答

这一节模拟真实面试场景。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。

面试官
【基础】 一个 Go 程序必须有几个 main 包和几个 main 函数?如果把 package main 写成 package hello,go run 会报什么错?
候选人

一句话结论:一个 Go 进程有且仅有一个 main 包,包里有且仅有一个无参数、无返回值的 main 函数作为入口;写成 hello 会报 package command-line-arguments is not a main package。

展开:Go 工具链只在 main 包里找 main 函数作为程序入口,这是硬性约定而非风格建议。同一个目录下所有 .go 文件(不含 _test.go)的 package 名必须一致,否则编译报错。另外 package 名可以和目录名不同,但工程上强烈建议保持一致,否则 import 后的标识符会让人困惑。

面试官想听的关键词:唯一入口、工具链约定、package 名与目录名一致、_test 包例外。

常见失分点:只答"会报错"却说不出具体报错信息;或者误以为可以有多个 main 函数(同一个包里多个 main 函数会报 main redeclared)。

面试官
【基础】 len("你好,Go") 返回多少?为什么?要正确截取前两个字符应该怎么做?
候选人

一句话结论:返回 11(字节数),不是 5(字符数);截取含中文的字符串必须先转 []rune(s) 再按字符下标切。

展开:Go 的 string 本质是不可变的字节序列,默认 UTF-8 编码。“你好,Go” 里每个汉字和全角逗号占 3 字节,G 和 o 各占 1 字节,所以 3+3+3+1+1 = 11。直接 s[:5] 是按字节切,会切在"好"字中间导致乱码。正确做法:runes := []rune(s); string(runes[:2])。字符个数要用 utf8.RuneCountInString(s)。

面试官想听的关键词:字节序列、UTF-8 变长编码、[]rune 转换、rune 是 int32 别名、range 遍历时下标是字节位置。

常见失分点:把 len 说成"字符数";知道要用 []rune 但说不清"为什么直接切会乱码"(答不出"字节下标可能落在多字节字符中间")。

面试官
【基础】 切片 s[a:b] 和原切片是什么关系?为什么说"函数返回子切片可能导致内存泄露",怎么解决?
候选人

一句话结论:子切片与原切片共享同一个底层数组,只有 append 超过 cap 触发扩容时才会分配新数组、不再共享;解决内存泄露用 copy 复制出独立切片。

展开:切片是个三字段头部结构(Data 指针、Len、Cap)加一段底层数组。取子切片只是复制了头部,Data 仍指向同一块内存,所以修改会互相影响。判断两个切片是否共享,只看中间有没有发生扩容。内存泄露场景:从 100 万元素的大切片里返回前 10 个,只要这 10 个元素还活着,整个 100 万元素的底层数组就无法被 GC。

面试官想听的关键词:SliceHeader 三字段、共享底层数组、扩容才分离、copy 切断引用、GC 可达性。

常见失分点:说"子切片是拷贝了一份"(完全说反);或者知道共享但答不出"什么时候不共享"(漏掉扩容这个唯一判据)。

面试官
【基础】 map 的 key 必须满足什么约束?var m map[string]int 之后,哪些操作合法、哪些会 panic?
候选人

一句话结论:key 必须是 comparable(可比较);nil map 能读、能 len、能 range、能 delete,就是不能写——写入会 panic。

展开:可比较的类型包括基本类型、string、指针、channel、接口、数组(元素可比较时)、结构体(所有字段都可比较时);切片、map、函数不能做 key,因为它们无法定义稳定的相等性。nil map 的不对称是新手最常栽的坑:切片 var s []int 不初始化也能 append(Go 内部帮你分配),但 map 不 make 就写入会直接 panic: assignment to entry in nil map。

面试官想听的关键词:comparable、nil map 可读不可写、与切片的不对称、make 初始化、两返回值判断存在性。

常见失分点:说"nil map 读也会 panic"(错,读返回零值不 panic);或者漏掉"结构体要求所有字段可比较"这个限定条件。

面试官
【进阶】 defer fmt.Println(x) 和 defer func(){ fmt.Println(x) }(),在 x 后续被修改时分别打印什么?为什么?
候选人

一句话结论:参数传值打印定义时的值,闭包引用打印执行时的最终值;口诀"参数传值定义时定,闭包引用执行时定"。

展开:defer 注册时,函数的参数会被立即求值并拷贝,所以 fmt.Println(x) 那一刻 x 是多少就是多少。而闭包捕获的是变量本身(引用),真正执行时(函数返回前)才去读,那时 x 已经被改过了。同时 defer 的执行顺序是后进先出(LIFO),后注册的先执行。

面试官想听的关键词:LIFO、参数即时求值、闭包捕获变量、执行时机、资源清理。

常见失分点:把两者的打印结果说反(这是答错率最高的一题);只答出结果说不出"参数在注册时求值"这个原理。

面试官
【进阶】 defer 内部能不能修改函数的返回值?如果能,需要什么条件?
候选人

一句话结论:能,但前提是返回值有名字(有名返回值);匿名返回值在 return 那一刻值就被拷贝走了,defer 改不到。

展开:return x 实际是两步——先把 x 赋给返回值变量,再执行 defer,最后返回。有名返回值(func f() (result int))相当于在函数顶部声明了局部变量 result,defer 改 result 就等于改返回值;匿名返回值则把值拷进了你看不到的地方,defer 里改局部变量无济于事。这个特性不是玩具——defer + recover 把 panic 转成 error 返回,正是利用了它。

面试官想听的关键词:有名返回值、return 分两步(赋值 + defer)、recover 兜 panic、匿名返回值拷贝。

常见失分点:直接答"不能"(忽略了有名返回值这个前提);或者知道能改但说不清 return 的两步语义。

面试官
【进阶】 Go 切片的扩容系数是多少?为什么要设计成前期 2 倍、后期 1.25 倍?
候选人

一句话结论:Go 1.18+ 是 cap < 256 时 2 倍,cap ≥ 256 时 1.25 倍(1.18 之前分界点是 1024);这是"时间换空间"的权衡。

展开:前期切片小,扩容代价低(复制几十个元素很快),用大倍数减少扩容次数,是空间换时间;后期切片大,2 倍扩会让"已用 + 预留"峰值达到 2.5 倍,太浪费,改成 1.25 倍把内存冗余压低,代价是多扩几次容,是时间换空间。分界点从 1024 下调到 256 是 Go 团队基于大规模实测数据做的调整——1024 太大,导致很多中等切片白白多扩了几次。

面试官想听的关键词:256 分界点、版本差异(1.18)、时间换空间、实测数据驱动、预分配 make([]T, 0, cap)。

常见失分点:只背数字说不出背后的权衡;把分界点说成 1024(那是旧版本);不知道 1.18 改过。

面试官
【进阶】 多个 goroutine 并发读写同一个 map 会发生什么?怎么解决?sync.Map 和 map + RWMutex 怎么选?
候选人

一句话结论:会直接 panic(fatal error: concurrent map read and map write),Go 运行时主动检测并崩溃;解决用 sync.RWMutex 或 sync.Map。

展开:Go 故意选择"崩溃"而不是"内部加锁",因为加锁有性能成本,而绝大多数 map 根本不需要并发访问——让需要的人自己加锁,换取所有人的性能(不为不需要的东西付费)。选型上:key 相对固定、读远多于写 → map + sync.RWMutex(性能更好、类型安全);key 频繁增删、读写都频繁 → sync.Map(内部用读写分离的双 map 优化)。sync.Map 的代价是丢失类型安全,参数返回值都是 any,需要类型断言。

面试官想听的关键词:运行时主动检测、fatal error 不可 recover、RWMutex 读写锁、sync.Map 读写分离、类型安全取舍、设计哲学。

常见失分点:以为并发读写只是"数据竞争"会得到错误结果(实际是直接崩溃);只说 sync.Map 却说不清它和普通 map 加锁的适用场景差异。

面试官
【追问】 为什么 Go 的 map 遍历顺序每次都不同?这是 bug 吗?如果我需要稳定顺序怎么办?
候选人

一句话结论:不是 bug,是 Go 故意做的随机化;需要稳定顺序就取出 key 排序后按序遍历。

展开:随机化的目的是防止开发者写出"依赖遍历顺序"的代码。如果顺序稳定,就会有人依赖它,将来 Go 调整哈希实现时那些代码会集体崩溃——随机化从第一天就逼着大家不依赖顺序。实现上,Go 在遍历时会随机选择起始 bucket 和起始 cell 偏移量。需要有序时:把 key 收集进切片,sort.Strings / sort.Slice 排序,再按下标遍历 map。

面试官想听的关键词:故意随机化、防止依赖顺序、起始 bucket 随机、sort 排序 key、工程可预测性。

常见失分点:答"map 内部是哈希表所以无序"(只答对一半,没答出"故意随机化"这个设计意图);或者提出"用 slice 代替 map"(没回答"需要稳定顺序"这个具体问题)。

面试官
【追问】 defer 有几种实现机制?什么情况下 defer 会失去优化、性能变差?
候选人

一句话结论:三种——堆上分配(最慢)、栈上分配(快 30%)、开放编码(最快);当 defer 超过 8 个、写在循环里、或 return 数 × defer 数 > 15 时会退回到较慢的实现。

展开:堆上分配是把 defer 结构放到堆上交给 GC 管理,是默认 fallback;栈上分配在编译期能证明 defer 不逃逸时启用,省掉 GC 开销;开放编码(open-coded defers)是最激进的优化,相当于编译期把 defer 的内容内联挪到函数末尾,几乎零开销。能否优化取决于内存逃逸分析——defer 引用的数据是否逃出函数生命周期。

面试官想听的关键词:三种机制、8 个 defer 上限、循环里 defer、逃逸分析、open-coded defers、版本演进(1.13 栈上、1.14 开放编码)。

常见失分点:只知道"defer 有开销"说不出三种机制;把"循环里 defer 会堆积到函数结束"和"失去开放编码优化"混为一谈(这是两个独立问题,前者是资源释放时机问题,后者是性能问题)。

面试官
【追问】 组合和继承有什么区别?从 Java 转 Go 的开发者最容易踩什么坑?
候选人

一句话结论:组合不是继承,没有多态——Go 没有动态分派,父级方法在编译期就绑定好了,子级"重写"不会改变父级方法里的调用行为。

展开:Go 只有组合(匿名字段),没有 extends。组合带来三个特性:方法直达(s.Log())、接口继承(B 实现的接口 A 也算实现)、初始化时当普通字段。最大的坑是 Java 开发者习惯的"重写父类方法改变父类行为"在 Go 里不成立——Service 组合了 Logger,DoWork 里调用 s.Log() 在编译期就绑定到 Logger.Log,你在 Service 上再定义一个 Log 方法也不会被调用。要"可替换行为",就必须用接口字段:在结构体里放一个接口类型的字段,运行时注入不同实现。

面试官想听的关键词:无多态、无动态分派、编译期绑定、方法直达、接口字段实现替换、四种组合形态。

常见失分点:说"组合就是 Go 的继承"(概念错误);或者知道没有多态但举不出"要用接口字段替代"这个解决方案。

面试官
【追问】 用 map 实现 Set 时,为什么用 map[K]struct{} 而不是 map[K]bool?struct{} 到底占多少内存?
候选人

一句话结论:struct{} 是零字节的空结构体,百万级元素时能省下可观内存,且语义上"存在即真"比 bool 的"存 true 还是 false"更干净。

展开:Go 运行时对 struct{} 做了特殊处理,它的 unsafe.Sizeof 是 0。用 map[K]bool 每个 value 至少占 1 字节,100 万个元素就多占约 1 MB(还要算上内存对齐的padding)。更重要的是语义:map[K]struct{} 里"key 存在"就是唯一信息,不需要额外的布尔值来表达;而 bool 反而引入了一个无意义的维度——你永远只会存 true,那为什么要存?判定写法是 _, ok := m[k]。

面试官想听的关键词:空结构体零字节、内存对齐、_, ok := 判定、语义更干净、百万级数据场景、Go idiom。

常见失分点:只知道"省内存"说不出具体是 0 字节;不知道判定写法是 _, ok := m[k](写成 if m[k] {} 对 struct{} 是编译不过的)。

面试官
【基础】 goroutine 和操作系统线程有什么区别?为什么说 Go 的并发成本低?
候选人

一句话结论:goroutine 是由 Go 运行时调度的用户态"轻量线程"——初始栈只有 2KB 且可伸缩,切换不陷入内核;OS 线程栈是 MB 级、切换要内核上下文切换。一台机器开几千线程就到头,goroutine 开几十上百万很轻松。

展开:运行时的 GMP 调度器把 M 个 goroutine 复用到少量 OS 线程上:goroutine 阻塞在 channel 或网络 IO 上时,线程不会跟着阻塞,而是被调度器拿去跑别的 goroutine。这就是"启动一个 goroutine 几乎免费"的来源。本课程后面 MQ 消费者的并发处理、热点探测的 worker,都建立在这个成本模型上。

面试官想听的关键词:用户态调度、2KB 初始栈、GMP、上下文切换成本、百万级并发。

常见失分点:说"goroutine 就是更小的线程"却讲不出"谁在调度"(运行时 vs 内核);或者说不出 2KB 这个数量级。

面试官
【进阶】 channel 关闭后再读、再写会发生什么?什么时候该 close channel?
候选人

一句话结论:关闭后再读能读到零值(v, ok := <-ch 的 ok 为 false),再写会 panic;原则是"由发送方负责 close",而且重复 close 也会 panic。

展开:range ch 就是靠"读尽后 ok=false"这个机制自动退出的,所以 close 的真正作用是广播结束信号让接收方退出循环,而不是"释放资源"(不再引用的 channel GC 会回收)。多个发送方时不能随手 close——别人还在往里写,你一关对方就 panic;要么把关闭权收敛到一个 goroutine,要么引入额外的 done channel 协调。不是所有 channel 都需要 close:只做"传值"、没有"结束"语义的 channel 可以不关。

面试官想听的关键词:零值 + ok=false、发送方关闭、重复 close panic、close 是广播、range 退出机制。

常见失分点:以为 channel 关了就"锁死"不能读(其实能读到零值);或者不知道"谁写谁关、多写不关"这个原则。

面试官
【追问】 为什么 bigcache 这类缓存库把值序列化塞进 []byte 就能减轻 GC?背后的原理是什么?
候选人

一句话结论:Go 的 GC 要扫描堆上的指针才能判定对象存活——map[uint64]*Entry 里百万个条目就是百万个指针,标记耗时随条目数线性涨;塞进大块 []byte 后堆上几乎只剩一个底层数组指针,索引退化成 map[uint64]uint32 存"偏移量"整数,GC 几乎不用扫。

展开:本质是"减少堆指针“的工程思想,不是省内存——内存该多少还是多少,省的是 GC 扫描成本。代价是写入要序列化、读取要反序列化,拿 CPU 换 GC:小对象(热点帖子 id 列表这种)很划算,大对象会亏。第 09 章讲本地缓存选型时这个原理会再次出现,是同一棵知识树。

面试官想听的关键词:GC 扫指针、标记耗时、偏移量索引、CPU 换 GC、指针逃逸。

常见失分点:答”[]byte 更省内存"(方向错了,是省 GC 扫描);或不知道代价是序列化开销,说不出"什么对象不适合放进去"。


八、本章小结

学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。

复习提示:

可勾选清单(面试前 5 分钟扫一遍)

  • 工程结构:一个 Go 进程 = 一个 main 包 + 一个 main 函数;go run 开发用,go build 产二进制部署用;{ 必须写在行尾。
  • 强类型:没有隐式转换,int 和 int64 不能相加;落库字段用 int64 不用 int。
  • string:是字节序列不是字符序列;len 返回字节数;含中文一律先 []rune;range 的下标是字节位置。
  • defer:后进先出;参数传值定义时定,闭包引用执行时定;只能改有名返回值;三种实现机制(堆/栈/开放编码),超 8 个或写在循环里会退化。
  • 闭包:捕获变量使其逃逸到堆,长生命周期闭包别引用大对象。
  • 控制结构:只有 if/for/switch/select;for 一人分饰四角;for range 迭代变量是复用的(Go 1.22+ 已修复);switch 默认不穿透。
  • 切片:头部(Data/Len/Cap)+ 底层数组;判断是否共享只看有没有扩容;返回子切片会钉住整个底层数组,用 copy;扩容 cap<256 时 2 倍、否则 1.25 倍;make([]T, 0, cap) 预分配。
  • map:key 必须 comparable;nil map 可读不可写;非并发安全(会 fatal error);遍历顺序随机(故意的);判断存在用 v, ok := m[k];实现 Set 用 map[K]struct{}。
  • 接口:隐式实现(鸭子类型);由使用方定义;“接受接口,返回结构体”。
  • 结构体:遇事不决用指针接收器,接收器要保持一致;初始化永远用字段名;自引用只能用指针。
  • 组合:不是继承,没有多态;需要可替换行为就用接口字段。
  • 泛型:[T 约束],约束分类型集合和方法集合,~ 表示底层类型;适合通用工具库,业务代码别滥用。

下一章我们将进入 Gin + GORM 的世界,把这些语法基础用到真实的 Web 工程里——你会看到本章定义的 UserRepository 接口,如何演化成真正的五层架构。

复习提示:给下一章留个伏笔:本章我们为了"并发安全"给 MemoryUserRepository 加了 sync.RWMutex。下一章把它换成真实的 MySQL 之后,并发问题就交给了数据库,但会冒出新的问题:连接池、事务、N+1 查询。带着这些问题进入第 02 章,会学得更扎实。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!