基础技能树-27 方法表达式
基础技能树-27 方法表达式
2017-12-01 10:56 by 李永京, … 阅读, … 评论, 收藏, 编辑
本节内容
- 开篇
- Method Expression和Method Value
- 方法表达式实现方式
开篇
方法集严格意义上是为了实现接口使用的,正常情况下实现接口的方式是比如接口X,基类A实现一部分,继承类B实现一部分,C实现一部分,最后C实现了这个接口。这是我们传统的基于继承体系的做法。
go语言是基于组合的,怎么办呢?A包含了B、C的话,A编译器就自动生成B和C的方法包装,这样一来,A就同时拥有了A、B、C的方法,最后A就实现了X接口。很显然,方法集就是为了实现接口准备。因为很多时候我们为了实现一个方法是通过多个组件拼装出来的,未必都是自己实现的。
比如我要实现下订单的接口,那可能是由三个对象实现的,比如一个对象维持对象的列表,一个对象实现计算相应的价格,因为涉及到很多折扣的东西,另外对象对订单临时暂存类似于这样的功能。我们可能对这多个对象要么继承要么组合。
Method Expression和Method Value
方法集除了用作接口以外,还有另外一个做法用作方法表达式。方法表达式有两种行为,Method Expression和Method Value。
class A{
a()
b()
c()
}
x = new A()
x.a()
x.b() //method call
A.a(x)
A.b(x) //method expression
var z = x.a //method value
z() ===>x.a() === {x/instance, a/method}
在现在高级语言里,函数和方法是第一类型对象,它可以赋值给一个变量的,执行z(),被翻译成x.a()调用,也就意味着z里面必须包含两个数据,第一个x的实例,第二个a的方法。z必须要存储这两个东西才能完成一次合法的调用。
所以对一个方法的调用实际上有三种模式,第一种方式是最常见的普通调用方式,第二种方式是类型表达式方式调用,实例指针隐式的变成显式的传递,第三种我们可以把方法赋值给一个变量,接下来用变量来调用,这时候就要注意,这个变量就必须同时持有这个方法的实例和方法本身。
方法表达式实现方式
现在研究Method Value究竟怎么实现的?它是怎么持有那两个数据的,另外这两个数据怎么保存下来的?怎么传递的?
$ cat test.go
package main
type N int
func (n N) Print() {
println(n)
}
func main() {
var n N = 100 // instance
p := &n // n.pointer
f := p.Print // *T = (T + *T) --> autogen func (n *N) Print
n++
println(n, *p)
f()
}
N有个方法Print,在main方法中先创建N的实例n,获得它的指针p,指针p合法的拥有Print方法,当我们执行f()调用的时候,它怎么拿到p,怎么拿到Print?
编译
$ go build -gcflags "-N -l" -o test test.go
调试
$ gdb test
$ l
$ l
$ b 15
$ b 18
$ r
$ info locals #f看上去是栈上的数据,是个指针
$ p/x f-$rsp #f的偏移量是38,是栈上的
$ set disassembly-flavor intel #设置intel样式
$ disass #看到sub rsp,0x50代表整个栈帧大小是50,f是在38的位置
$ x/xg f #f是一个指针,指向0x000000c42003bf48目标
#0xc42003bf60: 0x000000c42003bf48
$ x/2g 0x000000c42003bf48 #查看地址内容,0x0000000000450b60地址代表.text段里面的数据,0x0000000000000064是100,f是个指针,指向这样一个数据结构,第一个是.text某段代码,第二个是n
#0xc42003bf48: 0x0000000000450b60 0x0000000000000064
$ info symbol 0x0000000000450b60 #text段编译器生成的一个函数,函数名称加了后缀fm。go编译器会加一些后缀表示特殊用途。main.(N).Print-fm是个符号,符号和方法签名未必是一致的。我们现在知道f实际上是方法和实例复合结构体的指针&{method, instance}
#main.(N).Print-fm in section .text
$ c
$ disass
=> 0x0000000000450ace <+206>: mov rdx,QWORD PTR [rsp+0x38] #这里存的是f的指针,指针指向一个复合结构体{p.Print,n}
0x0000000000450ad3 <+211>: mov rax,QWORD PTR [rdx] #直接读出一个数据,就是p.Print
0x0000000000450ad6 <+214>: call rax #调用目标方法
0x0000000000450ad8 <+216>: mov rbp,QWORD PTR [rsp+0x48]
0x0000000000450add <+221>: add rsp,0x50
0x0000000000450ae1 <+225>: ret
$ b *0x0000000000450ad6 #进入目标方法
$ c #执行
$ si #汇编层面单步
$ disass
Dump of assembler code for function main.(N).Print-fm:
=> 0x0000000000450b60 <+0>: mov rcx,QWORD PTR fs:0xfffffffffffffff8
0x0000000000450b69 <+9>: cmp rsp,QWORD PTR [rcx+0x10]
0x0000000000450b6d <+13>: jbe 0x450b9f <main.(N).Print-fm+63>
0x0000000000450b6f <+15>: sub rsp,0x18 #分配栈桢
0x0000000000450b73 <+19>: mov QWORD PTR [rsp+0x10],rbp
0x0000000000450b78 <+24>: lea rbp,[rsp+0x10]
0x0000000000450b7d <+29>: lea rax,[rdx+0x8] #把实例地址读出来
0x0000000000450b81 <+33>: mov QWORD PTR [rsp+0x8],rax #把地址放到当前栈桢0x8位置
0x0000000000450b86 <+38>: test BYTE PTR [rax],al #指针判断是否为null
0x0000000000450b88 <+40>: mov rax,QWORD PTR [rdx+0x8] #把实例数据读出来
0x0000000000450b8c <+44>: mov QWORD PTR [rsp],rax #当前实例数据放到当前栈桢0x0位置
0x0000000000450b90 <+48>: call 0x4509b0 <main.N.Print> #调用目标方法
0x0000000000450b95 <+53>: mov rbp,QWORD PTR [rsp+0x10]
0x0000000000450b9a <+58>: add rsp,0x18
0x0000000000450b9e <+62>: ret
0x0000000000450b9f <+63>: call 0x4486f0 <runtime.morestack>
0x0000000000450ba4 <+68>: jmp 0x450b60 <main.(N).Print-fm>
当我们实现把方法赋值给变量时候,这个变量会指向一个复合结构,这个复合结构包含了方法的指针和方法的实例,调用时候,把复合结构通过RDX同闭包调用规则完全一致去调用,自动生成包装方法,然后包装方法在内部把参数准备好放在RSP位置去call真正我们写的那个方法,就是这样的一套逻辑,无非是在中间编译器替我们生成了代码。
我们搞明白一件事,高级语言的规则甭管说的多么好听,说的多么智能、多么聪明,归根结底得有人把这个过程写成具体的代码,这个代码要么我们自己写,那么编译器替我们写,不管是谁都没有办法偷这个懒。
这样分析的话,我们对go语言的方法集或者方法值、方法表达式就认为很简单,你无非就是调用,区别在于要么直接调用它,要么调用中间包装一层,本来直接调用A,现在我们没办法直接调用A,那你写一个函数去间接调用A,调用A时候把参数准备好。
类似的做法很多,我们经常有种设计模式叫做代理模式,比如说现在有个目标叫A(x,y),为了实现某个接口我们写个包装ProxyA(x),内部调用A(x,100),这样我们可以把ProxyA(x)暴露出去,但是内部最终调用的还是我们真正目标A(x,y),因为这个代理方法是我们自己写的,为了让代理方法去适应某种接口,因为A需要两个参数,但是在外部调用的时候用户只给一个参数。