基础技能树-27 方法表达式

时间:2024-01-23 19:20:53

基础技能树-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需要两个参数,但是在外部调用的时候用户只给一个参数。