nil 接口、接口比较、空接口

7.10 接口与 nil

在讨论指针时(“六 指针<105页>”),曾提到了 nil——指针类型的零值(Zero Value)。虽然 nil 也可以用来表示接口实例的零值,但其行为比具体类型(Concrete Type)更为复杂。

要理解接口与 nil 的关系,需要先了解 Go 接口的底层实现机制。在 Go 运行时中,接口实际上是由两个指针组成的结构体实现的:

  • 类型指针:指向接口动态类型的信息(*itab);
  • 值指针:指向实际存储的数据(unsafe.Pointer);

只要类型指针非空(即使值指针为 nil),接口就不等于 nil。因为 Go 是强类型语言,所有变量必须有类型信息。因此:

  • 若值指针非空,则类型指针必然非空(即“不可能存在无类型的值”)。
  • 但类型指针非空时,值指针可以为空(即“有类型的 nil”)。

两个结论:

  1. 接口值为空 <=> 接口的类型指针和值指针都为空
  2. 接口值非空 => 接口一定包含类型信息,即类型指针非空

在 Go 中,只有当接口的类型和值均为 nil 时,该接口才会被判定为 nil。以下代码可以直观展示这一特性,前两行输出 true,最后一行输出 false

var pointerCounter *Counter
fmt.Println(pointerCounter == nil) // prints true

var incrementer Incrementer
fmt.Println(incrementer == nil) // prints true

incrementer = pointerCounter    // pointerCounter = nil,但 incrementer != nil
fmt.Println(incrementer == nil) // prints false,即使值指针为 nil,但是类型指针不是 nil,因为赋值使接口知道了类型信息为 *Counter

一个 nil 指针放进接口之后,接口通常就不再是 nil 了,因为接口已经记住了这个指针的类型。

在 Go 中,接口类型变量的 nil 状态决定了能否调用其方法。如之前所述,通过具体类型的 nil 指针是可以调用方法的,因此当接口变量被赋值 nil 具体实例时,同样允许方法调用。

  • 若接口变量本身为 nil,调用任何方法都会触发 panic(因为类型指针为 nil,根本不知道调用哪个方法)(将在“9.9 panic 与 recover<194页>”一节详述)。
  • 若接口变量非 nil(即包含了类型信息),则可以调用方法(因为类型指针非 nil,知道调用具体类型的哪个方法)。但需注意:若具体值为 nil 且该具体类型的方法未正确处理 nil 接收者,仍可能触发 panic

由于带有非空类型信息的接口实例不等于 nil,因此当接口类型非空时,直接判断其关联值是否为 nil 并非易事。必须借助反射机制(详见“16.1.3 用反射检查接口的值是否为 nil<370页>”)才能准确识别。

7.11 接口是可比较的

在“三 复合类型<31页>”中,已经介绍了“可比较类型”⁴。这里要告诉你,接口也是可比较的。就像接口只有在类型字段与值字段都为 nil 时,接口才等于 nil 一样,两个接口类型的实例只有在它们的类型相同且值相等时,两个接口实例才相等

⁴可比较类型:指可以使用等值运算符 == 检查相等性的类型

Go runtime 中空接口和非空接口的内部表示并不完全一样(存在内存优化),这属于 runtime 实现细节;

理解语言行为时,以“动态类型 + 动态值”这个模型最稳妥,即:

  • 接口 = (动态类型,动态值)
    • 动态类型,就是赋给接口的那个具体值本身的类型,例如 var i any = 10 时动态类型是 intvar i any = &x 时动态类型是 *int
    • 动态值,就是该具体值,例如 var i any = 10 时动态值是 10var i any = &x 时动态值是指针 &x
    • Go runtime 在底层实现接口时,可能会用指针间接保存某些非指针值,但这是实现细节(见 Golang 接口原理 - libilibi)。
  • 两个接口相等 <=> 它们的动态类型相同,且对应的动态值也相等。

这就引出了一个问题:如果类型本身不可比较会发生什么?让我们用一个简单的示例(源码见本章资源库 sample_code/comparable 目录)来探讨这个问题。首先定义一个接口和该接口的几个实现:

// 接口类型,定义了一个Double方法
type Doubler interface {
	Double()
}

// 定义 DoubleInt 类型,底层为 int
type DoubleInt int

// 实现 Doubler 接口的 Double 方法,接收者为 *DoubleInt
func (d *DoubleInt) Double() {
	*d = *d * 2
}

// 定义 DoubleIntSlice 类型,底层为 []int,即 int 切片
type DoubleIntSlice []int

// 实现 Doubler 接口的 Double 方法,接收者为 DoubleIntSlice
func (d DoubleIntSlice) Double() {
	for i := range d {
		d[i] = d[i] * 2
	}
}

