接口值看起来是一个对象,实际上只是两个字。这篇不靠源码推测,直接用 unsafe 把接口值拆开读:看类型字和数据字各装了什么、itab 里有什么、同一个类型装进不同接口时 itab 是不是同一个,再用汇编和基准测试量出调用的真实成本。
unsafe.Sizeof:any = 16,io.Reader = 16,Summer = 16,*T = 8,T = 16,string = 16,[]int = 24 零值 any(nil):两个字都是 nil?typ=true data=true
空接口和带方法的接口都是两个字,只是第一个字不同:any(eface)放类型指针,带方法的接口(iface)放 itab 指针。运行时源码里两者的定义在 internal/abi/iface.go:EmptyInterface{Type, Data} 与 ITab{Inter, Type, Hash, Fun},下面的实验用同样的布局把接口值读出来。
any(42) 与 any(1000):类型指针相同?true;any(1000) 与 any("hi"):false
any(42):*(*int)(data) = 42;any(1000):*(*int)(data) = 1000;两个 any(1000) 的 data 指针相同?false(两个值各有自己的一份数据)
any("hi"):data 指向字符串头(ptr,len):len = 2,内容 "hi"
any(&T):data 指针 == 原指针 p?true(指针类型直接把指针放进 data 字,不再多一层)
var av any = v(T 值);之后 v.A = 100:v.A = 100,接口里的拷贝 {A:1 B:2};data 指针 == &v?false;类型字等于 *T 的类型字?false数据字有两种用法:指针类型(*T)直接把指针放进数据字,不再多一层;非指针类型(T、int、string)的数据字指向一份值的拷贝,所以装进接口之后再改原变量,接口里的值不变。字符串的数据字指向的是字符串头(指针 + 长度),不是字符内容本身。
两个 Summer 都装着 *T:itab 指针相同?true;itab.typ 等于 any(&T) 的类型字?true itab.fun[0] 指向的函数名:main.(*T).Sum reflect 取到的 (*T).Sum 地址 == itab.fun[0]?true 同一个动态类型 *bytes.Buffer,装进 io.Reader 与 io.ReadWriter:itab 指针相同?false;itab.typ 相同?true;itab.inter 相同?false RWC 的 itab.fun[0] → main.(*F).Close RWC 的 itab.fun[1] → main.(*F).Read RWC 的 itab.fun[2] → main.(*F).Write
itab 是“(接口类型, 动态类型)”这一对的方法表:相同的一对共用同一个 itab,所以两个 *T 装进 Summer 得到同一个指针;同一个 *bytes.Buffer 装进 io.Reader 与 io.ReadWriter 是两对,得到两个 itab。方法地址按方法名排序,而不是按接口里声明的顺序。注意最后一行:F 的方法是值接收者,itab 里放的却是 main.(*F).Close——数据字永远是个指针,所以 itab 里放的是接收指针的包装函数(名字来自实测输出,包装的具体实现我没有去读)。
main.static 的汇编里与接口有关的指令(go build -gcflags=-S):
(main.go:15) MOVD $go:itab.*main.T,main.Summer(SB), R0
main.dynamic 的汇编里与接口有关的指令(go build -gcflags=-S):
(main.go:18) CALL runtime.typeAssert(SB)
(main.go:18) CALL runtime.panicnildottype(SB)
3 7
dynamic(x) 稳态每次调用的分配次数:0编译器能确定的转换(return t 里把 *T 变成 Summer)只有一条汇编:取 go:itab.*main.T,main.Summer 这个符号的地址,没有任何运行时查找。运行时才知道类型的断言(x.(Summer))要调用 runtime.typeAssert;稳态下它不分配内存。运行时源码 runtime/iface.go 里的 getitab 与 itabTable 就是这类 itab 的缓存,不过本文没有去量缓存命中与未命中的差别。
直接调用 t.Sum() 中位 1.98 ns/op(最小 1.98 / 最大 2.00) 经接口 s.Sum() 中位 1.97 ns/op(最小 1.97 / 最大 1.98) 4096 个同类型,逐个调用 中位 1.99 ns/op(最小 1.99 / 最大 2.02) 4096 个 4 类型随机,逐个调用 中位 7.23 ns/op(最小 7.21 / 最大 7.25)
在这个最小的例子里,经接口调用与直接调用没有可测的差别(1.97 与 1.98 ns/op),两者都被 //go:noinline 的调用本身占满了。真正拉开差距的是类型不固定:4096 个接口里随机放 4 种类型,每次间接跳转的目标都在变,耗时变成 7.23 ns/op。把它当成“接口调用本身便宜,类型多变会让它变贵”的一个数据点,不要套用到别的程序上。