Golang Go语言中的 interface
如未做特别说明, 全文依然假设 GOOS=linux GOARCH=amd64, go 的版本为 go1.21.8.
值调用指针方法的实现
这大概率是一个语法糖, 由编译器塞入取地址指令. 我们构造了如下的例子, 编译后通过汇编来验证我们的猜想.
1 package main
2
3 type Foo struct {
4 name string
5 age int
6 }
7
8 //go:noinline
9 func (foo *Foo) ChangeName(name string) {
10 foo.name = name
11 }
12
13 func main() {
14 foo := Foo{name: "foo", age: 35}
15 foo.ChangeName("bar")
16 (&foo).ChangeName("baz")
17 }
➜ go-generic git:(main) ✗ GOOS=linux GOARCH=amd64 go build value_call_ptr_method.go
➜ go-generic git:(main) ✗ GOOS=linux GOARCH=amd64 go tool objdump -S value_call_ptr_method | grep "foo.ChangeName(\"bar" -A 11
foo.ChangeName("bar")
0x45786c 488d442418 LEAQ 0x18(SP), AX
0x457871 488d1d9dea0000 LEAQ 0xea9d(IP), BX
0x457878 b903000000 MOVL $0x3, CX
0x45787d 0f1f00 NOPL 0(AX)
0x457880 e85bffffff CALL main.(*Foo).ChangeName(SB)
(&foo).ChangeName("baz")
0x457885 488d442418 LEAQ 0x18(SP), AX
0x45788a 488d1d87ea0000 LEAQ 0xea87(IP), BX
0x457891 b903000000 MOVL $0x3, CX
0x457896 e845ffffff CALL main.(*Foo).ChangeName(SB)
}
我们可以看到两次调用的汇编几乎是一致, 其中:
- 0x45786c 将 foo 的地址加载到寄存器 AX, 调用方法时, 需要将接受者作为一个参数传入.
- 0x457871 和 0x457878 将字符串 bar 加载到寄存器 BX, CX, 字符串需要用连个寄存器
- 0x45787d 是 NOPL 指令, 没有实际影响
- 0x457880 调用 ChangeName, 两个参数依次被保存在 AX, BX+CX
interface 的实现
Russ Cox 的 Go Data Structures: Interfaces 是了解 interface 实现的最好入口之一. 在此基础上, 我们通过一些构造的例子来加深/验证自己的理解.
1 package main
2
3 import (
4 "unsafe"
5 )
6
7 type Namer interface {
8 GetName() string
9 }
10
11 type User struct {
12 Name string
13 Age int
14 }
15
16 func (u User) GetName() string { return u.Name }
17
18 //go:noinline
19 func getName(i Namer) string { return i.GetName() }
20
21 func main() {
22 u := User{"Foo", 35}
23 i := Namer(u)
24 getName(i)
25 }
上述代码对应的汇编如下:
➜ go-generic git:(main) ✗ GOOS=linux GOARCH=amd64 go tool compile -S itf.go | sed 's/\/Users\/j2gg0s\/go\/src\/github.com\/j2gg0s\/j2gg0s\/examples\/go-generic\///g' | cat -n - | grep -E "itf.go:(22|23|24)"
65 0x000e 00014 (itf.go:22) MOVQ $0, main.u+16(SP)
66 0x0017 00023 (itf.go:22) MOVUPS X15, main.u+24(SP)
67 0x001d 00029 (itf.go:22) LEAQ go:string."Foo"(SB), CX
68 0x0024 00036 (itf.go:22) MOVQ CX, main.u+16(SP)
69 0x0029 00041 (itf.go:22) MOVQ $3, main.u+24(SP)
70 0x0032 00050 (itf.go:22) MOVQ $35, main.u+32(SP)
71 0x003b 00059 (itf.go:23) LEAQ type:<unlinkable>.User(SB), AX
72 0x0042 00066 (itf.go:23) LEAQ main.u+16(SP), BX
73 0x0047 00071 (itf.go:23) PCDATA $1, $0
74 0x0047 00071 (itf.go:23) CALL runtime.convT(SB)
75 0x004c 00076 (itf.go:24) MOVQ AX, BX
76 0x004f 00079 (itf.go:24) LEAQ go:itab.<unlinkable>.User,<unlinkable>.Namer(SB), AX
77 0x0056 00086 (itf.go:24) CALL main.getName(SB)
其中:
- L67~L70 新建了变量 u 并存放在 16(SP)
- L71 将类型 User 的加载到寄存器 AX
- L72 将变量 u 的地址加载到寄存器 BX
- L74 调用 runtime.convT, 两个入参保存在 AX 和 BX
- L75 将 runtime.convT 的返回从寄存器 AX 移动到寄存器 BX
- L76 将 interface 的 itab 加载到寄存器 AX
- L77 调用 main.getName
结合上述的汇编代码和 runtime, 不难理解 interface 在 runtime 中对应的结构体 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.
}
直观的来看, iface 保存的核心信息是:
- interface 的类型, itab.inter
- 底层的精确类型, itab._type
- 底层的值, data
L76 的 go:itab.User,.Namer
大概率是编译器结合 interface 和 struct 构造的 itab,
但是 go tool compile
并没有直接给出可以验证这一点的内容.
我们参考 go-internal
, 尝试从 elf 文件中读取读取相关内容.
➜ go-generic git:(main) ✗ GOOS=linux GOARCH=amd64 go build itf.go
➜ go-generic git:(main) ✗
➜ go-generic git:(main) ✗ x86_64-linux-gnu-objdump -t -j .rodata itf | grep Namer
000000000047e3a8 g O .rodata 0000000000000020 go:itab.main.User,main.Namer
➜ go-generic git:(main) ✗ x86_64-linux-gnu-objdump -t -j .rodata itf | grep go:itab.main.User,main.Namer | awk '{print "ibase=16;"toupper($1)}' | bc
4711336
➜ go-generic git:(main) ✗ x86_64-linux-gnu-objdump -t -j .rodata itf | grep go:itab.main.User,main.Namer | awk '{print "ibase=16;"toupper($5)}' | bc
32
➜ go-generic git:(main) ✗
➜ go-generic git:(main) ✗ x86_64-linux-gnu-readelf -St -W itf | grep -A 1 .rodata | tail -n +2
PROGBITS 0000000000458000 058000 0272c6 00 0 0 32
➜ go-generic git:(main) ✗ x86_64-linux-gnu-readelf -St -W itf | grep -A 1 .rodata | tail -n +2 | awk '{print "ibase=16;"toupper($3)}' | bc
360448
➜ go-generic git:(main) ✗ x86_64-linux-gnu-readelf -St -W itf | grep -A 1 .rodata | tail -n +2 | awk '{print "ibase=16;"toupper($2)}' | bc
4554752
- 我们首先将代码编译到指定平台
- 随后读取到 go:itab.main.User,main.Namer 的地址和长度: 4711336(0x47e3a8) 和 32(0x20).
- 为了将地址转换到 elf 文件内的偏移量, 读取 .rodata 的偏移量和地址: 360448(0x58000) 和 4554752(0x458000)
所以 go:itab.main.User,main.Namer 应该在文件的第 4711336-4554752+360448=517032 开始的 32 个字节.
➜ go-generic git:(main) ✗ dd if=itf of=/dev/stdout bs=1 count=32 skip=517032 2>/dev/null | hexdump
0000000 e920 0045 0000 0000 1160 0046 0000 0000
0000010 2a0b 99d4 0000 0000 7940 0045 0000 0000
0000020
通过 itab.hash 可以验证上述数据的准确性:
22 func main() {
23 u := User{"Foo", 35}
24 i := Namer(u)
25 getName(i)
26
27 iface := (*iface)(unsafe.Pointer(&i))
28 fmt.Printf("%#x\n", iface.tab.hash)
29 }
30
31 // simplified definitions of runtime's iface & itab types
32 type iface struct {
33 tab *struct {
34 inter uintptr
35 _type uintptr
36 hash uint32
37 _ [4]byte
38 fun [1]uintptr
39 }
40 data unsafe.Pointer
41 }
运行结果 0x99d42a0b 和第 24 到 32 字节的内容完全相符.
interface 的代价
比较直观的一点是, 在通过 runtime.convT 将值转换为 iface.data 时, 可能需要分配一个堆上对象.
➜ go-generic git:(main) ✗ cat -n itf_test.go
1 package main
2
3 import (
4 "fmt"
5 "io"
6 "testing"
7 )
8
9 func BenchmarkInterfaceAllocate(b *testing.B) {
10 u := User{"Foo", 35}
11
12 var v interface{}
13 for i := 0; i < b.N; i++ {
14 v = interface{}(u)
15 }
16
17 fmt.Fprintln(io.Discard, v)
18 }
19
20 func BenchmarkInterfaceNoAllocate(b *testing.B) {
21 var v interface{}
22 for i := 0; i < b.N; i++ {
23 v = interface{}(32)
24 }
25
26 fmt.Fprintln(io.Discard, v)
27 }
28
29 func BenchmarkInterfacePtr(b *testing.B) {
30 u := &User{"Foo", 35}
31
32 var v interface{}
33 for i := 0; i < b.N; i++ {
34 v = interface{}(u)
35 }
36
37 fmt.Fprintln(io.Discard, v)
38 }
➜ go-generic git:(main) ✗ go test -benchmem --bench=. ./...
goos: darwin
goarch: arm64
pkg: github.com/j2gg0s/j2gg0s/examples/go-generic
BenchmarkInterfaceAllocate-10 62783398 19.01 ns/op 24 B/op 1 allocs/op
BenchmarkInterfaceNoAllocate-10 1000000000 0.2959 ns/op 0 B/op 0 allocs/op
BenchmarkInterfacePtr-10 1000000000 0.2911 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/j2gg0s/j2gg0s/examples/go-generic 2.834s
复杂的是 interface 的 method dispatch. 从 Russ Cox 的文章中, 我们可以理解转发表保存在 itab.fun, 并由 runtime 在运行时构建. 但是从之前的例子来看, 依然存在的困惑点:
- 有谁触发并构建了 itab.fun
- 调用 main.getName 时并不能看到相关逻辑, 是编译器针对此类 case 直接填充了?
针对前一个问题, 我们构建一个 interface2interface 的例子来触发相关逻辑.
➜ go-generic git:(main) ✗ cat -n i2i.go
1 package main
2
3 type Namer interface {
4 GetName() string
5 }
6
7 type NamerAndAger interface {
8 GetName() string
9 GetAge() int
10 }
11
12 type User struct {
13 Name string
14 Age int
15 }
16
17 func (u User) GetName() string { return u.Name }
18 func (u User) GetAge() int { return u.Age }
19
20 //go:noinline
21 func getName(i Namer) string { return i.GetName() }
22
23 func main() {
24 u := NamerAndAger(User{"Foo", 35})
25 getName(u)
26 }
➜ go-generic git:(main) ✗ GOOS=linux GOARCH=amd64 go tool compile -S i2i.go | sed 's/\/Users\/j2gg0s\/go\/src\/github.com\/j2gg0s\/j2gg0s\/examples\/go-generic\///g' | cat -n - | grep "i2i.go:25"
87 0x0051 00081 (i2i.go:25) LEAQ go:itab.<unlinkable>.User,<unlinkable>.NamerAndAger(SB), BX
88 0x0058 00088 (i2i.go:25) LEAQ type:<unlinkable>.Namer(SB), AX
89 0x005f 00095 (i2i.go:25) PCDATA $1, $1
90 0x005f 00095 (i2i.go:25) NOP
91 0x0060 00096 (i2i.go:25) CALL runtime.convI2I(SB)
92 0x0065 00101 (i2i.go:25) MOVQ main..autotmp_7+16(SP), BX
93 0x006a 00106 (i2i.go:25) PCDATA $1, $0
94 0x006a 00106 (i2i.go:25) CALL main.getName(SB)
此时, 我们不再调用 convT, 而是调用 runtime.convI2I. 其内部会调用 itab.init 构建 itab.fun.
对于后一个问题, 我们依然可以通过读取 elf 中的内容来验证. 回顾 go.itab.main.User,main.Namer 其值, 除去用于对其的 4 个字节, fun 的值是 7940 0045, 对应偏移为 0x457940. 毫不意外, 其是 User.GetName 的入口.
➜ go-generic git:(main) ✗ dd if=itf of=/dev/stdout bs=1 count=32 skip=517032 2>/dev/null | hexdump
0000000 e920 0045 0000 0000 1160 0046 0000 0000
0000010 2a0b 99d4 0000 0000 7940 0045 0000 0000
0000020
➜ go-generic git:(main) ✗ x86_64-linux-gnu-objdump -t -j .text itf | grep 457940
0000000000457940 g F .text 0000000000000037 main.(*User).GetName
Golang Go语言中的 interface
学到了学到了, 比起枯燥的文字描述,我更喜欢看交互细节。
太难了
asm 习惯了就好,Go Team 还是比较愿意写文章的。
在Golang(Go语言)中,interface
是一个非常重要的特性,它提供了一种定义对象行为的方式,而不必关心对象的具体类型。interface
是一种抽象类型,它规定了对象应该具备的一组方法签名,但不包含这些方法的实现。
一个 interface
类型可以包含零个或多个方法,当一个类型提供了 interface
中所要求的方法实现时,我们就说这个类型实现了该 interface
。在Go语言中,不需要显式地声明某个类型实现了某个 interface
,只要该类型具有 interface
中所有方法的实现即可。
interface
的使用带来了很多好处,比如提高了代码的灵活性和可扩展性。通过 interface
,我们可以编写出更加通用和可复用的代码,因为 interface
允许我们在不改变现有代码结构的情况下,轻松地将新的类型引入到系统中。
此外,interface
也是Go语言中实现多态的一种手段。多态允许我们以统一的接口来调用不同类型的对象,从而简化了代码逻辑,提高了代码的可读性和可维护性。
在Go语言中,空 interface
(interface{}
)是一个特殊的 interface
,它不包含任何方法。因此,空 interface
可以表示任何类型的值,这使得它在处理未知类型的数据时非常有用,例如在编写通用的JSON序列化和反序列化代码时。
总之,interface
是Go语言中一个非常强大和灵活的特性,它极大地提高了代码的抽象能力和可扩展性。