Go · interface 与动态派发

接口是方法集的隐式契约 — iface/eface 内存布局, nil 接口不是 nil 指针, 鸭子类型的工程学

取 itab 危险区 — nil 与断言 eface — 空接口 (any) 两个机器字: 只认类型, 不认方法 _type — 类型描述符指针 data — 值的地址 (装箱) var x any = 42 → 42 拷进接口, 常触发堆分配 == 比较: 类型相同且值相等才为 true iface — 非空接口 (带方法集) 也是两个机器字, 但第一字是 itab tab — 指向 itab 方法表 data — 值的地址 runtime.itab — 全局缓存 inter — 接口自己的类型 _type — 具体类型 (*redisStore) hash — 类型断言的快查指纹 fun[0]=Get fun[1]=Set ... 同一 (接口, 具体类型) 对只算一次 itab 接口值恒两个机器字, 与方法数量无关 动态派发 — s.Get() 的两跳寻址 var s Store = &redisStore{} s.Get(ctx, "k1") ① 读 tab 拿到 itab 地址 ② 取 fun[0] 方法入口地址 ③ 间接 call 跳到具体实现 itab.fun: [Get, Set, ...] — 按方法名字典序在编译期排定 目标地址运行期才知道 → 内联被阻断, 逃逸分析变保守 两跳都是依赖内存的读: 比直接调用多两次寻址 去虚化: 编译器能证明 s 只有一种实现时会转直接调用 直接调用 vs 接口调用 直接调用 call 地址编译期定死 可内联成零开销 无装箱无逃逸 热路径首选 vs 接口调用 tab→fun 两跳寻址 难内联 值装箱可能逃逸 架构解耦首选 nil 陷阱 — error 返回翻车 Top1 func find() Store { var p *redisStore = nil return p // 装箱! } s := find() s != nil → true ! tab → itab(*redisStore) 非空! data → nil 接口 ==nil 要求两个字都为 nil; typed nil 只空了一半 → 判定失败 正解: 显式 return nil / 返回具体类型 字段判空: reflect.ValueOf(s).IsNil() 断言纪律 v, ok := s.(*mysqlStore) ok=false 走默认分支, 不 panic switch v := s.(type) // 多路分派 nil case 永远放第一个 方法集规则 — 谁能满足接口 隐式实现 无 implements: 方法集覆盖即实现 接收者规则 值接收者 T/*T 都行; 指针接收者仅 *T 小接口哲学 1~2 个方法最通用; 接口定义在使用侧 Legend 接口内存布局 (eface/iface/itab) 动态派发 / 方法集规则 危险语义 接口调用成本

本质: 方法集的隐式契约

  • • 无 implements, 方法集匹配即实现
  • • 接口值 = 动态类型 + 动态值, 恒两机器字
  • • eface {_type, data} / iface {tab, data}
  • • itab.fun[] 是方法跳转表, 全局缓存

派发与成本

  • • s.Get() = 读 tab → 读 fun[i] → 间接 call
  • • 比直接调用多两次寻址, 内联被阻断
  • • 去虚化: 单实现可被编译器优化成直接调用
  • • 装箱逃逸: pprof 里 convT* 就是现场

nil 与断言纪律

  • • typed nil 装接口后 != nil (error 翻车 Top1)
  • • 断言一律 v, ok := x.(T), 裸断言失败即 panic
  • • type switch 记得 default 兜住未知类型
  • • 动态类型不可比时 == 直接运行时 panic

💡 一句话理解

Go 的接口不是类继承, 是一张方法集的隐式契约: 你不用声明 implements, 方法对得上就算数。接口值在内存里永远是两个机器字——动态类型 + 动态值, 调 s.Get() 时运行时拿 tab 里的 fun 表间接跳转, 这就是"动态派发"。空接口是 eface, 非空接口是 iface, 区别只在第一字是裸类型描述还是带方法表的 itab。

代价与纪律也要记牢: 两跳寻址 + 装箱逃逸, 热路径要掂量; 最著名的陷阱是 typed nil——具体类型的 nil 指针装进接口后 != nil。工程口诀: 接口定义在使用侧, 越小越好; accept interfaces, return structs。

🧠 必知必会 必考 & 必会

隐式实现
Go 没有 implements: 一个类型只要方法集完整覆盖接口的方法集, 就算实现了它 —— 第三方类型不用改源码就能满足你定义的接口, 相当于"编译期检查的鸭子类型"。
type Sayer interface{ Say() string }
type Dog struct{}                     // 没写任何 implements
func (d Dog) Say() string { return "wang" }
var s Sayer = Dog{}                   // 关键: 方法集覆盖即实现
fmt.Println(s.Say())                  // → wang
方法集规则
值接收者方法进 T 和 *T 的方法集; 指针接收者方法只进 *T。所以 func (s *S) Get() 实现接口时, S{} 值不满足接口, 只有 &S{} 满足, 编译报 "S does not implement Store (method Get has pointer receiver)"。
func (r *R) Get() { r.n++ }
var s1 Store = R{}                    // 错: R does not implement Store (pointer receiver)
var s2 Store = &R{}                   // 关键: *R 满足, R 不满足
接口组合
io.ReadWriter = io.Reader + io.Writer, 组合是接口唯一的"继承"形态; 标准库大量小接口靠组合拼出大能力, 实现者却只需各管一摊。
var rw io.ReadWriter = os.Stdout      // Reader+Writer 能力都在
n, _ := rw.Write([]byte("hi"))
fmt.Println(n)                        // → 2, 组合拼大, 各管一摊
类型断言
v := x.(T) 失败直接 panic; 生产代码一律 v, ok := x.(T), ok=false 走默认分支 —— 把"运行时爆炸"降级成"可处理的分支"。
var x any = "hello"
n := x.(int)                    // 错: panic: interface conversion
s, ok := x.(string)           // 对: ok=true, s="hello"
if !ok { return def }             // 关键: 爆炸降级成分支
type switch
switch v := x.(type) 按动态类型多路分派; nil case 放最前, default 兜住未知类型, 否则新增类型会被静默吞掉。
switch v := x.(type) {
case nil:                       // nil case 永远放最前
case string: fmt.Println(len(v))
default:                         // 关键: 兜住未知类型
    log.Printf("unknown %T", v)
}
nil 的两个维度
接口值 = (tab, data) 两字, 两字全 nil 才 == nil。typed nil 装进接口后 tab 非空 → i != nil 为 true, 这是 Go 最著名的翻车点。
type I interface{ Get() }
var p *S                           // typed nil: 具体类型的 nil 指针
var i I = p                        // 装箱: (tab=*S, data=nil)
fmt.Println(i != nil)             // → true! 只空了一半
eface / iface
空接口是 eface {_type, data}; 非空接口是 iface {tab, data}, itab 里带 inter/_type/hash/fun[]。接口本身恒两个机器字, 与方法多少无关 —— 方法都在 itab 里。
var e any = 300              // eface {_type, data}
var i fmt.Stringer = time.Hour      // iface {tab, data}
fmt.Println(unsafe.Sizeof(e), unsafe.Sizeof(i))
// → 16 16 : 恒两机器字, 方法全在 itab 里
动态派发成本
s.Get() 要先读 tab 再读 fun[i] 才能间接 call, 比直接调用多两次依赖内存的寻址, 且内联被阻断; 编译器"去虚化"能在证明单实现时优化掉。
r := &redisStore{}
r.Get(ctx, "k")                    // 直接调用: 编译期定址, 可内联
var s Store = r
s.Get(ctx, "k")                    // 接口: 读 tab → 读 fun[0] → 间接 call
// 微基准约 1ns → 5ns, 内联被阻断
接口可比较性
两接口 == 要求动态类型相同且动态值相等; 动态类型不可比 (slice/map/func) 时运行时 panic。sentinel error 判等用的就是接口相等。
a, b := any([]int{1}), any([]int{1})
_ = a == b                          // panic: comparing uncomparable type []int
x, y := a.([]int), b.([]int)
fmt.Println(slices.Equal(x, y))     // → true, 内容比较走具体类型
小接口哲学
标准库大量单方法接口 (Reader/Writer/Stringer); 接口越大越脆弱 —— 加一个方法, 所有实现者都要跟着改, 这也是"接口定义在使用侧"的理由。
// 大接口: 加一个方法, 所有实现者都要跟着改
type Store interface{ Get(); Set(); Del(); Stat(); Ping() }
// 单方法接口最通用: io.Reader 只有 Read 一个
var _ io.Reader = strings.NewReader("x")
accept interfaces, return structs
参数用窄接口收 (调用方塞什么都行), 返回值用具体 struct (调用方拿到全部字段方法, 不用断言) —— Go 社区的 API 设计共识。
func New(c *redis.Client) Cacher    // 错: 参数锁具体客户端, 返回锁接口
func New(r Getter) *Cache            // 对: 参数收窄接口
// 关键: 返回 struct, 调用方拿全部字段免断言
接口与泛型分工
泛型是编译期多态 (零派发成本, 适合容器/算法), 接口是运行时多态 (可跨边界传, 适合策略/mock/插件)。1.18+ 后 slices.SortFunc 接走了 sort.Interface 的大半场景。
slices.SortFunc(nums, func(a, b int) int { return a - b })
// 泛型: 编译期多态, 零派发 → 容器/算法
var s Store = &redisStore{}          // 接口: 运行时多态 → 策略/mock
装箱与逃逸
值装进接口常触发堆分配 (小整数/bool 有 runtime 静量表可免); pprof allocs 里一片 convT* 就是装箱现场 —— 热路径慎装接口。
func box(n int) any { return n }
box(42)                             // → 0 allocs: 0~255 走 runtime 静态表
box(300)                            // → 1 alloc: convT64, pprof 现场在这
接口定义在使用侧
依赖倒置的 Go 版: 消费包自己定义"我需要的那一两个方法"的窄接口, 让第三方 SDK 的具体类型隐式满足它 —— 依赖方向反转, 换库不动业务代码。
// package order (消费侧) — 不 import 任何短信 SDK
type Sender interface{ Send(ctx context.Context, phone, text string) error }
type Service struct{ s Sender }      // 关键: 换供应商只换装配行

🏭 生产实战 real world

场景 1 · 存储层抽象: Store 接口 + redis/mysql 双实现 + 内存 fake

订单域只关心"取/存字节", 不该被具体存储绑架; 测试也不该为了一条路径起真 Redis:

type Store interface {                                   // 定义在使用侧: 域内只要这两个方法
    Get(ctx context.Context, key string) ([]byte, error) // 只依赖行为, 不依赖介质
    Set(ctx context.Context, key string, val []byte, ttl time.Duration) error
}

type redisStore struct{ r *redis.Client }                 // 指针接收者: 连接池绝不能被拷贝
func (s *redisStore) Get(ctx context.Context, key string) ([]byte, error) {
    return s.r.Get(ctx, key).Bytes()
}
func (s *redisStore) Set(ctx context.Context, k string, v []byte, ttl time.Duration) error {
    return s.r.Set(ctx, k, v, ttl).Err()
}

type memStore struct{ m map[string][]byte }               // 测试 fake: 内存实现同样满足 Store
func (s *memStore) Get(_ context.Context, k string) ([]byte, error) {
    v, ok := s.m[k]                                          // 单测注入它, 不碰真 Redis, ms 级跑完
    if !ok { return nil, ErrNotFound }
    return v, nil
}

场景 2 · io.Reader/Writer 流式管道: 10GB 备份, 512MB 内存跑通

全量读进内存必炸; 整条链路用 Reader/Writer 组合, 中间任何一环都能替换:

func archive(ctx context.Context, src io.Reader, dst io.Writer) error {
    gw, _ := gzip.NewWriterLevel(dst, gzip.BestSpeed)   // 压缩先行: 级别换 CPU, 管道内存恒定
    defer gw.Close()                                       // 不 Close 尾块丢失 → 文件损坏
    ew := &encWriter{w: gw}                                  // 自定义 Writer 实现 AES-GCM 加密
    buf := make([]byte, 256<<10)                          // 256KB 滑动窗口: 与文件大小无关
    for {
        if err := ctx.Err(); err != nil { return err }    // 支持取消: 备份中途可停
        n, err := src.Read(buf)
        if n > 0 {
            if _, werr := ew.Write(buf[:n]); werr != nil { return werr }
        }
        if err == io.EOF { return nil }
        if err != nil { return fmt.Errorf("archive read: %w", err) }
    }
}

收益: 峰值内存 = 缓冲区大小, 10GB 流照跑; 中间加解密/上报环都是十几行的 Writer 装饰。

场景 3 · type switch 处理多态 webhook payload

支付回调同一字段可能是对象/数组/字符串, 断言链写到底不如一次分派:

func handleEvent(evt map[string]any) error {
    raw, _ := json.Marshal(evt["payload"])
    var v any
    if err := json.Unmarshal(raw, &v); err != nil { return err }
    switch p := v.(type) {                              // 按动态类型分派, 各自结构不同
    case map[string]any:                                 // charge.succeeded: 对象
        id, _ := p["id"].(string)
        amt, _ := p["amount"].(float64)
        return handleCharge(id, amt)
    case []any:                                           // batch.refunded: 数组逐条
        for _, it := range p {
            if m, ok := it.(map[string]any); ok { handleRefund(m) }
        }
        return nil
    case string:                                           // 旧版纯文本事件: 兼容分支
        return handleLegacy(p)
    case nil:                                              // 空事件: 显式忽略而非 panic
        return nil
    default:
        return fmt.Errorf("unknown payload type %T", v) // 新类型进 default, 可见可查
    }
}

场景 4 · 单测 mock 取舍: 手写函数字段 stub vs gomock

接口 ≤3 个方法时手写 fake 十几行最直观; 方法一多才轮到 gomock/mockery:

// 手写 stub: 函数字段让每个用例现编行为, 断言"调用过什么"也直接
type stubStore struct{
    get func(ctx context.Context, k string) ([]byte, error) // 每用例注入不同实现
    setCalls int                                          // 顺手计数, 验证写入次数
}
func (s *stubStore) Get(ctx context.Context, k string) ([]byte, error) { return s.get(ctx, k) }
func (s *stubStore) Set(_ context.Context, _ string, _ []byte, _ time.Duration) error {
    s.setCalls++
    return nil
}

func TestOrderCacheMiss(t *testing.T) {
    st := &stubStore{get: func(_ context.Context, _ string) ([]byte, error) {
        return nil, redis.Nil                               // 模拟未命中: 业务走回源分支
    }}
    svc := NewOrderService(st)
    ... // 断言回源后 st.setCalls == 1: 未命中后写回缓存
}

场景 5 · 逗号-ok 断言防 panic 的连接升级分支

网关里拿到的连接可能是 *tls.Conn 也可能是裸 TCP, 裸断言一个脏连接就炸整个 worker:

if tlsConn, ok := conn.(*tls.Conn); ok {              // 逗号-ok: 失败只是 false
    st := tlsConn.ConnectionState()                     // TLS 连接: 拿 ALPN 协商结果
    if st.NegotiatedProtocol == "h2" {
        return serveHTTP2(tlsConn)
    }
    return serveHTTP1(tlsConn)
} else if tcp, ok := conn.(*net.TCPConn); ok {        // 明文: 补 TCP 保活
    tcp.SetKeepAlive(true)
    tcp.SetKeepAlivePeriod(90 * time.Second)
    return serveHTTP1(tcp)
}
return fmt.Errorf("unsupported conn type %T", conn) // 未知类型显式报错, 不静默

场景 6 · 装饰器: 用接口包 http.RoundTripper 做重试与慢日志

http.Client 的扩展点就是 Transport 接口, 包一层就是中间件, 不用碰业务代码:

type logTransport struct{ next http.RoundTripper }
func (t *logTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    start := time.Now()
    resp, err := t.next.RoundTrip(req)                  // 装饰: 包住默认 Transport
    if d := time.Since(start); d > 800*time.Millisecond {
        log.Warnf("slow upstream %s %s cost=%s", req.Method, req.URL.Path, d)
    }
    return resp, err
}

type retryTransport struct{ next http.RoundTripper }
func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    var lastErr error
    for i := 0; i < 3; i++ {                              // 只有幂等 GET 才敢自动重试
        if req.Method != http.MethodGet { break }
        resp, err := t.next.RoundTrip(req)
        if err == nil { return resp, nil }
        lastErr = err                                    // 网关超时/连接重置才值得重试
    }
    return nil, lastErr
}

