Go 语言学习教程 万字精讲(新手完整版)

Go 语言学习指南(新手完整版)

本文档是一份面向零基础/初学者的 Go 语言系统学习资料。从环境搭建开始,逐步覆盖基础语法、函数、复合数据类型、指针、结构体、接口、错误处理、包管理、并发编程、标准库、测试、泛型等全部核心主题,并配有大量可运行的代码示例(标注为"完整可运行程序"的代码块可以直接复制运行),最后附一个综合实战项目和常见坑清单。

建议学习方式:按章节顺序阅读,每个示例都亲手敲一遍并运行,每章末尾的练习尽量独立完成后再看提示。

目录

  1. Go 语言简介
  2. 环境搭建与第一个程序
  3. 基础语法(一):变量、常量、数据类型
  4. 基础语法(二):运算符、流程控制
  5. 函数
  6. 数组、切片、map 与字符串
  7. 指针
  8. 结构体与方法
  9. 接口
  10. 错误处理与 panic/recover
  11. 包与模块
  12. 并发编程
  13. 标准库常用包
  14. 文件操作实战
  15. 测试与代码质量
  16. 泛型
  17. 常用命令与工具
  18. 常见坑与最佳实践
  19. 综合实战:命令行待办事项管理器
  20. 底层原理:编译、逃逸分析与内存管理(进阶)
  21. 学习路线与资源推荐

第 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 的主要特点速览

在正式开始学习前,先建立整体印象(以下概念后续章节都会详细展开):

  1. 编译型语言:源码(.go 文件)通过编译器直接变成机器可执行的二进制文件,运行不需要额外环境,启动速度极快。
  2. 静态类型:每个变量在编译时就确定类型,类型不匹配会直接编译报错。
  3. 垃圾回收 :不需要 free / delete,内存由运行时自动回收。
  4. goroutine(协程) :用 go 关键字就能启动一个轻量级并发单元,一个 Go 程序可以轻松创建数十万个 goroutine。
  5. channel(通道) :goroutine 之间通信的管道,Go 的并发哲学是「不要通过共享内存来通信,而要通过通信来共享内存」。
  6. 多返回值:函数可以返回多个值,Go 的错误处理惯例就是用「返回值 + error」。
  7. 接口是隐式实现 :不需要显式声明 implements,类型只要实现了接口要求的方法,就自动满足该接口(鸭子类型)。

1.5 与其他语言的简单对比

维度 Go C Java Python
类型 静态 静态 静态 动态
编译 是(字节码) 否(解释执行)
内存管理 自动 GC 手动 自动 GC 自动 GC
并发 goroutine(极轻量) 线程(重) 线程(重) 线程 + GIL 限制
语法复杂度
运行速度 最快
学习曲线 平缓 陡峭 中等 平缓

小结:Go 是一门「上手容易、下限高(不容易写出烂代码)、上限高(能做大型基础设施)」的语言,非常适合作为第二门编程语言,也很适合作为第一门语言。


第 2 章 环境搭建与第一个程序

2.1 下载与安装

