嗯好的,因为个人原因变动所以决定从公司跑路转向Golang后端/大模型应用所以有了这篇文章

大纲参考B站枫枫讲Go语言-8小时入门GO

一、变量定义

Go 使用 var 定义变量,也支持在函数内部使用短变量声明 :=

1. 显式指定类型

var age int = 25

含义:

  • 变量名是 age
  • 类型是 int
  • 初始值是 25

Go 的类型写在变量名后面,这一点和 Java、C# 不同。

Java / C# 写法:

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. 只声明,不赋值

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.Scanfmt.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

普通的 intboolstring 不能是 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 分别维护缓冲导致的。

排查与处理建议:

  1. 优先确认是否在多个函数里重复 bufio.NewReader(os.Stdin)
  2. 如果是,改为在 main 创建一个 Reader,并作为参数传下去。
  3. 如果同时使用了 Fscan 和 ReadString,注意清理换行符,避免把上一段输入残留给下一段逻辑。

11. 本节小结

输入输出需要重点掌握:

  • Print 不自动换行。
  • Println 自动换行。
  • Printf 用格式化模板输出。
  • Sprintf 返回格式化后的字符串。
  • Scan 读取输入时要传变量地址,例如 &name
  • Scan 默认按空白字符分隔,不适合读取整句话。
  • 读取一整行文本可以使用 bufio.Reader
  • Scan 默认从标准输入读取,Fscan 可以指定读取来源。
  • ReadString('\n') 表示读取到换行符为止。
  • 多个输入函数建议共用同一个 reader
  • err 是普通变量名,通常用于接收错误。
  • nil 通常表示没有值,err == nil 表示没有错误。
  • _ 用于丢弃不需要的返回值。

三、基本数据类型

Go 是静态类型语言,变量一旦确定类型,就不能再改成其他类型。

Go 中常见的基本数据类型包括:

  • 布尔类型:bool
  • 整数类型:intint8int16int32int64
  • 无符号整数类型:uintuint8uint16uint32uint64
  • 浮点数类型:float32float64
  • 复数类型:complex64complex128
  • 字符串类型: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。只有在明确需要固定范围、二进制协议、文件格式、网络协议,或者需要表达“不能为负”时,才更常使用 int8uint32 这类具体位数类型。

注意:不同整数类型之间不能直接运算。

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 类似 floatfloat64 类似 double,Go 中更常用 float64
  • float64 是常用浮点类型,但不适合直接做高精度金额计算。
  • complex64complex128 是复数类型,普通业务开发较少使用。
  • 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]intb 的类型是 [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 可以覆盖其他语言中的 forwhiledo 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 只会退出当前内层循环。

写循环时建议先明确:

  1. 循环起点是什么。
  2. 结束条件是什么。
  3. 每轮如何推进到下一轮。

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. 函数拆分建议

写函数时建议遵循:

  1. 一个函数尽量只做一件事。
  2. 函数名体现动作和意图(如 calcTotalprintReport)。
  3. 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. 实战判断规则

判断“函数内修改是否影响外部”时,优先看两件事:

  1. 修改的是“参数副本本身”,还是“参数副本指向的底层数据”。
  2. 该类型是否共享底层结构(如切片底层数组、map 底层哈希表、指针指向对象)。

7. 本节小结

  • Go 参数传递语义是值传递。
  • 基本类型值传参,函数内修改通常不影响外部。
  • 指针可通过解引用修改外部对象。
  • 切片/map 常出现“外部可见修改”,本质是共享底层数据。
  • append 可能导致切片重新分配,影响是否对外可见。
  • 即使不扩容,append 后也要在外部接收返回的新切片,才能稳定拿到新增元素。

十一、init 函数和 defer 语句

init 和 defer 都与函数执行时机相关:

  • init 关注“程序启动时的初始化顺序”。
  • defer 关注“函数返回前要做的收尾动作”。

1. init 函数是什么

init 是 Go 的特殊函数,特点:

  1. 没有参数、没有返回值。
  2. 不能被手动调用。
  3. 在 main 执行前自动执行。

示例:

func init() {
	fmt.Println("init running")
}

2. 初始化执行顺序

在同一个 package 中,通常可按下面顺序理解:

  1. 包级变量初始化(包括由函数参与的初始化)。
  2. init 函数执行。
  3. 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()

这样可以减少“忘记关闭资源”的风险。

实践中,常见推荐写法是:

  1. 资源一旦成功打开,立即写 defer 关闭逻辑。
  2. 不要把关闭逻辑拖到函数末尾手动写。

例如:

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
}