client := &http.Client{Timeout: 5 * time.Second,     // 洋葱式装配: 日志包重试包默认
    Transport: &logTransport{next: &retryTransport{next: http.DefaultTransport}}}

场景 7 · Feature 接口做功能开关, 配置驱动热切换灰度

推荐 V2 要 5% → 50% → 100% 放量, 回滚只换装配不改代码:

type Feature interface{ Enabled(ctx context.Context, uid int64) bool }

type staticFeat struct{ on bool }                         // 阶段1: 配置中心一刀切
func (f *staticFeat) Enabled(context.Context, int64) bool { return f.on }

type rolloutFeat struct{ rdb *redis.Client }              // 阶段2: 按桶百分比放量
func (f *rolloutFeat) Enabled(ctx context.Context, uid int64) bool {
    v, err := f.rdb.Get(ctx, fmt.Sprintf("feat:rec:v2:%d", uid%100)).Int()
    return err == nil && v == 1                            // 桶号命中开白名单
}

type RecService struct{ v2 Feature }                      // 依赖接口: 运行时换实现
func (s *RecService) Rec(ctx context.Context, uid int64) []Item {
    if s.v2.Enabled(ctx, uid) { return s.recV2(ctx, uid) }
    return s.recV1(ctx, uid)                             // 事故回滚: 装回 staticFeat{false}
}