Windows

  1. 打开官网下载页:https://go.dev/dl
  2. 下载 Windows 版本的 .msi 安装包(如 go1.25.0.windows-amd64.msi
  3. 双击运行安装。默认安装到 C:\Program Files\Go;如果你的 C 盘空间紧张(比如剩余不足 10G),强烈建议在安装向导中把安装目录改到其他盘 ,例如 E:\Go
  4. 安装完成后,打开新的终端窗口,输入 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 官方扩展

  1. 安装 VS Code:https://code.visualstudio.com
  2. 在扩展商店搜索安装 Go (发布者为 golang.go 官方扩展)
  3. 第一次打开 .go 文件时,VS Code 会提示安装 Go 工具链(gopls 等),点击安装即可
  4. 安装完成后享受:自动补全、语法高亮、保存自动格式化、错误提示、跳转定义、调试

其他选择: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 章会详细讲解模块与依赖管理。

本章练习

  1. 完成 Hello World 的编写与运行。
  2. go build 编译出可执行文件并直接运行它。
  3. 试着把 fmt.Println("Hello, World!") 改成打印你自己的名字。
  4. (选做)尝试交叉编译一个 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 变量的作用域

  • 包级变量:声明在函数外,整个包内可见
  • 函数内变量:从声明处到所在代码块结束
  • 代码块变量iffor 等语句内声明的变量只在该块内有效
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

只有两个值:truefalse。默认零值 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 要熟练

本章练习

  1. 声明一个 const 块,用 iota 定义一周七天。
  2. 声明三个不同类型的变量,用 fmt.Printf 打印它们的值和类型。
  3. 定义一个 Celsius 自定义类型,写一行代码把 Celsius(100) 转成 Fahrenheit(公式 F = C * 9/5 + 32)。
  4. %#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 更强大且用法不同:

  1. 每个 case 默认自动 break ,不需要手动写 break
  2. case 可以是任意表达式(不限于常量)
  3. case 从上到下匹配,命中即结束
  4. 需要穿透时用 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
}

无表达式的 switchswitch { ... } 相当于 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、无表达式 switch
  • for:四种形态(三段式 / while / 无限 / range)
  • break / continue / 标签 / goto

本章练习

  1. 用 for 循环计算 1 到 100 的和。
  2. 用 for + continue 打印 1~50 之间所有能被 3 整除的数。
  3. 用 switch 把 1~5 映射为中文「一」到「五」。
  4. 写一个嵌套循环,用标签在 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
}

命名返回值的好处:

  1. 函数内可以直接使用返回值变量
  2. 配合 defer 可以修改返回值(见 5.8 节,这是 defer 的高级用法)
  3. 提高代码可读性(返回值有了名字,等于自带文档)

注意 :裸 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(参数) 改写成两件事------

  1. 在 defer 语句处调用运行时函数 runtime.deferproc:创建一个 _defer 结构,把要执行的函数指针和参数值 存进去(参数在此时就拷贝完毕------这就是「参数立即求值」规则的底层原因),然后挂到当前 goroutine 的 defer 链表头部
  2. 在函数每个返回路径前 插入 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
}

写递归的两个要点

  1. 必须有递归出口(base case),否则无限递归导致栈溢出(Go 中表现为程序崩溃)
  2. 递归深度要有限制。Go 的 goroutine 栈会动态增长(默认最大 1GB),但深递归仍可能耗尽内存。能用循环解决的问题优先用循环(更省内存);递归的优势是代码简洁、符合问题的自然结构(如目录树遍历、二叉树操作)

5.10 本章小结

  • 函数定义:func 名(参数) 返回值 { }
  • 多返回值func div(a, b int) (int, int),错误处理惯例「返回结果 + error」
  • 命名返回值 + 裸 return;可变参数 ...
  • 函数是一等公民:可赋值、可传参、可返回
  • 匿名函数 + 闭包:捕获外部变量;注意循环变量捕获(Go 1.22 已修复)
  • defer:LIFO 执行、参数立即求值、可修改命名返回值、典型用途是资源释放

本章练习

  1. 写一个 max(a, b int) int 函数返回较大值。
  2. 写一个可变参数函数,返回所有参数的平均值。
  3. 写一个闭包实现的「累加器」:每次调用返回累计和。
  4. 用 defer 写一个程序:先打印「开始」,函数末尾打印「结束」,中间用 panic 模拟异常,验证 defer 在异常时也会执行(panic 第 10 章讲,先体验)。
  5. 用递归实现「汉诺塔」打印移动步骤(挑战题)。

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

