Go 没有继承。类型系统的核心是两个角色:结构体(struct)组织数据,接口(interface)描述行为,两者通过隐式实现连接——一个类型只要拥有接口要求的全部方法,即视为实现了该接口,无需 implements 声明。"组合优于继承"在 Go 中不是风格建议,而是由编译器执行的默认规则。

结构体与接口的高频错误高度集中:接口定义得过大、过早;把嵌入(embedding)当作继承使用;同一类型的接收者在值与指针之间混用;类型断言不使用 comma-ok 形式,使运行时 panic 进入生产环境;为字段改名而无意中改变序列化协议。多数问题的共同根源,是将其他语言类型系统的直觉直接迁移到 Go。以下逐项分析,示例统一省略 package 与 import。

零值可用

设计结构体时应遵循一条原则:保证零值直接可用。 标准库大量采用该设计:bytes.Buffer、sync.Mutex 声明后即可使用,无需构造函数。零值可用省去一层样板代码,并消除一类"忘记初始化"导致的 nil 缺陷。

var buf bytes.Buffer
buf.WriteString("hello") // 零值即可用

var mu sync.Mutex
mu.Lock() // 同样零值即可用

含 map、slice 等引用类型字段的结构体需要专门处理:nil map 一旦写入即 panic。此时不应强制所有调用方经过构造函数,应在方法内做惰性初始化,使零值结构体同样可用;惰性初始化不提供并发安全,并发场景需显式同步:

type Registry struct {
    items map[string]Item // 零值为 nil,直接写入会 panic
}

// 方法内惰性初始化,零值结构体也能直接用
func (r *Registry) Register(name string, item Item) {
    if r.items == nil {
        r.items = make(map[string]Item)
    }
    r.items[name] = item
}

含锁、channel 或内部指针的结构体禁止值拷贝,必须按指针传递。 值拷贝会将锁状态一并复制,两个副本各持独立锁,并发保护因此静默失效,编译器不报任何错误。go vet 的 copylocks 检查器用于检测此类拷贝:标准库的 sync.WaitGroup 嵌入了 noCopy 哨兵——一个仅定义 Lock/Unlock 方法的空结构体,使包含它的类型被识别为锁,每次值拷贝都会被报告;strings.Builder 则在运行时检测非法拷贝,拷贝后调用方法会 panic:

type noCopy struct{}
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

type ConnPool struct {
    noCopy noCopy
    mu     sync.Mutex
    conns  []*Conn
}

// ✅ 按指针传递
func process(pool *ConnPool) {}
// ❌ func process(pool ConnPool) —— go vet 会标记这次值拷贝

日常检查配置:go vet ./... 检出拷贝,go test -race 检出拷贝引发的数据竞争。

嵌入不是继承

嵌入(embedding)将内部类型的字段和方法提升到外层,语义是"外层是内部类型的增强版"。判断标准只有一条:需要"提升完整 API"时才嵌入;仅内部持有依赖时,使用命名字段。 命名字段表达"外层拥有一个依赖",内部类型的方法不进入外层方法集:

// 嵌入:Server 天然就是 http.Handler,可直接注册
type Server struct {
    http.Handler
}

// 命名字段:store 只是内部依赖,不外泄其方法
type Server struct {
    store *DataStore
}

一个易被忽略的细节:被提升方法的接收者仍是内部类型,不是外层。外层需要接管该方法时,应定义同名方法将其遮蔽:

type Server struct {
    *slog.Logger // 嵌入指针,日志方法被提升
    addr         string
}

s := &Server{Logger: slog.Default(), addr: ":8080"}
s.Info("starting", "addr", s.addr) // 调用的是 *slog.Logger 的方法

反之,只需内部类型的个别方法时不应嵌入:嵌入会把该类型的全部 API 并入外层方法集,调用方会误认为这些行为属于外层契约的一部分,内部类型的任何变动都成为外层公共 API 的变动。应使用命名字段加显式委托,只暴露确实需要的能力。

值接收者还是指针接收者