这表示方法拿到的是结构体地址。
在方法内部修改字段,通常会影响外部原对象。

可以把它理解成:

  • 方法逻辑需要修改原对象状态。
  • 或者结构体比较大,不想每次调用都复制整个结构体。
如何选择

现阶段可以先用一个简单规则判断:

  1. 只读、计算、不改外部对象:优先考虑值接收者。
  2. 需要修改外部对象:使用指针接收者。

从效果上看,它和前面讲过的“结构体值传参 / 结构体指针传参”本质是一回事,只不过这里换成了“挂在类型上的函数”。

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 个常见但容易混淆的点:

  1. Go 没有传统面向对象里的“类继承”。
  2. Go 可以通过结构体嵌入(embedding)实现字段和方法复用。
  3. 结构体 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" 不是把 NameAge 直接改名,而是把整个嵌入字段作为一个名为 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. 定义自定义类型
  2. 定义类型别名

它们长得很像,但语义完全不同。

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

这些类型虽然底层分别是 float64intstring,但读代码时能更清楚地表达业务含义。

除了语义更清晰,自定义类型还有一个很实际的价值:
它能在编译期减少“本来都是 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 自带并发安全能力,提供 StoreLoadDeleteRange 等方法。
  • 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:参数类型里使用了类型参数 T
  • T:返回值类型也使用了 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
}

它的好处很直接:

  • CodeMsg 这类固定字段只写一份
  • 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 是读取到的内容,类型是 []byte
  • err 表示读取过程是否出错

这也是为什么文件读取时通常第一步还是:

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 这种写法为什么很常见

单元测试里经常会看到:

  • got
  • want

这是非常常见的一种约定写法。

它的好处是很直接:

  • 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 和 60
  • 69 和 70
  • 79 和 80
  • 89 和 90

所以表驱动测试一个很实用的价值就是:
可以把这些边界值系统地排在一起测,而不是只随手测几个中间值。

8. t.Run 的作用是什么

在表驱动测试基础上,经常还会看到:

t.Run(tc.name, func(t *testing.T) {
	...
})

t.Run 可以把每一组测试数据再包装成一个独立的子测试。

这样做的好处主要有:

  • 每组测试会有自己的名字
  • 失败时更容易定位是哪一组数据出了问题
  • 输出结构更清晰

例如:

  • TestIsAdult/minor
  • TestIsAdult/adult edge

看到名字就能知道是哪一组场景没过。

不过子测试层级也不是越深越好。
如果只是把同一组测试数据重复包上多层、但行为并没有区别,
那输出虽然更长,却不会带来新的信息。

所以更实用的原则通常是:

  • 每一层 t.Run 都应该表达一个明确的分类
  • 子测试名字尽量直接对应具体场景
  • 没有新含义的重复嵌套,通常可以省掉

9. 测试 error 场景时应该怎么想

很多函数不只是返回一个正常结果,
还可能在异常输入下返回 error

例如:

func divide(a, b int) (int, error)

这类函数测试时,通常至少要覆盖两条路径:

  • 正常路径:返回正确结果,err == nil
  • 异常路径:返回错误,err != nil

也就是说,单元测试不只是测试“成功时对不对”,
也要测试“失败时是不是按预期失败”。

如果函数签名是这种形式:

value, err := fn(...)

那测试时一个很常见的顺序是:

  1. 先判断 err 是否符合预期
  2. 再判断返回值 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.Errorft.Fatalt.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 不是一回事

刚开始学反射时,一个很容易混的点就是:

  • Type
  • Kind

它们相关,但不是同一个层面。

可以先粗略这样理解:

  • 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
后面直接调用 SetIntSetString 这类方法通常就会报错或 panic。

所以一个比较稳妥的反射思路通常是:

  1. 先拿到 reflect.Value
  2. 先看 Kind()
  3. 再看 CanSet()
  4. 确认可以修改后,再调用对应的 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 就完事了,
而是通常要根据目标值类型,调用对应的 SetIntSetStringSetBool 等方法。

10. 结构体字段也可以通过反射修改

如果变量是结构体,例如:

user := User{Name: "tom", Age: 18}

那么常见思路是:

  1. 先传结构体指针给 ValueOf
  2. 再通过 Elem() 拿到结构体本身
  3. 再取字段
  4. 再修改字段值