场景 8 · 隐式接口解耦第三方 SDK: consumer 侧定义窄接口

业务包直接 import 短信 SDK, SDK 一升级全量重编译还连带测试; 倒置依赖方向:

// package sms (业务包) — 不 import 任何第三方 SDK, 只声明自己要的形状
type Sender interface{ Send(ctx context.Context, phone, text string) error }

type Service struct{ s Sender }
func New(s Sender) *Service { return &Service{s: s} }     // 阿里云/腾讯云谁满足谁进来

// package smsali (适配层) — 全项目唯一 import 那个 SDK 的地方
type aliAdapter struct{ cli *aliyun.Client }
func (a *aliAdapter) Send(ctx context.Context, phone, text string) error {
    req := aliyun.NewSendRequest(phone, text, "中秋活动", "TPL_001")
    _, err := a.cli.Do(ctx, req)                          // SDK 细节被关在适配层里
    return err
}
// 换供应商 = 换一行装配; 单测给 Sender 塞 fake, SDK 永远不进测试进程

场景 9 · sort.Interface 一句话引出泛型: 排序的两次进化

老接口三方法样板代码一堆还要付派发钱, 1.21+ 泛型一行零派发:

// 旧写法: 实现 sort.Interface 三方法 (Len/Less/Swap) 才能排
type ByDeadline []*Task
func (a ByDeadline) Len() int           { return len(a) }
func (a ByDeadline) Less(i, j int) bool { return a[i].deadline < a[j].deadline }
func (a ByDeadline) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
sort.Sort(ByDeadline(tasks))                             // 接口派发 + 不稳定排序