同一类型的所有方法,接收者类型必须统一;只要有一个方法使用指针,全部方法都应使用指针。 混用导致方法集不对称:同一类型时而满足接口、时而不满足,并增加阅读成本。单个方法的取舍依据如下表:

用指针 (s *Server)用值 (s Server)
方法需要修改接收者接收者小且不可变
含 sync.Mutex 等锁字段基本类型(int、string)
结构体较大只读访问器
类型里已有指针接收者(保持一致)map、函数等本身就是引用类型

接口要小,定义在使用方

接口应保持 1~3 个方法,更大的契约由小接口组合而成。Go 谚语指出 "The bigger the interface, the weaker the abstraction.":接口越大,实现与 mock 的成本越高,抽象越名不副实。需要宽接口时应通过组合获得,不应堆叠方法:

// 由小接口组合而成
type ReadWriter interface {
    io.Reader
    io.Writer
}

接口必须定义在使用方,而不是实现方。 使用方声明自己需要的能力,实现方只需恰好满足,双方无需互相 import。email 包导出具体的 Client 结构体即可,无需知道 Sender 的存在:

// package notification:只声明自己需要的能力
type Sender interface {
    Send(to, body string) error
}

type Service struct {
    sender Sender // 具体实现由调用方注入,此处不关心来源
}

函数应接受接口,返回具体类型。 返回接口会隐藏具体类型的其余方法,调用方需要时必须再做断言;返回结构体,上游需要时随时可将其赋给接口变量:

// ✅ 入参灵活,出参信息完整
func NewService(store UserStore) *Service { ... }

// ❌ 返回接口:具体类型的方法全被隐藏
func NewService(store UserStore) ServiceInterface { ... }

不应过早发明接口。 只有一个实现就抽取接口,相当于对"哪些方法会变化"做出预测,该预测通常不准确,最终往往需要重塑抽象;还引入一层不必要的间接,使具体类型对读者与工具不可见。应先写具体类型,等第二个实现、第二个消费方或测试 mock 出现后再抽取:

// ❌ 单实现的"占位接口"
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

// ✅ 从具体类型开始,需要时再抽接口
type UserRepository struct{ db *sql.DB }

构造函数接受接口做依赖注入,与上述原则不冲突:接口由使用方定义、且确实存在多实现或 mock 需求时,才值得引入。完整的装配方法见依赖注入。

配套的零成本手段是编译期接口检查:var _ Interface = (*Type)(nil) 放在类型定义附近,零运行时开销。类型不再满足接口时,编译立即失败,不必等到测试或线上反馈:

var _ io.ReadWriter = (*MyBuffer)(nil)

实现标准库接口时,方法签名必须与规范完全一致:定义 String() 应遵循 fmt.Stringer,不应自创 ToString()、ReadData()。高频规范接口包括 io.Reader/io.Writer/io.Closer、fmt.Stringer、内置 error、http.Handler、json.Marshaler/json.Unmarshaler。接口与方法的命名约定见命名与代码风格。

类型断言与 type switch

类型断言必须使用 comma-ok 形式。 单值形式在类型不符时直接 panic,一个本可恢复的分支因此演变为线上崩溃:

// ✅ 失败走分支
s, ok := val.(string)
if !ok {
    return errors.New("not a string")
}

// ❌ 类型不符即 panic
s := val.(string)

type switch 的 case 按书写顺序求值:具体类型应放在前,接口类型放在后;nil 是合法输入时应显式写 case nil。具体类型排在它所满足的接口之后,则该分支不可达;nil 接口值匹配 case nil 而非 default,漏写会把合法输入误报为未知类型:

switch v := val.(type) {
case nil:
    return ErrEmpty
case string:
    return v
case io.Reader: // 接口类型放在具体类型之后
    return readAll(v)
default:
    return fmt.Errorf("unexpected type %T", v)
}

探测可选能力时,应断言到小的可选接口,而不是把参数类型声明得很宽;参数保持最小接口,富实现按需利用。标准库的 http.Flusher、io.ReaderFrom、io.WriterTo 均采用该模式:

