nil 接口、接口比较、空接口
7.10 接口与 nil
在讨论指针时(“六 指针<105页>”),曾提到了 nil——指针类型的零值(Zero Value)。虽然 nil 也可以用来表示接口实例的零值,但其行为比具体类型(Concrete Type)更为复杂。
要理解接口与 nil 的关系,需要先了解 Go 接口的底层实现机制。在 Go 运行时中,接口实际上是由两个指针组成的结构体实现的:
- 类型指针:指向接口动态类型的信息(
*itab); - 值指针:指向实际存储的数据(
unsafe.Pointer);
只要类型指针非空(即使值指针为 nil),接口就不等于 nil。因为 Go 是强类型语言,所有变量必须有类型信息。因此:
- 若值指针非空,则类型指针必然非空(即“不可能存在无类型的值”)。
- 但类型指针非空时,值指针可以为空(即“有类型的 nil”)。
两个结论:
- 接口值为空 <=> 接口的类型指针和值指针都为空
- 接口值非空 => 接口一定包含类型信息,即类型指针非空
在 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时动态类型是int,var i any = &x时动态类型是*int。- 动态值,就是该具体值,例如
var i any = 10时动态值是10,var 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。
❷ 第二次调用,将 *DoubleInt 与 DoubleIntSlice 进行比较,此处将打印出 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.Value 的 Comparable 方法检查接口是否可比较(有关反射的内容介绍,详见“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)。这两种技能将在下一节中详细介绍。
评论