// 新写法 (Go 1.21+): 泛型实例化, 零接口派发开销
slices.SortFunc(tasks, func(a, b *Task) int {
    return a.deadline.Compare(b.deadline)             // cmp 三态: 负/零/正
})
// 容器与算法选泛型, 运行时多态才留给接口 —— 分工明确

场景 10 · pprof 排查接口装箱逃逸: allocs 里的 convT64

埋点函数把 int 塞进 any 每秒四千万次, allocs 火焰图一片 convT64 占 9%:

// 现象: go tool pprof -sample_index=alloc_objects heap
//   runtime.convT64  39,812,044 (9.2%)  ← 接口装箱现场
func track(sh *metrics.Handler, route string, costMs int) {
    sh.Observe("route_cost", map[string]any{    // any 装箱: 每次调用 2 个堆对象
        "route": route, "ms": costMs,               // string/int 全部逃逸
    })
}

// 修复: 热路径改具体类型签名, 不走 any 装箱
sh.ObserveInt("route_cost_ms", route, costMs)     // int 直进环形数组, 零分配

// 验证: go test -bench=. -benchmem
//   BenchmarkTrack-8   allocs/op  2 → 0, ns/op  142 → 38

收益: 埋点链路 CPU 降 8%, GC 压力同步下降 —— 接口的灵活是拿分配买的。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · nil 指针装进接口后 != nil — 查库 miss 时 var p *redisStore = nil; return p 当 error 返回, 调用方 if err != nil 恒为真, 走错兜底分支. 原因: 接口两字里 tab 非空, 只空了一半. 正解: 显式 return nil, 或干脆返回具体类型。
func find() Store {
    var p *redisStore = nil
    return p                      // 错: 装箱后 s != nil → true
}
func find() Store { return nil }   // 对: 显式返回 nil 接口
坑 2 · 指针接收者 + 值类型变量不满足接口 — 编译报 "does not implement ... (method has pointer receiver)". 原因: 值拷贝寻不到址, 方法集不含指针接收者方法. 正解: &s{} 装接口, 或接收者统一改值语义。
func (r *R) Get() {}
var s1 Store = R{}                  // 错: does not implement (pointer receiver)
var s2 Store = &R{}                 // 对: 指针接收者方法只进 *R 方法集
坑 3 · 裸断言失败直接 panic — x.(T) 类型不符当场炸, 一条脏数据拖挂整个请求. 正解: 一律 v, ok := x.(T), ok=false 走默认分支并记日志。
v := x.(int)                    // 错: 类型不符当场 panic
v, ok := x.(int)                // 对: 失败只是 ok=false
if !ok { log.Warn("bad data"); return def }
坑 4 · 接口装大结构体按值拷贝 — 4KB 的 struct 值装接口, 每次装箱全量拷贝再加一次堆分配. 正解: 存 *T 进接口, 或热路径直接用具体类型参数。
var w1 Writer = task4KB{}          // 错: 每次装箱拷 4KB + 堆分配
var w2 Writer = &task4KB{}         // 对: 指针装箱只拷一个地址
func hot(w *task4KB)               // 对: 热路径直接具体类型参数
坑 5 · 接口 == 比较运行时 panic — 动态类型是 slice/map/func 时 i == j 直接崩 "comparing uncomparable type". 正解: 先断言回具体类型再比, 内容比较用 bytes.Equal/slices.Equal。
a, b := any([]int{1}), any([]int{1})
_ = a == b                          // 错: panic: comparing uncomparable type []int
x, y := a.([]int), b.([]int)
fmt.Println(slices.Equal(x, y))     // 对: → true
坑 6 · type switch 漏 default — 新增事件类型没有匹配 case, 被静默吞掉, 排查半天才发现"少处理了一类". 正解: default 里报错或记日志, 让未知类型可见。
switch v := evt.(type) {
case string: handle(v)
                                    // 错: 无 default, 新类型被静默吞掉
default:
    return fmt.Errorf("unknown %T", v) // 对: 未知类型可见可查
}
坑 7 · 无意中满足接口被别人依赖 — 给类型加了个 Close() error, 瞬间"实现了" io.Closer, 框架开始自动调它关闭资源. 正解: 公共类型的方法命名避开标准接口签名, 或在文档里明确承诺语义。
func (f *File) Close() error { ... }  // 错: 命中 io.Closer, 框架自动调它
func (f *File) release() {}          // 对: 小写避开标准接口签名
坑 8 · 结构体里滥用接口嵌套导致方法来源混乱 — struct { Store; io.Closer } 两层来源, 出了 bug 分不清 Get 是谁实现的. 正解: 显式命名字段 + 构造函数注入, 保持依赖可追溯。
type Svc1 struct{ Store; io.Closer }   // 错: 方法两层来源, bug 难定位
type Svc2 struct{                       // 对: 显式命名字段
    store  Store
    closer io.Closer                   // 依赖可追溯, 注入走构造函数
}
坑 9 · 接口装箱吃掉热路径性能 — pprof allocs 里 convT64/convTstring 高企. 原因: 值装接口触发堆分配. 正解: 热路径改具体类型签名, 或预分配 boxed 值。
sh.Observe("cost", map[string]any{"ms": ms})
// 错: convT64 每秒 4 千万次堆分配
sh.ObserveInt("cost_ms", ms)          // 对: 具体类型签名, 零分配
坑 10 · map[string]any 全链路传参 — 编译期零检查, 取值全靠断言链, 字段改名全靠 grep. 正解: 定义 struct 承载结构化数据; 过渡期用带类型 getter 封装断言逻辑。
func Render(d map[string]any) {
    u := d["user"].(string)         // 错: 断言链, 改名靠 grep
}
type Vars struct{ User string; Age int }
func RenderV(v Vars) { ... }          // 对: struct 编译期检查
坑 11 · 接口加方法导致全线编译错 — Store 加一个 Del, 十几个实现全红. 原因: 大接口 + 隐式实现的代价. 正解: 先想想是否该拆小/另建接口, 已发布接口尽量封闭, 用组合扩展。
type Store interface{ Get(); Set(); Del() }
// 错: 加 Del, 十几个实现全红
type Deleter interface{ Del() }        // 对: 另建小接口
type StoreEx interface{ Store; Deleter } // 对: 组合扩展, 老实现不动
坑 12 · 接口定义在实现侧 — SDK 包导出几十个方法的巨型接口, 消费者被迫依赖一堆用不到的方法, mock 也要全实现. 正解: 接口定义在使用侧, 消费包自建 1~2 方法的窄接口。
// package smsdk (实现侧)
type Client interface{ A(); B(); C(); ... }  // 错: 消费者被迫全依赖
// package order (使用侧)
type Sender interface{ Send(phone, text string) error } // 对: 窄接口
坑 13 · 接口组合方法签名冲突 — 两个嵌入接口同名方法但签名不同, 编译报 ambiguous selector. 正解: 组合前核对签名; 冲突时新建接口显式声明自己的方法集。
type A interface{ Read() int }
type B interface{ Read() string }
type C1 interface{ A; B }             // 错: duplicate method Read
type C2 interface{ Read() string }      // 对: 显式声明自己的方法集
坑 14 · 泛型约束与接口来回绕 — func f[T any](x T) 里又把 x 断言回具体类型, 白绕一圈还丢了类型安全. 正解: 约束写准 (自定义约束接口/comparable), 断言只留给真正的运行时多态。
func f[T any](x T) {
    s, ok := x.(string)           // 错: any 约束再断言, 白绕一圈
    _ = s; _ = ok
}
func maxOf[T cmp.Ordered](a, b T) T { if a > b { return a }; return b } // 对
坑 15 · 用接口解循环引用却引入运行时断言 — A/B 包互 import 编译不过, 抽接口后又靠 x.(Concrete) 强转回去, 编译期问题变成运行时地雷. 正解: 共享类型下沉第三包, 接口只出现在边界上。
// 错: 抽接口解循环后又强转回具体类型
h, ok := svc.(*aPkg.Handler)
if !ok { panic("not handler") }      // 编译期问题变运行时地雷
// 对: 共享类型下沉第三包, 接口只出现在边界
坑 16 · 接口实现的并发安全责任不清 — 文档没写 Store 是否并发安全, 使用方有的加锁有的裸调, 偶发脏读. 正解: 接口注释写明并发契约 ("并发安全"/"需外部同步"), 与 error 语义一起当 API 合同。
type Store1 interface{ Get(k string) []byte }  // 错: 契约没写, 脏读偶发
// Store 的实现是并发安全的, 可被多 goroutine 同时调用。
type Store2 interface{ Get(k string) []byte }  // 对: 语义写进注释
坑 17 · mockery 生成物与真实接口漂移 — 接口加了方法忘 re-generate, 旧 mock 编译通过测试全绿, 上线才炸真实现. 正解: go generate 挂进 CI, 生成物 diff 即失败。
// 错: 接口加方法忘 re-generate, 旧 mock 全绿上线炸
//go:generate mockgen -source=store.go Store    // 指令放接口文件头
// 对: CI 跑 go generate ./... && git diff --exit-code
坑 18 · sentinel error 用 == 比较踩空 — 自定义错误是含指针/map 的结构, err == ErrXxx 比的是接口两字, 偶尔判不上. 正解: sentinel 用包级 errors.New, 比较统一走 errors.Is (详见本库 go-error 页)。
var ErrX1 = &MyErr{}               // 错: 指针 sentinel, == 比指针
var ErrX2 = errors.New("x")         // 对: 包级 sentinel
if errors.Is(err, ErrX2) {}          // 对: 判等统一走 errors.Is
坑 19 · any 当万能参数 — func Render(v any) 调用方传啥都行, 内部 type switch 越写越长, 新类型 = 改函数. 正解: 同构操作用泛型, 真多态定义小接口让调用方实现。
func Render1(v any)               // 错: type switch 越写越长
func Render2[T fmt.Stringer](v T)    // 对: 同构操作用泛型
type View interface{ Render() }     // 对: 真多态用小接口
坑 20 · 接收者风格混用导致方法集半实现 — T 实现了 Get, *T 实现了 Get+Set, 结果 T 只满足 ReadOnly 而 *T 满足 ReadWriter, 团队误当同一个用. 正解: 有状态结构体一律指针接收者, 全类型统一风格。
func (t T) Get() {}               // 值接收者
func (t *T) Set(v int) {}         // 指针接收者
// 错: T 只满足 ReadOnly, *T 满足 ReadWriter, 误当同一个
// 对: 有状态结构体一律指针接收者, 全类型统一风格