接口的隐形规则:方法集、nil 陷阱与小接口设计
Go 的接口没有 implements,看上去比传统面向对象语言轻得多。但接口真正难的部分并不在声明,而在三个不直接写在调用处的规则:接口值同时携带动态类型与动态值;值类型和指针类型的方法集不同;嵌入只提升方法,不建立继承关系。
把这三件事弄清楚,很多“明明是 nil 却进不了 if”的问题就不再神秘。
接口值是一个二元组
概念上,一个接口值可以写成:
(dynamic type, dynamic value)只有两部分都为空时,接口才等于 nil:
(nil, nil) == nil下面的 err 不是 nil,因为它已经携带了 *PathError 这个动态类型:
type PathError struct {
Path string
}
func (e *PathError) Error() string {
return "invalid path: " + e.Path
}
func validate(path string) error {
var err *PathError
return err // (*PathError, nil)
}
func main() {
err := validate("/tmp")
fmt.Println(err == nil) // false
}这类 bug 的修复不应该是到处用反射检查 typed nil,而是让返回接口的函数在没有错误时明确返回 nil:
func validate(path string) error {
var err *PathError
if path == "" {
err = &PathError{Path: path}
}
if err != nil {
return err
}
return nil
}一个实用约束是:不要先声明具体错误指针,再无条件作为 error 返回。错误只在确实发生时构造。
方法集决定谁实现了接口
假设 Close 使用指针接收者:
type Client struct{}
func (Client) Name() string { return "payment" }
func (*Client) Close() error { return nil }方法集的关键结论是:
Client的方法集只包含值接收者方法Name;*Client的方法集同时包含Name和Close。
因此:
type Named interface {
Name() string
}
type Resource interface {
Name() string
Close() error
}
var _ Named = Client{}
var _ Resource = (*Client)(nil)
// var _ Resource = Client{} // 编译失败value.Close() 有时能调用成功,是因为变量可寻址时编译器可以把它改写成 (&value).Close();接口实现判断不会做这层地址转换。方法调用方便性和方法集是两套规则,不要混为一谈。
接收者怎么选
如果方法需要修改接收者、接收者包含锁或复制成本明显,应使用指针接收者。同一个类型的方法通常保持一致,不要一半值、一半指针,除非它确实是不可变的小值类型。
包含 sync.Mutex、sync.Once 等“首次使用后不可复制”字段的类型更不能用值接收者,否则每次调用都可能复制同步状态。go vet 的 copylocks 检查能发现一部分问题。
接口应由使用者定义
一个常见的过度设计是:实现包先为自己的类型声明一个包含十几个方法的接口,然后要求所有调用方依赖它。结果是 mock 很重,任何新增方法都会扩大实现成本。
更符合 Go 习惯的做法是由消费方描述自己真正需要的能力:
// package report
type OrderFinder interface {
FindOrder(ctx context.Context, id string) (Order, error)
}
type Service struct {
orders OrderFinder
}数据库实现可以有二十个方法,报表服务只依赖其中一个。接口越靠近使用点,越能表达真实边界。
这不意味着接口必须只有一个方法,而是接口中的方法应共同服务于一个稳定角色。io.Reader 很小;事务接口可能合理地包含 Commit 和 Rollback。数字不是标准,内聚性才是。
接受接口,返回具体类型
“接受接口,返回具体类型”是一条有用的默认原则:参数使用最小能力集合,返回具体类型让调用方保留全部能力,也避免过早冻结抽象。
func NewExporter(w io.Writer, options Options) *Exporter {
return &Exporter{writer: w, options: options}
}它不是铁律。构造函数如果必须隐藏多个实现、返回值本身就是稳定协议,返回接口也合理。关键是别仅仅为了“解耦”就给每个 struct 配一个同名 interface;接口的价值在替换行为,不在隐藏字段。
嵌入不是继承
type Metrics struct{}
func (Metrics) Count(string) {}
type Service struct {
Metrics
}Service 可以调用提升后的 Count,也可能因此满足某个接口。但 Service 不是 Metrics 的子类,不存在虚方法覆盖。外层定义同名方法只是遮蔽选择器:
func (Service) Count(name string) {
// 不会自动参与 Metrics 内部的动态派发。
}嵌入适合组合能力和转发方法。为了少写几行代理代码而嵌入一个拥有大量无关方法的类型,会把那些方法也暴露为外层 API,形成难以收回的兼容性承诺。
类型断言不是分支系统
类型断言适合协议边界和少量可选能力:
type Flusher interface {
Flush() error
}
if flusher, ok := writer.(Flusher); ok {
return flusher.Flush()
}如果业务代码到处用 type switch 判断几十种实现,通常说明行为没有被接口本身表达出来,或者数据模型正在假装成多态对象。先考虑把差异下沉到实现方法,而不是不断扩大中央 switch。
编译期断言值得保留
var _ http.Handler = (*Server)(nil)它不会生成运行时代码,却能在接口或实现变化时尽早失败。对框架入口、插件实现和跨包协议尤其有价值。断言放在实现附近,读代码的人也能立刻看到这个类型承担了什么角色。
设计接口时的三个问题
- 这个接口描述的是调用方需要的角色,还是实现方现有方法的镜像?
- 调用方是否真的需要替换实现,还是一个具体类型已经足够?
- 返回接口时,是否可能把 typed nil 交给调用方?
Go 的接口越用越小,通常不是因为系统简单,而是因为边界被想清楚了。