关于 golang-structs-interfaces
面向开发者和编码 Agent 的 Go 类型设计技能,涵盖结构体、接口、泛型和组合,强调由使用方定义小接口并返回具体类型。
主要功能
- 01在使用方定义小型接口
- 02优先返回具体类型
- 03设计可直接使用的结构体零值
- 04用泛型实现类型安全操作
- 05使用接口检查和依赖注入
适用场景
- 01为使用方程序包设计接口
- 02通过注入依赖构建可测试服务
- 03审查结构体和接收者设计
- 04在编译时检查接口实现
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ samber/ cc-skills-golang --skill golang-structs-interfaces安装后在 Claude Code 中输入/golang-structs-interfaces确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
evals/evals.jsonreferences/struct-fields.mdtype-assertions.md详细介绍
**角色:**你是一名 Go 类型系统设计师。你推崇小巧、可组合的接口和具体的返回类型——设计时注重可测试性和清晰度,而非为了抽象而抽象。
**社区默认规则。**明确取代
samber/cc-skills-golang@golang-structs-interfaces技能的公司技能优先。
Go 结构体与接口
接口设计原则
保持接口小巧
“接口越大,抽象越弱。”——Go 格言
接口应有 1-3 个方法。小接口更易于实现、模拟和组合。如果需要更大的契约,请用小接口组合而成:
→ 接口命名规范(方法名 + “-er” 后缀、规范名称)请参阅 samber/cc-skills-golang@golang-naming 技能
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// Composed from small interfaces
type ReadWriter interface {
Reader
Writer
}
用较小的接口组合成更大的接口:
type ReadWriteCloser interface {
io.Reader
io.Writer
io.Closer
}
在使用接口的地方定义接口
接口属于使用方。
接口必须在使用处定义,而非实现处。这样可以让使用方掌控契约,也避免仅为使用接口而导入某个包。
// package notification — defines only what it needs
type Sender interface {
Send(to, body string) error
}
type Service struct {
sender Sender
}
email 包导出一个具体的 Client 结构体——它无需了解 Sender。
接受接口,返回结构体
函数应接受接口参数以提高灵活性,并返回具体类型以确保清晰。调用方可以完整访问返回类型的字段和方法;上游使用方仍可根据需要将结果赋给接口变量。
// Good — accepts interface, returns concrete
func NewService(store UserStore) *Service { ... }
// Bad — an interface return hides every other method of the concrete type from callers
func NewService(store UserStore) ServiceInterface { ... }
不要过早创建接口
“不要用接口来设计,要去发现接口。”
在第二种实现出现之前编写接口,相当于猜测哪些方法会变化——而这种猜测通常是错的,因此无论如何都得重新调整抽象。与此同时,它还增加了一层间接性,使读者和工具难以看到具体类型。从具体类型开始;当第二个使用方、第二种实现或测试模拟需要接口时,再提取接口。
// Bad — premature interface with a single implementation
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type userRepository struct { db *sql.DB }
// Good — start concrete, extract an interface later when needed
type UserRepository struct { db *sql.DB }
让零值可用
设计结构体时,应让它们无需显式初始化即可工作。设计良好的零值可以减少构造函数的样板代码,并避免与 nil 相关的错误:
// Good — zero value is ready to use
var buf bytes.Buffer
buf.WriteString("hello")
var mu sync.Mutex
mu.Lock()
// Bad — zero value is broken, requires constructor
type Registry struct {
items map[string]Item // nil map, panics on write
}
// Good — lazy initialization guards the zero value
func (r *Registry) Register(name string, item Item) {
if r.items == nil {
r.items = make(map[string]Item)
}
r.items[name] = item
}
能用特定类型时,避免使用 any / interface{}
从 Go 1.18 起,对于类型安全的操作,必须优先使用泛型而非 any。仅在类型确实未知的边界处使用 any(例如 JSON 解码、反射):
// Bad — loses type safety
func Contains(slice []any, target any) bool { ... }
// Good — generic, type-safe
func Contains[T comparable](slice []T, target T) bool { ... }
重要的标准库接口
| 接口 | 包 | 方法 |
|---|---|---|
Reader | io | Read(p []byte) (n int, err error) |
Writer | io | Write(p []byte) (n int, err error) |
Closer | io | Close() error |
Stringer | fmt | String() string |
error | builtin | Error() string |
Handler | net/http | ServeHTTP(ResponseWriter, *Request) |
Marshaler | encoding/json | MarshalJSON() ([]byte, error) |
Unmarshaler | encoding/json | UnmarshalJSON([]byte) error |
必须遵循规范的方法签名——如果你的类型有 String() 方法,它必须符合 fmt.Stringer。不要自行创造 ToString() 或 ReadData()。
编译时接口检查
使用空白标识符赋值,在编译时验证某个类型是否实现了接口。将其放在类型定义附近:
var _ io.ReadWriter = (*MyBuffer)(nil)
这不会产生任何运行时开销。如果 MyBuffer 不再满足 io.ReadWriter,构建会立即失败。
类型断言与类型 switch
类型断言必须使用 comma-ok 形式(s, ok := val.(string))——单值形式在类型不匹配时会触发 panic,而不是进行分支处理。使用类型 switch 根据动态类型进行分派,并断言为小型可选接口(if f, ok := w.(Flusher); ok),以利用更丰富的实现,而不必扩大已声明的参数类型。
→ 参见 类型断言与类型 switch,了解类型 switch 的顺序、nil 情况以及可选行为模式。
结构体与接口嵌入
结构体嵌入
嵌入会将内部类型的方法和字段提升到外部类型——这是组合,不是继承:
type Logger struct {
*slog.Logger
}
type Server struct {
Logger
addr string
}
// s.Info(...) works — promoted from slog.Logger through Logger
s := Server{Logger: Logger{slog.Default()}, addr: ":8080"}
s.Info("starting", "addr", s.addr)
提升方法的接收者是_内部_类型,而不是外部类型。外部类型可以定义同名方法来覆盖它。
何时使用嵌入,何时使用命名字段
| 用途 | 时机 |
|---|---|
| 嵌入 | 希望提升内部类型的完整 API——外部类型是增强版的“内部类型” |
| 命名字段 | 只需在内部使用内部类型——外部类型“拥有一个”依赖项 |
// Embed — Server exposes all http.Handler methods
type Server struct {
http.Handler
}
// Named field — Server uses the store but doesn't expose its methods
type Server struct {
store *DataStore
}
通过接口进行依赖注入
在构造函数中将依赖项作为接口接收。这样可以解耦组件,并简化测试:
type UserStore interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type UserService struct {
store UserStore
}
func NewUserService(store UserStore) *UserService {
return &UserService{store: store}
}
在测试中,传入满足 UserStore 的 mock 或 stub——无需真实数据库。
结构体字段标签
序列化结构体中的导出字段必须带有字段标签——如果没有,编码器会回退到 Go 字段名,因此重命名字段会悄无声息地改变传输格式:
type Order struct {
ID string `json:"id" db:"id"`
Total float64 `json:"total" db:"total"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
Internal string `json:"-" db:"-"`
}
→ 参见 结构体字段:标签与复制安全,了解完整的标签指令表、omitempty 与 omitzero 的陷阱,以及 go vet 诊断信息。
指针接收者与值接收者
使用指针 (s *Server) | 使用值 (s Server) |
|---|---|
| 方法会修改接收者 | 接收者较小且不可变 |
接收者包含 sync.Mutex 或类似内容 | 接收者是基本类型(int、string) |
| 接收者是大型结构体 | 方法是只读访问器 |
| 保持一致:如果有任何方法使用指针,所有方法都应使用指针 | Map 和函数值(本身已是引用类型) |
同一类型的所有方法必须保持接收者类型一致——如果某个方法使用指针接收者,所有方法都应如此。
使用 noCopy 防止结构体复制
持有互斥锁、通道或内部指针的结构体在复制后会出问题:副本会复制锁状态,因此两个 goroutine 会分别保护不同的互斥锁,而不变量会悄无声息地失效。嵌入 noCopy 哨兵,让 go vet 报告每一次值复制,并通过指针传递此类结构体。
诊断: 1- go vet ./... — copylocks 会报告包含锁的结构体被按值复制的情况
→ 参见 结构体字段:标签与复制安全,了解 noCopy 的实现以及 vet 如何检测它。
交叉引用
- → 有关接口命名约定(Reader、Closer、Stringer),请参见
samber/cc-skills-golang@golang-namingskill - → 有关函数式选项、构造函数和构建器模式,请参见
samber/cc-skills-golang@golang-design-patternsskill - → 有关使用接口实现 DI 的模式,请参见
samber/cc-skills-golang@golang-dependency-injectionskill - → 有关值参数与指针参数(与接收者不同),请参见
samber/cc-skills-golang@golang-code-styleskill - → 有关安全重命名和
implementInterface代码操作,请参见samber/cc-skills-golang@golang-goplsskill——重命名参与接口满足关系的方法或接收者时,会更新所有调用点,并拒绝会悄然破坏接口的重命名;grep/sed 无法检测这种情况
常见错误
| 错误 | 修正方法 |
|---|---|
| 接口过大(5 个以上方法) | 拆分为专注于 1-3 个方法的接口,并在需要时组合 |
| 在实现方的包中定义接口 | 在使用接口的地方定义 |
| 构造函数返回接口 | 返回具体类型 |
| 类型断言未使用逗号-ok 形式 | 始终使用 v, ok := x.(T) |
| 只需要少数几个方法,却使用嵌入 | 使用具名字段并显式委托 |
| 序列化结构体缺少字段标签 | 为序列化类型中的所有导出字段添加标签 |
| 同一类型混用指针接收者和值接收者 | 选择一种并保持一致 |
| 忘记编译时接口检查 | 添加 var _ Interface = (*Type)(nil) |
使用 ToString() 而非 String() | 遵循规范的方法名称 |
| 只有一个实现时就过早定义接口 | 从具体类型开始,按需提取接口 |
| 零值结构体中的 map/slice 为 nil | 在方法中使用延迟初始化 |
使用 any 进行类型安全操作 | 改用泛型([T comparable]) |