Go 接口值(P1):动态类型与动态值

在讨论 Go 接口时,我们通常会首先关注接口类型本身的定义与使用。然而,仅仅理解接口的声明是不够的。我们有必要进一步探讨它背后运作的核心机制,尤其是接口值这一概念。接口值在Go语言中的作用至关重要,它不仅影响到"多态"的实现,还会关系到程序的行为。那么,什么是接口值呢?

Q1: 什么是接口值

概念上讲接口值,一个接口类型的值,由两个部分组成,一个具体的类型和那个类型的值 。它们被称为接口的动态类型和动态值 ^[1]^^[2]^。而在 runtime 表示上,接口值由两个指针组成,一个指针指向具体类型信息,而另一个指针指向具体类型的值。

我们先从一个简单的例子开始, 定义一个Binary类型,它基于uint64Binary类型实现了Stringer接口,使得它可以通过String()方法将自身的uint64值以某种格式表示并返回字符串。

Go 复制代码
type Stringer interface {
    String() string
}

type Binary uint64

func (i Binary) String() string {
    return strconv.FormatUint(i.Get(), 2)
}

func (i Binary) Get() uint64 {
    return uint64(i)
}

接着创建一个类型为Binary的变量 b,并为其赋值。如b := Binary(200)

随后将变量 b 指派给类型为Stringer的变量 s。如var s Stringer = b。在此过程中,接口值的动态类型会被设置为Binary,动态值则被置为200

对于Stringer接口类型的变量 s 来说,我们赋给它的值被叫做它的实际值(动态值 ),而该值的类型被叫做这个变量的实际类型(动态类型)。

动态类型这个叫法是相对于静态类型 而言的。对于变量 s 来讲,它的静态类型就是Stringer,并且永远是Stringer,但是它的动态类型却会随着我们赋给它的值而变化。比如,只有我们把一个Binary类型的值赋给变量 s 之后,该变量的动态类型才会是Binary。如果还有一个实现了Stringer接口的类型,比如Hexadecimal,并且我们又把一个此类型的值赋给了 s,那么 s 的动态类型就会变为Hexadecimal。此外,在我们给一个接口类型的变量赋予实际的值之前,它的动态类型和动态值是不存在的,都是 nil ^[3]^。

现在让我们思考一下几个问题:

  • ① nil 接口与 nil 指针之间的区别?它们在什么情况下会引发问题?
  • ② 为什么有时候接口值是 nil,但却无法检测为 nil?
  • ③ 在什么情况下,接口的动态类型不是 nil 但动态值是 nil?
  • ④ 两个接口何时可以认为相等?什么时候它们即使看似相同但却不相等?

其实以上四个问题本质是同一个问题。以第一个问题说明,在 Go 中,nil 接口和 nil 指针是两个不同的概念,尽管它们的值都是 nil,但它们的含义和表现形式在程序中是不同的。

  • nil 接口 :当接口的动态类型和动态值(两者本质都是指针)都为 nil 时,接口才会被认为是 nil。一个简单的例子是定义一个Stringer类型的变量 s ,但我们却没有将Binary(200)赋值给 s,那么此时 s 的接口值就是 nil。
  • nil 指针 :nil 指针是一个指针类型(如 *T)的变量,该变量指向的值为 nil,即代表它没有实际指向任何内存地址或对象,但它仍然具有具体的指针类型。

通常,这类概念的混淆会导致错误的 nil 判断,从而引发逻辑错误,尤其是在进行接口和指针比较时。比如,nil 指针赋值给接口后,接口不为 nil。当一个 nil 指针赋值给接口时,接口的动态类型仍然存在,因此接口不会被认为是 nil。这导致了一些情况下程序逻辑的混淆,可能会错误地认为接口为 nil,但实际上它并非真正的 nil。

Go 复制代码
type MyStruct struct{}

func main() {
    var p *MyStruct = nil
    var i interface{} = p

    fmt.Println(i == nil) // false,因为接口的动态类型是 *MyStruct
}

在这个例子中,虽然 p 是一个 nil 指针,但它被赋值给了接口 i 后,接口的动态类型是*MyStruc,因此 i 并不是 nil,即使它包含了一个 nil 指针的动态值。这种情况下,使用i == nil来判断接口值是否为 nil 就会失败。

Q2: 接口值的运行时表示

前面的讨论中我们提到过"在 runtime 表示上,接口值由两个指针组成,一个指针指向存储在接口中的类型信息,而另一个指针指向相关类型的值"。但在 runtime 中接口值究竟长什么样呢?这得先从 Go 接口的两种分类说起。

Go 语言中的接口分为两类:一是没有方法的空接口interface{},二是拥有方法集的接口(如Stringer)。Go 语言底层运行时则使用了两种不同的结构来表示这两类接口:

  • eface:表示空接口;
  • iface:表示拥有方法集的接口。
Go 复制代码
type eface struct {
	_type *_type	
	data  unsafe.Pointer
}

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

可当我们看到这两个结构后或许会产生几个疑问:

  • _type类型和itab类型代表什么?
  • ② 为什么空接口与非空接口要用不同的结构表示?

Q2.1: _type类型和itab类型

