基础技能树-28 接口
基础技能树-28 接口
2017-12-05 10:46 by 李永京, … 阅读, … 评论, 收藏, 编辑
本节内容
- 什么是接口
- 什么是Duck Type
- 接口实现方式,方法集与接口 和 接口内部结构
- 接口调用与直接调用的性能差异
- 总结
什么是接口
方法集归根结底是为实现接口而准备的,接口到底是怎么样实现的,我们现在已经为接口调用准备好了接口所对应的方法,接下来接口到底怎么样把一个普通的对象变成一个接口呢?或者说怎么样通过接口来调用真实的对象方法呢?
我们实现一个类型X,X有a,b,c三个方法,现在有个接口I,要求必须要实现a,b,c。这样的情况下就说X实现了I接口,接下来可以I等于X的实例,I可以调用a,这个最终会还原成X.a的调用。我们想分析下“I等于X的实例”怎么实现的?就是说接口怎么存储一个真实的对象,还想了解用接口调用一个方法的时候究竟是怎么实现的,就是说接口怎么找到真实的方法,怎么知道那个方法在哪?这是接下来需要了解的,第一接口怎么存储对象实例,第二通过接口调用和普通方法调用究竟什么区别。
很多语言里都会使用大量接口,接口到底是什么样的?我们说我们定义一个类型,声明字段方法。接口就是一个声明。
大部分语言对接口实现方式类似,动态语言没有明确接口一说,是按名字来寻址和按地址寻址不太一样,大多数静态语言对接口实现方式理论上非常相似。
什么是Duck Type
$ cat test.go
package main
type N int
func (N) A() { println("*N.A") }
func (*N) B() { println("*N.B") }
func (*N) C() { println("*N.C") } // *N = N + *N = A+B+C // N = A
type Ner interface {
A()
B()
}
func main() {
var n N = 0x100
var x Ner = &n // main.(*N)
x.A()
x.B()
}
定义类型N,N有三个方法,其中一个属于N的,两个属于N指针的,定义一个接口Ner,要求必须有两个方法A和B。因为go语言并不需要明确的在类型上声明实现了某个接口,但是Java或者C#需要明确声明。
go语言就是当方法集包含某个接口的全部声明就表示你实现了这个接口,我们通常把类型Ner叫做鸭子类型(Duck Type),就是你长的像这只鸭子我们就可以把你当做鸭子。类型N有A和B,那么就认为它实现了Ner接口。
main方法中首先创建了N的对象实例,接下来把这个实例赋值给接口对象,这地方为什么不直接用N而是用N指针呢?因为类型N包含A,N指针包含A、B、C,所以只有N指针实现了Ner接口,N并没有实现Ner接口,N没有实现Ner接口的话var x Ner = n赋值是不成功的。接下来用接口调用A、B。
接口实现方式,方法集与接口 和 接口内部结构
接下来用GDB看下接口内部结构
编译
$ go build -gcflags "-N -l" -o test test.go
调试
$ gdb test
$ l
$ l
$ b 18
$ r
$ info locals
&n = 0xc4200140a8
x = {tab = 0x4aa100 <N,main.Ner>, data = 0xc4200140a8}
x是接口,包含了两个字段,tab
和data
,data
存储的就是n的指针,很显然接口调用通过data
就可以找到实例在哪,就可以访问实例的数据。
第一个问题接口Ner怎么存储N?我们知道通过data
字段存储的。
那么接下来怎么调用?因为我们知道接口是运行期的动态绑定,问题是怎么去找?剩下来怀疑的目标是tab
。
输出结构定义
$ ptype x
type = struct runtime.iface {
runtime.itab *tab;
void *data;
}
看到tab
是runtime.itab
结构
$ ptype x.tab
type = struct runtime.itab {
runtime.interfacetype *inter;
runtime._type *_type;
runtime.itab *link;
uint32 hash;
bool bad;
bool inhash;
[2]uint8 unused;
[1]uintptr fun;
} *
runtime.itab
结构挺复杂,内部包含了很多东西,我们先找关注的目标,运行期找数据肯定找类型相关的东西。
*inter
和*_type
是重点对象,*link
是一个链表结构,应该是内部管理的东西,hash
、inhash
是hash值性能相关,bad
是内部管理标记位、unused
是计数器,运行期runtime用的,fun
通常是函数的缩写,uintptr
是一个不完全结构体,通常定义成运行期的动态列表。
先看x.tab.inter
$ ptype x.tab.inter
type = struct runtime.interfacetype {
runtime._type typ;
runtime.name pkgpath;
struct []runtime.imethod mhdr;
} *
x.tab.inter
结构嵌套了很多东西。一次性获取数据:
$ p/x *x.tab.inter #获取指针的数据
$2 = {typ = {size = 0x10, ptrdata = 0x10, hash = 0x6b66c6dd, tflag = 0x7, align = 0x8,
fieldalign = 0x8, kind = 0x14, alg = 0x4aae20, gcdata = 0x478bee, str = 0x1a63,
ptrToThis = 0x6de0}, pkgpath = {bytes = 0x4512c8}, mhdr = {array = 0x4603a0, len = 0x2,
cap = 0x2}}
size
长度、ptrdata
指针数据,不是动态行为、hash
很多东西用hash快速比较是否相等,避免字段字段判断、tflag
标记、align
对齐、fieldalign
字段对齐、kind
类型、alg
和gcdata
是、str
是一个字符串,我们在进行内存分析的时候字符串往往是个线索,字符串里面可能包含了我们需要的关键性的目标指示,这个字符串是个指针。
$ p/x *x.tab.inter.typ.str ###1.9版本Cannot access memory at address
输出”main.Ner”是符号名,就是接口Ner名字,也就意味着x.tab.inter
里面存的是接口相关的数据,包含了接口类型对象各种各样运行期的元数据,记录接口里面的内存布局。
mhdr
通常是方法表,很常见的缩写,从数据结构上可以判断是个切片,底层数组的指针、长度、容量,长度是2。
我们看看切片里面是什么。
$ p/x x.tab.inter.mhdr
$5 = {array = 0x4603a0, len = 0x2, cap = 0x2}
$ p/x x.tab.inter.mhdr.array #获取指针
$6 = 0x4603a0
$ p/x *x.tab.inter.mhdr.array #获取指针的数据
$7 = {name = 0x3, ityp = 0x9e40}
$ p/x *x.tab.inter.mhdr.array.name #方法表第一项存的是A的相关信息###1.9版本Cannot access memory at address
$ p/x *x.tab.inter.mhdr.array[0].name #方法表第一项存的是A的相关信息###1.9版本Cannot access memory at address
$ p/x *x.tab.inter.mhdr.array[1].name #方法表第二项存的是B的相关信息###1.9版本Cannot access memory at address
很显然x.tab.inter.mhdr
方法表里面保存的是接口的方法声明,因为这样我们通过反射的时候,既能找到接口相关的元数据也能找到接口一共有几个方法。
所以tab.inter
第一个字段存储的是接口的元数据Metadata,第二个字段存储的是接口的方法表MethodTable。因为这两个东西都存在,反射时候才能把整个接口的类型信息还原。
这是接口的数据,还没有找到接口和对象实例的映射。
接下来分析x.tab._type
$ p/x *x.tab._type
$8 = {size = 0x8, ptrdata = 0x8, hash = 0xc4149a57, tflag = 0x1, align = 0x8, fieldalign = 0x8,
kind = 0x36, alg = 0x4aadf0, gcdata = 0x478bec, str = 0x102e, ptrToThis = 0x0}
很复杂的东西,看上去个tab.inter
有点像,区别在于它没有方法表
$ p/x *x.tab._type.str ###1.9版本Cannot access memory at address
输出”*main.N”是符号名,就是实现接口的类型。
所以tab._type
里面存储的是实现接口的类型元数据TypeMetadata。
现在还缺接口的方法到实现接口类型方法的映射关系。
$ p/x *x.tab
$9 = {inter = 0x460340, _type = 0x460520, link = 0x0, hash = 0xc4149a57, bad = 0x0, inhash = 0x1,
unused = {0x0, 0x0}, fun = {0x450b80}}
先看默认第一个数据是什么
$ info symbol 0x450b80
main.(*N).A in section .text
查看fun{0x450b80},存储的是实现接口的方法main.(*N).A地址,这里是第一组数据。
很怀疑第二组数据就是B的地址
$ x/2xg x.tab.fun #长度是2,输出2组指针
0x4aa120 <go.itab.*main.N,main.Ner+32>: 0x0000000000450b80 0x0000000000450a10
$ info symbol 0x0000000000450a10 #第二组数据
main.(*N).B in section .text
很显然x.tab.fun
存储的是实现接口类型的方法,即存的是真实目标类型实现那些接口对应的方法地址。
显然一个完整的接口里面data
存着接口实例的对象地址;tab
里面存储三样数据,第二个是接口本身的元数据,用来描述接口什么样子的怎么样的布局包含哪些具体的信息,第二个保存的是实现接口类型的元数据,比如什么名字,怎么对齐的,多大长度。第三个数据用了不完全结构体数组来保存真实目标的那些方法地址,因为只有这样它才能找到真正需要调用的目标。
通过这个分析我们搞明白一个接口对象里面到底存的什么东西,起码利用接口这些数据做反射没有问题,我们知道反射是很典型的运行期行为。然后利用接口进行调用,只要访问fan字段我们就可以知道真实目标的代码地址然后进行call调用。
接下来看接口怎么调用的
$ gdb test
$ l
$ l
$ b 18
$ r
$ set disassembly-flavor intel #设置intel样式
$ disass
Dump of assembler code for function main.main:
0x0000000000450a70 <+0>: mov rcx,QWORD PTR fs:0xfffffffffffffff8
0x0000000000450a79 <+9>: cmp rsp,QWORD PTR [rcx+0x10]
0x0000000000450a7d <+13>: jbe 0x450afb <main.main+139>
0x0000000000450a7f <+15>: sub rsp,0x38
0x0000000000450a83 <+19>: mov QWORD PTR [rsp+0x30],rbp
0x0000000000450a88 <+24>: lea rbp,[rsp+0x30]
0x0000000000450a8d <+29>: lea rax,[rip+0xdd6c] # 0x45e800
0x0000000000450a94 <+36>: mov QWORD PTR [rsp],rax
0x0000000000450a98 <+40>: call 0x40c480 <runtime.newobject>
0x0000000000450a9d <+45>: mov rax,QWORD PTR [rsp+0x8]
0x0000000000450aa2 <+50>: mov QWORD PTR [rsp+0x18],rax
0x0000000000450aa7 <+55>: mov QWORD PTR [rax],0x100
0x0000000000450aae <+62>: mov rax,QWORD PTR [rsp+0x18]
0x0000000000450ab3 <+67>: mov QWORD PTR [rsp+0x10],rax
0x0000000000450ab8 <+72>: lea rcx,[rip+0x59641] # 0x4aa100 <go.itab.*main.N,main.Ner>
0x0000000000450abf <+79>: mov QWORD PTR [rsp+0x20],rcx
0x0000000000450ac4 <+84>: mov QWORD PTR [rsp+0x28],rax
=> 0x0000000000450ac9 <+89>: mov rax,QWORD PTR [rsp+0x28] #这里0x28交换到rax
0x0000000000450ace <+94>: mov rcx,QWORD PTR [rsp+0x20]
0x0000000000450ad3 <+99>: mov rcx,QWORD PTR [rcx+0x20]
0x0000000000450ad7 <+103>: mov QWORD PTR [rsp],rax #很显然接口对象放到rsp
0x0000000000450adb <+107>: call rcx
0x0000000000450add <+109>: mov rax,QWORD PTR [rsp+0x20]
0x0000000000450ae2 <+114>: mov rax,QWORD PTR [rax+0x28]
0x0000000000450ae6 <+118>: mov rcx,QWORD PTR [rsp+0x28]
0x0000000000450aeb <+123>: mov QWORD PTR [rsp],rcx
0x0000000000450aef <+127>: call rax
0x0000000000450af1 <+129>: mov rbp,QWORD PTR [rsp+0x30]
0x0000000000450af6 <+134>: add rsp,0x38
0x0000000000450afa <+138>: ret
0x0000000000450afb <+139>: call 0x448790 <runtime.morestack_noctxt>
0x0000000000450b00 <+144>: jmp 0x450a70 <main.main>
End of assembler dump.
注意到<go.itab.*main.N,main.Ner>
就是通过这些信息访问的。
$rsp+0x28存的是什么
$ ptype $rsp+0x28
是个指针
type = void *
看里面存的什么
$ x/xg $rsp+0x28
0xc42003df68: 0x000000c4200140a8
$ x/xg 0x000000c4200140a8
里面存的是100,很显然接口对象放到rsp
0xc4200140a8: 0x0000000000000100
看看执行过程,call rcx
下断点,执行到断点,反汇编
$ b *0x0000000000450adb
$ c
$ disass
Dump of assembler code for function main.main:
0x0000000000450a70 <+0>: mov rcx,QWORD PTR fs:0xfffffffffffffff8
0x0000000000450a79 <+9>: cmp rsp,QWORD PTR [rcx+0x10]
0x0000000000450a7d <+13>: jbe 0x450afb <main.main+139>
0x0000000000450a7f <+15>: sub rsp,0x38
0x0000000000450a83 <+19>: mov QWORD PTR [rsp+0x30],rbp
0x0000000000450a88 <+24>: lea rbp,[rsp+0x30]
0x0000000000450a8d <+29>: lea rax,[rip+0xdd6c] # 0x45e800
0x0000000000450a94 <+36>: mov QWORD PTR [rsp],rax
0x0000000000450a98 <+40>: call 0x40c480 <runtime.newobject>
0x0000000000450a9d <+45>: mov rax,QWORD PTR [rsp+0x8]
0x0000000000450aa2 <+50>: mov QWORD PTR [rsp+0x18],rax
0x0000000000450aa7 <+55>: mov QWORD PTR [rax],0x100
0x0000000000450aae <+62>: mov rax,QWORD PTR [rsp+0x18]
0x0000000000450ab3 <+67>: mov QWORD PTR [rsp+0x10],rax
0x0000000000450ab8 <+72>: lea rcx,[rip+0x59641] # 0x4aa100 <go.itab.*main.N,main.Ner>
0x0000000000450abf <+79>: mov QWORD PTR [rsp+0x20],rcx
0x0000000000450ac4 <+84>: mov QWORD PTR [rsp+0x28],rax
0x0000000000450ac9 <+89>: mov rax,QWORD PTR [rsp+0x28]
0x0000000000450ace <+94>: mov rcx,QWORD PTR [rsp+0x20]
0x0000000000450ad3 <+99>: mov rcx,QWORD PTR [rcx+0x20] #搞清楚这里是什么
0x0000000000450ad7 <+103>: mov QWORD PTR [rsp],rax
=> 0x0000000000450adb <+107>: call rcx
0x0000000000450add <+109>: mov rax,QWORD PTR [rsp+0x20]
0x0000000000450ae2 <+114>: mov rax,QWORD PTR [rax+0x28]
0x0000000000450ae6 <+118>: mov rcx,QWORD PTR [rsp+0x28]
0x0000000000450aeb <+123>: mov QWORD PTR [rsp],rcx
0x0000000000450aef <+127>: call rax
0x0000000000450af1 <+129>: mov rbp,QWORD PTR [rsp+0x30]
0x0000000000450af6 <+134>: add rsp,0x38
0x0000000000450afa <+138>: ret
0x0000000000450afb <+139>: call 0x448790 <runtime.morestack_noctxt>
0x0000000000450b00 <+144>: jmp 0x450a70 <main.main>
End of assembler dump.
看rcx+0x20
里面存的什么
$ x/xg $rcx+0x20
很显然是指向某个方法的地址,然后进行call调用,这个数据很显然就是从tab.fun
拿到的。
0x450ba0 <main.(*N).A+32>: 0x8b483775db854820
很显然call调用是动态的,因为目标地址是从某个地方读出来的,因为我们知道如果是静态调用直接给的是具体地址,例如call 0x40c480 <runtime.newobject>
很常见的静态绑定都是给出很明确的目标地址,call rcx
的调用地址很显然是从某个地方读出来的,很显然是运行期的动态绑定。
那么通过接口调用是一种动态行为,调用目标的地址是在运行期读出来的。rcx
是从栈桢上交换出来的,栈桢上数据肯定是在运行期才有的。从运行期的栈桢上读取数据放到rcx
里面,然后call rcx
,这显然是运行期的动态绑定。
接口调用与直接调用的性能差异
直接调用是静态绑定,接口调用是动态绑定,因为接口调用的地址是在运行期从栈帧里面取的。很显然动态绑定的性能低于静态绑定。我们对比一下接口调用到底有多大的性能损失。
bench_test.go
package main
import (
"testing"
)
type N int
func (n N) A() int {
c := 0
for i := 0; i < 1<<20; i++ {
c += i
}
return c
}
func BenchmarkCall(b *testing.B) {
var n N = 0x100
for i := 0; i < b.N; i++ {
_ = n.A()
}
}
func BenchmarkIface(b *testing.B) {
var n N = 0x100
var e Ner = &n
for i := 0; i < b.N; i++ {
_ = e.A()
}
}
$ go test -v -bench . -benchmem
BenchmarkCall 5000 290851 ns/op 0 B/op 0 allocs/op
BenchmarkIface 3000 339684 ns/op 0 B/op 0 allocs/op
PASS
从汇编角度,动态绑定肯定没有静态绑定块,因为还涉及到二次寻址操作,你得把一个地址从某个地方读出来。为什么测试结果不一样?究竟怎么测试才是最准确的。
总结
其实OOP这块概念非常的多,正因为这样,OOP现在被很多人批判,因为觉得把事情搞得太复杂。跟OOP诞生的大量设计模式,这些设计模式都会大量的运用接口来实现,形成一种抽象。因为接口实际上是从类型耦合拆解出来。当你依赖于接口的时候你就不用依赖于某个具体的类型。但如果你依赖某个类型,哪怕是抽象类型,那你也必须依赖于某条继承树。这样就造成OOP复杂度非常的高,而且随着程序的维护这种复杂度越来越复杂,中间的耦合度越来越高。现在语言里会大幅度简化OOP的概念,因为用其它方式来模拟OOP概念的话没必要搞成那么复杂。一来可以在编译器实现上可以做的更优化更简单,第二对于指令的优化可以做的很简单。OOP对于我们日常编程来说,既方便又复杂,方便把一些数据抽象成个体。新的语言多半是为了解决一些老的语言的弊端。