5.1 接口定义与隐式实现
第 4 章里,TaskAPI 的数据直接躺在 main.go 的 map[int64]Task 里,add / list 函数伸手就能拿到那个 map。这种写法在第 3 章够用,但它把「怎么存」和「存什么」焊死在了一起:想换一个后端存储,就得改遍所有调用点。本节引入 TaskStore 接口,把「任务存取的契约」从「内存实现」里剥出来。
本节把 TaskAPI 推进到「存储抽象」:定义
TaskStore接口,写出内存实现MemStore,让上层代码只依赖接口而不依赖具体存储,为第 14 章接数据库预留接缝。
5.1.1 接口是契约,不是类
接口在 Go 里是一组方法签名的集合。它只描述「能做什么」,不描述「是什么」:
type TaskStore interface {
Create(title string) (Task, error)
Get(id int64) (Task, bool)
List() []Task
}
这段声明说的是:任何类型,只要拥有这三个方法(名字、参数、返回值完全一致),就满足 TaskStore。接口里没有字段、没有构造函数、没有实现体。它是调用方和实现方之间的一份契约。
和 Java / C++ 不同,Go 的接口满足是隐式的:MemStore 不需要写 implements TaskStore,只要它的方法集恰好覆盖了接口的全部方法,赋值就成立。
type MemStore struct {
nextID int64
tasks map[int64]Task
}
func (m *MemStore) Create(title string) (Task, error) {
m.nextID++
t := Task{ID: m.nextID, Title: title}
m.tasks[t.ID] = t
return t, nil
}
func (m *MemStore) Get(id int64) (Task, bool) {
t, ok := m.tasks[id]
return t, ok
}
func (m *MemStore) List() []Task {
out := make([]Task, 0, len(m.tasks))
for _, t := range m.tasks {
out = append(out, t)
}
return out
}
没有任何一行声明 MemStore 实现 TaskStore——但下面这行赋值能通过编译,这就证明了实现关系:
var s TaskStore = &MemStore{tasks: make(map[int64]Task)}
5.1.2 隐式实现的价值与代价
隐式实现是一把双刃剑,值得说清楚。
价值:接口可以由使用方定义,而不是实现方。TaskStore 是 main.go(或未来的 handler 层)为了解耦而提出的需求,它不必去 internal/store 里改代码、加注解。这带来两个好处:
- 依赖倒置:上层依赖抽象,下层依赖抽象,双方都不依赖对方。
- 无侵入适配:连第三方类型都能被你定义的接口满足,只要它方法名对得上。
代价:没有编译器强制标注,你不容易一眼看出某个类型实现了哪些接口。补救办法是编译期断言:
var _ TaskStore = (*MemStore)(nil)
这行放在 store.go 里,作用是在编译期强制检查 *MemStore 是否满足 TaskStore。如果哪天有人改了 Create 的签名,这行会立刻报错,而不是等到某处赋值时才暴露。项目里每个实现都加一行 var _,是低成本高收益的习惯。
5.1.3 方法集与接口满足
一个类型能否满足接口,取决于它的方法集是否覆盖接口。而方法集又由接收者决定——这正是 4.2 讲过的规则:
| 实现类型 | 值接收者方法 | 指针接收者方法 |
|---|---|---|
MemStore(值) | 在方法集内 | 不在 |
*MemStore(指针) | 在方法集内 | 在方法集内 |
MemStore 的 Create 用指针接收者(它要改 nextID 和 tasks),所以只有 *MemStore 满足 TaskStore,MemStore 本身不满足:
var _ TaskStore = (*MemStore)(nil) // OK
// var _ TaskStore = MemStore{} // 编译错误
这条规则解释了为什么几乎所有「有状态的实现类型」都通过指针来满足接口。第 5 章的 MemStore、第 14 章的 SQLStore,接口变量里存的都是指针。
5.1.4 空接口与 any
interface{} 是零方法的接口,任何类型都满足它。Go 1.18 起提供了别名 any,两者完全等价:
var x any = 42
x = "hello"
x = Task{ID: 1}
因为没有任何方法约束,any 能装下任何值——代价是丢失了所有类型信息。要用里面的值,必须先做类型断言(5.2 的主题)。项目里 any 的正确用法是「确实需要容纳任意类型」的边界,比如 JSON 解码的中间结果、log/slog 的键值对。不要用 any 来偷懒规避类型设计:如果 TaskStore 的 Create 写成 Create(v any) error,那接口的契约就形同虚设。
5.1.5 接口值的内部结构
理解接口值的内部结构,能解释很多「看起来诡异」的行为。一个接口值由两部分组成:动态类型和动态值。
var s TaskStore = &MemStore{...}
// s 的内部:type=*MemStore, value=指向 MemStore 的指针
当接口变量为 nil 时,两部分都是 nil。但有一种著名的陷阱:接口里装了一个 nil 指针,此时接口本身不是 nil:
var p *MemStore
var s TaskStore = p
fmt.Println(s == nil) // false:类型部分仍是 *MemStore
实测输出 false。这与 5.2 的类型断言直接相关:s == nil 为 false,但 s 指向的对象是 nil,调用 s.Create(...) 会 panic。判断接口是否为「有效实现」不能只看 == nil,还要考虑内部值是否为 nil 指针。
对照实验:
var a TaskStore // 完全未赋值
fmt.Println(a == nil) // true
var p *MemStore // 值为 nil 的指针
var b TaskStore = p // 装进接口
fmt.Println(b == nil) // false
5.1.6 标准库如何消费接口:fmt.Stringer
接口的价值在标准库里体现得最充分。fmt 包在格式化任意值时,会检查它是否实现了若干接口,实现了就调用对应方法——fmt.Stringer 是最常用的一个:
type Task struct {
ID int64
Title string
}
func (t Task) String() string { return fmt.Sprintf("#%d %s", t.ID, t.Title) }
func main() {
t := Task{ID: 1, Title: "写接口"}
fmt.Println(t) // #1 写接口
fmt.Println(&t) // #1 写接口
}
实测输出两行 #1 写接口。fmt 内部做的正是 5.2 要讲的类型断言:它不 import 你的包,却能在运行时发现 Task 满足 fmt.Stringer 并调用它。这就是「隐式实现 + 使用方定义接口」的威力——标准库定义接口,你的类型去满足,双方零耦合。
注意 String() 用的是值接收者,所以 Task 和 *Task 都能触发;若改成指针接收者,fmt.Println(t) 就不会调用它,会退化成默认的 {1 写接口}。这是「接收者选择通过方法集影响接口满足」的又一实例。
5.1.7 接口应由使用方定义
这是 Go 接口设计里最重要的一条惯用法,值得单独强调:接口属于调用者,不属于实现者。
反例(不推荐):在 internal/store 包里定义一个大而全的接口,让 MemStore 去实现它。结果接口里塞满了只有某个调用方需要的方法,其他调用方被迫依赖用不到的方法。
正例(推荐):每个调用方按自己实际需要的方法定义最小接口。main.go 需要创建和列出,就定义:
type TaskCreator interface {
Create(title string) (Task, error)
}
未来 HTTP handler 需要查询,再定义一个 TaskReader。这种「小接口」让依赖关系精确、测试替身好写(第 8 章会用到)。
| 反模式 | 正模式 |
|---|---|
| 实现方定义大接口 | 使用方定义最小接口 |
| 接口方法越多越好 | 接口越小越稳定 |
| 先定义接口再写实现 | 先写实现,需要抽象时再抽接口 |
小接口的极致例子是标准库:io.Reader 只有一个方法,io.Writer 只有一个方法,两者组合出整个 I/O 体系。项目里 TaskStore 之所以是 3 个方法而非 10 个,也是同一个原则。
5.1.8 项目落地:TaskStore 与 MemStore
综合以上,第 5 章的存储层定为:
// TaskStore 是任务存取的契约,由使用方定义。
type TaskStore interface {
Create(title string) (Task, error)
Get(id int64) (Task, bool)
List() []Task
}
// MemStore 是 TaskStore 的内存实现。
type MemStore struct {
nextID int64
tasks map[int64]Task
}
// 编译期断言:确保 *MemStore 满足 TaskStore。
var _ TaskStore = (*MemStore)(nil)
上层代码从此只依赖 TaskStore:
func Run(s TaskStore) error {
t, err := s.Create("写接口")
if err != nil {
return err
}
fmt.Println("created:", t)
for _, item := range s.List() {
fmt.Println("task:", item)
}
return nil
}
Run 不知道背后是内存还是数据库。第 14 章接 PostgreSQL 时,只要写一个满足 TaskStore 的 SQLStore,Run 一行都不用改。这就是接口带来的解耦收益,也是第 5 章存在的全部理由。
5.1.9 小结与检查清单
- 接口是方法签名的集合,只描述行为,不含状态
- 满足是隐式的,无需
implements;用var _ I = (*T)(nil)做编译期断言 - 接口应由使用方定义,尽量小
- 指针接收者方法只在指针方法集里,故实现通常用指针满足接口
-
any能装任意值但丢失类型,别拿它规避类型设计 - 接口装 nil 指针时接口本身不为 nil,这是断言与判空的坑
- 标准库靠接口消费你的类型(如
fmt.Stringer),接收者选择会影响能否被识别
下一节处理「如何从接口里取回具体类型」:类型断言与 type switch,它们是把 any 还原成可用值的主要途径。
阅读导航:上一节:4.3 组合与嵌入 · 下一节:5.2 类型断言与 type switch 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。