2.2 接口与多态:岗位说明书


2.2 接口与多态:岗位说明书

本节摘要:接口定义"会做什么"而不关心"是谁在做",是 Go 实现多态与解耦的核心机制。本节从隐式实现讲到接口值的内部两格结构,重点厘清 nil 接口这一经典陷阱。学完本节,你能设计小而准的接口,并预判接口变量在比较、判空时的真实行为。

上一节的档案卡记录"是谁",本节的岗位说明书只写"会什么"。一个反例开场:想给一组员工按工时排序,却发现排序函数不可能为每种类型各写一遍——除非语言提供一种"只要求能力、不限定类型"的约定。这就是接口诞生的现场。

从一段"写不下去"的排序说起

标准库的排序入口要求参数实现三个方法:取长度、比大小、交换。任何类型只要提供这三样,就能被同一个排序函数处理:

package main import ( "fmt" "sort" ) type ByHours []float64 func (h ByHours) Len() int { return len(h) } func (h ByHours) Less(i, j int) bool { return h[i] < h[j] } func (h ByHours) Swap(i, j int) { h[i], h[j] = h[j], h[i] } func main() { hours := []float64{162.5, 88, 170, 60} sort.Sort(ByHours(hours)) fmt.Println(hours) } // 运行输出: // [60 88 162.5 170]

ByHours 从未写过"我实现了排序接口"的声明,只是方法凑齐了,它就自动满足。这就是隐式实现——与 Java 的 implements 显式声明完全不同。好处是解耦:你的类型不必 import 定义接口的包,老类型也能无痛适配后来才出现的新接口。

能力清单的两种用法

隐式实现是自由的,但"类型是否真的实现了接口"最好在编译期确认,而不是等到运行时传参才炸。一行空白赋值就是标准检查姿势:

type Notifier interface { Send(title, body string) error } // 编译期断言:Mail 未实现 Notifier 的话,这行直接报错 var _ Notifier = (*Mail)(nil) type Mail struct{ Addr string } func (m *Mail) Send(title, body string) error { fmt.Println("发信至", m.Addr, ":", title) return nil }

接口越小,实现者越多、组合越灵——这是 Go 社区"小接口"品味的由来。一个方法甚至零方法的接口都很常见,比如只要求"能读"的 Reader。

图 2-2:接口值的内部两格结构

图 2-2:接口值的内部两格结构

这张图是本节的含金量所在:接口变量不是盒子里的"那个对象",而是两根指针。理解了两格模型,下面的陷阱题不过是把图读出来而已。

空接口、类型开关与 nil 陷阱

空接口 interface{}(1.18 后可用别名 any)两格全空,任何值都能装进去,于是它成为"任意类型"的占位。从空接口里取回具体类型用类型开关:

func describe(v any) string { switch x := v.(type) { case int: return fmt.Sprintf("整数 %d", x) case string: return "字符串 " + x default: return "未知类型" } } // describe(7) 得 "整数 7";describe("go") 得 "字符串 go"

nil 陷阱正面演示:

type Engine struct{ Started bool } func (e *Engine) Start() error { e.Started = true; return nil } func tryStart(e *Engine) error { if e == nil { return errors.New("引擎不存在") } return e.Start() } func main() { var e *Engine // nil 指针 var task error = tryStart(e) // 假设 tryStart 没做判空,返回了 nil // 真实风险场景:函数签名返回 error 接口,实际 return 了一个 *T 类型的 nil fmt.Println(task == nil) } // 经典翻车输出: false // 原因:error 接口的类型格装着指针类型信息,数据格是 nil, // 两格不全空,接口判定为"非 nil"

防御方法很直接:函数返回 error 时,宁可显式 return nil,也不要返回一个值为 nil 的具体类型指针变量;调用方判空永远对接口本身做。

完整案例:通知渠道的可插拔

背景:值班告警原来只发邮件,现在要求按工单级别自动选择邮件或短信,未来还要加即时消息。

操作:定义单方法 Notifier 接口;Mail 与 SMS 各自实现;分发器只依赖接口。未来新增渠道只需再写一个实现,分发器零改动。

package main import "fmt" type Notifier interface { Send(title, body string) error } type Mail struct{ Addr string } type SMS struct{ Phone string } func (m *Mail) Send(title, body string) error { fmt.Printf("[邮件->%s] %s: %s\n", m.Addr, title, body) return nil } func (s *SMS) Send(title, body string) error { fmt.Printf("[短信->%s] %s: %s\n", s.Phone, title, body) return nil } // dispatch 只认能力不认类型:新增渠道不改此函数 func dispatch(n Notifier, level, msg string) { if level == "P1" { _ = n.Send("P1 告警", msg) } } func main() { var by Notifier = &Mail{Addr: "duty@example.com"} dispatch(by, "P1", "主库延迟超阈值") by = &SMS{Phone: "13800000000"} // 同一个接口变量,换装不同实现 dispatch(by, "P1", "主库延迟超阈值") } // 运行输出: // [邮件->duty@example.com] P1 告警: 主库延迟超阈值 // [短信->13800000000] P1 告警: 主库延迟超阈值

结果:两种渠道在同一分发逻辑下各自送达。

解读:接口变量 by 的两格在两次赋值间被换装,分发函数毫无感知——多态的代价只是一次接口方法调用,没有虚表继承的包袱。小接口加组合,正是 Go 版"依赖倒置"。

变式:把 Notifier 扩成 GroupNotifier,内嵌切片聚合多个渠道,实现扇出告警。你会发现聚合类型照样只实现一个 Send 就能顶替任何单一渠道——接口的组合性让"树形分发"自然涌现,这个模式在 3.3 的扇出扇入里会以 channel 形态再现。

收班要点

  • 隐式实现让类型与接口解耦,老类型适配新接口零成本。
  • 编译期断言一行 var _ 接口 = (*实现)(nil),把实现错误拦在构建阶段。
  • 接口值是两格结构:类型指针加数据指针,判空要看两格,typed-nil 是高频事故源。
  • 小接口是品味也是策略:方法越少,实现者与组合方式越多。
  • 类型开关处理 any 分支,默认分支兜底未知类型。

下一节看指针本体——接口第二格里那根指针,正是无数并发 bug 的起点。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U