例如:

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. 反射修改值这件事,本质上还是绕不开指针和可修改性

很多人第一次学反射时会觉得:

  • ValueOf
  • Elem
  • CanSet
  • SetXXX

这些 API 很碎。

但把它们串起来看,主线其实很统一:

  1. 先拿到反射值
  2. 判断它是什么类别
  3. 如果要修改原值,先确保拿到的是地址链路
  4. 再进入真实目标值
  5. 最后调用对应的设置方法

也就是说,反射虽然看起来新,
但底层仍然没有脱离前面已经接触过的:

  • 值传递
  • 指针
  • 类型判断

12. 什么时候反射容易出问题

这一节先不讲完整的反射风险清单,
但最常见的坑可以先记住两个:

  1. 明明拿到了 Value,却直接想改,结果 CanSet() 是 false
  2. 明明传了指针,却忘了 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 的主线通常就是:

  1. 先拿到结构体的反射值
  2. 遍历结构体字段
  3. 读取每个字段的字段名、tag、字段值
  4. 决定哪些字段参与 SQL
  5. 拼出 SQL 字符串
  6. 按 SQL 顺序收集参数

也就是说,这一节的重点并不只是“字符串拼接”,
而是“如何从结构体里稳定地提取出足够的信息”。

2. 为什么这件事常常和 tag 一起出现

如果完全不使用 tag,
那结构体字段名和数据库列名就只能强行直接对应。

例如:

  • 结构体字段叫 UserName
  • 数据库列可能叫 user_name

这时通常就需要一个中间映射规则。
最常见的方式之一就是 tag:

Name string `db:"name"`

可以先把 db tag 理解成:

  • 这个结构体字段在数据库里的列名是什么

所以反射在这里做的事情并不复杂,
本质上就是在运行时读取:

  • 字段名
  • 字段类型
  • 字段值
  • 字段 tag

然后再据此做后续拼接。

3. 为什么还要额外区分主键字段

如果只是生成 INSERT
很多时候只需要知道:

  • 哪些列要写进去
  • 对应值是什么

但如果是 UPDATEDELETESELECT
通常还需要知道:

  • 哪个字段应该拿来做条件

所以教学版里通常会额外约定一个主键标记,例如:

orm:"pk"

如果字段还带有自增含义,
还可以再补一个标记,例如:

orm:"pk,auto"

这样后续生成 SQL 时,就能把主键字段和普通字段区分开来。

4. INSERT SQL 一般怎么拼

教学版 INSERT 的思路通常是:

  1. 遍历字段
  2. 跳过自增主键
  3. 收集列名
  4. 为每一列补一个 ?
  5. 把字段值按顺序放进参数切片

最终结果通常类似:

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,
而是会得到两份结果:

  1. SQL 字符串
  2. 参数切片

例如:

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 时,最常见的错误通常有这些:

  1. 没有先判断输入是不是结构体或结构体指针
  2. 传入指针后忘了 Elem()
  3. 列顺序和参数顺序不一致
  4. UPDATE 时把主键也写进了 SET
  5. DELETE / SELECT 时没有稳定找到主键字段
  6. 忽略自增主键,导致 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 里的普通值类型,例如:

  • int
  • string
  • bool
  • struct

都不能直接和 nil 比较。

能和 nil 比较的,通常是这些类型:

  • 指针
  • slice
  • map
  • chan
  • func
  • interface

所以:

  • var pkField *fieldMeta 可以判断 pkField == nil
  • var 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
  • 反射
  • 字符串拼接
  • 参数组织

所以这一节更合理的学习目标通常不是:

  • 从零默写完整版本

而是:

  • 能看懂代码整体流程
  • 知道每一步为什么存在
  • 能解释 INSERTUPDATEDELETESELECT 分别在拼什么

只要已经能够把流程说清楚,
这一节就算达到学习目的了。
后面随着对反射和结构体理解更深,再回头自己实现,会轻松很多。

13. 本节小结

  • 通过反射读取结构体字段和 tag,可以实现一个教学版 SQL 生成器。
  • db tag 常用来描述列名映射。
  • 额外的主键标记可以帮助区分普通字段和条件字段。
  • 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 更像是:

  1. 先拨通
  2. 接通后开始说话
  3. 说完挂断

它和“发一封就走的短消息”式通信不太一样。

2. TCP 编程里最基本的两个角色

TCP 示例里通常有两个角色:

  1. 服务端
  2. 客户端

它们的职责不同。

