Przeglądaj źródła

feat: 新增第十六课接口例程与测试替身说明

zhuts 3 dni temu
rodzic
commit
f8619cf98f

+ 11 - 6
README.md

@@ -56,12 +56,16 @@
     │   ├── README.md       # 测试语法、子测试、错误断言和运行命令
     │   ├── main.go         # 被测函数与普通程序入口
     │   └── main_test.go    # 基础测试、表驱动测试和边界用例
-    └── 15_packages/        # 第 15 课:包与模块
-        ├── README.md       # 模块路径、导入、导出与外部测试
-        ├── main.go         # 导入自定义包的程序入口
-        └── score/
-            ├── score.go    # 成绩转换与校验包
-            └── score_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 地址相同。
@@ -85,6 +89,7 @@
 | 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、测试替身 | 已建立 |
 
 后续按编号逐步新增独立目录。不一次性铺开后续课程,也不把新例程写进旧例程的入口文件。
 

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