zhuts f8619cf98f feat: 新增第十六课接口例程与测试替身说明 3 дней назад
..
README.md f8619cf98f feat: 新增第十六课接口例程与测试替身说明 3 дней назад
main.go f8619cf98f feat: 新增第十六课接口例程与测试替身说明 3 дней назад
main_test.go f8619cf98f feat: 新增第十六课接口例程与测试替身说明 3 дней назад

README.md

第 16 课:接口 interface

学习目标

让不同类型通过相同的方法约定被统一使用。掌握接口声明、隐式实现、接口参数与切片、值和指针的方法集,以及最基本的 nil 接口区别。

先手敲 main.go,运行成功后再看 main_test.go。本课共享根目录的 go.mod,只用标准库,无需安装依赖。不引入泛型约束、类型断言、类型 switch 或反射。

1. 接口描述行为

type Introducer interface {
    Introduce() string
}

这是一个接口类型,要求实现者提供“没有参数、返回 string”的 Introduce 方法。接口中写方法签名,不写方法体,也不写学生姓名等数据字段。

func (student Student) Introduce() string {
    return "我是学生:" + student.Name
}

Student 具备这个方法,就自动实现 Introducer,不需要写 implements,也不是通过继承某个基类实现。

方法名、参数和返回值类型必须匹配;只有一个同名结构体字段或包级函数,不算实现接口。大小写也有区别,introduce 不等于 Introduce。

2. 函数接收接口

func introduction(person Introducer) string {
    return person.Introduce()
}

传入学生时调用学生的方法,传入老师时调用老师的方法。函数只依赖“能自我介绍”这个能力,不依赖具体字段。

这就是一种多态:相同调用,具体行为由实际类型决定。函数仍然按值传参,不会因为参数是接口就变成引用传递。

接口不一定越多越好。简单、固定的计算函数不必为了形式而抽象;当确实存在多个实现,或调用方只需要很少的方法时,小接口才有价值。

3. 静态接口类型与动态具体类型

var person Introducer = Student{Name: "小朱"}
person = Teacher{Name: "王老师", Subject: "Go"}

person 变量的静态类型始终是 Introducer,内部保存的具体值和动态类型可以变化。fmt.Printf("%T", person) 显示动态具体类型。

可以先按“接口值包含具体类型和对应值”理解,不要把这当成必须依赖的内存布局。

通过接口变量只能直接调用该接口声明的方法;即使具体学生有 Name 字段,也不能写 person.Name。需要具体类型特有能力时,后续再学习类型断言。

4. 接口切片

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 的方法。

因此:

var person Introducer = &Assistant{Name: "小陈"} // 正确。
// var person Introducer = Assistant{Name: "小陈"} // 错误。

上一课说可取地址变量可以自动调用指针方法,但那是直接方法调用的便利规则,不会让 Assistant 值在接口赋值时自动变成 *Assistant。

不必死背所有方法集边界,先能解释本课这三种实现即可。

6. nil 接口与包含 nil 指针的接口

var empty Introducer

没有具体类型、也没有具体值,因此 empty 等于 nil,不能调用其方法。

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 可以先理解成:

type error interface {
    Error() string
}

这是预定义接口的定义形式示意,不要在本课自行重新声明它。

具备 Error() string 方法的类型就能作为 error 使用。这解释了为什么 strconv 的转换错误和 errors.New 的错误,都能通过 error 返回。自定义错误类型留到进阶练习。

8. 接口为什么有利于测试

测试里定义了 fakeIntroducer:它只返回预先设置的固定文本,也满足接口。

func (fake fakeIntroducer) Introduce() string {
    return fake.text
}

把它传给 introduction,就能验证函数是否调用并返回接口方法的结果,不需要真实学生或老师。这个小例子只是演示机制,业务逻辑很少,测试收益有限。

真实项目中,同样的思路可以让业务依赖小型存储接口,用内存实现替代外部数据库,或避免测试时发送真实通知。并不是所有测试都需要替身,也不必为每个类型都创建接口。

测试中的 t.Fatal 与 t.Fatalf 一样会标记失败并停止当前测试,只是 Fatal 不使用格式占位符。

编译并运行

在仓库根目录执行:

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

修改源码后需要重新编译。快速编译并运行:

go run ./lessons/16_interfaces

或从根目录进入本课:

Set-Location lessons/16_interfaces
go run .

预期输出

=== 不同类型实现同一个接口 ===
我是学生:小朱
我是老师:王老师,教授Go

=== 接口变量与动态类型 ===
实际类型:main.Student,介绍:我是学生:小朱
实际类型:main.Teacher,介绍:我是老师:王老师,教授Go

=== 接口切片 ===
我是学生:小朱
我是老师:王老师,教授Go
我是助教:小陈

=== 接口与 nil ===
未赋值接口为 nil:true
保存 nil 指针后,接口为 nil:false
助教信息为空

测试与练习

在仓库根目录执行测试:

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 或同名类型。

下一课学习文件读写与路径操作,将数据从内存保存到文件。

上一课:包与模块 · 学习总览