因为需要修改 int 的值,所以 DoubleInt 类型的 Double 方法使用了指针接收者(Pointer Receiver)。而在 DoubleIntSlice 类型上定义的 Double 方法可以使用值接收者(Value Receiver),因为正如“6.7 映射与切片的区别<115页>”一节所述,即使参数是切片类型,仍然可以修改切片中的元素值。

*DoubleInt 类型是可比较的(所有指针类型都是可比较的),而 DoubleIntSlice 类型是不可比较的(切片类型不可比较)。

函数 DoublerCompare 接受两个 Doubler 类型的参数,并打印二者是否相等:

func DoublerCompare(d1, d2 Doubler) {
	fmt.Println(d1 == d2)
}

定义 4 个变量:

var di DoubleInt = 10
var di2 DoubleInt = 10
var dis = DoubleIntSlice{1, 2, 3}
var dis2 = DoubleIntSlice{1, 2, 3}

执行 3 次 DoublerCompare 函数调用(如下所示):

DoublerCompare(&di, &di2)   // ❶
DoublerCompare(&di, dis)    // ❷
DoublerCompare(dis, dis2)   // ❸

❶ 第一次调用,将打印出 false。类型匹配(两者都是 *DoubleInt),但此处比较的是指针,而不是值,因为指针指向不同的实例,因此指针值不相同,结果是 false

❷ 第二次调用,将 *DoubleIntDoubleIntSlice 进行比较,此处将打印出 false。因为二者类型不同(前者是指针,后者是切片)。

❸ 第三次调用,此次调用再就在编译时没有任何问题,但在运行时会引发 panic

panic: runtime error: comparing uncomparable type main.DoubleIntSlice

尽管两个接口的动态类型相同(均为 DoubleIntSlice),但该动态类型 DoubleIntSlice(切片) 的值不可比较。

另请注意,映射(map)的 key 必须是可比较的,因此可将映射的 key 定义为接口类型⁵:

m := map[Doubler]int{}

如果向此映射添加一个键值对,并且键(key)是不可比较的,将引发 panic(有关 panic 内容,详见“9.9 panic 与 recover<194页>”)。

鉴于这种行为,在将 ==!= 与 interfaces 一起使用或将 interface 用作映射的 key 时要小心,否则可能引发导致程序崩溃的 panic。即使当前所有的接口实现都是可比较的,也无法预知他人使用或修改代码时会发生什么,而且无法指定某个接口只能由可比较的类型实现。如果希望提高代码安全性,可以在使用 ==!= 之前,通过 reflect.ValueComparable 方法检查接口是否可比较(有关反射的内容介绍,详见“16.1 用 reflect 包动态处理动态类型<363页>”)。

7.12 空接口

在静态类型语言中,有时需要一种方式来表示变量可以存储任意类型的值。Go 使用空接口 interface{} 来实现这一需求:

var i interface{}
i = 20
i = "hello"
i = struct {
	FirstName string
	LastName  string
}{"Fred", "Fredson"}

interface{} 并非特殊语法结构。空接口类型由于没有任何方法,因此可以认为任何类型都实现了空接口,因此空接口变量可以接受 Go 中的任何类型的值

空接口没有没有任何方法,接口类型是否包含方法是一个静态属性——源代码中的类型要么写成 interface{},要么写成 interface{ methods... }——因此,编译器知道程序中每个位置使用的是哪一种表示形式。见 Golang 接口原理 - libilibi

为提高代码可读性,Go 语言新增了关键字 any 作为 interface{} 的类型别名。在 Go 1.18 版本之前编写的遗留代码通常使用 interface{},但新代码建议统一采用 any

由于空接口无法提供其所存储值的任何类型信息,因此直接使用时功能有限。any 类型最常见的用途是作为占位符,用于存储来自外部数据源(如 JSON 文件)且模式不确定的数据

data := map[string]any{}
contents, err := os.ReadFile("testdata/sample.json")
if err != nil {
	return err
}

json.Unmarshal(contents, &data)
// 将 contents 内容存储在 data(映射类型)中

在 Go 引入泛型(Generics)(详见“十八 数据库框架——GORM<415页>”)特性前,用户自定义的数据容器通常使用空接口(interface{})来存储值。例如,标准库中的 container/list 就是一个典型例子。现在既然 Go 已经支持泛型(Generics),建议所有新开发的数据容器都改用泛型来实现。

当看到函数接收空接口(interface{}any)作为参数时,该函数很可能正在使用反射(详见“十六 神龙驾到:reflect、unsafe 与 cgo 包<363页>”)来读写数值。前文示例中 json.Unmarshal 函数的第二个参数就被声明为 any 类型。这类情况应该相当少见——请尽量避免使用 any。Go 作为强类型语言,刻意绕过类型系统的做法不符合语言设计哲学。

如果遇到必须将值存入空接口(interface{}any)的情况,可能会疑惑如何安全地取出这些值。这时,需要掌握另两种技能:类型断言(Type Assertion)与类型判断(Type Switch)。这两种技能将在下一节中详细介绍。

评论