最近工作压力山大,好久不更新博客了。难得找到了一个合适的问题,于是深入分析了一下,在此做个记录。顺便检验了自己的汇编水平,至少目前还能追查出简单代码中所包含的底层细节……
问题与分析
问题源自于前导师兼同事(珩总)的一个脑洞:如果有两个类A和B,A包含一个A::show()方法,而B包含一个函数指针成员B::func_ptr,并且通过某种方式强行让类B的实例化对象b的func_ptr成员指向函数A::show()的地址。那么当发起调用b.func_ptr()的时候,此时的this指针会指向谁呢?
个人感觉这是个非常好的面试问题,严格来说它其实没有一个正确的答案,甚至可以说这个问题本身就是错误的,但如何去分析这个问题,却能够深刻体现出一个人对于C++的理解程度。我们知道,C艹的所谓面向对象机制,本质上,就是一种将数据与操作数据的方法绑定在一起的技术。其中的类方法(不考虑static method),必然需要作用在某些数据上,也就是作用在对象上面。因此,对象的内存地址,一定会被作为一个隐含的参数传递给类方法,而在C++的语义中,则被设计为传递一个隐含的this指针,通过这个指针,我们可以直接访问到当前对象的内存地址。
由于对象b中只保存了一个A的成员函数地址A::show(),而并没有该如何调用这个函数的信息,即便真的成功发生调用,此时编译器怎么会知道该把谁的地址作为this指针传递给这个函数并发起调用呢?所以实际上,这是个完全违背面向对象语义的问题,本身就是不正确的,自然不会存在正确的答案。
那么回到问题本身,对于一个实例化对象,试图调用不属于它的方法,会发生什么呢?我们首先写出第一个demo:
Demo Code I
#include <cstdio>
class A {
public:
void show() {
printf("Address of object: %p\n", this);
}
};
typedef void (*show_func)();
class B {
public:
show_func show;
};
int main()
{
A *a = new A();
a->show();
B *b = new B();
b->show = a->show;
b->show();
printf("Address of a: %p\n", a);
printf("Address of b: %p\n", b);
return 0;
}
上述这段代码完全能够通过静态分析程序的检查,然而编译器却并不会接受它,报错如下:
error: reference to non-static member function must be called; did you mean to call it with no arguments? b->show = a->show;
从中我们可以看出,C艹编译器严格限制了非静态成员函数这种符号的引用——必须得是以函数调用的形式才能引用这些符号,不然编译器拒绝接受。这样就能成功规避绝大多数试图以错误的语义引用成员函数地址的代码。
然而,码农们的智慧总是无穷的,通过Google,我们能够找到一些特殊的奇技淫巧,来达成我们的目的。随着编译器版本的变革,很多早期的技巧已经不再有效,由此可见编译器开发者们长期以来都在限制程序员滥用语言的语义。不过下面这段代码所展示的技巧目前来看还是是可行的(clang-700.1.76):
Demo Code II
#include <cstdio>
class A {
public:
void show() {
printf("Address of object: %p\n", this);
}
};
typedef void (*show_func)();
class B {
public:
show_func show;
};
int main()
{
union {
void *pv;
void (A::*pfn)();
} u;
u.pfn = &A::show;
A *a = new A();
B *b = new B();
b->show = (show_func)u.pv;
b->show();
a->show();
printf("Address of a: %p\n", a);
printf("Address of b: %p\n", b);
return 0;
}
简而言之,这段代码所做的事情就是,在union中定义一个特殊的类型,完成函数指针赋值,然后再利用union的另一个成员来取出其中的值。从中可以看到,对象的方法实际上属于其所属的类的命名空间,所以必须定义一个类型处于同样命名空间下的变量,才可以访问该命名空间下的类方法并赋值。从这个角度来看,在不考虑继承的简单情形下,其实静态方法和普通类方法本质上是一样的,只不过对于类方法,调用的时候隐含传递了一个this指针而已。但就生成的汇编代码本身而言,并没有什么区别。
在我的工作环境中(MacBook Pro, Intel i5, LLVM version 7.0)编译并运行该代码,得到的输出如下:
Address of object: 0x7ffd83400260
Address of object: 0x7ffd834013d0
Address of a: 0x7ffd83400260
Address of b: 0x7ffd834013d0
看起来似乎当指针赋值以后,a->show()和b->func_ptr()这两种用法是一样的了?传递给它们的this指针正确地分别等于指针a和b?所以相当于类B能以这样的方式获取到一个跟类A一样的show()方法?然而事情并没有这么简单。如果我们简单调换一下代码的顺序如下:
A *a = new A();
B *b = new B();
b->show = (show_func)u.pv;
a->show();
b->show();
那么就会得到如下的输出:
Address of object: 0x7ffa80400260
Address of object: 0x7fff73c84118
Address of a: 0x7ffa80400260
Address of b: 0x7ffa804013d0
也就是在b->show();这个调用中所获得的this指针又不等于指针b了!所以问题来了:这种奇怪的现象是怎么出现的?
于是祭出调试器,关键位置加个断点,看看从汇编的角度,CPU究竟做了什么操作。
底层分析
使用lldb(LLVM的调试器)将下面几行代码反汇编:
B *b = new B();
b->show = (show_func)u.pv;
b->show();
a->show();
得到结果如下:
callq 0x100001eac ; symbol stub for: operator new(unsigned long)
movq %rax, %rdi ; 注意这里,%rax中存放函数返回值,即new出来的地址,亦即指针b所指向的地址
movq %rax, %rdx ; 同样的值被复制给%rdx
movq %rdi, -0x50(%rbp) ; 此处先保存%rdi原本的值
movq %rax, %rdi ; 然后再把%rax,也就是新分配的内存地址放到%rdi中,作为传入参数(构造对象用)
movq %rdx, -0x58(%rbp) ; 然后又保存了%rdx的值,也就是保存了%rax的值
callq 0x100001e9a ; symbol stub for: B::B() 调用对象B的构造函数
movq -0x58(%rbp), %rax ; 构造完成之后,重新将%rax的值还原(因为构造函数木有返回值,所以此时%rax是没用的)
movq %rax, -0x38(%rbp) ; 再把%rax的值,也就是b指针的值,保存到栈上的一个位置-0x38(%rbp)
movq -0x18(%rbp), %rcx ; 这里取出u.pv的值
movq -0x38(%rbp), %rdx ; 然后再找到b在栈中的位置
movq %rcx, 0x8(%rdx) ; 于是执行赋值: b->show = (show_func)u.pv;
; 此时我们注意到,%rdi的值并没有被在这里的上下文被修改;
; 而我们打印寄存器里面的值,同样可以看到,%rdx的值与%rdi的值是相同的
movq -0x38(%rbp), %rcx ; 将对象b的指针存入%rcx
callq *0x8(%rcx) ; 相当于(*b->show)(b),对指针解除引用,直接call过去,没有传递任何参数。
movq -0x20(%rbp), %rdi ; 但是在这里,一个正确的成员函数调用,需要使用%rdi传递一个隐含的调用参数,即this指针
callq 0x100001e88 ; symbol stub for: A::show() 然后直接jmp到对应的成员函数地址
上面的每行代码后面都附带了对应的注释。分析这些汇编语句,我们基本就能够还原出整个过程,CPU在执行这几句代码的时候都发生了什么。
我们先分析正常的成员函数调用是怎样的。直接看最后两行汇编,它对应于a->show();这行代码。我们看到,由于show()方法并不需要普通参数,于是编译器生成的汇编代码的行为就是先把某个栈内存中的值赋给%rdi寄存器,然后直接就发起函数调用了。通过对于上下文的整体分析,我们可以得知,-0x20(%rbp)这个栈内存地址其实就是指针变量a的内存地址。所以由此可以猜测,在发起成员函数调用的时候,this指针通过%rdi寄存器传递。事实上,System V(也就是Unix)在x86_64架构下具有如下的函数调用约定[1]:
The first six integer or pointer arguments are passed in registers RDI, RSI, RDX, RCX, R8, and R9.
而this指针恰好就是第一个同时也是唯一一个参数,自然会被放进%rdi里来传递。那么如果超过六个参数怎么办?那就只有压栈了呗。
然后我们再从头开始看这段汇编。在构造对象的时候,需要为对象分配内存地址,于是一块空闲空间的起始地址被内存分配函数返回,保存在%rax中(遵循函数调用约定)。注意到这里使用的是new操作符,所以分配的是堆地址。随后,这块新内存的起始地址被分别保存到了%rdi和%rdx中,以及栈上的内存地址-0x50(%rbp)处。接下来%rdx被做了个备份,保存在-0x58(%rbp)处,这同样相当于新内存的地址被保存到这个位置。显然地,新内存的地址其实就是指针b所指向的位置。内存分配完成后,接下来调用对象B的构造函数;构造完成之后,重新将%rax的值还原,这里因为构造函数没有返回值,所以此时%rax里的值是没用的,覆盖掉也没关系。然后再把%rax的值,也就是b对象的内存地址,保存到栈上的一个位置-0x38(%rbp),这个位置其实就是指针变量b的内存位置,所以此时相当于彻底执行完成了B *b = new B();这一句。
b对象构造完成之后,取出u.pv的值,赋值给对象b的起始地址后的某个偏移位置处,这里自然就是类B的成员变量的位置。此时我们回顾这部分代码的上下文,可以注意到,%rdi的值并没有在这里被修改。而我们打印寄存器里面的值,同样可以看到,%rdx的值域%rdi的值是相同的,也就是说均等于指针b的值。因此表明,在调用B对象构造函数的时候,寄存器%rdi的值并没有被修改,依然维持原样,即为指针b的值;上文已经说过,这个指针显然指向堆中的地址。那么,在接下来的函数调用中,即便并没有正确设置传递给类方法的this指针的值,但由于约定的寄存器%rdi中恰好存有正确的值,所以就表现出先前所示的现象,b对象在强行调用类A的成员函数的过程中,神奇地得到了指向自己的正确的this指针。但实际上,这只是个巧合而已。当我们像Demo II中的代码那样,轻微调换一下构造函数的执行顺序,就会修改掉%rdi寄存器中的值,巧合也就不会发生了。
那么,我们如何能够让b对象在“借用”类A的成员函数的时候,可以正常地工作呢?我们来看第三个demo:
Demo Code III
#include <cstdio>
class A {
public:
const char *type_name = "A";
void show(int v1, int v2, int v3, int v4, int v5, int v6) {
printf("This is class %s\n", this->type_name);
}
};
typedef void (*show_func)(void *, int v1, int v2, int v3, int v4, int v5, int v6);
class B {
const char *type_name = "B";
public:
show_func show;
};
int main() {
union {
void *pv;
void (A::*pfn)(int, int, int, int, int, int);
} u;
u.pfn = &A::show;
A *a = new A();
B *b = new B();
b->show = (show_func)u.pv;
a->show(0, 0, 0, 0, 0, 0);
b->show(b, 0, 0, 0, 0, 0, 0);
return 0;
}
我们只要巧妙地声明函数指针,显式地把对象的内存地址作为this指针传递过去,就可以让对象b成功“借用”类A的成员函数了。当然,这样做的前提必须是,类A和类B的内存布局是相似的。严谨来说,应该是对于被“借用”的成员函数所访问的成员变量,必须有着相同的内存布局。
最后,上述代码的运行输出如下:
This is class A
This is class B
可以看到,即便传入了6个参数,整个程序依然运行成功!这同样表明,正常情况下编译器所生成的汇编代码中,会把this指针隐含地作为第一个参数传递给成员函数。
Python的成员函数调用
为了进一步拓宽讨论范围,我们来研究一下在Python语言中,类似的做法会带来怎样的效果。考虑如下代码:
Demo Code IV
class A:
def show(self):
print self
class B:
def __init__(self):
self.show = None
if __name__ == '__main__':
a = A()
b = B()
b.show = a.show
a.show()
b.show()
得到的运行结果为:
<main.A instance at 0x10d6e1cb0>
<main.A instance at 0x10d6e1cb0>
也就是说,当在对象b上调用类A的成员函数的时候,函数得到的self依然指向类A的实例对象a!这种不同行为的深层次原理牵扯到解释型语言与编译型语言在实现上的根本性差异。对于后者,为了获得尽可能高的效率,函数被编译成为机器码,其中尽可能少地包含运行时信息以及类型信息;而对于前者,诸如Python这样极为灵活的动态语言,事实上已经将函数实现为一种“自包含”的对象——每一个函数对象都完全可以根据自己的内部成员变量找到自己所属的父对象等等。因此,不同于C++的是,当执行b.show()的时候,解释器并没有给这个成员函数传递任何附加信息,而是直接从a.show这个函数对象中获取了该函数所属对象a的相关信息,并作为self参数传递进去,完成函数调用。在定义成员函数的时候显式写明的self引用仅仅是一种语法规定而已,事实上并没有从外部根据它来进行参数传递。
因此,其实我们写出下面的代码,得到的效果也是一样的:
Demo Code V
class A:
def show(self):
print self
if __name__ == '__main__':
a = A()
f = a.show
a.show()
f()
这个例子充分体现出,Python语言中的成员函数确实是self-contained。那么为什么Python要设计出这种完全“自包含”的对象呢?一方面是得益于动态语言实现上的灵活性,使得解释器可以尽可能多地在一个对象中记录各种运行时信息;另一方面,这种设计也可以看做是一种语法糖,使得我们在涉及到诸如回调函数之类特殊场景的时候,能够以简洁灵活的方式编写代码。这里举个例子,比如在tkinter图形库中,我们可以写出如下这样的代码来产生一个按钮:
import Tkinter as tk
tk.Button(master=self.root, text='Do it',
command=self.do_func).pack(side=tk.LEFT)
如果函数对象不是自包含的,那么我们仅仅传递进去一个成员函数作为按钮被按下时的回调函数,但当真正发生回调的时候,如何知道这个函数应该作用于哪个对象呢?此时就需要更为纠结的语法来完成这个事情。因此,Python这样的设计是有着背后的深层考虑的。
参考资料
[1] https://en.wikipedia.org/wiki/X86_calling_conventions
[2] Bryant, Randal E., et al. 深入理解计算机系统.
Bonus
珩总离职前一天还在电话面试,几天后就去鹅厂入职了,如果到了那里还有面试任务,并且遇到了先前的面试者的话,那场景就好玩了……估计被面试的人内心一定是崩溃的:怎么到哪都能遇到这货!😂😂😂