服务端通常负责:

  • 监听某个端口
  • 等待别人连进来
  • 接收请求
  • 回写响应

客户端通常负责:

  • 主动发起连接
  • 发送数据
  • 读取服务端返回结果

这和 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
后面的读写,都是围绕这个连接对象进行的。

所以服务端的大致顺序通常是:

  1. Listen
  2. Accept
  3. Read / Write
  4. Close

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 一次
  • 只处理一个客户端

这样做不是因为真实项目只能这么写,
而是为了先把最小模型看清楚:

  1. 监听
  2. 建连
  3. 收发
  4. 关闭

等这个闭环清楚之后,
下一步才会自然过渡到:

  • 循环 Accept
  • 每个连接交给 goroutine 处理

也就是说,
“并发处理多个客户端”是下一层问题,
不是 TCP 入门第一步就必须一起塞进来的内容。

11. 这一节最应该先记住什么

TCP 入门时,最重要的不是先背住所有 API 名字,
而是把这个模型记住:

服务端:

  1. Listen
  2. Accept
  3. 读取客户端消息
  4. 写回响应
  5. 关闭连接

客户端:

  1. Dial
  2. 发送消息
  3. 读取响应
  4. 关闭连接

只要这个顺序在脑子里是稳定的,
后面再看更复杂的 socket 代码时就不容易迷路。

12. TCP 常见应用场景

TCP 最典型的适用场景可以概括为一句话:

  • 需要可靠传输的网络通信

这里的“可靠”主要体现在:

  • 通信前先建立连接
  • 数据按顺序到达
  • 丢失的数据可以重传

因此,TCP 常见于这些场景:

  1. Web 服务
    例如 HTTP、HTTPS 底层都大量依赖 TCP。

  2. 数据库连接
    例如 MySQL、PostgreSQL、Redis 等客户端与服务端之间的通信。

  3. 文件传输
    文件内容要求完整,不能随便丢字节。

  4. 即时通信
    例如很多聊天系统、消息推送系统的底层连接。

  5. 远程登录
    例如 SSH 这类远程终端通信。

也就是说,
如果业务要求“数据不能乱、不能少”,
通常就更适合优先考虑 TCP。

13. 这一节代码里最容易混淆的两个对象

在 TCP 示例里,
很多初学者最容易把这两个对象混在一起:

  1. listener
  2. conn

它们职责完全不同。

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 把通信内容组织成了更明确的结构。

请求里常见包含:

  • 请求方法,例如 GETPOST
  • 请求路径,例如 /hello
  • 查询参数
  • 请求头
  • 请求体

响应里常见包含:

  • 状态码,例如 200 OK
  • 响应头
  • 响应体

这套规则一旦固定下来,
客户端和服务端的沟通成本就会大幅下降。

3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP

裸 TCP 的优点是更底层、更灵活。
但对常见 Web 接口开发来说,
自己处理消息边界、协议格式、状态码、路径分发,
成本会很高。

HTTP 则已经帮我们约定好了很多东西,例如:

  • 请求方法
  • 请求路径
  • 状态码
  • 请求头与响应头
  • 请求体和响应体的组织方式

所以在做接口开发时,
通常不用自己重新发明一套通信规则。
直接基于 HTTP,就能更快进入业务处理本身。

4. Go 里 HTTP 服务端通常长什么样

Go 标准库里的 HTTP 服务端,
一般会围绕这几样东西展开:

  1. 路由
  2. 处理函数
  3. 服务对象

例如:

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 写出去。

所以处理函数本质上就在做三件事:

  1. 读取请求
  2. 处理逻辑
  3. 写回响应

6. 什么是路由

路由可以先简单理解成:

  • 路径和处理逻辑的对应关系

例如:

  • /hello 返回欢迎语
  • /echo 返回处理后的请求体
  • /ping 返回健康检查结果

如果没有路由,
服务端就很难根据不同 URL 路径执行不同逻辑。

所以在 HTTP 服务端里,
路由几乎是最基础的一层组织方式。

7. GET 和 POST 可以先怎么理解

初学 HTTP 时,
先不用把所有方法都背全。
先抓住最常见的两个:

  1. GET
  2. POST

GET 常用于:

  • 获取资源
  • 读取信息
  • 通过 URL 查询参数传递简单数据

例如:

/hello?name=golang

POST 常用于:

  • 提交数据
  • 把内容放进请求体里发送给服务端

