Go 类型系统:结构体、方法、接口与泛型

Go 的类型系统并不追求复杂的继承层次,而是通过结构体、方法、接口和组合建立清晰的抽象。掌握这些机制后,才能正确设计领域模型、服务边界和可复用组件。

本文示例适用于 Go 1.21 及以上版本。

结构体:为数据建立明确边界

结构体将一组相关字段组合为一个类型。未显式赋值的字段会获得对应类型的零值,因此 Go 很多类型可以直接声明后使用。

package main

import "fmt"

type User struct {
	ID      int64
	Name    string
	Email   string
	Enabled bool
}

func main() {
	var empty User
	fmt.Printf("%+v\n", empty)

	user := User{
		ID:      1,
		Name:    "Alice",
		Email:   "[email protected]",
		Enabled: true,
	}
	fmt.Println(user.Name)
}

推荐使用带字段名的复合字面量。即使以后调整字段顺序或增加字段,调用代码也不容易发生隐蔽错误。

结构体标签

标签是附加在字段上的元数据,常用于 JSON、数据库映射和参数校验。

type CreateUserRequest struct {
	Name  string `json:"name" validate:"required,min=2,max=50"`
	Email string `json:"email" validate:"required,email"`
}

标签本身不会执行任何逻辑,需要由 encoding/json 或验证库读取。标签格式写错通常不会在编译阶段报错,应通过测试覆盖序列化结果。

组合优于继承

Go 没有传统类继承。通过嵌入类型,可以复用字段和方法,同时保持对象关系清晰。

type AuditFields struct {
	CreatedAt int64
	UpdatedAt int64
}

type Order struct {
	AuditFields
	ID     int64
	Amount int64
}

Order 可以直接访问 CreatedAt,但它并不是 AuditFields 的子类。嵌入只是组合和方法提升,不应被当作继承树使用。

当字段名发生冲突时,必须写出完整路径:

fmt.Println(order.AuditFields.CreatedAt)

命名类型、类型别名与显式转换

基于已有底层类型定义新类型,可以建立更强的业务边界:

type UserID int64
type OrderID int64

func LoadUser(id UserID) {}

UserIDOrderID 的底层类型都是 int64,但它们是两个不同类型,不能被意外混用。需要转换时必须显式表达:

raw := int64(42)
userID := UserID(raw)

这种设计能让编译器阻止“把订单 ID 传给用户查询”等错误,还可以为命名类型定义方法:

func (id UserID) Valid() bool {
	return id > 0
}

类型别名使用等号,它不会创建新类型:

type Byte = uint8

别名主要用于包迁移和保持 API 兼容,不适合用来建立领域边界。普通业务类型应优先使用 type UserID int64 这种命名类型。

结构体之间即使字段完全相同,也不会自动转换。可以在底层结构一致时显式转换,但更推荐编写转换函数,让字段含义和校验保持清晰:

func toUserDTO(user User) UserDTO {
	return UserDTO{
		ID:   int64(user.ID),
		Name: user.Name,
	}
}

方法与接收者

方法是绑定到命名类型上的函数。

type Counter struct {
	value int
}

func (c Counter) Value() int {
	return c.value
}

func (c *Counter) Add(delta int) {
	c.value += delta
}

值接收者会复制接收者,适合以下情况:

  • 类型较小且复制成本低。
  • 方法不需要修改接收者。
  • 类型本身具有值语义,例如坐标、时间或金额。

指针接收者适合以下情况:

  • 方法需要修改字段。
  • 结构体较大,不希望每次调用都复制。
  • 类型内部包含锁、缓存或其他不可复制状态。

同一个类型的方法应尽量统一接收者风格。包含 sync.Mutex 的结构体不能在使用后复制,相关方法必须使用指针接收者。

方法集与接口实现

如果方法定义在 T 上,T*T 的方法集都可以包含它;如果方法定义在 *T 上,只有 *T 实现对应接口。

type Incrementer interface {
	Add(int)
}

var _ Incrementer = (*Counter)(nil)

最后一行是编译期接口检查。它不会创建对象,却能在接口或实现变化时立即暴露不兼容问题。

接口:描述行为而不是数据

Go 类型只要拥有接口要求的方法,就会隐式实现该接口,不需要额外声明。

type UserStore interface {
	FindByID(ctx context.Context, id int64) (User, error)
	Save(ctx context.Context, user User) error
}