这个问题我们先只探讨一半,大致了解_type类型代表的什么。而itab类型会在《动态分派与接口表》中进行介绍,现在只需知道它是一个包含了接口本身信息、具体类型信息和方法映射的接口表即可。

那么我们现在来看_type类型,它其实是在 runtime 中被定义的一个类型的别名,其真实类型为 abi.Typeabi.Type用于表示类型元数据,是 Go 语言内部表示类型系统的重要部分,为运行时提供类型信息支持,包括反射、类型断言、接口实现等功能,其中封装了每个 Go 类型的相关信息,如大小、对齐方式等。

Go 复制代码
type Type struct {
	Size_       uintptr
	PtrBytes    uintptr
	Hash        uint32		// 类型的哈希值,避免在哈希表中进行计算
	TFlag       TFlag		
	Align_      uint8		
	FieldAlign_ uint8		
	Kind_       uint8
	Equal       func(unsafe.Pointer, unsafe.Pointer) bool
	GCData      *byte		
	Str         NameOff
	PtrToThis   TypeOff
}

Type提供了所有类型的最基本的描述,对于一些更复杂的类型,例如复合类型 slice、map 等,运行时中分别定义了slicetypemaptype等对应的结构^[4]^。还比如之后我们会提及的interfacetype,这些结构中会嵌入类型元数据Type

Go 复制代码
type InterfaceType struct {
	Type 				// 接口类型
	PkgPath Name      	// import path
	Methods []Imethod 	// 方法集合,按照hash排序
}

// 又比如数组
type ArrayType struct {
	Type
	Elem  *Type
	Slice *Type
	Len   uintptr
}

此外如果是自定义类型或者具有方法的类型,则类型信息中还会包含非通用信息(即方法集信息)UncommonType

Go 复制代码
type UncommonType struct {
	PkgPath NameOff
	Mcount  uint16  // 方法数量
	Xcount  uint16  // 可导出的方法数量
	Moff    uint32  // 从 uncommontype 到 [mcount]Method 的偏移量
	_       uint32 
}

type Method struct {
	Name NameOff	// 通过name偏移能够找到方法的名称字符串
	Mtyp TypeOff	// 偏移处是方法的类型元数据,进一步可以找到参数和返回值相关的类型元数据
	Ifn  TextOff	// 提供接口调用的方法地址
	Tfn  TextOff	// 普通的方法地址
}

对于自定义类型或接口类型,如果它们有方法集(即使它没有方法,仍然有可能有UncommonType),那么它们会使用UncommonType来存储这些方法信息。且这些信息会紧跟在相关类型数据后面,如下内存布局所示:
如果想更清楚了解UncommonType,可以去查看方法 Uncommon。,或许可以从中得到更多启发。

Q2.2: 为什么空接口与非空接口要用不同的结构

efaceiface本质明明都是存储两个指针的结构,为什么不能统一都用一个结构呢,比如都用iface?确实,尽管从理论上iface可以替代eface,但出于对性能优化内存占用的考虑 ,Go 最终选择了使用两种结构。

空接口 用于表示任何类型,它在 Go 中被广泛用于处理任意类型的数据。非空接口 用于定义和约束某些行为,它规定了实现类型必须具备的一些方法,是为了抽象特定的行为契约。两者用途之间的差异使得空接口与非空接口所承载的信息亦有所差异。空接口 仅需包含类型信息和类型值数据,而非空接口 除了类型信息和类型值数据外,还需要存储接口本身的类型信息具体类型方法与接口方法的映射关系。

若使用iface表示空接口,那么接口表itab除了保存指向原始类型的指针之外没有任何用途。保留的其他额外的字段会增加内存占用,导致不必要的开销,尤其是在大量使用空接口的情况下^[2]^。

此外,itab的存在还会带来额外的性能开销。当某个值赋给非空接口或者接口之间进行转换时,Go 运行时会进行动态分派 ,或生成或查找itab,便于后续的动态方法调用。这又涉及到运行时的查找和加载itab表的开销。

所以出于对性能优化和内存占用的考虑 ,不如对空接口的表示进行优化,去除接口表itab,保留直接指向类型的指针^[2]^。

相关推荐
OT.Ter3 小时前
【力扣打卡系列】二分查找(搜索旋转排序数组)
算法·leetcode·职场和发展·go·二分查找
0x派大星16 小时前
Golang 并发编程入门:Goroutine 简介与基础用法
开发语言·后端·golang·go·goroutine
白泽来了17 小时前
我开源了一个短视频应用(Go+React)|DouTok2.0 项目介绍
微服务·开源·go·react
techdashen18 小时前
标准库中的模糊测试
go
程序员阿法1 天前
Go语言基础教程:数据结构Map
开发语言·golang·go
程序员阿法2 天前
Go 语言教程:8.数组
开发语言·golang·go·1024程序员节
确实可以2 天前
7.使用Redis进行秒杀优化
redis·消息队列·go
GoFly开发者3 天前
GoFly快速开发框架已集成了RTSP流媒体服务器(直播、录播)代码插件-开发者集成流媒体服务器更加方便
go·音视频开发
科技百宝箱3 天前
gin入门教程(9):路由分组与路由版本控制
开发语言·golang·go·gin·1024程序员节