G03第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

函数、方法与接口

函数、方法与接口 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这课把书签存取从 main 函数里拆出来:定义 Store 接口,实现内存版,让业务代码只依赖接口。读完你能按「使用方需要什么」来设计接口,并完成一次实现替换。

问题场景

G02 的 CountByTag 直接吃切片,数据一进数据库就要重写所有调用点。更实际的问题:测试想用假数据、生产想用 SQLite,两套实现不该污染业务代码。Go 的做法是业务侧定义一个窄接口,两种实现各自满足它,切换只在装配的那一行发生。

核心解释

接口只写使用方需要的方法:

type BookmarkStore interface {
	Add(b Bookmark) (int64, error)
	Get(id int64) (Bookmark, error)
	List() ([]Bookmark, error)
}

内存版实现,用指针接收者:

var ErrNotFound = errors.New("bookmark not found") // G04 讲透错误设计

type MemoryStore struct {
	mu   sync.Mutex
	next int64
	byID map[int64]Bookmark
}

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{byID: make(map[int64]Bookmark)}
}

func (s *MemoryStore) Add(b Bookmark) (int64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.next++
	b.ID = s.next
	s.byID[b.ID] = b
	return b.ID, nil
}

func (s *MemoryStore) Get(id int64) (Bookmark, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	b, ok := s.byID[id]
	if !ok {
		return Bookmark{}, ErrNotFound
	}
	return b, nil
}

Go 的接口是隐式满足的:MemoryStore 写对了方法签名,就自动是 BookmarkStore,不需要 implements 声明。这带来一条黄金法则:接口定义在使用方一侧——调用方只需要遍历,就只依赖 List,不要上来先设计十方法的「万能仓库接口」。标准库到处是单方法接口(io.Reader、fmt.Stringer),窄接口才有第二、第三种实现。

接收者的选择在这里很关键:Add 要改 s.next 和 s.byID,必须用指针接收者。写成值接收者的话,方法操作的是副本,改了也白改,而且编译不报错。惯例是:只要有一个方法用指针接收者,整个类型的所有方法统一用指针接收者。

替换实现时业务代码一行不改:

func CountByTag(s BookmarkStore, tag string) (int, error) {
	bs, err := s.List()
	if err != nil {
		return 0, fmt.Errorf("count by tag %q: %w", tag, err)
	}
	n := 0
	for _, b := range bs {
		for _, t := range b.Tags {
			if t == tag {
				n++
			}
		}
	}
	return n, nil
}

CountByTag 的参数是接口类型,传 *MemoryStore 或将来的 *SQLiteStore 都能编译,G08 写好 SQLiteStore 后换装配行就完成迁移。

示例拆解

模块 bookmark 下组织成三个文件:

store.go   // BookmarkStore 接口 + MemoryStore + ErrNotFound
main.go    // 构造 store,Add 几条,调用 CountByTag 打印

go run . 验证:添加三个书签(两个带 go 标签),输出 count=2。验证接口确实生效有个惯用技巧:在 store.go 里写一行 var _ BookmarkStore = (*MemoryStore)(nil)。这是编译期接口断言,方法签名不匹配编译立刻报错,不用等运行才发现。另一个惯例藏在构造函数里——返回具体类型 *MemoryStore 而不是接口:参数收接口、返回给具体,给调用方留升级空间。

动手任务

  1. 给接口加 Delete(id int64) error,同步更新 MemoryStore:不存在时返回 ErrNotFound。
  2. 写第二个实现 FilteredStore:内嵌一个 BookmarkStore,List 时过滤掉不含指定标签的书签,体验不改原实现就扩展行为。
  3. 用 MemoryStore 和 FilteredStore 分别调用 CountByTag,确认同一份代码两种行为。

常见错误

  • 用值接收者改字段:方法拿到的是副本,改了外部看不见,这种 bug 编译器不报。
  • 先设计大接口再写实现:方法越堆越多,第二个实现写不动。接口从使用方需求里长出来。
  • 忘记初始化 map 字段:直接 Add 会 panic(assignment to entry in nil map),构造函数里必须 make。
  • 为了「通用」在接口里返回 interface{}:丢掉类型信息,调用方被迫做类型断言,错误推迟到运行时。

检查与复盘

完成下方检查题。自查清单:

  • 能说出「接口定义在使用方一侧」的原因
  • 新类型实现接口时习惯加 var _ 接口 = (*实现)(nil) 断言
  • 构造函数返回具体类型,函数参数收接口

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1单选简单

定义了 type Store interface { Get(id string) (Book, error) }。类型 BookmarkService 要满足 Store 接口,需要做什么?

Q2多选中等(多选,全部选对才算正确)

书签服务要支持「内存实现跑测试、SQLite 实现跑生产」。关于接口与存储抽象,下列做法正确的有哪些?(多选,Go 1.22+)

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...