例如:

  • 发送一段文本
  • 提交表单
  • 提交 JSON

当然这只是入门阶段的常见理解方式。
后面学 REST 风格接口时,还会接触更多语义化约定。

8. 为什么这里要区分查询参数和请求体

HTTP 里常见的输入位置至少有两种:

  1. URL 查询参数
  2. 请求体

例如:

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 入门时,
最重要的不是先背框架,
而是把这个最小模型记住:

服务端:

  1. 注册路由
  2. 写处理函数
  3. 启动 HTTP 服务
  4. 从请求里拿数据
  5. 把响应写回去

客户端:

  1. 发请求
  2. 拿到响应
  3. 读取状态码和响应体

只要这个骨架清楚,
后面切换到 Gin、Echo、Fiber 这类框架时,
你也能知道它们本质上是在对哪一层做封装。

12. 什么是状态码

HTTP 响应里除了响应体,
还有一个很重要的信息:状态码。

例如:

  • 200 OK
  • 404 Not Found
  • 500 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 服务端时,
会觉得“新增一个接口”像是一件很大的事。
但在最基本的标准库模型里,它其实很直接:

  1. 再定义一个处理函数
  2. 再注册一个路径

例如:

mux.HandleFunc("/ping", pingHandler)

这就表示:

  • 当客户端请求 /ping
  • 就交给 pingHandler 去处理

因此,
在最小 HTTP 示例里,
一个“新接口”本质上往往就是:

  • 多一个路由
  • 多一段处理逻辑

15. net/http 和 Gin 是什么关系

这一点在后面学框架时非常重要。

可以把层级关系先理解成这样:

  1. TCP
  2. HTTP
  3. Go 标准库 net/http
  4. Gin 这类 Web 框架

也就是说:

  • TCP 负责底层可靠传输
  • HTTP 负责请求和响应规则
  • net/http 提供 Go 里的标准 HTTP 编程接口
  • Gin 再在 net/http 这一层之上做进一步封装和简化

所以 Gin 并不是“另起炉灶重新发明 HTTP”,
而更像是:

  • 帮你把路由写得更顺
  • 帮你把参数读取做得更方便
  • 帮你把 JSON 响应、中间件、分组路由这些常见能力包装得更好用

因此,
现在先学 net/http 是很有价值的。
因为以后学 Gin 时,你不会只是背框架 API,
而会知道它到底帮你省掉了哪些原始步骤。

16. 这一节更重要的是掌握 HTTP 的最小骨架

HTTP 这一节和前面的反射转 SQL 有点类似:

  • 重点不一定是把所有练习都写花哨
  • 更重要的是先把主骨架看清楚

也就是:

服务端:

  1. 注册路由
  2. 启动服务
  3. 读取请求
  4. 写回响应

客户端:

  1. 发请求
  2. 拿响应
  3. 读状态码
  4. 读响应体

只要这个骨架是稳定的,
后面无论是继续写标准库 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. 什么叫部署

部署可以先简单理解成:

  • 把程序从“本地开发状态”
  • 变成“目标环境中可运行状态”

这通常至少包含几件事:

  1. 把代码构建成可执行文件
  2. 准备程序运行所需配置
  3. 启动程序
  4. 确认程序是否正常对外提供服务

也就是说,
部署关注的重点已经不只是“代码能不能编译通过”,
而是:

  • 这份程序能不能在别的环境稳定跑起来

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 服务时,
交付形式会比较直接:

  1. 编译出 exe 或二进制文件
  2. 带上必要配置
  3. 运行它

和某些依赖复杂运行时环境的语言相比,
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、云平台全塞进来。
先把最小骨架记住:

  1. 写好服务程序
  2. 用 go build 生成可执行文件
  3. 让程序通过环境变量读取配置
  4. 启动服务并监听端口
  5. 提供最基本的健康检查接口
  6. 在停止服务时尽量优雅关闭

只要这个骨架是清楚的,
后面再接容器化、进程托管、自动发布,
都只是往这套基础上继续加层次。

10. 代码和配置为什么要分开

这是部署里非常核心的一条原则。

如果把端口、地址、账号等全部写死在代码里,
那每换一个环境就可能要改代码、重新提交、重新构建。

而如果把这些值放到环境变量或配置文件里,
就可以做到:

  • 代码尽量保持不变
  • 不同环境只改配置

这样会带来几个直接好处:

  • 发布流程更稳定
  • 环境切换更方便
  • 降低因为手动改代码带来的风险

