Go接口:深入内部原理-《GO开发知识笔记》

admin 2025-11-04 01:05:51 编程 来源:ZONE.CI 全球网 0 阅读模式
  • nil 非空?
  • 当给接口赋值时
  • eface, iface
    • iface
    • iface,接口绑定的 method 你存到了哪里?
  • 类型断言是怎么做到的
  • 总结
    • 接口的作用
    • 接口的内部存储有两类
    • 当心,变成接口后,判空不准

    nil 非空?

    1. package main
    2. func main() {
    3. var obj interface{}
    4. obj = 1
    5. println(obj == 1) // true
    6. obj = "hello"
    7. println(obj == "hello") // true
    8. type User struct {
    9. }
    10. var u *User
    11. obj = u
    12. println(u == nil) // true
    13. println(obj == nil) // true
    14. }

    前面的只是对比,说明interface can hold everything。我们需要注意的最后两个判断:

    • u是一个User类型的空指针,println(u == nil)输出true是意料之内;
    • 将u赋值给obj后,println(obj == nil)输出的是false,意料之外

    为什么把空指针u赋值给interface后,obj就不是nil了吗?那它会是什么呢?通过gdb工具调试,我们看到interface原来是长这样的:

    1. (gdb) ptype obj
    2. type = struct runtime.eface {
    3. runtime._type *_type;
    4. void *data;
    5. }

    通过goland断点看一下obj里面到底了什么Go 接口:深入内部原理 - 图1可以看出来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的

    1. package main
    2. import "fmt"
    3. func main() {
    4. var a int64 = 123
    5. var i interface{} = a // 这一行进行转换
    6. fmt.Println(i)
    7. }

    通过命令go tool compile -N -l -S main.go将其转成汇编代码Go 接口:深入内部原理 - 图2红框内的正是第 7 行对应的汇编指CALL runtime.convT64(SB)(汇编代码可以直接调用 Go func),我们可以在runtime包中找到对应的函数函数

    1. // runtime/iface.go
    2. func convT64(val uint64) (x unsafe.Pointer) {
    3. if val < uint64(len(staticuint64s)) {
    4. x = unsafe.Pointer(&staticuint64s[val])
    5. } else {
    6. x = mallocgc(8, uint64Type, false) // 分配内存,(size, _type, needzero)
    7. *(*uint64)(x) = val // 复制
    8. }
    9. return
    10. }

    eface, iface

    通过上面的实验,我们了解了接口的底层结构是eface。实际上,Golang 根据接口是否包含方法,将接口分为两类:

    • eface:不包含任何绑定方法的接口
      • 比如:空接口 interface{}
    • iface:包含绑定方法的接口 ```go type Writer interface {
      1. Write(p []byte) (n int, err error)
    1. - 比如:os.Writer
    2. <a name="dXWdh"></a>
    3. ### eface
    4. eface的数据结构:
    5. ```go
    6. type eface struct {
    7. _type *_type
    8. data unsafe.Pointer
    9. }

    这个我们应该比较熟悉了,在上面的实验中我们已经见过了:_type 和 data 属性,分别代表底层的指向的类型信息和指向的值信息指针。我们在看一下_type属性,它的类型是又是一个结构体:

    1. type _type struct {
    2. size uintptr // 类型的大小
    3. ptrdata uintptr // 包含所有指针的内存前缀的大小
    4. hash uint32 // 类型的 hash 值,此处提前计算好,可以避免在哈希表中计算
    5. tflag tflag // 额外的类型信息标志,此处为类型的 flag 标志,主要用于反射
    6. align uint8 // 对应变量与该类型的内存对齐大小
    7. fieldAlign uint8 // 对应类型的结构体的内存对齐大小
    8. kind uint8 // 类型的枚举值, 包含 Go 语言中的所有类型,例如:`kindBool`、`kindInt`、`kindInt8`、`kindInt16` 等
    9. equal func(unsafe.Pointer, unsafe.Pointer) bool // 用于比较此对象的回调函数
    10. gcdata *byte // 存储垃圾收集器的 GC 类型数据
    11. str nameOff
    12. ptrToThis typeOff
    13. }

    总结来说:runtime 只需在这里查询,就能得到与类型相关的所有信息(字节大小、类型标志、内存对齐等)。

    iface

    iface的数据结构:

    1. type iface struct {
    2. tab *itab
    3. data unsafe.Pointer
    4. }

    与iface相比,它们的data属性是一样的,用于存储数据;不同的是,因为iface不仅要存储类型信息,还要存储接口绑定的方法,所有需要使用itab结构来存储两者信息。我们看一下itab:

    1. type itab struct {
    2. inter *interfacetype // 接口的类型信息
    3. _type *_type // 具体类型信息
    4. hash uint32 // _type.hash 的副本,用于目标类型和接口变量的类型对比判断
    5. _ [4]byte
    6. fun [1]uintptr // 存储接口的方法集的具体实现的地址,其包含一组函数指针,实现了接口方法的动态分派,且每次在接口发生变更时都会更
    7. }

    总结来讲,接口的数据结构基本表示形式比较简单,就是类型和值描述。再根据其具体的区别,例如是否包含方法集,具体的接口类型等进行组合使用。Go 接口:深入内部原理 - 图3

    iface,接口绑定的 method 你存到了哪里?

    通过上节,我们知道iface可以存储接口绑定的方法。从其结构体也能看出来iface.tab.fun字段就是用来干这个事。但是,我有一个疑问:fun类型是长度为 1 的指针数组,难道它就只能存一个 method?

    1. type Animal interface {
    2. Speak () string
    3. Move()
    4. Attack()
    5. }
    6. type Lion struct {
    7. }
    8. func (l Lion) Speak() string {
    9. return "Uh....."
    10. }
    11. func (l Lion) Move() {
    12. }
    13. func (l Lion) Attack() {
    14. }
    15. func main() {
    16. lion := Lion{}
    17. var obj interface{} = lion
    18. cc, _ := obj.(Animal)
    19. fmt.Println(cc.Speak()) // Un....
    20. }

    Lion是一个实现了接口Animal所有方法的结构体,所以一个接口obj尝试通过类型断言转换成Animal接口是,是可以成功的。通过 Debug 调试,当我执行cc, _ := obj.(Animal)这行代码时,内部回去调 assertE2I2方法然后返回

    1. func assertE2I2(inter *interfacetype, e eface) (r iface, b bool) {
    2. t := e._type
    3. if t == nil {
    4. return
    5. }
    6. tab := getitab(inter, t, true)
    7. if tab == nil {
    8. return
    9. }
    10. r.tab = tab
    11. r.data = e.data
    12. b = true
    13. return
    14. }

    所以返回的cc变量实际上是一个iface结构体,因为iface无法导出我们看不到内部数据,但我们可以通过在 main 程序中把iface结构体定义一封,通过指针操作进行转换:

    1. type iface struct {
    2. tab *itab
    3. data unsafe.Pointer
    4. }
    5. type itab struct {
    6. inter *interfacetype
    7. _type *_type
    8. hash uint32 // copy of _type.hash. Used for type switches.
    9. _ [4]byte
    10. fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
    11. }
    12. ...
    13. func main() {
    14. lion := Lion{}
    15. var obj interface{} = lion
    16. cc, _ := obj.(Animal)
    17. fmt.Println(cc.Speak()) // Uh.....
    18. dd := *(*iface)(unsafe.Pointer(&cc)) // 当cc转成 iface 接口体
    19. fmt.Printf("%v\n", dd)
    20. fmt.Printf("%+V", cc)
    21. }

    通过 debug 可以看到,接口Animal对应的eface的一个完整的数据Go 接口:深入内部原理 - 图4tab里面保存了类型和绑定方法的数据:inter.mhdr的长度为 3,看起来是存储了 3 个方法的名字和类型,fun里存储了一个指针,应该就是第一个方法的地址了。下面这段代码可以证实:

    1. // itab 的初始化
    2. func (m *itab) init() string {
    3. inter := m.inter
    4. typ := m._type
    5. x := typ.uncommon()
    6. // ni的值为接口绑定的方法数量
    7. ni := len(inter.mhdr)
    8. nt := int(x.mcount)
    9. // 我猜 xmhdr 是真实存储接口的方法的地方
    10. xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt]
    11. j := 0
    12. methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni]
    13. var fun0 unsafe.Pointer
    14. imethods:
    15. // 遍历3个方案
    16. for k := 0; k < ni; k++ {
    17. i := &inter.mhdr[k]
    18. itype := inter.typ.typeOff(i.ityp)
    19. name := inter.typ.nameOff(i.name)
    20. iname := name.name()
    21. ipkg := name.pkgPath()
    22. if ipkg == "" {
    23. ipkg = inter.pkgpath.name()
    24. }
    25. for ; j < nt; j++ {
    26. t := &xmhdr[j]
    27. tname := typ.nameOff(t.name)
    28. // 通过遍历 xmhdr,如果和mhrd[k]的名字、类型并且pkgpath都相等,就找到了
    29. if typ.typeOff(t.mtyp) == itype && tname.name() == iname {
    30. pkgPath := tname.pkgPath()
    31. if pkgPath == "" {
    32. pkgPath = typ.nameOff(x.pkgpath).name()
    33. }
    34. if tname.isExported() || pkgPath == ipkg {
    35. if m != nil {
    36. // 获取方法的地址
    37. ifn := typ.textOff(t.ifn)
    38. if k == 0 {
    39. // 记录第一个方法的地址
    40. fun0 = ifn // we'll set m.fun[0] at the end
    41. } else {
    42. methods[k] = ifn
    43. }
    44. }
    45. continue imethods
    46. }
    47. }
    48. }
    49. // didn't find method
    50. m.fun[0] = 0
    51. return iname
    52. }
    53. // func[0] = 第一个方法的地址
    54. m.fun[0] = uintptr(fun0)
    55. return ""
    56. }

    总结一下,在将一个不确定的interface{}类型断言成某个特定接口时,runtime 会将原来的数据、方法以iface的数据结构进行返回。iface实际上只保存第一个方法的地址,其他的方法通过偏移量就能找到,偏移的信息保存在 mhdr 中(待验证)

    类型断言是怎么做到的

    Go 是强类型的语言,变量类型、函数传参的类型一定定义就不能变换。这为程序的类型提供了安全稳定的保证,但也为程序的编码带来更多的工作量。比如我们去是实现一个加法函数,需要对不同的类型都写一遍,并且使用起来也不方便:

    1. func addInt(a, b int) int { return a + b }
    2. func addInt32(a, b int32) int32 { return a + b }
    3. func addInt64(a, b int64) int64 { return a + b }
    4. func addFloat32(a, b float32) float32 { return a + b }
    5. func addFloat64(a, b float64) float64 { return a + b }

    基于interface can hold everything,我们通过使用interface{}当入参类型,用一个函数来实现:

    1. func add(a, b interface{}) interface{} {
    2. switch av := a.(type) {
    3. case int:
    4. if bv, ok := b.(int); ok {
    5. return av + bv
    6. }
    7. panic("bv is not int")
    8. case int32:
    9. if bv, ok := b.(int32); ok {
    10. return av + bv
    11. }
    12. panic("bv is not int32")
    13. ...
    14. case float64:
    15. if bv, ok := b.(float64); ok {
    16. return av + bv
    17. }
    18. panic("bv is not float64")
    19. }
    20. panic("illegal a and b")
    21. }
    22. func main() {
    23. var a int64 = 1
    24. var b int64 = 4
    25. c := add(a, b)
    26. fmt.Println(c) // 5
    27. }

    可能会有人问:add函数的参数变量类型是interface{}了, 它在函数里面是后如何把从interface{}中的带变量?(答案就是eface)

    1. 第一步int64 -> eface注意这行代码 c := add(a, b),翻译成汇编的话:

      1. 0x002f 00047 (main.go:132) FUNCDATA $2, "".main.stkobj(SB)
      2. 0x002f 00047 (main.go:142) MOVQ $1, "".a+56(SP)
      3. 0x0038 00056 (main.go:143) MOVQ $4, "".b+48(SP)
      4. 0x0041 00065 (main.go:144) MOVQ "".a+56(SP), AX
      5. 0x0046 00070 (main.go:144) MOVQ AX, (SP)
      6. 0x004a 00074 (main.go:144) PCDATA $1, $0
      7. 0x004a 00074 (main.go:144) CALL runtime.convT64(SB)
    2. 注意最后一行runtime.convT64,上面提到过,这里的操作就拷贝一份值给到函数add

      1. func convT64(val uint64) (x unsafe.Pointer) {
      2. if val < uint64(len(staticuint64s)) {
      3. x = unsafe.Pointer(&staticuint64s[val])
      4. } else {
      5. x = mallocgc(8, uint64Type, false)
      6. *(*uint64)(x) = val
      7. }
      8. return
      9. }
    3. 第二步从eface中得到类型信息为了验证我们的猜想,我们在add函数入口处通过类型转换把interface{} a转成eface dd来看一它的具体数据长什么样

      1. func add(a, b interface{}) interface{} {
      2. dd := *(*eface)(unsafe.Pointer(&a))
      3. fmt.Println(dd)
      4. switch av := a.(type) {
      5. case int:
      6. if bv, ok := b.(int); ok {
      7. return av + bv
      8. }
      9. panic("bv is not int")
      10. }
    4. 通过 debug 看到的 dd 数据如下:Go 接口:深入内部原理 - 图5注意dd._type.kind字段的只为 6,在src/runtime/typekind.go文件中,维护了每个类型对应一个常量

      1. const (
      2. kindBool = 1 + iota
      3. kindInt
      4. kindInt8
      5. kindInt16
      6. kindInt32
      7. kindInt64 // 6
      8. kindUint
      9. kindUint8
      10. kindUint16
      11. kindUint32
      12. kindUint64
      13. kindUintptr
      14. kindFloat32
      15. ...
      16. )
    5. 可以看到,int64对应的常量值正好是 6。这也就解释通过类型断言获取将interface{}转成具体类型的原理。

      总结

      接口的作用

    • 在 Go 运行时,为方便内部传递数据、操作数据,使用interface{}作为存储数据的媒介,大大降低了开发成本。这个媒介存储了数据的位置、数据的类型,有这两个信息,就能代表一切变量,即interface can hold everything。
    • 接口也作为一种抽象的能力,通过定义一个接口所需实现的方法,等同于对如何判定这个 struct 是不是这类接口完成了明确的定义,即必须是接口绑定的所有方法。通过这种能力,可以在编码上做到很大程度的解耦,接口就好比上下游开发者之间协议。

      接口的内部存储有两类

      Golang 根据接口是否包含方法,将接口分为两类:

    • eface:不包含任何绑定方法的接口

      • 比如:空接口 interface{}
    • iface:包含绑定方法的接口
      • 比如:os.Writer

    二者之间的差别在与eface多存了接口绑定的方法信息。

    当心,变成接口后,判空不准

    判空的条件是结构体的所有字段都为nil才行,当nil的固定类型值转成接口后,接口的数据值为nil,但是类型值不为nil会导致判空失败。Go 接口:深入内部原理 - 图6解决的方案是:函数返回参数不要写出接口类型,在外部先做判空,在转成接口。

    以太坊cppgolang区别 编程

    以太坊cppgolang区别

    以太坊是一种去中心化的开源平台,它采用智能合约技术,旨在构建和运行不受干扰的分布式应用程序。作为目前最受欢迎的区块链平台之一,以太坊提供了多种编程语言的支持,其
    progolang 编程

    progolang

    Go语言(Golang)是由Google开发的一门静态类型编程语言。作为一名专业的Golang开发者,我深知这门语言的优势和特点。在本文中,我将介绍Golang
    golangn个发送者 编程

    golangn个发送者

    Golang是一种开源的编程语言,由Google团队开发,旨在提高程序的并发性和简化软件开发过程。在Go语言中,有时需要向多个接收者发送信息。本文将介绍如何在G
    golang技能图谱 编程

    golang技能图谱

    从互联网行业的快速发展到人工智能技术的日益成熟,各种编程语言也应运而生。而在这众多的编程语言中,Golang(即Go)作为一门强大且高效的开发语言备受关注。Go
    评论:0   参与:  11