内存图理解ab 都指向同一个底层数组 [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 的规则(必须记住)

  1. append 返回新切片 ,必须用返回值接收:s = append(s, x)
  2. 容量足够时,原地写入底层数组(共享者可能受影响)
  3. 容量不足时,分配新的更大的底层数组(元素全部拷贝过去),旧数组成为孤儿
  4. 扩容策略:容量小于 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           │
└───────────────────┘

用这个视角,前面所有「奇怪」的行为都一目了然:

  1. 为什么传切片很便宜------函数传参只拷贝 24 字节的 header,不拷贝数据(对比数组整体复制)
  2. 为什么函数内修改元素,外面看得见------header 里的 array 指向同一个底层数组
  3. 为什么 append 必须用返回值接收 ------append 会修改 header 的 len,可能修改 cap,容量不足时还会换一个新的底层数组(array 指针也变了);值传递的副本改动了,调用方的 header 并不知道
  4. 为什么两个切片共享底层数据 ------b := a[1:3] 只是复制了一个 header,array 仍指向同一块内存
  5. 为什么 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 的四个必知要点

  1. 读取不存在的键返回零值 ,不报错;判断存在必须用 v, ok := m[key]
  2. 遍历顺序随机(Go 故意打乱顺序,防止依赖遍历顺序写出 bug)
  3. 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
  1. 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
}

三个重要推论:

  1. 为什么字符串不可变 :底层字节数组是只读的(可能位于只读内存段或被多处共享),运行时禁止修改。不可变带来一个巨大好处------字符串可以安全共享whole[:5]whole 指向同一段字节,截取子串是 O(1) 操作,不需要复制。任何语言中「截取子串不复制」都要求底层不可变
  2. 为什么 len() 是字节数 :描述符里只有字节数,没有字符数;想知道字符数必须遍历解码(utf8.RuneCountInString 或 range)
  3. 为什么 + 拼接慢 :每次 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 高效拼接

本章练习

  1. 写函数 reverse(s []int) 原地反转切片。
  2. 写函数 dedup(s []int) []int 去除相邻重复元素。
  3. 统计一段英文文本中每个单词的出现次数(用 strings.Fields + map)。
  4. 把字符串 "3.14159" 解析为 float64 并保留两位小数输出。
  5. 用切片实现一个简单的「栈」(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

本章练习

  1. 写函数 swap(a, b *int) 交换两个变量的值,并在 main 中验证。
  2. 写一个 doubleAll(nums []int) 函数,把切片每个元素翻倍(直接修改)。
  3. 思考:为什么 append 要返回新切片,而 changeFirst 不用?
  4. 写一个返回 *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
}

注意 CD 字段相同、顺序不同,大小分别是 12 和 8------字段顺序影响结构体占用的内存

实际影响:

  • 大量小结构体(缓存对象、网络消息、游戏实体)时,合理排列字段(大字段在前)能节省可观内存
  • 但这是实现细节,普通业务代码不必刻意优化;只有内存敏感或性能敏感场景才值得调整字段顺序
  • 判断字段是否需要按特定顺序是编译器/内存对齐的责任,新手只需知道「结构体大小 ≠ 字段大小之和」即可

8.8 本章小结

  • type Xxx struct { ... } 定义结构体;Xxx{字段: 值} 初始化
  • 结构体是值类型;传指针可修改外部
  • 方法:值接收者 / 指针接收者(要修改字段、大结构体、拿不准 → 指针接收者)
  • 组合代替继承:匿名嵌入,字段与方法自动提升,可覆盖
  • struct tag 配合 JSON 序列化;只有大写字段才参与序列化
  • 构造函数惯例:NewXxx(...) *Xxx

本章练习

  1. 定义 Circle 结构体(半径),实现 Area()Perimeter() 方法。
  2. 定义 BankAccount 结构体(余额),用指针接收者实现 DepositWithdraw 方法,取款余额不足时返回错误。
  3. 定义 Car 嵌入 Engine,验证字段和方法的提升。
  4. 给一个结构体加 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
}

关键点

  1. 接口只描述「能做什么」(方法集合),不关心「是什么」
  2. 实现是隐式的 :没有 implements 关键字,实现了方法就自动满足接口
  3. 接口变量可以保存任何实现了该接口的具体值
  4. 通过接口调用方法时,实际执行的是动态类型的方法(多态)

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.ReadWriterio.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
}