11. 本节小结

  • 部署的核心是让程序在目标环境中可运行并可维护。
  • go run 更适合开发验证,go build 更适合交付可执行文件。
  • Go 程序常见部署形态之一是构建出单独的可执行文件。
  • 环境变量常用于让同一份程序适配不同运行环境。
  • /health 常用于健康检查,/version 常用于确认当前程序版本。
  • 优雅关闭可以减少请求中断和资源收尾不完整的问题。
  • 代码与配置分离,是部署和运维里非常重要的一条原则。

Golang 学习笔记

一、变量定义

1. 显式指定类型

2. 省略类型,让编译器推断

3. 只声明,不赋值

4. 短变量声明

5. 一次声明多个变量

6. 变量可以重新赋值

7. 包级变量

8. 本节小结

二、输入输出

1. 基本输出

2. 格式化输出

3. 生成字符串

4. 基本输入

5. 按空白分隔读取

6. 读取一整行

7. 输入代码中的常见概念

nil

err

_

reader

缓冲读取器

*bufio.Reader

输入场景中的指针

8. Scan 和 Fscan 的区别

9. ReadString 的作用

10. 多个输入函数共用同一个 Reader

11. 本节小结

三、基本数据类型

1. bool

2. 整数类型

3. byte

4. rune

5. 浮点数类型

6. 复数类型

7. string

8. 零值

9. 显式类型转换

10. Go 没有包装类型和装箱拆箱

11. 本节小结

四、数组和切片

1. 数组

2. 数组长度属于类型

3. 数组遍历

4. 切片

5. len 和 cap

6. append

7. make 创建切片

8. 切片表达式

9. 切片共享底层数组

10. nil 切片和空切片

11. 数组和切片的选择

12. 本节小结

五、map(键值对)

1. map 的定义与初始化

2. 读取 map

3. 判断 key 是否存在

4. 新增、更新、删除

5. 遍历 map

6. nil map 和 make

7. 本节小结

六、if 条件语句

1. 基本 if

2. if else

3. if else if else

4. if 初始化语句

5. 条件中的逻辑运算符

6. if 中变量的作用域

7. Go 的 if 语法特点

8. 本节小结

七、switch 分支语句

1. 基本 switch

2. 一个 case 匹配多个值

3. 无表达式 switch

4. switch 初始化语句

5. break 与 fallthrough

6. switch 与 if 的选择

7. 典型业务写法

分数分级(按十位分段)

月份天数(分组 case)

8. 本节小结

八、for 循环

1. 三段式 for

2. 把 for 当作 while

3. 无限循环

4. break 与 continue

5. 嵌套循环

6. for range

7. 常见边界问题

8. 本节小结

九、函数(func)

1. 函数定义

2. 参数

3. 返回值

4. 可变参数

5. 函数是一等值

6. 函数拆分建议

7. 本节小结

十、值传递和“引用效果”

1. 基本类型:典型值传递

2. 指针参数:通过地址间接修改

3. 切片参数:看起来像“引用”

4. map 参数:修改可见

5. struct:值传参与指针传参的差异

6. 实战判断规则

7. 本节小结

十一、init 函数和 defer 语句

1. init 函数是什么

2. 初始化执行顺序

3. 多个 init

4. defer 基本语义

5. defer 的执行顺序(LIFO)

6. defer 参数求值时机

7. 常见用途

8. 本节小结

十二、结构体(struct)

1. 结构体定义

2. 结构体初始化

3. 字段访问

4. 匿名结构体

5. Go 没有内建 getter / setter

6. 方法与接收者

值接收者

指针接收者

如何选择

7. 结构体指针

8. 结构体参数传递

9. 结构体比较

10. 使用场景

11. 本节小结

十三、结构体嵌入、结构体指针与 tag

1. Go 没有传统继承

2. 结构体嵌入(embedding)

3. 字段提升

4. 结构体指针

5. 什么是结构体 tag

6. tag 的常见用途

7. 读取 tag

8. 本节小结

十四、自定义类型与类型别名

1. 自定义类型

2. 类型别名

3. 两者的根本区别

4. 为什么要自定义类型

5. 为什么要类型别名

6. 使用时的判断思路

7. 本节小结

十五、接口(interface)

1. 接口定义

2. 隐式实现

3. 接口变量

4. 接口作为参数

5. any 与空接口

6. 类型断言

7. 接口的价值

8. 本节小结

十六、协程(goroutine)

1. 启动 goroutine

