Go 接口是一组方法,任何拥有这些方法的类型都自动满足它,不用声明。讲清方法集、Stringer、io.Reader、类型选择,以及为什么装着 nil 指针的 error 不是 nil。
Go 的接口说的是一个值能做什么,而不是它是什么。任何拥有对应方法的类型都符合要求,而且从来不用声明自己符合。正是这一条规则,让 Go 代码往往接口很小,类型层次很少。
本文讲类型怎样满足接口、让人栽跟头的指针接收者规则、标准库里的小接口、any 和类型选择,以及几乎每个 Go 程序员都掉进去过一次的 nil 陷阱。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果粘贴而来。
接口是一组方法
接口类型列出一组方法签名。任何类型的值,只要拥有全部这些方法,就能存进这个接口。
package main
import (
"fmt"
"math"
)
type Shape interface {
Area() float64
Perimeter() float64
}
type Rect struct {
W, H float64
}
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Perimeter() float64 { return 2 * (r.W + r.H) }
type Circle struct {
R float64
}
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.R }
func describe(s Shape) {
fmt.Printf("%T: area %.2f, perimeter %.2f\n", s, s.Area(), s.Perimeter())
}
func main() {
describe(Rect{W: 3, H: 4})
describe(Circle{R: 1})
shapes := []Shape{Rect{W: 2, H: 2}, Circle{R: 2}}
total := 0.0
for _, s := range shapes {
total += s.Area()
}
fmt.Printf("total area %.2f\n", total)
}
输出:
main.Rect: area 12.00, perimeter 14.00
main.Circle: area 3.14, perimeter 6.28
total area 16.57
注意少了什么。Rect 和 Circle 从没提到 Shape,哪儿都没有 implements Shape。它们有签名正确的 Area 方法和 Perimeter 方法,这就够了。
describe 接受任何 Shape,%T 显示里面真正装着的具体类型。切片 []Shape 把 Rect 和 Circle 放在一起,循环对每个元素调用 Area,不需要知道谁是谁。
因为满足接口是隐式的,你可以在类型已经存在之后再定义接口,甚至可以定义在另一个包里,而类型的作者从没听说过这个包。类型本身不用改。
编译器在使用的地方检查
隐式不等于不检查。你一把某个值当作接口使用,编译器就会检查它的类型是否拥有每一个方法,缺一个就构建失败。
package main
import "fmt"
type Shape interface {
Area() float64
Perimeter() float64
}
type Square struct {
Side float64
}
func (s Square) Area() float64 { return s.Side * s.Side }
func describe(s Shape) {
fmt.Println(s.Area(), s.Perimeter())
}
func main() {
describe(Square{Side: 2})
}
构建失败,报错:
./main.go:21:11: cannot use Square{…} (value of struct type Square) as Shape value in argument to describe: Square does not implement Shape (missing method Perimeter)
错误信息指出了缺少的方法。这里不会有运行时的意外:类型不符合,就在试图使用它的那一行报编译错误。
没有使用的地方也能检查
有时你的包里还没有任何地方把这个类型当作接口传递。一个库类型可能只有调用方才会这样用。为了照样得到检查,Go 程序员会写这样一行:
package main
import "fmt"
type Shape interface {
Area() float64
Perimeter() float64
}
type Square struct {
Side float64
}
func (s *Square) Area() float64 { return s.Side * s.Side }
var _ Shape = (*Square)(nil)
func main() {
fmt.Println("never gets here")
}
构建失败,报错:
./main.go:16:15: cannot use (*Square)(nil) (value of type *Square) as Shape value in variable declaration: *Square does not implement Shape (missing method Perimeter)
var _ Shape = (*Square)(nil) 声明了一个你用不了的变量,因为它的名字是空白标识符 _。值是一个 nil 的 *Square,没有任何开销。这一行唯一的用处就是这次赋值,它让编译器检查 *Square 是否满足 Shape。加上 Perimeter 之后,这一行编译出来什么都没有。
指针接收者和方法集
如果一个类型的方法用的是指针接收者,那么只有指针类型满足接口,普通的值不满足。
package main
import "fmt"
type Counter interface {
Inc()
Value() int
}
type Clicks struct {
n int
}
func (c *Clicks) Inc() { c.n++ }
func (c *Clicks) Value() int { return c.n }
func main() {
var c Counter = Clicks{}
c.Inc()
fmt.Println(c.Value())
}
构建失败,报错:
./main.go:18:18: cannot use Clicks{} (value of struct type Clicks) as Counter value in variable declaration: Clicks does not implement Counter (method Inc has pointer receiver)
这一点常让人意外,因为对普通变量来说,你可以在 Clicks 值上调用 c.Inc(),Go 会替你取地址。但这个捷径对接口不适用。改为存指针:
package main
import "fmt"
type Counter interface {
Inc()
Value() int
}
type Clicks struct {
n int
}
func (c *Clicks) Inc() { c.n++ }
func (c *Clicks) Value() int { return c.n }
func main() {
var c Counter = &Clicks{}
c.Inc()
c.Inc()
fmt.Println(c.Value())
}
输出:
2
这条规则有个名字:方法集。T 的方法集包含值接收者的方法。*T 的方法集包含这些方法,再加上指针接收者的方法。一个类型的方法集包含接口的全部方法时,它就满足这个接口。
原因在于接口存的是什么。把 Clicks{} 放进接口,会把结构体复制进去。如果 Go 允许 Inc 在这份副本上运行,c.n++ 改的就是一份藏起来的副本,你根本看不到,计数会悄无声息地一直是零。拒绝编译是更友好的做法。至于一开始什么时候该选指针接收者,讲结构体和方法的那一部分已经讲过。
标准库里的小接口
Go 里最有用的接口只有一个方法,标准库就建立在少数几个这样的接口之上。
fmt.Stringer
fmt.Stringer 的声明是 interface { String() string }。fmt 包会检查它,所以任何有 String 方法的类型都能控制自己怎么打印。
package main
import "fmt"
type Temp float64
func (t Temp) String() string {
return fmt.Sprintf("%.1f°C", float64(t))
}
type Point struct {
X, Y int
}
func main() {
t := Temp(21.456)
fmt.Println(t)
fmt.Printf("%v and %s\n", t, t)
fmt.Println(Point{X: 1, Y: 2})
fmt.Println(float64(t))
}
输出:
21.5°C
21.5°C and 21.5°C
{1 2}
21.456
Temp 有 String 方法,所以 Println、%v 和 %s 都会用它。Point 没有,所以用默认的结构体格式。最后一行值得多看一眼。把 t 转换成 float64,得到的是另一个类型的值,而 float64 没有 String 方法,所以打印出来的是原始数字。
出于同样的原因,我们在 String 里调用 Sprintf 时传的是 float64(t),而不是 t。用 %v 传 t 会再次调用 String,String 又调用 Sprintf,没完没了。
io.Reader 和 io.Writer
io.Reader 只有一个方法 Read(p []byte) (n int, err error),io.Writer 只有一个方法 Write(p []byte) (n int, err error)。文件、网络连接、HTTP 请求体、缓冲区和字符串,都满足其中一个或两个。
package main
import (
"bytes"
"fmt"
"io"
"os"
"strings"
)
type upperWriter struct {
w io.Writer
}
func (u upperWriter) Write(p []byte) (int, error) {
return u.w.Write(bytes.ToUpper(p))
}
func main() {
r := strings.NewReader("hello from a string\n")
n, err := io.Copy(os.Stdout, r)
fmt.Println(n, err)
loud := upperWriter{w: os.Stdout}
io.Copy(loud, strings.NewReader("hello again\n"))
}
输出:
hello from a string
20 <nil>
HELLO AGAIN
io.Copy 接收一个 Writer 和一个 Reader,把字节从一边搬到另一边,直到 reader 读完。它不知道自己是在读字符串,还是在往终端写。它返回 20,也就是复制的字节数。
upperWriter 是我们自己的 Writer。它唯一的方法把字节转成大写,再交给它包装的那个 writer。四行代码就做出了一个类型,io.Copy 以及其他所有接收 Writer 的函数都能用它。
接收接口,返回结构体
Go 里有一条常见的准则:函数应该接收接口,返回具体类型。接收接口能让调用方手里有什么就传什么。
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"strings"
)
func countLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
n := 0
for sc.Scan() {
n++
}
return n, sc.Err()
}
func main() {
n, err := countLines(strings.NewReader("one\ntwo\nthree\n"))
fmt.Println(n, err)
var buf bytes.Buffer
buf.WriteString("alpha\nbeta\n")
n, err = countLines(&buf)
fmt.Println(n, err)
}
输出:
3 <nil>
2 <nil>
countLines 只需要 Read,所以它要的是 io.Reader。同一个函数能数字符串、缓冲区、打开的文件或请求体里的行数,测试时也可以给它一个 strings.Reader,而不用真实文件。
返回结构体,比如 *bytes.Buffer 或 *bufio.Scanner,则是反过来的道理。调用方能拿到这个类型的所有方法,想存进哪个小接口都行。如果你返回接口,就把这些方法藏了起来,替调用方决定了他们能做什么。
这是准则,不是铁律。error 就是接口,函数一直在返回它。
any、类型断言和 comma-ok
any 是 interface{} 的另一个名字,它是没有任何方法的接口,所以每个类型都满足它。你在 fmt.Println(a ...any) 里见过它。要把具体的值取出来,就用类型断言。
package main
import "fmt"
func main() {
var x any = "gopher"
s, ok := x.(string)
fmt.Printf("%q %v\n", s, ok)
n, ok := x.(int)
fmt.Println(n, ok)
x = 42
fmt.Println(x.(int) + 1)
fmt.Println(x.(string))
}
它输出三行,然后停下:
"gopher" true
0 false
43
panic: interface conversion: interface {} is int, not string
x.(string) 问的是:“x 里面的值是 string 吗?”用两个返回值 s, ok := x.(string) 时,如果是,你得到这个值和 true。如果不是,比如 x.(int),你得到零值和 false,什么都不会出错。这和 map 用的 comma-ok 形式一样。
只用一个返回值时,没有地方报告失败,所以猜错就会 panic。panic 信息里写的仍是 interface {} 这个旧写法,因为 any 只是别名。只有当类型不对就意味着你自己代码里有 bug 时,才用单返回值的形式。
类型选择,再深入一层
类型选择是一串类型断言,它的 case 既可以写具体类型,也可以写接口。讲控制流的那一部分介绍过基本形式,这里看看它还能做什么。
package main
import (
"errors"
"fmt"
"strconv"
)
type Celsius float64
func (c Celsius) String() string { return strconv.FormatFloat(float64(c), 'f', 1, 64) + "°C" }
func describe(x any) string {
switch v := x.(type) {
case nil:
return "nil"
case int, int64:
return fmt.Sprintf("a whole number, %T %v", v, v)
case error:
return "an error: " + v.Error()
case fmt.Stringer:
return "a Stringer: " + v.String()
default:
return fmt.Sprintf("something else: %T", v)
}
}
func main() {
fmt.Println(describe(nil))
fmt.Println(describe(7))
fmt.Println(describe(int64(7)))
fmt.Println(describe(Celsius(21.5)))
fmt.Println(describe(errors.New("disk full")))
fmt.Println(describe([]int{1, 2}))
}
输出:
nil
a whole number, int 7
a whole number, int64 7
a Stringer: 21.5°C
an error: disk full
something else: []int
这里有三件事:
- 只有一个类型的 case,
v就是那个类型。在case error里,v是error,所以v.Error()能编译。 - 有多个类型的 case,比如
case int, int64,没法确定是哪一个,所以v仍然是any。你可以打印它,但不再做一次断言就不能拿它做算术。 - 写接口的 case 匹配任何类型拥有这些方法的值。
Celsius从没提过fmt.Stringer,却匹配上了它。
case 按顺序尝试,第一个匹配的胜出。一个同时有 Error 和 String 方法的类型,在这里会进入 case error,因为它排在前面。
当一个值确实可能是几种互不相关的类型之一时,比如解码出来的 JSON 值,就用类型选择。如果这些类型有共同的行为,就把行为放进接口方法里直接调用。开头的 Shape 就是这么做的。
nil 陷阱
装着 nil 指针的接口本身不是 nil,这会让返回 error 的函数报告一个根本没发生的失败。
package main
import "fmt"
type MyError struct {
Msg string
}
func (e *MyError) Error() string { return e.Msg }
func checkAge(age int) error {
var e *MyError
if age < 0 {
e = &MyError{Msg: "age can't be negative"}
}
return e
}
func main() {
err := checkAge(30)
if err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println("age is fine")
}
输出:
failed: <nil>
年龄没问题,e 一直是 nil 指针。可 err != nil 却为 true,程序走进了失败分支。
<nil> 是第二个意外。fmt 在一个 nil 的 *MyError 上调用了 Error,而 e.Msg 解引用了 nil。fmt 会从这种特定的 panic 中恢复,改为打印 <nil>。你自己调用 err.Error() 的话,程序会因为 nil 指针解引用而崩溃。
用十岁孩子能懂的话说
把接口想象成一个盖子上贴着标签的盒子。盒子有两个位置:标签,写着里面是哪种东西;还有东西本身。
空盒子的标签上什么都没写,里面也什么都没有。Go 只把这种盒子叫作 nil。
现在拿一个盒子,在标签上写“MyError”,里面什么也不放。它是空的吗?有人检查“标签是不是空白、盒子是不是空的”,答案是否定的,因为标签写了东西。所以即使里面什么都没有,err == nil 也是 false。
准确的说法
接口值由两个字组成。第一个说明存的是哪个具体类型,第二个是该类型的值(如果是指针,就是指针本身)。只有两个字都为空时,接口才是 nil。
return e 把 *MyError 转换成 error。这次转换总会把类型 *MyError 记在第一个字里,第二个字存着 nil 指针。有一个字不为空,所以接口不是 nil。
这个比喻的局限:真实的空盒子没什么用,但装着 nil 指针的接口并不总是错误。你可以在它上面调用方法,专门处理 nil 接收者的方法照样能正常工作。陷阱只在于拿它和 nil 比较,还指望得到 true。
看看这两个字
在这个陷阱的每一步里,接口值的两个字都在变化,下面的动画跟着它们走一遍:
把接口值画成两个格子:类型和值。空的 err 等于 nil。赋给它一个 nil 的 *MyError,类型格就填上了 *MyError,值格仍然是 nil,于是 err 不再等于 nil。直接返回 nil,两个格子都保持为空。
如果动画没有播放,下面用文字把这几步再说一遍:
var err error创建一个两个格子都为空的接口。err == nil为 true。var p *MyError = nil创建一个什么都不指向的指针。p == nil为 true。err = p把类型*MyError存进类型格,把 nil 指针存进值格。- 现在
err == nil为 false,因为类型格不是空的。 - 修复:
return nil,而不是返回那个指针,两个格子就都保持为空。
下面这个程序走一遍前四步:
package main
import "fmt"
type MyError struct {
Msg string
}
func (e *MyError) Error() string { return e.Msg }
func main() {
var err error
fmt.Println("step 1: err == nil is", err == nil)
var p *MyError = nil
fmt.Println("step 2: p == nil is", p == nil)
err = p
fmt.Printf("step 3: err holds type %T\n", err)
fmt.Println("step 4: err == nil is", err == nil)
}
输出:
step 1: err == nil is true
step 2: p == nil is true
step 3: err holds type *main.MyError
step 4: err == nil is false
p == nil 拿指针和 nil 比较,结果为 true。err == nil 拿接口和空接口比较,结果为 false。右边同样是 nil,意思却取决于左边的类型,完全不同。
修复:直接返回 nil
不要把错误存进具体指针类型的变量再返回。成功时直接返回 nil:
package main
import "fmt"
type MyError struct {
Msg string
}
func (e *MyError) Error() string { return e.Msg }
func checkAge(age int) error {
if age < 0 {
return &MyError{Msg: "age can't be negative"}
}
return nil
}
func main() {
for _, age := range []int{30, -1} {
if err := checkAge(age); err != nil {
fmt.Println(age, "failed:", err)
continue
}
fmt.Println(age, "is fine")
}
}
输出:
30 is fine
-1 failed: age can't be negative
在结果类型为 error 的函数里写 return nil,得到的是两个字都为空的接口。要记住的规则很简单:函数返回 error,那么存错误的变量也应该是 error 类型,而不是 *MyError。讲错误的那一部分会在此基础上介绍包装、errors.Is 和 errors.As。
要点
- 接口是一组方法。类型只要拥有这些方法就满足接口,没有
implements关键字。 - 编译器在使用的地方检查。没有使用的地方时,用
var _ Shape = (*Square)(nil)强制检查。 - 指针接收者的方法只属于指针类型的方法集,所以要把
&T{}而不是T{}存进接口。 - 小接口干的活最多:
fmt.Stringer、io.Reader、io.Writer。参数接收它们,返回具体类型。 - 用 comma-ok 形式
v, ok := x.(T),除非类型不对确实意味着 bug。有多个类型的类型选择 case 会让v保持为any。 - 只有类型和值都为空时,接口才是 nil。以
error返回的 nil*MyError不是 nil,所以要直接返回nil。
接口里既没有类型也没有值,它才是 nil。