接口通常应定义在使用方,而不是实现方。服务只声明自己真正依赖的能力,数据库实现、内存实现和测试替身都可以接入。

type UserService struct {
	store UserStore
}

func NewUserService(store UserStore) *UserService {
	return &UserService{store: store}
}

保持接口小而稳定

优先设计只有一到三个方法的小接口。大接口会迫使实现方提供无关能力,也会让测试替身变得笨重。

标准库中的 io.Reader 只有一个方法,却能连接文件、网络、压缩和内存缓冲区:

type Reader interface {
	Read(p []byte) (n int, err error)
}

nil 接口陷阱

接口值由动态类型和动态值组成。只有两者都为空时,接口才等于 nil

type AppError struct {
	Message string
}

func (e *AppError) Error() string { return e.Message }

func load() error {
	var err *AppError
	return err
}

func main() {
	err := load()
	fmt.Println(err == nil) // false
}

这里返回的接口包含 *AppError 动态类型,虽然指针值为空,接口本身仍不为空。没有错误时应直接 return nil

类型断言与类型选择

func describe(v any) string {
	switch value := v.(type) {
	case string:
		return "string: " + value
	case int:
		return fmt.Sprintf("int: %d", value)
	case fmt.Stringer:
		return value.String()
	default:
		return "unknown"
	}
}

单次断言应使用逗号 ok 形式,避免类型不匹配时触发 panic:

name, ok := value.(string)
if !ok {
	return errors.New("value is not a string")
}

泛型:复用算法与容器

泛型适合类型不同但算法完全相同的场景。不要仅为了减少几行重复代码就引入类型参数。

泛型函数

标准库 cmp 包提供了有序类型约束:

package collection

import "cmp"

func Min[T cmp.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

调用时通常不需要显式写出类型参数:

smallest := Min(12, 7)
word := Min("go", "gopher")

自定义约束与底层类型

~int 表示底层类型为 int 的所有类型,因此自定义类型也能满足约束。

type Integer interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

func Sum[T Integer](values []T) T {
	var total T
	for _, value := range values {
		total += value
	}
	return total
}

type Score int

func example() {
	result := Sum([]Score{10, 20, 30})
	fmt.Println(result)
}

泛型容器

type Stack[T any] struct {
	items []T
}

func (s *Stack[T]) Push(value T) {
	s.items = append(s.items, value)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	if len(s.items) == 0 {
		return zero, false
	}

	last := len(s.items) - 1
	value := s.items[last]
	s.items = s.items[:last]
	return value, true
}

返回零值和布尔值比在空栈时 panic 更适合作为通用容器 API。

如何选择抽象方式

  • 数据字段固定、需要表达领域状态:使用结构体。
  • 行为属于某个类型:使用方法。
  • 调用方需要替换实现或隔离依赖:定义小接口。
  • 算法对多种类型完全一致:考虑泛型。
  • 只是共享实现代码:优先组合,不要创建庞大接口。
  • 只有一个实现且没有测试替换需求:暂时使用具体类型,等边界出现后再抽象。

常见设计问题

提前定义大接口

先写出包含十几个方法的仓储接口,往往意味着边界尚未明确。应从调用方实际使用的方法开始,随着稳定需求演进。

到处使用 any

any 会把类型检查推迟到运行时。配置解析、协议边界等动态数据可以使用它,领域代码应尽快转换为明确类型。

滥用泛型模拟继承

泛型解决的是静态类型复用,不适合构建继承层次。需要运行时替换行为时,接口通常更直接。

复制带状态的结构体

结构体包含互斥锁、原子值或大型缓存时,应使用指针传递,并避免从方法中返回其副本。可以运行 go vet -copylocks ./... 检查锁复制问题。

实践检查清单

  1. 结构体是否具有可用的零值,或者通过构造函数建立必要约束。
  2. 方法接收者是否保持一致,修改状态时是否使用指针。
  3. 接口是否由使用方定义,并保持足够小。
  4. 返回接口时是否可能产生带类型的 nil 指针。
  5. 泛型是否真的复用了稳定算法,而不是隐藏业务差异。
  6. 是否用编译期断言验证关键接口实现。
  7. 是否避免复制包含锁或不可复制资源的结构体。

理解结构体、方法、接口和泛型各自解决的问题,比记住语法更重要。清晰的类型边界能让编译器更早发现错误,也能让代码在需求变化时保持可维护性。