2. main 结束会带走其他 goroutine

3. 用 Sleep 暂时等待

4. goroutine 传参

5. 使用 WaitGroup

6. 输出顺序不一定固定

7. Sleep 和 WaitGroup 的区别

8. 本节小结

十七、频道(channel)

1. 创建 channel

2. 发送和接收

3. 为什么 channel 常和 goroutine 一起出现

4. 无缓冲 channel 的阻塞特性

5. 有缓冲 channel

6. close 和 range

7. 单向 channel

8. close 的边界规则

9. channel 和 WaitGroup 的分工不同

10. 常见风险:死锁

11. 本节小结

十八、select 与协程超时处理

1. select 是干什么的

2. select 和 switch 的区别

3. 最基础的 select 接收

4. 同时监听多个 channel

5. default 分支

6. 为什么超时处理常和 select 一起用

7. 使用 time.After 做超时控制

8. 超时控制的是等待方,不一定是任务本身

9. select 只是选择“当前可执行”的分支

10. select 和 channel 的关系

11. 超时现象里为什么有时会“刚好收到结果”

12. 本节小结

十九、线程安全与 sync.Map

1. 为什么会有线程安全问题

2. 普通 map 默认不是并发安全的

3. 用 sync.Mutex 保护共享数据

4. Mutex 保护的不是“变量名”,而是那段共享访问过程

5. sync.RWMutex 是什么

6. 为什么带锁结构体的方法通常用指针接收者

7. 为什么示例里经常把 map 和锁封装进结构体

8. sync.Map 的基本定位

9. LoadOrStore 是什么意思

10. sync.Map 的 Range 和普通 map 的 for range 不一样

11. 为什么会看到 concurrent map writes

12. 是不是以后都该直接用 sync.Map

13. 如何判断自己是否遇到了并发安全问题

14. 本节小结

二十、错误处理与 panic/recover

1. Go 里的 error 是什么

2. 为什么 Go 常写 result, err

3. nil 表示没有错误

4. 使用 errors.New 创建简单错误

5. 使用 fmt.Errorf 补充上下文

6. 错误包装和 %w

7. errors.Is 的作用

8. 自定义业务错误常见怎么做

9. panic 是什么

10. recover 是怎么用的

11. 把函数当参数传进去是什么意思

12. 这种包装写法为什么有点像切面思路

13. panic 和 error 的分工

14. recover 不会自动处理所有问题

15. 错误处理为什么强调显式判断

16. 本节小结

二十一、泛型

1. 什么是泛型

2. 泛型函数长什么样

3. any 在泛型里是什么意思

4. 为什么泛型比 any + 类型断言更自然

5. 泛型里参数和返回值是不是都要写 T

6. 泛型结构体是什么

7. 泛型很适合做通用返回结构

8. 类型约束是什么

9. 约束接口和普通接口有什么不同

10. 波浪线 ~ 是什么意思

11. max 这类函数为什么需要可比较约束

12. 类型推断是什么

13. 泛型是不是意味着以后都该泛型化

14. 本节小结

二十二、文件读取

1. 最常见的整文件读取:os.ReadFile

2. 为什么读出来是 []byte

3. 文件读取为什么必须先判断 err

4. 获取文件内容长度

5. 使用 os.Open 打开文件

6. 为什么打开文件后要 Close

7. 逐行读取:bufio.Scanner

8. 逐行读取后为什么还要检查 scanner.Err

9. 一次读完整个文件 vs 逐行读取

10. 文件不存在时为什么会直接报错

11. 为什么示例里的路径是从 learn 开始

12. 文件路径和 package main 有什么关系

13. 为什么同样是相对路径,有时写 sample.txt 也行

14. 为什么第一行前面会多出一个奇怪字符

15. 空文件读取会怎么样

16. 本节小结

二十三、文件写入

1. 最直接的写法:os.WriteFile

2. 为什么写入内容常常还是 []byte

3. 覆盖写入是什么意思

4. 为什么写文件也必须判断 err

5. 追加写入:os.OpenFile

6. OpenFile 和 WriteFile 的使用区别

7. 写入多行文本时要自己处理换行

8. 使用 bufio.Writer 做带缓冲写入

9. 为什么用了 Writer 还要 Flush

10. 打开文件写入后为什么也要 Close

11. 文件写入路径和读取路径的规则一样

12. 一次性写入、追加写入、缓冲写入分别适合什么

13. 本节小结

二十四、单元测试

