Blog

Go 接口:隐式实现、小接口和 nil 陷阱

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 (error) nil nil p (*MyError) 它的类型 指针 *MyError nil *MyError nil err == nil 为 true p == nil 为 true err 的类型是 *MyError,值是 nil err == nil 为 false err == nil 又为 true 第 1 步:var err error,两个格子都是空的 第 2 步:var p *MyError = nil 是什么都不指向的指针 第 3 步:err = p 把 *MyError 填进类型格,值是 nil 第 4 步:类型格有内容,所以 err 不是 nil 修复:return nil,两个格子都保持为空

把接口值画成两个格子:类型和值。空的 err 等于 nil。赋给它一个 nil 的 *MyError,类型格就填上了 *MyError,值格仍然是 nil,于是 err 不再等于 nil。直接返回 nil,两个格子都保持为空。

如果动画没有播放,下面用文字把这几步再说一遍:

  1. var err error 创建一个两个格子都为空的接口。err == nil 为 true。
  2. var p *MyError = nil 创建一个什么都不指向的指针。p == nil 为 true。
  3. err = p 把类型 *MyError 存进类型格,把 nil 指针存进值格。
  4. 现在 err == nil 为 false,因为类型格不是空的。
  5. 修复: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。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。