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

注意少了什么。RectCircle 从没提到 Shape,哪儿都没有 implements Shape。它们有签名正确的 Area 方法和 Perimeter 方法,这就够了。

describe 接受任何 Shape%T 显示里面真正装着的具体类型。切片 []ShapeRectCircle 放在一起,循环对每个元素调用 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

TempString 方法,所以 Println%v%s 都会用它。Point 没有,所以用默认的结构体格式。最后一行值得多看一眼。把 t 转换成 float64,得到的是另一个类型的值,而 float64 没有 String 方法,所以打印出来的是原始数字。

出于同样的原因,我们在 String 里调用 Sprintf 时传的是 float64(t),而不是 t。用 %vt 会再次调用 StringString 又调用 Sprintf,没完没了。

io.Readerio.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

anyinterface{} 的另一个名字,它是没有任何方法的接口,所以每个类型都满足它。你在 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

这里有三件事:

  • 只有一个类型的 casev 就是那个类型。在 case error 里,verror,所以 v.Error() 能编译。
  • 有多个类型的 case,比如 case int, int64,没法确定是哪一个,所以 v 仍然是 any。你可以打印它,但不再做一次断言就不能拿它做算术。
  • 写接口的 case 匹配任何类型拥有这些方法的值。Celsius 从没提过 fmt.Stringer,却匹配上了它。

case 按顺序尝试,第一个匹配的胜出。一个同时有 ErrorString 方法的类型,在这里会进入 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.Iserrors.As

要点

  • 接口是一组方法。类型只要拥有这些方法就满足接口,没有 implements 关键字。
  • 编译器在使用的地方检查。没有使用的地方时,用 var _ Shape = (*Square)(nil) 强制检查。
  • 指针接收者的方法只属于指针类型的方法集,所以要把 &T{} 而不是 T{} 存进接口。
  • 小接口干的活最多:fmt.Stringerio.Readerio.Writer。参数接收它们,返回具体类型。
  • 用 comma-ok 形式 v, ok := x.(T),除非类型不对确实意味着 bug。有多个类型的类型选择 case 会让 v 保持为 any
  • 只有类型和值都为空时,接口才是 nil。以 error 返回的 nil *MyError 不是 nil,所以要直接返回 nil

接口里既没有类型也没有值,它才是 nil。

这篇文章对你有帮助吗?

点一颗爱心来评分!

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

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