C++虚函数表

c++ 虚函数表

在c++中,实现多态有多种方式,其中动态多态的核心就是虚函数表。每一个拥有虚函数的类,都有一个虚函数表。

虚函数表

类的虚函数表

对于虚函数表,我们首先可以记住一些基本的知识:

  1. 每个包含了虚函数的类,都会有虚函数表;
  2. 每个类只有一个虚函数表,每个类的对象内会有一个虚函数指针指向这个类的虚函数表
  3. 一个类继承另一个类时,会继承这个类的虚函数,所以一个类继承了包含虚函数的基类,那么这个类也会有虚函数,也会有虚函数表
  4. 虚函数表内的指针赋值发生在编译器

下面这张图也基本描述了一些虚函数表和虚函数指针的信息,虚函数表是一个指针数组,其元素是虚函数的指针,虚函数和虚函数表内的指针一一对应;而该类的非虚函数,在调用时不需要经过虚函数表。

1
2
3
4
5
6
7
8
9
10
11
class A{
public:
virtual void vfunc1();
virtual void vfunc2();
void func1();
void func2();

private:
int m_data1;
int m_data2;
};

类的对象的虚函数表指针

对于类A的每一个对象来说,都会有一个虚函数表指针指向这个类的虚函数表。虚函数表指针是编译器在编译时创建的。

同一个类的所有对象都使用同一个虚函数表,下图中A的对象中虚函数表指针都指向同一个虚函数表。

虚函数表和动态绑定

继承中的虚函数表

虚函数表时如何实现动态绑定的呢?我们研究一个简单的案例,类A是基类,类B继承于类A,可以使用下图来描述其对象的虚函数表关系。

1
2
3
4
5
class B : public A{
public:
virtual void vfunc1();
void func1();
};

此时,类A和类B的继承关系和虚函数表关系如图所示,由于类B继承了类A,并且重写了虚函数vfunc2。对于类A的对象来说,其虚函数表中,指向类A的的两个虚函数A::vfunc1()和A::vfunc2()。而类B的虚函数表,指向重写后的B::vfunc1和继承于类A的虚函数A::vfunc2()

总而言之,一个基本结论:对象的虚函数表指针都是指向对象自己的虚函数表。一般来说,虚函数表指针占用4个字节的大小。

虚函数实现的多态

对于上面的A和B,通过以下程序来认识通过虚函数实现的多态。为了说明虚函数和多态,我们观察以下程序的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class A{
public:
virtual void vfunc1(){
std::cout << "A::vfunc1"<< std::endl;
};
virtual void vfunc2(){};
void func1(){
std::cout << "A::func1"<< std::endl;
}
void func2();

private:
int m_data1;
int m_data2;
};

class B : public A{
public:
virtual void vfunc1(){
std::cout << "B::vfunc1"<< std::endl;
}
void func1(){
std::cout << "B::func1"<< std::endl;
}
};

int main(int argc, char const* argv[])
{
B bObject;
A *a = &bObject;
B *b = &bObject;
a->func1();
a->vfunc1();
b->func1();
b->vfunc1();
return 0;
}

程序的输入如下

1
2
3
4
A::func1
B::vfunc1
B::func1
B::vfunc1

在程序中,我们定义了一个B的对象bObject,然后声明一个类A的指针a指向bObject和一个类B的指针b指向bObject

  1. 当指针a调用func1时,非虚函数不需要经过虚函数表,a调用了自身的func1,输出A::func1

  2. 而当指针a调用虚函数vfun1时,此时需要使用虚函数表,而此时的对象bObject的虚函数指针指向的虚函数表中的vfun1指向的是B::vfunc1,输出了B::vfunc1

  3. 当指针b调用func1时,b调用了自身的func1,输出B::func1
  4. 当指针b调用vfunc1时,此时需要使用虚函数表,所以调用了B::vfunc1,输出B::vfunc1

这种通过虚函数表调用虚函数的过程称之为动态绑定,表现出来就是运行时的动态。

如果此时还有一个类C继承于类B,代码如下,又会输出什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class C : public B
{
public:
virtual void vfunc2() {
std::cout << "C::vfunc2" << std::endl;
}
void func2() {
std::cout << "C::func2" << std::endl;
}
};

int main(int argc, char const* argv[])
{
C cObject;
A* a = &cObject;
B* b = &cObject;
C* c = &cObject;
c->vfunc1();
a->vfunc2();
b->vfunc2();
c->vfunc2();
return 0;
}

代码输出如下

1
2
3
4
B::vfunc1
C::vfunc2
C::vfunc2
C::vfunc2

对于a->vfunc2();b->vfunc2();c->vfunc2();这三个调用,通过前面地例子地学习,此处应该通过虚函数表来调用虚函数,可以很清楚地知道会调用C::vfunc2()。需要关注的是c->vfunc1(),此时类C的虚函数表里面对应的vfunc1()B::vfunc1()。所以可以总结得到,虚函数的调用,调用的是指向的对象的最近的一个类虚函数表的该虚函数

通过观察可以看到,实现这种多态有几个关键的条件:

  1. 需要通过指针来调用函数
  2. 调用的是虚函数
  3. 有继承类向基类的转化,例如上面的通过基类指针指向继承类的对象。

对于这种多态,我们可以总结出来:

非虚函数,谁的指针调用谁的方法

虚函数,调用指向的对象的最近的一个类虚函数表的函数

多重继承中的虚函数表

考虑以下场景,如果增加了一个类D,一个类E继承于A和D,其虚函数表会是什么样的构成呢?这里就涉及到了多重继承的问题。

1
2
3
4
5
6
7
8
9
10
class D {
public:
virtual void vfunc3() {
std::cout << "D::vfunc3" << std::endl;
}
};

class E : public A, public D
{
};

首先,需要注意的是如果D和A有相同的函数,并且没有被e重写过,使用e的指针调用该函数是会出现调用不明确的问题,无法编译通过的,但是可以通过e->A::vfunc2来调用。

在多重继承中:

1) 每个父类都有对应的虚函数表。

2) 子类的成员函数调用,如果没有被重写,是按声明继承的顺序,如果被重写就调用重写后的。依然是调用指向的对象的最近的一个类虚函数表的函数

虚函数表相关的其他问题

虚函数默认值问题

纯虚函数中有默认值,如下面的程序之中,基类和派生类的虚函数vfunc1()里面都有一个默认值name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class A {
public:
virtual void vfunc1(std::string name =" A ") {
std::cout << "A::vfunc1" << name << std::endl;
};
virtual void vfunc2() {};
void func1() {
std::cout << "A::func1" << std::endl;
}
void func2();
};

class B : public A {
public:
virtual void vfunc1(std::string name =" B ") {
std::cout << "B::vfunc1" << name << std::endl;
}
void func1() {
std::cout << "B::func1" << std::endl;
}
};

int main(int argc, char const* argv[])
{
B bObject;
A* a = &bObject;
B* b = &bObject;
a->vfunc1();
b->vfunc1();
return 0;
}

在实际调用时,我们可以知道的是a->vfunc1()b->vfunc1()实际上都调用的是B::vfunc1,这一点毫无疑问,所以函数的输出结果是

1
2
B::vfunc1 A 
B::vfunc1 B

前面的B::vfunc1没有问题,有趣的后面的默认参数namea->vfunc1()使用的默认参数是`std::string name =" A "b->vfunc1()使用的默认参数是std::string name =" B ",这是因为参数值是在编译期就已经决定的,所以类A的指针调用时,使用的时类A的默认参数。类B的指针调用时使用的时类B的默认参数。

虚函数与构造函数和析构函数

  1. 构造函数不能是虚函数,如果构造函数是虚函数,此时对象开始还未分配内存空间,也就不会有什么虚函数表了,所以根本就无法找到虚函数表,也就无法调用构造函数了,所以构造函数是不能成为虚函数
  2. 析构函数可以是虚函数,并且类有继承的时候,析构函数常常必须为虚函数。当使用基类指针指向派生类对象时,基类指针调用析构函数,因为析构函数是虚函数,基类和子类都会被释放。如果析构函数不是虚函数的时候,只能调用基类析构函数,只有基类会被释放,而派生类没有被释放。

对于如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class A {
public:
A(){};
virtual ~A(){std::cout << "~A()" << std::endl;}
};

class B : public A {
public:
B(){};
~B(){std::cout << "~B()" << std::endl;}
};

int main(int argc, char const* argv[])
{
A* a = new B();
B* b = new B();
delete a;
delete b;
return 0;
}

因为A的构造函数是虚函数,所以会输出

1
2
3
4
~B()
~A()
~B()
~A()`

如果A的析构函数不是虚函数,那么只能是

1
2
3
~A()
~B()
~A()

此时,指针a的对象派生类内存没有释放。

此时还有一个问题,如果构造函数里面有虚函数呢?例如下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class A {
public:
A() {
vfunc1();
};
virtual ~A() {}
virtual void vfunc1() {
std::cout << "A::vfunc1" << std::endl;
}
};

class B : public A {
public:
B() {
vfunc1();
};
~B() {}
virtual void vfunc1() {
std::cout << "B::vfunc1" << std::endl;
}
}

int main(int argc, char const* argv[])
{
B b;
return 0;
}

这里输出是

1
2
A::vfunc1 
B::vfunc1

这里初始化类B的对象,先调用B的构造函数,会调用A的构造函数,先按照A对象的内存布局进行初始化,初始化了类A的对象的虚函数表指针,所以此时的vfunc1指向的是A::vfunc1,虚函数表指针还没有指向B的虚函数表。之后对B的对象内存布局进行初始化,虚函数表指针才会指向B的虚函数表,然后才会调用B的B::vfunc1。如果是析构函数里面有虚函数,那么顺序就是相反的,因为析构的顺序和构造函数的顺序是相反的。

多态与类型转换

考虑如下函数的输出,还是前面的类A类B,构造函数和析构函数里的调用和输出注释掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void test1(A &a){
a.vfunc1();
}

void test2(A a){
a.vfunc1();
}

int main(int argc, char const* argv[])
{
B bObject1;
test1(bObject1);
test2(bObject1);
return 0;
}

对于函数test1来说,其输入的是该对象的引用,而test2输入的是该对象的拷贝赋值。所以在test1中调用虚函数vfunc1时,调用的依然是B::vfunc1(),而在test2里调用的是A::vufnc2(),所以输出如下:

1
2
B::vfunc1
A::vfunc1