《Go 语言编程入门》5.1 接口定义与隐式实现

接口是 Go 实现抽象的核心机制。本节为 TaskAPI 定义 TaskStore 接口并写出内存实现 MemStore,讲清隐式实现、接口即契约、方法集与接口满足、空接口与 any、接口值的内部结构、fmt.Stringer 的自动调用,以及为什么接口应由使用方定义。

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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练