1. Go 的单元测试主要依赖 testing 包

2. 测试文件为什么通常要写成 _test.go

3. 最基础的测试函数长什么样

4. got 和 want 这种写法为什么很常见

5. 运行单元测试通常不用 go run,而是 go test

6. 为什么说单元测试更适合测小范围逻辑

7. 表驱动测试为什么在 Go 里很常见

8. t.Run 的作用是什么

9. 测试 error 场景时应该怎么想

10. t.Errorf、t.Fatal、t.Fatalf 有什么区别

11. 覆盖率是什么意思

12. 为什么覆盖率有时不是 100%

13. 哪些函数更适合先写单元测试

14. 本节小结

二十五、反射(一):获取值与修改值

1. 反射最常从 TypeOf 和 ValueOf 开始

2. Type 和 Kind 不是一回事

3. ValueOf 拿到的是反射值,不是普通值本身

4. Interface() 的作用是什么

5. 为什么直接传普通值时通常改不了

6. CanSet() 到底在判断什么

7. 为什么改值时通常要传指针

8. Elem() 是做什么的

9. 修改基础类型时常见的写法

10. 结构体字段也可以通过反射修改

11. 反射修改值这件事,本质上还是绕不开指针和可修改性

12. 什么时候反射容易出问题

13. 本节小结

二十六、通过反射实现转 SQL(教学版)

1. 反射转 SQL 的核心思路是什么

2. 为什么这件事常常和 tag 一起出现

3. 为什么还要额外区分主键字段

4. INSERT SQL 一般怎么拼

5. UPDATE SQL 和 INSERT 的区别

6. DELETE SQL 的重点其实是条件来源

7. SELECT SQL 为什么也可以复用同一套反射信息

8. 为什么参数通常要单独放进切片

9. 为什么这里先按 MySQL 风格来写

10. 为什么说这还不等于完整 ORM

11. 这一节里最容易写错的地方

12. 阅读这类代码时容易困惑的几个点

12.1 为什么 UPDATE 示例里会用 *fieldMeta

12.2 为什么结构体值不能直接和 nil 比较

12.3 为什么 range 时有时不能直接拿遍历变量来取地址

12.4 fmt.Sprintf 不会自动帮你补空格

12.5 这一节更重要的是看懂流程,而不是强行默写

13. 本节小结

二十七、网络编程(一):TCP

1. 什么是 TCP

2. TCP 编程里最基本的两个角色

3. 服务端为什么先 Listen

4. Accept 是什么

5. 客户端为什么用 Dial

6. net.Conn 可以把它当成什么

7. 为什么这里经常搭配 bufio.Reader

8. 为什么示例里消息后面要加 \n

9. 为什么要关闭连接

10. 这节示例为什么只处理一个连接

11. 这一节最应该先记住什么

12. TCP 常见应用场景

13. 这一节代码里最容易混淆的两个对象

13.1 listener 是监听入口

13.2 conn 是已经建立好的具体连接

14. 服务端里的 for {} 到底是什么

15. 为什么服务端看起来一直在“卡住等消息”

16. 本节小结

二十八、网络编程(二):HTTP

1. HTTP 和 TCP 是什么关系

2. HTTP 最核心的模型是什么

3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP

4. Go 里 HTTP 服务端通常长什么样

5. 处理函数里的两个参数分别是什么

5.1 r *http.Request

5.2 w http.ResponseWriter

6. 什么是路由

7. GET 和 POST 可以先怎么理解

8. 为什么这里要区分查询参数和请求体

9. 客户端在 HTTP 里通常怎么发请求

10. 为什么响应体和请求体都常常要记得关闭

11. 这一节最应该先记住什么

12. 什么是状态码

13. 为什么处理函数里经常会写默认值分支

14. 新增路由其实就是“再注册一个处理函数”

15. net/http 和 Gin 是什么关系

16. 这一节更重要的是掌握 HTTP 的最小骨架

17. 本节小结

二十九、Go 部署

1. 什么叫部署

2. go run 和 go build 在部署里的角色不同

3. 为什么 Go 程序很适合做单文件交付

4. 为什么部署时常把配置放进环境变量

5. 为什么很多服务会提供 /health

6. 为什么很多服务还会提供 /version

7. 什么叫优雅关闭

8. 这节示例为什么还是用 HTTP 服务来演示部署

9. 这一节最应该先记住什么

10. 代码和配置为什么要分开

11. 本节小结

更多推荐