Go 接口:深入内部原理
接口的基本概念不在这里赘述,详情请看第十六章:接口
nil 非空?
package main
func main() {
var obj interface{}
obj = 1
println(obj == 1) // true
obj = "hello"
println(obj == "hello") // true
type User struct {
}
var u *User
obj = u
println(u == nil) // true
println(obj == nil) // true
}
前面的只是对比,说明interface can hold everything
。我们需要注意的最后两个判断:
u
是一个User
类型的空指针,println(u == nil)
输出true
是意料之内;- 将
u
赋值给obj
后,println(obj == nil)
输出的是false
,意料之外
为什么把空指针u
赋值给interface
后,obj
就不是nil了吗?那它会是什么呢?
通过gdb
工具调试,我们看到interface
原来是长这样的:
(gdb) ptype obj
type = struct runtime.eface {
runtime._type *_type;
void *data;
}
通过goland
断点看一下obj
里面到底了什么
可以看出来data
是用来存储数据,_type
用来存储类型:
- 当
obj = 1
时,底层的eface
的两个属性都是有值的; - 当
obj = u
时,底层的eface
的data
属性为空,_type
属性非空 - 当
obj = nil
时,底层的eface
的data
和_type
属于都为空
对应结构体类型的比较,要求结构体中的所有字段都相等时两个变量才是相等的,因为eface
的_type
属于非空,所以当将u
赋值给obj
后,println(obj == nil
输出的是false
。
这就引出了另一个问题,当执行
obj = u
这行代码时,golang runtime是如何把静态类型的值u
转换成eface
结构的呢?
当给接口赋值时
接着上面的问题,我们通过下面这段简单代码,看看是如何把一个静态类型值转换成eface
的
package main
import "fmt"
func main() {
var a int64 = 123
var i interface{} = a // 这一行进行转换
fmt.Println(i)
}
通过命令go tool compile -N -l -S main.go
将其转成汇编代码
红框内的正是第 7 行对应的汇编指CALL runtime.convT64(SB)
(汇编代码可以直接调用 Go func),我们可以在runtime
包中找到对应的函数函数
// runtime/iface.go
func convT64(val uint64) (x unsafe.Pointer) {
if val < uint64(len(staticuint64s)) {
x = unsafe.Pointer(&staticuint64s[val])
} else {
x = mallocgc(8, uint64Type, false) // 分配内存,(size, _type, needzero)
*(*uint64)(x) = val // 复制
}
return
}
eface, iface
通过上面的实验,我们了解了接口的底层结构是eface
。实际上,Golang 根据接口是否包含方法,将接口分为两类:
eface
:不包含任何绑定方法的接口- 比如:空接口
interface{}
- 比如:空接口
iface
:包含绑定方法的接口- 比如:os.Writer
type Writer interface {
Write(p []byte) (n int, err error)
}
eface
eface
的数据结构:
type eface struct {
_type *_type
data unsafe.Pointer
}
这个我们应该比较熟悉了,在上面的实验中我们已经见过了:_type
和 data
属性,分别代表底层的指向的类型信息和指向的值信息指针。
我们在看一下_type
属性,它的类型是又是一个结构体:
type _type struct {
size uintptr // 类型的大小
ptrdata uintptr // 包含所有指针的内存前缀的大小
hash uint32 // 类型的 hash 值,此处提前计算好,可以避免在哈希表中计算
tflag tflag // 额外的类型信息标志,此处为类型的 flag 标志,主要用于反射
align uint8 // 对应变量与该类型的内存对齐大小
fieldAlign uint8 // 对应类型的结构体的内存对齐大小
kind uint8 // 类型的枚举值, 包含 Go 语言中的所有类型,例如:`kindBool`、`kindInt`、`kindInt8`、`kindInt16` 等
equal func(unsafe.Pointer, unsafe.Pointer) bool // 用于比较此对象的回调函数
gcdata *byte // 存储垃圾收集器的 GC 类型数据
str nameOff
ptrToThis typeOff
}
总结来说:runtime 只需在这里查询,就能得到与类型相关的所有信息(字节大小、类型标志、内存对齐等)。
iface
iface
的数据结构:
type iface struct {
tab *itab
data unsafe.Pointer
}
与iface
相比,它们的data
属性是一样的,用于存储数据;不同的是,因为iface
不仅要存储类型信息,还要存储接口绑定的方法,所有需要使用itab
结构来存储两者信息。我们看一下itab
:
type itab struct {
inter *interfacetype // 接口的类型信息
_type *_type // 具体类型信息
hash uint32 // _type.hash 的副本,用于目标类型和接口变量的类型对比判断
_ [4]byte
fun [1]uintptr // 存储接口的方法集的具体实现的地址,其包含一组函数指针,实现了接口方法的动态分派,且每次在接口发生变更时都会更
}
总结来讲,接口的数据结构基本表示形式比较简单,就是类型和值描述。再根据其具体的区别,例如是否包含方法集,具体的接口类型等进行组合使用。
iface,接口绑定的 method 你存到了哪里?
通过上节,我们知道iface
可以存储接口绑定的方法。从其结构体也能看出来iface.tab.fun
字段就是用来干这个事。但是,我有一个疑问:fun
类型是长度为 1 的指针数组,难道它就只能存一个 method?
type Animal interface {
Speak () string
Move()
Attack()
}
type Lion struct {
}
func (l Lion) Speak() string {
return "Uh....."
}
func (l Lion) Move() {
}
func (l Lion) Attack() {
}
func main() {
lion := Lion{}
var obj interface{} = lion
cc, _ := obj.(Animal)
fmt.Println(cc.Speak()) // Un....
}
Lion
是一个实现了接口Animal
所有方法的结构体,所以一个接口obj
尝试通过类型断言转换成Animal
接口是,是可以成功的。通过 Debug 调试,当我执行cc, _ := obj.(Animal)
这行代码时,内部回去调 assertE2I2
方法然后返回
func assertE2I2(inter *interfacetype, e eface) (r iface, b bool) {
t := e._type
if t == nil {
return
}
tab := getitab(inter, t, true)
if tab == nil {
return
}
r.tab = tab
r.data = e.data
b = true
return
}
所以返回的cc
变量实际上是一个iface
结构体,因为iface
无法导出我们看不到内部数据,但我们可以通过在 main 程序中把iface
结构体定义一封,通过指针操作进行转换:
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct {
inter *interfacetype
_type *_type
hash uint32 // copy of _type.hash. Used for type switches.
_ [4]byte
fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}
...
func main() {
lion := Lion{}
var obj interface{} = lion
cc, _ := obj.(Animal)
fmt.Println(cc.Speak()) // Uh.....
dd := *(*iface)(unsafe.Pointer(&cc)) // 当cc转成 iface 接口体
fmt.Printf("%v\n", dd)
fmt.Printf("%+V", cc)
}
通过 debug 可以看到,接口Animal
对应的eface
的一个完整的数据
tab
里面保存了类型和绑定方法的数据:inter.mhdr
的长度为 3,看起来是存储了 3 个方法的名字和类型,fun
里存储了一个指针,应该就是第一个方法的地址了。下面这段代码可以证实:
// itab 的初始化
func (m *itab) init() string {
inter := m.inter
typ := m._type
x := typ.uncommon()
// ni的值为接口绑定的方法数量
ni := len(inter.mhdr)
nt := int(x.mcount)
// 我猜 xmhdr 是真实存储接口的方法的地方
xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt]
j := 0
methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni]
var fun0 unsafe.Pointer
imethods:
// 遍历3个方案
for k := 0; k < ni; k++ {
i := &inter.mhdr[k]
itype := inter.typ.typeOff(i.ityp)
name := inter.typ.nameOff(i.name)
iname := name.name()
ipkg := name.pkgPath()
if ipkg == "" {
ipkg = inter.pkgpath.name()
}
for ; j < nt; j++ {
t := &xmhdr[j]
tname := typ.nameOff(t.name)
// 通过遍历 xmhdr,如果和mhrd[k]的名字、类型并且pkgpath都相等,就找到了
if typ.typeOff(t.mtyp) == itype && tname.name() == iname {
pkgPath := tname.pkgPath()
if pkgPath == "" {
pkgPath = typ.nameOff(x.pkgpath).name()
}
if tname.isExported() || pkgPath == ipkg {
if m != nil {
// 获取方法的地址
ifn := typ.textOff(t.ifn)
if k == 0 {
// 记录第一个方法的地址
fun0 = ifn // we'll set m.fun[0] at the end
} else {
methods[k] = ifn
}
}
continue imethods
}
}
}
// didn't find method
m.fun[0] = 0
return iname
}
// func[0] = 第一个方法的地址
m.fun[0] = uintptr(fun0)
return ""
}
总结一下,在将一个不确定的interface{}
类型断言成某个特定接口时,runtime 会将原来的数据、方法以iface
的数据结构进行返回。iface
实际上只保存第一个方法的地址,其他的方法通过偏移量就能找到,偏移的信息保存在 mhdr 中(待验证)
类型断言是怎么做到的
Go 是强类型的语言,变量类型、函数传参的类型一定定义就不能变换。这为程序的类型提供了安全稳定的保证,但也为程序的编码带来更多的工作量。比如我们去是实现一个加法函数,需要对不同的类型都写一遍,并且使用起来也不方便:
func addInt(a, b int) int { return a + b }
func addInt32(a, b int32) int32 { return a + b }
func addInt64(a, b int64) int64 { return a + b }
func addFloat32(a, b float32) float32 { return a + b }
func addFloat64(a, b float64) float64 { return a + b }
基于interface can hold everything
,我们通过使用interface{}
当入参类型,用一个函数来实现:
func add(a, b interface{}) interface{} {
switch av := a.(type) {
case int:
if bv, ok := b.(int); ok {
return av + bv
}
panic("bv is not int")
case int32:
if bv, ok := b.(int32); ok {
return av + bv
}
panic("bv is not int32")
...
case float64:
if bv, ok := b.(float64); ok {
return av + bv
}
panic("bv is not float64")
}
panic("illegal a and b")
}
func main() {
var a int64 = 1
var b int64 = 4
c := add(a, b)
fmt.Println(c) // 5
}
可能会有人问:add
函数的参数变量类型是interface{}
了, 它在函数里面是后如何把从interface{}
中的带变量?(答案就是eface
)
第一步
int64
->eface
注意这行代码
c := add(a, b)
,翻译成汇编的话:0x002f 00047 (main.go:132) FUNCDATA $2, "".main.stkobj(SB)
0x002f 00047 (main.go:142) MOVQ $1, "".a+56(SP)
0x0038 00056 (main.go:143) MOVQ $4, "".b+48(SP)
0x0041 00065 (main.go:144) MOVQ "".a+56(SP), AX
0x0046 00070 (main.go:144) MOVQ AX, (SP)
0x004a 00074 (main.go:144) PCDATA $1, $0
0x004a 00074 (main.go:144) CALL runtime.convT64(SB)
注意最后一行
runtime.convT64
,上面提到过,这里的操作就拷贝一份值给到函数add
func convT64(val uint64) (x unsafe.Pointer) {
if val < uint64(len(staticuint64s)) {
x = unsafe.Pointer(&staticuint64s[val])
} else {
x = mallocgc(8, uint64Type, false)
*(*uint64)(x) = val
}
return
}
第二步从
eface
中得到类型信息为了验证我们的猜想,我们在
add
函数入口处通过类型转换把interface{} a
转成eface dd
来看一它的具体数据长什么样func add(a, b interface{}) interface{} {
dd := *(*eface)(unsafe.Pointer(&a))
fmt.Println(dd)
switch av := a.(type) {
case int:
if bv, ok := b.(int); ok {
return av + bv
}
panic("bv is not int")
}
...
通过 debug 看到的 dd 数据如下:
注意dd._type.kind
字段的只为 6,在src/runtime/typekind.go
文件中,维护了每个类型对应一个常量const (
kindBool = 1 + iota
kindInt
kindInt8
kindInt16
kindInt32
kindInt64 // 6
kindUint
kindUint8
kindUint16
kindUint32
kindUint64
kindUintptr
kindFloat32
...
)
可以看到,
int64
对应的常量值正好是 6。这也就解释通过类型断言获取将interface{}
转成具体类型的原理。
总结
接口的作用
- 在 Go 运行时,为方便内部传递数据、操作数据,使用
interface{}
作为存储数据的媒介,大大降低了开发成本。这个媒介存储了数据的位置
、数据的类型
,有这两个信息,就能代表一切变量,即interface can hold everything
。 - 接口也作为一种抽象的能力,通过定义一个接口所需实现的方法,等同于对
如何判定这个 struct 是不是这类接口
完成了明确的定义,即必须是接口绑定的所有方法。通过这种能力,可以在编码上做到很大程度的解耦,接口就好比上下游开发者之间协议。
接口的内部存储有两类
Golang 根据接口是否包含方法,将接口分为两类:
eface
:不包含任何绑定方法的接口- 比如:空接口
interface{}
- 比如:空接口
iface
:包含绑定方法的接口- 比如:os.Writer
二者之间的差别在与eface
多存了接口绑定的方法信息。
当心,变成接口后,判空不准
判空的条件是结构体的所有字段都为nil
才行,当nil
的固定类型值转成接口后,接口的数据值为nil
,但是类型
值不为nil
会导致判空失败。
解决的方案是:函数返回参数不要写出接口类型,在外部先做判空,在转成接口。
Go 接口:深入内部原理的更多相关文章
- Android线程管理(三)——Thread类的内部原理、休眠及唤醒
线程通信.ActivityThread及Thread类是理解Android线程管理的关键. 线程,作为CPU调度资源的基本单位,在Android等针对嵌入式设备的操作系统中,有着非常重要和基础的作用. ...
- 【转】SQLServer内部原理
原文地址:http://twb.iteye.com/blog/182083 在讲SQLSERVER内部原理的之前,我觉得非常有必要向大家介绍一下SQLSERVER的历史. 让我们站在1999年,看看计 ...
- asp.net内部原理3
asp.net内部原理(三) 第三个版本 (最详细的版本) 前言: 今天继续吧这个系列补齐,这几天公司的项目比较忙,回到家已经非常的累了,所以也没顾得上天天来这里分享一些东西和大家一起探讨,但是今天晚 ...
- JVM内部原理
这篇文章详细描述了Java虚拟机的内在结构.下面这张图来自<The Java Virtual Machine Specification Java SE 7 Edition>,它展示了一个 ...
- JVM 内部原理(六)— Java 字节码基础之一
JVM 内部原理(六)- Java 字节码基础之一 介绍 版本:Java SE 7 为什么需要了解 Java 字节码? 无论你是一名 Java 开发者.架构师.CxO 还是智能手机的普通用户,Java ...
- JVM 内部原理(四)— 基本概念之 JVM 结构
JVM 内部原理(四)- 基本概念之 JVM 结构 介绍 版本:Java SE 7 每位使用 Java 的程序员都知道 Java 字节码在 Java 运行时(JRE - Java Runtime En ...
- JVM 内部原理(三)— 基本概念之类文件格式
JVM 内部原理(三)- 基本概念之类文件格式 介绍 版本:Java SE 7 每位使用 Java 的程序员都知道 Java 字节码在 Java 运行时(JRE - Java Runtime Envi ...
- JVM 内部原理(二)— 基本概念之字节码
JVM 内部原理(二)- 基本概念之字节码 介绍 版本:Java SE 7 每位使用 Java 的程序员都知道 Java 字节码在 Java 运行时(JRE - Java Runtime Enviro ...
- JVM 内部原理(一)— 概述
JVM 内部原理(一)- 概述 介绍 版本:Java SE 7 图中显示组件将会从两个方面分别解释.第一部分涵盖线程独有的组件,第二部分涵盖独立于线程的组件(即线程共享组件). 目录 线程独享(Thr ...
随机推荐
- 一文详解 Ansible 自动化运维
开源Linux 一个执着于技术的公众号 一.Ansible 概述 Ansible 是近年来越来越火的一款开源运维自动化工具,通过Ansible可以实现运维自动化,提高运维工程师的工作效率,减少人为失误 ...
- python写一个能变身电光耗子的贪吃蛇
python写一个不同的贪吃蛇 写这篇文章是因为最近课太多,没有精力去挖洞,记录一下学习中的收获,python那么好玩就写一个大一没有完成的贪吃蛇(主要还是跟课程有关o(╥﹏╥)o,课太多好烦) 第一 ...
- docker-compose 启动 rabbitmq
说明 前提条件 ubuntu-20.04-server docker & docker-compose 安装参考 安装 准备 rabbitmq.conf 新建 rabbitmq.conf 文件 ...
- linux下三种服务开机自启的方式
方式一.二.三适用于ubuntu,centos推荐使用方式二.方式三 方式一 在ubuntu系统中,如果你使用的apt方式安装的软件,可以使用如下方式直接添加服务的开机自启, 如果你是手动解压缩官网下 ...
- SpringCloud基础概念学习笔记(Eureka、Ribbon、Feign、Zuul)
SpringCloud基础概念学习笔记(Eureka.Ribbon.Feign.Zuul) SpringCloud入门 参考: https://springcloud.cc/spring-cloud- ...
- 3D编程模式:依赖隔离模式
大家好~本文提出了"依赖隔离"模式 系列文章详见: 3D编程模式:开篇 本文相关代码在这里: 相关代码 目录 编辑器需要替换引擎 设计意图 定义 应用 扩展 最佳实践 更多资料推荐 ...
- QT 基于QScrollArea的界面嵌套移动
在实际的应用场景中,经常会出现软件界面战场图大于实际窗体大小,利用QScrollArea可以为widget窗体添加滚动条,可以实现小窗体利用滚动条显示大界面需求.实现如下: QT创建一个qWidget ...
- Camunda如何配置和使用mysql数据库
Camunda默认使用已预先配置好的H2数据库,数据库模式和所有必需的表将在引擎第一次启动时自动创建.如果你想使用自定义独立数据库,比如mysql,请遵循以下步骤: 一.新建mysql数据库 为Cam ...
- php类精确验证身份证号码
<?php // check class check{ // $num为身份证号码,$checkSex:1为男,2为女,不输入为不验证 public function checkIdentity ...
- 【Redis】集群故障转移
集群故障转移 节点下线 在集群定时任务clusterCron中,会遍历集群中的节点,对每个节点进行检查,判断节点是否下线.与节点下线相关的状态有两个,分别为CLUSTER_NODE_PFAIL和CLU ...