嗯好的,因为个人原因变动所以决定从公司跑路转向Golang后端/大模型应用所以有了这篇文章
大纲参考B站枫枫讲Go语言-8小时入门GO
一、变量定义
Go 使用 var 定义变量,也支持在函数内部使用短变量声明 :=。
1. 显式指定类型
Go
var age int = 25
含义:
- 变量名是
age - 类型是
int - 初始值是
25
Go 的类型写在变量名后面,这一点和 Java、C# 不同。
Java / C# 写法:
java
int age = 25;
Go 写法:
var age int = 25
2. 省略类型,让编译器推断
var name = "Alice"
Go 会根据右侧的值推断变量类型。
上面的代码中,name 会被推断为 string 类型。
这和 C# 的 var 有相似之处:
var name = "Alice";
但需要注意,Go 仍然是静态类型语言。变量类型一旦确定,后续不能改成其他类型。
var age = 25
// age = "18" // 编译错误
3. 只声明,不赋值
Go
var score int
var enabled bool
var message string
Go 中变量只声明不赋值时,会自动获得对应类型的零值。
常见零值:
| 类型 | 零值 |
|---|---|
int / float64 等数字类型 |
0 |
bool |
false |
string |
"" |
| 指针、切片、map、channel、函数、接口 | nil |
示例:
var score int
var enabled bool
var message string
fmt.Println(score) // 0
fmt.Println(enabled) // false
fmt.Println(message) // ""
Java、C# 中字段也有默认值,Go 的零值机制和它们有相似点。区别是 Go 的局部变量如果声明了,就必须被使用,否则编译失败。
4. 短变量声明
city := "Shanghai"
:= 是 Go 的短变量声明语法,等价于声明变量并赋初始值。
city := "Shanghai"
可以理解为:
var city = "Shanghai"
限制:
:=只能在函数内部使用。:=不能用于包级变量。:=左边至少要有一个新变量。
错误示例:
package main
appName := "GoStudy" // 编译错误
正确写法:
package main
var appName = "GoStudy"
5. 一次声明多个变量
var x, y int = 10, 20
也可以使用短变量声明:
width, height := 1920, 1080
如果变量较多,也可以使用分组声明:
var (
price float64 = 99.9
quantity int = 3
inStock bool = true
)
分组声明常用于定义一组相关变量。
6. 变量可以重新赋值
var age int = 25
age = age + 1
变量可以重新赋值,但不能改变类型。
var age int = 25
// age = "Alice" // 编译错误
这一点和 Python 不同。
Python 中变量名更像是对象引用:
age = 25
age = "Alice"
Go 中变量有明确类型:
age := 25
// age = "Alice" // 编译错误
7. 包级变量
定义在函数外部的变量叫包级变量。
package main
var appName = "GoStudy"
func main() {
fmt.Println(appName)
}
包级变量可以近似理解为 Go 中的"全局变量",但更准确的说法是"包级变量"。
它属于当前 package,同一个包里的其他 .go 文件也可以访问它。
和 Java / C# 类比,包级变量接近类上的静态字段:
class App {
static String appName = "GoStudy";
}
区别是 Go 没有 class,函数外定义的变量直接属于当前 package。
包级变量的访问范围和首字母有关:
var appName = "GoStudy" // 小写开头:只在当前包内可见
var AppName = "GoStudy" // 大写开头:其他包也可以访问
8. 本节小结
变量定义需要重点掌握:
var name type = value:显式类型声明。var name = value:省略类型,由编译器推断。var name type:只声明不赋值,使用零值。name := value:短变量声明,只能在函数内部使用。- 包级变量定义在函数外,属于当前 package。
- Go 是静态类型语言,变量类型确定后不能改成其他类型。
二、输入输出
Go 中最常用的基础输入输出来自标准库 fmt 包。
常见输出函数:
fmt.Print:输出内容,不自动换行。fmt.Println:输出内容,并自动换行。fmt.Printf:按照格式化模板输出。fmt.Sprintf:按照格式化模板生成字符串,但不直接输出。
常见输入方式:
fmt.Scan:从标准输入读取内容。fmt.Scanln:读取一行中的内容。fmt.Scanf:按指定格式读取内容。fmt.Fscan:从指定输入源读取内容,常和bufio.Reader搭配使用。bufio.Reader:适合读取一整行文本。
1. 基本输出
fmt.Print("Hello")
fmt.Print(" Go\n")
Print 不会自动换行。如果需要换行,要自己写 \n。
fmt.Println("Hello Go")
Println 会自动换行,并且多个参数之间会自动加空格。
name := "Alice"
age := 25
fmt.Println("name:", name, "age:", age)
输出:
name: Alice age: 25
2. 格式化输出
Printf 用于格式化输出。
name := "Alice"
age := 25
score := 95.678
fmt.Printf("name=%s, age=%d, score=%.2f\n", name, age, score)
输出:
name=Alice, age=25, score=95.68
常用占位符:
| 占位符 | 含义 |
|---|---|
%s |
字符串 |
%d |
十进制整数 |
%f |
浮点数 |
%.2f |
保留两位小数 |
%t |
布尔值 |
%v |
按默认格式输出任意值 |
%T |
输出值的类型 |
%q |
带引号的字符串 |
3. 生成字符串
Sprintf 和 Printf 类似,但它不会直接输出,而是返回一个字符串。
name := "Alice"
age := 25
message := fmt.Sprintf("%s is %d years old", name, age)
fmt.Println(message)
Printf 和 Sprintf 的核心区别:
| 函数 | 是否直接输出 | 是否返回字符串 |
|---|---|---|
fmt.Printf |
是 | 否 |
fmt.Sprintf |
否 | 是 |
Printf 适合直接把内容打印到控制台。
fmt.Printf("name=%s, age=%d\n", name, age)
Sprintf 适合先生成一个字符串,再赋值给变量、写入文件、作为函数参数传递。
message := fmt.Sprintf("name=%s, age=%d", name, age)
fmt.Println(message)
和 C# 类比:
string message = string.Format("{0} is {1} years old", name, age);
和 Python 类比:
message = f"{name} is {age} years old"
4. 基本输入
可以使用 fmt.Scan 读取用户输入。
var name string
var age int
fmt.Scan(&name, &age)
fmt.Printf("name=%s, age=%d\n", name, age)
输入:
Tom 18
输出:
name=Tom, age=18
注意这里传入的是 &name 和 &age,不是 name 和 age。
& 表示取变量地址。因为输入函数需要把读取到的值写回变量,所以必须传变量地址。
这一点和 Java、C#、Python 的普通输入函数不同。Go 在这里显式要求把"要被修改的变量地址"传进去。
5. 按空白分隔读取
fmt.Scan、fmt.Fscan 默认按空白字符分隔输入。
空格、Tab、换行都可以作为分隔符。
var name string
var age int
fmt.Scan(&name, &age)
下面两种输入效果相同:
Tom 18
Tom
18
如果要读取一句完整的话,仅使用 fmt.Scan 不合适。
例如输入:
hello golang
使用 fmt.Scan(&text) 只能读到:
hello
因为空格会被当作分隔符。
6. 读取一整行
读取一整行文本,可以使用 bufio.Reader。
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
fmt.Println("read error:", err)
return
}
line = strings.TrimSpace(line)
fmt.Println("输入内容:", line)
需要导入:
import (
"bufio"
"fmt"
"os"
"strings"
)
ReadString('\n') 会一直读取到换行符为止。
strings.TrimSpace 用于去掉字符串前后的空白字符,包括换行符。
7. 输入代码中的常见概念
nil
nil 表示"没有值",可以类比 Java、C#、Python 中的 null / None,但不能完全等同。
在 Go 中,只有某些类型可以是 nil,例如:
- 指针
- 切片
- map
- channel
- 函数
- 接口
- error
普通的 int、bool、string 不能是 nil。
在输入输出代码里,常见写法是:
line, err := reader.ReadString('\n')
if err != nil {
fmt.Println("read error:", err)
return
}
这里的 err == nil 通常表示没有错误,err != nil 表示发生了错误。
err
err 不是关键字,只是一个普通变量名。
Go 里很多函数会把错误作为最后一个返回值返回,所以大家习惯把这个变量命名为 err。
line, err := reader.ReadString('\n')
这行代码的意思是:
line接收读取到的字符串。err接收读取过程中可能发生的错误。
_
_ 叫空白标识符,用来接收但丢弃某个值。
Go 要求函数内声明的变量必须被使用。如果某个返回值不需要,可以用 _ 丢弃。
_, err := fmt.Scan(&name)
上面代码中,fmt.Scan 的第一个返回值被丢弃,只保留错误信息 err。
如果两个返回值都不关心,也可以写:
_, _ = reader.ReadString('\n')
_ 不是普通变量,后面不能再使用它。
reader
reader 是变量名,不是关键字。
reader := bufio.NewReader(os.Stdin)
这行代码创建了一个从标准输入读取内容的缓冲读取器。
os.Stdin 表示标准输入,通常就是键盘输入。
bufio.NewReader(os.Stdin) 表示在标准输入外面包一层缓冲读取能力,使它可以更方便地读取一整行。
缓冲读取器
缓冲读取器可以理解为:程序不直接一点一点从输入源读取,而是先从输入源拿一批数据放到内存缓冲区里,后续读取时优先从这块缓冲区取数据。
普通读取可以理解为:
程序 -> 输入源
缓冲读取可以理解为:
程序 -> 缓冲区 -> 输入源
这样做有两个好处:
- 减少频繁访问底层输入源的次数。
- 提供更方便的读取方法,例如按行读取。
在 Go 中:
reader := bufio.NewReader(os.Stdin)
含义是:基于标准输入 os.Stdin 创建一个带缓冲能力的读取器。
之后可以使用:
line, err := reader.ReadString('\n')
这表示从缓冲读取器里读取内容,直到遇到换行符。
和 Java 类比:
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
Go 的 bufio.Reader 和 Java 的 BufferedReader 思路类似,都是在原始输入流外面包一层缓冲能力。
*bufio.Reader
bufio.Reader 是 Go 标准库 bufio 包里的一个类型。
*bufio.Reader 表示"指向 bufio.Reader 的指针"。
func discardRemainingLine(reader *bufio.Reader) {
_, _ = reader.ReadString('\n')
}
这里参数类型写成 *bufio.Reader,表示函数接收的是一个 Reader 对象的地址。
和 Java / C# 的引用对象类比:
BufferedReader reader
Go 里用 *bufio.Reader 明确表示这是一个指针。函数内部通过这个指针继续读取同一个输入流。
输入场景中的指针
Go 中的指针是显式的。
在输入场景里,最常见的是:
fmt.Scan(&name)
这里的 &name 表示取 name 变量的地址。
输入函数需要把用户输入写回变量,所以必须知道变量的地址。
如果写成:
fmt.Scan(name)
传进去的只是 name 当前的值,输入函数无法修改外面的 name 变量。
可以先记住这条规则:
函数如果需要修改变量,就传 `&变量`
而 reader := bufio.NewReader(os.Stdin) 创建出来的 reader 本身就是 *bufio.Reader 类型。
所以如果函数参数需要的是:
func readLine(reader *bufio.Reader) {
// ...
}
调用时直接传:
readLine(reader)
不需要写成:
readLine(&reader)
8. Scan 和 Fscan 的区别
Scan 和 Fscan 的核心区别是读取来源不同。
fmt.Scan(&name, &age)
Scan 默认从标准输入读取,也就是 os.Stdin。
fmt.Fscan(reader, &name, &age)
Fscan 从指定的输入源读取。这里指定的输入源是 reader。
可以简单理解为:
| 函数 | 读取来源 |
|---|---|
fmt.Scan |
默认从 os.Stdin 读取 |
fmt.Fscan |
从指定的 io.Reader 读取 |
如果已经创建了缓冲读取器:
reader := bufio.NewReader(os.Stdin)
后续建议统一使用:
fmt.Fscan(reader, &name, &age)
line, err := reader.ReadString('\n')
这样所有输入都从同一个 reader 读取,缓冲状态更一致。
9. ReadString 的作用
ReadString('\n') 是 bufio.Reader 的方法,用于一直读取到指定字符为止。
line, err := reader.ReadString('\n')
这里的 '\n' 表示换行符。
所以这行代码的意思是:
从 reader 中读取内容,直到遇到换行符
ReadString 返回两个值:
| 返回值 | 含义 |
|---|---|
line |
读取到的字符串 |
err |
读取过程中可能发生的错误 |
如果只是想清理当前行剩余内容,可以写:
_, _ = reader.ReadString('\n')
这里的两个 _ 表示两个返回值都丢弃。
这个写法常用于处理 Fscan 之后残留的换行符。
fmt.Fscan(reader, &name, &age)
_, _ = reader.ReadString('\n')
line, err := reader.ReadString('\n')
第一行按空白读取 name 和 age。
第二行清理当前行剩余内容,包括回车产生的换行符。
第三行再读取下一整行文本。
10. 多个输入函数共用同一个 Reader
如果一个程序中有多个函数都要读取用户输入,建议在外层创建一个 reader,然后传给每个函数。
推荐写法:
func main() {
reader := bufio.NewReader(os.Stdin)
readUser(reader)
readProduct(reader)
readLine(reader)
}
函数参数:
func readUser(reader *bufio.Reader) {
var name string
var age int
fmt.Fscan(reader, &name, &age)
}
不推荐每个函数内部都重新创建:
func readUser() {
reader := bufio.NewReader(os.Stdin)
}
func readProduct() {
reader := bufio.NewReader(os.Stdin)
}
原因是缓冲读取器内部会维护自己的缓冲区和读取位置。多个 Reader 同时包在同一个 os.Stdin 上,容易导致输入状态不一致。
一个程序中如果要连续读取多段输入,使用同一个 Reader 更清晰。
常见现象:
- 前一个输入函数看起来"多读了"后续输入。
- 后一个输入函数还没真正等待输入,就直接报读取失败。
- 提示顺序和报错顺序看起来混乱。
这类问题通常不是 if 判断写错,而是输入源被多个 Reader 分别维护缓冲导致的。
排查与处理建议:
- 优先确认是否在多个函数里重复
bufio.NewReader(os.Stdin)。 - 如果是,改为在
main创建一个 Reader,并作为参数传下去。 - 如果同时使用了
Fscan和ReadString,注意清理换行符,避免把上一段输入残留给下一段逻辑。
11. 本节小结
输入输出需要重点掌握:
Print不自动换行。Println自动换行。Printf用格式化模板输出。Sprintf返回格式化后的字符串。Scan读取输入时要传变量地址,例如&name。Scan默认按空白字符分隔,不适合读取整句话。- 读取一整行文本可以使用
bufio.Reader。 Scan默认从标准输入读取,Fscan可以指定读取来源。ReadString('\n')表示读取到换行符为止。- 多个输入函数建议共用同一个
reader。 err是普通变量名,通常用于接收错误。nil通常表示没有值,err == nil表示没有错误。_用于丢弃不需要的返回值。
三、基本数据类型
Go 是静态类型语言,变量一旦确定类型,就不能再改成其他类型。
Go 中常见的基本数据类型包括:
- 布尔类型:
bool - 整数类型:
int、int8、int16、int32、int64 - 无符号整数类型:
uint、uint8、uint16、uint32、uint64 - 浮点数类型:
float32、float64 - 复数类型:
complex64、complex128 - 字符串类型:
string - 字节类型:
byte - 字符类型:
rune
1. bool
bool 表示布尔值,只有两个值:
true
false
示例:
var enabled bool = true
var finished bool
fmt.Println(enabled) // true
fmt.Println(finished) // false
bool 的零值是 false。
Go 中不能把数字当成布尔值使用。
// if 1 { } // 编译错误
这一点和 Python 不同。Python 中 0、空字符串、空列表等可以作为假值判断,Go 不允许这样做,条件表达式必须是 bool。
2. 整数类型
Go 的整数类型分为有符号整数和无符号整数。
有符号整数可以表示正数、负数和零:
int
int8
int16
int32
int64
无符号整数只能表示零和正数:
uint
uint8
uint16
uint32
uint64
无符号整数也可以叫 unsigned integer。
var count uint = 100
// count = -1 // 编译错误
常用的是 int。
var age int = 18
int 的具体大小和平台有关。在 64 位系统上通常是 64 位,在 32 位系统上通常是 32 位。
如果需要明确位数,可以使用:
var a int32 = 100
var b int64 = 10000000000
业务代码中通常优先使用 int。只有在明确需要固定范围、二进制协议、文件格式、网络协议,或者需要表达"不能为负"时,才更常使用 int8、uint32 这类具体位数类型。
注意:不同整数类型之间不能直接运算。
var a int = 10
var b int64 = 20
// result := a + b // 编译错误
result := int64(a) + b
fmt.Println(result)
Go 不会自动把 int 转成 int64,必须显式转换。
3. byte
byte 是 uint8 的别名,通常用于表示一个字节。
var b byte = 'A'
fmt.Println(b) // 65
fmt.Printf("%c", b) // A
'A' 是字符字面量,底层对应 Unicode 编码值。因为 A 的编码值是 65,所以打印数值时会看到 65。
处理二进制数据、文件内容、网络数据时,经常会看到 []byte。
data := []byte("hello")
fmt.Println(data)
[]byte 表示 byte 切片。切片后面会单独学习,这里可以先理解为"一组 byte"。
把字符串转成 []byte,看到的是字符串的 UTF-8 字节表示。
s := "Go语言"
bytes := []byte(s)
fmt.Println(len(bytes)) // 8
其中:
G 占 1 字节
o 占 1 字节
语 占 3 字节
言 占 3 字节
4. rune
rune 是 int32 的别名,通常用于表示一个 Unicode 字符。
var r rune = '语'
fmt.Println(r)
fmt.Printf("%c", r)
Go 的字符串底层是 UTF-8 编码,一个中文字符通常会占多个字节。
s := "Go语言"
fmt.Println(len(s)) // 字节长度
fmt.Println(len([]rune(s))) // 字符数量
len(s) 返回的是字节数量,不是字符数量。
如果要按字符处理字符串,通常需要转成 []rune。
[]rune 表示 rune 切片。这里可以先理解为"一组 Unicode 字符"。
s := "Go语言"
runes := []rune(s)
fmt.Println(len(runes)) // 4
byte 和 rune 的区别可以简单记为:
byte 看底层字节
rune 看 Unicode 字符
5. 浮点数类型
Go 的浮点数类型有:
float32
float64
常用的是 float64。
float32 和 float64 的区别类似 Java、C# 中 float 和 double 的区别。
| Go | Java / C# 类比 | 说明 |
|---|---|---|
float32 |
float |
单精度浮点数,精度较低 |
float64 |
double |
双精度浮点数,精度较高 |
float64 更常用,因为精度更高,标准库中很多数学函数也使用 float64。
var price float64 = 19.99
fmt.Printf("%.2f\n", price)
%.2f 表示保留两位小数输出。
注意:浮点数适合表示近似小数,不适合直接用于高精度金额计算。
float32 和 float64 不能直接运算。
var a float32 = 1.2
var b float64 = 3.4
// result := a + b // 编译错误
result := float64(a) + b
Go 要求显式类型转换。
6. 复数类型
复数是数学中的 complex number,由实部和虚部组成。
Go 内置复数类型:
complex64
complex128
示例:
var c complex128 = 3 + 4i
fmt.Println(real(c)) // 3
fmt.Println(imag(c)) // 4
其中:
3 是实部
4 是虚部
i 是虚数单位
大多数业务开发中复数类型用得较少,主要出现在数学、科学计算等场景。
7. string
string 表示字符串。
var name string = "Alice"
字符串可以用双引号:
s := "hello"
也可以用反引号表示原始字符串:
text := `第一行
第二行`
反引号字符串会保留换行和普通字符,不会处理转义。
Go 字符串是不可变的。
s := "hello"
// s[0] = 'H' // 编译错误
如果要修改字符串,需要创建新字符串。
s = "H" + s[1:]
这里的 s[1:] 是切片表达式,表示从下标 1 开始截取到字符串末尾。
h e l l o
0 1 2 3 4
所以:
s[1:]
得到:
ello
再拼接:
"H" + s[1:]
得到新的字符串:
Hello
这不是原地修改 "hello",而是创建新字符串 "Hello",然后让变量 s 指向新字符串。
原来的 "hello" 如果没有其他地方再使用,就会变成不可达数据,之后可以由运行时回收。不过字符串字面量也可能被编译器放在只读数据区,实际是否立即回收不需要依赖。
从语言语义上,只需要记住:
字符串不可变
修改字符串的效果通常是创建新字符串
变量重新指向新字符串
8. 零值
Go 中变量声明后如果没有赋值,会自动获得零值。
常见基本类型零值:
| 类型 | 零值 |
|---|---|
bool |
false |
| 整数类型 | 0 |
| 浮点数类型 | 0 |
| 复数类型 | 0+0i |
string |
"" |
示例:
var number int
var price float64
var ok bool
var message string
fmt.Println(number) // 0
fmt.Println(price) // 0
fmt.Println(ok) // false
fmt.Println(message) // ""
9. 显式类型转换
Go 不会自动进行不同类型之间的隐式转换。
var age int = 18
var score float64 = 95.8
fmt.Println(float64(age))
fmt.Println(int(score))
把浮点数转成整数时,小数部分会被截断。
var price float64 = 19.9
fmt.Println(int(price)) // 19
注意:这是截断,不是四舍五入。
10. Go 没有包装类型和装箱拆箱
Go 没有 Java 中 int / Integer 这种包装类型体系,也没有 Java、C# 意义上的自动装箱和拆箱。
Java 示例:
int a = 10;
Integer b = a;
int c = b;
Go 中只有:
var a int = 10
如果需要传递地址,使用指针:
p := &a // p 的类型是 *int
如果需要接收任意类型,可以使用 any。
var x any = a
从 any 中取回具体类型时,需要使用类型断言。
value, ok := x.(int)
if ok {
fmt.Println(value)
}
any 可以类比 Java 中的 Object,但 Go 通常不会把这个过程称为装箱拆箱,而是称为把具体类型赋给接口类型,再通过类型断言取回具体类型。
11. 本节小结
基本数据类型需要重点掌握:
bool只有true和false,不能用数字代替布尔值。int是最常用的整数类型。uint是无符号整数,只能表示零和正数。- 不同数字类型之间不能直接运算,需要显式转换。
byte是uint8的别名,常用于表示字节。rune是int32的别名,常用于表示 Unicode 字符。len(string)返回字节长度,不是字符数量。- 字符串不可变,不能直接修改某个字符。
- 修改字符串通常是生成新字符串,再让变量指向新字符串。
float32类似float,float64类似double,Go 中更常用float64。float64是常用浮点类型,但不适合直接做高精度金额计算。complex64、complex128是复数类型,普通业务开发较少使用。- Go 没有 Java 风格的包装类型和自动装箱拆箱。
四、数组和切片
数组和切片都可以保存一组相同类型的数据,但它们在 Go 中是两个不同概念。
简单区分:
数组:长度固定,长度属于类型的一部分
切片:长度可变,更常用于日常开发
1. 数组
数组使用 [长度]类型 表示。
var scores [3]int = [3]int{90, 85, 100}
也可以简写:
scores := [3]int{90, 85, 100}
如果希望编译器根据元素数量推断数组长度,可以使用 ...:
scores := [...]int{90, 85, 100}
数组的长度可以用 len 获取。
fmt.Println(len(scores)) // 3
2. 数组长度属于类型
Go 中数组长度是类型的一部分。
var a [3]int
var b [4]int
a 的类型是 [3]int,b 的类型是 [4]int。它们不是同一种类型。
// a = b // 编译错误
这一点和 Java、C# 中的数组差别较大。
Java / C# 中通常更关注数组元素类型,而 Go 中数组长度也参与类型判断。
3. 数组遍历
普通 for 循环:
scores := [3]int{90, 85, 100}
for i := 0; i < len(scores); i++ {
fmt.Println(scores[i])
}
for range 遍历:
for index, value := range scores {
fmt.Println(index, value)
}
如果不需要下标,可以用 _ 丢弃:
for _, value := range scores {
fmt.Println(value)
}
如果在遍历中要计算平均值,建议直接使用浮点除法,避免整数除法丢失小数部分:
total := 381
count := 5
avg := float64(total) / float64(count) // 76.2
4. 切片
切片使用 []类型 表示。
numbers := []int{10, 20, 30}
注意和数组的区别:
array := [3]int{10, 20, 30}
slice := []int{10, 20, 30}
区别在于:
[3]int 是数组,长度固定为 3
[]int 是切片,长度可变
切片更接近 Java、C# 中日常使用的动态列表。
Go: []int
Java: ArrayList<Integer> / int[] 的部分使用场景
C#: List<int> / int[] 的部分使用场景
严格说,Go 的切片不是 Java/C# 的 List。切片是一个描述结构,内部指向一个底层数组。
5. len 和 cap
切片有两个重要概念:
len:当前切片长度,也就是能访问的元素数量
cap:当前切片容量,也就是从切片起点到底层数组末尾最多能容纳多少元素
示例:
numbers := []int{10, 20, 30}
fmt.Println(len(numbers)) // 3
fmt.Println(cap(numbers)) // 3
len 决定当前能访问哪些下标。
fmt.Println(numbers[0])
fmt.Println(numbers[2])
// fmt.Println(numbers[3]) // 越界
6. append
切片可以使用 append 追加元素。
numbers := []int{10, 20, 30}
numbers = append(numbers, 40)
注意:append 会返回新的切片,所以通常要接收返回值。
numbers = append(numbers, 50)
不能只写:
// append(numbers, 50) // 编译错误:返回值没有使用
append 时,如果底层数组容量够用,会直接在原底层数组上追加。如果容量不够,Go 会分配新的底层数组,并把原来的元素复制过去。
容量增长不是固定"永远翻倍"。在较小容量阶段常见翻倍增长;容量变大后,增长比例会下降并逐渐接近约 1.25x。另外,最终 cap 还会受到内存分配对齐影响,实际值可能比理论值略大。
7. make 创建切片
可以使用 make 创建切片。
numbers := make([]int, 2, 5)
含义:
创建一个 []int 切片
当前长度 len 是 2
当前容量 cap 是 5
fmt.Println(len(numbers)) // 2
fmt.Println(cap(numbers)) // 5
长度范围内的元素可以直接访问:
numbers[0] = 10
numbers[1] = 20
但是不能访问超过 len 的下标:
// numbers[2] = 30 // 编译能过,运行时报错:index out of range
如果要新增元素,使用 append:
numbers = append(numbers, 30)
8. 切片表达式
切片表达式用于从数组、切片或字符串中截取一段。
numbers := []int{10, 20, 30, 40, 50}
常见写法:
numbers[0:2] // 下标 0 到 2,不包含 2
numbers[:2] // 从开头到下标 2,不包含 2
numbers[2:] // 从下标 2 到最后
示例:
fmt.Println(numbers[0:2]) // [10 20]
fmt.Println(numbers[:2]) // [10 20]
fmt.Println(numbers[2:]) // [30 40 50]
规则:
左闭右开:包含起始下标,不包含结束下标
9. 切片共享底层数组
切片本身不是数组,它是对底层数组某一段的描述。
numbers := []int{10, 20, 30, 40}
part := numbers[1:3]
此时:
fmt.Println(part) // [20 30]
如果修改 part:
part[0] = 200
原切片 numbers 也会变化:
fmt.Println(numbers) // [10 200 30 40]
原因是 part 和 numbers 共享同一个底层数组。
这一点非常重要。切片值本身可以复制,但复制后的切片仍可能指向同一个底层数组,因此修改元素可能互相可见。
10. nil 切片和空切片
nil 切片:
var a []int
空切片:
b := []int{}
c := make([]int, 0)
区别:
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false
它们的长度都是 0:
fmt.Println(len(a)) // 0
fmt.Println(len(b)) // 0
fmt.Println(len(c)) // 0
nil 切片也可以直接 append:
var a []int
a = append(a, 10)
所以在很多场景下,nil 切片和空切片都可以正常使用。
11. 数组和切片的选择
日常开发中更常用切片。
数组适合:
- 长度固定且非常明确的场景。
- 底层实现、性能敏感代码。
- 需要把长度作为类型约束的一部分。
切片适合:
- 大多数列表数据。
- 需要追加元素。
- 需要截取一段数据。
- 函数参数中传递一组数据。
一般可以先记:
不确定用哪个时,优先用切片
12. 本节小结
数组和切片需要重点掌握:
- 数组写法是
[长度]类型,例如[3]int。 - 切片写法是
[]类型,例如[]int。 - 数组长度属于类型,
[3]int和[4]int是不同类型。 - 切片长度可变,更常用于日常开发。
len表示长度,cap表示容量。append会返回新的切片,通常要用原变量接收。make([]int, len, cap)可以创建指定长度和容量的切片。- 切片表达式是左闭右开,例如
numbers[1:3]。 - 切片可能共享同一个底层数组,修改一个切片可能影响另一个切片。
- nil 切片可以直接
append。
五、map(键值对)
map 是 Go 中用于保存键值对的数据结构,适合按 key 快速查找 value。
key -> value
1. map 的定义与初始化
map 的基本写法:
var m map[string]int
这种写法只声明了 map 类型变量,默认值是 nil,还不能直接写入键值对。
常见可用初始化方式有两种:
// 方式 1:字面量初始化
scores := map[string]int{
"Tom": 88,
"Lucy": 92,
}
// 方式 2:make 初始化
prices := make(map[string]float64)
prices["book"] = 19.9
如果需要"空 map 但可写",下面两种都可以:
m1 := map[string]int{}
m2 := make(map[string]int)
两者都不是 nil map,都可以直接写入键值对。
2. 读取 map
读取语法:
value := m[key]
如果 key 不存在,不会报错,会返回 value 类型的零值。
scores := map[string]int{"Tom": 88}
fmt.Println(scores["Tom"]) // 88
fmt.Println(scores["Alice"]) // 0
只看返回值时,无法区分"key 不存在"与"key 对应值刚好是零值"。
3. 判断 key 是否存在
推荐使用:
value, ok := m[key]
ok == true:key 存在。ok == false:key 不存在。
示例:
score, ok := scores["Tom"]
fmt.Println(score, ok) // 88 true
score2, ok2 := scores["Alice"]
fmt.Println(score2, ok2) // 0 false
4. 新增、更新、删除
map 写入语法:
m[key] = value
如果 key 原本不存在,就是新增;如果已存在,就是更新。
删除使用 delete:
delete(m, key)
即使 key 不存在,delete 也不会报错。
5. 遍历 map
使用 for range:
for key, value := range m {
fmt.Println(key, value)
}
如果只需要 key:
for key := range m {
fmt.Println(key)
}
如果只需要 value:
for _, value := range m {
fmt.Println(value)
}
说明:map 的遍历顺序是无序的,不应依赖固定输出顺序。
6. nil map 和 make
var m map[string]int
m 是 nil map,特点:
- 可以读取:
m["x"]返回零值。 - 不能写入:
m["x"] = 1会 panic。
读取 nil map 时同样可以用 value, ok := m[key],其中 ok 会是 false。
所以需要写入时,必须先初始化:
m = make(map[string]int)
m["x"] = 1
7. 本节小结
- map 用于保存键值对,适合按 key 查找 value。
- 读取不存在的 key 会返回零值,不会报错。
- 判断 key 是否存在,使用
value, ok := m[key]。 m[key] = value可用于新增或更新。delete(m, key)用于删除 key。- map 遍历顺序无序,不应依赖顺序。
- nil map 可以读不能写;写入前需要
make初始化。
六、if 条件语句
if 用于根据条件是否成立执行不同分支逻辑,是 Go 中最常用的流程控制语句之一。
1. 基本 if
基本写法:
if condition {
// 条件成立时执行
}
示例:
score := 92
if score >= 60 {
fmt.Println("及格")
}
2. if else
当条件不成立时,可以走 else 分支:
age := 16
if age >= 18 {
fmt.Println("成年")
} else {
fmt.Println("未成年")
}
3. if else if else
多分支判断使用 else if:
score := 78
if score >= 90 {
fmt.Println("A")
} else if score >= 80 {
fmt.Println("B")
} else if score >= 70 {
fmt.Println("C")
} else {
fmt.Println("D/E")
}
建议把区间从高到低写,逻辑更清晰,也能避免条件重叠带来的误判。
4. if 初始化语句
Go 支持在 if 中先执行一条初始化语句,再判断条件:
if length := len("Golang"); length > 5 {
fmt.Println(length)
}
语法结构:
if initStatement; condition {
// ...
}
这种写法常用于"只在本次判断中使用"的临时变量。
5. 条件中的逻辑运算符
常见逻辑运算符:
&&:并且,两边都为 true 才为 true。||:或者,任一边为 true 就为 true。!:取反,true 变 false,false 变 true。
示例:
age := 25
hasTicket := true
if age >= 18 && hasTicket {
fmt.Println("可以入场")
}
6. if 中变量的作用域
在 if init; condition 中声明的变量,只在这个 if/else 语句块内有效:
if n := 10; n%2 == 0 {
fmt.Println(n)
}
// fmt.Println(n) // 编译错误:undefined: n
这类临时变量不会泄漏到外层作用域,适合减少变量污染。
7. Go 的 if 语法特点
和 C、Java 等语言相比,Go 的 if 有两个常见语法差异:
- 条件外不需要也不允许写括号:
if score > 60 { ... } - 左花括号
{必须和if在同一行
错误写法示例(会报语法错误):
// if (score > 60) { } // 不推荐这种风格
// if score > 60
// { // 左花括号不能单独换行
// }
8. 本节小结
if用于条件判断,if else用于二选一分支。- 多分支使用
if else if else,建议从高到低写条件。 if init; condition可在判断前声明临时变量。- 逻辑运算符
&&、||、!可组合复杂条件。 if初始化语句里的变量只在当前语句块内有效。- Go 的
if条件不用括号,且{必须和if同行。
七、switch 分支语句
switch 适合处理"一个值对应多个分支"或"多条件分支"场景。和连续 if else if 相比,可读性通常更好。
1. 基本 switch
基本写法:
switch expression {
case value1:
// ...
case value2:
// ...
default:
// ...
}
示例:
day := 3
switch day {
case 1:
fmt.Println("Monday")
case 2:
fmt.Println("Tuesday")
case 3:
fmt.Println("Wednesday")
default:
fmt.Println("Other day")
}
2. 一个 case 匹配多个值
同一个 case 可以写多个匹配值,用逗号分隔:
score := 95
switch score / 10 {
case 10, 9:
fmt.Println("A")
case 8:
fmt.Println("B")
default:
fmt.Println("Other")
}
3. 无表达式 switch
switch 后面可以不写表达式,等价于 switch true,适合写区间判断:
temp := 28
switch {
case temp >= 35:
fmt.Println("炎热")
case temp >= 25:
fmt.Println("温暖")
default:
fmt.Println("寒冷")
}
这种写法在"按区间判断"的可读性上通常优于长链 if else if。
4. switch 初始化语句
和 if 一样,switch 也支持初始化语句:
switch n := len("Golang"); {
case n > 5:
fmt.Println("长度大于 5")
default:
fmt.Println("长度不大于 5")
}
这里 n 的作用域仅在当前 switch 语句内。
5. break 与 fallthrough
Go 的 switch 默认在每个 case 结束后自动跳出,不需要手动写 break。
如果希望"继续执行下一个 case",可以使用 fallthrough:
level := 1
switch level {
case 1:
fmt.Println("level 1")
fallthrough
case 2:
fmt.Println("level 2")
default:
fmt.Println("default")
}
说明:fallthrough 会直接进入下一个 case 的语句块,不会再次判断下一个 case 的条件。
6. switch 与 if 的选择
- 当判断"一个值对应多个离散分支"时,优先考虑
switch。 - 当条件是复杂布尔表达式且分支不多时,
if else也很合适。 - 区间判断可以用
switch { ... },可读性通常更好。
7. 典型业务写法
分数分级(按十位分段)
当分级规则是固定区间时,常见写法是先整除再 switch:
switch score / 10 {
case 10, 9:
fmt.Println("A")
case 8:
fmt.Println("B")
case 7:
fmt.Println("C")
case 6:
fmt.Println("D")
default:
fmt.Println("E")
}
这种写法分支短、边界清晰,维护成本较低。
月份天数(分组 case)
一个 case 放多个月份,可以减少重复判断:
switch month {
case 1, 3, 5, 7, 8, 10, 12:
fmt.Println("31 天")
case 4, 6, 9, 11:
fmt.Println("30 天")
case 2:
fmt.Println("28 天")
default:
fmt.Println("输入无效")
}
如果前面已经做了输入校验(例如必须是 1~12),建议在非法输入时直接 return,避免后续逻辑继续执行。
8. 本节小结
switch适合多分支判断,代码更清晰。case可以一次匹配多个值。switch { ... }适合区间条件判断。switch支持初始化语句,变量作用域局限在当前语句内。- Go 的
switch默认不会贯穿到下一个case。 - 使用
fallthrough才会继续执行下一个case语句块。
八、for 循环
Go 只有一种循环语句:for。
通过不同写法,for 可以覆盖其他语言中的 for、while、do while 的大部分场景。
1. 三段式 for
标准写法:
for init; condition; post {
// 循环体
}
示例:
sum := 0
for i := 1; i <= 5; i++ {
sum += i
}
fmt.Println(sum) // 15
说明:
init:循环开始前执行一次。condition:每轮开始前判断,true才继续。post:每轮结束后执行。
2. 把 for 当作 while
Go 没有单独的 while 关键字,直接省略三段式中的 init 和 post:
n := 1
for n < 20 {
n *= 2
}
3. 无限循环
for {
// ...
}
通常配合 break 在满足条件时退出:
for {
if done {
break
}
}
4. break 与 continue
break:立即结束当前循环。continue:跳过本轮剩余语句,直接进入下一轮。
示例(只打印奇数):
for i := 1; i <= 5; i++ {
if i%2 == 0 {
continue
}
fmt.Println(i)
}
5. 嵌套循环
循环内部还可以再写循环:
for i := 1; i <= 3; i++ {
for j := 1; j <= 2; j++ {
fmt.Printf("i=%d, j=%d\n", i, j)
}
}
常用于二维数据处理、表格输出(如九九乘法表)等场景。
6. for range
for range 常用于遍历切片、数组、字符串、map、channel。
遍历切片:
nums := []int{10, 20, 30}
for index, value := range nums {
fmt.Println(index, value)
}
遍历字符串:
text := "Go语言"
for index, ch := range text {
fmt.Println(index, ch)
}
注意:遍历字符串时,range 返回的是 rune(Unicode 码点),index 是字节下标。
7. 常见边界问题
- 循环边界写错(
<与<=)。 - 忘记更新循环变量,导致死循环。
- 在嵌套循环中
break只会退出当前内层循环。
写循环时建议先明确:
- 循环起点是什么。
- 结束条件是什么。
- 每轮如何推进到下一轮。
8. 本节小结
- Go 只提供
for,但可表达多种循环场景。 - 三段式
for适合固定次数循环。 - 条件式
for可替代while。 for {}是无限循环,常配合break。continue用于跳过当前轮次。for range是遍历容器的常用写法。
九、函数(func)
函数用于封装可复用的逻辑。
把重复代码抽成函数后,代码更清晰、可维护性更高。
1. 函数定义
Go 使用 func 定义函数:
func functionName(params) returnType {
// ...
}
无参无返回值函数示例:
func sayHello() {
fmt.Println("Hello, Golang")
}
调用方式:
sayHello()
2. 参数
函数可以接收参数:
func printUser(name string, age int) {
fmt.Printf("name=%s, age=%d\n", name, age)
}
调用时按顺序传值:
printUser("Alice", 25)
多个相邻参数类型相同,也可以简写为:
func add(a, b int) int {
return a + b
}
3. 返回值
单返回值:
func add(a int, b int) int {
return a + b
}
多返回值:
func divMod(a int, b int) (int, int) {
return a / b, a % b
}
调用处:
q, r := divMod(17, 5)
fmt.Println(q, r) // 3 2
Go 的多返回值在错误处理场景中非常常见,例如:
value, err := someFunc()
4. 可变参数
可变参数写法:
func sumAll(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
调用时可以传任意数量参数:
sumAll(1, 2, 3)
sumAll(10, 20, 30, 40)
如果已有切片,也可以展开传入:
nums := []int{1, 2, 3}
sumAll(nums...)
5. 函数是一等值
Go 中函数可以赋值给变量,也可以作为参数传递:
op := add
fmt.Println(op(3, 4)) // 7
这为后续学习匿名函数、闭包和高阶函数打下基础。
6. 函数拆分建议
写函数时建议遵循:
- 一个函数尽量只做一件事。
- 函数名体现动作和意图(如
calcTotal、printReport)。 main主要负责流程组织,不要堆太多细节。
这样在后续调试和重构时会更轻松。
7. 本节小结
- 使用
func定义函数。 - 参数类型写在参数名后面。
- 函数可以有一个或多个返回值。
...用于可变参数。- 函数可以赋值给变量并像普通值一样传递。
- 合理拆分函数有助于提升代码可读性和可维护性。
十、值传递和"引用效果"
先给结论:Go 的参数传递是值传递 。
调用函数时,传入的是实参的副本。
很多同学会觉得 Go 里也有"引用传递",通常是因为切片、map、指针等类型在函数内修改后,外部也能看到变化。
这类现象更准确的说法是:传递了"值的副本",但这个值本身可能指向同一份底层数据。
因此在 Go 语境里,更推荐使用"值传递""指针传参""共享底层数据"这些表述,而不是把它直接称为"引用传递"。
1. 基本类型:典型值传递
func changeInt(n int) {
n = 100
}
调用后外部变量不变,因为 n 是外部变量的拷贝。
2. 指针参数:通过地址间接修改
func changeIntByPointer(p *int) {
*p = 100
}
这里仍是值传递:传入的是"指针值"的副本。
但该指针副本和原指针都指向同一个地址,所以通过 *p 修改会影响外部数据。
3. 切片参数:看起来像"引用"
切片值包含指向底层数组的指针、长度、容量。函数参数接收的是这个切片头部的副本。
func changeSliceElement(nums []int) {
nums[0] = 99
}
修改元素通常会影响外部,因为内外切片头都指向同一底层数组。
但要注意 append:
func appendInside(nums []int) {
nums = append(nums, 100)
}
append 可能触发扩容并让函数内切片指向新数组。此时外部切片不一定能看到新增元素。
另外,即使没有触发扩容,函数内 append 后外部切片的 len 也不会自动变化。
如果要在外部看到新增元素,通常需要接收 append 返回的新切片。
4. map 参数:修改可见
map 值内部也包含对底层哈希结构的引用信息。
把 map 传入函数后,修改 key/value 在外部通常可见:
func changeMap(m map[string]int) {
m["Tom"] = 95
}
这不是"按引用传参",而是"值传递 + 值里包含共享底层结构"。
5. struct:值传参与指针传参的差异
值传参:
func changeUserByValue(u User) {
u.Name = "Bob"
}
只改了副本,外部结构体不变。
指针传参:
func changeUserByPointer(u *User) {
u.Name = "Bob"
}
可修改外部原对象。
6. 实战判断规则
判断"函数内修改是否影响外部"时,优先看两件事:
- 修改的是"参数副本本身",还是"参数副本指向的底层数据"。
- 该类型是否共享底层结构(如切片底层数组、map 底层哈希表、指针指向对象)。
7. 本节小结
- Go 参数传递语义是值传递。
- 基本类型值传参,函数内修改通常不影响外部。
- 指针可通过解引用修改外部对象。
- 切片/map 常出现"外部可见修改",本质是共享底层数据。
append可能导致切片重新分配,影响是否对外可见。- 即使不扩容,
append后也要在外部接收返回的新切片,才能稳定拿到新增元素。
十一、init 函数和 defer 语句
init 和 defer 都与函数执行时机相关:
init关注"程序启动时的初始化顺序"。defer关注"函数返回前要做的收尾动作"。
1. init 函数是什么
init 是 Go 的特殊函数,特点:
- 没有参数、没有返回值。
- 不能被手动调用。
- 在
main执行前自动执行。
示例:
func init() {
fmt.Println("init running")
}
2. 初始化执行顺序
在同一个 package 中,通常可按下面顺序理解:
- 包级变量初始化(包括由函数参与的初始化)。
init函数执行。main函数执行。
例如:
var appName = initAppName()
func initAppName() string {
fmt.Println("var init")
return "GoStudy"
}
func init() {
fmt.Println("init")
}
func main() {
fmt.Println("main")
}
3. 多个 init
一个文件里可以有多个 init,同一 package 下也可以分散在多个文件。
它们会在 main 前依次执行。
实践建议:即使语法允许多个 init,也不要把初始化逻辑拆得太碎,避免维护困难。
4. defer 基本语义
defer 用于把一个调用延迟到"当前函数即将返回"时执行。
func doWork() {
defer fmt.Println("cleanup")
fmt.Println("working")
}
输出顺序是先 working,函数返回前再执行 cleanup。
5. defer 的执行顺序(LIFO)
同一个函数里多个 defer,按后进先出执行:
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
最终输出顺序是:3 -> 2 -> 1。
6. defer 参数求值时机
defer 后面的函数调用,其参数会在"defer 语句出现时"就完成求值。
x := 10
defer fmt.Println(x)
x = 20
最终 defer 打印的通常是 10,不是 20。
7. 常见用途
defer 最常见用途是资源释放,确保函数中途 return 时也能收尾:
f, err := os.Open("a.txt")
if err != nil {
return
}
defer f.Close()
这样可以减少"忘记关闭资源"的风险。
实践中,常见推荐写法是:
- 资源一旦成功打开,立即写
defer关闭逻辑。 - 不要把关闭逻辑拖到函数末尾手动写。
例如:
f, err := os.Open("a.txt")
if err != nil {
return
}
defer f.Close()
// 后面继续处理文件
这样做有两个直接好处:
- 打开和关闭逻辑距离很近,可读性更好。
- 即使中间出现提前
return,关闭逻辑仍然会执行,能降低资源泄露风险。
8. 本节小结
init在main前自动执行,不能手动调用。- 初始化顺序可理解为:包级变量 ->
init->main。 defer在函数返回前执行,常用于收尾逻辑。- 多个
defer按后进先出执行。 defer参数在语句出现时求值,不是函数结束时才求值。- 资源打开成功后,通常应立即
defer对应的关闭逻辑。
十二、结构体(struct)
结构体用于把多个不同类型的字段组合成一个整体,适合表示"一个对象"的多项属性。
1. 结构体定义
Go 使用 type + struct 定义结构体:
type User struct {
Name string
Age int
City string
}
这里 User 是自定义类型,包含 3 个字段。
2. 结构体初始化
常见初始化方式一:使用字段名初始化,推荐这种写法,可读性更好。
u := User{
Name: "Alice",
Age: 20,
City: "Shanghai",
}
常见初始化方式二:先声明,再逐个赋值。
var u User
u.Name = "Bob"
u.Age = 25
u.City = "Beijing"
Go 没有 Java / C# 那种语言内建的"构造函数"语法。
如果确实需要统一初始化逻辑,Go 中常见做法是写一个普通函数,例如:
func NewUser(name string, age int, city string) User {
return User{
Name: name,
Age: age,
City: city,
}
}
这种 NewXxx 写法在 Go 里很常见,本质上只是普通函数,不是语言关键字级别的构造器。
3. 字段访问
使用点号访问结构体字段:
fmt.Println(u.Name)
fmt.Println(u.Age)
结构体字段和普通变量一样,可以读取、修改。
4. 匿名结构体
如果某个结构体只临时使用一次,可以直接写匿名结构体:
config := struct {
Host string
Port int
}{
Host: "localhost",
Port: 8080,
}
匿名结构体适合临时数据组合,不适合长期复用的领域对象。
5. Go 没有内建 getter / setter
Go 不会像某些语言那样自动生成 getName()、setName()。
如果需要封装访问逻辑,可以自己写方法:
func (u User) GetName() string {
return u.Name
}
func (u *User) SetName(name string) {
u.Name = name
}
很多场景下,如果字段本身就适合直接访问,Go 代码也常直接使用 u.Name,不强制包一层 getter/setter。
6. 方法与接收者
Go 可以把函数"挂"到某个类型上,这样的函数叫方法。
写法:
func (u User) Introduce() string {
return "Hello, I'm " + u.Name
}
其中 (u User) 这一部分就叫接收者(receiver)。
它表示:这个方法属于 User 类型。
调用时写法和访问字段类似:
u := User{Name: "Alice"}
fmt.Println(u.Introduce())
值接收者
如果接收者写成:
func (u User) Rename(name string) {
u.Name = name
}
这表示方法拿到的是结构体副本。
在方法内部修改 u.Name,通常不会影响外部原对象。
可以把它理解成:
- 方法逻辑更偏"只读"或"基于当前值做计算"。
- 即使内部改了字段,改到的也是副本。
指针接收者
如果接收者写成:
func (u *User) Rename(name string) {
u.Name = name
}
这表示方法拿到的是结构体地址。
在方法内部修改字段,通常会影响外部原对象。
可以把它理解成:
- 方法逻辑需要修改原对象状态。
- 或者结构体比较大,不想每次调用都复制整个结构体。
如何选择
现阶段可以先用一个简单规则判断:
- 只读、计算、不改外部对象:优先考虑值接收者。
- 需要修改外部对象:使用指针接收者。
从效果上看,它和前面讲过的"结构体值传参 / 结构体指针传参"本质是一回事,只不过这里换成了"挂在类型上的函数"。
7. 结构体指针
结构体也可以取地址:
u := User{Name: "Tom", Age: 18, City: "Hangzhou"}
p := &u
通过指针访问字段时,Go 允许直接写:
p.Age = 20
虽然 p 是指针,但这里不需要手动写成 (*p).Age,Go 会自动完成解引用。
8. 结构体参数传递
结构体值传参时,会复制整个结构体副本:
func changeUser(u User) {
u.Name = "Bob"
}
调用后外部原结构体通常不变。
如果希望函数内修改影响外部,可以传结构体指针:
func changeUserByPointer(u *User) {
u.Name = "Bob"
}
这也是理解结构体行为时很重要的观察点之一:
- 值传参:函数里改的是副本,外部原对象不变。
- 指针传参:函数里可以改到原对象。
9. 结构体比较
如果结构体的所有字段都支持比较,那么整个结构体也可以直接使用 ==、!= 比较:
u1 := User{Name: "Tom", Age: 18, City: "Hangzhou"}
u2 := User{Name: "Tom", Age: 18, City: "Hangzhou"}
fmt.Println(u1 == u2) // true
如果结构体中包含切片、map、函数等不可比较字段,则该结构体不能直接比较。
10. 使用场景
结构体常用于:
- 表示一个用户、一条订单、一本书等业务对象。
- 组织函数参数和返回值。
- 给后续方法、接口、JSON 编解码等内容打基础。
11. 本节小结
struct用于把多个字段组织成一个整体。- 推荐优先使用带字段名的初始化方式。
- Go 没有语言内建构造函数,常用
NewXxx普通函数封装初始化。 - 使用点号访问和修改字段。
- Go 没有强制的 getter/setter 语法,需要时可自己写方法。
- 方法通过接收者绑定到具体类型上。
- 值接收者通常操作副本,指针接收者通常可以修改原对象。
- 匿名结构体适合临时数据。
- 结构体值传参会复制副本,指针传参可修改原对象。
- 字段都可比较时,结构体也可直接比较。
十三、结构体嵌入、结构体指针与 tag
这一节有 3 个常见但容易混淆的点:
- Go 没有传统面向对象里的"类继承"。
- Go 可以通过结构体嵌入(embedding)实现字段和方法复用。
- 结构体 tag 是附加在字段上的元数据,常用于 JSON、数据库映射、校验等场景。
1. Go 没有传统继承
很多语言里会写:
Child extends Parent
Go 没有这样的继承语法。
在 Go 中,更常用结构体嵌入来复用字段和方法。
2. 结构体嵌入(embedding)
示例:
type Person struct {
Name string
Age int
}
type Employee struct {
Person
Company string
}
这里 Employee 嵌入了 Person。
这表示 Employee 内部包含一个 Person 字段,只是这个字段使用了简写形式。
初始化时仍然可以显式写出嵌入字段:
e := Employee{
Person: Person{
Name: "Alice",
Age: 28,
},
Company: "OpenAI",
}
3. 字段提升
嵌入后,可以直接访问内部结构体的字段:
fmt.Println(e.Name)
fmt.Println(e.Age)
fmt.Println(e.Company)
这里之所以可以直接写 e.Name,是因为嵌入字段会发生字段提升(promoted fields)。
本质上它仍然来自 e.Person.Name,只是 Go 允许省略中间层。
需要注意:字段提升不等于真正的继承。
Employee 并没有变成"就是一个 Person",它只是内部组合了一个 Person 字段,并把其中一部分访问路径简化了。
4. 结构体指针
结构体取地址后,可以通过指针直接访问和修改字段:
p := &Person{Name: "Tom", Age: 18}
p.Age = 19
这里 p.Age = 19 是语法糖,本质等价于:
(*p).Age = 19
5. 什么是结构体 tag
结构体字段后面可以写反引号包裹的标签:
type Product struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
这些 tag 不会直接改变 Go 语言本身的字段行为,但很多库会读取这些 tag 做额外处理。
6. tag 的常见用途
最常见的是 JSON 编解码:
type Product struct {
Name string `json:"name"`
Price float64 `json:"price"`
}
当转成 JSON 时,字段名可以变成 tag 指定的名字,而不是默认的 Go 字段名。
data, _ := json.Marshal(Product{Name: "Keyboard", Price: 199.9})
fmt.Println(string(data))
输出通常类似:
{"name":"Keyboard","price":199.9}
如果 tag 写在嵌入字段上,例如:
type Student struct {
Person `json:"person"`
School string `json:"school"`
}
那么序列化时通常会得到嵌套结构:
{"person":{"name":"李华","age":20},"school":"..."}
也就是说,这里的 json:"person" 不是把 Name、Age 直接改名,而是把整个嵌入字段作为一个名为 person 的子对象输出。
7. 读取 tag
tag 本质上是字段元数据,可以通过 reflect 读取:
t := reflect.TypeOf(Product{})
field, _ := t.FieldByName("Name")
fmt.Println(field.Tag.Get("json"))
这会输出:
name
8. 本节小结
- Go 没有传统继承,常用结构体嵌入做复用。
- 结构体嵌入后,内部字段会发生字段提升,可直接访问。
- 结构体指针可用
p.Field直接访问字段,Go 会自动解引用。 - 结构体 tag 是字段的元数据,常用于 JSON 等库。
json:"name"这类 tag 会影响序列化时的字段名。- 嵌入字段上的 tag 可能让序列化结果变成嵌套对象。
十四、自定义类型与类型别名
Go 里 type 有两种常见写法:
- 定义自定义类型
- 定义类型别名
它们长得很像,但语义完全不同。
1. 自定义类型
写法:
type MyInt int
这表示:基于 int 定义了一个新类型 MyInt。
虽然它底层还是 int,但从类型系统角度看,MyInt 和 int 已经不是同一个类型。
例如:
var a MyInt = 10
var b int = 20
// a + b // 编译错误
total := int(a) + b
这里必须显式转换。
2. 类型别名
写法:
type UserID = int
这表示:UserID 只是 int 的另一个名字,不会创建新类型。
例如:
var id UserID = 1001
var raw int = id
这里通常可以直接赋值,因为 UserID 本质上就是 int。
3. 两者的根本区别
可以用一句话记:
type T U -> 新类型
type T = U -> 旧类型的别名
自定义类型强调"类型隔离"和"业务语义"。
类型别名强调"换个名字,但还是同一个类型"。
4. 为什么要自定义类型
自定义类型常用于提升语义清晰度:
type Celsius float64
type OrderID int
type PhoneNumber string
这些类型虽然底层分别是 float64、int、string,但读代码时能更清楚地表达业务含义。
除了语义更清晰,自定义类型还有一个很实际的价值:
它能在编译期减少"本来都是 int / string,结果被随手混用"的问题。
例如 OrderID 和 UserID 底层都可能是 int,但如果它们是两个不同的自定义类型,编译器就不会允许随意直接混用。
5. 为什么要类型别名
类型别名常见用途:
- 给已有类型换一个更贴近业务的名字。
- 兼容旧代码或重构过程中平滑迁移类型名。
例如:
type Username = string
它不会引入新的类型转换成本,但能提升命名可读性。
6. 使用时的判断思路
如果想要:
- 一个"新的独立类型",用来自定义语义、限制混用:用自定义类型。
- 只是"换个名字",不想改变原类型行为:用类型别名。
可以再记一条更实战的经验:
- 面向业务约束时,优先考虑自定义类型。
- 面向重命名和兼容时,优先考虑类型别名。
7. 本节小结
type T U会定义一个新类型。type T = U只是定义别名,不会创建新类型。- 自定义类型和原始类型通常不能直接混用,需要显式转换。
- 类型别名和原始类型通常可以直接配合使用。
- 自定义类型常用于表达更清晰的业务语义。
- 自定义类型还可以在编译期减少业务上不该混用的类型误用。
十五、接口(interface)
接口用于定义一组行为规范。
谁实现了这些方法,谁就可以被当作这个接口类型来使用。
1. 接口定义
Go 使用 interface 定义接口:
type Speaker interface {
Speak() string
}
这个接口要求:实现者必须提供一个 Speak() string 方法。
2. 隐式实现
Go 的接口实现是隐式的。
不需要写 implements 之类的关键字,只要某个类型实现了接口要求的方法,它就自动实现了这个接口。
例如:
type Dog struct {
Name string
}
func (d Dog) Speak() string {
return d.Name + " says: wang"
}
因为 Dog 有 Speak() string 方法,所以它实现了 Speaker 接口。
如果一个接口里定义了多个方法,那么某个类型必须把这些方法全部实现,才算实现这个接口。
例如:
type Runner interface {
Run() string
Introduce() string
}
此时如果某个类型只实现了 Run(),没有实现 Introduce(),它就不能赋值给 Runner 类型变量。
3. 接口变量
接口也可以作为变量类型使用:
var s Speaker
s = Dog{Name: "Buddy"}
fmt.Println(s.Speak())
此时 s 的静态类型是 Speaker,但它内部保存的是具体值 Dog。
4. 接口作为参数
接口常用于函数参数,表示"只关心行为,不关心具体类型":
func printSpeak(s Speaker) {
fmt.Println(s.Speak())
}
这样只要实现了 Speaker 的类型,都可以传入这个函数。
5. any 与空接口
Go 里的 any 是 interface{} 的别名,表示"可以接收任意类型的值"。
var v any
v = 123
v = "hello"
v = Dog{Name: "Tom"}
因为所有类型都实现了空接口,所以 any 可以装任意值。
6. 类型断言
当一个接口变量内部装的是具体类型值时,可以通过类型断言取回具体类型:
var v any = "golang"
text, ok := v.(string)
fmt.Println(text, ok) // golang true
如果断言失败:
number, ok := v.(int)
fmt.Println(number, ok) // 0 false
更稳妥的写法通常是 value, ok := v.(T),避免断言失败直接 panic。
7. 接口的价值
接口最大的价值在于解耦:
- 调用方只依赖行为,不依赖具体实现。
- 不同类型只要满足同一接口,就可以被统一处理。
- 后续更容易扩展和替换实现。
实践中,Go 通常更鼓励"小接口":
- 接口方法越少,实现成本越低。
- 越容易让不同类型复用同一接口。
- 越能减少"为了实现接口而被迫补很多无关方法"的问题。
这也是为什么很多 Go 代码里的接口都很小,甚至只有 1 个方法。
8. 本节小结
- 接口定义的是行为,不是数据。
- Go 接口实现是隐式的,不需要显式声明。
- 接口变量可以保存实现该接口的具体值。
any可以接收任意类型。- 类型断言用于从接口值中取回具体类型。
- 接口中的方法通常需要全部实现,类型才能被当成该接口使用。
- Go 更鼓励小接口,而不是方法很多的大接口。
十六、协程(goroutine)
goroutine 是 Go 用来执行并发任务的轻量级执行单元。
可以把它先理解为:一段可以和当前代码"并发执行"的函数调用。
1. 启动 goroutine
使用 go 关键字启动一个 goroutine:
go someFunc()
示例:
go func() {
fmt.Println("goroutine: hello")
}()
这表示把这个函数放到新的 goroutine 中执行,主 goroutine 不会等待它自动结束。
2. main 结束会带走其他 goroutine
Go 程序入口是 main 函数。
当 main 结束时,整个进程就会退出,即使其他 goroutine 还没执行完。
这也是为什么刚开始写 goroutine 时,经常会看到"明明开了 goroutine,但没有输出"的现象。
3. 用 Sleep 暂时等待
入门阶段,最简单的等待方式是:
time.Sleep(100 * time.Millisecond)
这样主 goroutine 会暂停一小段时间,给其他 goroutine 执行机会。
但 Sleep 只是"猜一个等待时间",并不精确,也不可靠。
4. goroutine 传参
goroutine 启动时可以直接传参数:
go printTask("task A")
go printTask("task B")
这和普通函数调用参数传递方式一致,只是执行位置变成了新的 goroutine。
5. 使用 WaitGroup
更常见、也更可靠的等待方式是 sync.WaitGroup。
典型用法:
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
fmt.Println("worker 1")
}()
go func() {
defer wg.Done()
fmt.Println("worker 2")
}()
wg.Wait()
可以这样理解:
Add(n):告诉 WaitGroup 还要等几个任务。Done():某个任务完成,数量减 1。Wait():阻塞等待,直到数量减到 0。
6. 输出顺序不一定固定
多个 goroutine 并发执行时,输出顺序通常不稳定:
go printTask("task A")
go printTask("task B")
不能假设一定先看到 task A 再看到 task B。
调度顺序由运行时决定。
即使代码顺序固定,实际输出顺序也可能每次运行都不同。
这不是程序写错了,而是并发调度本来就不保证固定先后。
7. Sleep 和 WaitGroup 的区别
time.Sleep:按时间猜测等待多久,简单但不可靠。WaitGroup:按任务完成状态等待,更适合正式并发代码。
如果任务执行时间变长,Sleep 可能等得不够;如果任务很快结束,Sleep 又可能白白多等。
这也是为什么在正式代码里,WaitGroup 通常比 Sleep 更值得优先使用:
Sleep等的是时间,不是任务状态。WaitGroup等的是"任务真的完成了没有"。- 任务执行时间不稳定时,
WaitGroup更稳妥。
8. 本节小结
go关键字用于启动 goroutine。- goroutine 会和当前代码并发执行。
main结束时,未完成的 goroutine 也会一起结束。time.Sleep适合临时演示,不适合作为正式等待方案。sync.WaitGroup是等待多个 goroutine 完成的常用工具。- 并发执行时,输出顺序通常不固定,不应依赖打印顺序推断严格时序。
十七、频道(channel)
channel 是 Go 里专门用于 goroutine 之间通信的机制。
如果 goroutine 是"并发执行的任务",那么 channel 可以理解成"任务之间传数据的通道"。
它最常见的用途有两个:
- 在不同 goroutine 之间传递数据。
- 在收发过程中顺便完成同步。
1. 创建 channel
channel 使用 make 创建:
ch := make(chan int)
这表示创建了一个可以传 int 的 channel。
2. 发送和接收
channel 的发送和接收都使用 <-:
ch <- 10 // 发送
value := <-ch // 接收
可以先把它简单记成:
ch <- 数据:往 channel 里放数据。<-ch:从 channel 里取数据。
这里的 <- 不是指针,也不是取地址,而是 channel 的专用收发符号。
3. 为什么 channel 常和 goroutine 一起出现
单看语法,channel 不一定非要和 goroutine 搭配。
但在实际使用里,它最常见的场景就是"一个 goroutine 发,另一个 goroutine 收"。
例如:
ch := make(chan string)
go func() {
ch <- "hello"
}()
msg := <-ch
fmt.Println(msg)
这里就形成了一次很典型的并发通信:
- 子 goroutine 负责发送。
- 主 goroutine 负责接收。
4. 无缓冲 channel 的阻塞特性
默认创建出来的 channel 是无缓冲 channel:
ch := make(chan int)
无缓冲 channel 有一个非常重要的特点:收发双方要"对上"。
可以先这样理解:
- 发送时,如果暂时没人接收,发送方会阻塞。
- 接收时,如果暂时没人发送,接收方会阻塞。
这也是为什么很多初学者第一次写 channel 时,会遇到"程序卡住"或者死锁报错。
本质上不是语法错,而是收发两边没有配对成功。
5. 有缓冲 channel
有缓冲 channel 可以在创建时指定容量:
ch := make(chan int, 3)
这表示这个 channel 最多可以先缓存 3 个值。
例如:
ch := make(chan string, 2)
ch <- "A"
ch <- "B"
在缓冲区没满之前,发送方可以先把值放进去,不必立刻等接收方来取。
但要注意:
- 缓冲区满了,再发送仍然会阻塞。
- 缓冲区空了,再接收仍然会阻塞。
所以有缓冲并不代表"永不阻塞",只是多了一段可缓存空间。
6. close 和 range
如果已经确定后面不会再往某个 channel 发送数据,可以关闭它:
close(ch)
关闭后,常见写法是配合 for range 读取剩余数据:
for value := range ch {
fmt.Println(value)
}
这种写法很适合"发送多个值,接收方逐个处理"的场景。
需要注意两点:
close表示"不再发送新数据",不是"立刻清空已有数据"。- 已经放进 channel 的数据,接收方仍然可以继续取出来。
for range 会持续接收,直到满足两个条件才结束:
- 这个 channel 已经被关闭。
- channel 里原本剩余的数据也已经全部取完。
也就是说,close(ch) 之后并不是立刻结束遍历,而是会先把已经发送进去的值继续读完。
如果 channel 一直不关闭,而接收方又使用 for range 等待后续数据,那么循环就可能一直阻塞下去。
7. 单向 channel
有时为了让函数职责更明确,可以限制 channel 的使用方向。
例如:
func send(ch chan<- string) {
ch <- "hello"
}
func receive(ch <-chan string) {
fmt.Println(<-ch)
}
这里:
chan<- string表示只发送。<-chan string表示只接收。
这样做的好处是函数边界更清晰,也更不容易误用。
需要特别注意一点:变量本身如果是普通的双向 channel,例如:
ch := make(chan string)
那么它在传参时,可以自动匹配成更窄的单向 channel:
send(ch)
receive(ch)
也就是说:
- 双向 channel 可以传给
chan<- T参数。 - 双向 channel 也可以传给
<-chan T参数。
可以把它理解成"传参时自动收窄为单向能力"。
但这种收窄只发生在使用位置上,ch 这个变量本身依然还是双向 channel。
反过来则不行:
- 只发送 channel 不能当成可接收 channel 使用。
- 只接收 channel 也不能当成可发送 channel 使用。
这也是为什么很多示例代码里会先定义普通 channel,再在函数参数处限制方向。
这样既保留了主流程里的灵活性,也能让具体函数职责更明确。
8. close 的边界规则
close 虽然语法很简单,但它有几个很重要的边界规则:
- 关闭的是 channel 本身,不是某个具体值。
- 关闭后不能再向这个 channel 发送数据。
- 对已经关闭的 channel 再发送数据,会直接 panic。
所以在实际代码里,close 一般应该由发送方负责,或者由"最后一个发送者"负责。
接收方通常只负责读取,不负责随意关闭 channel。
这背后的思路很直接:
谁最清楚"后面不会再发数据了",谁就更适合执行 close。
9. channel 和 WaitGroup 的分工不同
学习到这里,容易把 channel 和 WaitGroup 混在一起。
它们都和并发有关,但职责并不一样。
WaitGroup更偏向"等任务结束"。channel更偏向"传数据 / 做同步协作"。
可以粗略理解成:
- 只想等几个 goroutine 全部跑完,用
WaitGroup更直接。 - 想让 goroutine 之间交换数据、通知结果,用
channel更合适。
实际代码里,两者也经常一起出现,并不是二选一关系。
10. 常见风险:死锁
如果 channel 的发送和接收关系没配好,就可能出现死锁。
例如下面这种情况:
ch := make(chan int)
ch <- 1
如果这时没有其他 goroutine 正在接收,这里就会阻塞住。
同理:
ch := make(chan int)
value := <-ch
fmt.Println(value)
如果没有发送方,这里也会一直等。
所以学习 channel 时,脑子里最好始终问一句:
"这个值是谁发的?谁来收?两边什么时候对上?"
11. 本节小结
channel是 Go 中用于 goroutine 之间通信的重要机制。- 使用
make(chan T)创建无缓冲 channel。 - 使用
make(chan T, n)创建有缓冲 channel。 ch <- value表示发送,<-ch表示接收。- 无缓冲 channel 的发送和接收通常需要同步配对。
close常和for range一起使用,用来处理一组发送完成的数据。for range会在 channel 关闭且剩余数据读完后结束。- 单向 channel 可以限制函数只发送或只接收。
- 双向 channel 在传参时可以自动匹配成单向 channel,但反过来不行。
close后不能继续发送数据,通常应由发送方负责关闭。WaitGroup更偏向等待任务结束,channel更偏向传数据和协作同步。- 如果收发关系没配好,程序可能出现阻塞甚至死锁。
十八、select 与协程超时处理
学完 channel 之后,下一步通常就是 select。
因为一旦程序里不只一个 channel,或者想给等待过程加上超时控制,select 就会变得很常用。
可以先把它理解成:
select 是"专门给 channel 用的分支选择语句"。
1. select 是干什么的
select 用来同时等待多个 channel 操作。
哪个 channel 的操作先准备好,就执行对应的 case。
基本形式:
select {
case v := <-ch1:
fmt.Println(v)
case ch2 <- 10:
fmt.Println("sent")
}
这里要注意,select 里的 case 不是普通布尔条件,
而是 channel 的发送或接收操作。
2. select 和 switch 的区别
虽然 select 和 switch 看起来有点像,但它们判断的东西完全不同:
switch判断的是值或条件。select判断的是哪个 channel 操作先就绪。
所以 select 不是通用分支语句,而是并发通信场景下的专用工具。
3. 最基础的 select 接收
例如:
select {
case msg := <-ch:
fmt.Println(msg)
}
如果这个 ch 还没有值可读,那么这段代码会阻塞等待。
所以只写一个 case 的 select,在效果上很像直接写:
msg := <-ch
fmt.Println(msg)
区别在于:select 的结构更容易扩展到多个 channel 或超时处理场景。
4. 同时监听多个 channel
select 最典型的价值,就是同时监听多个 channel:
select {
case msg1 := <-ch1:
fmt.Println("from ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("from ch2:", msg2)
}
谁先准备好,就先进入谁的分支。
可以先这样理解:
- 不是按代码写的先后顺序选。
- 也不是固定优先选第一个
case。 - 而是谁先就绪,就执行谁。
如果多个 case 在同一时刻都已经可以执行,运行时会从中选一个执行。
因此实际结果不应依赖某个固定分支顺序。
还要补一个很关键的执行特点:
一次 select 执行下来,通常只会进入一个分支,不会把所有 ready 的 case 全部执行一遍。
也就是说:
- 单次
select= 从当前可执行的分支里选一个执行。 - 如果后面还想继续处理更多消息,通常需要下一轮新的
select。
这也是为什么在"要连续接收多个结果"的场景里,select 经常会和 for 搭配使用。
例如:
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
这里不是说一次 select 会把两个 channel 都处理完,
而是"循环两次,每次执行一个分支",这样才能把两个消息都收完。
5. default 分支
select 还可以写 default:
select {
case msg := <-ch:
fmt.Println(msg)
default:
fmt.Println("no value ready")
}
它的含义是:
- 如果有可执行的
case,就执行对应分支。 - 如果当前没有任何
case能立刻执行,就直接走default。
这意味着:
加了 default 之后,这个 select 通常就不会阻塞等待,而是立刻给出一个结果。
所以 default 经常被用来做"非阻塞检查"。
6. 为什么超时处理常和 select 一起用
并发代码里一个很常见的问题是:
"我在等某个 goroutine 的结果,但它迟迟不返回怎么办?"
这时候如果只写:
result := <-resultCh
那么只要结果一直不来,这里就会一直等下去。
而 select 可以让"任务结果"和"超时信号"同时参与竞争。
谁先到,就处理谁。
7. 使用 time.After 做超时控制
Go 里最常见的超时写法之一是:
select {
case result := <-resultCh:
fmt.Println("result:", result)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
可以把 time.After(d) 先简单理解成:
- 过了指定时间后,会有一个"时间到了"的信号可接收。
因此这个 select 的意思就是:
- 如果结果先回来,就处理结果。
- 如果 1 秒先到了,就走超时分支。
这也是为什么很多 Go 并发代码里,超时处理看起来都是 select + time.After 的组合。
8. 超时控制的是等待方,不一定是任务本身
这是一个非常关键、也非常容易误解的点。
当超时分支先执行时,通常只表示:
"当前这段等待逻辑不再继续等了"。
它不等于:
- 原来的 goroutine 自动被杀掉了。
- 原来的任务自动停止执行了。
也就是说,很多情况下超时控制的是"等待方",不是"任务本身"。
例如:
select {
case result := <-resultCh:
fmt.Println(result)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
}
如果此时慢任务 goroutine 还在后台执行,它很可能仍然会继续跑完。
只是当前这段代码已经不想继续等它了。
如果这个慢任务后面还要往无缓冲 channel 发送结果,而此时已经没有接收方了,
那么它甚至可能继续阻塞在发送语句上。
例如:
select {
case msg := <-ch:
fmt.Println(msg)
case <-time.After(2 * time.Second):
fmt.Println("timeout")
}
如果超时先发生,接收方结束;
而发送方过一会儿才执行:
ch <- "slow result"
这时因为已经没人再接收,它就可能卡在这里。
这点很重要,因为它直接关系到后面更深入的并发控制问题:
超时了以后,任务要不要取消?怎么通知它停止?资源怎么回收?
9. select 只是选择"当前可执行"的分支
select 的核心不是"预测未来",而是看"现在谁能执行"。
所以可以这样记:
- 有 ready 的 channel 操作,就选其中一个执行。
- 没有 ready 的操作,又没有
default,就阻塞等待。 - 没有 ready 的操作,但有
default,就立即执行default。
这个理解方式比死记语法更重要。
10. select 和 channel 的关系
可以把它们的关系看成这样:
channel负责通信。select负责在多个通信机会里做选择。
如果只有一个 channel,很多时候直接收发就够了。
如果有多个 channel,或者还想监听超时、退出信号,那么 select 就会很自然地出现。
11. 超时现象里为什么有时会"刚好收到结果"
如果 select 被放进循环里,而且每一轮都重新写了 time.After(...),
那么要特别注意:超时时间是"从这一轮 select 开始时重新计算"的。
例如第一轮先等到了一个结果,第二轮才开始,那么第二轮里的 time.After(3 * time.Second)
并不是从整个函数一开始就算 3 秒,而是从第二轮开始再重新算 3 秒。
这就可能出现一种现象:
- 慢任务结果到达的时间点
- 当前这一轮超时到达的时间点
两者非常接近,甚至几乎同时 ready。
一旦发生这种情况,select 选中哪个分支就不应该被当成固定结果。
所以看到"有时超时,有时刚好拿到结果",很多时候不是程序随机坏了,
而是时间点刚好撞在一起了。
12. 本节小结
select是 Go 中专门配合 channel 使用的分支语句。select的case写的是 channel 的发送或接收操作,不是普通条件判断。- 同时监听多个 channel 时,哪个先就绪,就先执行哪个分支。
- 如果多个
case同时就绪,执行哪个分支通常不应被写死依赖。 - 单次
select通常只执行一个分支;要连续处理多个结果,常常需要配合for。 default可以让select变成一次非阻塞检查。time.After常和select一起使用,用来实现超时等待。- 超时分支先执行,通常只表示"不再继续等结果",不代表原 goroutine 一定自动停止。
- 如果超时后已经没有接收方,慢发送方后续还可能阻塞在发送语句上。
channel负责通信,select负责在多个通信机会之间做选择。
十九、线程安全与 sync.Map
学到 goroutine、channel、select 之后,很自然就会碰到一个问题:
多个 goroutine 同时操作同一份数据时,这段代码到底安不安全?
很多时候大家会说"线程安全"。
放到 Go 这里,更贴切的说法通常是"并发安全":
也就是多个 goroutine 同时访问共享数据时,结果是否可靠、程序是否稳定。
1. 为什么会有线程安全问题
只要一份数据会被多个 goroutine 共同访问,就可能出现并发安全问题。
例如:
- 两个 goroutine 同时写同一个 map。
- 一个 goroutine 在写,另一个 goroutine 在读。
- 多个 goroutine 同时修改同一个计数器。
如果没有额外保护,就可能出现:
- 数据结果不正确。
- 数据状态互相覆盖。
- 程序直接报错甚至 panic。
所以这里的重点不是"map 这种类型奇怪",
而是"共享数据在并发访问时需要保护"。
2. 普通 map 默认不是并发安全的
Go 里的普通 map 在默认情况下,不应该被多个 goroutine 直接同时读写。
例如下面这种思路就是危险的:
m := make(map[string]int)
go func() {
m["go"]++
}()
go func() {
m["go"]++
}()
这类写法不是"有点风险",而是从设计上就不应该依赖它。
尤其是并发写 map,实际运行时很可能直接报错。
所以看到"多个 goroutine 共用同一个 map"时,第一反应应该是:
"这里有没有加保护?"
3. 用 sync.Mutex 保护共享数据
最基础、也最常见的保护方式,就是互斥锁 sync.Mutex。
可以先把它理解成:
同一时刻只允许一个 goroutine 进入这段临界区代码。
典型写法:
type SafeCounter struct {
mu sync.Mutex
data map[string]int
}
func (c *SafeCounter) Add(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key]++
}
这里的意思就是:
- 进入修改逻辑前先上锁。
- 修改结束后解锁。
- 其他 goroutine 如果也想同时进来,就得先等。
这是一种非常基础但非常实用的并发保护方式。
4. Mutex 保护的不是"变量名",而是那段共享访问过程
初学时容易把锁想成"给某个变量贴标签"。
更准确一点的理解是:
锁保护的是"访问共享数据的这段过程"。
也就是说,真正重要的不是"我定义了一个锁",
而是"所有读写共享数据的路径,是否都走了同一把锁"。
如果只有一部分代码加了锁,另一部分代码还在绕过锁直接操作,
那这份数据依然不算安全。
还要补一个很关键的点:
如果业务逻辑是"先读,再判断,再修改",那么锁不能只分别包住"读方法"和"写方法",
而要看这整个过程是不是一个原子整体。
例如下面这种思路:
if stock > 0 {
stock--
}
如果"读取库存"和"扣减库存"分散在两个独立加锁的方法里,
那多个 goroutine 仍然可能同时读到同一个旧值,然后都通过判断。
这类问题和 map 会不会崩溃不是一回事,它属于更上层的业务竞态问题。
所以锁不仅要"有",还要看它包住的范围够不够大。
5. sync.RWMutex 是什么
如果某份共享数据"读很多,写较少",Go 还提供了 sync.RWMutex。
它可以简单理解成两种锁:
- 读锁:
RLock()/RUnlock() - 写锁:
Lock()/Unlock()
常见思路是:
- 读操作走读锁。
- 写操作走写锁。
示例:
func (c *SafeConfig) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
func (c *SafeConfig) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
可以先这样理解它和 Mutex 的区别:
Mutex不区分读和写,进来就互斥。RWMutex把"读"和"写"区分开了。
入门阶段只要先记住这个原则就够了:
只读用 RLock,修改用 Lock。
但这里也别把它想成"写锁永远绝对优先"。
更稳妥的理解是:
- 读锁和读锁可以并发共存。
- 写锁和任何锁都不能共存。
- 如果已经有写者在等待,后续新的读者通常不会再一直插队。
所以当"读和写撞在一起时,往往先完成写再继续读"这种现象出现时,
这是可以观察到的现象;但它更准确反映的是读写互斥和调度时机,
而不是一句简单的"写锁绝对优先"。
6. 为什么带锁结构体的方法通常用指针接收者
只要结构体里带了 sync.Mutex 或 sync.RWMutex,方法通常都应该优先使用指针接收者。
例如:
func (c *SafeConfig) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
原因不只是"方法里可能要改数据",
更重要的是:不能随便把锁复制一份。
如果把这类方法写成值接收者:
func (c SafeConfig) Get(key string) string
那么调用时就可能复制整个结构体。
这意味着:
- 锁也被复制了一份。
- map 头部也被复制了一份。
而 map 底层数据通常还是共享的,
结果就可能变成"多个副本锁着不同的锁,却访问同一份底层数据"。
所以对于带锁结构体,可以先记一条很实用的经验:
- 结构体里有
Mutex/RWMutex,方法基本都用指针接收者。
7. 为什么示例里经常把 map 和锁封装进结构体
会发现很多 Go 代码不会把 map 和锁散着放,
而是经常写成:
type SafeCounter struct {
mu sync.Mutex
data map[string]int
}
这样做有两个好处:
- 锁和它要保护的数据放在一起,关系更清楚。
- 外部更容易只通过方法访问,减少绕过锁直接操作的机会。
这也是为什么并发安全的数据结构,常常会通过结构体 + 方法的方式来组织。
但这里也要注意 Go 的一个特点:
可见性边界是"包",不是"对象本身"。
所以即使字段是小写,只要还在同一个包里,理论上还是能直接访问。
也就是说,并发安全并不是靠"小写字段名自动保护"出来的,
而是靠大家统一走加锁方法、不要绕过封装。
8. sync.Map 的基本定位
除了"普通 map + 锁"之外,Go 标准库还提供了 sync.Map。
sync.Map 是一个已经内置并发安全能力的 map 结构。
它的目的不是替代所有普通 map,而是为特定并发场景提供更方便的写法。
最常见的基本操作有:
Store(key, value):存值Load(key):取值Delete(key):删值Range(func(key, value any) bool):遍历
例如:
var m sync.Map
m.Store("name", "gopher")
value, ok := m.Load("name")
fmt.Println(value, ok)
这里的 ok 用法和普通 map 取值时的"是否存在"思路比较接近。
9. LoadOrStore 是什么意思
sync.Map 里一个很常见的方法是 LoadOrStore:
actual, loaded := m.LoadOrStore("topic", "sync.Map")
它的意思可以理解成:
- 如果 key 已经存在,就直接把已有值取出来。
- 如果 key 不存在,就把新值存进去。
返回值里的 loaded 很关键:
loaded == true:说明原来就有值,这次没有新建。loaded == false:说明原来没有值,这次是新存进去的。
这个方法很适合"如果没有就初始化,有的话就复用"的场景。
10. sync.Map 的 Range 和普通 map 的 for range 不一样
普通 map 的遍历通常是:
for k, v := range m {
fmt.Println(k, v)
}
而 sync.Map 不能直接这样写,它要用回调形式的 Range:
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true
})
这里传进去的是一个匿名函数,同时它也作为回调函数被 Range 调用。
可以这样理解:
- 匿名函数,强调的是"它没有函数名"。
- 回调函数,强调的是"它作为参数传进去,后面由
Range在内部遍历时反过来调用"。
也就是说,sync.Map 不是要求手写循环体,
而是它内部负责遍历,只需要提供"每遍历到一项时要做什么"。
这里返回的 bool 可以控制是否继续遍历:
- 返回
true:继续遍历 - 返回
false:停止遍历
这也是为什么 sync.Map 的使用手感和普通 map 不完全一样。
11. 为什么会看到 concurrent map writes
当多个 goroutine 同时直接去写普通 map 时,
Go 运行时很可能直接报:
fatal error: concurrent map writes
这说明已经正确触发了"普通 map 不能直接并发写"这个问题。
它验证到的是 map 本身的并发不安全。
而如果原本还想进一步观察"先判断再修改导致多个 goroutine 同时越界"这类现象,
那么要知道:很多时候程序会先被 map 自身的并发写错误打断,
还来不及稳定展示更上层的业务竞态。
所以这两类问题最好分开理解:
concurrent map writes:map 本身并发访问就已经不安全。- 判断后再修改被多个 goroutine 同时穿透:属于业务逻辑层面的竞态。
12. 是不是以后都该直接用 sync.Map
不是。
这是一个很容易走偏的点:
sync.Map 是并发安全的,不代表它就应该替代所有 map。
更稳妥的理解是:
- 如果只是普通业务数据结构,很多时候"普通 map + 锁"更直接、更清晰。
- 如果确实是并发访问场景,而且
sync.Map的接口形式刚好合适,它会更方便。
所以重点不应该变成"以后只用哪一种 map",
而应该先问:
"这份数据有没有并发访问?访问模式是什么?哪种写法更清晰、更合适?"
13. 如何判断自己是否遇到了并发安全问题
当代码同时满足下面两个条件时,就应该立刻提高警惕:
- 有多个 goroutine 同时运行
- 它们访问的是同一份可变数据
这时脑子里最好立刻补一句:
- 这份数据有没有锁保护?
- 是否所有访问路径都统一走了这层保护?
- 如果是 map,这里能不能直接并发访问?
这种思考习惯比死记某一个 API 更重要。
14. 本节小结
- 线程安全放到 Go 并发学习里,通常更准确地说是"并发安全"。
- 只要多个 goroutine 同时访问共享数据,就需要考虑保护问题。
- 普通 map 默认不是并发安全的,不能随便并发读写。
sync.Mutex可以用来保护共享数据的访问过程。- 锁不仅要"有",还要看是否把"读 / 判断 / 修改"这整个关键过程包成了原子操作。
sync.RWMutex区分读锁和写锁,适合读写分离的场景理解。- 带锁结构体的方法通常应该用指针接收者,避免复制锁。
- 锁最好和被保护的数据放在同一个结构体里统一管理。
sync.Map自带并发安全能力,提供Store、Load、Delete、Range等方法。sync.Map.Range传入的是匿名函数,也是在遍历过程中被调用的回调函数。LoadOrStore适合"存在就复用,不存在就初始化"的场景。fatal error: concurrent map writes说明普通 map 的并发写已经被错误触发出来了。sync.Map不是所有 map 的默认替代品,很多场景下普通 map + 锁仍然更合适。
二十、错误处理与 panic/recover
很多语言会把"异常处理"当成一整套主流流程。
但在 Go 里,更常见、更核心的日常错误处理方式其实是:返回 error 值。
也就是说,Go 处理错误时更强调:
- 函数把错误显式返回出来
- 调用方显式检查并决定怎么处理
因此学习这个知识点时,最好先把概念分开:
error:日常错误处理的主流方式panic/recover:更偏异常式中断和兜底恢复
1. Go 里的 error 是什么
error 是 Go 标准库里约定好的一个接口类型。
只要某个值实现了 Error() string 方法,它就可以当成错误使用。
最常见的函数写法是:
func divide(a, b int) (int, error)
这表示函数除了正常结果之外,还可能返回一个错误值。
2. 为什么 Go 常写 result, err
Go 里一个非常常见的写法是:
result, err := divide(10, 2)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(result)
可以先把它理解成一个固定套路:
- 先调用函数
- 拿到结果和错误
- 先检查
err - 没错再继续处理正常结果
这也是为什么在 Go 代码里会频繁看到 if err != nil。
3. nil 表示没有错误
在 Go 里,错误值通常遵循一个非常重要的约定:
err == nil:说明没有错误err != nil:说明出现了错误
例如:
if err != nil {
return err
}
这不是语法硬性规定,而是 Go 里非常核心的错误处理习惯。
4. 使用 errors.New 创建简单错误
如果只是想返回一个简单的错误信息,最常见的写法之一就是:
return errors.New("divisor cannot be 0")
这种方式适合:
- 错误原因比较直接
- 只需要一段固定错误消息
例如除数不能为 0、参数为空、状态不合法等场景。
还可以进一步把它理解成:
errors.New("xxx") 更像是在创建一个"有固定语义的错误值"。
如果只是直接把它打印出来,那么最终看到的通常也就是这段错误文字本身。
所以它非常适合拿来定义一些固定、可复用的基础错误。
例如:
var ErrUserNotFound = errors.New("user not found")
var ErrStockNotEnough = errors.New("stock not enough")
这种错误在 Go 里常被称为"哨兵错误"。
它们的价值不只是能打印信息,更重要的是后面可以被稳定识别和判断。
5. 使用 fmt.Errorf 补充上下文
很多时候,仅仅返回一句固定错误信息还不够。
调用方往往还想知道:到底是哪个步骤、哪个参数、哪个场景出了问题。
这时候常见写法是:
return fmt.Errorf("query user failed: userID=%d", userID)
它的价值在于:
可以把更多上下文信息拼进错误消息里,方便排查问题。
所以可以先这样区分:
errors.New:更适合定义一个固定错误。fmt.Errorf:更适合在返回时补充当前场景的信息。
6. 错误包装和 %w
Go 里还经常会遇到"下层已经有一个错误,上层想补充上下文后继续往外返回"的场景。
例如:
return fmt.Errorf("query user failed: %w", ErrInvalidUserID)
这里的 %w 表示:
在保留原始错误的同时,再包上一层新的错误说明。
可以把它理解成:
- 原始错误负责表达"底层到底错了什么"
- 包装后的错误负责表达"这个错误是在什么场景里发生的"
这比单纯拼字符串更有价值,因为后面还可以继续判断底层错误类型。
这也是为什么"只是拼一个错误字符串"和"真正包装一个底层错误"要分开理解:
fmt.Errorf("xxx: %v", err):只是把错误内容拼进字符串里。fmt.Errorf("xxx: %w", err):不仅补充了上下文,还保留了底层错误身份。
7. errors.Is 的作用
当错误被包装过之后,直接拿字符串比较通常不是好办法。
Go 更推荐通过 errors.Is 判断某个错误链里是否包含目标错误。
例如:
errors.Is(err, ErrInvalidUserID)
如果返回 true,说明这个错误虽然可能已经被包了一层甚至多层,
但底层本质上还是 ErrInvalidUserID。
所以可以先这样记:
fmt.Errorf(... %w ...):包装错误errors.Is(...):判断底层是不是某个目标错误
8. 自定义业务错误常见怎么做
当代码开始进入更真实的业务场景时,错误往往不只是"打印一句话"这么简单。
这时候常见思路通常有三种:
- 固定业务错误:直接定义哨兵错误
- 需要补充上下文:在外层用
fmt.Errorf包装 - 需要携带更多业务字段:自定义错误类型
例如固定业务错误:
var ErrUserNotFound = errors.New("user not found")
再例如补充上下文:
return fmt.Errorf("create order failed: %w", ErrStockNotEnough)
如果还想携带错误码、订单号之类的额外信息,
就更适合定义自己的错误类型,而不是只靠一段字符串。
所以可以先把业务错误设计理解成三层:
- 错误身份
- 错误上下文
- 错误附加字段
并不是所有情况都只靠 errors.New 或一层 fmt.Errorf 就能表达完整。
9. panic 是什么
和 error 不同,panic 不是"正常业务错误返回值",
而是一种更激烈的中断方式。
例如:
panic("something went wrong")
一旦发生 panic,当前函数会立刻停止继续往后正常执行,
然后开始沿调用栈向外层展开。
如果没有被恢复,程序最终会直接崩掉。
10. recover 是怎么用的
recover 的作用是:在 panic 已经发生时,尝试把这次崩溃拦下来。
但它不是随便写在哪都行。
最常见、也最重要的使用方式是放在 defer 里:
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
可以先记住这个硬规则:
recover一般放在defer的函数里使用
如果成功恢复,那么程序就不会因为这次 panic 直接整体退出。
11. 把函数当参数传进去是什么意思
在 Go 里,函数本身也可以作为参数传给另一个函数。
例如:
func runSafe(fn func()) {
fn()
}
这里的:
fn func()
意思是:
fn是参数名func()是参数类型- 这个类型表示"一个无参数、无返回值的函数"
所以如果调用时这样写:
runSafe(func() {
panic("something went wrong")
})
本质上就是:
- 把一个匿名函数当成参数传进去
- 再由
runSafe在内部执行这个函数
这类写法本质上是一种"包装执行"的思路:
把真正要做的事情交给外层函数统一包起来执行。
12. 这种包装写法为什么有点像切面思路
像下面这种结构:
func runSafe(fn func()) {
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
fn()
}
可以理解成:
fn:真正的业务动作runSafe:外层包装器defer + recover:统一加上的保护逻辑
所以它在思想上,确实有点像"把横切逻辑包在外层统一处理"。
不过这里仍然只是显式的函数传参和包装执行,不是完整的 AOP 体系。
13. panic 和 error 的分工
这是学习这一章最关键的边界之一。
大多数普通业务错误,其实都应该优先用 error 返回,而不是直接 panic。
例如下面这些场景,通常更适合返回 error:
- 参数不合法
- 文件打开失败
- 查询失败
- 网络请求失败
而 panic 更适合:
- 非常严重、已经不适合按正常流程继续处理的问题
- 明显违反程序假设的情况
- 需要在极少数位置统一兜底恢复的情况
所以不要把 panic 理解成"更高级的 error"。
它们不是同一层次的东西。
换句话说:
- 普通业务错误优先返回
error - 只有当问题已经超出正常业务处理范围,继续执行没有意义时,才考虑
panic
如果 panic 没有被恢复,程序通常会直接退出。
所以它不应该拿来替代日常错误处理。
14. recover 不会自动处理所有问题
即使写了 recover,也不代表错误就真的"解决了"。
它更像是把程序从直接崩溃里先拉回来。
恢复之后,往往还应该继续思考:
- 当前状态是不是还可靠
- 资源有没有正确释放
- 是否应该记录日志
- 是否应该向上继续返回错误
也就是说,recover 更偏向兜底,而不是日常错误处理主流方案。
15. 错误处理为什么强调显式判断
很多初学者刚接触 Go 时,会觉得频繁写 if err != nil 很啰嗦。
但这恰恰体现了 Go 的风格:
- 错误不隐藏
- 调用方明确知道这里可能失败
- 每一步由当前代码自己决定怎么处理
这种方式虽然没有"异常自动上抛"那样省代码,
但控制流通常会更直白,也更容易看出错误是在哪一层被处理掉的。
16. 本节小结
- Go 的日常错误处理主流方式是返回
error,而不是依赖异常机制。 error通常和正常结果一起返回,调用方显式检查err。err == nil表示没有错误,err != nil表示发生了错误。errors.New适合创建简单固定错误。fmt.Errorf适合补充更多错误上下文。%v和%w不是一回事,只有%w才表示包装并保留底层错误身份。%w可以用来包装底层错误。errors.Is可以判断包装后的错误链里是否包含目标错误。- 固定业务错误、上下文包装、以及自定义错误类型,是常见的三层错误设计思路。
func()可以作为参数类型,表示"把一个函数当参数传进去再执行"。panic是更激烈的中断方式,不应替代普通业务错误处理。recover一般放在defer中使用,用来兜底恢复panic。
二十一、泛型
在没有泛型时,如果一段逻辑既想支持 int,又想支持 float64,
往往就只能:
- 写多份几乎一样的函数
- 或者退回到
interface{}/any再做类型断言
泛型解决的核心问题就是:
让一份逻辑可以在"保持类型安全"的前提下复用于多种类型。
1. 什么是泛型
可以先把泛型理解成:
"把类型也当成参数传给函数或结构体"。
例如:
func add[T Number](a, b T) T
这里的 T 就不是普通值参数,而是类型参数。
也就是说,这个函数真正运行时,T 会被具体类型替换掉。
2. 泛型函数长什么样
最常见的泛型函数写法是:
func first[T any](items []T) T {
return items[0]
}
这里可以拆开看:
first:函数名[T any]:类型参数列表items []T:参数类型里使用了类型参数TT:返回值类型也使用了T
也就是说,这个函数不是只给 []int 用,也不是只给 []string 用,
而是"只要是某种切片,都可以把第一个元素取出来"。
这里还可以进一步按语法拆得更细一点:
func first[T any](items []T) T
T:类型参数名[T any]:类型参数列表items []T:普通函数参数,只不过参数类型里用了T- 最后的
T:返回值类型里也用了T
所以泛型里并不是"方括号本身表示切片",
这里的方括号表示的是"声明类型参数列表"。
3. any 在泛型里是什么意思
any 在这里表示:
这个类型参数没有额外限制,可以是任意类型。
例如:
func first[T any](items []T) T
它的含义可以先理解成:
T可以是int- 也可以是
string - 也可以是结构体
也就是说,any 是最宽松的类型约束。
这里要注意一个容易混淆的点:
- 以前学到的
any,常常是"一个值可以装任意类型" - 泛型里的
T any,表达的是"类型参数T可以取任意类型"
两者都和"任意类型"有关,但语境并不一样。
4. 为什么泛型比 any + 类型断言更自然
如果没有泛型,很多通用逻辑只能写成:
func first(items []any) any
这样虽然也能接收各种类型,
但调用方拿到结果后通常还要再做类型断言。
而泛型的优势就在于:
- 传入什么类型,返回通常就还是那个类型
- 编译期就能知道类型信息
- 少掉很多不必要的类型断言
所以泛型提供的不是"更自由",而是"更通用同时还更类型安全"。
5. 泛型里参数和返回值是不是都要写 T
不一定。
很多入门示例里,参数和返回值确实会一起使用 T,例如:
func max[T Ordered](a, b T) T
因为这类函数的含义通常就是:
- 传入什么类型
- 返回也还是那个类型
但泛型函数并不要求一定"参数和返回值都用泛型"。
例如只在参数里用:
func printValue[T any](v T)
例如参数里用泛型,返回固定类型:
func isZero[T comparable](v T) bool
所以更准确的理解是:
- 哪个位置和"类型变化"有关,就在哪个位置使用类型参数
- 不是所有参数、返回值都必须机械地写成
T
6. 泛型结构体是什么
泛型不只可以用在函数上,也可以用在结构体上。
例如:
type Box[T any] struct {
Value T
}
这里表示 Box 里有一个字段 Value,
但这个字段的具体类型由 T 决定。
所以可以这样实例化:
intBox := Box[int]{Value: 100}
stringBox := Box[string]{Value: "hello"}
这相当于同一个结构体模板,可以生成不同类型版本。
7. 泛型很适合做通用返回结构
泛型结构体一个很典型、也很实用的场景,就是后端里的统一返回结果结构。
例如:
type Result[T any] struct {
Code int
Msg string
Data T
}
它的好处很直接:
Code、Msg这类固定字段只写一份Data可以根据不同接口替换成不同类型- 依然保留类型安全,不必把
Data一律写成any
例如:
Result[string]
Result[int]
Result[User]
Result[[]User]
这类场景正好符合泛型最擅长的模式:
- 外层结构固定
- 里面承载的数据类型变化很大
8. 类型约束是什么
泛型并不总是"什么类型都能传"。
很多时候,一段逻辑只适用于一部分类型。
例如加法:
int可以相加float64可以相加- 但有些自定义类型未必适合直接这么写
这时就需要类型约束。
例如:
type Number interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
然后函数可以写成:
func add[T Number](a, b T) T {
return a + b
}
它的含义就是:
T不是任意类型T必须属于Number约束允许的那一组类型
9. 约束接口和普通接口有什么不同
这里的 interface 虽然也写成接口形式,
但它的用途和前面学的"行为接口"不完全一样。
前面学接口时,常见的是:
type Speaker interface {
Speak() string
}
这种接口定义的是"方法集合"。
而泛型约束里常见的是:
type Number interface {
~int | ~float64
}
这里描述的不是"要实现哪些方法",
而是"允许哪些底层类型参与这个泛型"。
所以泛型约束里的接口,更像是在描述"类型范围"。
10. 波浪线 ~ 是什么意思
在类型约束里看到:
~int
它的意思可以先理解成:
- 不只是字面上的
int - 底层类型是
int的自定义类型也可以匹配进来
例如:
type MyInt int
如果约束写的是 ~int,那么 MyInt 也能满足。
如果只写 int,那就只匹配字面上的 int 本身。
11. max 这类函数为什么需要可比较约束
像下面这种逻辑:
if a > b {
return a
}
并不是所有类型都能直接用 > 比较。
所以这类泛型函数必须给 T 加上足够明确的约束。
例如:
type Ordered interface {
~int | ~int32 | ~int64 | ~float32 | ~float64 | ~string
}
然后:
func max[T Ordered](a, b T) T
这样编译器才能知道:
T 是一组支持大小比较的类型。
12. 类型推断是什么
调用泛型函数时,不一定每次都要手写类型参数。
例如:
fmt.Println(add(10, 20))
这里虽然没有显式写 [int],
但编译器可以从参数 10 和 20 推断出 T 是 int。
当然,也可以显式写:
fmt.Println(add[int](10, 20))
所以可以先这样理解:
- 编译器能从实参看出来时,通常可以省略类型参数
- 如果推断不出来,或者想写得更明确,也可以手动写出来
也就是说,下面两种写法在很多场景里都成立:
add[int](10, 20)
add(10, 20)
前者是显式写出类型参数,
后者是让编译器自动推断。
13. 泛型是不是意味着以后都该泛型化
不是。
泛型的价值在于"同一份逻辑确实需要复用于多种类型",
而不是"只要看到重复就立刻改成泛型"。
如果一个函数本来就只服务于很具体的业务类型,
那普通函数往往反而更直接、更清晰。
所以泛型更适合:
- 明显可抽象成通用算法的逻辑
- 只是类型不同,但处理流程本质一样
而不是把所有代码都追求成"可泛型化"。
14. 本节小结
- 泛型的核心是让一份逻辑在保持类型安全的前提下复用于多种类型。
- 泛型函数和泛型结构体都可以声明类型参数。
[T any]这种写法表示类型参数列表,T是类型参数名。any表示最宽松的类型约束。- 泛型通常比
any + 类型断言更自然,也更类型安全。 - 参数和返回值不一定都要用
T,哪个位置涉及类型变化,哪个位置再使用类型参数。 Result[T]这类外层结构固定、内部数据类型变化的场景,非常适合用泛型。- 类型约束用于限制类型参数不是"任意类型",而是一组允许的类型。
- 泛型约束里的接口常常描述的是类型范围,而不只是方法集合。
~int这类写法表示"底层类型是 int 的类型也可以匹配"。- 像
max这种依赖比较运算的泛型函数,需要更明确的约束。 - 编译器很多时候可以自动推断类型参数,不一定要每次手写。
- 泛型不是越多越好,只有在"逻辑相同、类型不同"的场景下才最有价值。
二十二、文件读取
学完前面的语言基础之后,文件操作通常就是非常实用的一章。
因为很多程序都需要把配置、日志、文本数据或者本地资源从文件里读出来。
Go 做文件读取时,最常见的问题通常不是语法本身,
而是下面这些实际点:
- 文件路径是否正确
- 打开文件时是否处理了错误
- 是要一次读完整个文件,还是逐行读取
1. 最常见的整文件读取:os.ReadFile
如果只是想把一个文件整体读出来,最直接的方式之一就是:
data, err := os.ReadFile("sample.txt")
这里:
data是读取到的内容,类型是[]byteerr表示读取过程是否出错
这也是为什么文件读取时通常第一步还是:
if err != nil {
...
}
2. 为什么读出来是 \[\]byte
os.ReadFile 返回的是 []byte,不是 string。
这是因为文件本质上读出来的是一串字节数据。
如果确定它是文本内容,再把它转成字符串通常就可以:
fmt.Println(string(data))
所以可以先这样理解:
- 文件读取的底层结果先是字节
- 文本展示时常常再转成字符串
3. 文件读取为什么必须先判断 err
文件操作比很多内存内的普通变量操作更容易失败。
常见原因有:
- 路径写错了
- 文件不存在
- 没有权限
- 文件正在被别的程序占用
所以文件读取里,错误判断不是可有可无,而是核心步骤之一。
例如:
data, err := os.ReadFile("sample.txt")
if err != nil {
fmt.Println("read file error:", err)
return
}
4. 获取文件内容长度
如果已经读到了 []byte,那就可以直接:
len(data)
这里得到的是字节长度,不一定等于"字符个数"。
特别是文本里如果含有中文、emoji 或其他多字节字符时,
字节数和肉眼看到的字符数量往往不是一回事。
所以文件处理里最好先明确:
统计的到底是字节长度,还是文本字符数量。
5. 使用 os.Open 打开文件
除了直接 ReadFile 一次读完之外,也可以先手动打开文件:
file, err := os.Open("sample.txt")
这种方式更适合后面继续做"流式处理",例如:
- 逐行读取
- 按块读取
- 配合扫描器处理内容
如果文件成功打开,通常还要记得:
defer file.Close()
这样函数结束前会自动关闭文件句柄。
6. 为什么打开文件后要 Close
文件不像普通变量,用完就自然没事。
它背后通常对应着操作系统资源。
如果文件打开后长期不关闭,可能会带来:
- 资源占用
- 文件句柄泄漏
- 后续读写受影响
所以常见习惯是:
- 打开成功后,尽快
defer file.Close()
7. 逐行读取:bufio.Scanner
如果文件更适合一行一行处理,Go 里很常见的方式是:
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
这里可以先这样理解:
Scan():尝试继续往下读一行Text():拿到当前读到的这一行文本
这种写法很适合日志文件、配置文件、逐行文本处理等场景。
8. 逐行读取后为什么还要检查 scanner.Err
很多人看到:
for scanner.Scan() {
...
}
会以为循环结束就万事大吉了。
但更稳妥的写法通常还会补一句:
if err := scanner.Err(); err != nil {
fmt.Println(err)
}
原因是:
Scan()结束不一定只代表"读完了"- 也可能是在扫描过程中发生了错误
所以逐行读取的完整思路通常是:
- 循环里处理每一行
- 循环后补查一次扫描错误
9. 一次读完整个文件 vs 逐行读取
这两种方式并不是谁绝对更好,而是适用场景不同。
一次读完整个文件更适合:
- 文件不大
- 需要整体处理内容
- 配置文件、模板、小文本文件
逐行读取更适合:
- 文件较大
- 一行一行处理更自然
- 日志、数据导出文本、逐行解析任务
可以先把它理解成:
- 小而整体性的内容,用
ReadFile - 大而流式处理的内容,用
Open + Scanner
10. 文件不存在时为什么会直接报错
如果路径不对,或者目标文件根本不存在,
那么在执行:
os.ReadFile(...)
或:
os.Open(...)
时就会直接返回错误。
这类错误不是"程序自己逻辑写坏了",
而是操作系统层面表明:这个资源当前拿不到。
所以文件读取时,路径问题是最先应该排查的现实问题之一。
11. 为什么示例里的路径是从 learn 开始
例如下面这种写法:
os.ReadFile("learn/022_file_reading/sample.txt")
很多人第一次看到时会疑惑:
为什么路径是从 learn 开始,而不是直接写 sample.txt?
这里的关键不是包名,而是"程序运行时的工作目录"。
如果命令是在项目根目录执行:
go run ./learn/022_file_reading
那么相对路径通常就是相对于项目根目录来算。
于是:
learn/022_file_reading/sample.txt
实际上表示的是:
项目根目录/learn/022_file_reading/sample.txt
所以这类路径写法的核心不是 Go 特殊规定,
而是"当前工作目录 + 相对路径"的组合结果。
12. 文件路径和 package main 有什么关系
文件读取路径和 package main 没有直接关系。
这里最好把两件事拆开:
package main:表示这一组代码要编译成可执行程序。os.ReadFile("..."):表示运行时去操作系统里按路径找文件。
也就是说:
- 包结构,和 Go 源码目录组织有关。
- 文件读取路径,和程序运行时的工作目录有关。
不要把"代码所在目录"和"运行时相对路径基准目录"当成同一件事。
13. 为什么同样是相对路径,有时写 sample.txt 也行
如果是在当前知识点目录里运行:
cd learn/022_file_reading
go run .
那么相对路径基准就变成了这个目录本身。
这时直接写:
os.ReadFile("sample.txt")
通常就能找到文件。
所以相对路径该怎么写,不是看 .go 文件放在哪,
而是看程序启动时当前工作目录在哪里。
14. 为什么第一行前面会多出一个奇怪字符
有时读取文本文件后,第一行开头会多出一个看起来奇怪的字符。
如果文件本身带了 UTF-8 BOM,那么就可能出现这种现象。
UTF-8 BOM 本质上也是文件开头的几个字节。
既然 Go 读文件时拿到的是原始字节数据,那么这些字节也会一起被读出来。
这意味着:
- Go 不是"额外多读了东西"
- 而是文件本身开头就带着那几个字节
如果后续需要更严格处理文本内容,就要意识到:
某些文本文件不是"纯正文",还可能带有编码标记。
15. 空文件读取会怎么样
如果文件存在,但内容为空,那么通常并不会因为"空"就报错。
更常见的现象是:
ReadFile读到一个长度为 0 的[]byte- 逐行扫描时,循环体一次也不会进入
也就是说,"空文件"和"读取失败"不是一回事。
16. 本节小结
os.ReadFile适合一次性把整个文件读出来。- 文件读取结果常常先是
[]byte,文本展示时再转成string。 - 文件操作里必须重视
err判断,因为路径、权限、文件状态都可能导致失败。 len(data)得到的是字节长度,不一定等于字符个数。os.Open更适合后续做流式处理。- 文件打开成功后,通常要及时
defer file.Close()。 bufio.Scanner很适合逐行读取文本内容。- 逐行扫描结束后,通常还应继续检查
scanner.Err()。 - 小文件、整体处理内容时,
ReadFile更直接;大文件、逐行处理时,Scanner更合适。 - 相对路径通常是相对于程序运行时的工作目录,不是相对于当前
.go文件所在目录。 - 文件读取路径和
package main没有直接关系。 - 某些文本文件如果带有 UTF-8 BOM,读取后第一行开头可能会连同 BOM 一起显示出来。
- 空文件不一定报错,它和"文件不存在"是两种完全不同的情况。
二十三、文件写入
学会文件读取之后,文件写入通常就是顺着要掌握的一章。
很多程序都需要把结果、日志、配置或者导出内容写到本地文件中。
文件写入时最重要的几个问题通常是:
- 这次是覆盖原文件,还是追加到末尾
- 写入过程有没有报错
- 如果用了缓冲写入,是否已经真正刷到文件里
1. 最直接的写法:os.WriteFile
如果只是想把一段内容直接写进文件里,最简单的方式之一就是:
err := os.WriteFile("output.txt", []byte("hello"), 0644)
这里可以先这样理解:
- 第一个参数:目标文件路径
- 第二个参数:要写入的字节内容
- 第三个参数:文件权限
如果写入失败,同样会通过 err 返回出来。
2. 为什么写入内容常常还是 \[\]byte
和读取一样,文件操作底层仍然是字节。
所以写入时经常会看到:
[]byte("hello golang")
也就是说:
- 平时写的是字符串
- 真正写进文件前,常常会转成字节
3. 覆盖写入是什么意思
os.WriteFile 一个很重要的特点是:
通常会把目标文件内容直接按当前给出的数据重写掉。
也就是说,如果原文件里已经有内容,再执行新的 WriteFile,
最终文件内容通常以这次的新内容为准。
连续两次对同一路径调用 os.WriteFile 时,
第二次写入通常会直接替换第一次写入后的文件内容,
而不是自动把两次内容拼接起来。
所以可以先把它理解成:
WriteFile更适合"一次性写出最终结果"- 它不是天然的"往后继续追加"
4. 为什么写文件也必须判断 err
写文件时同样可能失败。
常见原因包括:
- 路径不存在
- 没有写权限
- 文件被占用
- 磁盘或系统状态异常
所以写文件和读文件一样,
if err != nil 不是可选项,而是基础操作。
5. 追加写入:os.OpenFile
如果不是想覆盖,而是想在文件末尾继续写,
常见方式是:
file, err := os.OpenFile("output.txt", os.O_APPEND|os.O_WRONLY, 0644)
这里的关键是:
os.O_APPEND:追加模式os.O_WRONLY:只写模式
这样后续再写入时,内容就会加到文件末尾,而不是从头覆盖。
如果后面使用的是:
n, err := file.WriteString("next line\n")
那么返回值里的 n 表示本次成功写入的字节数。
这里统计的是字节,不是"看起来写了几段文本"。
6. OpenFile 和 WriteFile 的使用区别
可以先这样理解:
WriteFile:更适合一次性把完整内容直接写进去OpenFile:更适合后续继续控制写入方式,例如追加、分多次写
如果场景是:
- 输出一个完整小文件
- 一次生成一次写完
那 WriteFile 很直接。
如果场景是:
- 日志持续追加
- 文件需要多次写入
- 需要更细粒度控制写入方式
那 OpenFile 通常更合适。
7. 写入多行文本时要自己处理换行
文件写入不会自动补换行。
如果想写多行内容,通常要自己把换行符写进去。
例如:
"line one\nline two\n"
也就是说,文件里一行怎么结束、下一行怎么开始,
通常由写入内容本身决定。
8. 使用 bufio.Writer 做带缓冲写入
如果不想每写一点内容就立刻直接落盘,
可以用带缓冲的写入器:
writer := bufio.NewWriter(file)
然后:
writer.WriteString("line 1\n")
writer.WriteString("line 2\n")
这种方式适合:
- 分多次写内容
- 减少频繁直接写文件的次数
- 用更顺手的方式组织输出
9. 为什么用了 Writer 还要 Flush
带缓冲写入最关键的一点就是:
写进去的内容,可能先只是进入内存缓冲区,还没有真正落到文件里。
所以常见写法最后还会有:
writer.Flush()
可以先把 Flush 理解成:
- 把缓冲区里暂存的内容,真正刷到文件中
如果忘了 Flush,那就可能出现:
- 程序以为已经写了
- 但文件里实际内容并不完整
如果在 Flush() 之前立刻去读取这个文件,
读到的结果可能是空内容,也可能还是旧内容,
因为数据此时还停留在 bufio.Writer 的内存缓冲区里。
10. 打开文件写入后为什么也要 Close
和读取一样,写入文件时也涉及操作系统资源。
所以文件成功打开之后,通常同样要记得:
defer file.Close()
它的价值包括:
- 释放文件句柄
- 避免资源长期占用
- 让文件操作流程更完整
如果使用的是 bufio.Writer,
更稳妥的顺序通常是先 Flush(),再 Close()。
前者负责把缓冲区里的数据推进文件,后者负责关闭文件资源。
11. 文件写入路径和读取路径的规则一样
写文件时的相对路径规则,和读文件时是一样的。
关键仍然是程序运行时的工作目录。
例如:
os.WriteFile("learn/023_file_writing/output.txt", ...)
如果程序是在项目根目录下执行:
go run ./learn/023_file_writing
那么这个路径就是相对于项目根目录来找和写入。
所以文件写入路径也和 package main 没有直接关系。
12. 一次性写入、追加写入、缓冲写入分别适合什么
可以先做一个很实用的场景划分:
- 一次性写完整个小文件:
os.WriteFile - 持续往文件末尾补内容:
os.OpenFile + O_APPEND - 多次写入并希望先经过缓冲:
bufio.Writer
这三种不是谁更高级,
而是文件写入方式不同,对应选择也不同。
13. 本节小结
os.WriteFile适合一次性把内容直接写进文件。- 写文件时常常仍然是把内容先写成
[]byte。 WriteFile通常是覆盖写入,不是天然追加。- 连续多次对同一路径执行
WriteFile时,后一次内容通常会覆盖前一次结果。 - 文件写入同样必须认真检查
err。 - 如果要在末尾继续补内容,通常使用
os.OpenFile的追加模式。 WriteString返回的n表示本次成功写入的字节数。- 写入多行文本时,换行符需要自己控制。
bufio.Writer适合多次写入和带缓冲输出。- 使用带缓冲写入后,通常要记得
Flush()。 Flush()之前去读文件,读到的内容可能还是空的或者不完整。- 写入文件成功打开后,也要及时
Close()释放资源。 - 文件写入路径的相对路径规则,和文件读取时是一致的。
二十四、单元测试
写完函数之后,下一步经常不是立刻继续堆业务,
而是先验证这个函数在不同输入下是否真的符合预期。
这就是单元测试最核心的价值。
单元测试可以先简单理解成:
- 针对一个函数或一小段逻辑做独立验证
- 给它一组输入
- 检查返回结果是否和预期一致
对于后端开发来说,这类测试会非常常见。
像参数判断、金额计算、状态流转、权限判断、字符串处理、时间范围判断,
都很适合先用单元测试把逻辑固定下来。
1. Go 的单元测试主要依赖 testing 包
Go 标准库已经内置了测试能力,
最常见的就是:
import "testing"
也就是说,写 Go 单元测试通常不需要先引入第三方测试框架,
只用标准库就可以完成最基础、最常用的测试工作。
2. 测试文件为什么通常要写成 _test.go
Go 里测试代码通常单独放在:
xxx_test.go
这样的文件里。
例如:
calc.go:放被测试的函数calc_test.go:放测试代码
这样做的价值是:
- 业务代码和测试代码分开
- 平时运行程序时不会把测试当成正常入口执行
go test会自动识别这些测试文件
3. 最基础的测试函数长什么样
Go 里最基础的测试函数通常写成这样:
func TestAdd(t *testing.T) {
got := add(10, 20)
want := 30
if got != want {
t.Errorf("add(10, 20) = %d, want %d", got, want)
}
}
这里可以先这样理解:
- 函数名要以
Test开头 - 参数通常是
t *testing.T got表示实际结果want表示期望结果
如果结果不一致,就通过 t.Errorf 或 t.Fatal 报出问题。
4. got 和 want 这种写法为什么很常见
单元测试里经常会看到:
gotwant
这是非常常见的一种约定写法。
它的好处是很直接:
got:程序这次真正算出来的结果want:预期应该得到的结果
测试失败时,直接把这两个值一起打印出来,
就能很快看出到底是逻辑算错了,还是测试预期写错了。
5. 运行单元测试通常不用 go run,而是 go test
平时执行示例代码时常用的是:
go run ./learn/024_unit_testing
但执行测试时,常见命令是:
go test ./learn/024_unit_testing
如果想看更详细的测试过程,可以加:
go test ./learn/024_unit_testing -v
这里的 -v 可以理解成 verbose,
也就是把每个测试、子测试的执行过程打印得更详细一些。
6. 为什么说单元测试更适合测小范围逻辑
单元测试最适合的对象通常是:
- 输入清晰
- 输出清晰
- 副作用少
- 不强依赖数据库、网络、文件、外部服务
例如:
- 两个数相加
- 年龄是否成年
- 某个状态是否允许流转
- 某个参数是否有效
因为这类函数更容易控制输入,也更容易验证输出。
如果一个函数内部同时掺杂了:
- 数据库查询
- HTTP 请求
- 文件读写
- 日志、缓存、消息发送
那测试难度通常会明显上升。
不是说完全不能测,而是它已经不再只是一个"简单单元"的问题了。
7. 表驱动测试为什么在 Go 里很常见
当一个函数有多组输入、多组预期结果时,
如果每一组都单独写一份重复测试代码,会比较啰嗦。
所以 Go 里很常见的一种方式是表驱动测试。
可以先把它理解成:
- 先定义一组测试数据
- 再用循环把这些测试数据逐个跑一遍
典型写法类似这样:
testCases := []struct {
name string
age int
want bool
}{
{name: "minor", age: 17, want: false},
{name: "adult edge", age: 18, want: true},
{name: "adult", age: 25, want: true},
}
然后:
for _, tc := range testCases {
got := isAdult(tc.age)
if got != tc.want {
t.Errorf("isAdult(%d) = %v, want %v", tc.age, got, tc.want)
}
}
这种方式的优点是:
- 多组测试数据放在一起更集中
- 扩展新测试用例更方便
- 特别适合边界值和分支较多的函数
像分数分级、年龄判断、状态切换这类逻辑,
真正容易出错的地方往往不是"普通值",
而是阈值附近的边界。
例如分数分级里常见的边界点可能就是:
59和6069和7079和8089和90
所以表驱动测试一个很实用的价值就是:
可以把这些边界值系统地排在一起测,而不是只随手测几个中间值。
8. t.Run 的作用是什么
在表驱动测试基础上,经常还会看到:
t.Run(tc.name, func(t *testing.T) {
...
})
t.Run 可以把每一组测试数据再包装成一个独立的子测试。
这样做的好处主要有:
- 每组测试会有自己的名字
- 失败时更容易定位是哪一组数据出了问题
- 输出结构更清晰
例如:
TestIsAdult/minorTestIsAdult/adult edge
看到名字就能知道是哪一组场景没过。
不过子测试层级也不是越深越好。
如果只是把同一组测试数据重复包上多层、但行为并没有区别,
那输出虽然更长,却不会带来新的信息。
所以更实用的原则通常是:
- 每一层
t.Run都应该表达一个明确的分类 - 子测试名字尽量直接对应具体场景
- 没有新含义的重复嵌套,通常可以省掉
9. 测试 error 场景时应该怎么想
很多函数不只是返回一个正常结果,
还可能在异常输入下返回 error。
例如:
func divide(a, b int) (int, error)
这类函数测试时,通常至少要覆盖两条路径:
- 正常路径:返回正确结果,
err == nil - 异常路径:返回错误,
err != nil
也就是说,单元测试不只是测试"成功时对不对",
也要测试"失败时是不是按预期失败"。
如果函数签名是这种形式:
value, err := fn(...)
那测试时一个很常见的顺序是:
- 先判断
err是否符合预期 - 再判断返回值
value是否符合预期
原因很直接:
如果本来就应该报错,那这时再去比较正常结果通常没有意义;
反过来,如果本来应该成功,但 err 已经不对,
那后面的结果比较也会失去参考价值。
10. t.Errorf、t.Fatal、t.Fatalf 有什么区别
可以先这样区分:
t.Errorf:记录错误,当前测试函数后续代码还会继续执行t.Fatal:记录错误后立刻结束当前测试t.Fatalf:相当于带格式化输出的t.Fatal
什么时候更适合用 t.Fatal?
通常是当后续逻辑已经依赖前面的结果时。
例如本来应该先成功拿到返回值和 err == nil,
结果这里已经失败了,那么后面再继续比较数值就没有意义了。
11. 覆盖率是什么意思
Go 里可以直接查看测试覆盖率:
go test ./learn/024_unit_testing -cover
覆盖率可以先简单理解成:
- 当前测试执行到了多少代码语句
它能帮助发现一个问题:
有些函数看起来已经测了,
但实际上某些分支根本没有跑到。
不过覆盖率高,不等于逻辑一定没有问题。
它更像是一个辅助指标,而不是正确性的唯一标准。
12. 为什么覆盖率有时不是 100%
覆盖率统计的是整个 package 里的代码。
如果某些函数、某些分支、某些演示入口没有被测试跑到,
那覆盖率就不会是 100%。
例如在学习示例里:
main.go可能只是一个演示入口- 真正重点测试的是
calc.go里的函数
这种情况下,即使核心逻辑已经覆盖到,
整体 package 覆盖率也未必满分。
所以看覆盖率时,要结合具体代码结构一起判断。
13. 哪些函数更适合先写单元测试
如果一个函数具备这些特点,通常就比较适合优先写单元测试:
- 逻辑相对独立
- 输入输出明确
- 分支比较多
- 有边界条件
- 容易因为改动而出错
例如:
- 年龄判断
age >= 18 - 除法里除数不能为 0
- 某个字符串是否满足格式要求
- 某个订单状态是否允许取消
这类函数往往不长,
但一旦判断写错,就会直接影响业务结果。
所以越是这种小而关键的逻辑,越适合及时补测试。
还有一个很实际的原则是:
测试数据必须和函数真实采用的规则保持一致。
例如某个字符串校验函数如果底层使用的是:
unicode.IsLetter(r)
那它判断的是"是否属于字母类字符",
而不只是"是否属于英文大小写字母"。
这时中文字符通常也会被视为合法字母。
所以测试时不能只凭直觉判断某个输入应不应该失败,
而要先看清楚函数真正用了什么规则。
14. 本节小结
- Go 标准库里的
testing包可以直接完成基础单元测试。 - 测试代码通常写在
_test.go文件里。 - 最基础的测试函数命名通常以
Test开头,并接收t *testing.T。 got和want是单元测试里非常常见的一组命名习惯。- 执行测试常用的是
go test,不是go run。 go test -v可以看到更详细的测试输出。- 表驱动测试适合一组函数对应多组输入输出的场景。
- 表驱动测试特别适合把边界值和多个分支系统地放在一起验证。
t.Run可以把每组数据拆成更清晰的子测试。- 子测试层级应当有实际含义,重复嵌套同一组场景通常没有必要。
- 返回
error的函数,通常要同时覆盖正常路径和异常路径。 - 同时返回结果和
error时,通常先判断err,再判断结果值。 t.Errorf、t.Fatal、t.Fatalf的中断行为并不一样。go test -cover可以帮助查看当前测试覆盖到了多少代码。- 覆盖率是辅助指标,不是判断代码正确性的唯一标准。
- 测试数据要和函数真实采用的判断规则保持一致,不能只靠直觉设预期。
二十五、反射(一):获取值与修改值
Go 里的反射,第一次接触时很容易觉得绕。
因为平时写代码时,我们通常是:
- 直接用变量
- 直接访问字段
- 直接调用方法
但反射做的事情是:
把"一个变量"当成"一个可以被程序继续分析和操作的对象"来看。
这一节先不展开讲完整反射体系,
只先抓最基础的一条主线:
- 怎么通过反射拿到类型
- 怎么通过反射拿到值
- 为什么有的值能改,有的值不能改
- 为什么反射改值时通常要配合指针
1. 反射最常从 TypeOf 和 ValueOf 开始
Go 里最常见的两个入口就是:
reflect.TypeOf(x)
reflect.ValueOf(x)
可以先这样理解:
TypeOf:更偏向"这个变量的类型信息是什么"ValueOf:更偏向"这个变量当前持有的值是什么"
例如:
age := 18
fmt.Println(reflect.TypeOf(age))
fmt.Println(reflect.ValueOf(age))
运行后通常会看到类似:
- 类型是
int - 值是
18
2. Type 和 Kind 不是一回事
刚开始学反射时,一个很容易混的点就是:
TypeKind
它们相关,但不是同一个层面。
可以先粗略这样理解:
Type:更具体,看到的是完整类型Kind:更抽象,看到的是底层类别
例如一个变量如果是:
user := User{Name: "tom", Age: 18}
那么:
reflect.TypeOf(user)更接近说明"它是main.User"reflect.TypeOf(user).Kind()更接近说明"它本质上属于struct"
所以后面写反射代码时,
经常会先看 Kind(),因为很多反射操作取决于"它到底是指针、结构体、切片,还是基础类型"。
3. ValueOf 拿到的是反射值,不是普通值本身
例如:
score := 95
value := reflect.ValueOf(score)
这里的 value 不是普通的 int,
而是一个 reflect.Value。
也就是说,反射拿到的往往不是"直接可用的原值",
而是一个"包了一层的反射对象"。
后续如果要继续判断类型、判断能不能改、取出原值,
通常都要基于这个 reflect.Value 再继续操作。
4. Interface() 的作用是什么
如果已经拿到了:
value := reflect.ValueOf(score)
那可以通过:
raw := value.Interface()
把它重新取回成一个普通接口值。
可以先这样理解 Interface():
- 把
reflect.Value里包着的原始值再拿出来
这时如果打印:
fmt.Printf("%v %T\n", raw, raw)
就能看到它对应的实际值和实际类型。
5. 为什么直接传普通值时通常改不了
这是反射里第一个真正关键的点。
例如:
count := 10
value := reflect.ValueOf(count)
这时如果查看:
value.CanSet()
结果通常是 false。
原因可以先简单理解成:
此时交给反射的是"这个值当前的一份副本信息",
而不是一个可以回写到原变量上的地址入口。
所以这里虽然"看得到值",
但并不代表"能改回原变量"。
6. CanSet() 到底在判断什么
CanSet() 可以先这样理解:
- 当前这个反射值,是否允许被修改
如果返回 false,
后面直接调用 SetInt、SetString 这类方法通常就会报错或 panic。
所以一个比较稳妥的反射思路通常是:
- 先拿到
reflect.Value - 先看
Kind() - 再看
CanSet() - 确认可以修改后,再调用对应的
SetXXX()
7. 为什么改值时通常要传指针
如果想真正改掉原变量,
通常要把变量地址交给反射。
例如:
count := 10
ptrValue := reflect.ValueOf(&count)
这时拿到的是一个指针反射值,
它的 Kind() 通常会是:
ptr
也就是说,
反射现在拿到的不是 count 的一份普通值信息,
而是指向原变量的地址入口。
这一点和之前学过的指针本质上是连着的:
想改外部原值,前提通常就是先拿到它的地址。
8. Elem() 是做什么的
如果拿到的是指针反射值:
ptrValue := reflect.ValueOf(&count)
那它本身还不是最终要改的那个值,
而是"指向那个值的指针"。
这时通常还要继续:
elemValue := ptrValue.Elem()
Elem() 可以先这样理解:
- 从指针再往里取一层
- 取到这个指针真正指向的那个值
所以反射里经常会出现这套组合:
reflect.ValueOf(&x).Elem()
它背后的意思其实就是:
- 先把地址交给反射
- 再找到这个地址真正指向的原值
9. 修改基础类型时常见的写法
如果已经拿到了一个可修改的 reflect.Value,
那就可以调用对应类型的 SetXXX() 方法。
例如整数:
elemValue.SetInt(99)
例如字符串:
elemValue.SetString("hello")
这里要注意一个点:
反射修改值不是一个通用的 Set 就完事了,
而是通常要根据目标值类型,调用对应的 SetInt、SetString、SetBool 等方法。
10. 结构体字段也可以通过反射修改
如果变量是结构体,例如:
user := User{Name: "tom", Age: 18}
那么常见思路是:
- 先传结构体指针给
ValueOf - 再通过
Elem()拿到结构体本身 - 再取字段
- 再修改字段值
例如:
value := reflect.ValueOf(&user).Elem()
nameField := value.FieldByName("Name")
ageField := value.FieldByName("Age")
然后:
nameField.SetString("alice")
ageField.SetInt(25)
这种写法的核心并不在"结构体有多特殊",
而在于:
最终仍然要先拿到一个"可设置"的字段反射值。
这里还可以顺手理清两个点:
FieldByName("Name")取出来的仍然是一个reflect.Value- 只是这个
reflect.Value现在对应的是结构体里的某个具体字段
也就是说,
结构体字段并不是"跳出了反射体系",
而是反射操作目标从"整个结构体"继续缩小到了"结构体里的某个字段"。
如果结构体本身是通过:
reflect.ValueOf(&user).Elem()
这种方式拿到的,
那它本身通常是可设置的;
继续通过 FieldByName 取到的导出字段,通常也会是可设置的。
所以实际写反射修改结构体字段时,经常会连着看:
- 结构体本身的
CanSet() - 某个字段的
CanSet()
11. 反射修改值这件事,本质上还是绕不开指针和可修改性
很多人第一次学反射时会觉得:
ValueOfElemCanSetSetXXX
这些 API 很碎。
但把它们串起来看,主线其实很统一:
- 先拿到反射值
- 判断它是什么类别
- 如果要修改原值,先确保拿到的是地址链路
- 再进入真实目标值
- 最后调用对应的设置方法
也就是说,反射虽然看起来新,
但底层仍然没有脱离前面已经接触过的:
- 值传递
- 指针
- 类型判断
12. 什么时候反射容易出问题
这一节先不讲完整的反射风险清单,
但最常见的坑可以先记住两个:
- 明明拿到了
Value,却直接想改,结果CanSet()是false - 明明传了指针,却忘了
Elem(),最后拿到的还是指针本身,不是目标值
所以刚开始写反射时,
一个很实用的排查顺序就是:
- 它的
Type是什么 - 它的
Kind是什么 CanSet()是true还是false- 现在手里拿到的是值本身,还是指针,还是字段
13. 本节小结
- 反射最常见的两个入口是
reflect.TypeOf和reflect.ValueOf。 Type更偏向具体类型,Kind更偏向底层类别。reflect.Value是反射值,不是普通变量本身。Interface()可以把反射值再取回成普通接口值。- 直接把普通值传给
ValueOf时,通常只能看,不能改。 CanSet()用来判断当前反射值是否允许被修改。- 如果想改原变量,通常要先把指针交给反射。
Elem()用来继续拿到指针真正指向的那个值。- 修改值时通常要根据目标类型调用对应的
SetXXX()方法。 - 结构体字段也可以通过反射取出并修改,但前提仍然是这个字段可设置。
FieldByName取出来的字段本质上仍然是reflect.Value,只是目标缩小成了具体字段。- 反射改值这条主线,本质上仍然和指针、值传递、类型判断密切相关。
二十六、通过反射实现转 SQL(教学版)
学完通过反射获取值、修改值之后,
下一步比较自然的应用,就是让反射去读取结构体,再把结构体信息拼成 SQL。
这类能力本质上很像一个极简 ORM 的起点。
例如:
- 结构体字段对应数据库列
- 结构体字段值对应 SQL 参数
- 主键字段决定
WHERE条件 - 字段 tag 决定列名映射规则
这一节先只做教学版,重点放在:
- 通过反射读取结构体字段和 tag
- 生成 MySQL 风格的 SQL 字符串
- 生成和 SQL 对应顺序一致的参数切片
这里先不真正连接数据库,
也不展开讲完整 ORM 的全部能力。
1. 反射转 SQL 的核心思路是什么
如果有这样一个结构体:
type User struct {
ID int `db:"id" orm:"pk,auto"`
Name string `db:"name"`
Age int `db:"age"`
Email string `db:"email"`
}
那么反射转 SQL 的主线通常就是:
- 先拿到结构体的反射值
- 遍历结构体字段
- 读取每个字段的字段名、tag、字段值
- 决定哪些字段参与 SQL
- 拼出 SQL 字符串
- 按 SQL 顺序收集参数
也就是说,这一节的重点并不只是"字符串拼接",
而是"如何从结构体里稳定地提取出足够的信息"。
2. 为什么这件事常常和 tag 一起出现
如果完全不使用 tag,
那结构体字段名和数据库列名就只能强行直接对应。
例如:
- 结构体字段叫
UserName - 数据库列可能叫
user_name
这时通常就需要一个中间映射规则。
最常见的方式之一就是 tag:
Name string `db:"name"`
可以先把 db tag 理解成:
- 这个结构体字段在数据库里的列名是什么
所以反射在这里做的事情并不复杂,
本质上就是在运行时读取:
- 字段名
- 字段类型
- 字段值
- 字段 tag
然后再据此做后续拼接。
3. 为什么还要额外区分主键字段
如果只是生成 INSERT,
很多时候只需要知道:
- 哪些列要写进去
- 对应值是什么
但如果是 UPDATE、DELETE、SELECT,
通常还需要知道:
- 哪个字段应该拿来做条件
所以教学版里通常会额外约定一个主键标记,例如:
orm:"pk"
如果字段还带有自增含义,
还可以再补一个标记,例如:
orm:"pk,auto"
这样后续生成 SQL 时,就能把主键字段和普通字段区分开来。
4. INSERT SQL 一般怎么拼
教学版 INSERT 的思路通常是:
- 遍历字段
- 跳过自增主键
- 收集列名
- 为每一列补一个
? - 把字段值按顺序放进参数切片
最终结果通常类似:
INSERT INTO users (name, age, email) VALUES (?, ?, ?)
这里的重点有两个:
- SQL 里的列顺序要和参数顺序一致
- 如果字段被跳过,例如自增主键,也要同步从参数里跳过
5. UPDATE SQL 和 INSERT 的区别
UPDATE 和 INSERT 的最大区别,不在于都是"拼字符串",
而在于职责分工不同:
- 普通字段:放进
SET - 主键字段:放进
WHERE
例如:
UPDATE users SET name = ?, age = ?, email = ? WHERE id = ?
这里参数顺序通常也要和 SQL 保持完全一致,例如:
[nameValue, ageValue, emailValue, idValue]
如果顺序乱了,
即使 SQL 字符串表面看起来对,真正执行时也会把值绑定错位置。
6. DELETE SQL 的重点其实是条件来源
教学版 DELETE 往往最短,例如:
DELETE FROM users WHERE id = ?
但它背后的关键仍然是同一个问题:
WHERE条件到底从哪个字段来
如果前面已经通过 tag 识别出主键字段,
那这一步就会变得很直接:
取主键字段列名,取主键字段值,再拼删除语句和参数。
7. SELECT SQL 为什么也可以复用同一套反射信息
教学版 SELECT 常见写法类似:
SELECT id, name, age, email FROM users WHERE id = ?
这里其实又复用了前面已经拿到的同一批结构体信息:
- 所有字段列名,用来拼
SELECT的列列表 - 主键字段列名,用来拼
WHERE - 主键字段值,用来作为参数
这也是为什么反射在 ORM 这类场景里会很常见。
同一份结构体元信息,往往可以同时服务于多类 SQL 生成。
8. 为什么参数通常要单独放进切片
教学版里经常不是直接把值硬拼进 SQL,
而是会得到两份结果:
- SQL 字符串
- 参数切片
例如:
sql := "INSERT INTO users (name, age) VALUES (?, ?)"
args := []any{"alice", 20}
这样做至少有两个直接好处:
- SQL 模板和参数值分离,结构更清晰
- 后面如果真的接入
database/sql,也更容易直接复用
也就是说,即使当前这一节不连数据库,
这种返回形式也更接近真实数据库编程习惯。
9. 为什么这里先按 MySQL 风格来写
这一节为了聚焦反射本身,
先统一按 MySQL 常见占位符风格来演示:
?
这样可以把注意力放在:
- 字段怎么读
- tag 怎么读
- SQL 怎么拼
- 参数顺序怎么保持一致
如果后面换到其他数据库,例如 PostgreSQL,
就不一定还是 ?,而可能会变成:
$1, $2, $3
所以数据库切换并不总是"只换驱动"这么简单,
SQL 生成规则本身也可能随之变化。
10. 为什么说这还不等于完整 ORM
通过反射把结构体转成 SQL,
确实已经很像 ORM 的核心基础能力之一了。
但这距离完整 ORM 还差不少东西,例如:
- 表名映射规则
- 更复杂的条件拼接
- 查询结果扫描回结构体
- 不同数据库方言差异
- 批量操作
- 关联关系处理
- hook、事务、缓存等能力
所以这一节更准确的定位是:
- 用反射手写一个教学版 SQL 生成器
而不是:
- 完整复刻 GORM 这类 ORM 框架
11. 这一节里最容易写错的地方
教学版反射转 SQL 时,最常见的错误通常有这些:
- 没有先判断输入是不是结构体或结构体指针
- 传入指针后忘了
Elem() - 列顺序和参数顺序不一致
UPDATE时把主键也写进了SETDELETE/SELECT时没有稳定找到主键字段- 忽略自增主键,导致
INSERT把本来不该手动写入的字段也带进去了
这类问题里,
真正最关键的并不是"某个字符串有没有拼对",
而是反射读出来的信息有没有被正确分类和组织。
12. 阅读这类代码时容易困惑的几个点
这一节虽然最后落点是"反射转 SQL",
但真正容易把人卡住的,往往不是 SQL 本身,
而是 Go 里几个基础但不太直觉的细节。
12.1 为什么 UPDATE 示例里会用 *fieldMeta
教学代码里曾出现过这样的写法:
var pkField *fieldMeta
后面在遍历字段时,如果遇到主键字段,就把它保存下来。
这种写法的核心目的不是为了修改这个字段,
而是为了让"没找到主键"这件事可以直接用 nil 表示。
也就是说,这里的重点不是"必须用指针才能完成功能",
而是:
- 找到了主键:
pkField != nil - 没找到主键:
pkField == nil
如果不想用指针,也完全可以写成:
- 一个普通
fieldMeta变量 - 再配一个
found bool
这两种写法都成立。
只是指针版把"是否找到"的状态折叠进了 nil 判断里。
12.2 为什么结构体值不能直接和 nil 比较
fieldMeta 本身是一个 struct,属于值类型。
Go 里的普通值类型,例如:
intstringboolstruct
都不能直接和 nil 比较。
能和 nil 比较的,通常是这些类型:
- 指针
slicemapchanfuncinterface
所以:
var pkField *fieldMeta可以判断pkField == nilvar pkField fieldMeta不能判断pkField == nil
如果使用值类型版本,就需要额外加一个 found 或 ok 变量来表示"有没有找到"。
12.3 为什么 range 时有时不能直接拿遍历变量来取地址
这一点在切片遍历里很容易踩坑。
例如:
for _, field := range fields {
// field 是遍历变量
}
这里的 field 并不是切片中元素本身,
而是每次循环拿到的一个副本。
所以如果后面想保存"切片里原始元素的地址",
就不能写:
&field
因为这拿到的是遍历变量的地址。
这时通常要改用下标遍历:
for i := range fields {
pkField = &fields[i]
}
这样拿到的才是切片元素自己的地址。
不过,如果后面根本不需要地址,
只是想读取字段值,
那直接使用 for _, field := range fields 往往更简单。
12.4 fmt.Sprintf 不会自动帮你补空格
很多人第一次看拼 SQL 的代码时,会误以为:
fmt.Sprintf会自动处理空格- 或者 Go 会自动把 SQL 拼得更"好看"
实际上都不会。
例如:
fmt.Sprintf("%s = ?", field.column)
如果字段名是 name,结果就是:
name = ?
这里 = 两边的空格,
完全来自格式字符串 "%s = ?" 本身。
再比如:
strings.Join(setParts, ", ")
这里的分隔符写的是 ", ",
也就是"逗号 + 空格",
所以最后才会得到:
name = ?, age = ?, email = ?
因此,生成 SQL 时中间那些空格,
不是 Go 自动补的,
而是我们在模板字符串和 Join 分隔符里手动写进去的。
12.5 这一节更重要的是看懂流程,而不是强行默写
反射转 SQL 这一节,已经不是单纯的语法入门。
它本质上是在同时使用:
struct- tag
any- 反射
- 字符串拼接
- 参数组织
所以这一节更合理的学习目标通常不是:
- 从零默写完整版本
而是:
- 能看懂代码整体流程
- 知道每一步为什么存在
- 能解释
INSERT、UPDATE、DELETE、SELECT分别在拼什么
只要已经能够把流程说清楚,
这一节就算达到学习目的了。
后面随着对反射和结构体理解更深,再回头自己实现,会轻松很多。
13. 本节小结
- 通过反射读取结构体字段和 tag,可以实现一个教学版 SQL 生成器。
dbtag 常用来描述列名映射。- 额外的主键标记可以帮助区分普通字段和条件字段。
INSERT常见是收集列名、占位符和参数。UPDATE通常把普通字段放进SET,把主键字段放进WHERE。DELETE和SELECT的关键在于稳定找到条件字段。- SQL 字符串和参数切片分开返回,更接近真实数据库编程方式。
- MySQL 常见使用
?占位符,不同数据库的 SQL 风格并不完全一致。 - 反射转 SQL 是 ORM 的基础能力之一,但还不等于完整 ORM。
- 指针版"保存主键字段"和"值 +
found标记"这两种写法都成立,只是表达方式不同。 range变量默认是副本;只有需要原元素地址时,才需要回到下标写法。- SQL 中出现的空格和分隔符都来自我们自己写的模板,而不是
fmt.Sprintf自动补出来的。
二十七、网络编程(一):TCP
学完前面的语法、结构体、接口、协程、反射之后,
接下来开始进入 Go 里非常重要的一块内容:网络编程。
这一节先只看 TCP。
先把最核心的连接模型建立起来,
暂时不急着上 HTTP、WebSocket、RPC 这些更高层的东西。
1. 什么是 TCP
TCP 是一种面向连接的传输协议。
"面向连接"可以先简单理解成:
- 通信前,客户端和服务端要先建立连接
- 连接建立后,双方才能持续收发数据
- 通信结束后,再关闭连接
如果类比成打电话,
TCP 更像是:
- 先拨通
- 接通后开始说话
- 说完挂断
它和"发一封就走的短消息"式通信不太一样。
2. TCP 编程里最基本的两个角色
TCP 示例里通常有两个角色:
- 服务端
- 客户端
它们的职责不同。
服务端通常负责:
- 监听某个端口
- 等待别人连进来
- 接收请求
- 回写响应
客户端通常负责:
- 主动发起连接
- 发送数据
- 读取服务端返回结果
这和 Java、C#、Python 里的 socket 编程思路本质上是一样的,
只是 Go 的标准库 API 更直接一些。
3. 服务端为什么先 Listen
服务端第一步通常是:
listener, err := net.Listen("tcp", "127.0.0.1:9090")
这里可以拆成两部分理解:
"tcp":表示使用 TCP 协议"127.0.0.1:9090":表示监听本机 9090 端口
Listen 的含义不是"已经连上客户端",
而是:
- 在这个地址和端口上开始等待连接
也就是说,
服务端先把门打开,
但这时候还没有访客真正进门。
4. Accept 是什么
仅仅 Listen 还不够。
服务端还要真正接收客户端连接:
conn, err := listener.Accept()
Accept 可以理解成:
- 从已经监听的端口上,取出一个真正连进来的客户端连接
一旦 Accept 成功,
服务端就拿到了一个 conn。
后面的读写,都是围绕这个连接对象进行的。
所以服务端的大致顺序通常是:
ListenAcceptRead / WriteClose
5. 客户端为什么用 Dial
客户端和服务端不同,
客户端不是等待连接,
而是主动发起连接:
conn, err := net.Dial("tcp", "127.0.0.1:9090")
Dial 可以理解成"拨号"。
它会去尝试连接目标地址。
如果连接成功,
客户端也会拿到一个 conn。
注意这里有一个很重要的点:
- 服务端和客户端最后都拿到
conn
也就是说,
连接建立之后,
双方对"这条连接"的后续操作是很相似的:
- 都可以读
- 都可以写
6. net.Conn 可以把它当成什么
net.Conn 可以先把它理解成一个"网络连接对象",
也可以把它类比成一个"面向网络的流"。
这一点和之前学过的文件读写非常像:
- 文件对象可以读写字节流
- 网络连接对象也可以读写字节流
区别只是在于:
- 文件通常连的是磁盘
net.Conn连的是网络另一端
所以很多读写操作的思维方式,其实是相通的。
7. 为什么这里经常搭配 bufio.Reader
连接对象本身支持读取,
但很多时候直接裸读并不够方便。
例如我们想"按一行一行地读",
就很适合包一层:
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
这表示:
- 从连接里持续读取
- 直到遇到换行符
\n - 把这一整行作为字符串返回
这和前面输入输出章节里读控制台输入时使用 bufio.Reader,
思路其实是类似的。
也就是说,
bufio.Reader 并不关心底层一定是键盘、文件还是网络。
只要底层对象满足读取接口,
它就能帮我们做更方便的缓冲读取。
8. 为什么示例里消息后面要加 \n
如果服务端使用的是:
ReadString('\n')
那它就会一直读,直到遇到换行符为止。
所以客户端发送时通常会写成:
conn.Write([]byte(message + "\n"))
这样服务端才能知道:
- 这一条消息到这里就结束了
否则如果一直没有 \n,
按行读取就可能继续等下去。
这背后的本质是:
TCP 只负责传输字节流,
并不会天然帮我们划分"这一条消息到哪里结束"。
消息边界需要应用层自己约定。
9. 为什么要关闭连接
无论是客户端还是服务端,
通常都要记得在用完连接后关闭:
defer conn.Close()
关闭连接至少有几个直接意义:
- 释放系统资源
- 告诉对方"这边已经结束了"
- 避免连接长期挂着不释放
如果连接不关,
轻则程序行为变得不明确,
重则长期运行时把资源慢慢耗掉。
所以 Close() 在网络编程里不是一个可有可无的收尾动作,
而是连接生命周期的重要一环。
10. 这节示例为什么只处理一个连接
教学版 TCP 示例通常会先写成:
- 服务端只
Accept一次 - 只处理一个客户端
这样做不是因为真实项目只能这么写,
而是为了先把最小模型看清楚:
- 监听
- 建连
- 收发
- 关闭
等这个闭环清楚之后,
下一步才会自然过渡到:
- 循环
Accept - 每个连接交给 goroutine 处理
也就是说,
"并发处理多个客户端"是下一层问题,
不是 TCP 入门第一步就必须一起塞进来的内容。
11. 这一节最应该先记住什么
TCP 入门时,最重要的不是先背住所有 API 名字,
而是把这个模型记住:
服务端:
ListenAccept- 读取客户端消息
- 写回响应
- 关闭连接
客户端:
Dial- 发送消息
- 读取响应
- 关闭连接
只要这个顺序在脑子里是稳定的,
后面再看更复杂的 socket 代码时就不容易迷路。
12. TCP 常见应用场景
TCP 最典型的适用场景可以概括为一句话:
- 需要可靠传输的网络通信
这里的"可靠"主要体现在:
- 通信前先建立连接
- 数据按顺序到达
- 丢失的数据可以重传
因此,TCP 常见于这些场景:
-
Web 服务
例如 HTTP、HTTPS 底层都大量依赖 TCP。
-
数据库连接
例如 MySQL、PostgreSQL、Redis 等客户端与服务端之间的通信。
-
文件传输
文件内容要求完整,不能随便丢字节。
-
即时通信
例如很多聊天系统、消息推送系统的底层连接。
-
远程登录
例如 SSH 这类远程终端通信。
也就是说,
如果业务要求"数据不能乱、不能少",
通常就更适合优先考虑 TCP。
13. 这一节代码里最容易混淆的两个对象
在 TCP 示例里,
很多初学者最容易把这两个对象混在一起:
listenerconn
它们职责完全不同。
13.1 listener 是监听入口
例如:
listener, err := net.Listen("tcp", "127.0.0.1:9090")
这里得到的 listener,
表示服务端已经在某个端口上开始等待连接。
它更像一个"接线台"或"入口"。
它负责的事情是:
- 占住某个地址和端口
- 等待客户端连进来
13.2 conn 是已经建立好的具体连接
例如:
conn, err := listener.Accept()
或者客户端侧:
conn, err := net.Dial("tcp", "127.0.0.1:9090")
这里得到的 conn,
表示"某一条已经建立好的连接"。
真正的数据收发,都是围绕 conn 完成的。
所以可以把它们记成:
listener:负责等别人进来conn:负责和某个已经连上的对象通信
14. 服务端里的 for {} 到底是什么
Go 里的:
for {
...
}
就是一个无限循环。
它的作用等价于很多语言里的:
while (true) {
...
}
在 TCP 服务端里这样写的原因是:
- 服务端通常不只处理一条消息
- 它要持续读取客户端发来的内容
- 读一条,处理一条,再回一条
因此,示例里才会写成:
for {
line, err := reader.ReadString('\n')
...
}
这段逻辑的实际含义是:
- 只要连接还在,就持续读消息
- 如果读失败、客户端断开、或者收到退出指令,就结束循环
也就是说,
这不是"Go 特有的神秘语法",
本质上就是一个 while (true) 风格的持续处理循环。
15. 为什么服务端看起来一直在"卡住等消息"
这一点其实是网络编程里的正常现象。
例如:
conn, err := listener.Accept()
会阻塞等待客户端连接。
而:
line, err := reader.ReadString('\n')
又会阻塞等待客户端发来一整行消息。
所谓"阻塞",
可以先简单理解成:
- 代码会停在这里等
- 条件满足了才继续往下执行
所以 TCP 服务端经常看起来像在"等":
- 等客户端连接
- 等客户端发消息
- 等下一条消息
这正是网络服务端程序的常见工作方式。
16. 本节小结
- TCP 是面向连接的传输协议。
- TCP 编程里最基本的两个角色是服务端和客户端。
- 服务端通常先
Listen,再Accept。 - 客户端通常通过
Dial主动连接服务端。 - 连接建立后,双方都会拿到
net.Conn,都可以进行读写。 listener负责监听端口,conn负责具体连接上的通信。net.Conn可以理解成一个面向网络的流。bufio.Reader常用于按行读取网络数据。- 如果用
ReadString('\n'),发送方通常就要约定用换行符作为消息结束标记。 for {}在 Go 里就是无限循环,常用于持续处理连接上的消息。Accept和ReadString('\n')这类操作都会阻塞等待。Close()是连接生命周期的重要一环。- 教学版示例先只处理一个连接,更适合把 TCP 的最小闭环看清楚。
- TCP 常见于 Web、数据库、文件传输、聊天、远程登录等可靠传输场景。
二十八、网络编程(二):HTTP
在上一节 TCP 中,
已经看到了网络通信最底层的一个最小模型:
- 服务端监听端口
- 客户端主动连接
- 双方拿到连接后读写字节流
HTTP 则是在这个基础上,再往上抽象出一层更适合 Web 开发的通信规则。
1. HTTP 和 TCP 是什么关系
理解 HTTP 时,
最容易搞混的一点就是:
- HTTP 不是用来替代 TCP 的
更准确的关系是:
- TCP 负责可靠传输
- HTTP 负责约定请求和响应的格式
可以把它理解成:
- TCP 是运输通道
- HTTP 是通道里双方约定好的交流格式
因此,
当客户端访问一个普通 Web 服务时,
底层通常仍然是先通过 TCP 建立连接,
然后再在这条连接之上收发 HTTP 请求和响应。
2. HTTP 最核心的模型是什么
HTTP 最核心的模型可以压缩成一句话:
- 客户端发送请求
- 服务端返回响应
这和上一节 TCP 里的"双方直接对着连接读写字节"相比,
已经更具体了。
因为 HTTP 把通信内容组织成了更明确的结构。
请求里常见包含:
- 请求方法,例如
GET、POST - 请求路径,例如
/hello - 查询参数
- 请求头
- 请求体
响应里常见包含:
- 状态码,例如
200 OK - 响应头
- 响应体
这套规则一旦固定下来,
客户端和服务端的沟通成本就会大幅下降。
3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP
裸 TCP 的优点是更底层、更灵活。
但对常见 Web 接口开发来说,
自己处理消息边界、协议格式、状态码、路径分发,
成本会很高。
HTTP 则已经帮我们约定好了很多东西,例如:
- 请求方法
- 请求路径
- 状态码
- 请求头与响应头
- 请求体和响应体的组织方式
所以在做接口开发时,
通常不用自己重新发明一套通信规则。
直接基于 HTTP,就能更快进入业务处理本身。
4. Go 里 HTTP 服务端通常长什么样
Go 标准库里的 HTTP 服务端,
一般会围绕这几样东西展开:
- 路由
- 处理函数
- 服务对象
例如:
mux := http.NewServeMux()
mux.HandleFunc("/hello", helloHandler)
这里的 mux 可以理解成一个路由分发器。
它的作用是:
- 根据请求路径
- 把请求交给对应的处理函数
也就是说:
- 请求
/hello - 就走
helloHandler
5. 处理函数里的两个参数分别是什么
Go 标准库 HTTP 处理函数常见签名是:
func handler(w http.ResponseWriter, r *http.Request) {
}
这里的两个参数非常关键。
5.1 r *http.Request
r 代表客户端发来的请求。
常见可以从里面拿到:
- 请求方法
r.Method - 请求路径
r.URL.Path - 查询参数
r.URL.Query() - 请求体
r.Body
也就是说,
只要是"客户端发过来的东西",
大多都是从 r 里面取。
5.2 w http.ResponseWriter
w 代表服务端要写回给客户端的响应。
常见操作包括:
- 写响应体
w.Write(...) - 写状态码
w.WriteHeader(...) - 设置响应头
也就是说,
只要是"服务端回给客户端的东西",
大多都是通过 w 写出去。
所以处理函数本质上就在做三件事:
- 读取请求
- 处理逻辑
- 写回响应
6. 什么是路由
路由可以先简单理解成:
- 路径和处理逻辑的对应关系
例如:
/hello返回欢迎语/echo返回处理后的请求体/ping返回健康检查结果
如果没有路由,
服务端就很难根据不同 URL 路径执行不同逻辑。
所以在 HTTP 服务端里,
路由几乎是最基础的一层组织方式。
7. GET 和 POST 可以先怎么理解
初学 HTTP 时,
先不用把所有方法都背全。
先抓住最常见的两个:
GETPOST
GET 常用于:
- 获取资源
- 读取信息
- 通过 URL 查询参数传递简单数据
例如:
/hello?name=golang
POST 常用于:
- 提交数据
- 把内容放进请求体里发送给服务端
例如:
- 发送一段文本
- 提交表单
- 提交 JSON
当然这只是入门阶段的常见理解方式。
后面学 REST 风格接口时,还会接触更多语义化约定。
8. 为什么这里要区分查询参数和请求体
HTTP 里常见的输入位置至少有两种:
- URL 查询参数
- 请求体
例如:
GET /hello?name=golang
这里的 name=golang 是查询参数。
Go 里通常通过:
r.URL.Query().Get("name")
来读取。
而如果是 POST 请求体里的内容,
通常需要从:
r.Body
里读取。
所以这两者不要混成一件事:
- 查询参数在 URL 上
- 请求体在 Body 里
9. 客户端在 HTTP 里通常怎么发请求
Go 标准库已经把很多常用请求封装好了。
例如:
http.Get("http://127.0.0.1:8081/hello?name=golang")
和:
http.Post("http://127.0.0.1:8081/echo", "text/plain", strings.NewReader("hello http"))
对于当前学习阶段,可以先这样理解:
Get:发 GET 请求Post:发 POST 请求
请求发出去之后,
客户端会拿到一个响应对象 resp。
后面就可以从里面读取:
- 状态码
- 响应体
10. 为什么响应体和请求体都常常要记得关闭
在 Go 里,
无论是请求体 r.Body,
还是客户端收到的响应体 resp.Body,
通常都要在用完后关闭。
例如:
defer r.Body.Close()
defer resp.Body.Close()
这样做的意义和前面学过的文件关闭、连接关闭很类似:
- 释放资源
- 让底层生命周期更明确
因此,
读完 Body 后顺手关闭,
应该逐渐形成习惯。
11. 这一节最应该先记住什么
HTTP 入门时,
最重要的不是先背框架,
而是把这个最小模型记住:
服务端:
- 注册路由
- 写处理函数
- 启动 HTTP 服务
- 从请求里拿数据
- 把响应写回去
客户端:
- 发请求
- 拿到响应
- 读取状态码和响应体
只要这个骨架清楚,
后面切换到 Gin、Echo、Fiber 这类框架时,
你也能知道它们本质上是在对哪一层做封装。
12. 什么是状态码
HTTP 响应里除了响应体,
还有一个很重要的信息:状态码。
例如:
200 OK404 Not Found500 Internal Server Error
状态码可以先理解成:
- 服务端对这次请求处理结果的一个简短标记
例如:
200往往表示请求处理成功404往往表示路径不存在500往往表示服务端内部出错
在 Go 标准库里,
如果想手动写状态码,
通常会使用:
w.WriteHeader(http.StatusOK)
或者:
w.WriteHeader(http.StatusInternalServerError)
然后客户端就可以从:
resp.Status
或:
resp.StatusCode
中读取这次响应的状态信息。
13. 为什么处理函数里经常会写默认值分支
HTTP 请求里的输入,
并不一定总是完整的。
例如:
GET /hello?name=golang
这里可能有 name 参数,
也可能没有。
所以处理函数里经常会写这种逻辑:
name := r.URL.Query().Get("name")
if name == "" {
name = "guest"
}
这背后的思路其实很普通:
- 先尝试读取请求参数
- 如果没有,就使用默认值
这种写法在真实接口开发里非常常见。
因为客户端发来的请求,不一定总是完全按预期组织好的。
14. 新增路由其实就是"再注册一个处理函数"
很多初学者第一次看 HTTP 服务端时,
会觉得"新增一个接口"像是一件很大的事。
但在最基本的标准库模型里,它其实很直接:
- 再定义一个处理函数
- 再注册一个路径
例如:
mux.HandleFunc("/ping", pingHandler)
这就表示:
- 当客户端请求
/ping - 就交给
pingHandler去处理
因此,
在最小 HTTP 示例里,
一个"新接口"本质上往往就是:
- 多一个路由
- 多一段处理逻辑
15. net/http 和 Gin 是什么关系
这一点在后面学框架时非常重要。
可以把层级关系先理解成这样:
- TCP
- HTTP
- Go 标准库
net/http - Gin 这类 Web 框架
也就是说:
- TCP 负责底层可靠传输
- HTTP 负责请求和响应规则
net/http提供 Go 里的标准 HTTP 编程接口- Gin 再在
net/http这一层之上做进一步封装和简化
所以 Gin 并不是"另起炉灶重新发明 HTTP",
而更像是:
- 帮你把路由写得更顺
- 帮你把参数读取做得更方便
- 帮你把 JSON 响应、中间件、分组路由这些常见能力包装得更好用
因此,
现在先学 net/http 是很有价值的。
因为以后学 Gin 时,你不会只是背框架 API,
而会知道它到底帮你省掉了哪些原始步骤。
16. 这一节更重要的是掌握 HTTP 的最小骨架
HTTP 这一节和前面的反射转 SQL 有点类似:
- 重点不一定是把所有练习都写花哨
- 更重要的是先把主骨架看清楚
也就是:
服务端:
- 注册路由
- 启动服务
- 读取请求
- 写回响应
客户端:
- 发请求
- 拿响应
- 读状态码
- 读响应体
只要这个骨架是稳定的,
后面无论是继续写标准库 HTTP,
还是切换到 Gin,
都会顺很多。
17. 本节小结
- HTTP 建立在 TCP 之上。
- TCP 负责可靠传输,HTTP 负责约定请求和响应格式。
- HTTP 的核心模型是"客户端发请求,服务端返回响应"。
- Go 标准库里常用
ServeMux做路径分发。 - HTTP 处理函数通常接收
http.ResponseWriter和*http.Request两个参数。 *http.Request主要用来读取客户端请求信息。http.ResponseWriter主要用来写回服务端响应。GET常见用于获取数据,POST常见用于提交数据。- 查询参数通常从 URL 中读取,请求体通常从
Body中读取。 - 客户端发起请求后,会拿到响应对象,再读取状态码和响应体。
- 请求体和响应体在使用完成后通常都要关闭。
- 状态码用于表达服务端对本次请求的处理结果。
- 处理函数里经常需要对缺失参数设置默认值。
- 新增一个 HTTP 接口,在最基本模型里通常就是"新增路由 + 新增处理函数"。
- Gin 这类框架本质上是在
net/http之上继续做封装和简化。
二十九、Go 部署
学到这里,
前面已经写过命令行程序、文件读写、TCP、HTTP 服务。
接下来就可以开始看一个很现实的问题:
- Go 程序写完之后,怎么交付和运行
这就是"部署"要解决的核心。
1. 什么叫部署
部署可以先简单理解成:
- 把程序从"本地开发状态"
- 变成"目标环境中可运行状态"
这通常至少包含几件事:
- 把代码构建成可执行文件
- 准备程序运行所需配置
- 启动程序
- 确认程序是否正常对外提供服务
也就是说,
部署关注的重点已经不只是"代码能不能编译通过",
而是:
- 这份程序能不能在别的环境稳定跑起来
2. go run 和 go build 在部署里的角色不同
学习 Go 时最常见的命令之一就是:
go run ./learn/xxx
它的特点是:
- 临时编译
- 立即运行
所以它更适合:
- 学习
- 调试
- 本地快速验证
而部署时更常见的是:
go build -o app.exe ./learn/xxx
它会生成一个真正的可执行文件。
后面可以把这个文件拷贝到目标机器上运行。
因此可以先这样理解:
go run更偏开发阶段go build更偏交付阶段
3. 为什么 Go 程序很适合做单文件交付
Go 的一个很常见优势就是:
- 可以直接构建出可执行文件
这意味着很多时候部署一个小型 Go 服务时,
交付形式会比较直接:
- 编译出 exe 或二进制文件
- 带上必要配置
- 运行它
和某些依赖复杂运行时环境的语言相比,
Go 程序在交付和迁移时通常更轻一些。
当然,
真实项目里仍然可能配合:
- 配置文件
- 环境变量
- 日志目录
- 数据目录
- 容器镜像
但"有一个可直接执行的二进制文件"这件事,
本身就是 Go 部署体验里很重要的一点。
4. 为什么部署时常把配置放进环境变量
程序部署到不同环境时,
常常并不是所有配置都一样。
例如:
- 本地开发环境端口是
8082 - 测试环境端口可能是
9000 - 生产环境端口可能又不同
再比如:
- 服务名不同
- 版本号不同
- 数据库地址不同
如果把这些值全部写死在代码里,
每换一个环境都改代码,就会很麻烦。
因此部署时很常见的一种方式是:
- 代码里写默认值
- 运行时通过环境变量覆盖
例如:
os.Getenv("PORT")
这种做法的核心好处是:
- 同一份代码可以部署到多个环境
- 配置和代码分离
5. 为什么很多服务会提供 /health
部署完成后,
第一件经常要确认的事就是:
- 服务现在活着吗
- 服务能正常响应吗
所以很多 Web 服务都会提供一个很轻量的接口:
/health
它通常不做复杂业务,
只是快速返回一个简单结果,例如:
ok
它的价值在于:
- 方便人工排查
- 方便监控系统探测
- 方便负载均衡或编排系统判断服务状态
6. 为什么很多服务还会提供 /version
另一个常见问题是:
- 线上当前运行的到底是哪一版程序
这时如果服务提供一个:
/version
就很方便快速确认:
- 新版本有没有成功发布
- 多台机器跑的是不是同一个版本
所以 /version 这类接口虽然简单,
但在排查部署问题时很有用。
7. 什么叫优雅关闭
如果一个服务正在处理请求,
这时程序被直接强行结束,
就可能出现一些问题:
- 请求处理到一半被打断
- 日志还没写完
- 连接还没正常收尾
因此,部署场景里经常会提到一个概念:
- 优雅关闭
Go 标准库里常见写法是:
server.Shutdown(ctx)
它的思路不是"立刻暴力断电",
而是:
- 通知服务准备停止
- 给它一点时间,把正在处理的事情收尾
这对线上服务尤其重要。
8. 这节示例为什么还是用 HTTP 服务来演示部署
"部署"本身不是一种语法。
它更像一组工程实践问题。
所以最适合拿来演示部署的,
往往是一个可运行的服务程序。
而 HTTP 服务正好具备这些特点:
- 容易启动
- 容易访问
- 容易观察端口、接口、状态码、响应内容
因此用一个最小 HTTP 服务来讲部署,
比继续拿纯语法示例来讲会自然很多。
9. 这一节最应该先记住什么
Go 部署入门时,
先不用一下子把 Docker、systemd、CI/CD、云平台全塞进来。
先把最小骨架记住:
- 写好服务程序
- 用
go build生成可执行文件 - 让程序通过环境变量读取配置
- 启动服务并监听端口
- 提供最基本的健康检查接口
- 在停止服务时尽量优雅关闭
只要这个骨架是清楚的,
后面再接容器化、进程托管、自动发布,
都只是往这套基础上继续加层次。
10. 代码和配置为什么要分开
这是部署里非常核心的一条原则。
如果把端口、地址、账号等全部写死在代码里,
那每换一个环境就可能要改代码、重新提交、重新构建。
而如果把这些值放到环境变量或配置文件里,
就可以做到:
- 代码尽量保持不变
- 不同环境只改配置
这样会带来几个直接好处:
- 发布流程更稳定
- 环境切换更方便
- 降低因为手动改代码带来的风险
11. 本节小结
- 部署的核心是让程序在目标环境中可运行并可维护。
go run更适合开发验证,go build更适合交付可执行文件。- Go 程序常见部署形态之一是构建出单独的可执行文件。
- 环境变量常用于让同一份程序适配不同运行环境。
/health常用于健康检查,/version常用于确认当前程序版本。- 优雅关闭可以减少请求中断和资源收尾不完整的问题。
- 代码与配置分离,是部署和运维里非常重要的一条原则。
[Golang 学习笔记](#Golang 学习笔记)
[1. 显式指定类型](#1. 显式指定类型)
[2. 省略类型,让编译器推断](#2. 省略类型,让编译器推断)
[3. 只声明,不赋值](#3. 只声明,不赋值)
[4. 短变量声明](#4. 短变量声明)
[5. 一次声明多个变量](#5. 一次声明多个变量)
[6. 变量可以重新赋值](#6. 变量可以重新赋值)
[7. 包级变量](#7. 包级变量)
[8. 本节小结](#8. 本节小结)
[1. 基本输出](#1. 基本输出)
[2. 格式化输出](#2. 格式化输出)
[3. 生成字符串](#3. 生成字符串)
[4. 基本输入](#4. 基本输入)
[5. 按空白分隔读取](#5. 按空白分隔读取)
[6. 读取一整行](#6. 读取一整行)
[7. 输入代码中的常见概念](#7. 输入代码中的常见概念)
[8. Scan 和 Fscan 的区别](#8. Scan 和 Fscan 的区别)
[9. ReadString 的作用](#9. ReadString 的作用)
[10. 多个输入函数共用同一个 Reader](#10. 多个输入函数共用同一个 Reader)
[11. 本节小结](#11. 本节小结)
[1. bool](#1. bool)
[2. 整数类型](#2. 整数类型)
[3. byte](#3. byte)
[4. rune](#4. rune)
[5. 浮点数类型](#5. 浮点数类型)
[6. 复数类型](#6. 复数类型)
[7. string](#7. string)
[8. 零值](#8. 零值)
[9. 显式类型转换](#9. 显式类型转换)
[10. Go 没有包装类型和装箱拆箱](#10. Go 没有包装类型和装箱拆箱)
[11. 本节小结](#11. 本节小结)
[1. 数组](#1. 数组)
[2. 数组长度属于类型](#2. 数组长度属于类型)
[3. 数组遍历](#3. 数组遍历)
[4. 切片](#4. 切片)
[5. len 和 cap](#5. len 和 cap)
[6. append](#6. append)
[7. make 创建切片](#7. make 创建切片)
[8. 切片表达式](#8. 切片表达式)
[9. 切片共享底层数组](#9. 切片共享底层数组)
[10. nil 切片和空切片](#10. nil 切片和空切片)
[11. 数组和切片的选择](#11. 数组和切片的选择)
[12. 本节小结](#12. 本节小结)
[1. map 的定义与初始化](#1. map 的定义与初始化)
[2. 读取 map](#2. 读取 map)
[3. 判断 key 是否存在](#3. 判断 key 是否存在)
[4. 新增、更新、删除](#4. 新增、更新、删除)
[5. 遍历 map](#5. 遍历 map)
[6. nil map 和 make](#6. nil map 和 make)
[7. 本节小结](#7. 本节小结)
[六、if 条件语句](#六、if 条件语句)
[1. 基本 if](#1. 基本 if)
[2. if else](#2. if else)
[3. if else if else](#3. if else if else)
[4. if 初始化语句](#4. if 初始化语句)
[5. 条件中的逻辑运算符](#5. 条件中的逻辑运算符)
[6. if 中变量的作用域](#6. if 中变量的作用域)
[7. Go 的 if 语法特点](#7. Go 的 if 语法特点)
[8. 本节小结](#8. 本节小结)
[七、switch 分支语句](#七、switch 分支语句)
[1. 基本 switch](#1. 基本 switch)
[2. 一个 case 匹配多个值](#2. 一个 case 匹配多个值)
[3. 无表达式 switch](#3. 无表达式 switch)
[4. switch 初始化语句](#4. switch 初始化语句)
[5. break 与 fallthrough](#5. break 与 fallthrough)
[6. switch 与 if 的选择](#6. switch 与 if 的选择)
[7. 典型业务写法](#7. 典型业务写法)
[月份天数(分组 case)](#月份天数(分组 case))
[8. 本节小结](#8. 本节小结)
[八、for 循环](#八、for 循环)
[1. 三段式 for](#1. 三段式 for)
[2. 把 for 当作 while](#2. 把 for 当作 while)
[3. 无限循环](#3. 无限循环)
[4. break 与 continue](#4. break 与 continue)
[5. 嵌套循环](#5. 嵌套循环)
[6. for range](#6. for range)
[7. 常见边界问题](#7. 常见边界问题)
[8. 本节小结](#8. 本节小结)
[1. 函数定义](#1. 函数定义)
[2. 参数](#2. 参数)
[3. 返回值](#3. 返回值)
[4. 可变参数](#4. 可变参数)
[5. 函数是一等值](#5. 函数是一等值)
[6. 函数拆分建议](#6. 函数拆分建议)
[7. 本节小结](#7. 本节小结)
[1. 基本类型:典型值传递](#1. 基本类型:典型值传递)
[2. 指针参数:通过地址间接修改](#2. 指针参数:通过地址间接修改)
[3. 切片参数:看起来像"引用"](#3. 切片参数:看起来像“引用”)
[4. map 参数:修改可见](#4. map 参数:修改可见)
[5. struct:值传参与指针传参的差异](#5. struct:值传参与指针传参的差异)
[6. 实战判断规则](#6. 实战判断规则)
[7. 本节小结](#7. 本节小结)
[十一、init 函数和 defer 语句](#十一、init 函数和 defer 语句)
[1. init 函数是什么](#1. init 函数是什么)
[2. 初始化执行顺序](#2. 初始化执行顺序)
[3. 多个 init](#3. 多个 init)
[4. defer 基本语义](#4. defer 基本语义)
[5. defer 的执行顺序(LIFO)](#5. defer 的执行顺序(LIFO))
[6. defer 参数求值时机](#6. defer 参数求值时机)
[7. 常见用途](#7. 常见用途)
[8. 本节小结](#8. 本节小结)
[1. 结构体定义](#1. 结构体定义)
[2. 结构体初始化](#2. 结构体初始化)
[3. 字段访问](#3. 字段访问)
[4. 匿名结构体](#4. 匿名结构体)
[5. Go 没有内建 getter / setter](#5. Go 没有内建 getter / setter)
[6. 方法与接收者](#6. 方法与接收者)
[7. 结构体指针](#7. 结构体指针)
[8. 结构体参数传递](#8. 结构体参数传递)
[9. 结构体比较](#9. 结构体比较)
[10. 使用场景](#10. 使用场景)
[11. 本节小结](#11. 本节小结)
[十三、结构体嵌入、结构体指针与 tag](#十三、结构体嵌入、结构体指针与 tag)
[1. Go 没有传统继承](#1. Go 没有传统继承)
[2. 结构体嵌入(embedding)](#2. 结构体嵌入(embedding))
[3. 字段提升](#3. 字段提升)
[4. 结构体指针](#4. 结构体指针)
[5. 什么是结构体 tag](#5. 什么是结构体 tag)
[6. tag 的常见用途](#6. tag 的常见用途)
[7. 读取 tag](#7. 读取 tag)
[8. 本节小结](#8. 本节小结)
[1. 自定义类型](#1. 自定义类型)
[2. 类型别名](#2. 类型别名)
[3. 两者的根本区别](#3. 两者的根本区别)
[4. 为什么要自定义类型](#4. 为什么要自定义类型)
[5. 为什么要类型别名](#5. 为什么要类型别名)
[6. 使用时的判断思路](#6. 使用时的判断思路)
[7. 本节小结](#7. 本节小结)
[1. 接口定义](#1. 接口定义)
[2. 隐式实现](#2. 隐式实现)
[3. 接口变量](#3. 接口变量)
[4. 接口作为参数](#4. 接口作为参数)
[5. any 与空接口](#5. any 与空接口)
[6. 类型断言](#6. 类型断言)
[7. 接口的价值](#7. 接口的价值)
[8. 本节小结](#8. 本节小结)
[1. 启动 goroutine](#1. 启动 goroutine)
[2. main 结束会带走其他 goroutine](#2. main 结束会带走其他 goroutine)
[3. 用 Sleep 暂时等待](#3. 用 Sleep 暂时等待)
[4. goroutine 传参](#4. goroutine 传参)
[5. 使用 WaitGroup](#5. 使用 WaitGroup)
[6. 输出顺序不一定固定](#6. 输出顺序不一定固定)
[7. Sleep 和 WaitGroup 的区别](#7. Sleep 和 WaitGroup 的区别)
[8. 本节小结](#8. 本节小结)
[1. 创建 channel](#1. 创建 channel)
[2. 发送和接收](#2. 发送和接收)
[3. 为什么 channel 常和 goroutine 一起出现](#3. 为什么 channel 常和 goroutine 一起出现)
[4. 无缓冲 channel 的阻塞特性](#4. 无缓冲 channel 的阻塞特性)
[5. 有缓冲 channel](#5. 有缓冲 channel)
[6. close 和 range](#6. close 和 range)
[7. 单向 channel](#7. 单向 channel)
[8. close 的边界规则](#8. close 的边界规则)
[9. channel 和 WaitGroup 的分工不同](#9. channel 和 WaitGroup 的分工不同)
[10. 常见风险:死锁](#10. 常见风险:死锁)
[11. 本节小结](#11. 本节小结)
[十八、select 与协程超时处理](#十八、select 与协程超时处理)
[1. select 是干什么的](#1. select 是干什么的)
[2. select 和 switch 的区别](#2. select 和 switch 的区别)
[3. 最基础的 select 接收](#3. 最基础的 select 接收)
[4. 同时监听多个 channel](#4. 同时监听多个 channel)
[5. default 分支](#5. default 分支)
[6. 为什么超时处理常和 select 一起用](#6. 为什么超时处理常和 select 一起用)
[7. 使用 time.After 做超时控制](#7. 使用 time.After 做超时控制)
[8. 超时控制的是等待方,不一定是任务本身](#8. 超时控制的是等待方,不一定是任务本身)
[9. select 只是选择"当前可执行"的分支](#9. select 只是选择“当前可执行”的分支)
[10. select 和 channel 的关系](#10. select 和 channel 的关系)
[11. 超时现象里为什么有时会"刚好收到结果"](#11. 超时现象里为什么有时会“刚好收到结果”)
[12. 本节小结](#12. 本节小结)
[十九、线程安全与 sync.Map](#十九、线程安全与 sync.Map)
[1. 为什么会有线程安全问题](#1. 为什么会有线程安全问题)
[2. 普通 map 默认不是并发安全的](#2. 普通 map 默认不是并发安全的)
[3. 用 sync.Mutex 保护共享数据](#3. 用 sync.Mutex 保护共享数据)
[4. Mutex 保护的不是"变量名",而是那段共享访问过程](#4. Mutex 保护的不是“变量名”,而是那段共享访问过程)
[5. sync.RWMutex 是什么](#5. sync.RWMutex 是什么)
[6. 为什么带锁结构体的方法通常用指针接收者](#6. 为什么带锁结构体的方法通常用指针接收者)
[7. 为什么示例里经常把 map 和锁封装进结构体](#7. 为什么示例里经常把 map 和锁封装进结构体)
[8. sync.Map 的基本定位](#8. sync.Map 的基本定位)
[9. LoadOrStore 是什么意思](#9. LoadOrStore 是什么意思)
[10. sync.Map 的 Range 和普通 map 的 for range 不一样](#10. sync.Map 的 Range 和普通 map 的 for range 不一样)
[11. 为什么会看到 concurrent map writes](#11. 为什么会看到 concurrent map writes)
[12. 是不是以后都该直接用 sync.Map](#12. 是不是以后都该直接用 sync.Map)
[13. 如何判断自己是否遇到了并发安全问题](#13. 如何判断自己是否遇到了并发安全问题)
[14. 本节小结](#14. 本节小结)
[二十、错误处理与 panic/recover](#二十、错误处理与 panic/recover)
[1. Go 里的 error 是什么](#1. Go 里的 error 是什么)
[2. 为什么 Go 常写 result, err](#2. 为什么 Go 常写 result, err)
[3. nil 表示没有错误](#3. nil 表示没有错误)
[4. 使用 errors.New 创建简单错误](#4. 使用 errors.New 创建简单错误)
[5. 使用 fmt.Errorf 补充上下文](#5. 使用 fmt.Errorf 补充上下文)
[6. 错误包装和 %w](#6. 错误包装和 %w)
[7. errors.Is 的作用](#7. errors.Is 的作用)
[8. 自定义业务错误常见怎么做](#8. 自定义业务错误常见怎么做)
[9. panic 是什么](#9. panic 是什么)
[10. recover 是怎么用的](#10. recover 是怎么用的)
[11. 把函数当参数传进去是什么意思](#11. 把函数当参数传进去是什么意思)
[12. 这种包装写法为什么有点像切面思路](#12. 这种包装写法为什么有点像切面思路)
[13. panic 和 error 的分工](#13. panic 和 error 的分工)
[14. recover 不会自动处理所有问题](#14. recover 不会自动处理所有问题)
[15. 错误处理为什么强调显式判断](#15. 错误处理为什么强调显式判断)
[16. 本节小结](#16. 本节小结)
[1. 什么是泛型](#1. 什么是泛型)
[2. 泛型函数长什么样](#2. 泛型函数长什么样)
[3. any 在泛型里是什么意思](#3. any 在泛型里是什么意思)
[4. 为什么泛型比 any + 类型断言更自然](#4. 为什么泛型比 any + 类型断言更自然)
[5. 泛型里参数和返回值是不是都要写 T](#5. 泛型里参数和返回值是不是都要写 T)
[6. 泛型结构体是什么](#6. 泛型结构体是什么)
[7. 泛型很适合做通用返回结构](#7. 泛型很适合做通用返回结构)
[8. 类型约束是什么](#8. 类型约束是什么)
[9. 约束接口和普通接口有什么不同](#9. 约束接口和普通接口有什么不同)
[10. 波浪线 ~ 是什么意思](#10. 波浪线 ~ 是什么意思)
[11. max 这类函数为什么需要可比较约束](#11. max 这类函数为什么需要可比较约束)
[12. 类型推断是什么](#12. 类型推断是什么)
[13. 泛型是不是意味着以后都该泛型化](#13. 泛型是不是意味着以后都该泛型化)
[14. 本节小结](#14. 本节小结)
[1. 最常见的整文件读取:os.ReadFile](#1. 最常见的整文件读取:os.ReadFile)
[2. 为什么读出来是 \[\]byte](#2. 为什么读出来是 []byte)
[3. 文件读取为什么必须先判断 err](#3. 文件读取为什么必须先判断 err)
[4. 获取文件内容长度](#4. 获取文件内容长度)
[5. 使用 os.Open 打开文件](#5. 使用 os.Open 打开文件)
[6. 为什么打开文件后要 Close](#6. 为什么打开文件后要 Close)
[7. 逐行读取:bufio.Scanner](#7. 逐行读取:bufio.Scanner)
[8. 逐行读取后为什么还要检查 scanner.Err](#8. 逐行读取后为什么还要检查 scanner.Err)
[9. 一次读完整个文件 vs 逐行读取](#9. 一次读完整个文件 vs 逐行读取)
[10. 文件不存在时为什么会直接报错](#10. 文件不存在时为什么会直接报错)
[11. 为什么示例里的路径是从 learn 开始](#11. 为什么示例里的路径是从 learn 开始)
[12. 文件路径和 package main 有什么关系](#12. 文件路径和 package main 有什么关系)
[13. 为什么同样是相对路径,有时写 sample.txt 也行](#13. 为什么同样是相对路径,有时写 sample.txt 也行)
[14. 为什么第一行前面会多出一个奇怪字符](#14. 为什么第一行前面会多出一个奇怪字符)
[15. 空文件读取会怎么样](#15. 空文件读取会怎么样)
[16. 本节小结](#16. 本节小结)
[1. 最直接的写法:os.WriteFile](#1. 最直接的写法:os.WriteFile)
[2. 为什么写入内容常常还是 \[\]byte](#2. 为什么写入内容常常还是 []byte)
[3. 覆盖写入是什么意思](#3. 覆盖写入是什么意思)
[4. 为什么写文件也必须判断 err](#4. 为什么写文件也必须判断 err)
[5. 追加写入:os.OpenFile](#5. 追加写入:os.OpenFile)
[6. OpenFile 和 WriteFile 的使用区别](#6. OpenFile 和 WriteFile 的使用区别)
[7. 写入多行文本时要自己处理换行](#7. 写入多行文本时要自己处理换行)
[8. 使用 bufio.Writer 做带缓冲写入](#8. 使用 bufio.Writer 做带缓冲写入)
[9. 为什么用了 Writer 还要 Flush](#9. 为什么用了 Writer 还要 Flush)
[10. 打开文件写入后为什么也要 Close](#10. 打开文件写入后为什么也要 Close)
[11. 文件写入路径和读取路径的规则一样](#11. 文件写入路径和读取路径的规则一样)
[12. 一次性写入、追加写入、缓冲写入分别适合什么](#12. 一次性写入、追加写入、缓冲写入分别适合什么)
[13. 本节小结](#13. 本节小结)
[1. Go 的单元测试主要依赖 testing 包](#1. Go 的单元测试主要依赖 testing 包)
[2. 测试文件为什么通常要写成 _test.go](#2. 测试文件为什么通常要写成 _test.go)
[3. 最基础的测试函数长什么样](#3. 最基础的测试函数长什么样)
[4. got 和 want 这种写法为什么很常见](#4. got 和 want 这种写法为什么很常见)
[5. 运行单元测试通常不用 go run,而是 go test](#5. 运行单元测试通常不用 go run,而是 go test)
[6. 为什么说单元测试更适合测小范围逻辑](#6. 为什么说单元测试更适合测小范围逻辑)
[7. 表驱动测试为什么在 Go 里很常见](#7. 表驱动测试为什么在 Go 里很常见)
[8. t.Run 的作用是什么](#8. t.Run 的作用是什么)
[9. 测试 error 场景时应该怎么想](#9. 测试 error 场景时应该怎么想)
[10. t.Errorf、t.Fatal、t.Fatalf 有什么区别](#10. t.Errorf、t.Fatal、t.Fatalf 有什么区别)
[11. 覆盖率是什么意思](#11. 覆盖率是什么意思)
[12. 为什么覆盖率有时不是 100%](#12. 为什么覆盖率有时不是 100%)
[13. 哪些函数更适合先写单元测试](#13. 哪些函数更适合先写单元测试)
[14. 本节小结](#14. 本节小结)
[1. 反射最常从 TypeOf 和 ValueOf 开始](#1. 反射最常从 TypeOf 和 ValueOf 开始)
[2. Type 和 Kind 不是一回事](#2. Type 和 Kind 不是一回事)
[3. ValueOf 拿到的是反射值,不是普通值本身](#3. ValueOf 拿到的是反射值,不是普通值本身)
[4. Interface() 的作用是什么](#4. Interface() 的作用是什么)
[5. 为什么直接传普通值时通常改不了](#5. 为什么直接传普通值时通常改不了)
[6. CanSet() 到底在判断什么](#6. CanSet() 到底在判断什么)
[7. 为什么改值时通常要传指针](#7. 为什么改值时通常要传指针)
[8. Elem() 是做什么的](#8. Elem() 是做什么的)
[9. 修改基础类型时常见的写法](#9. 修改基础类型时常见的写法)
[10. 结构体字段也可以通过反射修改](#10. 结构体字段也可以通过反射修改)
[11. 反射修改值这件事,本质上还是绕不开指针和可修改性](#11. 反射修改值这件事,本质上还是绕不开指针和可修改性)
[12. 什么时候反射容易出问题](#12. 什么时候反射容易出问题)
[13. 本节小结](#13. 本节小结)
[二十六、通过反射实现转 SQL(教学版)](#二十六、通过反射实现转 SQL(教学版))
[1. 反射转 SQL 的核心思路是什么](#1. 反射转 SQL 的核心思路是什么)
[2. 为什么这件事常常和 tag 一起出现](#2. 为什么这件事常常和 tag 一起出现)
[3. 为什么还要额外区分主键字段](#3. 为什么还要额外区分主键字段)
[4. INSERT SQL 一般怎么拼](#4. INSERT SQL 一般怎么拼)
[5. UPDATE SQL 和 INSERT 的区别](#5. UPDATE SQL 和 INSERT 的区别)
[6. DELETE SQL 的重点其实是条件来源](#6. DELETE SQL 的重点其实是条件来源)
[7. SELECT SQL 为什么也可以复用同一套反射信息](#7. SELECT SQL 为什么也可以复用同一套反射信息)
[8. 为什么参数通常要单独放进切片](#8. 为什么参数通常要单独放进切片)
[9. 为什么这里先按 MySQL 风格来写](#9. 为什么这里先按 MySQL 风格来写)
[10. 为什么说这还不等于完整 ORM](#10. 为什么说这还不等于完整 ORM)
[11. 这一节里最容易写错的地方](#11. 这一节里最容易写错的地方)
[12. 阅读这类代码时容易困惑的几个点](#12. 阅读这类代码时容易困惑的几个点)
[12.1 为什么 UPDATE 示例里会用 *fieldMeta](#12.1 为什么 UPDATE 示例里会用 *fieldMeta)
[12.2 为什么结构体值不能直接和 nil 比较](#12.2 为什么结构体值不能直接和 nil 比较)
[12.3 为什么 range 时有时不能直接拿遍历变量来取地址](#12.3 为什么 range 时有时不能直接拿遍历变量来取地址)
[12.4 fmt.Sprintf 不会自动帮你补空格](#12.4 fmt.Sprintf 不会自动帮你补空格)
[12.5 这一节更重要的是看懂流程,而不是强行默写](#12.5 这一节更重要的是看懂流程,而不是强行默写)
[13. 本节小结](#13. 本节小结)
[1. 什么是 TCP](#1. 什么是 TCP)
[2. TCP 编程里最基本的两个角色](#2. TCP 编程里最基本的两个角色)
[3. 服务端为什么先 Listen](#3. 服务端为什么先 Listen)
[4. Accept 是什么](#4. Accept 是什么)
[5. 客户端为什么用 Dial](#5. 客户端为什么用 Dial)
[6. net.Conn 可以把它当成什么](#6. net.Conn 可以把它当成什么)
[7. 为什么这里经常搭配 bufio.Reader](#7. 为什么这里经常搭配 bufio.Reader)
[8. 为什么示例里消息后面要加 \n](#8. 为什么示例里消息后面要加 \n)
[9. 为什么要关闭连接](#9. 为什么要关闭连接)
[10. 这节示例为什么只处理一个连接](#10. 这节示例为什么只处理一个连接)
[11. 这一节最应该先记住什么](#11. 这一节最应该先记住什么)
[12. TCP 常见应用场景](#12. TCP 常见应用场景)
[13. 这一节代码里最容易混淆的两个对象](#13. 这一节代码里最容易混淆的两个对象)
[13.1 listener 是监听入口](#13.1 listener 是监听入口)
[13.2 conn 是已经建立好的具体连接](#13.2 conn 是已经建立好的具体连接)
[14. 服务端里的 for {} 到底是什么](#14. 服务端里的 for {} 到底是什么)
[15. 为什么服务端看起来一直在"卡住等消息"](#15. 为什么服务端看起来一直在“卡住等消息”)
[16. 本节小结](#16. 本节小结)
[1. HTTP 和 TCP 是什么关系](#1. HTTP 和 TCP 是什么关系)
[2. HTTP 最核心的模型是什么](#2. HTTP 最核心的模型是什么)
[3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP](#3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP)
[4. Go 里 HTTP 服务端通常长什么样](#4. Go 里 HTTP 服务端通常长什么样)
[5. 处理函数里的两个参数分别是什么](#5. 处理函数里的两个参数分别是什么)
[5.1 r *http.Request](#5.1 r *http.Request)
[5.2 w http.ResponseWriter](#5.2 w http.ResponseWriter)
[6. 什么是路由](#6. 什么是路由)
[7. GET 和 POST 可以先怎么理解](#7. GET 和 POST 可以先怎么理解)
[8. 为什么这里要区分查询参数和请求体](#8. 为什么这里要区分查询参数和请求体)
[9. 客户端在 HTTP 里通常怎么发请求](#9. 客户端在 HTTP 里通常怎么发请求)
[10. 为什么响应体和请求体都常常要记得关闭](#10. 为什么响应体和请求体都常常要记得关闭)
[11. 这一节最应该先记住什么](#11. 这一节最应该先记住什么)
[12. 什么是状态码](#12. 什么是状态码)
[13. 为什么处理函数里经常会写默认值分支](#13. 为什么处理函数里经常会写默认值分支)
[14. 新增路由其实就是"再注册一个处理函数"](#14. 新增路由其实就是“再注册一个处理函数”)
[15. net/http 和 Gin 是什么关系](#15. net/http 和 Gin 是什么关系)
[16. 这一节更重要的是掌握 HTTP 的最小骨架](#16. 这一节更重要的是掌握 HTTP 的最小骨架)
[17. 本节小结](#17. 本节小结)
[二十九、Go 部署](#二十九、Go 部署)
[1. 什么叫部署](#1. 什么叫部署)
[2. go run 和 go build 在部署里的角色不同](#2. go run 和 go build 在部署里的角色不同)
[3. 为什么 Go 程序很适合做单文件交付](#3. 为什么 Go 程序很适合做单文件交付)
[4. 为什么部署时常把配置放进环境变量](#4. 为什么部署时常把配置放进环境变量)
[5. 为什么很多服务会提供 /health](#5. 为什么很多服务会提供 /health)
[6. 为什么很多服务还会提供 /version](#6. 为什么很多服务还会提供 /version)
[7. 什么叫优雅关闭](#7. 什么叫优雅关闭)
[8. 这节示例为什么还是用 HTTP 服务来演示部署](#8. 这节示例为什么还是用 HTTP 服务来演示部署)
[9. 这一节最应该先记住什么](#9. 这一节最应该先记住什么)
[10. 代码和配置为什么要分开](#10. 代码和配置为什么要分开)
[11. 本节小结](#11. 本节小结)