C++内部如何利用虚函数实现多态机制
1. 引言
什么是虚函数?在某基类中声明为virtual 并在一个或多个派生类中被重新定义的成员函数叫虚函数。比如下面这个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25class A {
public:
virtual void func() { cout << "A func" << endl; }
virtual void funcA() { cout << "A funcA" << endl; }
void normalA() { cout << "normal A" << endl; };
public:
int a;
};
class B: public A {
public:
virtual void func() { cout << "B func" << endl; }
virtual void funcB() { cout << "B funcB" << endl; }
void normalB() { cout << "normal B" << endl; };
public:
int b;
};
int main () {
A* a = new A;
A* b = new B;
a->func(); // output will be "A func"
b->func(); // output will be "B func"
return 0;
}
上面是一个很常见的例子,基类A有一些函数,其中有些函数前面有virtual修饰,然后我们有一个派生类B,继承了A,然后重写了func
这个函数。我们在main里调用了b->func()
,虽然我一开始创建b
的时候用的是A*
,但程序还是跟我们需要的一样输出了跟B有关的信息”B func”,而不是”A func”,这个就是C++的多态机制。下面让我们来探讨一下C++的多态机制到底是怎么实现的。
2. 类是如何存放在内存中的(C++对象模型)
我们首先来看下面这个类定义:1
2
3
4
5
6
7
8
9
10
11class A {
public:
A() {};
virtual ~A() {};
void normalA { cout << "normal A" << endl; }
virtual void func() { cout << "A func" << endl; }
static void printType() { cout << "class A" << endl; }
public:
int a;
static int b;
};
这个类定义包括数据成员(静态变量
,非静态变量
)和函数成员(静态函数
,非静态函数
,virtual函数
),那么这个类在内存中到底是什么样子呢?
2.1 简单对象模型
上图中类的每一个实例(或者说对象)维护一个包含所有成员的指针表,数据成员和函数成员不做明显的区分,每个成员占用相同的空间,每个slot保存的是成员的地址。
- 优点:显然这个模型非常简单,降低了编译器设计的复杂度。
- 缺点:空间和时间上的效率降低。访问一个成员是,先要用过维护的表的索引找到相应的函数或者数据成员,所以时间上性能有所损失,当成员很多的时候会非常明显。每一个object都维护这样一个表,显然空间消耗是
一个对象中所有成员的个数
x运行时class A的对象个数
。
2.2 表格驱动对象模型
这个模型将数据成员和函数成员分离开,引入了一个中间层,一个类对象只维护一个表,这个表有两个slot,分别指向数据成员表和函数成员表,这个模型比如简单对象模型还与成员的个数相关。其中数据成员表中包含实际数据;函数成员表中包含的实际函数的地址(与数据成员相比,多一次寻址)。
- 优点:采用两层索引机制,可以保证所有的对象具有相同的大小,如果我们在类中怎家一个非静态数据变量,而应用程序代码没有改变,这时只需要重新编译这个类就可以了。
- 缺点:和2.1简单对象模型一样,空间和时间上的效率降低。
2.3 C++对象模型
真正的C++对象模型结合上面两者并做了优化,非静态数据成员直接放到对象内部,static数据成员,static和non-static 函数成员都独立出来,存储在对象之外。
对象内部除了非静态成员,还有一个虚函数表指针(vptr: virtual table pointer),该指针指向一个虚函数表,这个虚函数表存了所有的虚函数地址,以便函数调用。值得注意的是在虚函数表的最前面是一个type_Info指针,type_info用于支持runtime type identification (RTTI),里面的信息包括对象继承关系,对象本身的描述,多态中的dynamic_cast类型检查全靠这个type_info,vptr只有具有虚函数的对象会生成,vptr的设定和重置都由每一个class的构造函数,析构函数和拷贝赋值运算符自动完成。
只有定义了虚函数的类才有虚函数表,没有定义虚函数的类是没有虚函数表的。
- 优点:static和non-static函数独立出来后,调用的时候就不需要向前面两种模型一样每次通过索引查找,对于非静态成员也是直接访问,空间和存取时间都有不同程度上的优化;
- 缺点:如果应用程序本身未改变,但当所使用的类的non static数据成员添加删除或修改时,需要重新编译。对于虚函数还是要通过一个vptr查找到,不可能直接找到
3. 动态多态的实现
3.1 单继承
1 | class A { |
我们再来看一开始的这个例子,简化起见上图中只保留了vptr(比如实际上B对象还有一个内部变量int A::a
),派生类B的虚函数表,第一个slot是关于B的type_info,然后由于重写了func
,所以B::func
替换了原本的A::func
,没有被重写的A::funcA
被保留。
3.2 多继承
1 | class A { |
当派生类C继承多个基类时,如上表,那么C将会有两个vptr:
- 第一个虚函数表放的是C的
type_info
,重写过的C::func
,继承自A但未被重写的A::funcA()
,C自己的C::funcC
。 - 第二个虚函数表是B的
type_info
,B::func
,B::funcB
,但由于B::func
的函数名和A::func
的一样都是func
,在C中func
是被重写过的,所以调用的时候实际会跳转到C::func
。 - 如果C继承了三个类,那么会有三个vptr,右侧继承的第一个基类和C的所有虚函数会存放到第一个虚函数表,第二个基类的虚函数会存放到第二个虚函数表,第三个基类的虚函数会存放到第三个虚函数表。
4. Dynamic_cast
1 | class A { |
虚函数解决的只是虚函数自己的多态机制,像上面这个例子,在dynamic_cast之前我们直接调用b->normalB()
或者b->b
编译出错,说没有找到相应的函数或者变量,原因就是因为我们一开始用的是A*
而非B*
,如果要正常调用除了一开始直接指定B* b = new B
外我们还可以用dynamic_cast。1
dynamic_cast <type-id> (expression)
该运算符把expression
转换成type-id
类型的对象。type-id
必须是类的指针、类的引用或者void*
,如果type-id
是类指针类型,那么expression
也必须是一个指针。dynamic_cast运算符可以在执行期决定真正的类型,dynamic_cast具有类型检查的功能,比static_cast更安全,B* ba = dynamic_cast<B*>(a);
这里的ba转化后就是NULL,因为在dynamic_cast的时候,会检查a
这个对象的vptr
最前面所指向的type_info
,他发现A* a
不能转化成B*
所以返回了NULL,而static_cast
则没有检查,是强制类型转换。
还有一个B要有虚函数,否则会编译出错;static_cast则没有这个限制
5. 其他
5.1 用虚析构函数是一个好的习惯
1 | class A { |
上面的例子析构函数不是虚函数,程序的输出如下:1
2
3A constructor
B constructor
A deconstructor
这里会有一些问题,如果class B有很多内部变量,那么在析构的时候由于没有调用B的析构函数,会导致B中有些对象没有被释放掉,从而出现内存泄露。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18class A {
public:
A() { cout << "A constructor" << endl; }
virtual ~A() { cout << "A deconstructor" << endl; }
};
class B: public A {
public:
B() { cout << "B constructor" << endl; }
virtual ~B() { cout << "B deconstructor" << endl; }
};
int main()
{
A *b = new B;
delete b;
return 0;
}
那么我们修改一下,在A和B的析构函数前面都加上virtual
,此时我们可以看到如下信息,这样就不会有内存泄露的隐患了。1
2
3
4A constructor
B constructor
B deconstructor
A deconstructor