由这个结构推导出的重要结论:

  1. 把值赋给接口时,值会被拷贝data 指向这份拷贝(小值直接内联进 data 区域,大值在堆上分配后由 data 指向)
  2. 接口变量 = 「类型 + 数据」两部分 ------这就是 9.6 节 nil 陷阱的底层原因:var p *T = nil; var i I = p 时,data 是 nil,但 tab/_type 记录了类型信息,所以 i != nil
  3. 接口比较 :先比类型,类型相同再比数据;如果动态值是切片/map/函数这类不可比较类型,比较会 panic(runtime error: comparing uncomparable type)
  4. 接口方法调用比直接调用慢一点 :要通过 tab.fun 跳转表间接调用(动态分发),这是多态的代价;Go 编译器对部分场景会做 devirtualization 优化(类型已知时直接调用)

9.10 本章小结

  • 接口定义方法集合,实现是隐式的(鸭子类型)
  • 接口变量可以存任何实现者 → 多态
  • 空接口 interface{} / any:接受任意类型,但失去类型安全
  • 类型断言 v, ok := i.(T):安全取出动态类型;type switch 批量判断
  • nil 陷阱:接口 nil = 类型和值都 nil;不要返回值为 nil 的指针错误
  • 常用接口:errorfmt.Stringerio.Reader/Writersort.Interface

本章练习

  1. 定义 Speaker 接口(Speak() string),让 DogCatRobot 实现它,并写一个函数接收 Speaker 切片,逐一让它们说话。
  2. 写函数 sumNumbers(values []any) float64,把切片中的 int 和 float64 加起来(用类型断言)。
  3. 给自己的结构体实现 String() 方法,验证 Println 输出变化。
  4. 复现 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.Iserrors.As 的用途

  • 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% 的情况)
不可恢复的致命问题(配置错误、端口被占用) paniclog.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 是最后手段

本章练习

  1. 写一个 parseAge(s string) (int, error):空字符串或非数字返回 error,负数返回 error,正常返回年龄。
  2. 定义 ValidationError 结构体(字段名 + 原因),用 errors.As 提取并打印。
  3. 用 panic/recover 写一个不崩溃的「除以零」函数。
  4. 读代码:为什么库函数里不应该 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 框架:ginechofiberchi
  • 数据库驱动:database/sql + go-sql-driver/mysqlpgx(PostgreSQL)、mongo-driver
  • ORM:gorm
  • 日志:zerologzaplogrus
  • 命令行:cobra
  • 配置:viper
  • HTTP 客户端:官方 net/httpresty
  • 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 mathxmathx/ 目录)
  • 可见性 = 首字母大小写
  • 导入:基本 / 别名 / 点导入 / 匿名导入
  • init 在 main 前执行;依赖先初始化
  • 模块命令:go mod init / go get / go mod tidy
  • 标准库包熟悉度决定开发效率;第三方库从 pkg.go.dev 找

本章练习

  1. 创建项目 calculator,把加减乘除放进 calculator/ops 包,main 调用它。
  2. 验证:在 ops 包里加一个未导出函数,main 里调用看编译错误。
  3. go get 安装 github.com/sirupsen/logrus,写一段使用它的日志代码,然后 go mod tidy
  4. 查看 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 的特点

  1. 极轻量 :初始栈只有几 KB(线程默认 1-8MB),一个程序轻松跑数十万个 goroutine
  2. 调度由 Go 运行时管理(M:N 调度:大量 goroutine 映射到少量 OS 线程),不需要手动创建线程
  3. 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 使用铁律

  1. Add(1) 必须在 go 之前调用(在 goroutine 内部 Add 可能来不及)
  2. Done() 必须用 defer 保证执行
  3. 必须传指针 &wg(WaitGroup 是值类型,复制会破坏计数)
  4. 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.AddInt64atomic.LoadInt64atomic.StoreInt64atomic.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 派生子 context
  • cancel 必须调用(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 核数)

