Ver código fonte

feat: 新增第十五课包与模块例程及跨包测试

zhuts 3 dias atrás
pai
commit
66b4ad9511

+ 11 - 4
README.md

@@ -52,10 +52,16 @@
     ├── 13_function_values/ # 第 13 课:函数进阶
     │   ├── README.md       # 函数值、匿名函数、闭包与 t.Run 预习
     │   └── main.go         # 回调计算、独立计数器和 defer 对比
-    └── 14_testing/         # 第 14 课:单元测试专题
-        ├── README.md       # 测试语法、子测试、错误断言和运行命令
-        ├── main.go         # 被测函数与普通程序入口
-        └── main_test.go    # 基础测试、表驱动测试和边界用例
+    ├── 14_testing/         # 第 14 课:单元测试专题
+    │   ├── README.md       # 测试语法、子测试、错误断言和运行命令
+    │   ├── main.go         # 被测函数与普通程序入口
+    │   └── main_test.go    # 基础测试、表驱动测试和边界用例
+    └── 15_packages/        # 第 15 课:包与模块
+        ├── README.md       # 模块路径、导入、导出与外部测试
+        ├── main.go         # 导入自定义包的程序入口
+        └── score/
+            ├── score.go    # 成绩转换与校验包
+            └── score_test.go # 外部测试包
 ```
 
 根目录不放例程代码。各课共享根目录的 `go.mod`,不需要逐课初始化模块,也不需要 `go.work`。当前模块路径 `example.com/go-study` 是学习用标识,不必与 Git 地址相同。
@@ -78,6 +84,7 @@
 | 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、导出、跨包测试 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

+ 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)
+			}
+		})
+	}
+}