1
0

3 Revīzijas c2168558e2 ... f8619cf98f

Autors SHA1 Ziņojums Datums
  zhuts f8619cf98f feat: 新增第十六课接口例程与测试替身说明 3 dienas atpakaļ
  zhuts 66b4ad9511 feat: 新增第十五课包与模块例程及跨包测试 3 dienas atpakaļ
  zhuts df9ecf428c feat: 新增第十四课单元测试专题与表驱动例程 3 dienas atpakaļ

+ 20 - 3
README.md

@@ -49,9 +49,23 @@
     ├── 12_switch_defer/    # 第 12 课:switch 与 defer
     │   ├── README.md       # 多分支、延迟调用与执行顺序
     │   └── main.go         # 指令选择、成绩判断和 defer 示例
-    └── 13_function_values/ # 第 13 课:函数进阶
-        ├── README.md       # 函数值、匿名函数、闭包与 t.Run 预习
-        └── main.go         # 回调计算、独立计数器和 defer 对比
+    ├── 13_function_values/ # 第 13 课:函数进阶
+    │   ├── README.md       # 函数值、匿名函数、闭包与 t.Run 预习
+    │   └── main.go         # 回调计算、独立计数器和 defer 对比
+    ├── 14_testing/         # 第 14 课:单元测试专题
+    │   ├── README.md       # 测试语法、子测试、错误断言和运行命令
+    │   ├── main.go         # 被测函数与普通程序入口
+    │   └── main_test.go    # 基础测试、表驱动测试和边界用例
+    ├── 15_packages/        # 第 15 课:包与模块
+    │   ├── README.md       # 模块路径、导入、导出与外部测试
+    │   ├── main.go         # 导入自定义包的程序入口
+    │   └── score/
+    │       ├── score.go    # 成绩转换与校验包
+    │       └── score_test.go # 外部测试包
+    └── 16_interfaces/      # 第 16 课:接口
+        ├── README.md       # 隐式实现、方法集、nil 与测试替身
+        ├── main.go         # 不同类型通过统一接口自我介绍
+        └── main_test.go    # 接口实现及测试替身验证
 ```
 
 根目录不放例程代码。各课共享根目录的 `go.mod`,不需要逐课初始化模块,也不需要 `go.work`。当前模块路径 `example.com/go-study` 是学习用标识,不必与 Git 地址相同。
@@ -73,6 +87,9 @@
 | 11 | [错误处理](./lessons/11_errors/README.md) | error、nil、errors.New、fmt.Errorf、错误传递 | 已建立 |
 | 12 | [switch 与 defer](./lessons/12_switch_defer/README.md) | 多分支、延迟调用、后进先出、参数求值 | 已建立 |
 | 13 | [函数进阶](./lessons/13_function_values/README.md) | 函数值、匿名函数、函数参数、闭包 | 已建立 |
+| 14 | [单元测试专题](./lessons/14_testing/README.md) | testing.T、表驱动、t.Run、错误断言、测试命令 | 已建立 |
+| 15 | [包与模块](./lessons/15_packages/README.md) | package、import、module、导出、跨包测试 | 已建立 |
+| 16 | [接口](./lessons/16_interfaces/README.md) | 隐式实现、接口参数、方法集、nil、测试替身 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 184 - 0
lessons/14_testing/README.md

@@ -0,0 +1,184 @@
+# 第 14 课:单元测试专题
+
+## 学习顺序
+
+1. 阅读 [main.go](./main.go),先理解被测的 add 和 parseScore。
+2. 在 [main_test.go](./main_test.go) 中只敲 TestAdd,执行一次测试。
+3. 再敲 TestAddTable,把结构体切片、循环、匿名函数串起来。
+4. 最后敲 TestParseScore,学习验证错误和边界情况。
+
+共享根目录的 `go.mod`,仅使用标准库,无需安装测试框架。本课暂不使用并行测试、mock、基准测试或模糊测试。
+
+## 1. 测试如何启动
+
+```go
+package main
+
+import "testing"
+
+func TestAdd(t *testing.T) {
+    got := add(2, 3)
+    want := 5
+    if got != want {
+        t.Fatalf("实际=%d,期望=%d", got, want)
+    }
+}
+```
+
+- 测试文件以 `_test.go` 结尾。
+- 普通测试函数形如 `func TestXxx(t *testing.T)`,Test 后的名字不能以小写字母开头。本课统一使用 TestAdd 这样的名称。
+- `testing` 是标准库包,T 是它提供的类型,`*testing.T` 是指针类型,t 是参数名。
+- go test 负责生成测试入口、调用这些函数、传入 t;不需要在 main 中调用测试。
+- 同包测试使用 `package main`,可以直接访问本课未导出的 add 和 parseScore。
+- 测试文件不会被普通 go build 编译进应用程序。
+
+一次测试的核心是:准备输入 → 调用函数 → 比较实际与期望 → 不一致就报告失败。got、want、tt 都只是惯用变量名,不是关键字。
+
+## 2. Logf、Errorf 与 Fatalf
+
+| 方法 | 标记失败 | 是否继续当前测试 |
+| --- | --- | --- |
+| `t.Logf(...)` | 否,只记录信息 | 继续 |
+| `t.Errorf(...)` | 是 | 继续 |
+| `t.Fatalf(...)` | 是 | 停止当前测试 |
+
+它们支持格式字符串。测试正常执行完且未标记失败,就是通过,不需要额外调用成功方法。
+
+Fatalf 在子测试中只停止当前子测试的执行,其他用例通常仍会运行;不要把它理解为立即结束整个测试进程。普通 `return` 也能结束当前测试函数,但不会自动把它标记为失败。
+
+不要在测试中仅用 fmt.Println 输出“失败”来代替 t.Errorf / t.Fatalf,否则 go test 可能仍报告通过。
+
+## 3. 表驱动测试
+
+```go
+tests := []struct {
+    name string
+    a    int
+    b    int
+    want int
+}{
+    {name: "positive", a: 2, b: 3, want: 5},
+    {name: "zero", a: 0, b: 0, want: 0},
+}
+```
+
+这是第八课学过的“结构体切片”,每条记录表示一组输入与期望结果,不是特殊测试语法。
+
+```go
+for _, tt := range tests {
+    t.Run(tt.name, func(t *testing.T) {
+        got := add(tt.a, tt.b)
+        if got != tt.want {
+            t.Errorf("实际=%d,期望=%d", got, tt.want)
+        }
+    })
+}
+```
+
+按层理解:
+
+1. range 每次取出一条测试记录 tt。
+2. 外层 t.Run 创建有名字的子测试。
+3. 第二个参数是匿名函数,由框架调用;不在末尾加自行调用的 `()`。
+4. 内层 t 是子测试自己的管理器,遮蔽外层同名参数。
+5. 匿名函数使用外层 tt,本质上是上节课学习的闭包。
+6. 最后的 `})` 分别关闭匿名函数体和 Run 的调用参数列表。
+
+本课没有调用 t.Parallel,子测试回调结束后才继续下一次循环,先按顺序理解。并发执行以及旧版本 Go 循环变量捕获的差异留到后续,不要随意添加并行语句。
+
+## 4. 验证错误与边界
+
+TestParseScore 的 wantErr 表示“期望本次调用发生错误”,不是错误文本本身。
+
+```go
+got, err := parseScore(tt.input)
+hasErr := err != nil
+if hasErr != tt.wantErr {
+    t.Fatalf("错误=%v,期望发生错误=%t", err, tt.wantErr)
+}
+if tt.wantErr {
+    return
+}
+```
+
+先比较是否发生错误与预期是否一致:
+
+- 期望成功却失败:测试失败。
+- 期望失败却成功:测试失败。
+- 确实发生预期错误:本用例通过错误检查,结束,不再使用无效结果。
+- 确实成功:继续核对结果数值。
+
+本例覆盖 0、100 两个合法边界,以及 -1、101、非整数文本和空文本。错误只检查是否存在,不逐字匹配标准库错误信息。它不验证错误的具体分类;更精细的错误断言留到错误进阶。
+
+## 5. 测试命令
+
+以下命令在**仓库根目录**执行:
+
+```powershell
+# 运行本课所有测试,显示每个用例和日志
+go test -v ./lessons/14_testing
+
+# 强制重新执行,不复用成功的测试缓存
+go test -count=1 -v ./lessons/14_testing
+
+# 只运行最简单的 TestAdd,不匹配 TestAddTable
+go test -v ./lessons/14_testing -run '^TestAdd$'
+
+# 只运行一个指定子测试
+go test -v ./lessons/14_testing -run '^TestParseScore$/^upper_bound$'
+
+# 查看本课语句覆盖率
+go test -cover ./lessons/14_testing
+
+# 运行全仓库测试
+go test ./...
+```
+
+-run 接收正则表达式,不是普通字符串包含查找;`^`、`$` 分别约束开头与结尾。子测试用 `/` 分层筛选。
+
+显示 `(cached)` 表示复用了之前的成功测试结果,不是本次重新执行。覆盖率高不代表逻辑一定正确;本课只测试业务函数,没有覆盖 main 的终端展示,因此总覆盖率不会是 100%。
+
+## 6. 编译普通程序与编译测试程序
+
+普通程序仍可以编译运行,在仓库根目录执行:
+
+```powershell
+New-Item -ItemType Directory -Force bin/14_testing | Out-Null
+go build -o ./bin/14_testing/testing_demo.exe ./lessons/14_testing
+.\bin\14_testing\testing_demo.exe
+```
+
+普通程序预期输出:
+
+```text
+2 + 3 = 5
+转换后的成绩:85
+运行自动测试:go test -v ./lessons/14_testing(在仓库根目录执行)
+```
+
+也可以用 `go run ./lessons/14_testing`,但它只运行 main,不运行 `_test.go` 中的测试。
+
+go test 本身也需要先编译。若想单独保留测试可执行文件:
+
+```powershell
+go test -c -o ./bin/14_testing/lesson14.test.exe ./lessons/14_testing
+.\bin\14_testing\lesson14.test.exe '-test.v'
+```
+
+直接运行测试产物时使用 `-test.v` 等测试二进制参数,而不是 go test 的 `-v`。在 PowerShell 中给带点的参数加引号,确保原样传入。修改源码或测试后需要重新编译。当前目录的 bin 已在前面的命令中创建。
+
+## 动手练习
+
+- [ ] 只运行 TestAdd,找到 Logf 输出。
+- [ ] 把 want 从 5 改成 6,观察 FAIL 和报错文件行号,再恢复。
+- [ ] 将 Fatalf 临时换成 Errorf,并在其后加 Logf,比较是否继续执行;不要在 Fatalf 后写不可达的代码。
+- [ ] 在加法测试表里增加一条自己的输入。
+- [ ] 给成绩表添加 `" 85 "`,期望错误,因为 Atoi 不会自动去除空白。
+- [ ] 只运行 upper_bound 子测试。
+- [ ] 全部恢复为正确测试,执行 `go test -count=1 ./...`,确认通过。
+
+本课测试代码应直接放在本课目录。若另建 practice 子目录,需要把被测函数和对应测试一起放进去,再执行 `go test -v ./lessons/14_testing/practice`,不能只复制测试却指望自动访问父目录函数。
+
+下一课学习包与模块,把可复用函数拆到自定义包中并正确导入。
+
+[上一课:函数进阶](../13_function_values/README.md) · [学习总览](../../README.md)

+ 35 - 0
lessons/14_testing/main.go

@@ -0,0 +1,35 @@
+package main
+
+import (
+	"fmt"
+	"strconv"
+)
+
+// add 是最简单的被测函数。本课只用较小整数,不讨论整数溢出。
+func add(a, b int) int {
+	return a + b
+}
+
+// parseScore 将文本转换为成绩,失败时返回错误。
+func parseScore(text string) (int, error) {
+	score, err := strconv.Atoi(text)
+	if err != nil {
+		return 0, err
+	}
+	if score < 0 || score > 100 {
+		return 0, fmt.Errorf("成绩 %d 无效,应在 0~100 之间", score)
+	}
+	return score, nil
+}
+
+// main 只是正常程序示例。go test 不会调用这里的 main。
+func main() {
+	fmt.Printf("2 + 3 = %d\n", add(2, 3))
+	score, err := parseScore("85")
+	if err != nil {
+		fmt.Println("转换失败:", err)
+		return
+	}
+	fmt.Printf("转换后的成绩:%d\n", score)
+	fmt.Println("运行自动测试:go test -v ./lessons/14_testing(在仓库根目录执行)")
+}

+ 79 - 0
lessons/14_testing/main_test.go

@@ -0,0 +1,79 @@
+package main
+
+import "testing"
+
+// 1. 最基础测试:准备输入 -> 调用函数 -> 比较实际值与期望值。
+// 测试函数以 Test 开头,接收 *testing.T,没有返回值。
+func TestAdd(t *testing.T) {
+	got := add(2, 3) // got:实际得到的结果,是普通变量名。
+	want := 5        // want:期望结果,同样不是关键字。
+
+	if got != want {
+		// Fatalf:记录失败信息、标记失败,并停止当前测试。
+		t.Fatalf("add(2, 3):实际=%d,期望=%d", got, want)
+	}
+	// Logf 只记录日志,不会把测试标记为失败;通过时用 -v 查看日志。
+	t.Logf("检查通过:实际=%d,期望=%d", got, want)
+}
+
+// 2. 表驱动测试:把多组输入和期望结果放到结构体切片里。
+func TestAddTable(t *testing.T) {
+	tests := []struct {
+		name string
+		a    int
+		b    int
+		want int
+	}{
+		{name: "positive", a: 2, b: 3, want: 5},
+		{name: "zero", a: 0, b: 0, want: 0},
+		{name: "negative", a: -2, b: -3, want: -5},
+		{name: "mixed", a: -2, b: 3, want: 1},
+	}
+
+	for _, tt := range tests {
+		// Run 的参数:子测试名称、执行子测试的函数值。
+		// 匿名函数没有末尾的 (),由测试框架负责调用。
+		t.Run(tt.name, func(t *testing.T) {
+			got := add(tt.a, tt.b)
+			if got != tt.want {
+				// Errorf:记录并标记失败,但继续执行当前测试后面的代码。
+				t.Errorf("add(%d, %d):实际=%d,期望=%d", tt.a, tt.b, got, tt.want)
+			}
+		})
+	}
+}
+
+// 3. 测试有 error 返回值的函数:成功路径和失败路径都要覆盖。
+func TestParseScore(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   string
+		want    int
+		wantErr bool
+	}{
+		{name: "normal", input: "85", want: 85, wantErr: false},
+		{name: "lower_bound", input: "0", want: 0, wantErr: false},
+		{name: "upper_bound", input: "100", want: 100, wantErr: false},
+		{name: "below_range", input: "-1", wantErr: true},
+		{name: "above_range", input: "101", wantErr: true},
+		{name: "invalid_text", input: "abc", wantErr: true},
+		{name: "empty", input: "", wantErr: true},
+		{name: "decimal", input: "80.5", wantErr: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := parseScore(tt.input)
+			hasErr := err != nil
+			if hasErr != tt.wantErr {
+				t.Fatalf("parseScore(%q):错误=%v,期望发生错误=%t", tt.input, err, tt.wantErr)
+			}
+			if tt.wantErr {
+				return // 已确认发生预期错误,本用例不再检查无效结果。
+			}
+			if got != tt.want {
+				t.Errorf("parseScore(%q):实际=%d,期望=%d", tt.input, got, tt.want)
+			}
+		})
+	}
+}

+ 196 - 0
lessons/15_packages/README.md

@@ -0,0 +1,196 @@
+# 第 15 课:包与模块
+
+## 学习目标
+
+将可复用逻辑拆到自定义包,理解 package、import、module 的区别,掌握大小写可见性和跨包测试。本课是前面规划的基础语法阶段最后一课,后续进入接口及实际开发。
+
+共享仓库根目录的 `go.mod`,**不要在本课再次初始化模块**。只使用标准库和本仓库的本地包,不安装第三方库,不需要 go.work 或 replace。
+
+## 文件与阅读顺序
+
+```text
+lessons/15_packages/
+├── README.md
+├── main.go              # package main,程序入口
+└── score/
+    ├── score.go         # package score,可复用的成绩逻辑
+    └── score_test.go    # package score_test,外部测试
+```
+
+先敲 [score/score.go](./score/score.go),再敲 [main.go](./main.go),运行成功后再看 [score/score_test.go](./score/score_test.go)。两个目录仍属于同一课,只是用子目录演示代码拆包,不会修改其他课程。
+
+## 1. package 是包声明
+
+```go
+package score
+```
+
+同一目录里的普通 Go 源文件通常必须属于同一个包。一个包可以由多个源码文件组成,不需要每个文件都写 main。
+
+- package main:可执行程序所在的包,入口是 func main。
+- package score:普通库包,由其他包导入使用,本身不需要 main。
+- 每个包有自己的命名空间,其他课同名函数不会因此冲突。
+
+不能把 package main 和 package score 的普通源码混放在同一目录,也不要试图导入某个 `main.go` 文件。导入的是包,不是文件。
+
+## 2. module 是模块
+
+仓库根目录的模块配置:
+
+```go
+module example.com/go-study
+```
+
+模块是管理一组包及其依赖、版本的单位。一个 module 可以包含很多 package。
+
+本课自定义包的导入路径由两部分拼成:
+
+```text
+模块路径:example.com/go-study
+包相对目录:lessons/15_packages/score
+完整路径:example.com/go-study/lessons/15_packages/score
+```
+
+所以主程序写:
+
+```go
+import "example.com/go-study/lessons/15_packages/score"
+```
+
+当前模块中的包由 Go 在本地解析,不会因为路径像网址就去下载它。这个 module 是学习标识,不要求对应真实网站,也不要求与 Git 地址一致。以后发布给外部使用时,再设计可获取的模块路径。
+
+Go 导入路径使用 `/`,不是 Windows 路径中的反斜杠;不要写相对导入 `./score`。
+
+## 3. 大写导出,小写包内使用
+
+```go
+const Max = 100
+
+func Parse(text string) (int, error) {
+    // ...
+}
+
+func valid(value int) bool {
+    // ...
+}
+```
+
+- Max、Parse 大写开头:导出,其他包可以访问。
+- valid 小写开头:只允许同一个包访问,不是“只允许同一个文件访问”。
+
+主程序可写 `score.Parse("85")` 和 `score.Max`,不能调用 `score.valid(85)`。函数、类型、变量、常量以及结构体字段都要考虑这条可见性规则。
+
+Parse 是包级函数,不是方法:`score.Parse` 前面的 score 是导入的包名;`student.Birthday` 前面的 student 则是接收者变量。虽然都有点号,含义不相同。
+
+## 4. 导入路径与包名
+
+本课导入路径最后一段是 score,源码声明也是 package score,因此用 `score.Parse` 调用。
+
+导入路径是“到哪里找到包”,包名是“代码里怎样引用它”;两者通常一致,但不是必须一致。也可以显式起别名,例如将现有 import 行改成:
+
+```go
+import grades "example.com/go-study/lessons/15_packages/score"
+```
+
+随后使用 `grades.Parse` 和 `grades.Max`。不要同时保留两个写法导致重复导入。一般优先使用清晰的默认包名,只有冲突或确有需要时再起别名。
+
+Go 不允许普通的未使用导入;import 了就要实际使用。包之间也不能形成循环导入。本课是 main 导入 score,不让 score 反过来导入 main。
+
+## 5. 外部测试包
+
+前一课用同包测试,可以访问未导出函数。本课测试文件写:
+
+```go
+package score_test
+```
+
+这是 Go 对测试的特殊支持:同一目录中的 `_test.go` 文件可以使用 `包名_test` 作为外部测试包名。
+
+它像其他使用者一样显式 import score,只能访问导出的 Max 和 Parse,不能访问 valid。这里没有在 score 目录混放两个普通包,因为 score_test 只用于测试文件。
+
+两种测试方式都合法:
+
+- `package score`:同包测试,可直接测试包内成员。
+- `package score_test`:外部测试,验证对外接口。
+
+本课选择外部测试是为了练习导入和可见性,不代表所有测试都必须这样写。
+
+## 6. 依赖管理命令
+
+当前没有第三方模块依赖,不需要 go get,也没有 go.sum 是正常现象。下列命令在仓库根目录执行:
+
+```powershell
+# 查看模块路径
+go list -m
+
+# 查看本课的两个包
+go list ./lessons/15_packages/...
+
+# 根据源码整理模块依赖
+go mod tidy
+```
+
+将来需要第三方库时,再选择可信模块和明确版本执行 `go get 模块路径@版本`,并检查 go.mod / go.sum 的改动。`go install ...@版本` 安装命令行工具,不等于给项目添加库依赖。本课不执行无必要的第三方安装。
+
+## 编译并运行
+
+在**仓库根目录**执行:
+
+```powershell
+New-Item -ItemType Directory -Force bin/15_packages | Out-Null
+go build -o ./bin/15_packages/packages.exe ./lessons/15_packages
+.\bin\15_packages\packages.exe
+```
+
+Go 会自动编译入口包依赖的 score 包,不需要手工先编译 score.go。修改任一相关源码后,重新编译程序。
+
+快速编译并运行:
+
+```powershell
+go run ./lessons/15_packages
+```
+
+进入本课目录后也可以执行 `go run .`。不要只写 `go run score/score.go`,因为 score 是库包,不是可执行入口。
+
+## 预期输出
+
+```text
+=== 调用自定义包 ===
+成绩上限:100
+输入 "85" 成功:85
+输入 "100" 成功:100
+输入 "101" 失败:成绩 101 无效,应在 0~100 之间
+输入 "abc" 失败:strconv.Atoi: parsing "abc": invalid syntax
+main 负责组织流程,score 包负责成绩转换与校验
+```
+
+标准库错误文字可能随版本变化,不应逐字依赖。
+
+## 测试
+
+在仓库根目录执行:
+
+```powershell
+# 包括主程序目录及所有子包的测试
+go test -count=1 -v ./lessons/15_packages/...
+
+# 只测试 score 包
+go test -v ./lessons/15_packages/score
+```
+
+`./lessons/15_packages` 只指本课入口包;加 `/...` 才会递归包含 score 子包。普通入口包可能显示 `[no test files]`,实际成绩测试在 score 子包中。
+
+## 动手练习
+
+- [ ] 画出 main 导入 score、score 导入标准库的关系。
+- [ ] 新增导出常量 Min,在包内校验中使用,并让 main 输出它。
+- [ ] 临时将 Parse 改成 parse,观察 main 和外部测试的编译错误,再恢复。
+- [ ] 使用 grades 导入别名,修改相应调用,确认行为不变。
+- [ ] 在 score 目录新建另一个 package score 源文件,增加函数,理解一个包可以包含多个文件。
+- [ ] 给新导出函数补一条外部测试,并运行递归测试命令。
+
+若另行手敲完整例程,放到本课的 practice 子目录,主程序 import 路径也要指向自己创建的子包;不要仅复制 score 目录却忘记修改导入路径,导致仍调用参考实现。
+
+下一课学习接口 interface,认识不同类型如何通过相同的方法约定被统一使用。
+
+[上一课:单元测试专题](../14_testing/README.md) · [学习总览](../../README.md)

+ 26 - 0
lessons/15_packages/main.go

@@ -0,0 +1,26 @@
+package main
+
+import (
+	"fmt"
+
+	// 导入路径 = 根目录 go.mod 的 module 路径 + 包目录相对路径。
+	"example.com/go-study/lessons/15_packages/score"
+)
+
+func main() {
+	fmt.Println("=== 调用自定义包 ===")
+	fmt.Printf("成绩上限:%d\n", score.Max)
+
+	inputs := []string{"85", "100", "101", "abc"}
+	for _, input := range inputs {
+		value, err := score.Parse(input)
+		if err != nil {
+			fmt.Printf("输入 %q 失败:%v\n", input, err)
+			continue
+		}
+		fmt.Printf("输入 %q 成功:%d\n", input, value)
+	}
+
+	// score.valid(85) // 错误:valid 小写开头,不允许从其他包访问。
+	fmt.Println("main 负责组织流程,score 包负责成绩转换与校验")
+}

+ 27 - 0
lessons/15_packages/score/score.go

@@ -0,0 +1,27 @@
+// Package score 提供成绩转换与校验功能。
+package score
+
+import (
+	"fmt"
+	"strconv"
+)
+
+// Max 是导出的常量,大写开头,其他包可以使用 score.Max。
+const Max = 100
+
+// valid 是包内函数,小写开头,其他包不能直接调用。
+func valid(value int) bool {
+	return value >= 0 && value <= Max
+}
+
+// Parse 将文本转换为合法成绩。大写开头,允许其他包调用。
+func Parse(text string) (int, error) {
+	value, err := strconv.Atoi(text)
+	if err != nil {
+		return 0, err
+	}
+	if !valid(value) {
+		return 0, fmt.Errorf("成绩 %d 无效,应在 0~%d 之间", value, Max)
+	}
+	return value, nil
+}

+ 39 - 0
lessons/15_packages/score/score_test.go

@@ -0,0 +1,39 @@
+// score_test 是外部测试包,只通过导出的接口测试 score 包。
+package score_test
+
+import (
+	"testing"
+
+	"example.com/go-study/lessons/15_packages/score"
+)
+
+func TestParse(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   string
+		want    int
+		wantErr bool
+	}{
+		{name: "normal", input: "85", want: 85},
+		{name: "zero", input: "0", want: 0},
+		{name: "maximum", input: "100", want: score.Max},
+		{name: "negative", input: "-1", wantErr: true},
+		{name: "above_maximum", input: "101", wantErr: true},
+		{name: "invalid_text", input: "abc", wantErr: true},
+		{name: "empty", input: "", wantErr: true},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := score.Parse(tt.input)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("Parse(%q):错误=%v,期望发生错误=%t", tt.input, err, tt.wantErr)
+			}
+			if tt.wantErr {
+				return
+			}
+			if got != tt.want {
+				t.Errorf("Parse(%q):实际=%d,期望=%d", tt.input, got, tt.want)
+			}
+		})
+	}
+}

+ 201 - 0
lessons/16_interfaces/README.md

@@ -0,0 +1,201 @@
+# 第 16 课:接口 interface
+
+## 学习目标
+
+让不同类型通过相同的方法约定被统一使用。掌握接口声明、隐式实现、接口参数与切片、值和指针的方法集,以及最基本的 nil 接口区别。
+
+先手敲 [main.go](./main.go),运行成功后再看 [main_test.go](./main_test.go)。本课共享根目录的 `go.mod`,只用标准库,无需安装依赖。不引入泛型约束、类型断言、类型 switch 或反射。
+
+## 1. 接口描述行为
+
+```go
+type Introducer interface {
+    Introduce() string
+}
+```
+
+这是一个接口类型,要求实现者提供“没有参数、返回 string”的 Introduce 方法。接口中写方法签名,不写方法体,也不写学生姓名等数据字段。
+
+```go
+func (student Student) Introduce() string {
+    return "我是学生:" + student.Name
+}
+```
+
+Student 具备这个方法,就自动实现 Introducer,不需要写 implements,也不是通过继承某个基类实现。
+
+方法名、参数和返回值类型必须匹配;只有一个同名结构体字段或包级函数,不算实现接口。大小写也有区别,introduce 不等于 Introduce。
+
+## 2. 函数接收接口
+
+```go
+func introduction(person Introducer) string {
+    return person.Introduce()
+}
+```
+
+传入学生时调用学生的方法,传入老师时调用老师的方法。函数只依赖“能自我介绍”这个能力,不依赖具体字段。
+
+这就是一种多态:相同调用,具体行为由实际类型决定。函数仍然按值传参,不会因为参数是接口就变成引用传递。
+
+接口不一定越多越好。简单、固定的计算函数不必为了形式而抽象;当确实存在多个实现,或调用方只需要很少的方法时,小接口才有价值。
+
+## 3. 静态接口类型与动态具体类型
+
+```go
+var person Introducer = Student{Name: "小朱"}
+person = Teacher{Name: "王老师", Subject: "Go"}
+```
+
+person 变量的静态类型始终是 Introducer,内部保存的具体值和动态类型可以变化。`fmt.Printf("%T", person)` 显示动态具体类型。
+
+可以先按“接口值包含具体类型和对应值”理解,不要把这当成必须依赖的内存布局。
+
+通过接口变量只能直接调用该接口声明的方法;即使具体学生有 Name 字段,也不能写 `person.Name`。需要具体类型特有能力时,后续再学习类型断言。
+
+## 4. 接口切片
+
+```go
+people := []Introducer{student, teacher, &Assistant{Name: "小陈"}}
+```
+
+每个元素都必须能作为 Introducer 使用,但具体类型可以不同。循环通过相同接口调用即可,不需要对每个类型写 if 分支。
+
+注意 `[]Student` 不会自动转换为 `[]Introducer`;需要逐个元素赋值或 append,因为两个切片的元素类型不同。
+
+## 5. 值接收者与指针接收者
+
+本课三种实现:
+
+| 类型 | 方法接收者 | 能赋给 Introducer 的值 |
+| --- | --- | --- |
+| Student | Student | Student 和 *Student |
+| Teacher | Teacher | Teacher 和 *Teacher |
+| Assistant | *Assistant | 只有 *Assistant |
+
+对于本课这种普通命名类型 T,T 的方法集包含接收者为 T 的方法;*T 的方法集同时包含接收者为 T 和 *T 的方法。
+
+因此:
+
+```go
+var person Introducer = &Assistant{Name: "小陈"} // 正确。
+// var person Introducer = Assistant{Name: "小陈"} // 错误。
+```
+
+上一课说可取地址变量可以自动调用指针方法,但那是**直接方法调用的便利规则**,不会让 Assistant 值在接口赋值时自动变成 *Assistant。
+
+不必死背所有方法集边界,先能解释本课这三种实现即可。
+
+## 6. nil 接口与包含 nil 指针的接口
+
+```go
+var empty Introducer
+```
+
+没有具体类型、也没有具体值,因此 empty 等于 nil,不能调用其方法。
+
+```go
+var assistant *Assistant
+var person Introducer = assistant
+```
+
+assistant 是 nil 指针,但 person 中仍保存了动态类型 *Assistant,所以 person 不等于 nil。
+
+本课 Assistant.Introduce 主动检查了 nil 接收者,因此这个调用能安全返回提示。不代表所有实现都支持 nil;有些方法会访问字段并 panic。仅检查 `person != nil` 不能保证其中没有 nil 指针。
+
+这是 Go 常见易错点,本课先观察现象即可。特别是返回 error 时,通常应直接 `return nil` 表示无错误,不要把某个 nil 具体指针装进 error 接口后误以为它仍等于 nil。
+
+## 7. error 也是接口
+
+前面的 error 可以先理解成:
+
+```go
+type error interface {
+    Error() string
+}
+```
+
+这是预定义接口的定义形式示意,**不要在本课自行重新声明它**。
+
+具备 Error() string 方法的类型就能作为 error 使用。这解释了为什么 strconv 的转换错误和 errors.New 的错误,都能通过 error 返回。自定义错误类型留到进阶练习。
+
+## 8. 接口为什么有利于测试
+
+测试里定义了 fakeIntroducer:它只返回预先设置的固定文本,也满足接口。
+
+```go
+func (fake fakeIntroducer) Introduce() string {
+    return fake.text
+}
+```
+
+把它传给 introduction,就能验证函数是否调用并返回接口方法的结果,不需要真实学生或老师。这个小例子只是演示机制,业务逻辑很少,测试收益有限。
+
+真实项目中,同样的思路可以让业务依赖小型存储接口,用内存实现替代外部数据库,或避免测试时发送真实通知。并不是所有测试都需要替身,也不必为每个类型都创建接口。
+
+测试中的 `t.Fatal` 与 `t.Fatalf` 一样会标记失败并停止当前测试,只是 Fatal 不使用格式占位符。
+
+## 编译并运行
+
+在**仓库根目录**执行:
+
+```powershell
+New-Item -ItemType Directory -Force bin/16_interfaces | Out-Null
+go build -o ./bin/16_interfaces/interfaces.exe ./lessons/16_interfaces
+.\bin\16_interfaces\interfaces.exe
+```
+
+修改源码后需要重新编译。快速编译并运行:
+
+```powershell
+go run ./lessons/16_interfaces
+```
+
+或从根目录进入本课:
+
+```powershell
+Set-Location lessons/16_interfaces
+go run .
+```
+
+## 预期输出
+
+```text
+=== 不同类型实现同一个接口 ===
+我是学生:小朱
+我是老师:王老师,教授Go
+
+=== 接口变量与动态类型 ===
+实际类型:main.Student,介绍:我是学生:小朱
+实际类型:main.Teacher,介绍:我是老师:王老师,教授Go
+
+=== 接口切片 ===
+我是学生:小朱
+我是老师:王老师,教授Go
+我是助教:小陈
+
+=== 接口与 nil ===
+未赋值接口为 nil:true
+保存 nil 指针后,接口为 nil:false
+助教信息为空
+```
+
+## 测试与练习
+
+在仓库根目录执行测试:
+
+```powershell
+go test -count=1 -v ./lessons/16_interfaces
+```
+
+- [ ] 新增 Robot 类型,实现 Introduce() string,再加入 people。
+- [ ] 给一个实现增加额外字段,观察 introduction 是否需要修改。
+- [ ] 临时将 Assistant 值而不是指针赋给接口,观察编译错误,再恢复。
+- [ ] 给 fakeIntroducer 换一段文本,观察测试结果。
+- [ ] 对比未赋值接口和包含 nil 指针的接口,解释为什么 nil 判断不同。
+
+若另行手敲,放到本课的 `practice/` 子目录,用 `go run ./lessons/16_interfaces/practice` 运行,不要在同一个包中重复声明 main 或同名类型。
+
+下一课学习文件读写与路径操作,将数据从内存保存到文件。
+
+[上一课:包与模块](../15_packages/README.md) · [学习总览](../../README.md)

+ 75 - 0
lessons/16_interfaces/main.go

@@ -0,0 +1,75 @@
+package main
+
+import "fmt"
+
+// 1. 接口只约定需要什么方法,不规定结构体有哪些字段。
+type Introducer interface {
+	Introduce() string
+}
+
+type Student struct {
+	Name string
+}
+
+// 方法签名符合接口要求,Student 就自动实现 Introducer。
+func (student Student) Introduce() string {
+	return "我是学生:" + student.Name
+}
+
+type Teacher struct {
+	Name    string
+	Subject string
+}
+
+func (teacher Teacher) Introduce() string {
+	return fmt.Sprintf("我是老师:%s,教授%s", teacher.Name, teacher.Subject)
+}
+
+// 2. 函数依赖接口,不限定必须传 Student 或 Teacher。
+// 调用方负责传入可正常使用的实现,而不是 nil。
+func introduction(person Introducer) string {
+	return person.Introduce()
+}
+
+// 3. 指针接收者示例:只有 *Assistant 实现这个接口。
+type Assistant struct {
+	Name string
+}
+
+func (assistant *Assistant) Introduce() string {
+	// 本方法特意支持 nil 指针,其他实现不一定支持。
+	if assistant == nil {
+		return "助教信息为空"
+	}
+	return "我是助教:" + assistant.Name
+}
+
+func main() {
+	fmt.Println("=== 不同类型实现同一个接口 ===")
+	student := Student{Name: "小朱"}
+	teacher := Teacher{Name: "王老师", Subject: "Go"}
+	fmt.Println(introduction(student))
+	fmt.Println(introduction(teacher))
+
+	fmt.Println("\n=== 接口变量与动态类型 ===")
+	var person Introducer = student
+	fmt.Printf("实际类型:%T,介绍:%s\n", person, person.Introduce())
+	person = teacher
+	fmt.Printf("实际类型:%T,介绍:%s\n", person, person.Introduce())
+
+	fmt.Println("\n=== 接口切片 ===")
+	people := []Introducer{student, teacher, &Assistant{Name: "小陈"}}
+	for _, item := range people {
+		fmt.Println(introduction(item))
+	}
+
+	fmt.Println("\n=== 接口与 nil ===")
+	var empty Introducer
+	fmt.Printf("未赋值接口为 nil:%t\n", empty == nil)
+	// empty.Introduce() // 没有实际实现,调用会 panic。
+
+	var assistant *Assistant
+	person = assistant // 保存了具体类型 *Assistant,即使其中的指针值为 nil。
+	fmt.Printf("保存 nil 指针后,接口为 nil:%t\n", person == nil)
+	fmt.Println(person.Introduce())
+}

+ 54 - 0
lessons/16_interfaces/main_test.go

@@ -0,0 +1,54 @@
+package main
+
+import "testing"
+
+// 测试替身:只提供接口需要的方法,不依赖真实 Student 或 Teacher。
+type fakeIntroducer struct {
+	text string
+}
+
+func (fake fakeIntroducer) Introduce() string {
+	return fake.text
+}
+
+func TestIntroduction(t *testing.T) {
+	fake := fakeIntroducer{text: "固定测试内容"}
+	got := introduction(fake)
+	if got != fake.text {
+		t.Fatalf("实际=%q,期望=%q", got, fake.text)
+	}
+}
+
+func TestImplementations(t *testing.T) {
+	tests := []struct {
+		name   string
+		person Introducer
+		want   string
+	}{
+		{name: "student", person: Student{Name: "小朱"}, want: "我是学生:小朱"},
+		{name: "teacher", person: Teacher{Name: "王老师", Subject: "Go"}, want: "我是老师:王老师,教授Go"},
+		{name: "assistant", person: &Assistant{Name: "小陈"}, want: "我是助教:小陈"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := introduction(tt.person); got != tt.want {
+				t.Errorf("实际=%q,期望=%q", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestInterfaceNil(t *testing.T) {
+	var empty Introducer
+	if empty != nil {
+		t.Fatal("未赋值接口应为 nil")
+	}
+	var assistant *Assistant
+	var person Introducer = assistant
+	if person == nil {
+		t.Fatal("包含具体类型的 nil 指针接口不应等于 nil")
+	}
+	if got := person.Introduce(); got != "助教信息为空" {
+		t.Errorf("nil 助教的介绍不符合预期:%q", got)
+	}
+}