type Flusher interface{ Flush() error }

func writeData(w io.Writer, data []byte) error {
    if _, err := w.Write(data); err != nil {
        return err
    }
    if f, ok := w.(Flusher); ok { // 支持 Flush 时刷新,否则跳过
        return f.Flush()
    }
    return nil
}

同理,断言目标应是满足需求的最小接口,而非具体类型:断言到 *os.File 会把代码绑定在单一实现上;断言到 interface{ Sync() error } 则能接受任何具备 Sync 能力的实现。

检查错误类型必须使用 errors.As,不应使用类型断言。 errors.As 会沿 %w 包装链向上查找,裸断言 err.(*MyError) 会漏掉任何一层被包装过的错误——细节见错误处理。

类型安全场景应使用泛型,any 只保留给真正的动态边界——JSON 解码、反射等类型确实未知的场景。普通容器操作应使用泛型:

// ❌ 丢失类型安全
func Contains(slice []any, target any) bool

// ✅ 泛型,类型安全
func Contains[T comparable](slice []T, target T) bool

字段标签

参与序列化的导出字段必须显式标注 tag。 缺少 tag 时,编码器回退使用 Go 字段名——字段一旦改名,线上协议随之改变,且全程无任何报错。tag 由反射类编码器驱动,json、db、yaml 各自独立解析:

type Order struct {
    ID        string    `json:"id"         db:"id"`
    Total     float64   `json:"total"      db:"total"`
    Items     []Item    `json:"items"      db:"-"`        // 不映射到数据库列
    DeletedAt time.Time `json:"-"          db:"deleted_at"` // 不输出到 JSON
}

注意:非导出字段永远不会被序列化,为其标注 tag 是无效代码。

omitempty 与 omitzero 必须区分。omitempty 按"零值即省略"工作,0、""、false 均被省略,"显式为零"与"字段缺失"因此不可区分;语义上需要区分时,应改用指针字段,或 Go 1.24+ 引入的、按类型感知的 omitzero。

tag 语法在编译期不做校验:多一个空格(json: "id"),整条 tag 即被静默忽略。go vet 的 structtag 检查器可检出格式错误与重复 tag,应纳入 CI;再配合"marshal → unmarshal → 比对"的往返测试,覆盖字段名漂移问题。

速查清单

  1. 结构体应保证零值可用;含 map、slice 字段的在方法内惰性初始化,不强制构造函数。
  2. 含锁、channel、内部指针的结构体禁止值拷贝:嵌入 noCopy、按指针传递,以 go vet 与 -race 作为最终防线。
  3. 嵌入仅用于提升完整 API;内部依赖使用命名字段,只需个别方法时显式委托。
  4. 被提升方法的接收者是内部类型;外层需接管时定义同名方法将其遮蔽。
  5. 同一类型的方法接收者必须统一:存在一个指针接收者,则全部使用指针接收者。
  6. 接口保持 1~3 个方法;大契约拆分为小接口再组合。
  7. 接口定义在使用方;单实现时不预先抽取接口,待第二个实现或 mock 出现后再抽取。
  8. 函数应接受接口、返回具体类型。
  9. 以 var _ Interface = (*Type)(nil) 置于类型定义旁做编译期检查。
  10. 实现标准库接口时签名遵循规范:使用 String(),而非 ToString()。
  11. 类型断言必须带 comma-ok;type switch 中具体类型在前、接口在后,nil 合法时写 case nil。
  12. 探测可选能力应使用小接口(Flusher 模式);断言目标为满足需求的最小接口,而非具体类型。
  13. 判断错误类型必须使用 errors.As;裸断言会漏掉被包装的错误。
  14. 类型安全场景应使用泛型;any 仅用于 JSON 解码、反射等动态边界。
  15. 导出字段必须显式标注 tag;omitempty 会省略零值,需要区分"缺失"与"为零"时使用 omitzero 或指针字段;CI 中应配置 go vet 与往返测试。