调度循环(简化版):

  1. M 从自己 P 的本地队列取一个 G 执行
  2. 本地队列空了,去全局队列取一批
  3. 全局也空,去其他 P 的队列「偷」(work stealing)
  4. G 阻塞(channel 收发、锁等待)时,M 把 G 挂起,换下一个 G 执行------阻塞的是 G,不是线程
  5. G 陷入系统调用阻塞时,M 会被内核阻塞,运行时把 P 转交给其他空闲 M 继续干活
  6. 网络 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 detectorgo run -race,并发必测
  • 死锁 / goroutine 泄漏的预防
  • context:超时取消与传递

本章练习

  1. 启动 10 个 goroutine 并发计算各自数字的平方,用 channel 收集结果后打印。
  2. 用 WaitGroup 启动 100 个 goroutine 并发 counter++,先不加锁运行(观察结果),再加 Mutex 修复。
  3. go run -race 运行上面的程序,观察检测报告。
  4. 写一个带超时的「下载」模拟:goroutine 睡 3 秒,select 等 1 秒,打印超时。
  5. 用 channel 实现一个简易「工作池」:3 个 worker goroutine 从 channel 取任务并处理,主 goroutine 发 10 个任务后 close。
  6. 写一个并发安全的计数器结构体(Mutex + 方法),并用 1000 个 goroutine 验证。

第 13 章 标准库常用包

Go 的标准库极其丰富,被称为「自带电池」。本章挑选日常开发最高频的包逐一实战:ostimeencoding/jsonnet/httpsortlogflag

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.RawMessageDisallowUnknownFields 可处理,进阶)

通用 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、Exit
  • time:Now、Date、Add、Since、Format 用参考时间 2006-01-02 15:04:05、Timer/Ticker
  • encoding/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

本章练习

  1. 用 time 打印「今天是 2026年08月21日 星期五」格式的当前时间。
  2. 写一个 HTTP 服务:/now 返回当前时间 JSON,/echo?msg=xxx 原样返回 msg。
  3. 写程序读取 https://httpbin.org/ip 并解析出 origin 字段打印。
  4. 定义 Book{Title, Author, Pages},序列化时 pages 为 0 时省略,title 转成小写 key,然后存 JSON 文件再读回。
  5. 用 flag 写一个 greet 程序:-name 必填,-times 默认 1,-lang zh|en 切换语言。

第 14 章 文件操作实战

文件读写是几乎所有程序的基础能力。Go 的 osbufioiopath/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.ReadFileio.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 处理表格数据

本章练习

  1. 写程序:读入一个文本文件,统计行数、单词数、字符数并打印。
  2. 写程序:把 demo.txt 的内容逐行反转顺序写入 reversed.txt
  3. 写程序:把某个目录下所有 .txt 文件的大小总和算出来(用 WalkDir)。
  4. 把 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

本章练习

  1. 给你第 5 章的 max 函数写表驱动测试(正数、负数、相等、边界)。
  2. 写一个 Reverse(s string) string 函数(反转字符串),配测试,注意中文字符(提示:用 \[\]rune)。
  3. 给一个会失败的错误场景写测试,运行 go test -v 观察失败输出。
  4. 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 起标准库将常用约束移入 cmpslicesmaps 包):

约束 允许的类型
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 泛型的适用场景与限制

适合用泛型

  • 容器类型:栈、队列、集合、缓存(如 slicesmaps 标准库就是泛型重写)
  • 通用算法:排序、查找、去重、求极值
  • 类型间只存在「结构相似、类型不同」的重复代码

不建议滥用

  • 每个方法都要加类型参数,可读性下降
  • 泛型与接口方法不能混用(泛型类型不能定义方法上的新类型参数)
  • 性能不是主要考量:泛型并非「零成本抽象」的代名词,Go 泛型的实现有额外开销

好消息:标准库 slicesmapscmp 包(Go 1.21+)提供了大量现成的泛型工具函数(slices.Sortslices.Containsslices.Deletemaps.Clone 等),先查标准库再自己写。

16.5 本章小结

  • 泛型函数:func F[T 约束](...) T,调用时自动推断类型参数
  • 约束:类型集合(int | float64)、~ 匹配底层类型、any/comparable 内置
  • 泛型类型:type Stack[T any] struct{...},方法接收者带 [T]
  • 优先用标准库 slices / maps / cmp,别重复造轮子

