Go 语言学习指南(新手完整版)
本文档是一份面向零基础/初学者的 Go 语言系统学习资料。从环境搭建开始,逐步覆盖基础语法、函数、复合数据类型、指针、结构体、接口、错误处理、包管理、并发编程、标准库、测试、泛型等全部核心主题,并配有大量可运行的代码示例(标注为"完整可运行程序"的代码块可以直接复制运行),最后附一个综合实战项目和常见坑清单。
建议学习方式:按章节顺序阅读,每个示例都亲手敲一遍并运行,每章末尾的练习尽量独立完成后再看提示。
目录
- Go 语言简介
- 环境搭建与第一个程序
- 基础语法(一):变量、常量、数据类型
- 基础语法(二):运算符、流程控制
- 函数
- 数组、切片、map 与字符串
- 指针
- 结构体与方法
- 接口
- 错误处理与 panic/recover
- 包与模块
- 并发编程
- 标准库常用包
- 文件操作实战
- 测试与代码质量
- 泛型
- 常用命令与工具
- 常见坑与最佳实践
- 综合实战:命令行待办事项管理器
- 底层原理:编译、逃逸分析与内存管理(进阶)
- 学习路线与资源推荐
第 1 章 Go 语言简介
1.1 Go 是什么
Go(又称 Golang)是由 Google 于 2007 年开始设计、2009 年正式对外发布的一门编译型、静态类型的编程语言。它的三位主要设计者是:
- Rob Pike(Unix 早期开发者、UTF-8 编码设计者之一)
- Ken Thompson(Unix 之父、B 语言发明者、C 语言前身)
- Robert Griesemer(参与过 V8 JavaScript 引擎、Java HotSpot 虚拟机)
Go 的设计目标非常明确:在保留 C 语言简洁高效的同时,解决大型软件工程中的痛点------编译慢、并发难写、依赖管理混乱、语法过于灵活导致代码风格不统一。
1.2 Go 的设计哲学
一句话概括 Go 的设计哲学:少即是多(Less is more)。
| 设计决策 | 说明 |
|---|---|
| 语法简洁 | 只有 25 个关键字,比 C(32 个)、C++(84 个)、Java(50+ 个)都少;没有类、没有继承、没有异常、没有泛型(1.18 之前) |
| 编译快速 | 编译速度快,大型项目也能秒级到分钟级编译 |
| 静态类型 | 编译期就能发现大部分类型错误 |
| 自动垃圾回收 | 不需要手动管理内存,也不存在 C 里的野指针问题 |
| 原生并发 | goroutine + channel 是语言内置特性,不是第三方库 |
| 工具链完善 | 自带格式化(gofmt)、测试(go test)、依赖管理(go mod)、文档生成(go doc) |
| 标准库强大 | 网络、文件、JSON、加密、压缩、模板等开箱即用 |
| 跨平台 | 同一份代码可编译到 Windows / Linux / macOS / ARM 等几乎所有平台 |
| 代码风格统一 | 语言层面强制统一的格式(比如左花括号不能换行),团队协作零争论 |
新手提示 :Go 语法强制统一格式是刻意的设计------编译器会拒绝不规范的写法(例如
{必须跟在语句末尾),这让所有 Go 代码看起来都差不多,阅读他人代码的成本极低。
1.3 Go 能做什么
- 后端服务 / Web API:Kubernetes、Docker、etcd、Prometheus、Grafana 等云原生基础设施全部用 Go 编写
- 微服务:gRPC、分布式系统(分布式数据库 TiDB 的存储引擎就是用 Go 写的)
- 网络编程:代理(Caddy、Traefik)、网关、消息中间件
- CLI 工具:GitHub CLI、Docker CLI、各种运维工具
- 区块链:以太坊 geth 客户端、Hyperledger Fabric 等大量使用 Go
- 数据库与中间件:InfluxDB、CockroachDB、MinIO
- 游戏服务器:很多游戏后端使用 Go 处理高并发连接
不太适合的场景:操作系统内核、驱动、嵌入式实时系统(这些领域 C/Rust 更合适)、需要重度 GUI 的桌面应用。
1.4 Go 的主要特点速览
在正式开始学习前,先建立整体印象(以下概念后续章节都会详细展开):
- 编译型语言:源码(.go 文件)通过编译器直接变成机器可执行的二进制文件,运行不需要额外环境,启动速度极快。
- 静态类型:每个变量在编译时就确定类型,类型不匹配会直接编译报错。
- 垃圾回收 :不需要
free/delete,内存由运行时自动回收。 - goroutine(协程) :用
go关键字就能启动一个轻量级并发单元,一个 Go 程序可以轻松创建数十万个 goroutine。 - channel(通道) :goroutine 之间通信的管道,Go 的并发哲学是「不要通过共享内存来通信,而要通过通信来共享内存」。
- 多返回值:函数可以返回多个值,Go 的错误处理惯例就是用「返回值 + error」。
- 接口是隐式实现 :不需要显式声明
implements,类型只要实现了接口要求的方法,就自动满足该接口(鸭子类型)。
1.5 与其他语言的简单对比
| 维度 | Go | C | Java | Python |
|---|---|---|---|---|
| 类型 | 静态 | 静态 | 静态 | 动态 |
| 编译 | 是 | 是 | 是(字节码) | 否(解释执行) |
| 内存管理 | 自动 GC | 手动 | 自动 GC | 自动 GC |
| 并发 | goroutine(极轻量) | 线程(重) | 线程(重) | 线程 + GIL 限制 |
| 语法复杂度 | 低 | 中 | 高 | 低 |
| 运行速度 | 快 | 最快 | 中 | 慢 |
| 学习曲线 | 平缓 | 陡峭 | 中等 | 平缓 |
小结:Go 是一门「上手容易、下限高(不容易写出烂代码)、上限高(能做大型基础设施)」的语言,非常适合作为第二门编程语言,也很适合作为第一门语言。
第 2 章 环境搭建与第一个程序
2.1 下载与安装
Windows
- 打开官网下载页:https://go.dev/dl
- 下载 Windows 版本的
.msi安装包(如go1.25.0.windows-amd64.msi) - 双击运行安装。默认安装到
C:\Program Files\Go;如果你的 C 盘空间紧张(比如剩余不足 10G),强烈建议在安装向导中把安装目录改到其他盘 ,例如E:\Go - 安装完成后,打开新的终端窗口,输入
go version,看到类似go version go1.25.0 windows/amd64的输出即安装成功
注意 :安装完成后需要重新打开终端才会生效,因为安装程序只是修改了系统环境变量,已打开的终端不会自动刷新。
macOS
bash
# 方式一:官网下载 .pkg 安装包
# 方式二:Homebrew
brew install go
Linux
bash
# 以 Ubuntu/Debian 为例(apt 源里的版本可能较旧,推荐官网 tar 包)
wget https://go.dev/dl/go1.25.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz
# 将以下两行加入 ~/.bashrc
export PATH=$PATH:/usr/local/go/bin
2.2 环境变量
Go 依赖几个环境变量,绝大多数在安装时已自动配置好,新手了解即可:
| 变量 | 作用 | 默认值 |
|---|---|---|
GOROOT |
Go 安装目录(编译器、标准库所在位置) | Windows 安装时自动设置,一般无需手动改 |
GOPATH |
工作区目录(模块缓存、go install 产物的存放位置) |
%USERPROFILE%\go(即 C:\Users\你的用户名\go) |
GOPROXY |
模块下载代理地址 | 默认 https://proxy.golang.org,direct |
重要建议(C 盘空间紧张的 Windows 用户) :
GOPATH默认在 C 盘,它的子目录pkg\mod会缓存大量第三方依赖,可能占用数 GB 空间。建议执行以下命令把它移到其他盘:
powershell# PowerShell 中执行(永久设置当前用户环境变量) [Environment]::SetEnvironmentVariable("GOPATH", "E:\go", "User")然后重新打开终端 ,
go env GOPATH应输出E:\go。
国内用户必配:模块代理
由于网络原因,直接访问官方模块代理(proxy.golang.org)可能很慢或失败。国内用户建议设置代理(任选其一):
bash
# 方式一:七牛云代理(推荐,稳定)
go env -w GOPROXY=https://goproxy.cn,direct
# 方式二:阿里云代理
go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/,direct
# 查看当前配置
go env GOPROXY
go env -w 会把配置永久写入用户级配置文件,之后所有项目生效。
2.3 编辑器选择
新手推荐 VS Code + Go 官方扩展:
- 安装 VS Code:https://code.visualstudio.com
- 在扩展商店搜索安装 Go (发布者为
golang.go官方扩展) - 第一次打开 .go 文件时,VS Code 会提示安装 Go 工具链(gopls 等),点击安装即可
- 安装完成后享受:自动补全、语法高亮、保存自动格式化、错误提示、跳转定义、调试
其他选择:GoLand(JetBrains 出品,功能最强但收费)、Vim/Neovim + gopls 插件。
2.4 第一个程序:Hello, World!
新建一个文件夹(例如 E:\code\hello),在里面创建文件 main.go,输入以下内容:
go
// 完整可运行程序:Hello World
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
逐行解释:
| 行 | 说明 |
|---|---|
package main |
声明这个文件属于 main 包。package main 是可执行程序的入口包,编译器看到它就会生成可执行文件 |
import "fmt" |
导入标准库中的 fmt 包(format 的缩写),它提供格式化输入输出函数 |
func main() |
定义 main 函数------程序的入口,程序从这里开始执行 |
fmt.Println(...) |
调用 fmt 包的 Println 函数,打印一行文字并换行 |
// |
行注释,以 // 开头到行尾的内容是注释,不参与执行 |
运行程序
在终端中进入 main.go 所在目录,执行:
bash
go run main.go
输出:
Hello, World!
go run 会编译并直接运行,适合开发调试。更常用的编译流程见下。
2.5 go build:编译成可执行文件
bash
# 编译当前目录下的 main 包,生成可执行文件
go build
# 指定输出文件名
go build -o hello.exe
# 编译并输出到指定目录
go build -o E:\bin\hello.exe
Windows 下会生成 .exe 文件,直接双击或命令行运行即可,运行不需要安装 Go 环境(这是编译型语言的优势之一------部署就是拷贝一个二进制文件)。
交叉编译(在 Windows 上编译 Linux 程序)
Go 支持在一个平台编译另一个平台的程序,这是它的招牌特性之一:
bash
# 在 Windows 上编译 Linux amd64 程序
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o hello-linux
# (PowerShell 语法;bash 用:GOOS=linux GOARCH=amd64 go build -o hello-linux)
# 编译 macOS 程序
$env:GOOS="darwin"; $env:GOARCH="amd64"; go build -o hello-mac
GOOS是目标操作系统(windows/linux/darwin 等),GOARCH是目标 CPU 架构(amd64/arm64 等)。交叉编译是 Go 开发者的日常操作,部署服务器时非常方便。
2.6 项目目录结构(模块模式)
Go 从 1.11 开始引入**模块(module)**机制,从 1.16 开始默认开启。一个 Go 项目(模块)的标准形态:
myproject/
├── go.mod # 模块定义文件(模块名、Go 版本、依赖列表)
├── main.go # 入口文件(package main)
└── internal/ # 其他包目录(目录名即包名来源之一)
└── utils/
└── helper.go
创建模块的命令:
bash
# 在项目根目录执行;模块名一般用"域名/项目名"或简单名称
go mod init hello
执行后会生成 go.mod 文件,内容类似:
module hello
go 1.25.0
module hello 声明了模块名。后续第 11 章会详细讲解模块与依赖管理。
本章练习:
- 完成 Hello World 的编写与运行。
- 用
go build编译出可执行文件并直接运行它。 - 试着把
fmt.Println("Hello, World!")改成打印你自己的名字。 - (选做)尝试交叉编译一个 Linux 版本。
第 3 章 基础语法(一):变量、常量、数据类型
从本章开始进入正式的语法学习。Go 的语法刻意设计得紧凑简洁,请务必亲手运行每一个示例。
3.1 程序基本结构回顾
一个 Go 源文件的基本骨架:
go
// 包声明(每个 .go 文件的第一行有效代码)
package main
// 导入其他包
import "fmt"
// 常量、变量、函数、类型定义
func main() {
// 函数体
}
语法硬性规则(违反会直接编译报错,记牢):
- 包声明必须在文件最前面(注释除外)
import必须在包声明之后、其他代码之前- 左花括号
{必须与func/if/for等在同一行,不能换行 - 语句末尾不需要分号(编译器自动补全;当然你也可以写,但不建议)
main函数没有参数、没有返回值
3.2 注释
go
// 这是行注释:从 // 到行尾
/*
这是块注释:
可以跨多行,
常用于包、函数的说明文档。
*/
惯例 :Go 官方推荐用
//行注释。写在函数、类型上方的注释会被go doc工具提取为文档,首单词应该是被注释对象的名字。例如:
go// Add 返回 a 与 b 的和。 func Add(a, b int) int { return a + b }
3.3 变量
3.3.1 声明变量的三种方式
方式一:var 声明,显式类型
go
var name string // 声明一个 string 类型的变量,值为零值 ""
var age int // 声明一个 int 类型变量,值为零值 0
方式二:var 声明并初始化
go
var name string = "Alice"
var age int = 30
方式三:短变量声明 :=(最常用)
go
name := "Alice" // 类型由编译器根据右边的值自动推断为 string
age := 30 // 推断为 int
:= 是 Go 的特色语法:声明并初始化 一步完成,类型自动推断。函数体内强烈推荐使用 :=。注意 := 只能用在函数内部(包级变量只能用 var)。
新手常见困惑 :
=是赋值(变量必须已存在),:=是声明+赋值(创建新变量)。两者完全不同。
go
x := 10 // 声明 x
x = 20 // 赋值,正确
// x := 20 // 错误:x 已存在,不能重复声明(同一作用域内)
3.3.2 一次声明多个变量
go
var a, b, c int // 同时声明三个 int
var x, y = 1, "two" // 不同类型也可以,类型各自推断
m, n := 10, 20 // 短声明同样支持多变量
交换两个变量的值(Go 的语法糖,非常常用):
go
a, b := 1, 2
a, b = b, a // 交换,无需临时变量
3.3.3 零值(zero value)
Go 中声明但不初始化的变量会自动获得"零值",不存在"未初始化"的野变量:
| 类型 | 零值 |
|---|---|
| 数值类型(int、float 等) | 0 |
| bool | false |
| string | ""(空字符串) |
| 指针、接口、切片、map、函数、channel | nil |
go
var i int // 0
var f float64 // 0
var b bool // false
var s string // ""
var p *int // nil
var sl []int // nil
var m map[int]int // nil
零值是 Go 的一个贴心设计:声明即安全可用,避免了 C 语言中"未初始化变量"的未定义行为。
3.3.4 变量的作用域
- 包级变量:声明在函数外,整个包内可见
- 函数内变量:从声明处到所在代码块结束
- 代码块变量 :
if、for等语句内声明的变量只在该块内有效
go
package main
import "fmt"
var global = "我是包级变量" // 包级变量
func main() {
msg := "我是函数内变量"
if true {
inner := "我是 if 块内变量"
fmt.Println(inner) // 可以访问
}
// fmt.Println(inner) // 编译错误:inner 在这里不可见
fmt.Println(msg) // 可以访问
fmt.Println(global) // 可以访问
}
3.3.5 变量遮蔽(shadowing)
内层作用域可以声明与外层同名的变量,此时内层变量"遮蔽"外层变量:
go
x := 10
if true {
x := 20 // 这是新变量,与外面的 x 无关
fmt.Println(x) // 20
}
fmt.Println(x) // 10
新手常见的坑:在 if / for 里用 := 意外创建了新变量而不是修改外层变量。如果只想赋值,用 =。
3.4 常量
3.4.1 基本用法
go
const pi = 3.14159 // 无类型常量
const name string = "Go" // 有类型常量
const (
a = 1
b = 2
)
常量的特点:
- 用
const声明,一旦定义不可修改 - 可以不给类型,由使用场景决定类型(无类型常量)
- 常量可以是:字符串、布尔、数值(整数、浮点、复数)
go
const x = 5
// x = 6 // 编译错误:cannot assign to x
3.4.2 常量的自动重复
在 const 块中,如果某一行省略了「= 值」,它会复用上一行的表达式:
go
const (
a = 10
b // b = 10
c // c = 10
)
3.4.3 iota:枚举计数器
iota 是 Go 内置的常量计数器,在 const 块中从 0 开始,每出现一行(无论是否使用)自动加 1:
go
const (
Sunday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
用 _ 跳过不需要的值:
go
const (
_ = iota // 0,被丢弃
KB // 1
MB // 2
GB // 3
)
iota 参与表达式计算的经典用法(二进制位运算,第 4 章会讲位运算符):
go
const (
_ = iota // 0,丢弃
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB = 1 << (10 * iota) // 1 << 20 = 1048576
GB = 1 << (10 * iota) // 1 << 30 = 1073741824
TB = 1 << (10 * iota) // 1 << 40
)
把 iota 与位移组合起来的完整程序如下(直接复制即可运行):
go
// 完整可运行程序:验证 iota 枚举与位移
package main
import "fmt"
// iota 在 const 块中从 0 开始逐行递增
const (
Sunday = iota // 0
Monday // 1
Tuesday // 2
)
// iota 参与表达式:1 << (10*iota)
const (
_ = iota // 0,丢弃
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB = 1 << (10 * iota) // 1 << 20 = 1048576
GB = 1 << (10 * iota) // 1 << 30 = 1073741824
TB = 1 << (10 * iota) // 1 << 40
)
func main() {
fmt.Println(KB, MB, GB) // 输出:1024 1048576 1073741824
fmt.Println(Sunday, Monday, Tuesday) // 输出:0 1 2
}
3.5 基本数据类型
Go 的基本类型分为四组:
3.5.1 布尔型 bool
只有两个值:true 和 false。默认零值 false。
go
var ok bool = true
var no bool // false
3.5.2 数值类型
整数(按位数和有无符号划分):
| 类型 | 位数 | 取值范围 |
|---|---|---|
int |
平台相关(64 位系统上 64 位) | 有符号 |
int8 |
8 | -128 ~ 127 |
int16 |
16 | -32768 ~ 32767 |
int32 |
32 | -2147483648 ~ 2147483647 |
int64 |
64 | -9223372036854775808 ~ 9223372036854775807 |
uint |
平台相关 | 无符号 |
uint8 |
8 | 0 ~ 255 |
uint16 |
16 | 0 ~ 65535 |
uint32 |
32 | 0 ~ 4294967295 |
uint64 |
64 | 0 ~ 18446744073709551615 |
uintptr |
平台相关 | 用于存放指针的整数,一般不用 |
日常编程 90% 的场景用 int 就够了。需要明确小范围整数时用 int8/int16(例如存储年龄、温度),需要大数或非负数(如时间戳、文件大小)时用 int64/uint64。
浮点数:
| 类型 | 说明 |
|---|---|
float32 |
单精度,约 6-7 位有效数字 |
float64 |
双精度,约 15-17 位有效数字(默认推荐) |
go
var price float64 = 19.99
pi := 3.14159 // 浮点字面量默认推断为 float64
复数(Go 原生支持):
go
var c complex128 = 1 + 2i
// 实部 1,虚部 2
两个重要的别名类型:
| 类型 | 实际类型 | 用途 |
|---|---|---|
byte |
uint8 的别名 |
表示一个字节,常用于处理二进制数据 |
rune |
int32 的别名 |
表示一个 Unicode 码点(字符),常用于处理中文等文本 |
新手必知 :Go 源码中的字符串是 UTF-8 编码的。
byte处理「字节」,rune处理「字符」。一个中文字符在 UTF-8 编码下占 3 个字节,因此len("你好")是 6 而不是 2(详见第 6 章字符串一节)。
3.5.3 字符串 string
Go 的字符串是不可变的字节序列(UTF-8 编码):
go
var s1 string = "hello" // 双引号字符串,支持转义
var s2 = `hello
world` // 反引号字符串(raw string):原样保留换行和特殊字符
s3 := "你好,Go" // 中文字符串
转义字符示例:\n(换行)、\t(制表符)、\"(双引号)、\\(反斜杠)。
go
// 完整可运行程序:字符串基本操作
package main
import "fmt"
func main() {
raw := `第一行
第二行\t这里不会转义`
fmt.Println(raw)
// 输出:
// 第一行
// 第二行\t这里不会转义
escaped := "第一行\n第二行\t这里会转义"
fmt.Println(escaped)
// 输出:
// 第一行
// 第二行 这里会转义
// 字符串拼接
name := "Go"
greeting := "Hello, " + name + "!"
fmt.Println(greeting) // Hello, Go!
// 取长度(注意:返回的是字节数!)
fmt.Println(len("abc")) // 3
fmt.Println(len("你好")) // 6(每个汉字 3 字节)
}
3.5.4 字符串、rune、byte 的关系(重点)
go
// 完整可运行程序:字节与字符
package main
import "fmt"
func main() {
s := "你好Go"
fmt.Println(len(s)) // 8 = 3 + 3 + 1 + 1(字节数)
// range 遍历字符串时,自动按 rune(字符)迭代
for i, r := range s {
fmt.Printf("索引 %d,字符 %c,码点 %U\n", i, r, r)
}
// 输出:
// 索引 0,字符 你,码点 U+4F60
// 索引 3,字符 好,码点 U+597D
// 索引 6,字符 G,码点 U+0047
// 索引 7,字符 o,码点 U+006F
// 转成 rune 切片后,就可以按字符下标访问
runes := []rune(s)
fmt.Println(string(runes[1])) // 好
}
3.6 类型转换
Go 是强类型语言,不同类型之间不会自动转换,必须显式转换。
go
// 完整可运行程序:类型转换
package main
import (
"fmt"
"strconv"
)
func main() {
var i int = 42
var f float64 = float64(i) // int -> float64,显式转换
fmt.Println(f) // 42
f2 := 3.99
var i2 int = int(f2) // float64 -> int,小数部分被截断(不是四舍五入!)
fmt.Println(i2) // 3
var b byte = 65
fmt.Println(string(b)) // 把字节转成字符串,输出 "A"
// 整数与字符串互转(标准库 strconv 包,第 6 章详细介绍)
numStr := "123"
n, _ := strconv.Atoi(numStr) // 字符串 -> int
fmt.Println(n + 1) // 124
s := strconv.Itoa(456) // int -> 字符串
fmt.Println(s + "!") // 456!
}
注意 :这个程序用到了
strconv包,导入多个包时用圆括号分组书写,且必须全部被使用(有导入但没用会编译报错------这是 Go 的严格之处)。从第 4 章开始,示例都会给出完整 import。
类型转换的坑
go
// 错误示范:int 与 float 直接运算
// var x int = 5
// var y float64 = 2.5
// fmt.Println(x + y) // 编译错误:mismatched types int and float64
// 正确做法:先统一类型
var x int = 5
var y float64 = 2.5
fmt.Println(float64(x) + y) // 7.5
数值溢出:把超出目标类型范围的值转换过去,会得到错误结果(Go 在编译期能发现「常量」溢出,但变量转换的溢出发生在运行时,不会报错,只是值不对):
go
var big int64 = 300
var small int8 = int8(big) // 300 超出 int8 范围(-128~127),结果不可预期
fmt.Println(small) // 输出 44(发生截断,初学者不用深究,知道别这么写就行)
3.7 类型别名与自定义类型
类型别名(type alias)
给已有类型起个新名字,两者完全等价(主要用于代码兼容性,日常少用):
go
type MyInt = int // 别名:MyInt 就是 int
var a MyInt = 10
var b int = a // 可以直接赋值,因为它们是同一个类型
自定义类型(defined type)
创建一个基于已有类型的新类型 ,两者不是同一个类型(第 7 章会看到它的威力------可以给新类型定义方法):
go
type Celsius float64 // 自定义类型:摄氏温度
type Fahrenheit float64
var c Celsius = 25.0
// var f Fahrenheit = c // 编译错误:Celsius 与 Fahrenheit 是不同的类型
var f Fahrenheit = Fahrenheit(c) // 必须显式转换
新类型拥有底层类型的运算能力(可以做加减乘除),但赋值、比较时必须显式转换。
3.8 变量的打印与调试:fmt 包速览
fmt 包是学习阶段用得最多的包,先掌握它的核心函数:
| 函数 | 作用 |
|---|---|
fmt.Println(a, b) |
打印并用空格分隔,末尾换行 |
fmt.Print(a, b) |
打印,不换行 |
fmt.Printf(format, ...) |
按格式字符串打印,不自动换行 |
fmt.Sprintf(format, ...) |
返回格式化后的字符串(不打印,用于拼字符串) |
Printf 常用格式化动词(占位符):
| 动词 | 含义 | 示例输出 |
|---|---|---|
%v |
按默认格式打印值 | 42 |
%+v |
打印结构体时带字段名 | {Name:Alice Age:30} |
%#v |
按 Go 语法格式打印(调试神器) | main.Person{Name:"Alice", Age:30} |
%T |
打印值的类型 | int |
%d |
十进制整数 | 42 |
%f |
浮点数 | 3.140000 |
%.2f |
浮点数保留 2 位小数 | 3.14 |
%e |
科学计数法 | 3.140000e+00 |
%s |
字符串 | hello |
%q |
带引号的字符串(调试时避免看不见空白) | "hello" |
%c |
字符(rune) | A |
%x |
十六进制(数字/字节) | 1f |
%t |
布尔值 | true |
%p |
指针地址 | 0xc0000b2008 |
%% |
百分号本身 | % |
go
// 完整可运行程序:fmt 格式化
package main
import "fmt"
func main() {
name, age, score := "Alice", 30, 98.5
// 一次打印多个值,自动加空格
fmt.Println(name, age, score) // Alice 30 98.5
// 格式化输出
fmt.Printf("姓名:%s,年龄:%d,成绩:%.1f 分\n", name, age, score)
// 姓名:Alice,年龄:30,成绩:98.5 分
// 查看类型
fmt.Printf("name 的类型是 %T\n", name) // name 的类型是 string
// %v 与 %#v 的区别(第 7 章学了结构体后会更常用)
nums := []int{1, 2, 3}
fmt.Printf("%v\n", nums) // [1 2 3]
fmt.Printf("%#v\n", nums) // []int{1, 2, 3}
}
fmt 包的输入函数 (后面示例会用到):fmt.Scanln(&x) 从键盘读取一行输入存入变量 x。注意 & 是取地址符号(第 7 章详细讲),因为 Scanln 需要修改变量的值。
本章小结
- 变量声明:
var x int/var x int = 5/x := 5(函数内推荐) - 常量:
const+iota实现枚举 - 类型:bool、整数(int 系/uint 系)、浮点(float32/64)、复数、string、byte、rune
- 零值:声明即安全,数值 0 / 字符串 "" / 布尔 false / 其他 nil
- 强类型:类型转换必须显式
T(x) fmt.Printf的%v、%T、%d、%s、%f要熟练
本章练习:
- 声明一个
const块,用iota定义一周七天。 - 声明三个不同类型的变量,用
fmt.Printf打印它们的值和类型。 - 定义一个
Celsius自定义类型,写一行代码把Celsius(100)转成Fahrenheit(公式 F = C * 9/5 + 32)。 - 用
%#v打印一个字符串切片[]string{"a","b"},观察输出。
第 4 章 基础语法(二):运算符、流程控制
4.1 运算符
4.1.1 算术运算符
| 运算符 | 含义 | 示例 |
|---|---|---|
+ |
加(数字相加 / 字符串拼接) | 1 + 2 → 3;"a" + "b" → "ab" |
- |
减 | 5 - 2 → 3 |
* |
乘 | 3 * 4 → 12 |
/ |
除(整数相除结果还是整数,向零截断) | 7 / 2 → 3;7.0 / 2 → 3.5 |
% |
取余(仅整数) | 7 % 3 → 1 |
重点:整数除法与取余
go
// 完整可运行程序:整数除法陷阱
package main
import "fmt"
func main() {
fmt.Println(7 / 2) // 3(不是 3.5!两个整数相除,结果是整数)
fmt.Println(7.0 / 2) // 3.5(有一个是浮点数,结果是浮点数)
fmt.Println(7 % 3) // 1(取余数)
fmt.Println(-7 % 3) // -1(结果的符号跟随被除数)
// 判断一个数是否为偶数
n := 10
if n%2 == 0 {
fmt.Println(n, "是偶数")
}
}
4.1.2 比较运算符
返回 bool 值:==(相等)、!=(不等)、<、>、<=、>=。
go
fmt.Println(1 == 1) // true
fmt.Println(1 != 2) // true
fmt.Println(2 > 3) // false
注意 :比较的两个值类型必须一致;切片、map、函数不能直接用 == 比较 (只能与 nil 比较);字符串可以比较(按字典序)。
4.1.3 逻辑运算符
| 运算符 | 含义 | 说明 |
|---|---|---|
&& |
逻辑与 | 两边都为 true 才为 true;左边为 false 时不计算右边(短路) |
| ` | ` | |
! |
逻辑非 | 取反 |
go
age := 25
name := "Alice"
if age >= 18 && age < 60 {
fmt.Println("成年且未退休")
}
if name == "Alice" || name == "Bob" {
fmt.Println("是 Alice 或 Bob")
}
if !(age < 18) {
fmt.Println("已成年")
}
4.1.4 位运算符(处理二进制位,底层开发常用)
| 运算符 | 含义 | 示例 |
|---|---|---|
& |
按位与 | 5 & 3 → 1(101 & 011 = 001) |
| ` | ` | 按位或 |
^ |
按位异或 | 5 ^ 3 → 6(101 ^ 011 = 110) |
&^ |
按位清空(a 中清除 b 为 1 的位) | 5 &^ 3 → 4(101 &^ 011 = 100) |
<< |
左移(相当于乘以 2 的 n 次方) | 1 << 3 → 8 |
>> |
右移(相当于除以 2 的 n 次方) | 16 >> 2 → 4 |
go
// 完整可运行程序:位运算
package main
import "fmt"
func main() {
a, b := 0b101, 0b011 // 0b 前缀表示二进制字面量(Go 1.13+)
fmt.Printf("a=%04b b=%04b\n", a, b)
fmt.Printf("a&b = %04b (%d)\n", a&b, a&b) // 0001 (1) 都是 1 才为 1
fmt.Printf("a|b = %04b (%d)\n", a|b, a|b) // 0111 (7) 有一个 1 即为 1
fmt.Printf("a^b = %04b (%d)\n", a^b, a^b) // 0110 (6) 不相同才为 1
fmt.Printf("a&^b = %04b (%d)\n", a&^b, a&^b) // 0100 (4) b 的 1 位在 a 中被清掉
// 位移的典型用途:权限标记(每个 bit 代表一种权限)
const (
Read = 1 << iota // 1 (001)
Write // 2 (010)
Exec // 4 (100)
)
perm := Read | Write // 001 | 010 = 011,拥有读写权限
fmt.Printf("perm=%04b\n", perm)
fmt.Println("可读?", perm&Read != 0) // true
fmt.Println("可执行?", perm&Exec != 0) // false
}
位运算在权限系统、状态标志、协议解析、性能优化中非常常见,新手先理解
& | ^ << >>的含义即可。
4.1.5 赋值运算符
=、+=、-=、*=、/=、%=、<<=、>>=、&=、|=、^=:
go
x := 10
x += 5 // x = 15(等价于 x = x + 5)
x *= 2 // x = 30
4.1.6 自增自减:++ 和 --
go
i := 0
i++ // i = 1
i-- // i = 0
Go 的特殊规则 :++ / -- 是语句而不是表达式,因此不能写在表达式中:
go
// j := i++ // 编译错误:i++ 不是表达式
// fmt.Println(i++) // 编译错误
// 只能单独成行使用
4.1.7 运算符优先级(从高到低)
5 * / % << >> & &^
4 + - | ^
3 == != < <= > >=
2 &&
1 ||
惯例:不要依赖记忆优先级,复杂的表达式用括号明确意图,可读性第一。
go
result := (2 + 3) * 4 // 20,加了括号一目了然
4.2 流程控制:if / else
Go 的 if 有两个特点:条件不需要括号 ;支持初始化语句。
go
// 完整可运行程序:if 的三种形态
package main
import "fmt"
func main() {
score := 85
// 形态一:基本 if-else
if score >= 90 {
fmt.Println("优秀")
} else if score >= 60 {
fmt.Println("及格")
} else {
fmt.Println("不及格")
}
// 形态二:初始化语句 + 条件(变量作用域仅限 if 块内)
if n := score * 2; n > 100 {
fmt.Println("翻倍后超过 100:", n)
} // 在这里 n 已经不可见了
// 形态三:单行 if(条件不必换行)
if score < 60 {
fmt.Println("需要补考")
}
}
if 的规则:
- 条件表达式必须返回 bool(不能写
if score这种,Go 不像 C 那样把非 0 当 true) else if/else必须与前面的右花括号在同一行
4.3 流程控制:switch
Go 的 switch 比 C/Java 更强大且用法不同:
- 每个 case 默认自动 break ,不需要手动写
break - case 可以是任意表达式(不限于常量)
- case 从上到下匹配,命中即结束
- 需要穿透时用
fallthrough
go
// 完整可运行程序:switch 基础
package main
import "fmt"
func main() {
day := 3
switch day {
case 1:
fmt.Println("星期一")
case 2, 3, 4, 5: // 多个值共用一个 case
fmt.Println("工作日")
case 6, 7:
fmt.Println("周末")
default: // 都不匹配时执行
fmt.Println("无效的星期")
}
// 输出:工作日
}
switch 的初始化语句(与 if 相同):
go
// 完整可运行程序:switch 带初始化语句与表达式 case
package main
import "fmt"
func main() {
switch score := 88; {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B")
case score >= 60:
fmt.Println("C")
default:
fmt.Println("D")
}
// 输出:B
}
无表达式的 switch (switch { ... } 相当于 if-else 链的优雅写法,上面的例子就是这种)。
fallthrough(显式穿透到下一个 case,注意:无条件直接执行下一个 case 体,且不会重新判断条件):
go
// 完整可运行程序:fallthrough
package main
import "fmt"
func main() {
n := 1
switch n {
case 1:
fmt.Println("case 1")
fallthrough
case 2:
fmt.Println("case 2(被 fallthrough 强制执行)")
default:
fmt.Println("default")
}
// 输出:
// case 1
// case 2(被 fallthrough 强制执行)
}
type switch(判断接口的动态类型,第 9 章详讲,先混个眼熟):
go
var v interface{} = 42
switch t := v.(type) {
case int:
fmt.Println("是 int:", t)
case string:
fmt.Println("是 string:", t)
default:
fmt.Printf("未知类型 %T\n", t)
}
4.4 流程控制:for 循环
Go 只有 for 一种循环关键字 (没有 while、没有 do-while),但 for 有四种形态。
形态一:经典三段式(初始化;条件;步进)
go
// 完整可运行程序:for 基本用法
package main
import "fmt"
func main() {
// 打印 1 到 5
for i := 1; i <= 5; i++ {
fmt.Println(i)
}
}
形态二:只有条件(相当于 while)
go
n := 0
for n < 3 {
fmt.Println(n)
n++
}
// 输出 0 1 2
形态三:无限循环
go
for {
// 一直执行,直到 break 跳出
break
}
形态四:range 循环(遍历数组、切片、map、字符串,最重要!)
go
// 完整可运行程序:range 遍历
package main
import "fmt"
func main() {
// 遍历切片:i 是索引,v 是值
fruits := []string{"苹果", "香蕉", "橙子"}
for i, v := range fruits {
fmt.Printf("索引 %d:%s\n", i, v)
}
// 只要值,忽略索引
for _, v := range fruits {
fmt.Println(v)
}
// 只要索引
for i := range fruits {
fmt.Println(i)
}
// 遍历 map(顺序不固定!)
scores := map[string]int{"语文": 90, "数学": 95}
for subject, score := range scores {
fmt.Println(subject, score)
}
// 遍历字符串(按 rune,即字符)
for _, ch := range "Go语言" {
fmt.Printf("%c ", ch) // G o 语 言
}
}
break 与 continue
break:跳出当前循环(或 switch)continue:跳过本次循环的剩余部分,进入下一次迭代
go
// 完整可运行程序:break 与 continue
package main
import "fmt"
func main() {
// continue:只打印奇数
for i := 1; i <= 10; i++ {
if i%2 == 0 {
continue // 跳过偶数
}
fmt.Print(i, " ")
}
fmt.Println()
// 输出:1 3 5 7 9
// break:找到第一个能被 7 整除的数就停
for i := 1; ; i++ {
if i%7 == 0 {
fmt.Println("找到:", i)
break
}
}
// 输出:找到:7
}
标签(label):跳出多层循环
多层循环嵌套时,break 只能跳出最内层。用标签可以跳出指定层:
go
// 完整可运行程序:带标签的 break
package main
import "fmt"
func main() {
outer: // 标签(标签后必须紧跟循环语句)
for i := 1; i <= 3; i++ {
for j := 1; j <= 3; j++ {
if i*j > 4 {
fmt.Printf("在 i=%d, j=%d 时跳出外层循环\n", i, j)
break outer // 跳出 outer 这个外层循环
}
fmt.Printf("i=%d j=%d\n", i, j)
}
}
fmt.Println("循环结束")
}
goto
Go 支持 goto,但日常编程几乎不用(滥用会破坏可读性)。了解一下即可:
go
i := 0
loop:
i++
if i < 3 {
goto loop
}
fmt.Println(i) // 3
4.5 本章小结
- 运算符:注意整数除法、
%取余、++是语句不是表达式、短路求值 if:条件不加括号、支持if 初始化语句; 条件switch:case 自动 break、多值 case、fallthrough、无表达式 switchfor:四种形态(三段式 / while / 无限 / range)break/continue/ 标签 /goto
本章练习:
- 用 for 循环计算 1 到 100 的和。
- 用 for + continue 打印 1~50 之间所有能被 3 整除的数。
- 用 switch 把 1~5 映射为中文「一」到「五」。
- 写一个嵌套循环,用标签在 i+j 大于 10 时提前退出。
第 5 章 函数
函数是 Go 程序的基本组织单元。Go 的函数有几个与众不同的特点:支持多返回值 、函数是一等公民 (可以赋值给变量、作为参数传递)、defer 机制。
5.1 函数的定义与调用
go
// 基本结构:func 函数名(参数列表) 返回值列表 { 函数体 }
func add(a int, b int) int {
return a + b
}
调用:
go
sum := add(3, 5) // sum = 8
完整可运行示例:
go
// 完整可运行程序:函数定义与调用
package main
import "fmt"
// 无参数、无返回值
func sayHello() {
fmt.Println("你好!")
}
// 一个参数
func greet(name string) {
fmt.Println("你好,", name)
}
// 两个参数、一个返回值
func add(a int, b int) int {
return a + b
}
// 连续相同类型可以合并写法:a, b int 等价于 a int, b int
func multiply(a, b int) int {
return a * b
}
func main() {
sayHello() // 你好!
greet("小明") // 你好, 小明
fmt.Println(add(3, 5)) // 8
fmt.Println(multiply(4, 6)) // 24
}
5.2 参数传递:值传递
Go 的参数传递全部是值传递 :函数拿到的是实参的副本,函数内修改参数不会影响外面的变量。
go
// 完整可运行程序:值传递
package main
import "fmt"
func change(x int) {
x = 100 // 修改的是副本
}
func main() {
n := 10
change(n)
fmt.Println(n) // 10,外面的 n 没有被修改
}
想通过函数修改外部变量怎么办?两个办法:传指针 (第 7 章)或通过返回值(Go 更推荐后者)。Go 风格是「谁的数据谁负责修改,需要新值就返回新值」。
5.3 多返回值(Go 的招牌特性)
一个函数可以返回多个值,最常见的用途是「结果 + 错误」:
go
// 完整可运行程序:多返回值
package main
import (
"errors"
"fmt"
)
// 返回商和余数
func divMod(a, b int) (int, int) {
return a / b, a % b
}
// 返回结果和错误(Go 的错误处理惯例)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为 0")
}
return a / b, nil
}
func main() {
q, r := divMod(17, 5)
fmt.Printf("商 %d,余数 %d\n", q, r) // 商 3,余数 2
// 只要其中一个返回值,用 _ 丢弃另一个
_, rem := divMod(17, 5)
fmt.Println("余数:", rem) // 2
result, err := divide(10, 0)
if err != nil {
fmt.Println("出错了:", err) // 出错了: 除数不能为 0
} else {
fmt.Println("结果:", result)
}
}
errors.New("...")创建一条错误消息(第 10 章详细讲解错误处理)。_(下划线)是 Go 的空白标识符,用于丢弃不需要的值。
5.4 命名返回值
可以在函数签名里给返回值起名字 ,函数体直接给它们赋值,最后用裸 return 返回:
go
// 完整可运行程序:命名返回值
package main
import "fmt"
// 返回值被命名为 sum 和 product
func calc(a, b int) (sum int, product int) {
sum = a + b // 直接给命名返回值赋值
product = a * b
return // 裸 return:返回 sum 和 product 的当前值
}
func main() {
s, p := calc(3, 4)
fmt.Println(s, p) // 7 12
}
命名返回值的好处:
- 函数内可以直接使用返回值变量
- 配合 defer 可以修改返回值(见 5.8 节,这是 defer 的高级用法)
- 提高代码可读性(返回值有了名字,等于自带文档)
注意 :裸
return只在函数所有返回值都有名字时才允许。
5.5 可变参数(...)
用 ...类型 声明可变参数,函数可以接收任意数量的该类型参数。可变参数在函数内是一个切片。
go
// 完整可运行程序:可变参数
package main
import "fmt"
// nums ...int 表示接收任意多个 int,函数内 nums 是 []int
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum()) // 0(0 个参数也可以)
fmt.Println(sum(1)) // 1
fmt.Println(sum(1, 2, 3)) // 6
fmt.Println(sum(1, 2, 3, 4, 5)) // 15
// 把一个切片展开传入:切片名后加 ...
numbers := []int{10, 20, 30}
fmt.Println(sum(numbers...)) // 60
}
fmt.Println本身就是可变参数函数:func Println(a ...any)。
5.6 函数是一等公民
函数可以像普通值一样:赋值给变量、作为参数传递、作为返回值。
go
// 完整可运行程序:函数作为值
package main
import "fmt"
// 函数类型:func(int, int) int
func add(a, b int) int { return a + b }
func sub(a, b int) int { return a - b }
// 高阶函数:接收一个函数作为参数
func apply(f func(int, int) int, a, b int) int {
return f(a, b)
}
// 返回一个函数
func makeMultiplier(factor int) func(int) int {
return func(x int) int {
return x * factor
}
}
func main() {
// 函数赋值给变量
var f func(int, int) int = add
fmt.Println(f(3, 4)) // 7
// 作为参数传递(多态效果:同一个 apply,行为由传入的函数决定)
fmt.Println(apply(add, 10, 5)) // 15
fmt.Println(apply(sub, 10, 5)) // 5
// 函数作为返回值
double := makeMultiplier(2)
fmt.Println(double(10)) // 20
}
5.7 匿名函数与闭包
匿名函数
没有名字的函数,可以直接定义并调用,或赋值给变量:
go
// 完整可运行程序:匿名函数
package main
import "fmt"
func main() {
// 直接定义并调用
func() {
fmt.Println("我是匿名函数,立刻执行")
}()
// 带参数的匿名函数
func(name string) {
fmt.Println("你好,", name)
}("小明")
// 赋值给变量
double := func(x int) int { return x * 2 }
fmt.Println(double(21)) // 42
}
闭包(closure)
闭包 = 函数 + 它捕获的外部变量。匿名函数可以引用定义它的作用域中的变量,即使外层函数已经返回,这些变量依然存活(因为它们被闭包引用着)。
go
// 完整可运行程序:闭包
package main
import "fmt"
// counter 返回一个闭包:每次调用,内部计数 n 加 1
func counter() func() int {
n := 0 // n 被闭包捕获,跨多次调用保持
return func() int {
n++
return n
}
}
func main() {
c1 := counter()
fmt.Println(c1()) // 1
fmt.Println(c1()) // 2
fmt.Println(c1()) // 3
// 注意:每个 counter() 调用产生独立的 n
c2 := counter()
fmt.Println(c2()) // 1(从 1 重新开始,与 c1 互不影响)
}
闭包常见坑:循环变量捕获(面试高频考点)
go
// 完整可运行程序:循环变量与闭包
package main
import "fmt"
func main() {
var funcs []func()
// 用索引循环
for i := 0; i < 3; i++ {
funcs = append(funcs, func() { fmt.Println(i) })
}
for _, f := range funcs {
f()
}
// Go 1.22 及以后版本输出:0 1 2
// Go 1.21 及以前版本输出:3 3 3(闭包捕获的是同一个循环变量 i)
// 经典兼容写法:循环体内新建变量再捕获
funcs = funcs[:0]
for i := 0; i < 3; i++ {
i := i // 关键一行:每次迭代创建新的局部变量
funcs = append(funcs, func() { fmt.Println(i) })
}
for _, f := range funcs {
f()
}
// 任何版本都输出:0 1 2
}
知识更新 :Go 1.22(2024 年 2 月发布)起,
for循环的每次迭代都会创建新的循环变量,之前「闭包捕获循环变量」的坑在语法层面被修复。但如果你需要兼容老版本(如公司的 Go 1.21 环境),i := i的写法依然是最稳妥的。
5.8 defer:延迟执行
defer 是 Go 非常独特的机制:把一条语句推迟到当前函数返回前执行。用途:资源释放(关闭文件、解锁、断开连接),保证「无论函数从哪条路径返回,清理动作一定执行」。
go
// 完整可运行程序:defer 基础
package main
import "fmt"
func main() {
defer fmt.Println("这句话最后打印")
fmt.Println("第一句")
fmt.Println("第二句")
}
// 输出顺序:
// 第一句
// 第二句
// 这句话最后打印
defer 的三个关键规则
规则一:多个 defer 按「后进先出」(LIFO)执行
go
// 完整可运行程序:defer 执行顺序
package main
import "fmt"
func main() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
}
// 输出:3 2 1(后注册的先执行)
规则二:defer 语句中的参数在 defer 执行到的那一行就立即求值(不是函数返回时才求值)
go
// 完整可运行程序:defer 参数立即求值
package main
import "fmt"
func main() {
x := 10
defer fmt.Println(x) // 此时 x=10 已被"拍照",输出 10
x = 99 // 修改不影响 defer 里的值
}
// 输出:10
规则三:defer 可以修改命名返回值
go
// 完整可运行程序:defer 修改命名返回值
package main
import "fmt"
func double(n int) (result int) {
defer func() {
result = result * 2 // 在函数返回前修改命名返回值
}()
return n + 1
}
func main() {
fmt.Println(double(10)) // 22(先算 10+1=11,defer 再翻倍)
}
defer 的典型用途:成对出现的「打开/关闭」
go
// 完整可运行程序:defer 关闭文件
package main
import (
"fmt"
"os"
)
func main() {
// 打开文件
file, err := os.Open("example.txt") // 文件不存在也不怕,下面是错误处理
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer file.Close() // 函数结束前一定会关闭文件
// 读文件内容(第 14 章详细讲)
data, err := os.ReadFile("example.txt")
if err != nil {
fmt.Println("读取失败:", err)
return
}
fmt.Println(string(data))
}
在 Go 里你会经常看到这种模式:
file, err := os.Open(...)之后紧跟defer file.Close()。打开资源的代码和释放资源的代码紧挨着写,永远不会忘记释放。
defer 的坑:不要在循环里用
在循环体内 defer,所有延迟调用会累积到循环结束后一次性执行,可能浪费大量资源(尤其是打开文件):
go
// 错误示范:循环内 defer
// for _, file := range files {
// f, _ := os.Open(file)
// defer f.Close() // 所有文件都会等到函数结束时才关闭!
// }
// 正确做法:把循环体包进一个函数
// for _, file := range files {
// processFile(file) // 在 processFile 内部 open + defer close
// }
defer 的底层机制(进阶)
理解了「defer 是怎么实现的」,前面三条规则就都说得通了。
编译期 :编译器把 defer fn(参数) 改写成两件事------
- 在 defer 语句处调用运行时函数
runtime.deferproc:创建一个_defer结构,把要执行的函数指针和参数值 存进去(参数在此时就拷贝完毕------这就是「参数立即求值」规则的底层原因),然后挂到当前 goroutine 的 defer 链表头部; - 在函数每个返回路径前 插入
runtime.deferreturn:从链表头逐个取出_defer执行。
链表头插入、头取出 = 后进先出(LIFO)------这就是多条 defer 逆序执行的底层原因。
Go 1.14 的优化(open-coded defer) :对于函数体内 defer 数量在编译期确定(没有循环、没有复杂分支)的情况,编译器不再走 deferproc/deferreturn 的运行时调用,而是把延迟调用直接展开到函数末尾的返回路径上,用一个标志位记录哪些需要执行。这让 defer 的开销从约 80ns 降到约 10ns 量级,接近直接调用。循环里的 defer 无法这样优化(每次迭代数量不定),仍走链表方式------这也是「不要在循环里用 defer」的另一个理由。
一句话:defer = 编译期注册 + 返回前按 LIFO 执行;参数在注册那一刻就求值完毕。
5.9 递归
函数调用自身就是递归。经典例子:阶乘、斐波那契。
go
// 完整可运行程序:递归
package main
import "fmt"
// 阶乘:n! = n * (n-1)!
func factorial(n int) int {
if n <= 1 {
return 1 // 递归出口(必须要有!)
}
return n * factorial(n-1)
}
// 斐波那契数列:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)
func fibonacci(n int) int {
if n < 2 {
return n
}
return fibonacci(n-1) + fibonacci(n-2)
}
func main() {
fmt.Println(factorial(5)) // 120
fmt.Println(fibonacci(10)) // 55
}
写递归的两个要点:
- 必须有递归出口(base case),否则无限递归导致栈溢出(Go 中表现为程序崩溃)
- 递归深度要有限制。Go 的 goroutine 栈会动态增长(默认最大 1GB),但深递归仍可能耗尽内存。能用循环解决的问题优先用循环(更省内存);递归的优势是代码简洁、符合问题的自然结构(如目录树遍历、二叉树操作)
5.10 本章小结
- 函数定义:
func 名(参数) 返回值 { } - 多返回值 :
func div(a, b int) (int, int),错误处理惯例「返回结果 + error」 - 命名返回值 + 裸
return;可变参数... - 函数是一等公民:可赋值、可传参、可返回
- 匿名函数 + 闭包:捕获外部变量;注意循环变量捕获(Go 1.22 已修复)
defer:LIFO 执行、参数立即求值、可修改命名返回值、典型用途是资源释放
本章练习:
- 写一个
max(a, b int) int函数返回较大值。 - 写一个可变参数函数,返回所有参数的平均值。
- 写一个闭包实现的「累加器」:每次调用返回累计和。
- 用 defer 写一个程序:先打印「开始」,函数末尾打印「结束」,中间用 panic 模拟异常,验证 defer 在异常时也会执行(panic 第 10 章讲,先体验)。
- 用递归实现「汉诺塔」打印移动步骤(挑战题)。
第 6 章 数组、切片、map 与字符串
这是 Go 中最常用的四种数据容器。其中**切片(slice)**和 map 是日常编程的主角,必须熟练掌握。
6.1 数组(array)
6.1.1 声明与初始化
go
// 完整可运行程序:数组基础
package main
import "fmt"
func main() {
// 声明长度为 5 的 int 数组,元素自动为 0
var a [5]int
fmt.Println(a) // [0 0 0 0 0]
// 声明并初始化
var b [3]string = [3]string{"苹果", "香蕉", "橙子"}
fmt.Println(b) // [苹果 香蕉 橙子]
// 短声明
c := [3]int{1, 2, 3}
// 部分初始化:其余补零
d := [5]int{1, 2}
fmt.Println(d) // [1 2 0 0 0]
// 用索引指定位置初始化
e := [5]int{1: 10, 3: 30} // 下标 1 处是 10,下标 3 处是 30
fmt.Println(e) // [0 10 0 30 0]
// 让编译器数长度:...
f := [...]int{1, 2, 3, 4} // 长度自动为 4
fmt.Println(len(f)) // 4
// 访问与修改
fmt.Println(c[0]) // 1
c[1] = 99
fmt.Println(c) // [1 99 3]
}
6.1.2 数组的重要特性
特性一:长度是类型的一部分
go
var a [3]int
var b [4]int
// a = b // 编译错误:类型不同([3]int 与 [4]int 是两个不同的类型)
特性二:数组是值类型,赋值/传参都是完整复制
go
// 完整可运行程序:数组是值类型
package main
import "fmt"
func modify(arr [3]int) {
arr[0] = 999 // 修改的是副本
}
func main() {
nums := [3]int{1, 2, 3}
modify(nums)
fmt.Println(nums) // [1 2 3],原数组没变
// 赋值也是复制
nums2 := nums
nums2[0] = 100
fmt.Println(nums) // [1 2 3]
fmt.Println(nums2) // [100 2 3]
}
数组直接传参会复制整个数组,大数组很浪费。实际开发中几乎不用数组,都用切片(见下节)。切片传的是"视图",成本极低。
6.2 切片(slice)------最重要的容器
切片是 Go 的「动态数组」,是对数组某个区间的引用(视图) ,可以自动扩容。日常开发 90% 用切片而不是数组。
6.2.1 创建切片
go
// 完整可运行程序:创建切片的五种方式
package main
import "fmt"
func main() {
// 方式一:直接字面量
s1 := []int{1, 2, 3}
fmt.Println(s1, len(s1), cap(s1)) // [1 2 3] 3 3
// 方式二:make 创建(长度 5,容量 10)
s2 := make([]int, 5, 10)
fmt.Println(s2, len(s2), cap(s2)) // [0 0 0 0 0] 5 10
// make 只给长度(容量=长度)
s3 := make([]string, 3)
fmt.Println(len(s3), cap(s3)) // 3 3
// 方式三:从数组切出
arr := [5]int{10, 20, 30, 40, 50}
s4 := arr[1:4] // 下标 1 到 3(左闭右开!)
fmt.Println(s4) // [20 30 40]
// 方式四:从切片再切
s5 := s4[0:2]
fmt.Println(s5) // [20 30]
// 方式五:空切片
var s6 []int // nil 切片
s7 := []int{} // 空切片(非 nil)
fmt.Println(s6 == nil, s7 == nil) // true false
}
切片的三个要素 :指针(指向底层数组)、长度 len(当前元素个数)、容量 cap(底层数组可用空间)。
6.2.2 切片表达式:a[low:high]
- 左闭右开:包含
low,不包含high - 省略
low默认 0,省略high默认len(a) - 切片表达式的容量 = 底层数组容量 - low
go
s := []int{0, 1, 2, 3, 4, 5}
fmt.Println(s[1:4]) // [1 2 3]
fmt.Println(s[:3]) // [0 1 2]
fmt.Println(s[3:]) // [3 4 5]
fmt.Println(s[:]) // [0 1 2 3 4 5](整个切片)
6.2.3 切片的本质:共享底层数组(重点中的重点!)
切片不拥有数据,它只是底层数组的「窗口」。多个切片可以共享同一个底层数组------这是大量 bug 的来源,必须理解:
go
// 完整可运行程序:切片共享底层数组
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b 是 a 的窗口,指向同一个底层数组
fmt.Println(b) // [2 3]
b[0] = 99 // 修改 b 的元素
fmt.Println(a) // [1 99 3 4 5] ← a 也被改了!
fmt.Println(b) // [99 3]
}
内存图理解 :a 和 b 都指向同一个底层数组 [1 2 3 4 5],b 只是把指针定位到下标 1、长度记为 2。所以改 b[0] 就是改底层数组的第 1 个元素。
再看一个更容易踩的坑:append 后共享区域:
go
// 完整可运行程序:append 与共享
package main
import "fmt"
func main() {
a := []int{1, 2, 3, 4, 5}
// b 截取 a 的前 4 个元素,容量是 5
b := a[:4]
fmt.Println("b =", b, "cap =", cap(b)) // [1 2 3 4] cap = 5
// append 第 5 个元素:容量够用,直接写入底层数组
b = append(b, 100)
fmt.Println("b =", b) // [1 2 3 4 100]
fmt.Println("a =", a) // [1 2 3 4 100] ← a 的最后一位被悄悄改掉了!
}
结论 :两个切片共享底层数组时,一方
append或修改可能影响另一方。如果不想共享,用copy复制一份(见 6.2.5)。
6.2.4 append 与自动扩容
go
// 完整可运行程序:append 扩容
package main
import "fmt"
func main() {
s := make([]int, 0, 2) // 长度 0,容量 2
fmt.Println("初始:len =", len(s), "cap =", cap(s))
for i := 1; i <= 5; i++ {
s = append(s, i) // 追加元素
fmt.Printf("追加 %d 后:len = %d, cap = %d, s = %v\n", i, len(s), cap(s), s)
}
// 追加 1 后:len = 1, cap = 2
// 追加 2 后:len = 2, cap = 2
// 追加 3 后:len = 3, cap = 4 ← 容量翻倍(自动扩容,重新分配底层数组)
// 追加 4 后:len = 4, cap = 4
// 追加 5 后:len = 5, cap = 8 ← 再次扩容
}
append 的规则(必须记住):
append返回新切片 ,必须用返回值接收:s = append(s, x)- 容量足够时,原地写入底层数组(共享者可能受影响)
- 容量不足时,分配新的更大的底层数组(元素全部拷贝过去),旧数组成为孤儿
- 扩容策略:容量小于 256 时大致翻倍;更大时按约 1.25 倍增长(具体由运行时决定,不要依赖精确数值)
6.2.5 copy:复制切片
go
// 完整可运行程序:copy
package main
import "fmt"
func main() {
src := []int{1, 2, 3, 4, 5}
// 目标切片必须有足够的长度(copy 复制 min(len(dst), len(src)) 个)
dst := make([]int, len(src))
n := copy(dst, src)
fmt.Println("复制了", n, "个:", dst) // 复制了 5 个: [1 2 3 4 5]
// copy 之后互不影响(底层数组不同了)
dst[0] = 999
fmt.Println(src) // [1 2 3 4 5]
fmt.Println(dst) // [999 2 3 4 5]
// 部分复制
part := make([]int, 2)
copy(part, src)
fmt.Println(part) // [1 2]
}
6.2.6 切片遍历
go
s := []string{"a", "b", "c"}
for i, v := range s {
fmt.Println(i, v)
}
// 只要值:for _, v := range s
// 只要下标:for i := range s
6.2.7 切片删除元素(Go 没有内置删除)
go
// 完整可运行程序:删除切片元素
package main
import "fmt"
func main() {
// 删除下标 2 的元素:把后面所有元素往前挪
s := []int{1, 2, 3, 4, 5}
index := 2
s = append(s[:index], s[index+1:]...)
fmt.Println(s) // [1 2 4 5]
}
6.2.8 二维切片
go
// 完整可运行程序:二维切片
package main
import "fmt"
func main() {
// 方式一:整体初始化
grid := [][]int{
{1, 2, 3},
{4, 5, 6},
}
fmt.Println(grid[1][2]) // 6
// 方式二:逐行 make
matrix := make([][]int, 3)
for i := range matrix {
matrix[i] = make([]int, 3) // 每行长度 3
}
matrix[0][0] = 1
fmt.Println(matrix) // [[1 0 0] [0 0 0] [0 0 0]]
}
6.2.9 切片的底层结构(slice header,进阶)
切片变量在内存中其实是一个 24 字节(64 位平台)的描述符结构体,俗称 slice header:
go
type slice struct {
array unsafe.Pointer // 指向底层数组首元素(8 字节)
len int // 当前元素个数(8 字节)
cap int // 底层数组容量(8 字节)
}
go
// 完整可运行程序:验证 slice header 大小
package main
import (
"fmt"
"unsafe"
)
func main() {
var s []int
fmt.Println(unsafe.Sizeof(s)) // 24
}
内存视图:
s := make([]int, 3, 5)
slice 变量(24 字节) 底层数组(5 个 int)
┌───────────────────┐ ┌────┬────┬────┬────┬────┐
│ array ────────────┼───────►│ 0 │ 0 │ 0 │ │ │
│ len = 3 │ └────┴────┴────┴────┴────┘
│ cap = 5 │
└───────────────────┘
用这个视角,前面所有「奇怪」的行为都一目了然:
- 为什么传切片很便宜------函数传参只拷贝 24 字节的 header,不拷贝数据(对比数组整体复制)
- 为什么函数内修改元素,外面看得见------header 里的 array 指向同一个底层数组
- 为什么 append 必须用返回值接收 ------append 会修改 header 的 len,可能修改 cap,容量不足时还会换一个新的底层数组(array 指针也变了);值传递的副本改动了,调用方的 header 并不知道
- 为什么两个切片共享底层数据 ------
b := a[1:3]只是复制了一个 header,array 仍指向同一块内存 - 为什么 nil 切片可以 append------nil 切片只是 array=nil、len=0、cap=0,append 时分配新数组即可
扩展知识:Go 1.20 起官方建议用
unsafe.Slice等函数操作这类结构;日常开发中理解 header 三字段(指针/长度/容量)就足够。
6.3 map:键值对集合
map 是 Go 的哈希表(类似 Python 的 dict、Java 的 HashMap)。
6.3.1 创建与基本操作
go
// 完整可运行程序:map 基础操作
package main
import "fmt"
func main() {
// 方式一:make 创建(推荐)
scores := make(map[string]int)
// 方式二:字面量
// scores := map[string]int{"语文": 90, "数学": 95}
// 添加/修改键值对
scores["语文"] = 90
scores["数学"] = 95
scores["英语"] = 88
// 读取
fmt.Println(scores["语文"]) // 90
// 读取不存在的键:返回零值(不会报错!)
fmt.Println(scores["物理"]) // 0
// 判断键是否存在(重要!)
value, ok := scores["物理"]
if !ok {
fmt.Println("物理不存在,value 是零值:", value)
}
// 常用写法
if v, ok := scores["数学"]; ok {
fmt.Println("数学的成绩是:", v) // 数学的成绩是: 95
}
// 删除键
delete(scores, "英语")
fmt.Println(scores) // map[数学:95 语文:90]
// 长度
fmt.Println(len(scores)) // 2
// 遍历(顺序是随机的!不要依赖顺序)
for subject, score := range scores {
fmt.Println(subject, score)
}
}
map 的四个必知要点:
- 读取不存在的键返回零值 ,不报错;判断存在必须用
v, ok := m[key] - 遍历顺序随机(Go 故意打乱顺序,防止依赖遍历顺序写出 bug)
- nil map 可以读(返回零值),但不能写(写入会 panic)
go
var m map[string]int // nil map
fmt.Println(m["a"]) // 0,可以读
// m["a"] = 1 // panic: assignment to entry in nil map!必须先 make
- map 不是并发安全的:多个 goroutine 同时读写会 panic(第 12 章讲解决方案)
6.3.2 map 的常用套路
go
// 完整可运行程序:map 应用
package main
import (
"fmt"
"sort"
)
func main() {
// 套路一:统计字符出现次数
text := "hello world"
count := make(map[rune]int)
for _, ch := range text {
count[ch]++ // 不存在时自动从 0 开始
}
fmt.Println(count['l']) // 3
// 套路二:map 值可以是任意类型,包括切片和 map
students := map[string][]string{
"一班": {"小明", "小红"},
"二班": {"小刚"},
}
fmt.Println(students["一班"]) // [小明 小红]
// 套路三:用 map 实现 set(集合)
set := make(map[string]bool)
set["苹果"] = true
if set["苹果"] {
fmt.Println("苹果在集合中")
}
if !set["香蕉"] {
fmt.Println("香蕉不在集合中")
}
// 删除元素即从集合移除
delete(set, "苹果")
// 套路四:map 排序输出(遍历无序,需要先取出键排序)
ages := map[string]int{"Alice": 30, "Bob": 25, "Carol": 35}
keys := make([]string, 0, len(ages))
for k := range ages {
keys = append(keys, k)
}
sort.Strings(keys) // 需要 import "sort"
for _, k := range keys {
fmt.Println(k, ages[k]) // 按字母序输出
}
}
6.3.3 map 的底层原理(哈希表,进阶)
map 的本质是哈希表(hash table):对 key 计算哈希值,用哈希定位存储位置,理想情况下读写都是 O(1)。
Go map 的内部结构(简化):
go
type hmap struct {
count int // 元素个数
B uint8 // 桶数量的对数:桶数 = 2^B
buckets unsafe.Pointer // 桶数组(每个桶是一个 bmap)
// ... 还有扩容相关的 oldbuckets、nevacuate 等字段
}
核心设计:
- 哈希值的一部分决定进入哪个桶(bucket) ,每个桶最多存 8 个键值对;桶满了用**溢出桶(overflow bucket)**链下去(链地址法解决哈希冲突)
- 桶内先比 tophash (哈希值高 8 位,快速排除),再用
==精确比较 key - 负载因子 6.5 :平均每个桶超过 6.5 个元素时触发扩容(桶数翻倍,所有元素重新哈希)
- 扩容是渐进式的:不是一次性搬完,而是每次读写时顺带迁移一小部分,避免大 map 扩容时卡顿
用这个模型回看 6.3 节记过的规则,全部有了解释:
| 现象 | 底层原因 |
|---|---|
| 遍历顺序随机 | 遍历从随机桶、随机偏移开始,且扩容会改变元素位置 |
不能对 m[k] 取地址 |
扩容会搬动元素,地址会失效 |
| 并发写会 panic | hmap 无内置锁,并发修改桶结构/扩容会破坏内部状态;Go 检测到并发写直接 panic,不静默出错 |
| key 必须可比较 | 查找必须用 == 精确比较;slice、map、function 不可比较,不能做 key |
| 读不存在的 key 返回零值 | 查找失败返回零值,是语言层面的约定 |
| map 作为整体不能比较 | map 内部结构(桶、溢出链)无稳定比较语义 |
go
// 完整可运行程序:验证 key 必须可比较
package main
import "fmt"
type Point struct{ X, Y int } // 结构体字段都是可比较的 → 结构体可作 key
func main() {
m := map[Point]string{}
p := Point{X: 1, Y: 2}
m[p] = "原点偏移"
fmt.Println(m[Point{X: 1, Y: 2}]) // 原点偏移(结构体按字段值比较)
}
// 若把 key 换成 []int 或 map,编译直接报错:
// map[string]int 可以做 key;[]int 不可以(invalid map key type)
6.4 字符串深入
6.4.1 字符串是不可变的
go
s := "hello"
// s[0] = 'H' // 编译错误:cannot assign to s[0](字符串不可变)
// 想"修改"只能创建新字符串
s = "H" + s[1:] // "Hello"
字符串拼接的性能 :在循环中大量拼接,+ 每次都会创建新字符串,性能差。用 strings.Builder:
go
// 完整可运行程序:高效拼接
package main
import (
"fmt"
"strings"
)
func main() {
var builder strings.Builder
for i := 0; i < 10000; i++ {
builder.WriteString("x")
}
result := builder.String()
fmt.Println(len(result)) // 10000
}
6.4.2 strings 包常用函数
| 函数 | 作用 | 示例 |
|---|---|---|
strings.Contains(s, sub) |
是否包含子串 | "hello".Contains("ell") → true |
strings.HasPrefix(s, p) |
是否以 p 开头 | true |
strings.HasSuffix(s, p) |
是否以 p 结尾 | true |
strings.Index(s, sub) |
子串首次出现位置,无则 -1 | strings.Index("hello", "l") → 2 |
strings.Split(s, sep) |
按分隔符拆成切片 | "a,b,c" → ["a" "b" "c"] |
strings.Join(parts, sep) |
切片拼接成字符串 | "a,b,c" |
strings.Replace(s, old, new, n) |
替换前 n 个(n<0 全部) | |
strings.ReplaceAll(s, old, new) |
全部替换 | |
strings.TrimSpace(s) |
去掉首尾空白 | |
strings.Trim(s, cutset) |
去掉首尾指定字符 | |
strings.ToUpper(s) / ToLower(s) |
大小写转换 | |
strings.Repeat(s, n) |
重复 n 次 | |
strings.Fields(s) |
按空白拆分为单词切片 | "a b c" → ["a" "b" "c"] |
go
// 完整可运行程序:strings 常用函数
package main
import (
"fmt"
"strings"
)
func main() {
s := " Hello, Go World "
fmt.Println(strings.TrimSpace(s)) // Hello, Go World
fmt.Println(strings.Contains(s, "Go")) // true
fmt.Println(strings.HasPrefix(s, " He")) // true
fmt.Println(strings.Index(s, "World")) // 12
fmt.Println(strings.ToUpper(strings.TrimSpace(s))) // HELLO, GO WORLD
parts := strings.Split("苹果,香蕉,橙子", ",")
fmt.Println(parts) // [苹果 香蕉 橙子]
fmt.Println(strings.Join(parts, " / ")) // 苹果 / 香蕉 / 橙子
fmt.Println(strings.ReplaceAll("a-b-c", "-", "+")) // a+b+c
fmt.Println(strings.Repeat("ab", 3)) // ababab
words := strings.Fields(" go is great ")
fmt.Println(words) // [go is great]
}
6.4.3 strconv 包:字符串与数值互转
| 函数 | 作用 |
|---|---|
strconv.Atoi(s) |
字符串 → int(返回 (int, error)) |
strconv.Itoa(n) |
int → 字符串 |
strconv.ParseInt(s, base, bitSize) |
按进制解析 |
strconv.ParseFloat(s, 64) |
字符串 → float64 |
strconv.FormatFloat(f, 'f', 2, 64) |
float64 → 字符串(保留 2 位小数) |
strconv.ParseBool(s) |
字符串 → bool |
strconv.Quote(s) |
给字符串加引号(调试用) |
go
// 完整可运行程序:strconv 转换
package main
import (
"fmt"
"strconv"
)
func main() {
// Atoi:字符串转 int(注意要处理错误!)
n, err := strconv.Atoi("123")
if err != nil {
fmt.Println("转换失败:", err)
return
}
fmt.Println(n + 1) // 124
// 非数字字符串会返回错误
_, err = strconv.Atoi("abc")
fmt.Println(err) // strconv.Atoi: parsing "abc": invalid syntax
// Itoa:int 转字符串
fmt.Println(strconv.Itoa(456) + "!") // 456!
// 浮点数
f, _ := strconv.ParseFloat("3.14", 64)
fmt.Println(f * 2) // 6.28
// 格式化浮点数:保留 2 位小数
fmt.Println(strconv.FormatFloat(3.14159, 'f', 2, 64)) // 3.14
// 十六进制解析
hex, _ := strconv.ParseInt("ff", 16, 64)
fmt.Println(hex) // 255
}
惯例 :所有可能失败的解析函数都返回
(结果, error),务必检查err != nil。转换失败返回零值,不要忽略错误继续用结果。
6.4.4 字符串的底层结构(进阶)
字符串变量在内存中是 16 字节的描述符 + 只读的字节数组:
go
type stringStruct struct {
str unsafe.Pointer // 指向底层字节数组(8 字节)
len int // 字节数(8 字节)
}
go
// 完整可运行程序:验证字符串描述符大小与底层共享
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
var s string
fmt.Println(unsafe.Sizeof(s)) // 16(描述符本身的大小,与内容长度无关)
// 验证子串与母串共享底层字节
whole := "hello world"
part := whole[:5]
hw := (*reflect.StringHeader)(unsafe.Pointer(&whole))
hp := (*reflect.StringHeader)(unsafe.Pointer(&part))
fmt.Printf("母串数据地址:%#x\n", hw.Data)
fmt.Printf("子串数据地址:%#x\n", hp.Data)
fmt.Println("共享底层字节:", hw.Data == hp.Data) // true
}
三个重要推论:
- 为什么字符串不可变 :底层字节数组是只读的(可能位于只读内存段或被多处共享),运行时禁止修改。不可变带来一个巨大好处------字符串可以安全共享 :
whole[:5]与whole指向同一段字节,截取子串是 O(1) 操作,不需要复制。任何语言中「截取子串不复制」都要求底层不可变 - 为什么
len()是字节数 :描述符里只有字节数,没有字符数;想知道字符数必须遍历解码(utf8.RuneCountInString或 range) - 为什么
+拼接慢 :每次a + b都要分配一块新的 字节数组并把两边内容拷进去;循环里拼接 N 次,总拷贝量是 O(N²)。strings.Builder内部维护一个[]byte并支持预分配(Grow),追加时按切片扩容规则增长,总成本 O(N)
6.5 本章小结
- 数组:长度固定、长度是类型一部分、值类型;日常少用
- 切片:动态数组、
make/字面量/截取创建、append(必须接收返回值)、copy复制、共享底层数组的坑 - map:
make创建、v, ok := m[k]判断存在、delete删除、遍历无序、nil map 不能写 - 字符串:不可变、
strings包处理文本、strconv包转换、strings.Builder高效拼接
本章练习:
- 写函数
reverse(s []int)原地反转切片。 - 写函数
dedup(s []int) []int去除相邻重复元素。 - 统计一段英文文本中每个单词的出现次数(用
strings.Fields+ map)。 - 把字符串
"3.14159"解析为 float64 并保留两位小数输出。 - 用切片实现一个简单的「栈」(push / pop / peek)。
第 7 章 指针
7.1 为什么需要指针
回忆第 5 章:Go 的参数传递是值传递 ,函数内修改参数不会影响外部变量。指针让我们能间接访问并修改其他变量。
指针是一个变量,它存的是另一个变量的内存地址。
7.2 取地址 & 与解引用 *
go
// 完整可运行程序:指针基础
package main
import "fmt"
func main() {
x := 42
p := &x // p 是指向 x 的指针(类型是 *int),& 表示"取 x 的地址"
fmt.Println(p) // 0xc0000140a8(每次运行地址都不同)
fmt.Println(*p) // 42,* 表示"解引用":取出 p 指向的那个变量的值
*p = 100 // 通过指针修改 x
fmt.Println(x) // 100,x 真的被改了
// 指针的类型
var q *int // q 是 *int 类型,此时为 nil(没有指向任何变量)
fmt.Println(q == nil) // true
// fmt.Println(*q) // panic: runtime error: invalid memory address(对 nil 指针解引用会崩溃)
}
内存图理解:
变量 x(内存地址 0x...a8): 值 42
指针 p(内存地址 0x...c0): 值 0x...a8(即 x 的地址)
*p 就等价于"顺着 p 里存的地址找到那个变量",也就是 x
7.3 指针作为函数参数:修改外部变量
go
// 完整可运行程序:指针参数
package main
import "fmt"
// 参数是 *int:拿到的是外面变量的地址,可以修改它
func increment(p *int) {
*p++ // 等价于 (*p)++:把 p 指向的变量的值加 1
}
// 对比:值参数无法修改外部变量
func failIncrement(x int) {
x++
}
func main() {
n := 10
increment(&n) // 传地址
fmt.Println(n) // 11,修改成功
failIncrement(n)
fmt.Println(n) // 11,没变(failIncrement 修改的是副本)
}
Go 官方对「用指针修改外部变量」的态度:能用返回值就用返回值(函数式风格更清晰),指针主要用于:需要修改结构体的字段、避免大对象复制、实现共享状态。
7.4 new:分配零值变量并返回指针
new(T) 分配一个 T 类型的零值变量,返回它的指针 *T:
go
// 完整可运行程序:new
package main
import "fmt"
func main() {
p := new(int) // 分配一个 int 零值(0),返回指针
fmt.Println(*p) // 0
*p = 5
fmt.Println(*p) // 5
// 等价写法
var x int
q := &x
fmt.Println(*q) // 0
// 对结构体也适用(第 8 章讲结构体)
type Point struct{ X, Y int }
pt := new(Point)
fmt.Println(pt.X, pt.Y) // 0 0(结构体字段自动零值)
}
日常开发
new用得不多,更多是&结构体{}字面量(第 8 章)。
7.5 指针与切片、map 的关系
切片、map、channel、函数本身是引用类型:变量里存的就是指向底层数据的「描述符」。所以:
- 切片/ map 传给函数,函数内修改元素,外面能看到(因为共享底层数据)
- 但函数内给切片
append导致扩容,外面的切片变量不会变(扩容产生新底层数组,但外面的切片描述符没更新)------所以追加操作还是需要返回新切片
go
// 完整可运行程序:切片作为参数的行为
package main
import "fmt"
// 修改元素:共享底层数组,外面能看见
func changeFirst(s []int) {
s[0] = 999
}
// append 扩容:外层看不见(除非返回)
func appendOne(s []int) []int {
return append(s, 100)
}
func main() {
s := []int{1, 2, 3}
changeFirst(s)
fmt.Println(s) // [999 2 3],元素修改生效了
s = appendOne(s)
fmt.Println(s) // [999 2 3 100]
}
map 本身内部就有指针,直接传 map 就能增删改:
go
func addEntry(m map[string]int) {
m["new"] = 1 // 直接生效
}
结论:不要把切片/map 再包一层指针 (
*[]int基本不需要),直接传即可。只有需要「重新分配变量本身」时才需要指针(如函数内要替换整个切片)。
7.6 指针的常见坑
坑一:对 nil 指针解引用会 panic
go
var p *int
// *p = 1 // panic!p 还没指向任何变量
坑二:函数返回局部变量的地址是安全的(Go 的逃逸分析会自动把变量移到堆上,与 C 不同)
go
// 完整可运行程序:返回局部变量地址
package main
import "fmt"
func makeInt() *int {
x := 10 // 返回后 x 不会消失(Go 自动提升到堆上)
return &x
}
func main() {
p := makeInt()
fmt.Println(*p) // 10,完全合法
}
坑三:指针指向切片元素后切片扩容(扩容换底层数组,指针还指旧数组):
go
s := []int{1, 2, 3}
p := &s[0] // 指向底层数组第一个元素
s = append(s, 4, 5, 6, 7, 8) // 扩容,换了新的底层数组
// *p 仍然指向旧数组,值还是 1,但已经和 s 脱节
坑四:不要对 map 元素取地址(编译错误,因为 map 内部会移动元素):
go
m := map[string]int{"a": 1}
// p := &m["a"] // 编译错误:cannot take the address of m["a"]
7.7 指针小结
&x取地址,*p解引用- 指针类型:
*T new(T)创建零值并返回指针- 指针参数可以修改外部变量;但 Go 更推荐返回值风格
- 切片、map 是引用类型,直接传递即可,不需要指针包一层
- 对 nil 指针解引用会 panic
本章练习:
- 写函数
swap(a, b *int)交换两个变量的值,并在 main 中验证。 - 写一个
doubleAll(nums []int)函数,把切片每个元素翻倍(直接修改)。 - 思考:为什么
append要返回新切片,而changeFirst不用? - 写一个返回
*int的函数,验证返回的指针在函数结束后依然有效。
第 8 章 结构体与方法
Go 没有「类」(class),但提供了 结构体(struct)+ 方法(method) ,两者组合基本能实现面向对象的大部分能力------封装、组合、多态(多态靠接口,见第 9 章)。注意:Go 没有继承 ,用组合代替。
8.1 定义与初始化结构体
go
// 完整可运行程序:结构体基础
package main
import "fmt"
// 定义结构体类型(type 关键字)
type Person struct {
Name string
Age int
City string
}
func main() {
// 方式一:按字段名初始化(推荐,顺序无关、可读性好)
p1 := Person{Name: "小明", Age: 20, City: "北京"}
// 方式二:只初始化部分字段,其余为零值
p2 := Person{Name: "小红"}
fmt.Println(p2.Age) // 0
// 方式三:按顺序初始化(不推荐:字段顺序变了就出错,且可读性差)
p3 := Person{"小刚", 25, "上海"}
// 方式四:先声明再逐字段赋值
var p4 Person
p4.Name = "小李"
p4.Age = 22
// 方式五:取地址得到结构体指针(最常见!)
p5 := &Person{Name: "小王", Age: 30}
fmt.Println(p1, p2, p3, p4, p5)
// {小明 20 北京} {小红 0 } {小刚 25 上海} {小李 22 } &{小王 30 }
}
访问字段用点号 . 。结构体指针访问字段也直接用点号(Go 自动解引用,不需要 (*p).Name):
go
p := &Person{Name: "小王"}
fmt.Println(p.Name) // 等价于 (*p).Name,Go 帮你自动处理
p.Age = 31 // 通过指针修改字段
8.2 结构体是值类型
结构体赋值、传参都会复制整个结构体:
go
// 完整可运行程序:结构体值语义
package main
import "fmt"
type Person struct {
Name string
Age int
}
// 传值:函数内修改不影响外面
func birthday(p Person) {
p.Age++
}
// 传指针:可以修改外面
func birthdayPtr(p *Person) {
p.Age++
}
func main() {
me := Person{Name: "小明", Age: 20}
birthday(me)
fmt.Println(me.Age) // 20,没变
birthdayPtr(&me)
fmt.Println(me.Age) // 21,变了
}
大结构体频繁传值会复制开销大,通常传指针。但永远不要只为了省一次复制就到处用指针------小结构体(几个字段)传值反而更利于缓存和并发安全。
8.3 方法:给类型绑定函数
方法的定义:在 func 关键字和函数名之间加一个接收者(receiver):
go
// 完整可运行程序:方法定义
package main
import "fmt"
type Rectangle struct {
Width float64
Height float64
}
// 值接收者方法:area 方法属于 Rectangle 类型
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// 指针接收者方法:可以修改字段
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
func main() {
rect := Rectangle{Width: 4, Height: 5}
fmt.Println(rect.Area()) // 20
rect.Scale(2) // 修改接收者的字段
fmt.Println(rect) // {8 10}
fmt.Println(rect.Area()) // 80
}
方法 vs 函数的区别 :方法多了一个接收者。rect.Area() 本质上就是 Rectangle.Area(rect)。
值接收者 vs 指针接收者(高频面试题)
值接收者 func (r Rectangle) ... |
指针接收者 func (r *Rectangle) ... |
|
|---|---|---|
| 调用时 | 复制一份结构体 | 使用原结构体 |
| 能否修改字段 | 不能(改了也只是副本) | 能 |
| 大结构体 | 每次调用复制,开销大 | 无复制开销 |
| 与接口的关系 | 值类型和指针类型都实现了值接收者的方法 | 只有指针类型实现了指针接收者的方法 |
选择规则(简单版):
- 需要修改接收者的字段 → 指针接收者
- 结构体很大(> 几十字节)→ 指针接收者(避免复制)
- 拿不准 → 默认用指针接收者(Go 官方也倾向指针接收者)
重要细节 :Go 会自动取地址/解引用。定义了指针接收者方法
Scale后,rect.Scale(2)合法(Go 自动(&rect).Scale(2));定义了值接收者方法后,(&rect).Area()也合法(自动解引用)。只有接口实现时这两种接收者的差异才会真正影响你(第 9 章)。
8.4 嵌入与组合(Go 的"继承")
Go 用结构体嵌套 实现组合。被嵌入的类型,其字段和方法会提升到外层结构体:
go
// 完整可运行程序:结构体组合
package main
import "fmt"
type Animal struct {
Name string
}
// Animal 的方法
func (a Animal) Speak() string {
return "..."
}
// 嵌入 Animal(匿名嵌套)
type Dog struct {
Animal // 匿名嵌入:只有类型名,没有字段名
Breed string
}
func main() {
d := Dog{
Animal: Animal{Name: "旺财"}, // 初始化嵌入的字段
Breed: "柯基",
}
// 字段提升:可以直接访问 d.Name
fmt.Println(d.Name) // 旺财
// 方法提升:Dog 自动拥有 Speak 方法
fmt.Println(d.Speak()) // ...
// 也可以显式通过嵌入字段访问
fmt.Println(d.Animal.Name) // 旺财
}
方法覆盖:外层类型可以定义与嵌入类型同名的方法,外层优先:
go
type Dog struct {
Animal
}
// Dog 自己定义 Speak,覆盖 Animal.Speak
func (d Dog) Speak() string {
return "汪汪!"
}
组合优于继承 :
Dog拥有Animal的所有能力(字段+方法),又不受继承的限制。这是 Go 官方的设计哲学------复用通过组合实现,行为扩展通过接口实现。
8.5 struct 标签(tag)
结构体字段可以带标签(反引号包裹的字符串),常用于 JSON 序列化、数据库映射、表单验证等。最常见的用途是 JSON:
go
// 完整可运行程序:struct tag
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"` // 序列化时字段名变成 name
Age int `json:"age,omitempty"` // 值为零值时省略该字段
Pass string `json:"-"` // - 表示永远不参与序列化
}
func main() {
u := User{Name: "小明", Age: 20, Pass: "secret123"}
data, err := json.Marshal(u)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(data)) // {"name":"小明","age":20}
// omitempty 生效:Age 为 0 时被省略
u2 := User{Name: "小红", Pass: "x"}
data2, _ := json.Marshal(u2)
fmt.Println(string(data2)) // {"name":"小红"}
}
注意:只有导出的字段(首字母大写)才能被 encoding/json 序列化。小写字段会被静默忽略,这是新手最常见的「JSON 丢字段」原因。
8.6 构造函数惯例
Go 没有构造函数语法,惯例是用 NewXxx 函数返回结构体指针:
go
// 完整可运行程序:构造函数惯例
package main
import "fmt"
type Person struct {
Name string
Age int
}
// 构造函数惯例:NewXxx,返回指针
func NewPerson(name string, age int) *Person {
// 可以做参数校验、默认值等初始化逻辑
if age < 0 {
age = 0
}
return &Person{Name: name, Age: age}
}
func main() {
p := NewPerson("小明", -5)
fmt.Println(p) // &{小明 0},非法年龄被纠正为 0
}
8.7 结构体的内存布局与对齐(进阶)
结构体的字段在内存中连续存放 ,但为了 CPU 高效访问,编译器会做内存对齐(alignment):每个字段的起始地址必须是其类型大小(对齐值)的整数倍,结构体总大小对齐到所有字段中最大的对齐值。对齐会在字段之间或末尾引入**填充(padding)**空隙。
64 位平台的对齐值速查:bool/byte 为 1,int16 为 2,int32/float32 为 4,int/int64/float64/指针 为 8。
go
// 完整可运行程序:验证结构体对齐
package main
import (
"fmt"
"unsafe"
)
type A struct {
a bool // 1 字节
b int64 // 8 字节
}
type C struct {
a bool // 1 字节
b int32 // 4 字节
c bool // 1 字节
}
type D struct {
b int32 // 4 字节
a bool // 1 字节
c bool // 1 字节
}
func main() {
fmt.Println("A 大小:", unsafe.Sizeof(A{})) // 16:1 + 7 填充 + 8
fmt.Println("C 大小:", unsafe.Sizeof(C{})) // 12:1 + 3 填充 + 4 + 1 + 3 尾部填充
fmt.Println("D 大小:", unsafe.Sizeof(D{})) // 8:4 + 1 + 1 + 2 尾部填充
fmt.Println("A 对齐值:", unsafe.Alignof(A{})) // 8
}
注意 C 和 D 字段相同、顺序不同,大小分别是 12 和 8------字段顺序影响结构体占用的内存。
实际影响:
- 大量小结构体(缓存对象、网络消息、游戏实体)时,合理排列字段(大字段在前)能节省可观内存
- 但这是实现细节,普通业务代码不必刻意优化;只有内存敏感或性能敏感场景才值得调整字段顺序
- 判断字段是否需要按特定顺序是编译器/内存对齐的责任,新手只需知道「结构体大小 ≠ 字段大小之和」即可
8.8 本章小结
type Xxx struct { ... }定义结构体;Xxx{字段: 值}初始化- 结构体是值类型;传指针可修改外部
- 方法:值接收者 / 指针接收者(要修改字段、大结构体、拿不准 → 指针接收者)
- 组合代替继承:匿名嵌入,字段与方法自动提升,可覆盖
- struct tag 配合 JSON 序列化;只有大写字段才参与序列化
- 构造函数惯例:
NewXxx(...) *Xxx
本章练习:
- 定义
Circle结构体(半径),实现Area()和Perimeter()方法。 - 定义
BankAccount结构体(余额),用指针接收者实现Deposit、Withdraw方法,取款余额不足时返回错误。 - 定义
Car嵌入Engine,验证字段和方法的提升。 - 给一个结构体加 JSON tag,序列化后观察输出。
第 9 章 接口
接口(interface)是 Go 实现多态 的机制,也是 Go 类型系统最精巧的设计之一。核心思想:接口定义一组方法,任何类型只要实现了这些方法,就自动满足该接口------不需要显式声明(这就是「鸭子类型」:走起来像鸭子、叫起来像鸭子,那它就是鸭子)。
9.1 定义与实现
go
// 完整可运行程序:接口基础
package main
import (
"fmt"
"math"
)
// 定义接口:声明一组方法(只有签名,没有实现)
type Shape interface {
Area() float64
}
// Rectangle 实现了 Area 方法 → 自动满足 Shape 接口
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// Circle 也实现了 Area 方法 → 也满足 Shape 接口
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
// 接口作为参数:任何实现了 Shape 的类型都可以传进来(多态)
func printArea(s Shape) {
fmt.Printf("面积:%.2f\n", s.Area())
}
func main() {
r := Rectangle{Width: 4, Height: 5}
c := Circle{Radius: 3}
printArea(r) // 面积:20.00
printArea(c) // 面积:28.27
// 接口类型的变量:可以存放任何实现了该接口的值
var s Shape = r
fmt.Println(s.Area()) // 20
}
关键点:
- 接口只描述「能做什么」(方法集合),不关心「是什么」
- 实现是隐式的 :没有
implements关键字,实现了方法就自动满足接口 - 接口变量可以保存任何实现了该接口的具体值
- 通过接口调用方法时,实际执行的是动态类型的方法(多态)
9.2 接口嵌套
接口可以嵌套组合成更大的接口(类似结构体嵌入):
go
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// ReadWriter 组合了 Reader 和 Writer
type ReadWriter interface {
Reader
Writer
}
标准库大量使用这种组合,比如
io.ReadWriter、io.ReadCloser。
9.3 空接口 interface{} 与 any
空接口没有方法,任何类型的值都能赋给它(类似 Java 的 Object、C 的 void*):
go
// 完整可运行程序:空接口
package main
import "fmt"
func main() {
var v interface{} // 可以放任何东西
v = 42
fmt.Println(v) // 42
v = "hello"
fmt.Println(v) // hello
v = []int{1, 2, 3}
fmt.Println(v) // [1 2 3]
// Go 1.18 起,any 是 interface{} 的别名,两者完全等价
var a any = 3.14
fmt.Println(a) // 3.14
// 打印任意类型的函数
printAnything := func(x any) {
fmt.Printf("值=%v 类型=%T\n", x, x)
}
printAnything(42) // 值=42 类型=int
printAnything("hi") // 值=hi 类型=string
}
警告 :空接口虽然万能,但滥用会让代码失去类型安全(编译期查不出类型错误)。只在确实需要「接受任意类型」时使用(如
fmt.Println、JSON 解析)。
9.4 类型断言(type assertion)
空接口/接口变量里存的具体类型,可以用类型断言取出来:
go
// 完整可运行程序:类型断言
package main
import "fmt"
func main() {
var v any = 42
// 断言成功:得到 int 类型的值
n := v.(int)
fmt.Println(n + 1) // 43
// 不安全断言:类型不匹配会 panic!
// s := v.(string) // panic: interface conversion: interface {} is int, not string
// 安全断言(推荐):带 ok 的写法,不会 panic
s, ok := v.(string)
if ok {
fmt.Println("是字符串:", s)
} else {
fmt.Println("不是字符串") // 走这里
}
// 对接口做断言
var s2 any = "hello"
// 断言成另一个接口类型
r, ok := s2.(fmt.Stringer)
_ = r
fmt.Println("实现了 Stringer?", ok) // false(string 没实现 Stringer)
}
语法 :value, ok := i.(Type)。ok 为 true 表示断言成功,value 是转换后的值;失败时 value 是 Type 的零值,且不会 panic(用不带 ok 的写法失败会 panic)。
9.5 类型 switch(type switch)
连续判断接口的动态类型时,用 type switch 更优雅:
go
// 完整可运行程序:type switch
package main
import "fmt"
func describe(x any) string {
switch v := x.(type) { // 注意语法:x.(type) 只能在 switch 里用
case int:
return fmt.Sprintf("整数 %d", v)
case float64:
return fmt.Sprintf("浮点数 %.2f", v)
case string:
return fmt.Sprintf("字符串 %q", v)
case bool:
return fmt.Sprintf("布尔 %v", v)
case []int:
return fmt.Sprintf("整数切片 %v", v)
default:
return fmt.Sprintf("未知类型 %T", v)
}
}
func main() {
fmt.Println(describe(42)) // 整数 42
fmt.Println(describe(3.14)) // 浮点数 3.14
fmt.Println(describe("hi")) // 字符串 "hi"
fmt.Println(describe([]int{1, 2})) // 整数切片 [1 2]
fmt.Println(describe(struct{}{})) // 未知类型 struct {}
}
9.6 接口的 nil 陷阱(高频面试题!)
接口变量为 nil 的条件是:动态类型和动态值都为 nil。 一个常见的坑:把 nil 指针赋给接口,接口不是 nil:
go
// 完整可运行程序:接口 nil 陷阱
package main
import "fmt"
type MyError struct{ Msg string }
func (e *MyError) Error() string { return e.Msg }
// 返回 error 接口:注意这里返回的是 *MyError 类型
func getError() error {
var e *MyError = nil // 类型是 *MyError,值为 nil
return e // 注意!返回时 e 被包装进接口:类型=*MyError,值=nil
}
func main() {
err := getError()
fmt.Println(err == nil) // false!接口里存了类型信息,所以不是 nil
// 但调用方法会怎样?
if err != nil {
fmt.Println("有错误!") // 会走进来
// fmt.Println(err.Error()) // 这里实际会 panic!因为值部分是 nil
}
}
教训 :函数返回 error 时,不要返回「值为 nil 的指针类型」 。正确做法:成功时直接
return nil(返回裸 nil,而不是var e *MyError = nil; return e)。
9.7 常用的内置接口
| 接口 | 方法 | 用途 |
|---|---|---|
error |
Error() string |
错误值(第 10 章详讲) |
fmt.Stringer |
String() string |
控制 %v / Println 如何打印你的类型 |
fmt.GoStringer |
GoString() string |
控制 %#v 的打印 |
io.Reader |
Read(p []byte) (n int, err error) |
读取数据流(文件、网络、字符串都实现它) |
io.Writer |
Write(p []byte) (n int, err error) |
写入数据流 |
sort.Interface |
Len() / Less(i,j) / Swap(i,j) |
自定义排序 |
实现 Stringer 的例子(非常常用,能让调试输出变优雅):
go
// 完整可运行程序:Stringer 接口
package main
import "fmt"
type Point struct{ X, Y int }
// 实现 fmt.Stringer:自定义打印格式
func (p Point) String() string {
return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}
func main() {
p := Point{3, 4}
fmt.Println(p) // (3, 4)
fmt.Printf("%v\n", p) // (3, 4)
// %#v 仍然显示原始结构体
fmt.Printf("%#v\n", p) // main.Point{X:3, Y:4}
}
9.8 接口与指针接收者的注意事项
只有指针类型才能满足「指针接收者方法」组成的接口:
go
// 完整可运行程序:指针接收者与接口
package main
import "fmt"
type Counter struct{ n int }
// 指针接收者方法
func (c *Counter) Inc() { c.n++ }
// 只有 *Counter 实现的方法
type Increaser interface {
Inc()
}
func main() {
c := Counter{}
// var i Increaser = c // 编译错误:Counter 没有实现 Inc(Inc 是 *Counter 的方法)
var i Increaser = &c // 正确:*Counter 实现了
i.Inc()
fmt.Println(c.n) // 1
}
规则:接口要求的方法如果定义在值接收者 上,则值和指针都满足;如果定义在指针接收者 上,则只有指针 满足。所以「方法统一用指针接收者」的代码,接口赋值时通常也要传
&xxx。
9.9 接口的底层结构(iface / eface,进阶)
接口变量在内存里是两个字段的描述符:
go
// 非空接口(有方法):16 字节
type iface struct {
tab *itab // 接口类型 + 具体类型 + 方法跳转表
data unsafe.Pointer // 指向具体数据
}
// 空接口 interface{} / any:16 字节
type eface struct {
_type *_type // 具体类型的描述信息
data unsafe.Pointer // 指向具体数据
}
go
// 完整可运行程序:验证接口变量大小
package main
import (
"fmt"
"unsafe"
)
type Shape interface{ Area() float64 }
func main() {
var s Shape
var e any
fmt.Println(unsafe.Sizeof(s)) // 16
fmt.Println(unsafe.Sizeof(e)) // 16
}
由这个结构推导出的重要结论:
- 把值赋给接口时,值会被拷贝 ,
data指向这份拷贝(小值直接内联进 data 区域,大值在堆上分配后由 data 指向) - 接口变量 = 「类型 + 数据」两部分 ------这就是 9.6 节 nil 陷阱的底层原因:
var p *T = nil; var i I = p时,data是 nil,但tab/_type记录了类型信息,所以i != nil - 接口比较 :先比类型,类型相同再比数据;如果动态值是切片/map/函数这类不可比较类型,比较会 panic(runtime error: comparing uncomparable type)
- 接口方法调用比直接调用慢一点 :要通过
tab.fun跳转表间接调用(动态分发),这是多态的代价;Go 编译器对部分场景会做 devirtualization 优化(类型已知时直接调用)
9.10 本章小结
- 接口定义方法集合,实现是隐式的(鸭子类型)
- 接口变量可以存任何实现者 → 多态
- 空接口
interface{}/any:接受任意类型,但失去类型安全 - 类型断言
v, ok := i.(T):安全取出动态类型;type switch 批量判断 - nil 陷阱:接口 nil = 类型和值都 nil;不要返回值为 nil 的指针错误
- 常用接口:
error、fmt.Stringer、io.Reader/Writer、sort.Interface
本章练习:
- 定义
Speaker接口(Speak() string),让Dog、Cat、Robot实现它,并写一个函数接收Speaker切片,逐一让它们说话。 - 写函数
sumNumbers(values []any) float64,把切片中的 int 和 float64 加起来(用类型断言)。 - 给自己的结构体实现
String()方法,验证 Println 输出变化。 - 复现 9.6 的 nil 陷阱,并修正为正确写法。
第 10 章 错误处理与 panic/recover
Go 的错误处理哲学与 Java/Python 完全不同:没有 try-catch 异常机制,错误是普通的值,通过返回值显式传递。这迫使程序员在每个可能出错的地方都「直面错误」,代码更稳健。
10.1 error 是一个接口
error 是 Go 内置的接口:
go
type error interface {
Error() string
}
任何实现了 Error() string 方法的类型都是 error。错误就是一个值,可以赋值、传递、比较。
Go 的错误处理惯例 :函数可能出错时,返回 (结果, error);成功时 error 为 nil,失败时结果无意义(通常是零值):
go
// 完整可运行程序:错误处理惯例
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为 0") // 返回错误
}
return a / b, nil // 成功:error 是 nil
}
func main() {
result, err := divide(10, 0)
if err != nil { // 先检查错误
fmt.Println("出错了:", err) // 出错了: 除数不能为 0
return
}
fmt.Println("结果:", result)
}
铁律 :if err != nil { ... } 必须第一时间处理。看到 err 就立刻写检查,这是 Go 程序员的肌肉记忆。
10.2 创建错误的三种方式
go
// 完整可运行程序:创建错误
package main
import (
"errors"
"fmt"
)
func main() {
// 方式一:errors.New(最常用)
err1 := errors.New("这是一个新错误")
fmt.Println(err1) // 这是一个新错误
// 方式二:fmt.Errorf(需要格式化内容时)
name := "config.yaml"
err2 := fmt.Errorf("打开文件 %s 失败", name)
fmt.Println(err2) // 打开文件 config.yaml 失败
// 方式三:自定义错误类型(携带更多信息,见 10.4)
}
10.3 错误的包装与解包(Go 1.13+)
包装(wrapping) :在错误上附加上下文信息,保留原始错误------用 fmt.Errorf 的 %w 动词:
go
// 完整可运行程序:错误包装
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("资源不存在") // 哨兵错误(sentinel error)
func findUser(id int) error {
if id != 1 {
return fmt.Errorf("查询用户 %d 失败:%w", id, ErrNotFound) // %w 包装
}
return nil
}
func main() {
err := findUser(100)
if err != nil {
fmt.Println(err) // 查询用户 100 失败:资源不存在
// errors.Is:判断错误链中是否包含某个目标错误(类似"instanceof")
if errors.Is(err, ErrNotFound) {
fmt.Println("确实是「资源不存在」错误") // 会打印
}
}
}
errors.Is(err, target):沿着错误链查找,判断是否「等于」某个目标错误。适合比较哨兵错误 (包级导出的var ErrXxx = errors.New(...))errors.As(err, &target):沿着错误链查找,判断是否能「赋值给」某个错误类型。适合取出自定义错误类型里的额外字段
go
// 完整可运行程序:errors.As
package main
import (
"errors"
"fmt"
)
// 自定义错误类型:带错误码
type MyError struct {
Code int
Message string
}
// 实现 error 接口
func (e *MyError) Error() string {
return fmt.Sprintf("错误码 %d:%s", e.Code, e.Message)
}
func doSomething() error {
return &MyError{Code: 500, Message: "内部错误"}
}
func main() {
err := doSomething()
// 方式一:类型断言(只能处理"顶层"类型,包装过就失效)
if e, ok := err.(*MyError); ok {
fmt.Println("错误码:", e.Code) // 错误码: 500
}
// 方式二:errors.As(可以穿透包装层,推荐)
var myErr *MyError
if errors.As(err, &myErr) {
fmt.Printf("捕获到 MyError,码=%d,消息=%s\n", myErr.Code, myErr.Message)
}
}
%w只能用在fmt.Errorf中包装一个 错误。要包装多个错误用errors.Join(err1, err2)(Go 1.20+)。
10.4 自定义错误类型
什么时候需要自定义错误类型?当调用方需要根据错误类型做不同处理(如错误码、重试判断、HTTP 状态码)时:
go
// 完整可运行程序:自定义错误
package main
import (
"errors"
"fmt"
)
// 网络请求错误:带状态码
type HTTPError struct {
Status int
URL string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("请求 %s 失败,状态码 %d", e.URL, e.Status)
}
func fetch(url string) error {
// 模拟:用户不存在返回 404
return &HTTPError{Status: 404, URL: url}
}
func main() {
err := fetch("https://example.com/user/1")
var httpErr *HTTPError
if errors.As(err, &httpErr) {
switch httpErr.Status {
case 404:
fmt.Println("资源不存在,提示用户") // 走这里
case 500:
fmt.Println("服务器错误,稍后重试")
default:
fmt.Println("其他错误")
}
}
}
10.5 panic 与 recover
panic:程序崩溃
panic 让程序立即停止执行当前函数,逐层向上退出(运行所有已注册的 defer),最后打印错误信息并崩溃:
go
// 完整可运行程序:panic 基础
package main
import "fmt"
func main() {
fmt.Println("开始")
panic("出大事了!") // 程序在这里崩溃
fmt.Println("这行不会执行")
}
// 输出:
// 开始
// panic: 出大事了!
// goroutine 1 [running]: ...(堆栈信息)
// exit status 2
触发 panic 的常见情况(都是程序 bug,不是正常错误):
- 数组/切片下标越界
- 对 nil map 赋值、对 nil 指针解引用
- 类型断言失败(不带 ok 的写法)
- 显式调用
panic()(通常不推荐)
recover:捕获 panic
recover 可以「抓住」panic,让程序继续运行。recover 只在 defer 函数中有效:
go
// 完整可运行程序:recover
package main
import "fmt"
func safeDivide(a, b int) (result int, err error) {
// 匿名函数里调用 recover:panic 发生时从这里恢复
defer func() {
if r := recover(); r != nil { // r 是 panic 传入的值
err = fmt.Errorf("捕获到 panic:%v", r)
}
}()
return a / b, nil // b 为 0 时这里会 panic,被上面捕获
}
func main() {
result, err := safeDivide(10, 0)
if err != nil {
fmt.Println("调用失败:", err) // 调用失败: 捕获到 panic:runtime error: integer divide by zero
} else {
fmt.Println("结果:", result)
}
fmt.Println("程序继续运行!") // 证明没有崩溃
}
panic vs error:怎么选?
| 场景 | 选择 |
|---|---|
| 可预期的错误(文件不存在、参数非法、网络失败) | 返回 error(99% 的情况) |
| 不可恢复的致命问题(配置错误、端口被占用) | panic 或 log.Fatal |
| 编程错误(越界、空指针) | 让程序 panic 崩溃(这是 bug,早崩溃早发现) |
| 库代码 | 绝不 panic,一律返回 error(panic 会杀掉调用方的程序) |
黄金法则 :错误是值,能返回就返回;panic 是例外机制,不是日常错误处理手段。
recover只在「程序的最外层」兜底(如 Web 服务的一个请求处理函数崩了不能拖垮整个服务)。
10.6 错误处理的常见模式
go
// 模式一:直接返回(最常见的链式)
// func readConfig(path string) (*Config, error) {
// data, err := os.ReadFile(path)
// if err != nil {
// return nil, err // 原样返回
// }
// ...
// }
// 模式二:包装后返回(加上下文)
// func readConfig(path string) (*Config, error) {
// data, err := os.ReadFile(path)
// if err != nil {
// return nil, fmt.Errorf("读取配置 %s 失败:%w", path, err)
// }
// ...
// }
// 模式三:临时变量 + 简短命名
// 惯例:错误变量统一叫 err;只要一个错误就用 if err := fn(); err != nil { ... }
if err := os.MkdirAll("data", 0755); err != nil {
fmt.Println("创建目录失败:", err)
}
// 模式四:defer + 命名返回值收尾错误
// func save() (err error) {
// defer func() {
// if err != nil {
// fmt.Println("保存失败:", err)
// }
// }()
// ...
// }
10.7 本章小结
error是接口:Error() string- 惯例:
(结果, error)返回;err != nil第一时间处理 - 创建错误:
errors.New/fmt.Errorf/ 自定义类型 - 包装与判断:
%w包装,errors.Is比哨兵错误,errors.As取自定义类型 panic崩溃、recover在 defer 中恢复;日常用 error,panic 是最后手段
本章练习:
- 写一个
parseAge(s string) (int, error):空字符串或非数字返回 error,负数返回 error,正常返回年龄。 - 定义
ValidationError结构体(字段名 + 原因),用 errors.As 提取并打印。 - 用 panic/recover 写一个不崩溃的「除以零」函数。
- 读代码:为什么库函数里不应该 panic?
第 11 章 包与模块
大型程序需要组织代码:Go 用 包(package) 组织源码,用 模块(module) 管理依赖和版本。
11.1 包(package)基础
包 = 一组 .go 文件的集合,它们共享包名、彼此可以访问对方未导出的标识符。一个目录下的 .go 文件必须属于同一个包。
包名与导入
go
// 文件结构:
// myapp/
// ├── go.mod (module myapp)
// ├── main.go (package main)
// └── mathx/
// └── mathx.go (package mathx)
go
// mathx/mathx.go
package mathx
// 导出的函数(首字母大写):其他包可以调用
func Add(a, b int) int {
return a + b
}
// 未导出的函数(首字母小写):只能在 mathx 包内使用
func secretHelper() int {
return 42
}
go
// main.go
package main
import (
"fmt"
"myapp/mathx" // 导入路径 = 模块名 + 目录路径
)
func main() {
fmt.Println(mathx.Add(1, 2)) // 3
// mathx.secretHelper() // 编译错误:不能访问未导出的函数
}
可见性规则(记住这一条就够了) :首字母大写 = 导出(其他包可见);首字母小写 = 私有(本包可见)。适用于变量、函数、类型、结构体字段、方法。
import 的四种写法
go
import "fmt" // 基本导入
import f "fmt" // 别名导入:f.Println(...)
import . "fmt" // 点导入:直接 Println(...)(不推荐,污染命名空间)
import _ "image/png" // 匿名导入:只执行包的 init 函数(如注册解码器)
// 多个包分组
import (
"fmt"
"os"
)
导入规则 :导入的包必须被使用 ,否则编译错误;包名冲突时用别名解决(如两个包都叫 redis)。
init 函数
每个包可以有多个 init() 函数(无参数无返回值),在 main 之前自动执行,用于初始化:
go
// 完整可运行程序:init 执行顺序
package main
import "fmt"
// init 在 main 之前自动调用
func init() {
fmt.Println("init 执行了")
}
func main() {
fmt.Println("main 执行了")
}
// 输出:
// init 执行了
// main 执行了
执行顺序:依赖的包先 init → 本包 init → main。一个包被多个地方依赖也只会初始化一次。日常开发 init 用得不多(很多初始化可以在变量声明时完成),知道机制即可。
11.2 模块(module)与 go.mod
模块是 Go 项目的单位 ,go.mod 文件记录模块名、Go 版本、依赖清单。
常用命令速查
bash
# 初始化模块(模块名通常用域名/路径格式,或简单名)
go mod init myapp
# 添加依赖:import 了第三方包后执行(自动下载并写入 go.mod)
go get github.com/gin-gonic/gin
# 整理依赖:删除未使用的、补充缺失的(提交代码前必做)
go mod tidy
# 查看依赖
go list -m all
# 升级依赖
go get github.com/gin-gonic/gin@latest
go get github.com/gin-gonic/gin@v1.9.0 # 指定版本
# 校验依赖完整性
go mod verify
go mod tidy 之后 go.mod 类似:
module myapp
go 1.25.0
require github.com/gin-gonic/gin v1.10.0
第三方代码下载到 GOPATH/pkg/mod 缓存(第 2 章建议移到非 C 盘目录,就是这个原因)。
依赖版本选择
go get 包@latest:最新版本go get 包@v1.9.0:指定版本- Go 1.21+ 的 go.mod 可能包含
toolchain指令,一般不用手动管
11.3 标准库与第三方库
标准库:随 Go 安装,无需下载。常用包一览(后续章节详讲):
| 包 | 用途 |
|---|---|
fmt |
格式化输入输出 |
os |
操作系统接口(文件、环境变量、命令行参数) |
strings / strconv |
字符串处理、类型转换 |
time |
时间与定时 |
encoding/json |
JSON 编解码 |
net/http |
HTTP 服务端/客户端 |
io / bufio |
数据流读写 |
sort |
排序 |
math |
数学函数 |
log |
日志 |
sync |
并发同步(Mutex、WaitGroup) |
flag |
命令行参数解析 |
regexp |
正则表达式 |
crypto/* |
加密与哈希 |
testing |
单元测试 |
context |
上下文(超时、取消) |
reflect |
反射(进阶) |
runtime |
运行时信息(进阶) |
第三方库 :通过 go get 安装,最常用的几个方向:
- Web 框架:
gin、echo、fiber、chi - 数据库驱动:
database/sql+go-sql-driver/mysql、pgx(PostgreSQL)、mongo-driver - ORM:
gorm - 日志:
zerolog、zap、logrus - 命令行:
cobra - 配置:
viper - HTTP 客户端:官方
net/http或resty - Redis:
go-redis/redis
找库的入口:https://pkg.go.dev(官方文档站,可以搜包、看文档)。
11.4 项目目录结构建议(小中型项目)
myapp/
├── go.mod
├── main.go # 入口:解析参数、组装、启动
├── internal/ # 内部包(禁止被外部模块导入)
│ ├── handler/ # HTTP 处理器
│ ├── service/ # 业务逻辑
│ └── model/ # 数据结构
├── pkg/ # 可对外复用的包(可选)
├── config/ # 配置文件
└── testdata/ # 测试数据
internal/目录是 Go 的语法级保护:internal及其子目录只能被同一模块 (更准确说:同根源目录树)的代码导入,外部项目import会编译报错。这是组织私有代码的官方推荐方式。
11.5 本章小结
- 一个目录一个包;包名与目录名通常一致(
package mathx在mathx/目录) - 可见性 = 首字母大小写
- 导入:基本 / 别名 / 点导入 / 匿名导入
init在 main 前执行;依赖先初始化- 模块命令:
go mod init/go get/go mod tidy - 标准库包熟悉度决定开发效率;第三方库从 pkg.go.dev 找
本章练习:
- 创建项目
calculator,把加减乘除放进calculator/ops包,main 调用它。 - 验证:在 ops 包里加一个未导出函数,main 里调用看编译错误。
- 用
go get安装github.com/sirupsen/logrus,写一段使用它的日志代码,然后go mod tidy。 - 查看
go list -m all的输出,理解依赖树。
第 12 章 并发编程
并发是 Go 的灵魂。Go 的设计目标之一就是「让并发编程简单到每个人都能用」。本章是全文最重要的一章,请放慢速度仔细读。
12.1 并发与并行
- 并发(concurrency) :多个任务交替执行(单核也能并发,靠时间片切换),关注的是「结构上能同时处理多件事」
- 并行(parallelism) :多个任务同时执行(需要多核),关注的是「同一时刻真的在同时跑」
Rob Pike 的名言:「并发是一种程序设计方式,并行是执行方式。并发不一定是并行。」Go 的并发原语(goroutine + channel)解决的是并发编程的结构问题,而并行是运行时的自然结果(多核自动并行)。
12.2 goroutine:轻量级线程
goroutine 是 Go 的并发执行单元 。在函数调用前加一个 go 关键字,该函数就在新的 goroutine 中异步执行:
go
// 完整可运行程序:第一个 goroutine
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s, i)
time.Sleep(100 * time.Millisecond) // 模拟耗时
}
}
func main() {
go say("异步") // 在新的 goroutine 里执行
say("同步") // 主 goroutine 自己执行
}
goroutine 的特点:
- 极轻量 :初始栈只有几 KB(线程默认 1-8MB),一个程序轻松跑数十万个 goroutine
- 调度由 Go 运行时管理(M:N 调度:大量 goroutine 映射到少量 OS 线程),不需要手动创建线程
- main 函数所在的 goroutine 结束,整个程序就结束------即使其他 goroutine 没跑完
go
// 完整可运行程序:main 结束则程序结束
package main
import "fmt"
func main() {
go func() {
fmt.Println("这个可能永远不打印") // 主 goroutine 不等待它
}()
fmt.Println("main 结束")
}
// 可能只输出:main 结束
所以 :需要等待 goroutine 完成时,用 sync.WaitGroup(12.5)或 channel(12.3)。
12.3 channel:goroutine 之间的通道
channel(通道)是 goroutine 之间传递数据的管道 ,自带同步功能。Go 的并发哲学:「不要通过共享内存来通信,而要通过通信来共享内存」------goroutine 之间不共享变量,而是通过 channel 传递数据,从结构上消灭数据竞争。
12.3.1 创建与基本使用
go
// 完整可运行程序:channel 基础
package main
import "fmt"
func main() {
// 创建 int 类型的 channel(无缓冲)
ch := make(chan int)
// 发送:ch <- 值
// 接收:v := <-ch
// 在另一个 goroutine 中发送
go func() {
ch <- 42 // 发送数据
}()
v := <-ch // 主 goroutine 接收(会阻塞直到有数据)
fmt.Println(v) // 42
}
channel 的两大核心操作:
ch <- v:发送v := <-ch:接收
channel 像水管 :一端进水(发送),一端出水(接收)。没有缓冲的 channel,发送和接收必须同时准备好 ------发送方阻塞直到有人接收,接收方阻塞直到有人发送。上面的例子中,go func 里发送 42,主 goroutine 接收,两者「接头」成功,数据传递完成。这就是 goroutine 之间的同步机制。
12.3.2 有缓冲 channel
go
// 完整可运行程序:有缓冲 channel
package main
import "fmt"
func main() {
// 缓冲容量为 2:可以存 2 个值而不阻塞
ch := make(chan int, 2)
ch <- 1 // 不阻塞(缓冲空)
ch <- 2 // 不阻塞(缓冲有 1 个空位)
// ch <- 3 // 会阻塞!缓冲已满,直到有人取走
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
}
| 无缓冲 channel | 有缓冲 channel | |
|---|---|---|
| 发送时 | 阻塞直到有人接收 | 缓冲未满不阻塞 |
| 接收时 | 阻塞直到有人发送 | 缓冲非空不阻塞 |
| 用途 | 同步、一对一交接 | 生产者-消费者、限流 |
选择建议 :多数场景用无缓冲 channel(同步语义清晰);需要解耦生产与消费速度时用有缓冲的。
12.3.3 close 与 range 遍历
发送方完成所有发送后可以 close(ch) 关闭通道。关闭后:
- 接收方仍可取出缓冲中剩余的数据
- 数据取完后,接收返回零值;用
v, ok := <-ch判断,ok == false表示通道已关闭且无数据 - 向已关闭的 channel 发送会 panic
- 关闭已关闭的 channel 会 panic
go
// 完整可运行程序:close 与 range
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 发送完毕,关闭
// range 会持续接收直到通道关闭
for v := range ch {
fmt.Println(v) // 1 2 3
}
// 关闭后接收:返回零值
v, ok := <-ch
fmt.Println(v, ok) // 0 false(ok=false 表示已关闭且空)
}
经验法则 :发送方负责 close。接收方不要关闭通道。不需要显式 close 也行(垃圾回收会处理),但 range 遍历和「判断是否结束」需要 close。
12.3.4 生产者-消费者模式
go
// 完整可运行程序:生产者消费者
package main
import (
"fmt"
"time"
)
// 生产者:产生 0..n-1 发送到 channel
func produce(ch chan<- int, n int) { // chan<- 表示只写通道(单向)
for i := 0; i < n; i++ {
ch <- i
fmt.Println("生产:", i)
time.Sleep(50 * time.Millisecond)
}
close(ch) // 生产完毕,关闭
}
func main() {
ch := make(chan int, 2)
go produce(ch, 5)
// 消费者:不断接收直到通道关闭
for v := range ch {
fmt.Println("消费:", v)
}
fmt.Println("全部消费完毕")
}
12.3.5 单向 channel
函数参数可以限定 channel 方向,增强安全性(防止误用):
go
// chan<- int:只能发送
func sendOnly(ch chan<- int) { ch <- 1 }
// <-chan int:只能接收
func receiveOnly(ch <-chan int) int { return <-ch }
// 双向 channel 可以传给单向参数(自动转换),反之不行
日常写代码时,函数参数尽量声明为单向 :函数只负责发送就写
chan<- T,只负责接收就写<-chan T。这是 Go 社区的良好习惯。
12.3.6 用 channel 等待 goroutine 完成
go
// 完整可运行程序:channel 等待
package main
import (
"fmt"
"time"
)
func worker(name string, done chan<- bool) {
time.Sleep(200 * time.Millisecond) // 模拟工作
fmt.Println(name, "完成")
done <- true // 通知完成
}
func main() {
done := make(chan bool, 2) // 用缓冲避免接收不及时
go worker("任务A", done)
go worker("任务B", done)
// 等待两个任务完成
<-done
<-done
fmt.Println("所有任务完成")
}
12.4 select:多路等待
select 可以同时等待多个 channel 操作,哪个先就绪就执行哪个(多个同时就绪则随机选一个):
go
// 完整可运行程序:select 基础
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
// 两个 goroutine 在不同时间发送
go func() {
time.Sleep(100 * time.Millisecond)
ch1 <- "来自通道1"
}()
go func() {
time.Sleep(300 * time.Millisecond)
ch2 <- "来自通道2"
}()
// 谁先到就处理谁
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
}
}
}
// 输出:
// 来自通道1(100ms 先到)
// 来自通道2(300ms 后到)
select 的三种经典用法:
go
// 用法一:超时控制(最常用!)
ch := make(chan int)
go func() {
time.Sleep(2 * time.Second) // 模拟慢操作
ch <- 1
}()
select {
case v := <-ch:
fmt.Println("收到结果:", v)
case <-time.After(1 * time.Second): // 1 秒超时
fmt.Println("操作超时!")
}
// 用法二:非阻塞尝试接收(default 分支)
select {
case v := <-ch:
fmt.Println("有数据:", v)
default:
fmt.Println("通道没有数据,不等待")
}
// 用法三:优雅退出(配合退出信号 channel)
// 典型模式:select 中监听 stop 通道,收到就退出循环
stop := make(chan struct{}) // struct{} 零开销信号通道
go func() {
time.Sleep(500 * time.Millisecond)
close(stop) // 广播:close 后所有 <-stop 立即返回
}()
select {
case <-stop:
fmt.Println("收到停止信号")
}
select 注意事项:
case里可以是发送或接收- 多个 case 同时就绪时随机选择
- nil channel 永远不可用(select 会忽略它)------可以临时把 channel 置 nil 来禁用某个 case
- 没有可用 case 且没有 default 时,select 阻塞
12.5 sync.WaitGroup:等待一组 goroutine
当需要「启动 N 个 goroutine,等全部完成后继续」时,用 WaitGroup:
go
// 完整可运行程序:WaitGroup
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 3. 完成时计数减 1(用 defer 保证一定执行)
fmt.Println("工人", id, "开始工作")
time.Sleep(100 * time.Millisecond)
fmt.Println("工人", id, "完成")
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // 1. 计数加 1(在启动 goroutine 前调用)
go worker(i, &wg) // 2. 启动 goroutine(注意传指针!)
}
wg.Wait() // 4. 阻塞等待计数归零
fmt.Println("所有工人完成,主程序继续")
}
WaitGroup 使用铁律:
Add(1)必须在go之前调用(在 goroutine 内部 Add 可能来不及)Done()必须用defer保证执行- 必须传指针
&wg(WaitGroup 是值类型,复制会破坏计数) Wait()之后不要复用(除非重新 Add)
12.6 互斥锁:sync.Mutex 与 sync.RWMutex
当多个 goroutine 共享读写同一份数据时,必须加锁防止数据竞争:
go
// 完整可运行程序:Mutex 保护共享数据
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // 加锁
counter++ // 临界区:同一时刻只有一个 goroutine 能进来
mu.Unlock() // 解锁(或 defer mu.Unlock())
}()
}
wg.Wait()
fmt.Println("counter =", counter) // 1000(没有锁时经常 < 1000)
}
不加锁会怎样? counter++ 是「读-加-写」三步操作,多个 goroutine 交错执行会丢失更新。用 go run -race 还能检测出来(见 12.8)。
Mutex 使用要点:
Lock()与Unlock()必须成对出现(推荐defer mu.Unlock())- 不要复制 Mutex(拷贝后的锁互不相干);结构体包含 Mutex 时,使用结构体也要用指针
- 锁内不要做耗时操作(I/O、网络请求),锁的粒度要小
- 小心死锁:同一个 goroutine 对同一把锁 Lock 两次会死锁
RWMutex(读写锁):读多写少的场景,多个读可以并行,写独占:
go
// 完整可运行程序:RWMutex
package main
import (
"fmt"
"sync"
"time"
)
type SafeCache struct {
mu sync.RWMutex
items map[string]string
}
func (c *SafeCache) Get(key string) string {
c.mu.RLock() // 读锁:可以多个 goroutine 同时持有
defer c.mu.RUnlock()
return c.items[key]
}
func (c *SafeCache) Set(key, value string) {
c.mu.Lock() // 写锁:独占
defer c.mu.Unlock()
c.items[key] = value
}
func main() {
cache := &SafeCache{items: make(map[string]string)}
// 多个读并发(RWMutex 允许)
for i := 0; i < 5; i++ {
go func() {
for j := 0; j < 100; j++ {
cache.Get("key")
}
}()
}
cache.Set("key", "value") // 写
time.Sleep(100 * time.Millisecond)
fmt.Println(cache.Get("key")) // value
}
12.7 sync.Once:只执行一次
sync.Once 保证某个函数在多 goroutine 环境下只执行一次(线程安全),经典用途:单例初始化、只执行一次的全局配置加载:
go
// 完整可运行程序:sync.Once
package main
import (
"fmt"
"sync"
)
var (
config map[string]string
configOnce sync.Once
)
// 获取配置:无论被并发调用多少次,初始化只执行一次
func getConfig() map[string]string {
configOnce.Do(func() {
fmt.Println("初始化配置(只执行一次)")
config = map[string]string{"env": "prod"}
})
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = getConfig()
}()
}
wg.Wait()
// 输出只有一次"初始化配置(只执行一次)"
}
12.8 原子操作:sync/atomic
对单个整数/指针的简单操作(加、减、比较交换),原子操作比 Mutex 更轻量:
go
// 完整可运行程序:atomic 原子操作
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64 // 原子操作要求特定类型(int32/int64/uint32/uint64/指针)
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1) // 原子加 1,无需加锁
}()
}
wg.Wait()
fmt.Println("counter =", atomic.LoadInt64(&counter)) // 1000
}
常用函数:atomic.AddInt64、atomic.LoadInt64、atomic.StoreInt64、atomic.CompareAndSwapInt64(CAS)。
选择指南:简单计数器用 atomic;复杂临界区(多行操作)用 Mutex;读多写少用 RWMutex。
12.9 数据竞争与 race 检测器
数据竞争(data race) :多个 goroutine 同时读写同一变量,且至少一个是写操作、没有同步------这是并发 bug 的万恶之源,结果不可预测。
Go 自带竞态检测器(race detector),一条命令揪出数据竞争:
bash
go run -race main.go
go test -race ./...
go
// 完整可运行程序:演示数据竞争(用 -race 运行)
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // 数据竞争:多个 goroutine 无同步地写 count
}()
}
wg.Wait()
fmt.Println(count)
}
// go run -race 会输出 WARNING: DATA RACE 并指出具体代码行
并发安全清单:
- 多个 goroutine 只读共享数据 → 安全,不用加锁
- 有写操作 → 必须同步(Mutex / channel / atomic)
- map 天生不是并发安全的 :并发读写 map 会直接 panic(不是数据错误,是崩溃!)------用
sync.Mutex包裹,或用sync.Map(读多写少且 key 稳定时)
12.10 死锁与 goroutine 泄漏
死锁 :所有 goroutine 都在等待对方,谁也动不了,程序永久卡住。Go 运行时会检测并崩溃报 all goroutines are asleep - deadlock!:
go
// 经典死锁:无缓冲 channel 只发不收
func main() {
ch := make(chan int)
ch <- 1 // 主 goroutine 阻塞:没人接收,而能接收的代码在后面,永远到不了
// <-ch
}
// fatal error: all goroutines are asleep - deadlock!
避免死锁的原则:发送/接收前想清楚「谁在另一端」;不要在持锁时等待可能永远不来的事件;lock 顺序要一致(多个锁时按固定顺序获取)。
goroutine 泄漏 :goroutine 阻塞在 channel 上永远不退出,占着内存和栈。典型场景:只发不收、只收不发、select 没有退出路径。写并发代码时思考:这个 goroutine 什么时候退出?
12.11 context:超时、取消与传递
context 用于在 goroutine 之间传递「截止时间、取消信号、请求级数据」。Web 请求处理、数据库调用等场景几乎必用。
go
// 完整可运行程序:context 超时取消
package main
import (
"context"
"fmt"
"time"
)
// 模拟耗时操作:监听 ctx 的取消
func slowOperation(ctx context.Context, name string) error {
select {
case <-time.After(3 * time.Second): // 模拟 3 秒工作
fmt.Println(name, "正常完成")
return nil
case <-ctx.Done(): // 被取消/超时
fmt.Println(name, "被取消:", ctx.Err())
return ctx.Err()
}
}
func main() {
// 方式一:带超时的 context(2 秒后自动取消)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 惯例:用完必须 cancel(释放资源)
slowOperation(ctx, "任务1")
// 方式二:手动取消
ctx2, cancel2 := context.WithCancel(context.Background())
go func() {
time.Sleep(1 * time.Second)
cancel2() // 主动取消
}()
slowOperation(ctx2, "任务2")
}
context 使用规则:
context.Background():根 context,用于 main、初始化context.TODO():还没想好用什么 context 时占位WithTimeout/WithDeadline/WithCancel/WithValue派生子 contextcancel必须调用(defer cancel()),否则资源泄漏- 函数签名惯例:
context.Context作为第一个参数 - 不要存 context 到结构体;不要传 nil context
12.13 goroutine 的底层:GMP 调度模型(进阶)
goroutine 为什么能开到几十万个?因为 goroutine 不是操作系统线程 ,而是由 Go 运行时调度的用户态协程 。调度采用经典的 GMP 模型:
| 字母 | 全称 | 含义 |
|---|---|---|
| G | goroutine | 一个协程:自己的栈、状态、编号。初始栈只有约 2KB(线程默认 1-8MB),用完自动增长,上限约 1GB |
| M | machine | 操作系统线程,真正被内核调度、执行代码 |
| P | processor | 逻辑处理器:持有本地运行队列 (runq,容量 256);数量 = GOMAXPROCS(默认等于 CPU 核数) |
调度循环(简化版):
- M 从自己 P 的本地队列取一个 G 执行
- 本地队列空了,去全局队列取一批
- 全局也空,去其他 P 的队列「偷」(work stealing)
- G 阻塞(channel 收发、锁等待)时,M 把 G 挂起,换下一个 G 执行------阻塞的是 G,不是线程
- G 陷入系统调用阻塞时,M 会被内核阻塞,运行时把 P 转交给其他空闲 M 继续干活
- 网络 I/O 不占线程:netpoller(Linux 用 epoll、macOS/BSD 用 kqueue)监听 socket,就绪后把等待的 G 放回队列
抢占 :Go 1.14 起支持异步抢占------后台监控线程(sysmon)大约每 10ms 检查一次,发现某个 G 运行太久(比如死循环)就通过信号打断它,把 P 让给别的 G,防止「一个死循环饿死所有 goroutine」。
go
// 完整可运行程序:验证 GOMAXPROCS 与海量 goroutine
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
fmt.Println("CPU 核数:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 并发启动 10 万个 goroutine,毫无压力
var wg sync.WaitGroup
for i := 0; i < 100000; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
_ = n * 2
}(i)
}
wg.Wait()
fmt.Println("10 万个 goroutine 全部执行完毕")
}
为什么 goroutine 轻量:初始栈 2KB(vs 线程栈 1-8MB)、创建成本微秒级(vs 线程几十微秒)、切换是用户态函数调用级别的协程切换(不陷入内核)。代价是:GMP 调度本身有开销(所以超短任务上 goroutine 未必比同步快),且一个 G 阻塞在系统调用时线程仍可能等待------但整体上这是「用少量线程承载海量并发」的正确模型。
12.14 channel 与 Mutex 的底层结构(进阶)
channel 的内部(hchan)
go
type hchan struct {
qcount uint // 缓冲区中的元素个数
dataqsiz uint // 缓冲区容量(0 = 无缓冲)
buf unsafe.Pointer // 环形缓冲区
closed uint32 // 是否已关闭
sendx uint // 发送游标
recvx uint // 接收游标
recvq waitq // 等待接收的 goroutine 队列(sudog 链表)
sendq waitq // 等待发送的 goroutine 队列
lock mutex // 保护以上所有字段的互斥锁
}
由此推导:
- channel 是并发安全的 :所有收发操作先加
lock - 无缓冲 channel 收发:发送时若
recvq有人等待,直接把值交付给等待者;否则自己进入sendq挂起。接收是对称的------这就是「无缓冲 channel 必须收发同时就绪」的底层原因 - 有缓冲 channel:缓冲区未满直接写环形缓冲区(
sendx游标移动);满了进sendq - close 的语义 :关闭时唤醒
recvq/sendq里所有等待者;此后接收返回零值(ok=false),向已关闭的 channel 发送会 panic - select 的实现 :把各 case 的 channel 收集成数组,随机打乱顺序再依次尝试;都不就绪时,把当前 G 注册到所有 channel 的等待队列挂起,任一 case 就绪即被唤醒。随机打乱就是「多个 case 同时就绪时随机选一个」的由来
- nil channel 收发会永久挂起:hchan 为 nil 时直接阻塞。select 中把某个 case 的 channel 置 nil,就等于永久禁用该 case(这是故意的设计,可用于动态开关分支)
sync.Mutex 的内部(Go 1.9+ 双模式)
Mutex 由一个 32 位状态字 + 一个信号量组成。状态字通过原子位操作维护:locked(是否被持有)、woken(是否有唤醒者)、starving(是否饥饿模式)、waiter 计数。
- 正常模式 :新来的 goroutine 先自旋 (空转几个 CPU 周期等待锁释放,适合临界区极短的情况,默认最多自旋 4 次),自旋失败才挂起等待;被唤醒的等待者与新来的 goroutine 公平竞争------好处是吞吐高,坏处是极端情况下等待者可能一直抢不到(饿死)
- 饥饿模式 :当某个 goroutine 等待超过 1ms 时,锁切换到饥饿模式:被唤醒的等待者直接获得锁(按排队顺序),新来的不再自旋、直接排队------保证公平,防止饿死
- 锁被释放或等待者拿到锁后,若队列空了或等待者已拿到锁超过 1ms,切回正常模式
go
// 完整可运行程序:观察 Mutex 饥饿模式的效果
// 正常模式下,持续有新 goroutine 抢锁时,等待者可能长时间拿不到锁;
// 饥饿模式保证等待者按顺序获得。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
done := make(chan struct{})
// 等待者:晚启动,希望尽快拿到锁
go func() {
mu.Lock()
defer mu.Unlock()
fmt.Println("等待者拿到了锁")
close(done)
}()
// 抢锁者:持续快速抢锁
stop := make(chan struct{})
go func() {
for {
select {
case <-stop:
return
default:
}
mu.Lock() // 不断抢锁
time.Sleep(100 * time.Microsecond) // 模拟临界区
mu.Unlock()
}
}()
time.Sleep(10 * time.Millisecond)
select {
case <-done:
fmt.Println("等待者成功获得锁(饥饿模式保证了公平)")
case <-time.After(2 * time.Second):
fmt.Println("等待者 2 秒内没拿到锁")
}
close(stop)
}
12.15 本章小结
- goroutine:
go fn()启动;main 结束程序就结束 - channel:
make(chan T);ch <- v发送、v := <-ch接收;无缓冲=同步交接,有缓冲=队列 close由发送方调用;range遍历直到关闭;单向 channel 声明参数方向select:多路等待、超时、非阻塞、退出信号sync.WaitGroup:等一组 goroutine(Add 在 go 前,传指针)sync.Mutex/RWMutex/Once/sync/atomic- race detector :
go run -race,并发必测 - 死锁 / goroutine 泄漏的预防
context:超时取消与传递
本章练习:
- 启动 10 个 goroutine 并发计算各自数字的平方,用 channel 收集结果后打印。
- 用 WaitGroup 启动 100 个 goroutine 并发
counter++,先不加锁运行(观察结果),再加 Mutex 修复。 - 用
go run -race运行上面的程序,观察检测报告。 - 写一个带超时的「下载」模拟:goroutine 睡 3 秒,select 等 1 秒,打印超时。
- 用 channel 实现一个简易「工作池」:3 个 worker goroutine 从 channel 取任务并处理,主 goroutine 发 10 个任务后 close。
- 写一个并发安全的计数器结构体(Mutex + 方法),并用 1000 个 goroutine 验证。
第 13 章 标准库常用包
Go 的标准库极其丰富,被称为「自带电池」。本章挑选日常开发最高频的包逐一实战:os、time、encoding/json、net/http、sort、log、flag。
13.1 os 包:操作系统接口
命令行参数 os.Args
go
// 完整可运行程序:os.Args
package main
import (
"fmt"
"os"
)
func main() {
// os.Args[0] 是程序本身路径,[1:] 是用户传入的参数
fmt.Println("程序路径:", os.Args[0])
fmt.Println("参数个数:", len(os.Args)-1)
for i, arg := range os.Args[1:] {
fmt.Printf("参数 %d:%s\n", i+1, arg)
}
}
bash
go run main.go hello world
# 程序路径: /tmp/go-build.../exe/main
# 参数个数: 2
# 参数 1:hello
# 参数 2:world
简单的参数用
os.Args;复杂的(带-flag形式的)用flag包(13.7)。
环境变量
go
// 完整可运行程序:环境变量
package main
import (
"fmt"
"os"
)
func main() {
// 读取:不存在返回空字符串
path := os.Getenv("PATH")
fmt.Println("PATH 长度:", len(path))
// 带默认值
port := os.Getenv("PORT")
if port == "" {
port = "8080" // 默认值
}
fmt.Println("端口:", port)
// 判断是否存在
if v, ok := os.LookupEnv("HOME"); ok {
fmt.Println("HOME =", v)
}
// 设置(只影响当前进程)
os.Setenv("MY_VAR", "hello")
fmt.Println(os.Getenv("MY_VAR")) // hello
// 列出所有环境变量
// for _, e := range os.Environ() { fmt.Println(e) }
}
进程控制
go
os.Exit(0) // 正常退出
os.Exit(1) // 非零退出码表示失败(后续 defer 不执行!)
os.Getpid() // 当前进程 ID
13.2 time 包:时间与定时
获取与构造时间
go
// 完整可运行程序:time 基础
package main
import (
"fmt"
"time"
)
func main() {
// 当前时间
now := time.Now()
fmt.Println(now) // 2026-08-21 15:04:05.123456789 +0800 CST m=+0.000...
// 构造指定时间
t := time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local)
fmt.Println(t) // 2026-08-21 10:30:00 +0800 CST
// 获取时间的各部分
fmt.Println(now.Year()) // 2026
fmt.Println(now.Month()) // August
fmt.Println(int(now.Month())) // 8
fmt.Println(now.Day()) // 21
fmt.Println(now.Weekday()) // Friday
fmt.Println(now.Hour()) // 15
// Unix 时间戳(秒)
fmt.Println(now.Unix()) // 1784600000(示例值)
// 从时间戳恢复
back := time.Unix(now.Unix(), 0)
fmt.Println(back)
}
时间运算与 Duration
go
// 完整可运行程序:时间运算
package main
import (
"fmt"
"time"
)
func main() {
now := time.Now()
// 时间加减
fmt.Println(now.Add(2 * time.Hour)) // 2 小时后
fmt.Println(now.Add(-30 * time.Minute)) // 30 分钟前
fmt.Println(now.AddDate(1, 2, 3)) // 加 1 年 2 个月 3 天
// 时间差:结果是 time.Duration
start := time.Now()
time.Sleep(50 * time.Millisecond)
elapsed := time.Since(start) // 等价于 time.Now().Sub(start)
fmt.Println("耗时:", elapsed) // 耗时: 50.123456ms
// Duration 的常用单位
d := 1500 * time.Millisecond
fmt.Println(d) // 1.5s
fmt.Println(d.Seconds()) // 1.5
fmt.Println(d.Milliseconds()) // 1500
// 比较
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
t2 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
fmt.Println(t1.Before(t2)) // true
fmt.Println(t1.After(t2)) // false
}
时间格式化(Go 的独特约定!)
Go 用「参考时间」2006-01-02 15:04:05(Mon Jan 2 15:04:05 MST 2006)作为格式模板 ------这是 Go 的诞生年份,必须死记硬背 。不能像 Java 那样写 yyyy-MM-dd:
go
// 完整可运行程序:时间格式化
package main
import (
"fmt"
"time"
)
func main() {
now := time.Now()
// 格式化的"参考时间":2006-01-02 15:04:05
fmt.Println(now.Format("2006-01-02")) // 2026-08-21
fmt.Println(now.Format("2006/01/02 15:04:05")) // 2026/08/21 15:04:05
fmt.Println(now.Format("15:04")) // 15:04
fmt.Println(now.Format("2006年01月02日")) // 2026年08月21日
fmt.Println(now.Format(time.RFC3339)) // 2026-08-21T15:04:05+08:00
// 解析字符串为时间(模板必须与字符串格式一致!)
layout := "2006-01-02"
str := "2026-08-21"
t, err := time.Parse(layout, str)
if err != nil {
fmt.Println("解析失败:", err)
return
}
fmt.Println(t) // 2026-08-21 00:00:00 +0000 UTC
// 注意:Parse 默认按 UTC 解析;用 ParseInLocation 指定时区
t2, _ := time.ParseInLocation(layout, str, time.Local)
fmt.Println(t2) // 2026-08-21 00:00:00 +0800 CST
}
格式化参考表 (模板中的固定含义):
2006=年、01=月、02=日、15=24小时制时、03=12小时制时、04=分、05=秒、PM=上午/下午、Mon=周几缩写、MST=时区。
定时器:Timer 与 Ticker
go
// 完整可运行程序:Timer 与 Ticker
package main
import (
"fmt"
"time"
)
func main() {
// Timer:一次性定时(sleep 的可取消版本)
timer := time.NewTimer(1 * time.Second)
fmt.Println("等待 1 秒...")
<-timer.C // 到期后 C 通道收到时间值
fmt.Println("时间到!")
// 也可以简写:<-time.After(1 * time.Second)
// Ticker:周期性触发
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop() // 用完要 Stop,防止泄漏
count := 0
for t := range ticker.C {
fmt.Println("滴答:", t.Format("15:04:05.000"))
count++
if count >= 3 {
ticker.Stop() // 停止
break
}
}
}
13.3 encoding/json:JSON 编解码
JSON 是与外部系统交互的事实标准,Go 的支持非常完善。
结构体 ↔ JSON
go
// 完整可运行程序:JSON 序列化
package main
import (
"encoding/json"
"fmt"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Stock int `json:"stock,omitempty"` // 零值省略
Tag string `json:"-"` // 不参与序列化
}
func main() {
// 结构体 → JSON(序列化)
p := Product{ID: 1, Name: "机械键盘", Price: 299.0, Stock: 10, Tag: "内部"}
data, err := json.Marshal(p) // 紧凑格式
if err != nil {
fmt.Println("序列化失败:", err)
return
}
fmt.Println(string(data)) // {"id":1,"name":"机械键盘","price":299,"stock":10}
// 缩进格式(适合展示/存储)
pretty, _ := json.MarshalIndent(p, "", " ")
fmt.Println(string(pretty))
// JSON → 结构体(反序列化)
jsonStr := `{"id":2,"name":"显示器","price":999.5}`
var p2 Product
if err := json.Unmarshal([]byte(jsonStr), &p2); err != nil {
fmt.Println("反序列化失败:", err)
return
}
fmt.Printf("%+v\n", p2) // {ID:2 Name:显示器 Price:999.5 Stock:0 Tag:}
}
反序列化要点:
- 目标必须是指针
&p2 - 字段按 tag 匹配(不区分大小写),缺失的字段保持零值
- 多出来的 JSON 字段默认被忽略(
json.RawMessage或DisallowUnknownFields可处理,进阶)
通用 JSON:map 与任意结构
go
// 完整可运行程序:JSON 到 map
package main
import (
"encoding/json"
"fmt"
)
func main() {
// 解析到 map[string]any(不确定结构时)
data := `{"name":"小明","scores":[90,95,88],"info":{"city":"北京"}}`
var m map[string]any
if err := json.Unmarshal([]byte(data), &m); err != nil {
fmt.Println("解析失败:", err)
return
}
fmt.Println(m["name"]) // 小明
// 注意:JSON 数字默认解析为 float64
scores := m["scores"].([]any)
fmt.Println(scores[0].(float64)) // 90
// 嵌套 map
info := m["info"].(map[string]any)
fmt.Println(info["city"]) // 北京
}
数组与文件读写
go
// 完整可运行程序:JSON 数组与文件
package main
import (
"encoding/json"
"fmt"
"os"
)
type Student struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
students := []Student{
{Name: "小明", Age: 18},
{Name: "小红", Age: 19},
}
// 存到文件
data, _ := json.MarshalIndent(students, "", " ")
if err := os.WriteFile("students.json", data, 0644); err != nil {
fmt.Println("写入失败:", err)
return
}
// 从文件读回
content, err := os.ReadFile("students.json")
if err != nil {
fmt.Println("读取失败:", err)
return
}
var loaded []Student
if err := json.Unmarshal(content, &loaded); err != nil {
fmt.Println("解析失败:", err)
return
}
fmt.Println(loaded) // [{小明 18} {小红 19}]
}
编码时使用
json.NewEncoder(os.Stdout)或json.NewDecoder处理流式 JSON(大文件/网络流),进阶后自然会用到。
13.4 net/http:HTTP 服务端与客户端
Go 的标准库就能写 Web 服务,无需任何框架。
服务端
go
// 完整可运行程序:HTTP 服务器
package main
import (
"fmt"
"net/http"
)
func main() {
// 注册路由:路径 → 处理函数
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "你好,你访问了 %s", r.URL.Path)
})
// 另一个路由:带参数
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name") // 读取 ?name=xxx
if name == "" {
name = "世界"
}
fmt.Fprintf(w, "你好,%s!", name)
})
// 启动服务,监听 8080 端口
fmt.Println("服务启动:http://localhost:8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Println("服务启动失败:", err)
}
}
运行后在浏览器打开 http://localhost:8080/hello?name=Go,会看到「你好,Go!」。
处理函数的签名 :func(w http.ResponseWriter, r *http.Request)------w 用来写响应,r 包含请求信息(URL、参数、请求体、Header)。
返回 JSON 的接口
go
// 完整可运行程序:JSON API
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
http.HandleFunc("/api/user", func(w http.ResponseWriter, r *http.Request) {
// 设置响应头:告诉客户端返回的是 JSON
w.Header().Set("Content-Type", "application/json; charset=utf-8")
user := User{ID: 1, Name: "小明"}
if err := json.NewEncoder(w).Encode(user); err != nil {
http.Error(w, "序列化失败", http.StatusInternalServerError)
return
}
})
fmt.Println("API 服务:http://localhost:8080/api/user")
http.ListenAndServe(":8080", nil)
}
客户端
go
// 完整可运行程序:HTTP 客户端
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
// 带超时的客户端(重要!不设超时可能永远挂起)
client := &http.Client{Timeout: 5 * time.Second}
// GET 请求
resp, err := client.Get("https://httpbin.org/json")
if err != nil {
fmt.Println("请求失败:", err)
return
}
defer resp.Body.Close() // 响应体必须关闭!
// 读取响应体
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("读取响应失败:", err)
return
}
fmt.Println("状态码:", resp.StatusCode) // 200
fmt.Println("响应前 200 字符:", string(body)[:200])
// 解析 JSON 响应
resp2, _ := client.Get("https://httpbin.org/json")
defer resp2.Body.Close()
var result map[string]any
if err := json.NewDecoder(resp2.Body).Decode(&result); err == nil {
fmt.Println("解析成功,键:", len(result))
}
}
HTTP 客户端黄金习惯 :1)设置
Timeout;2)defer resp.Body.Close();3)检查err。
13.5 sort 包:排序
go
// 完整可运行程序:sort 常用
package main
import (
"fmt"
"sort"
)
type Person struct {
Name string
Age int
}
func main() {
// 基础类型切片排序
nums := []int{5, 2, 8, 1, 9}
sort.Ints(nums) // 升序
fmt.Println(nums) // [1 2 5 8 9]
words := []string{"banana", "apple", "cherry"}
sort.Strings(words)
fmt.Println(words) // [apple banana cherry]
// 判断是否已排序
fmt.Println(sort.IntsAreSorted(nums)) // true
// 查找(要求切片已排序)!返回目标的下标;不存在返回应该插入的位置
idx := sort.SearchInts(nums, 8)
fmt.Println(idx) // 3
// 自定义排序:sort.Slice + 比较函数(最重要)
people := []Person{
{Name: "小明", Age: 20},
{Name: "小红", Age: 18},
{Name: "小刚", Age: 25},
}
sort.Slice(people, func(i, j int) bool {
return people[i].Age < people[j].Age // 按年龄升序
})
fmt.Println(people) // [{小红 18} {小明 20} {小刚 25}]
// 降序:把比较结果取反
sort.Slice(people, func(i, j int) bool {
return people[i].Age > people[j].Age
})
fmt.Println(people) // [{小刚 25} {小明 20} {小红 18}]
}
13.6 log 包:日志
go
// 完整可运行程序:log 基础
package main
import (
"log"
"os"
)
func main() {
// 默认输出到控制台,带时间戳
log.Println("这是一条普通日志")
log.Printf("用户 %s 登录成功", "admin")
// 输出到文件
file, err := os.OpenFile("app.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.Fatal(err)
}
defer file.Close()
log.SetOutput(file) // 之后日志写入文件
// 设置前缀和格式
log.SetPrefix("[myapp] ")
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) // 日期+时间+代码位置
log.Println("这条会写到文件里")
// log.Fatal:打印日志后退出程序(相当于 log + os.Exit(1))
// log.Fatal("致命错误,退出")
// log.Panic:打印日志后 panic
// log.Panic("触发 panic")
}
生产级日志(结构化、级别、滚动)用第三方库
zerolog/zap/logrus,入门阶段log足够。
13.7 flag 包:命令行参数解析
go
// 完整可运行程序:flag 解析命令行参数
package main
import (
"flag"
"fmt"
)
func main() {
// 定义参数:名字、默认值、说明
// 注意:这里拿到的是指针
name := flag.String("name", "world", "要打招呼的名字")
times := flag.Int("times", 1, "重复次数")
verbose := flag.Bool("verbose", false, "是否输出详细信息")
// 必须调用 Parse 才会真正解析 os.Args
flag.Parse()
// 通过指针取值
if *verbose {
fmt.Printf("参数详情:name=%s times=%d\n", *name, *times)
}
for i := 0; i < *times; i++ {
fmt.Printf("你好,%s!\n", *name)
}
// 非 flag 参数(位置参数)
fmt.Println("剩余参数:", flag.Args())
}
bash
go run main.go -name Go -times 3 -verbose
# 参数详情:name=Go times=3
# 你好,Go!
# 你好,Go!
# 你好,Go!
# 剩余参数: []
# 查看帮助(自动生成)
go run main.go -h
flag是官方标准参数解析器,够用且零依赖。复杂 CLI(子命令、嵌套)用第三方库cobra。
13.8 本章小结
os:Args、Getenv、Exittime:Now、Date、Add、Since、Format 用参考时间 2006-01-02 15:04:05、Timer/Tickerencoding/json:Marshal/MarshalIndent/Unmarshal/NewDecoder;tag 控制字段net/http:HandleFunc + ListenAndServe 写服务;Get + Timeout + Close 调接口sort:Ints/Strings/Slice 自定义log:Println/Printf/Fatal;可输出到文件flag:String/Int/Bool + Parse
本章练习:
- 用 time 打印「今天是 2026年08月21日 星期五」格式的当前时间。
- 写一个 HTTP 服务:
/now返回当前时间 JSON,/echo?msg=xxx原样返回 msg。 - 写程序读取
https://httpbin.org/ip并解析出 origin 字段打印。 - 定义
Book{Title, Author, Pages},序列化时 pages 为 0 时省略,title 转成小写 key,然后存 JSON 文件再读回。 - 用 flag 写一个
greet程序:-name必填,-times默认 1,-lang zh|en切换语言。
第 14 章 文件操作实战
文件读写是几乎所有程序的基础能力。Go 的 os、bufio、io、path/filepath 包覆盖全部场景。
14.1 读取文件
方式一:一次性读入(最简单)
go
// 完整可运行程序:os.ReadFile
package main
import (
"fmt"
"os"
)
func main() {
// 先把文件创建出来,保证示例能跑
os.WriteFile("demo.txt", []byte("第一行\n第二行\n第三行\n"), 0644)
data, err := os.ReadFile("demo.txt") // 返回 []byte
if err != nil {
fmt.Println("读取失败:", err)
return
}
fmt.Print(string(data)) // 转成字符串打印
}
适合小文件(配置文件、JSON 数据);大文件会占内存,用下面的流式方式。
方式二:逐行读取(处理大文件)
go
// 完整可运行程序:逐行读取
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Open("demo.txt") // 打开文件(只读)
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer file.Close() // 惯例:打开后立刻 defer 关闭
scanner := bufio.NewScanner(file) // 创建扫描器
lineNum := 0
for scanner.Scan() { // 每次读一行
lineNum++
fmt.Printf("第 %d 行:%s\n", lineNum, scanner.Text())
}
// 检查扫描过程中是否出错
if err := scanner.Err(); err != nil {
fmt.Println("读取出错:", err)
}
}
方式三:io.ReadAll(从 Reader 读全部)
go
content, err := io.ReadAll(file) // file 实现了 io.Reader
os.ReadFile与io.ReadAll的区别:前者一步到位打开+读+关;后者需要自己打开和关闭,但可以用于任何 Reader(网络响应、管道等)。
14.2 写入文件
go
// 完整可运行程序:写入文件
package main
import (
"fmt"
"os"
)
func main() {
// 方式一:os.WriteFile(一次性写入,覆盖旧内容;文件不存在则创建)
content := []byte("Hello, Go!\n")
err := os.WriteFile("out1.txt", content, 0644)
if err != nil {
fmt.Println("写入失败:", err)
return
}
// 方式二:os.Create + WriteString(适合分多次写)
f, err := os.Create("out2.txt") // 创建/截断文件
if err != nil {
fmt.Println("创建失败:", err)
return
}
defer f.Close()
f.WriteString("第一行\n")
f.WriteString("第二行\n")
fmt.Fprintf(f, "格式化写入:%d\n", 42)
// 方式三:追加写入(不清空旧内容)
// os.O_APPEND:追加模式;os.O_CREATE:不存在则创建;os.O_WRONLY:只写
af, err := os.OpenFile("out1.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer af.Close()
af.WriteString("追加的一行\n")
}
权限位
0644:所有者可读写(6),组和其他人只读(4),是文件的标准权限。Windows 上该参数基本被忽略,但 Linux/macOS 上很重要。
14.3 文件信息与存在性判断
go
// 完整可运行程序:文件信息
package main
import (
"fmt"
"os"
)
func main() {
// 判断文件是否存在
_, err := os.Stat("demo.txt")
if err == nil {
fmt.Println("文件存在")
} else if os.IsNotExist(err) {
fmt.Println("文件不存在") // 推荐用 os.IsNotExist 判断
} else {
fmt.Println("其他错误:", err)
}
// 获取详细信息
info, err := os.Stat("demo.txt")
if err != nil {
fmt.Println(err)
return
}
fmt.Println("文件名:", info.Name())
fmt.Println("大小:", info.Size(), "字节")
fmt.Println("是否目录:", info.IsDir())
fmt.Println("修改时间:", info.ModTime())
}
14.4 目录操作
go
// 完整可运行程序:目录操作
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
// 创建目录(单层)
os.Mkdir("data", 0755)
// 递归创建(推荐)
os.MkdirAll("data/2026/08", 0755)
// 读取目录内容
entries, err := os.ReadDir("data")
if err != nil {
fmt.Println(err)
return
}
for _, e := range entries {
fmt.Printf("名称:%s,是否目录:%v\n", e.Name(), e.IsDir())
}
// 递归遍历目录树(filepath.WalkDir)
filepath.WalkDir("data", func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
fmt.Println("遍历到:", path)
return nil // 返回 nil 继续,返回 filepath.SkipDir 跳过当前目录
})
// 删除(递归删除目录用 os.RemoveAll)
os.Remove("data/2026/08/empty.txt") // 删除文件(不存在会报错,可忽略)
os.RemoveAll("data") // 递归删除整个目录树
}
14.5 综合示例:CSV 成绩单处理
把前面所有知识串起来------读取 CSV、处理、写回新文件:
go
// 完整可运行程序:CSV 处理
package main
import (
"encoding/csv"
"fmt"
"os"
"strconv"
)
// 成绩记录
type Score struct {
Name string
Score int
}
func main() {
// 1. 创建示例 CSV
csvFile, _ := os.Create("scores.csv")
w := csv.NewWriter(csvFile)
w.Write([]string{"name", "score"})
w.Write([]string{"小明", "90"})
w.Write([]string{"小红", "85"})
w.Write([]string{"小刚", "95"})
w.Flush() // 必须 Flush 才会真正写入
csvFile.Close()
// 2. 读取 CSV
f, err := os.Open("scores.csv")
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer f.Close()
reader := csv.NewReader(f)
records, err := reader.ReadAll() // 一次读全部(小文件)
if err != nil {
fmt.Println("解析失败:", err)
return
}
// 3. 跳过表头,解析数据
var scores []Score
for _, record := range records[1:] {
n, _ := strconv.Atoi(record[1])
scores = append(scores, Score{Name: record[0], Score: n})
}
// 4. 统计平均分
total := 0
for _, s := range scores {
total += s.Score
}
fmt.Printf("共 %d 名学生,平均分 %.1f\n", len(scores), float64(total)/float64(len(scores)))
}
14.6 本章小结
- 读:
os.ReadFile(小文件)/bufio.Scanner逐行(大文件) - 写:
os.WriteFile(覆盖)/os.Create/os.OpenFile+O_APPEND(追加) - 打开文件后
defer file.Close() os.Stat+os.IsNotExist判断存在;os.MkdirAll建目录;filepath.WalkDir遍历encoding/csv处理表格数据
本章练习:
- 写程序:读入一个文本文件,统计行数、单词数、字符数并打印。
- 写程序:把
demo.txt的内容逐行反转顺序写入reversed.txt。 - 写程序:把某个目录下所有
.txt文件的大小总和算出来(用 WalkDir)。 - 把 14.5 的成绩单按分数从高到低排序后写回新 CSV。
第 15 章 测试与代码质量
Go 对测试的支持是语言级的:testing 标准库 + go test 命令,不需要任何第三方框架。
15.1 单元测试基础
约定:
- 测试文件命名为
xxx_test.go(与被测文件同目录) - 测试函数名为
TestXxx,参数是*testing.T - 运行:
go test ./...(测试当前包所有测试)
先写一个被测模块:
go
// mathx/mathx.go
package mathx
// Add 返回两个整数之和
func Add(a, b int) int {
return a + b
}
// IsEven 判断整数是否为偶数
func IsEven(n int) bool {
return n%2 == 0
}
go
// mathx/mathx_test.go
package mathx
import "testing"
func TestAdd(t *testing.T) {
got := Add(1, 2)
want := 3
if got != want {
t.Errorf("Add(1, 2) = %d; want %d", got, want) // 报告失败,继续跑
}
}
func TestIsEven(t *testing.T) {
if !IsEven(4) {
t.Errorf("IsEven(4) = false; want true")
}
if IsEven(7) {
t.Errorf("IsEven(7) = true; want false")
}
}
bash
go test ./...
# ok mathx 0.123s 全部通过
t 的常用方法:
| 方法 | 作用 |
|---|---|
t.Errorf(format, args) |
记录失败,继续执行本测试 |
t.Fatalf(format, args) |
记录失败,立即终止本测试 |
t.Logf(format, args) |
打印日志(配合 -v 显示) |
t.Run(name, fn) |
运行子测试(表驱动里常用) |
t.Helper() |
标记辅助函数,报错时定位到调用者 |
15.2 表驱动测试(Table-Driven Tests)
这是 Go 社区的标准测试风格:把测试用例放进一个表(切片),循环执行:
go
// mathx/mathx_test.go
package mathx
import "testing"
func TestAddTable(t *testing.T) {
// 测试用例表:输入 + 期望输出
tests := []struct {
name string // 用例名
a, b int
want int
}{
{name: "正数相加", a: 1, b: 2, want: 3},
{name: "零参与", a: 0, b: 0, want: 0},
{name: "负数相加", a: -1, b: -2, want: -3},
{name: "正负抵消", a: 5, b: -5, want: 0},
{name: "大数", a: 1000000, b: 2000000, want: 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { // 每个用例作为子测试
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
bash
# 查看每个子测试的结果
go test -v ./...
# === RUN TestAddTable/正数相加
# === RUN TestAddTable/零参与
# ...
表驱动测试的优点:新增用例只需加一行;失败时能精确指出是哪个输入;一目了然。
15.3 测试错误场景(error 返回值)
go
// mathx/mathx.go 增加
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
var ErrDivideByZero = errors.New("除数不能为 0")
go
// mathx/mathx_test.go
package mathx
import (
"errors"
"testing"
)
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b int
want int
wantErr bool
}{
{name: "正常除法", a: 10, b: 2, want: 5},
{name: "除数为零", a: 10, b: 0, wantErr: true},
{name: "负数", a: -10, b: 5, want: -2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.wantErr {
if !errors.Is(err, ErrDivideByZero) {
t.Fatalf("期望 ErrDivideByZero,得到 %v", err)
}
return
}
if err != nil {
t.Fatalf("不期望错误,得到 %v", err)
}
if got != tt.want {
t.Errorf("Divide(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
15.4 基准测试(Benchmark)
衡量代码性能:函数名 BenchmarkXxx,参数 *testing.B,循环体用 b.N(框架自动调整次数):
go
// mathx/mathx_test.go
package mathx
import "testing"
func BenchmarkAdd(b *testing.B) {
// b.N 由框架自动决定(保证测试时间合理)
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}
bash
go test -bench=. ./...
# BenchmarkAdd-8 1000000000 0.2317 ns/op 每次调用耗时
对比两个实现哪个快:写两个 Benchmark 函数,分别运行,比较 ns/op。
15.5 覆盖率与常用命令
bash
go test -cover ./... # 覆盖率百分比
go test -coverprofile=cover.out ./... # 生成覆盖率文件
go tool cover -html=cover.out # 浏览器查看具体行覆盖情况
go test -v ./... # 显示每个测试的详细信息
go test -run TestAdd ./... # 只跑名字匹配的测试
go test -race ./... # 带竞态检测跑测试(并发代码必用)
15.6 go vet 与代码质量
bash
go vet ./... # 静态检查:找出可疑代码(未使用变量、错误格式、可疑锁等)
gofmt -l . # 列出格式不合规的文件
gofmt -w . # 自动格式化(或编辑器保存时自动执行)
团队协作习惯 :提交代码前跑
gofmt+go vet+go test ./...,三者全绿再提交。这也是 Go 项目 CI 的标准流程。
15.7 本章小结
xxx_test.go+TestXxx(t *testing.T)+go test ./...- 表驱动测试是 Go 的招牌风格,务必掌握
BenchmarkXxx(b *testing.B)+go test -bench=.-cover覆盖率、-race竞态检测、go vet静态检查- 质量三件套:gofmt + vet + test
本章练习:
- 给你第 5 章的
max函数写表驱动测试(正数、负数、相等、边界)。 - 写一个
Reverse(s string) string函数(反转字符串),配测试,注意中文字符(提示:用 \[\]rune)。 - 给一个会失败的错误场景写测试,运行
go test -v观察失败输出。 - 对
Reverse写基准测试并运行go test -bench=.。
第 16 章 泛型(Go 1.18+)
泛型让函数和类型可以不预先指定具体类型,编写一次、适用于多种类型,同时保留编译期类型安全。Go 1.18(2022 年 3 月)正式引入。
16.1 为什么需要泛型
看这个最经典的例子------求最大值:
go
// 没有泛型:每种类型都要写一遍
func MaxInt(a, b int) int {
if a > b {
return a
}
return b
}
func MaxFloat64(a, b float64) float64 {
if a > b {
return a
}
return b
}
// ... int8、int16、uint、string(如果要)......复制粘贴地狱
用泛型一次搞定:
go
// 完整可运行程序:泛型函数
package main
import (
"fmt"
)
// T 是类型参数,约束为 constraints.Ordered(可比较大小的类型)
func Max[T int | float64 | string](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(Max(3, 5)) // 5(推断 T = int)
fmt.Println(Max(3.14, 2.71)) // 3.14(推断 T = float64)
fmt.Println(Max("apple", "banana")) // banana(字符串按字典序)
// 也可以显式指定类型参数
fmt.Println(Max[int](10, 20)) // 20
}
泛型语法 :func 函数名[类型参数列表](参数) 返回值。[T int | float64 | string] 声明类型参数 T,并给出约束(T 可以是这些类型之一)。
16.2 约束(constraint)
约束 = 类型参数的「许可名单」,写在 interface 中:
go
// 完整可运行程序:自定义约束
package main
import "fmt"
// 约束:整数或浮点数
type Number interface {
~int | ~int64 | ~float64
}
// 求和:适用于所有 Number 类型
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
}
~ 的含义 :~int 表示「底层类型是 int 的类型」------包括你自己定义的 type MyInt int。不加 ~ 的 int 只匹配字面意义上的 int。日常写约束时,带 ~ 更友好(能接受自定义类型)。
预定义约束 (constraints 包,Go 1.18--1.20;1.21 起标准库将常用约束移入 cmp、slices、maps 包):
| 约束 | 允许的类型 |
|---|---|
constraints.Ordered |
所有可排序类型(整数、浮点、字符串) |
constraints.Integer |
所有整数类型 |
constraints.Float |
所有浮点类型 |
constraints.Signed / Unsigned |
有符号 / 无符号整数 |
comparable(内置) |
可用 == 比较的类型(几乎所有值类型) |
go
import "cmp" // Go 1.21+
func Max[T cmp.Ordered](a, b T) T { // 用 cmp.Ordered 代替 constraints.Ordered
if a > b {
return a
}
return b
}
16.3 泛型类型(泛型结构体)
go
// 完整可运行程序:泛型类型
package main
import "fmt"
// 泛型栈:适用于任意类型
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T // 泛型零值:必须用 var 声明
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func main() {
intStack := Stack[int]{}
intStack.Push(1)
intStack.Push(2)
v, ok := intStack.Pop()
fmt.Println(v, ok) // 2 true
strStack := Stack[string]{}
strStack.Push("hello")
fmt.Println(strStack) // {[hello]}
}
16.4 泛型的适用场景与限制
适合用泛型:
- 容器类型:栈、队列、集合、缓存(如
slices、maps标准库就是泛型重写) - 通用算法:排序、查找、去重、求极值
- 类型间只存在「结构相似、类型不同」的重复代码
不建议滥用:
- 每个方法都要加类型参数,可读性下降
- 泛型与接口方法不能混用(泛型类型不能定义方法上的新类型参数)
- 性能不是主要考量:泛型并非「零成本抽象」的代名词,Go 泛型的实现有额外开销
好消息:标准库
slices、maps、cmp包(Go 1.21+)提供了大量现成的泛型工具函数(slices.Sort、slices.Contains、slices.Delete、maps.Clone等),先查标准库再自己写。
16.5 本章小结
- 泛型函数:
func F[T 约束](...) T,调用时自动推断类型参数 - 约束:类型集合(
int | float64)、~匹配底层类型、any/comparable内置 - 泛型类型:
type Stack[T any] struct{...},方法接收者带[T] - 优先用标准库
slices/maps/cmp,别重复造轮子
本章练习:
- 写泛型函数
Min[T cmp.Ordered](a, b T) T。 - 写泛型函数
Contains[T comparable](items []T, target T) bool。 - 写泛型函数
Filter[T any](items []T, keep func(T) bool) []T。 - 用泛型实现一个简单的键值缓存(
type Cache[K comparable, V any] struct{...},带 Get/Set)。
第 17 章 常用命令与工具
17.1 命令速查表
| 命令 | 作用 | 常用形式 |
|---|---|---|
go version |
查看版本 | go version |
go run |
编译并运行 | go run main.go |
go build |
编译成可执行文件 | go build -o app ./... |
go install |
编译并安装到 GOPATH/bin |
go install 包路径@版本 |
go test |
运行测试 | go test ./...、go test -v -cover -race ./... |
go vet |
静态检查 | go vet ./... |
go fmt |
格式化(gofmt 的封装) |
go fmt ./...(注意它只格式化,建议 gofmt -w .) |
go doc |
查看包/函数文档 | go doc fmt.Println、go doc net/http |
go env |
查看环境配置 | go env GOPATH、go env -w GOPROXY=... |
go mod |
模块管理 | go mod init、go mod tidy、go mod download |
go get |
添加/升级依赖 | go get github.com/gin-gonic/gin@latest |
go list |
列出包 | go list -m all(所有依赖) |
go clean |
清理缓存 | go clean -cache(C 盘紧张时释放空间) |
go work |
多模块工作区 | go work init、go work use ./module1 ./module2 |
go build 的注意点:
go build ./...:编译当前目录及子目录所有包(检查能否编译通过)- 交叉编译:
GOOS=linux GOARCH=amd64 go build(第 2 章讲过)
17.2 查文档的姿势
bash
# 终端里查文档(离线可用)
go doc fmt.Printf
go doc strings.Split
go doc time.Time.Format
# 官方文档站(带搜索)
# https://pkg.go.dev/fmt
# https://pkg.go.dev/time
学 Go 的正确姿势:遇到不熟的函数先
go doc。标准库文档写得很规范,每个函数都有示例。
17.3 调试:Delve 与打印调试
- 打印调试:
fmt.Printf/log.Printf(学习阶段足够) - 专业调试器:Delve (
dlv),VS Code Go 扩展内置支持,断点、单步、查看变量:
bash
go install github.com/go-delve/delve/cmd/dlv@latest
dlv debug main.go
# 然后:break main.go:10 打断点 / continue 运行 / print 变量
17.4 本章小结
最常用的就 5 个:go run、go build、go test、go vet、go mod tidy。其余用到再查。
第 18 章 常见坑与最佳实践
把新手(和老手)最容易踩的坑集中列出,强烈建议学完前面章节后回读本章。
18.1 高频坑清单
坑 1:循环变量被闭包捕获(Go 1.22 前)
go
// 老版本 Go 的行为:全部打印 3
for i := 0; i < 3; i++ {
go func() { fmt.Println(i) }() // 捕获的是同一个 i
}
// 解法一:i := i 重新声明
// 解法二:传参 go func(n int) { fmt.Println(n) }(i)
// 解法三:升级到 Go 1.22+
坑 2:切片共享底层数组
go
a := []int{1, 2, 3, 4, 5}
b := a[:4]
b = append(b, 100) // 修改了 a[4]!
// 不想共享:用 copy 复制
坑 3:append 忘记接收返回值
go
s := []int{1, 2}
append(s, 3) // 没接返回值!s 还是 [1 2](可能扩容了,也可能悄悄改了底层数组)
s = append(s, 3) // 正确写法
坑 4:map 并发读写直接 panic
go
// 多个 goroutine 同时写 map → panic: concurrent map writes
// 解法:sync.Mutex 包裹 / sync.Map / 分片
坑 5:接口的 nil 陷阱
go
var p *MyType = nil
var i MyInterface = p
i == nil // false!接口里存了类型信息
坑 6:defer 在循环中堆积
go
for _, f := range files {
fp, _ := os.Open(f)
defer fp.Close() // 全部堆积到函数末尾才关闭
}
// 解法:把循环体抽成函数,或在循环内显式关闭
坑 7:字符串拼接用 + 拼很多次
go
s := ""
for i := 0; i < 10000; i++ {
s += "x" // 每次创建新字符串,O(n²)
}
// 解法:strings.Builder
坑 8:JSON 字段丢失
go
type User struct {
name string // 小写字段不会被序列化!
Age int
}
// json.Marshal 结果里只有 age。字段必须首字母大写,或用 tag 控制
坑 9:忽略错误继续用零值
go
n, _ := strconv.Atoi("abc") // 转换失败,n = 0,但业务上可能致命
// 必须检查 err
坑 10:整数溢出 / 类型不匹配
go
var b byte = 255
b++ // 溢出成 0(不报错!)
// 比较 int 和 int64:编译错误,必须显式转换
坑 11:对 nil 指针/空切片解引用
go
var s []int
s[0] = 1 // panic!空切片没有元素
坑 12:switch 忘记 fallthrough 的语义
Go 的 case 默认不穿透,这是特性不是 bug;需要穿透才用 fallthrough(且它不判断条件)。
坑 13:WaitGroup 复制 / Add 时机
go
var wg sync.WaitGroup
go func() { wg.Add(1); defer wg.Done(); ... }() // Add 在 goroutine 里可能来不及
wg.Wait() // 可能提前结束
// Add 必须在 go 之前
坑 14:通道只发不收 / 只收不发(死锁)
go
ch := make(chan int)
ch <- 1 // 主 goroutine 永久阻塞 → deadlock
坑 15:锁复制
go
type Counter struct {
mu sync.Mutex // 结构体被复制时锁也被复制,两把锁互不相干
n int
}
// 使用 Counter 一律用指针 *Counter
坑 16:time 格式化用错模板
go
t.Format("yyyy-MM-dd") // 错误!Go 用参考时间
t.Format("2006-01-02") // 正确
坑 17:修改 range 的副本变量
go
for _, v := range nums {
v = v * 2 // v 是副本,nums 没变!
}
// 想改原切片:for i := range nums { nums[i] *= 2 }
坑 18:导入未使用 / 变量未使用
go
import "fmt" // 没用到 → 编译错误
x := 1 // 声明了没使用 → 编译错误(_ = x 可临时绕过)
18.2 命名与代码风格规范
- 文件名、变量、函数 :小驼峰
myVariable、myFunc;导出 :首字母大写MyFunc - 常量 :首字母大写驼峰(
MaxRetries),不强制全大写(MAX_RETRIES不是 Go 风格,但包级常量全大写也常见,团队统一即可) - 缩写词 :统一大写如
URL、ID、API(userID、parseURL) - 接口名 :以
er结尾(Reader、Writer、Handler) - 错误变量 :
err;包级哨兵错误ErrXxx(ErrNotFound) - 注释 :导出标识符必须有注释,以标识符名开头(
// Add 返回...) - 包名 :全小写、无下划线(
mathx、httputil);避免util、common这类没信息量的名字
为什么 Go 这么强调风格 ?因为 gofmt 保证所有代码机器统一格式化,风格争议在 Go 社区根本不存在------这是它在大团队受欢迎的重要原因。
18.3 工程最佳实践清单
- 错误必须处理 :要么返回、要么记录日志,绝不
_吞掉关键错误 - 尽早返回 :
if err != nil { return ... }链式往下,避免深嵌套 - defer 立即配对:打开资源后立刻写 defer 关闭
- 接口尽量小:接口只声明你需要的 1-3 个方法
- 接收者一致:同一个类型的方法要么都用值接收者,要么都用指针接收者
- 并发安全 :共享数据必同步;
go test -race养成习惯 - 依赖管理 :提交前
go mod tidy,代码评审看 go.mod 变更 - 测试先行:重要逻辑写表驱动测试,覆盖率不是目标而是信号
- 包设计 :一个包一个职责;
internal/保护内部实现 - 不要过度设计:能用标准库就不引第三方;能简单就不抽象
18.4 本章小结
- 18.1 的坑清单值得反复看,遇到诡异 bug 先对照清单
- 风格规范是 Go 文化的核心:gofmt 一统天下
- 工程习惯:错误处理、defer、接口、并发安全、测试
第 19 章 综合实战:命令行待办事项管理器
把前面所有知识串成一个完整项目:结构体、切片、map、错误处理、JSON、文件操作、flag、函数组织。请先自己尝试,再对照代码。
19.1 需求
一个 CLI 程序 todo,支持四个子命令:
bash
todo add -title "学习 Go" # 添加任务
todo list # 列出所有任务(未完成的标 [ ],完成的标 [x])
todo done 1 # 把第 1 个任务标记为完成
todo delete 1 # 删除第 1 个任务
数据持久化到本地 todos.json 文件。
19.2 实现(完整可运行程序)
go
// 完整可运行程序:待办事项管理器(todo.go)
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strconv"
)
// 任务结构体
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
const dataFile = "todos.json"
// ---- 持久化 ----
// 从文件加载任务(文件不存在时返回空列表)
func loadTodos() ([]Todo, error) {
data, err := os.ReadFile(dataFile)
if err != nil {
if os.IsNotExist(err) { // 首次运行:没有文件
return []Todo{}, nil
}
return nil, fmt.Errorf("读取任务文件失败:%w", err)
}
var todos []Todo
if err := json.Unmarshal(data, &todos); err != nil {
return nil, fmt.Errorf("解析任务文件失败:%w", err)
}
return todos, nil
}
// 保存任务到文件
func saveTodos(todos []Todo) error {
data, err := json.MarshalIndent(todos, "", " ") // 缩进格式,便于人看
if err != nil {
return fmt.Errorf("序列化任务失败:%w", err)
}
if err := os.WriteFile(dataFile, data, 0644); err != nil {
return fmt.Errorf("写入任务文件失败:%w", err)
}
return nil
}
// ---- 各子命令的实现 ----
func cmdAdd(title string) error {
if title == "" {
return errors.New("任务内容不能为空(用法:todo add -title \"任务内容\")")
}
todos, err := loadTodos()
if err != nil {
return err
}
// 新任务 ID = 当前最大 ID + 1
maxID := 0
for _, t := range todos {
if t.ID > maxID {
maxID = t.ID
}
}
todos = append(todos, Todo{ID: maxID + 1, Title: title})
if err := saveTodos(todos); err != nil {
return err
}
fmt.Printf("已添加任务 #%d:%s\n", maxID+1, title)
return nil
}
func cmdList() error {
todos, err := loadTodos()
if err != nil {
return err
}
if len(todos) == 0 {
fmt.Println("暂无任务,用 todo add -title \"...\" 添加一个吧")
return nil
}
doneCount := 0
fmt.Println("===== 待办清单 =====")
for _, t := range todos {
mark := " "
if t.Done {
mark = "x"
doneCount++
}
fmt.Printf("[%s] #%d %s\n", mark, t.ID, t.Title)
}
fmt.Printf("===== 共 %d 项,已完成 %d 项 =====\n", len(todos), doneCount)
return nil
}
func cmdDone(id int) error {
todos, err := loadTodos()
if err != nil {
return err
}
for i := range todos {
if todos[i].ID == id {
todos[i].Done = true
if err := saveTodos(todos); err != nil {
return err
}
fmt.Printf("已完成:#%d %s\n", id, todos[i].Title)
return nil
}
}
return fmt.Errorf("找不到 ID 为 %d 的任务", id)
}
func cmdDelete(id int) error {
todos, err := loadTodos()
if err != nil {
return err
}
for i, t := range todos {
if t.ID == id {
// 删除:前段 + 后段拼接
todos = append(todos[:i], todos[i+1:]...)
if err := saveTodos(todos); err != nil {
return err
}
fmt.Printf("已删除:#%d %s\n", id, t.Title)
return nil
}
}
return fmt.Errorf("找不到 ID 为 %d 的任务", id)
}
// 打印用法
func usage() {
fmt.Println("用法:todo <子命令> [参数]")
fmt.Println("子命令:")
fmt.Println(" add -title \"任务内容\" 添加任务")
fmt.Println(" list 列出所有任务")
fmt.Println(" done <ID> 标记任务完成")
fmt.Println(" delete <ID> 删除任务")
}
func main() {
// 每个子命令有独立的 flag 集合
addCmd := flag.NewFlagSet("add", flag.ExitOnError)
addTitle := addCmd.String("title", "", "任务内容")
// 没有子命令时打印用法
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
// 按子命令分发
var err error
switch os.Args[1] {
case "add":
addCmd.Parse(os.Args[2:])
err = cmdAdd(*addTitle)
case "list":
err = cmdList()
case "done":
if len(os.Args) < 3 {
err = errors.New("用法:todo done <ID>")
break
}
id, convErr := strconv.Atoi(os.Args[2])
if convErr != nil {
err = fmt.Errorf("ID 必须是数字,收到 %q", os.Args[2])
break
}
err = cmdDone(id)
case "delete":
if len(os.Args) < 3 {
err = errors.New("用法:todo delete <ID>")
break
}
id, convErr := strconv.Atoi(os.Args[2])
if convErr != nil {
err = fmt.Errorf("ID 必须是数字,收到 %q", os.Args[2])
break
}
err = cmdDelete(id)
case "help", "-h", "--help":
usage()
default:
err = fmt.Errorf("未知子命令 %q", os.Args[1])
}
// 统一的错误出口:所有子命令的错误都汇聚到这里
if err != nil {
fmt.Println("错误:", err)
fmt.Println("用 todo help 查看用法")
os.Exit(1)
}
}
19.3 运行与验证
bash
go mod init todo
go build -o todo.exe # 编译
./todo.exe add -title "学习 Go 并发"
./todo.exe add -title "阅读标准库文档"
./todo.exe add -title "写一个 Web 服务"
./todo.exe list
# [ ] #1 学习 Go 并发
# [ ] #2 阅读标准库文档
# [ ] #3 写一个 Web 服务
./todo.exe done 2
./todo.exe list
# [ ] #1 学习 Go 并发
# [x] #2 阅读标准库文档
# [ ] #3 写一个 Web 服务
./todo.exe delete 3
./todo.exe list
# [ ] #1 学习 Go 并发
# [x] #2 阅读标准库文档
查看生成的 todos.json:
json
[
{
"id": 1,
"title": "学习 Go 并发",
"done": false
},
{
"id": 2,
"title": "阅读标准库文档",
"done": true
}
]
19.4 这个项目用到了什么
| 知识点 | 出现在哪里 |
|---|---|
| 结构体 + JSON tag | Todo 定义 |
| 函数组织与多返回值 | loadTodos / saveTodos 返回 (结果, error) |
| 错误处理 | errors.New、fmt.Errorf + %w 包装、os.IsNotExist |
| 文件读写 | os.ReadFile / os.WriteFile |
| JSON 编解码 | json.MarshalIndent / json.Unmarshal |
| 切片操作 | 追加、遍历、删除元素 |
| 命令行参数 | os.Args + flag.NewFlagSet 子命令 |
| 字符串转数字 | strconv.Atoi |
| 程序退出码 | os.Exit(1) |
扩展练习(进阶方向):
- 给
list加-all参数控制是否显示已完成任务 - 加
clear子命令一键清空已完成任务 - 加
-file全局参数指定数据文件路径(支持多个清单) - 用并发(goroutine + WaitGroup)给「添加任务」加一个模拟的耗时校验
- 为
cmdAdd/cmdDone写单元测试(提示:测试时把 dataFile 指向临时文件)
第 20 章 底层原理:编译、逃逸分析与内存管理(进阶)
本章适合学完前面所有章节、想深入理解 Go 内部机制时阅读。理解这些原理,能帮你写出更高效、更可预测的代码。全部内容基于 Go 1.21+ 的运行时实现。
20.1 Go 程序的编译过程
Go 是编译型语言,go build 完成从源码到机器码的完整流水线:
- 词法分析:源码字符流 → token 序列(关键字、标识符、运算符、字面量...)
- 语法分析:token → 抽象语法树(AST)
- 类型检查:在 AST 上做类型检查和 import 解析------绝大多数类型错误在这一步暴露(所以编译期就能保证类型安全)
- 生成 SSA:AST → 静态单赋值形式的中间表示(SSA),后续优化都在 SSA 上进行
- 优化:函数内联(inline)、逃逸分析、常数折叠、死代码消除、循环优化、边界检查消除...
- 生成机器码:SSA → 汇编 → 目标平台机器码(每种 GOARCH 对应一个后端)
- 链接 :把 Go runtime(调度器、GC、内存分配器)、标准库、用户代码链接成单个可执行文件
bash
# 查看编译全过程(-x 显示每一步执行的具体命令)
go build -x ./...
# 查看某个函数的汇编输出(进阶调试)
go tool compile -S main.go | head -50
几个值得知道的特性:
- AOT 静态编译:没有 JIT(Java 那种运行时编译),程序启动就是原生机器码,零预热
- 静态链接 :纯 Go 程序把运行时直接编进二进制,目标机器不需要安装任何环境,一个文件即可部署(这也是第 2 章「编译产物拷贝到别的机器直接跑」的原理)
- 交叉编译的原理 :编译器自带多套后端,设置
GOOS/GOARCH环境变量即选择对应后端,生成目标平台的机器码(第 2.5 节) - 二进制里包含完整的 runtime:调度器、GC、内存分配器都以库的形式链接进来------这也是 Go 程序体积比 C 大的原因之一(C 程序通常不捆绑运行时)
20.2 逃逸分析(Escape Analysis)
栈与堆
- 栈:随函数调用自动分配/释放,成本极低,不需要垃圾回收参与
- 堆:需要 GC 管理,分配慢一些,释放不确定
逃逸分析(escape analysis)是编译器在优化阶段做的一项静态分析:判断每个变量是否逃逸出它所在的作用域------
- 变量只在函数内使用、不被返回、不被外部引用 → 分配在栈上(函数返回自动释放)
- 变量被函数返回、被闭包捕获、被存入接口(装箱)、或过大 → 逃逸 到堆上,交给 GC 管理
go
// 完整可运行程序:观察逃逸分析
package main
import "fmt"
// 返回局部变量的指针 → x 逃逸到堆
func makePtr() *int {
x := 42
return &x
}
// 小值传给接口参数 → 装箱,可能逃逸
func printAny(v any) {
fmt.Println(v)
}
// 普通局部变量 → 留在栈上
func noEscape() int {
y := 10
return y * 2 // 返回的是值,y 不逃逸
}
func main() {
_ = makePtr()
printAny(123)
_ = noEscape()
}
用 -gcflags="-m" 让编译器打印逃逸分析结果:
bash
go build -gcflags="-m" main.go
# 输出类似:
# ./main.go:7:2: x escapes to heap ← makePtr 里的 x 逃逸了
# ./main.go:11:16: 123 escapes to heap ← 接口装箱逃逸
# ./main.go:15:2: y does not escape ← noEscape 里的 y 留在栈上
实践意义:
- Go 允许安全地返回局部变量地址(第 7.6 节)------编译器自动把 x 放到堆上,不会像 C 那样悬垂
- 频繁逃逸 = 频繁堆分配 = GC 压力大:性能敏感的热路径上,尽量让数据留在栈上(避免不必要的接口装箱、避免返回大结构体指针)
- 内联与逃逸互相影响:小函数被内联(展开到调用处)后,原本逃逸的变量可能不再逃逸------编译器优化是个整体
20.3 垃圾回收(GC):并发三色标记-清除
Go 的 GC 是并发三色标记-清除(tricolor mark-and-sweep,Go 1.5 起):
三色标记:
| 颜色 | 含义 |
|---|---|
| 白 | 未被扫描,可能是垃圾 |
| 灰 | 已发现、等待扫描(在队列中) |
| 黑 | 已扫描完毕,确认存活 |
流程:
- 从根对象(全局变量、goroutine 栈上的引用)出发,把直接引用的对象标灰
- 反复把灰对象标黑,同时把它引用的白对象标灰;直到没有灰色对象
- 剩下的白色对象就是垃圾,清除回收
并发与 STW :标记和清除阶段与用户程序并行执行 ,只有在切换阶段需要极短暂的 STW (Stop The World,暂停所有 goroutine)。Go 1.8 引入混合写屏障 (插入 + 删除屏障)后,STW 通常缩短到亚毫秒级------这就是 Go 程序「大堆也能低延迟」的关键。
写屏障(write barrier)为什么必要:标记是并发的,用户程序在标记期间可能修改指针(如把某个白对象引用挂到黑对象下),如果不用写屏障拦截,这个对象可能被漏标------漏标意味着回收了仍在使用的对象(灾难)。写屏障保证「并发改指针不会破坏三色不变式」。
触发时机:
- 堆内存增长到「上次 GC 后存活量 × GOGC%」(默认 GOGC=100,即翻倍时触发)
- 手动
runtime.GC()(一般不推荐)
go
// 完整可运行程序:制造大量垃圾,配合 GODEBUG 观察 GC
package main
func main() {
// 反复分配 1MB 的临时切片,成为垃圾
for i := 0; i < 200; i++ {
buf := make([]byte, 1<<20) // 1 MB
_ = buf
}
}
bash
# 运行并打印 GC 日志(gc 开头的行:堆大小、耗时、STW 时间)
GODEBUG=gctrace=1 go run main.go
# gc 1 @0.023s 2%: 0.011+0.42+0.012 ms clock, ...
调优方向(知道即可,多数项目不需要):
GOGC=200:降低 GC 频率,堆内存换吞吐;GOGC=50:更频繁回收,内存更省- 大量短生命周期小对象最伤 GC,考虑对象池
sync.Pool - 减少分配本身就是最好的 GC 优化(结合 21.2 的逃逸分析)
20.4 内存分配:mcache / mcentral / mheap
Go 的内存分配器借鉴了 TCMalloc 的分层设计:
| 层 | 作用 |
|---|---|
| mcache | 每个 P 一个的本地缓存 ,按 size class 分档存放空闲对象;小对象分配无需加锁(P 内独占) |
| mcentral | 全局中央缓存,按 size class 维护;mcache 缺货时来取(需要锁) |
| mheap | 向操作系统申请内存(以 8KB 的页为单位),负责大对象与所有 size class 的底层供给 |
关键机制:
- size class 分档:小对象(≤ 32KB)按大小分约 68 档(8B、16B、24B、32B、48B...),申请 10 字节实际给 16 字节档------这就是为什么扩容后的 cap 常是「看起来奇怪」的数值(对齐到档位)
- 分配流程:小对象 → 查 mcache(无锁,纳秒级)→ 不足去 mcentral → 再不足 mheap 向 OS 要新页
- 大对象(> 32KB)直接由 mheap 分配
- 对象释放后不立即还给 OS:留在 mcache/mcentral 复用(所以 Go 程序 RSS 可能「只升不降」是正常的)
实践含义:
- 大多数小分配走 mcache 无锁路径,非常快------不要为了「省一次分配」写难以维护的代码
- 但频繁分配/释放仍有开销,热路径上用
sync.Pool复用对象是标准做法
go
// 完整可运行程序:sync.Pool 对象池示例
package main
import (
"fmt"
"sync"
)
type BigStruct struct {
Data [64]byte
}
var pool = sync.Pool{
New: func() any { return new(BigStruct) }, // 池空时创建
}
func main() {
// 获取(池里有就复用,没有就 New)
obj := pool.Get().(*BigStruct)
obj.Data[0] = 1
// 用完归还(下次 Get 大概率复用,免分配)
pool.Put(obj)
again := pool.Get().(*BigStruct)
fmt.Println("再次获取的值:", again.Data[0]) // 通常为 1(Pool 不保证:GC 可能已清理池中对象)
}
20.5 性能剖析:pprof
标准库内置性能剖析工具 pprof,是定位「慢在哪、内存去哪了」的利器:
go
// 完整可运行程序:开启 pprof 剖析服务
package main
import (
"fmt"
"net/http"
_ "net/http/pprof" // 匿名导入:注册 /debug/pprof/ 路由
)
func main() {
fmt.Println("pprof 已开启:http://localhost:6060/debug/pprof/")
http.ListenAndServe("localhost:6060", nil)
}
bash
# 浏览器访问 http://localhost:6060/debug/pprof/ 查看概览
# 命令行抓取 CPU 剖析(30 秒采样)
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=30"
# 抓取堆内存剖析
go tool pprof "http://localhost:6060/debug/pprof/heap"
# 进入交互后常用命令:
# top 看占用最高的函数
# web 浏览器打开调用图(火焰图可视化)
# list 函数名 看具体行级耗时
# 更现代的方式:直接起本地 Web 界面
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"
常用端点:/debug/pprof/goroutine(goroutine 数量与堆栈,排查泄漏)、/debug/pprof/heap(内存分配)、/debug/pprof/profile(CPU)。排查思路:先看 goroutine 是否有泄漏 → 再看 CPU 热点 → 最后看堆分配。
20.6 本章小结
- 编译:词法 → 语法 → 类型检查 → SSA → 优化 → 机器码 → 静态链接;AOT、单文件部署
- 逃逸分析 :决定变量在栈还是堆;
go build -gcflags="-m"查看;减少逃逸 = 减少 GC 压力 - GC:并发三色标记-清除 + 混合写屏障,STW 亚毫秒级;GOGC 控制触发频率
- 内存:mcache → mcentral → mheap 三级,小对象分配无锁;size class 分档
- 剖析 :
net/http/pprof+go tool pprof是性能分析的标准入口
本章练习:
- 用
go build -gcflags="-m"分析 21.2 示例的逃逸输出,再写几个自己的函数观察。 - 用
GODEBUG=gctrace=1运行 21.3 示例,观察 gc 行的堆大小增长曲线。 - 写一个基准测试对比「直接分配」与「sync.Pool 复用」的耗时(第 15.4 节的知识)。
- 启动 pprof 服务器,用
go tool pprof -http=:8080抓一次堆剖析并查看火焰图。
第 21 章 学习路线与资源推荐
21.1 学习路线图(按顺序)
| 阶段 | 内容 | 预计时间 |
|---|---|---|
| 第一阶段 | 本文档第 1-11 章:语法基础 + 函数 + 数据结构 + 接口 + 错误 + 包 | 2-3 周 |
| 第二阶段 | 本文档第 12-16 章:并发 + 标准库 + 文件 + 测试 + 泛型 | 2-3 周 |
| 第三阶段 | 做 2-3 个小项目(CLI 工具、爬虫、Web API、聊天室) | 3-4 周 |
| 第四阶段 | 深入学习:Web 框架(gin)、数据库(database/sql、gorm)、gRPC、微服务 | 持续 |
| 第五阶段 | 源码阅读:标准库(net/http、sync、runtime 相关) | 持续 |
21.2 官方资源(首选,全部免费)
| 资源 | 说明 |
|---|---|
| https://go.dev/tour | A Tour of Go:官方交互式教程,跟着敲一遍(强烈推荐第一站) |
| https://go.dev/doc/effective_go | Effective Go:官方最佳实践,中文版《Go 语言圣经》有翻译 |
| https://go.dev/doc/ | 官方文档中心 |
| https://pkg.go.dev | 包文档搜索引擎(标准库 + 第三方) |
| https://go.dev/blog | 官方博客:语言设计思想、版本特性 |
| https://go.dev/play | Go Playground:浏览器里写 Go,无需安装(在线调试利器) |
21.3 书籍推荐
| 书籍 | 适合人群 |
|---|---|
| 《Go 语言圣经》/《The Go Programming Language》(Alan A. A. Donovan & Brian W. Kernighan) | 系统学习首选,权威且深入 |
| 《Go 程序设计语言》(中文版同上) | 同上 |
| 《Head First Go》 | 零基础趣味入门 |
| 《100 个 Go 语言典型错误》/《100 Go Mistakes》 | 进阶避坑 |
| 《Go 语言并发编程实战》 | 并发专题 |
21.4 练习资源
- LeetCode 用 Go 刷题 :快速熟悉语法与数据结构(https://leetcode.cn 支持 Go)
- Exercism Go Track :https://exercism.org/tracks/go 分级练习 + 导师点评
- Go by Example :https://gobyexample.com 短小精悍的示例合集(中文版:https://gobyexample-cn.github.io)
- gophercises :https://gophercises.com 20 个实战小项目(CLI、Web、并发练习)
- 开源项目阅读 :
gin、cobra、hugo(Go 写的静态网站生成器,代码质量高)
21.5 给新手的最后建议
- 手敲代码:看 100 遍不如敲 1 遍,所有示例务必亲手运行
- 先跑通再优化:第一版能跑就行,不要边写边纠结设计
- 学会看报错:Go 的编译错误信息非常明确,读错误信息本身就是学习
- 坚持读官方文档 :
go doc是最好的老师 - 参与社区 :Go 中文社区(https://golangcn.org)、Stack Overflow、GitHub Discussions
- 写博客/笔记:输出是最好的输入,把学到的东西讲给别人听
附录 A:本文档使用说明
- 标注「完整可运行程序」的代码块都可以直接复制为
.go文件运行(go run 文件名.go) - 标注为「片段」的代码块(没有 package 声明)依赖上下文,请结合说明理解
- 本文档所有示例基于 Go 1.25(同时兼容 Go 1.21+;个别 Go 1.22 新特性已标注)
- 遇到问题先看第 18 章的坑清单
祝学习顺利!Go 是一门「越写越喜欢」的语言------简单、直接、高效。坚持下去,你一定会爱上它。