静态联编和动态联编
当我们使用程序调用函数的时候,究竟应该执行哪一个代码块呢?将源代码中的函数调用解释为执行特定的函数代码块这个过程被称为函数名联编(binding)。
在C语言当中,这非常简单,因为每个函数名都对应一个不同的函数。而在C++当中,由于支持了函数重载,使得这个任务变得更加复杂。编译器必须要查看函数的参数以及函数名才能确定。好在函数的选择以及参数在编译的时候都是确定的,所以这部分联编在编译时就能完成,这种联编被称为静态联编。
在有了虚函数之后, 这个工作变得更加复杂。因为使用哪一个函数不能在编译时确定了,因为编译器不知道用户将选择哪个类型的对象。所以,编译器必须能在程序运行的时候选择正确的虚函数,这被称为动态联编。
指针和引用类型的兼容性
在C++当中,动态联编与指针和引用调用方法相关,这是由继承控制的。前文当中说过,公有继承建立的is-a关系,使得我们可以用父类指针或引用指向子类的对象。而在C++当中,是不允许将一种类型的地址赋值给另外一种类型的指针的。
下面两种操作都是非法的。
- double x = 2.5;
- int *pi = &x; // 非法
- long &r = x; // 非法
将派生类引用或指针转换成基类的引用和指针称为向上强制转换(upcasting),这种规则是is-a关系的一部分。因为派生类继承了基类当中所有的数据成员和成员函数,因此基类成员能够进行的操作都适用于子类成员,所以向上强制转换是可传递的。
如果反过来呢?将父类对象传递给子类指针呢?这种操作被称为向下强制转换(downcasting),在不使用强制转换的前提下是不允许的。因为is-a关系通常是不可逆的,派生类当中往往新增了一些数据成员或方法,不能保证在父类对象上一样还能兼容。
虚函数的工作原理
我们在使用虚函数的时候其实可以不需要知道当中的实现原理,但是了解了工作原理能够帮助我们更好地理解理念。另外在C++相关的开发面试当中经常会问起类似的实现细节。
通常,编译器处理虚函数的方法是:给每一个对象添加一个隐藏成员,这个成员当中保存了一个指向函数地址数组的指针,这种数组称为虚函数表。
这个虚函数表中存储了当前这个类对象的声明的虚函数的地址,我们来看一个例子:
- class Human {
- private:
- ...
- char name[40];
- public:
- virtual void show_name();
- virtual void show_all();
- ...
- };
- class Hero : public Human{
- private:
- ...
- char power[20];
- public:
- void show_all();
- virtual void show_power();
- ...
- };
对于Human类型的对象,它当中除了类中声明的内容之外,还会额外多一个指针,指向一个列表,比如是[1024,1222]。
这里的1024和1222分别是show_name和show_all两个函数代码块的地址。
同样Hero子类当中也会有这样一个指针指向一个虚函数的列表,由于我们在Hero子类当中没有重载show_name方法,所以Hero类型的对象中的列表中的第一个元素仍然是1024。由于我们重载了show_all方法,以及我们新增了一个show_power的虚函数,因此它的虚函数列表可能是[1024,2333,3777]。
简单来说,当我们调用虚函数的时候, 编译器会先通过每个对象中的虚函数列表指针拿到虚函数列表。然后在找到对应位置的虚函数代码块的地址,最后进行执行。
显然这个过程涉及到维护虚函数地址表,以及函数执行时有额外的查表操作,既带来了存储空间的消耗,也带来了性能的消耗。