本章练习

  1. 写泛型函数 Min[T cmp.Ordered](a, b T) T
  2. 写泛型函数 Contains[T comparable](items []T, target T) bool
  3. 写泛型函数 Filter[T any](items []T, keep func(T) bool) []T
  4. 用泛型实现一个简单的键值缓存(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.Printlngo doc net/http
go env 查看环境配置 go env GOPATHgo env -w GOPROXY=...
go mod 模块管理 go mod initgo mod tidygo 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 initgo 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(学习阶段足够)
  • 专业调试器:Delvedlv),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 rungo buildgo testgo vetgo 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 命名与代码风格规范

  • 文件名、变量、函数 :小驼峰 myVariablemyFunc导出 :首字母大写 MyFunc
  • 常量 :首字母大写驼峰(MaxRetries),不强制全大写(MAX_RETRIES 不是 Go 风格,但包级常量全大写也常见,团队统一即可)
  • 缩写词 :统一大写如 URLIDAPIuserIDparseURL
  • 接口名 :以 er 结尾(ReaderWriterHandler
  • 错误变量err;包级哨兵错误 ErrXxxErrNotFound
  • 注释 :导出标识符必须有注释,以标识符名开头(// Add 返回...
  • 包名 :全小写、无下划线(mathxhttputil);避免 utilcommon 这类没信息量的名字

为什么 Go 这么强调风格 ?因为 gofmt 保证所有代码机器统一格式化,风格争议在 Go 社区根本不存在------这是它在大团队受欢迎的重要原因。

18.3 工程最佳实践清单

  1. 错误必须处理 :要么返回、要么记录日志,绝不 _ 吞掉关键错误
  2. 尽早返回if err != nil { return ... } 链式往下,避免深嵌套
  3. defer 立即配对:打开资源后立刻写 defer 关闭
  4. 接口尽量小:接口只声明你需要的 1-3 个方法
  5. 接收者一致:同一个类型的方法要么都用值接收者,要么都用指针接收者
  6. 并发安全 :共享数据必同步;go test -race 养成习惯
  7. 依赖管理 :提交前 go mod tidy,代码评审看 go.mod 变更
  8. 测试先行:重要逻辑写表驱动测试,覆盖率不是目标而是信号
  9. 包设计 :一个包一个职责;internal/ 保护内部实现
  10. 不要过度设计:能用标准库就不引第三方;能简单就不抽象

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.Newfmt.Errorf + %w 包装、os.IsNotExist
文件读写 os.ReadFile / os.WriteFile
JSON 编解码 json.MarshalIndent / json.Unmarshal
切片操作 追加、遍历、删除元素
命令行参数 os.Args + flag.NewFlagSet 子命令
字符串转数字 strconv.Atoi
程序退出码 os.Exit(1)

扩展练习(进阶方向):

  1. list-all 参数控制是否显示已完成任务
  2. clear 子命令一键清空已完成任务
  3. -file 全局参数指定数据文件路径(支持多个清单)
  4. 用并发(goroutine + WaitGroup)给「添加任务」加一个模拟的耗时校验
  5. cmdAdd / cmdDone 写单元测试(提示:测试时把 dataFile 指向临时文件)

第 20 章 底层原理:编译、逃逸分析与内存管理(进阶)

本章适合学完前面所有章节、想深入理解 Go 内部机制时阅读。理解这些原理,能帮你写出更高效、更可预测的代码。全部内容基于 Go 1.21+ 的运行时实现。

20.1 Go 程序的编译过程

Go 是编译型语言,go build 完成从源码到机器码的完整流水线:

  1. 词法分析:源码字符流 → token 序列(关键字、标识符、运算符、字面量...)
  2. 语法分析:token → 抽象语法树(AST)
  3. 类型检查:在 AST 上做类型检查和 import 解析------绝大多数类型错误在这一步暴露(所以编译期就能保证类型安全)
  4. 生成 SSA:AST → 静态单赋值形式的中间表示(SSA),后续优化都在 SSA 上进行
  5. 优化:函数内联(inline)、逃逸分析、常数折叠、死代码消除、循环优化、边界检查消除...
  6. 生成机器码:SSA → 汇编 → 目标平台机器码(每种 GOARCH 对应一个后端)
  7. 链接 :把 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 留在栈上

实践意义:

  1. Go 允许安全地返回局部变量地址(第 7.6 节)------编译器自动把 x 放到堆上,不会像 C 那样悬垂
  2. 频繁逃逸 = 频繁堆分配 = GC 压力大:性能敏感的热路径上,尽量让数据留在栈上(避免不必要的接口装箱、避免返回大结构体指针)
  3. 内联与逃逸互相影响:小函数被内联(展开到调用处)后,原本逃逸的变量可能不再逃逸------编译器优化是个整体

20.3 垃圾回收(GC):并发三色标记-清除

Go 的 GC 是并发三色标记-清除(tricolor mark-and-sweep,Go 1.5 起):

三色标记

颜色 含义
未被扫描,可能是垃圾
已发现、等待扫描(在队列中)
已扫描完毕,确认存活

流程

  1. 根对象(全局变量、goroutine 栈上的引用)出发,把直接引用的对象标灰
  2. 反复把灰对象标黑,同时把它引用的白对象标灰;直到没有灰色对象
  3. 剩下的白色对象就是垃圾,清除回收

并发与 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 是性能分析的标准入口

本章练习

  1. go build -gcflags="-m" 分析 21.2 示例的逃逸输出,再写几个自己的函数观察。
  2. GODEBUG=gctrace=1 运行 21.3 示例,观察 gc 行的堆大小增长曲线。
  3. 写一个基准测试对比「直接分配」与「sync.Pool 复用」的耗时(第 15.4 节的知识)。
  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 练习资源

21.5 给新手的最后建议

  1. 手敲代码:看 100 遍不如敲 1 遍,所有示例务必亲手运行
  2. 先跑通再优化:第一版能跑就行,不要边写边纠结设计
  3. 学会看报错:Go 的编译错误信息非常明确,读错误信息本身就是学习
  4. 坚持读官方文档go doc 是最好的老师
  5. 参与社区 :Go 中文社区(https://golangcn.org)、Stack Overflow、GitHub Discussions
  6. 写博客/笔记:输出是最好的输入,把学到的东西讲给别人听

附录 A:本文档使用说明

  • 标注「完整可运行程序」的代码块都可以直接复制为 .go 文件运行(go run 文件名.go
  • 标注为「片段」的代码块(没有 package 声明)依赖上下文,请结合说明理解
  • 本文档所有示例基于 Go 1.25(同时兼容 Go 1.21+;个别 Go 1.22 新特性已标注)
  • 遇到问题先看第 18 章的坑清单

祝学习顺利!Go 是一门「越写越喜欢」的语言------简单、直接、高效。坚持下去,你一定会爱上它。

相关推荐
用户330144867634 小时前
08. mcentral:中心缓存的 span 管理
go
用户330144867634 小时前
07. mcache.refill:缓存补充与 span 归还
go
程序员爱钓鱼6 小时前
Go 编程实战:匿名函数 Anonymous Function——没有名字的函数与灵活回调
后端·面试·go
用户330144867631 天前
03. Size Class 分级机制
go
用户330144867631 天前
04. mallocgc:分配总入口
go
程序员爱钓鱼1 天前
Go 编程实战:函数 Function——参数、返回值与代码复用
后端·面试·go
名字还没想好☜2 天前
Go 1.23 range-over-func 迭代器实战:自定义可迭代类型、提前退出与惰性求值
开发语言·后端·golang·go·迭代器
赫媒派2 天前
Go 1.27 来了:泛型方法补齐,JSON 提速不踩坑
后端·go·敏捷开发
小满zs2 天前
Go语言第九章(错误处理)
后端·go