在进入主题之前,需要提前说明:
本文中的函数是广义上的函数,包括纯面向对象语言的中成员函数。文中的例子都是基于C++的,运行环境:64位MacOS Big Sur Xcode 12。为了避免干扰,代码中省略了内存管理的代码。
在软件开发过程中,经常会发现相同功能的代码,差别仅仅是几个参数不一样。DRY原则告诉我们,不要写重复的代码。于是,程序员们想到了使用函数对特定功能进行封装。几乎所有编程语言都支持编写函数。编程中使用函数是如此地自然,人们甚至忘了它的由来。
一、从函数早期聊起
1.诞生的故事
EDSAC计算机于1949年5月6日投入运行,它是世界上第一台实用的存储程序计算机。此前的计算机每次执行新的运算,都需要插入不同的线路进行重新装配,而EDSAC 则通过存储器中的软件实现各种不同的运算操作,这就对编程提出了很高的要求。许多程序在运行的过程中,都需要重复执行某个操作,比如在某个复杂的数字运算中,需要多次进行开平方操作。如果每次开平方都得把平方根代码写上,那么程序当中就会出现许多重复代码,占用不必要的空间(EDSAC 的内存只有两千字节左右),使程序变得庞大。
为了简化编程过程,威尔克斯的方法是建立子程序库,也就是将常见的子程序单独列出,集中起来。一旦程序在运行的过程中需要使用到某个常见的子函数,计算机就会在子程序库中“查找定义”,执行相应的子程序代码,根据输入值进行运算,再将运算结果返回。
威尔克斯团队比冯·诺依曼更早使用了汇编语言,其子程序的思想也成了后来的高级编程语言中的函数。
2.为什么需要函数
“函数”这个名词是从英文function翻译过来的,function的原意是“功能”。顾名思义,一个函数就是一个功能。在很多编程语言中,main函数就是编程入口。一个较大的程序不可能把所有代码都放到一个主函数中。
函数的出现解决了指令级别的重复问题。在早期,计算机存储还比较小的时候,避免重复指令可以显著的节约空间。现今的计算机硬件相比50年代的有了巨大优势,节约空间这一作用显得没那么重要了。相比于早期,函数更重要的作用是避免编写重复代码,提高代码的复用性,便于规划、组织、编程和调试。
二、探秘普通函数
1.不同角度看编程语言
高级编程语言屏蔽了语言特性的实现细节,让使用者站在一个更高层次上去使用编程语言。在更高层次的角度使用编程语言,便于我们关注业务本身,不必关注技术细节,专注于使用编程语言去解决问题;当我们从更底层的角度来思考编程语言提供的语法与特性,会更透彻地理解语言运行机制,从而认清各种语法的本质。
编程语言会告诉你支持哪些数据类型,不会告诉你数据类型是一种对内存数据的解释方式;编程语言会告诉如何定义一个类或结构体,不会告诉你类或结构体是相关性数据的组织方式。编程语言会告诉你如何定义并实现一个函数,同样不会告诉你函数在底层是什么。下面,带着好奇心,我们开启探索之旅吧。
2.函数是如何执行的
2.1函数的汇编表示
先来看一段简单的代码。定义了foo函数,foo函数有一个long类型的bar参数,函数体中定义long类型的两个变量a和b,函数返回了3个变量的和。在main函数中调用foo函数,返回值赋值给了result。
// 代码1
long foo(long bar) {
long a = 1;
long b = 2;
return bar + a + b;
}
int main(int argc, const char * argv[]) {
long result = foo(10);
return 0;
}
代码1对应的汇编代码如下:
// main函数
0x100003f80 <+0>: pushq %rbp
0x100003f81 <+1>: movq %rsp, %rbp
0x100003f84 <+4>: subq $0x20, %rsp
0x100003f88 <+8>: movl $0x0, -0x4(%rbp)
0x100003f8f <+15>: movl %edi, -0x8(%rbp)
0x100003f92 <+18>: movq %rsi, -0x10(%rbp)
0x100003f96 <+22>: movl $0xa, %edi
0x100003f9b <+27>: callq 0x100003f50
0x100003fa0 <+32>: xorl %ecx, %ecx
0x100003fa2 <+34>: movq %rax, -0x18(%rbp)
0x100003fa6 <+38>: movl %ecx, %eax
0x100003fa8 <+40>: addq $0x20, %rsp
0x100003fac <+44>: popq %rbp
0x100003fad <+45>: retq
// foo函数
0x100003f50 <+0>: pushq %rbp
0x100003f51 <+1>: movq %rsp, %rbp
0x100003f54 <+4>: movq %rdi, -0x8(%rbp)
0x100003f58 <+8>: movq $0x1, -0x10(%rbp)
0x100003f60 <+16>: movq $0x2, -0x18(%rbp)
0x100003f68 <+24>: movq -0x8(%rbp), %rax
0x100003f6c <+28>: addq -0x10(%rbp), %rax
0x100003f70 <+32>: addq -0x18(%rbp), %rax
0x100003f74 <+36>: popq %rbp
0x100003f75 <+37>: retq
2.2必要的汇编知识
在正式分析foo函数的执行过程之前,需要先补充一下必要的汇编知识。
本文中的汇编分析是在Xcode中进行的,汇编是x86-64汇编,汇编风格是AT&T。
AT&T汇编在指令后面加上q等字母表示操作数据的字节数,汇编指令后面的b表示操作1个字节的数据,w表示操作2个字节数据, l 表示操作4个字节数据,q表示操作8个字节数据。
%后面跟着的是寄存器,$后跟着的是操作数,()表示间接寻址。
参数传递按从左至右的顺序依次是:rdi, rsi, rdx, rcx, r8, r9,如果多余6个才有压栈的方式进行参数传递。
常用的汇编指令如下表:
2.3汇编分析
为了了解foo函数的执行过程,我们先分析下foo函数的汇编代码。
// foo函数汇编分析
0x100003f50 <+0>: pushq %rbp //将rbp寄存器的值压栈,目的是函数结束可以恢复rbp的值
0x100003f51 <+1>: movq %rsp, %rbp //将rsp寄存器的值赋值给rbp,也即rbp指向栈顶
0x100003f54 <+4>: movq %rdi, -0x8(%rbp) //将rdi的值存储到栈顶-8地址的空间,rdi中存储的函数main调用时传过来的10,稍后分析main函数会再次提到
0x100003f58 <+8>: movq $0x1, -0x10(%rbp) //将1存储到栈顶-16地址的空间,0x是16进制表示法,其中0x10即是10进制的16
0x100003f60 <+16>: movq $0x2, -0x18(%rbp) //将2存储到栈顶-24地址的空间
0x100003f68 <+24>: movq -0x8(%rbp), %rax //将10赋值给寄存器rax,此时rax中存储值是10
0x100003f6c <+28>: addq -0x10(%rbp), %rax //将1加rax值的和赋值给rax,此时rax中存储值是11
0x100003f70 <+32>: addq -0x18(%rbp), %rax //将2加rax值的和赋值给rax,此时rax中存储值是13
0x100003f74 <+36>: popq %rbp //恢复rbp的值
0x100003f75 <+37>: retq //函数调用结束结束,返回main函数
从上面的分析中,我们知道在foo返回前rax寄存器中存储的值13,在执行retq指令时并没有返回任何值,那main函数是如何拿到foo函数的返回值的呢?
我们直接分析和foo调用相关的代码,无关代码暂不分析。
// mian函数汇编分析
0x100003f96 <+22>: movl $0xa, %edi // 将10存入寄存器edi中,在foo的汇编分析中使用到了edi的值
0x100003f9b <+27>: callq 0x100003f50 // 调用foo函数
0x100003fa0 <+32>: xorl %ecx, %ecx // 清理寄存器ecx,此处和foo调用无关
0x100003fa2 <+34>: movq %rax, -0x18(%rbp) // 将寄存器rax存储的值存储到-0x18(%rbp)的内存中,也即赋值给result
至此,我们已经分析明白整个函数的执行过程。下图描述了foo函数调用过程栈的变化。
三、探秘成员函数
1.对象内存模型
先来看一段代码。在main函数中,将一个数组的地址通过强制转换赋值给了Person类型的指针变量person1,person2指向的是通过new关键字初始化的对象。通过person1和person2调用introduceOneself函数,输出的结果分别是什么?
// 代码2
#include
using namespace std;
class Person {
long m_age;
long m_height;
long m_weight;
public:
Person(long age, long height, long weight) {
m_age = age;
m_height = height;
m_weight = weight;
}
void introduceOneself() {
cout << m_age << endl;
cout << m_height << endl;
cout << m_weight << endl;
}
long foo(long bar) {
long a = 1;
long b = 2;
return bar + a + b;
}
};
int main(int argc, const char * argv[]) {
long array[3] = {20, 180, 75};
Person *person1 = (Person *)array;
person1->introduceOneself();
Person *person2 = new Person(20, 180, 75);
person2->introduceOneself();
return 0;
}
无论是通过person1还是person2调用introduceOneself函数,最终输出的结果都是20,180,75。
输出结果告诉我们,Preson对象的内存布局和数组并没什么差异。数组中的元素按照顺序依次从低地址到高地址排列,同样,对象中的成员变量也是(多继承和虚函数除外)。
person1内存数据如下图:
person2的内存数据如下图:
2.对象与成员函数
在上一小节中,我们分析了对象的内存模型,可以看出对象的内存模型中并没有存储和函数相关的信息,对象是怎么调用函数的呢?接下来,我们研究一下对象是如何调用自己的函数。introduceOneself函数中使用cout函数,分析起来干扰项太多,我们直接分析Person中的foo函数的调用过程。
// main函数的汇编分析
0x100003bfa <+106>: movq -0x18(%rbp), %rdi // -0x18(%rbp)存储的是person2的对象地址,即寄存器rdi存储着preson2的地址
0x100003bfe <+110>: movl $0xa, %esi // 将10存储到esi寄存器
0x100003c03 <+115>: callq 0x100003d10 // 调用foo函数
通过main函数的汇编,可以看到在调用foo函数时,传了2个参数,rdi中存储着对象的地址,esi中存储着函数的实参10。
// foo函数的汇编汇编分析
0x100003d10 <+0>: pushq %rbp
0x100003d11 <+1>: movq %rsp, %rbp
0x100003d14 <+4>: movq %rdi, -0x8(%rbp) // 将对象地址存(this)储到-0x8(%rbp)的内存单元
0x100003d18 <+8>: movq %rsi, -0x10(%rbp) // 将10储到-0x8(%rbp)的内存单元
0x100003d1c <+12>: movq -0x8(%rbp), %rax // this存储rax中
0x100003d20 <+16>: movq -0x10(%rbp), %rcx // 将10存储到rcx中
0x100003d24 <+20>: addq (%rax), %rcx // 将20+10=30存储到rcx中
0x100003d27 <+23>: addq 0x8(%rax), %rcx // 将30+180=210存储到rcx中
0x100003d2b <+27>: addq 0x10(%rax), %rcx // 75+210=285存储到rcx中
0x100003d2f <+31>: movq %rcx, %rax // 将285存储到rax中
0x100003d32 <+34>: popq %rbp
0x100003d33 <+35>: retq
在foo函数中通过寄存器rdi取到对象地址,将对象地址存储到寄存器rax中,然后分别通过(%rax)、0x8(%rax)、0x10(%rax)获取到m_age、m_height、m_weight。
分析了foo函数的调用过程,也不难理解为什么通过person1和person2调用introduceOneself函数输出的结果是一样的了。
3.继承与多态
3.1继承与函数
同样,先来看段代码。Student类继承自Person类,在main函数中person变量前后指向了Person对象和Student对象,并调用了introduceOneself函数。
// 代码3
class Person {
public:
void introduceOneself() {
cout << "Person" << endl;
}
};
class Student : public Person {
public:
void introduceOneself() {
cout << "Student" << endl;
}
};
int main(int argc, const char * argv[]) {
Person *person = new Person();
person->introduceOneself();
person = new Student();
person->introduceOneself();
return 0;
}
运行上面的代码,输出结果都会是Person。
看一下main函数的汇编,可以看到两次调用了introduceOneself ,调用地址都是0x1000031f0,说明两次调用同一个函数。第一次调用introduceOneself时,寄存器rdi存储的是Person对象的地址,第二次调用时,寄存器rdi存储的是Student对象的地址。
调用普通函数,会直接根据指针的类型调用对应的函数,不会考虑指针实际的指向,这是在编译期做的事情。
// main函数的汇编
0x1000031a0 <+0>: pushq %rbp
0x1000031a1 <+1>: movq %rsp, %rbp
0x1000031a4 <+4>: subq $0x20, %rsp
0x1000031a8 <+8>: movl $0x0, -0x4(%rbp)
0x1000031af <+15>: movl %edi, -0x8(%rbp)
0x1000031b2 <+18>: movq %rsi, -0x10(%rbp)
0x1000031b6 <+22>: movl $0x1, %edi
0x1000031bb <+27>: callq 0x100003e24
0x1000031c0 <+32>: movq %rax, -0x18(%rbp)
0x1000031c4 <+36>: movq -0x18(%rbp), %rdi
0x1000031c8 <+40>: callq 0x1000031f0 // 第一次调用introduceOneself
0x1000031cd <+45>: movl $0x1, %edi
0x1000031d2 <+50>: callq 0x100003e24
0x1000031d7 <+55>: movq %rax, -0x18(%rbp)
0x1000031db <+59>: movq -0x18(%rbp), %rdi
0x1000031df <+63>: callq 0x1000031f0 // 第一次调用introduceOneself
0x1000031e4 <+68>: xorl %eax, %eax
0x1000031e6 <+70>: addq $0x20, %rsp
0x1000031ea <+74>: popq %rbp
0x1000031eb <+75>: retq
3.2多态与虚函数
简单修改一下代码3,修改后的代码如下。代码4和代码3的区别在于在introduceOneself函数前面多个virtual关键字,表明introduceOneself是一个虚函数。
// 代码4
class Person {
long m_age = 10;
public:
virtual void introduceOneself() {
cout << "Person" << endl;
}
};
class Student : public Person {
long m_no = 20201234;
public:
void introduceOneself() {
cout << "Student" << endl;
}
};
int main(int argc, const char * argv[]) {
Person *person = new Person();
person->introduceOneself();
person = new Student();
person->introduceOneself();
return 0;
}
运行上面代码,输出结果将分别是Person和Student。
person指向的对象是运行时决定的,在编译时是无法决定的。那么,上面的代码是怎么调用到对应对应的函数呢?
在OC中,调用对象的函数是通过对象中的isa指针找到对应的类对象,类对象中保存着函数列表,然后就可以间接找到函数地址进行调用了。其实,在C++中,也有类似的机制,当有虚函数存在时,在对象的内存空间前8个字节中存储着虚函数列表的地址,也即虚表(这里指向的并不是虚表的表头,是表头+16的位置,从这里开始存储着各个虚函数的地址,这里可以不关心这个细节)。通过虚表,就可以找到对应的函数的地址。
下图显示的是Student对象的内存数据,前8个字节(红色框中)的数据是0x0100004068,也即Student对象的虚函数地址是0x0100004068。
下图显示的是0x0100004068内存的数据,可以看到黄色框中的数据是0x0100003D60 ,这个地址其实就是introduceOneself的地址。
下图显示的是断点introduceOneself函数调用的截图,可以看到introduceOneself函数调用地址和上图黄色框中的数据是0x0100003D60是一样的。
通过上面的分析,我们已经明白了虚函数的机制。下面换个角度,从汇编的代码看下虚函数是怎么调用的。由于汇编比较长,省略了部分无关代码,只需关注和虚函数相关的代码。
// main函数的汇编
// 省略部分代码
0x100003056 <+54>: movq -0x20(%rbp), %rdi // Person对象地址
0x10000305a <+58>: callq 0x1000030c0 // 调用Person构造函数
0x10000305f <+63>: movq -0x20(%rbp), %rax // Person对象地址存储到rax
0x100003063 <+67>: movq %rax, -0x18(%rbp) // -0x18(%rbp)就是person变量的地址,Person对象地址赋值给person变量
0x100003067 <+71>: movq -0x18(%rbp), %rcx // person变量的值存储到rcx,rcx此时存储的是Person对象的地址
0x10000306b <+75>: movq (%rcx), %rdx // 虚表地址存在Person对象的开头位置,通过Person对象地址取到虚表地址并存储到rdx
0x10000306e <+78>: movq %rcx, %rdi // Person对象地址存储到rdi
0x100003071 <+81>: callq *(%rdx) // rdx存储的是虚表地址,*(%rdx)表示取到虚表前8个字节的内容(也即虚函数的地址),然后调用虚函数
// 省略部分代码
0x100003093 <+115>: movq -0x28(%rbp), %rdi // Student对象地址
0x100003097 <+119>: callq 0x1000030e0 // 调用Person构造函数
0x10000309c <+124>: movq -0x28(%rbp), %rax // Student对象地址存储到rax
0x1000030a0 <+128>: movq %rax, -0x18(%rbp) // Student对象地址赋值给person变量
0x1000030a4 <+132>: movq -0x18(%rbp), %rax // -0x18(%rbp)就是person变量的地址,Student对象地址赋值给person变量
0x1000030a8 <+136>: movq (%rax), %rcx // 虚表地址存在Student对象的开头位置,通过Student对象地址取到虚表地址并存储到rdx
0x1000030ab <+139>: movq %rax, %rdi // Student对象地址存储到rax,也即this指针
0x1000030ae <+142>: callq *(%rcx) // rcx存储的是虚表地址,*(%rcx)表示取到虚表前8个字节的内容(也即虚函数的地址),然后调用虚函数
0x1000030b0 <+144>: xorl %eax, %eax
0x1000030b2 <+146>: addq $0x30, %rsp
0x1000030b6 <+150>: popq %rbp
0x1000030b7 <+151>: retq
3.4思考与提升
- 上面的内容并没分析多继承下的虚表情况,读者可以自行思考这种情况下的虚表和对象的关系,可以使用上面的分析方法进行验证。
- 子类如果没有重写父类的虚函数,情况会怎么样?
- OC是怎么实现继承与多态的,与C++的实现方法有什么异同?
参考资料:
【1】《计算机:一部历史》皮得·本特利 著
【2】《汇编语言》第三版 王爽 著
【3】《深度探索C++对象模型》Stanley B.Lippman(斯坦利·B.李普曼) 著,侯捷 译