Menu

Golang 接口:隐式实现、any 与 nil 陷阱

Go 的接口是隐式实现的:只要一个类型拥有对应的方法,它就实现了这个接口。了解 io.Reader 和 fmt.Stringer 这样的小接口、空接口 any、nil 接口陷阱,以及“接收接口,返回结构体”原则。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

接口就是一组方法

接口类型列出一组方法签名。任何拥有这些方法的类型都满足这个接口,不需要任何声明。

Rect 和 Circle 都没有提到 Shape。这就是隐式实现,也是 Go 接口最核心的特点。它意味着你可以在自己的包里定义一个接口,而其他包里的类型不用改动任何代码,就已经满足了它。

标准库中的小接口

Go 代码偏爱只有一两个方法的接口。最重要的几个:

接口方法使用者
fmt.StringerString() stringfmt 的打印函数
errorError() string所有可能失败的函数
io.ReaderRead(p []byte) (n int, err error)文件、网络、gzip、HTTP 请求体
io.WriterWrite(p []byte) (n int, err error)文件、缓冲区、哈希、HTTP 响应
sort.InterfaceLen、Less、Swapsort 包
http.HandlerServeHTTP(w, r)net/http

因为 io.Reader 只有一个方法,几十种类型都实现了它,任何接收 io.Reader 的函数都能处理所有这些类型:

Go 有一句格言:“接口越大,抽象越弱”(the bigger the interface, the weaker the abstraction)。更大的接口是由小接口组合而成的:io.ReadWriter 就是 Reader 加上 Writer,通过在一个接口中嵌入另一个接口来实现。

any:空接口

interface{} 没有任何方法,所以所有类型都满足它。Go 1.18 新增了别名 any,两者完全相同。

any 值可以装下任何东西,但在你用类型断言或类型 switch 取回具体类型之前,几乎什么都做不了。如果类型的范围是已知的,优先使用真正的接口或泛型。any 适合真正动态的数据,比如结构未知的 JSON 解码结果,以及打印。

接口值里装着什么

接口值是一对数据:动态类型和动态值。var s Shape = Rect{3, 4} 保存了类型 Rect 和这个值的一份副本。调用 s.Area() 时,会在运行时查找 Rect 的方法。

只有当两部分都为空时,接口才是 nil。这条规则导致了 Go 里最让人困惑的 bug。

nil 接口陷阱

把一个 nil 指针存进接口,得到的是一个非 nil 的接口。

输出:

false
*main.MyError true
true

validate(true) 返回的 error 接口装着类型 *MyError 和值 nil。这个接口有类型,所以它不等于 nil,调用者的 if err != nil 分支就会执行。如果在那里调用 err.Error(),就会因为访问 nil 接收者的字段而 panic。

修复方法很简单:把变量声明为 error,而不是具体的指针类型,或者在成功路径上直接返回字面量 nil。永远不要在返回值为 error 的函数里返回具体的错误指针类型。同样的陷阱适用于所有接口,不只是错误。

检查类型是否实现了接口

只有在把值赋给接口的地方,才会检查是否实现了接口。如果还没有代码这样做,方法签名写错了也不会被发现。在包级别加一个空白赋值,就能让这个检查明确发生:

var _ io.Writer = (*LogWriter)(nil)
var _ fmt.Stringer = Temp(0)

这在运行时没有任何开销。如果 *LogWriter 的方法是 Write(p []byte) error,而不是 Write(p []byte) (int, error),构建就会失败:

cannot use (*LogWriter)(nil) (value of type *LogWriter) as io.Writer value in variable declaration: *LogWriter does not implement io.Writer (wrong type for method Write)
		have Write([]byte) error
		want Write([]byte) (int, error)

指针接收者与接口

如果一个方法使用指针接收者,那么只有指针类型拥有这个方法。*Counter 能借此满足接口,Counter 却不能。编译器会提示 Counter does not implement Incrementer (method Inc has pointer receiver)。请把 &Counter{} 存进接口。方法页面讲解了方法集。

接收接口,返回结构体

Go 中一条常见的准则是:函数应该接收接口参数,并返回具体类型。

  • 接收接口让调用者可以传入任何符合要求的东西,包括测试用的假对象。一个读取数据的函数应该接收 io.Reader,而不是 *os.File。
  • 返回具体类型让调用者可以使用它的所有方法和字段,也避开了 nil 接口陷阱。os.Open 返回的是 *os.File,而不是 io.Reader。

一个相关的习惯是:在使用接口的地方定义接口,而不是在实现它的地方。如果你的服务需要一个能 Get(id) 获取用户的东西,就在服务的包里声明一个只有一个方法的接口,数据库包只需导出它的结构体。

比较接口值

当两个接口值的动态类型相同、动态值相等时,它们才相等。如果动态类型不可比较(切片、map),== 能编译通过,但会在运行时 panic:runtime error: comparing uncomparable type []int。

常见错误

  • 把带类型的 nil 指针作为接口返回。 请返回字面量 nil。
  • 过早引入接口。 先写具体类型。等到出现第二种实现或者测试需要时,再添加接口。
  • 指向接口的指针。 *io.Reader 几乎从来都是错的。当你把指针存进接口时,接口本身就已经装着这个指针了。
  • 大接口。 有十个方法的接口既难实现,又难伪造。把它拆开。

常见问题

Go 语言怎么实现一个接口?

在你的类型上定义接口列出的那些方法,名字和签名都要一致。Go 没有 implements 关键字。如果 *File 有 Read(p []byte) (int, error) 方法,它就自动成为一个 io.Reader。在你把值赋给接口类型的地方,编译器都会做这个检查。

Go 里的空接口或 any 是什么?

interface{} 没有任何方法,所以所有类型都满足它。从 Go 1.18 起,any 是 interface{} 的内置别名。any 类型的值可以装下任何东西,但要取回具体类型,你需要类型断言或类型 switch。

为什么我把 nil 指针赋给 Go 接口后,接口却不是 nil?

接口值包含一个类型和一个值。把一个为 nil 的 *MyError 赋给 error,得到的接口类型是 *MyError、值是 nil,而这个接口并不等于 nil。没有错误时,请直接返回字面量 nil,而不是带类型的 nil 指针。

怎么在编译期检查一个类型是否实现了某个接口?

在包级别加一个空白赋值:var _ io.Reader = (*MyReader)(nil)。如果 *MyReader 缺少某个方法,构建就会失败,报错信息会写出缺少的方法名。

Coddy 编程语言插图

和 Coddy 一起学编程

开始