刘洪江的流水帐

拾起点点滴滴, 聚沙成石.

一个连咖啡都要趁热一饮而尽的男子

虚函数

| Tags: C/C++

面试的时候,常常宣称自己是一个C++程序员,不免就会被问一些有关C++的问题,例如虚函数的实现原理;析构函数是否能定义为虚函数等等。很多时候都回答不上来,就总会以时间久了忘了,搪塞过去,面试完,可能回去查查答案,但也总是不求甚解。

既然有了博客,我就准备看看这些方面的内容,希望能记录下来,有所积累。于是就到网上查,或在书上找找,后来发现里面的内容太多了,不是一篇两篇博客就能写完的。于是没有办法,就只有硬着头皮写一个系列了。今天是第一部分虚函数和虚继承。

C++的关键字virtual只能用在两个地方,一是定义类的成员函数为虚函数,二是定义类的继承关系为虚继承。这两点的用处大相径庭,但是在设计思想上还是有一定的共同性的。这篇文章先讲虚函数,下一篇讲虚继承。

虚函数

虚,不实也。也可以理解为看到的和实际的不一样。虚函数存在的目的只有一个,那就是实现多态。关于多态,可以去参考各种教科书,上面都有详细的说明。虚函数在实现多态时,通过一种间接的运行时(而不是编译时)的机制激活(调用)的函数。下面看一个多态的简单例子。

多态的简单例子 (polymorphism.cc) download
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
/*
 * polymorphism.cc: example of polymorphism
 */

#include <iostream>

using namespace std;

class base {
public:
	virtual void f(){ cout << "in base:f" << endl;}
};

class derived1: public base {
public:
	virtual void f(){ cout << "in derived1:f" << endl;}
};

class derived2: public base {
public:
	virtual void f(){ cout << "in derived2:f" << endl;}
};

void call_func(base* pb) {
	pb->f();
}

int main(void) {
	base* pb = new base();
	base* pd1 = new derived1();
	base* pd2 = new derived2();
	call_func(pb);
	call_func(pd1);
	call_func(pd2);
}

运行结果如下1:

从运行结果来看,函数call_func()最后实际调用的f()要到运行时,根据传入的参数,才能确定调用的是哪个函数。

那么虚函数的这种性质是如何实现的呢?答案是虚表(vtable)。具体的做法是,在对象的存储空间里面开辟一个指针,指针指向一个存放着虚函数地址的函数指针表。编译器在生成调用虚函数的指令时,按照偏移量,从虚表中取相应的函数指针进行调用。子类的虚函数会覆盖父类中对应虚函数在虚表中的位置,所以在调用的时候,就调用到了子类的函数了。也许这段话没有说太清楚,下面看一段代码或许会有帮助。

测试虚表的例子 (vfun.cc) download
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
38
39
40
41
/*
 * test virtual functions
 */

#include <iostream>

using namespace std;

class base {
public:
	virtual void f(){ cout << "in base:f" << endl;}
};

class derived: public base {
public:
	virtual void f(){ cout << "in derived:f" << endl;}
};

typedef void(*fun)(void);
#define VTAB(pclass) ((long*)(*(long*)(pclass)))

int main(void) {
	base* pb = new base();
	pb->f();
	base* pd = (base*) new derived();
	pd->f();

	cout << "--- call through vtable ---" << endl;
	fun pfun = (fun)*VTAB(pd);
	pfun();
	pfun = (fun)*VTAB(pb);
	pfun();
	derived* pd_fake = (derived*)pb;
	pfun = (fun)*VTAB(pd_fake);
	pfun();

	pfun = (fun)*VTAB(pd);
	cout << "value of *pfun is: " << *pfun <<  endl;
	pfun = (fun)*(VTAB(pd) + 1);
	cout << "value of *pfun is: " << *pfun <<  endl;
}

输出结果如下:

上面这段代码的第20行,通过定义指针的方式2,直接访问虚表,发现它的输出与通过对象调用是一直的,而且指针的类型无关。由上面的代码也可以看出虚表在对象的内存空间中是怎么分布的。对象的起始地址,就是虚表指针。虚表的最后一项为0,代表虚表结束。上面程序的最后一项输出可以看出来。

用一张图来说明情况。

在多个虚函数的情况下,子类仅仅覆盖在子类重载的虚函数,而子类新定义的虚函数,加入到虚表的最后。下面这个例子就是多个虚函数的情况。

多个虚函数 (vfun1.cc) download
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
// test virtual functions

#include <iostream>

using namespace std;

class base {
public:
	virtual void f(){ cout << "in base:f" << endl;}
	virtual void f0(){ cout << "in base:f0" << endl;}
};
class derived: public base {
public:
	virtual void f(){ cout << "in derived:f" << endl;}
	virtual void f1(){ cout << "in derived:f1" << endl;}
};

typedef void(*fun)(void);

#define VTAB(pclass) ((long*)(*(long*)(pclass)))

int main(void) {
	base* pb_real = new base();
	pb_real->f();
	base* pb = (base*) new derived();
	pb->f();
	pb->f0();

	cout << "--- call function through vtable ---" << endl;
	fun pfun = (fun)*VTAB(pb);
	pfun();
	pfun = (fun)*(VTAB(pb)+1);
	pfun();
	pfun = (fun)*(VTAB(pb)+2);
	pfun();
}

输出结果:

虚表的组织结构如下:

由图中可以看出,子类的f()覆盖了基类的f(),而基类的f0()依然存在,子类的f1()添加到了虚表的最后。

当有多个基类时,子类会为每个基类添加一个虚表指针,指针的顺序按照类定义时的声明顺序。下面这个例子就是这样的。

虚函数多重继承 (vfun2.cc) download
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// test virtual functions

#include <iostream>

using namespace std;

class father_b1 {
public:
	virtual void f(){ cout << "in father of base1:f" << endl;}
	virtual void fbf(){ cout << "in father of base1:fbf" << endl;}
};

class father1_b1 {
public:
	virtual void f(){ cout << "in father1 of base1:f" << endl;}
	virtual void f1bf(){ cout << "in father1 of base1:f1bf" << endl;}
};

class base {
public:
	virtual void f(){ cout << "in base:f" << endl;}
	virtual void bf0(){ cout << "in base:f0" << endl;}
};

class base1: public father_b1, public father1_b1{
public:
	virtual void f(){ cout << "in base1:f" << endl;}
	virtual void b1f0(){ cout << "in base1:f0" << endl;}
};

class derived: public base1, public base {
public:
	virtual void f(){ cout << "in derived:f" << endl;}
	virtual void f1(){ cout << "in derived:f1" << endl;}
};

typedef void(*fun)(void);

#define VTAB(pclass) ((long*)(*(long*)(pclass)))
#define VTAB1(pclass) ((long*)(*(((long*)(pclass)) + 1)))
#define VTAB2(pclass) ((long*)(*(((long*)(pclass)) + 2)))


int main(void) {
	derived* pd = new derived();

	cout << "--- vtable ---" << endl;
	fun pfun = (fun)*VTAB(pd);
	pfun();
	pfun = (fun)*(VTAB(pd)+1);
	pfun();
	pfun = (fun)*(VTAB(pd)+2);
	pfun();
	pfun = (fun)*(VTAB(pd)+3);
	pfun();

	cout << endl;
	cout << "--- vtable + 1 ---" << endl;
	pfun = (fun)*VTAB1(pd);
	pfun();
	pfun = (fun)*(VTAB1(pd)+1);
	pfun();

	cout << endl;
	cout << "--- vtable + 2 ---" << endl;
	pfun = (fun)*VTAB2(pd);
	pfun();
	pfun = (fun)*(VTAB2(pd)+1);
	pfun();
}

输出结果:

这是一个比较复杂的继承关系了,可以看出最后的derived类里面已经有3个虚表指针了,也就是说,子类会讲所有的父类的虚表继承下来,并将自己的虚函数添加到第一个虚表的最后。还有一点,就是子类的虚函数会覆盖所有基类的对应虚函数,图中的derived::f()就覆盖了3处。其组织结构如下:

构造函数和析构函数的调用顺序

这个问题本来和虚函数没有关系,但是为了后面解释关于构造函数为什么不能声明为virtual,添加到这里的。

构造函数的调用顺序是,先调用父类的构造函数,然后调用成员变量的构造函数,最后调用子类自身的构造函数,多个父类时,按照父类的继承时的声明顺序调用,成员变量的构造函数也按照声明顺序调用。

析构函数的调用顺序与构造函数的正好相反,先调用子类自身的析构函数,然后是成员变量的析构函数,最后是父类的构造函数,多个父类时,按照父类的继承时的声明的相反顺序调用,成员变量的构造函数也按照声明的相反顺序调用。

下面是一段测试调用顺序的代码

构造和析构函数的调用顺序 (constructor_destructor_order.cc) download
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/*
 * 研究构造函数的调用顺序
 *  1. 首先调用基类的构造函数(如果有基类);如果有多个基类,则按基类被列出的顺序调用;
 *  2. 调用这个类的成员对象的构造函数(如果有的话);如果有多个成员对象,则按成员对象定义的顺序被调用(与参数列表中列出的顺序无关);
 *  3. 最后调用这个类自身的构造函数;
 *  4. 析构函数正好相反;
 *  注意:如果有虚基类,则先调用虚基类的构造函数。在调用基类的构造函数,如果有多个虚基类,则按列出的顺序调用; 
*/

#include <iostream>

using namespace std;

class father_base_a{
public:
   father_base_a(){cout<<"This is father_base_a;"<<endl;}
   virtual ~father_base_a() { cout << "This is ~father_base_a" << endl; }
};

class base_a: public father_base_a{
public:
   base_a(){cout<<"This is base_a;"<<endl;}
   virtual ~base_a() { cout << "This is ~base_a" << endl; }
};

class base_b{
public:
   base_b(){cout<<"This is base_b;"<<endl;}
   virtual ~base_b() { cout << "This is ~base_b" << endl; }
};

class member_a{
public:
   member_a(){cout<<"This is meber_a;"<<endl;}
   virtual ~member_a(){cout<<"This is ~meber_a;"<<endl;}
};

class member_b{
public:
   member_b(){cout<<"This is member_b;"<<endl;}
   virtual ~member_b(){cout<<"This is ~meber_b;"<<endl;}
};

class derived : public base_b, public base_a
{
public:
   member_a ma;
   derived(){
      cout<<"This is derived;"<<endl;
   }
   virtual ~derived() { cout << "this is ~derived()" << endl; }

private:
   member_b mb;
};

int main(void)
{
  derived de;
  cout << "---------" << endl;
}

输出结果

从输出结果可以看出,先调用了base_b的构造函数,然后调用base_a的构造函数(但得先调用base_a的父类father_base_a),然后是成员member_amember_b的构造函数,最后是derived自己的构造函数。析构的过程正好相反。

构造函数不能声明为虚函数

这也是面试中常被问道的一个问题。原因应该有两个

  1. 在构造函数之前,类的对象是不存在的,那么也没有vtable,也无法通过vtable找到虚函数。所有无法定义为虚函数。
  2. 构造函数是先调用父类,最后才是子类的构造,因为子类的内存布局是基于父类。如果使用虚构函数,那么在调用父类的构造函数时,实际调用的是子类的构造函数,那么就无法完成对象的构造。
  3. 虚函数的主要目的是多态,运行时确定调用那个函数,对象的构造过程是确定的,使用虚函数没有意义。

实际上,如果将构造函数声明为虚函数,编译时,gcc会报错。

析构函数声明为虚函数

在基类的声明中一般都应该将析构函数声明为虚函数。首先由于析构函数没有构造函数的问题,所以是可以声明为虚函数的。其次,可能程序需要利用析构函数为虚函数的特性,才能讲资源释放完全。

首先来看一个代码例子

析构函数为虚函数 (destructor_virtual.cc) download
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
// 分析析构函数为虚函数时的特性

#include <iostream>

using namespace std;

class base{
public:
	virtual	~base(){ cout << "in base::~base()" << endl; }
};

class derived: public base{
public:
	virtual ~derived(){ cout << "in derived::~derived" << endl; }
};

class base1 {
public:
	~base1(){ cout << "in base1::~base1()" << endl; }
};

class derived1: public base1{
public:
	~derived1(){ cout << "in derived1::~derived1" << endl; }
};

int main(void){
	base *pbase = new derived();
	delete pbase;
	cout << "--------" << endl;
	base1 *pbase1 = new derived1();
	delete pbase1;
}

运行结果

由结果可以看出,由于base1的析构函数没有声明为虚函数,在delete pbase1时,没有调用derived1的析构函数,那么在derived1中申请的资源就无法释放。而因为base类中的修改函数声明为了虚函数,就没有这样的问题。

什么时候使用虚函数

包含了虚函数的成员函数的对象,由于需要存放虚表地址,那么就比不包含虚函数的对象,多出了一块空间,32位为4个字节,64位为8个字节。如果没有用到多态,而且有大量的对象存在时,需要考虑这个开销。

在需要使用多态,或在定义接口时,需要使用虚函数。特别是定义接口时,最好使用纯虚函数。

另外,将私有成员函数声明为虚函数,可以通过对象的虚表指针的方式访问,造成了封装的不严密。例如,通过在前面的例子中定义的VTAB宏,就可以直接获取函数指针,然后调用函数,这种调用是需要检查这个成员函数的性质的。

同名函数隐藏规则

在父类和子类中如果存在同名的函数,那么一定要小心了,这时需要重载父类中所有同名的函数。首先来看一个关于同名函数的例子

诡异的同名函数 (same_name.cc) download
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
38
39
40
41
42
43
44
45
/*
 * 测试c++隐藏规则:
 * (1)如果派生类的函数与基类的函数同名,但是参数不同。
 *      不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。
 * (2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数
 *      没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。
 */

#include <iostream>

using namespace std;

class Base {
public:
	virtual void f(float x){ cout << "Base::f(float) " << x << endl; }
	void g(float x){ cout << "Base::g(float) " << x << endl; }
	void h(float x){ cout << "Base::h(float) " << x << endl; }
	void m(float x){ cout << "Base::m(float) " << x << endl; }
};

class Derived : public Base {
public:
	virtual void f(float x){ cout << "Derived::f(float) " << x << endl; }
	void g(int x){ cout << "Derived::g(int) " << x << endl; }
	void h(float x){ cout << "Derived::h(float) " << x << endl; }
	void m(int x){ cout << "Derived::m(int) " << x << endl; }
};

int main(void) {
	Derived d;
	Base *pb = &d;
	Derived *pd = &d;
	// Good : behavior depends solely on type of the object
	pb->f(3.14f); // Derived::f(float) 3.14
	pd->f(3.14f); // Derived::f(float) 3.14
	// Bad : behavior depends on type of the pointer
	pb->g(3.14f); // Base::g(float) 3.14
	pd->g(3.14f); // Derived::g(int) 3 (surprise!)
	// Bad : behavior depends on type of the pointer
	pb->h(3.14f); // Base::h(float) 3.14 (surprise!)
	pd->h(3.14f); // Derived::h(float) 3.14
	// Bad : behavior depends on type of the pointer
	pb->m(3.14f); // Base::m(float) 3.14
	pd->m(3.14f); // Derived::m(int) 3 (surprise!)
}

输出结果

注意在用不同类型的类指针调用g(),h(),m()三个函数时,程序实际的行为。

这样会带来一个问题,就是在继承后,重载了父类的一个函数,或在定义了一个函数,那么父类中与这个函数同名的函数都会被隐藏,子类的对象无法调用这些函数。

下面是一个例子

诡异的同名函数 (same_name1.cc) download
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
// 测试隐藏规则:
// 子类重载同名函数, 无论是否声明为虚函数,父类的所有同名函数将会被隐藏 

#include <iostream>

using namespace std;

class Base{
public:
	virtual void f(float x){ cout << "Base::f(float) " << x << endl; }
	virtual void f(float x, float y){ cout << "Base::f(float, float) " << x << endl; }
};

class Derived : public Base{
public:
	virtual void f(float x){ cout << "Derived::f(float) " << x << endl; }
};

int main(void){
	Derived d;
	Base *pb = &d;
	Derived *pd = &d;
	pb->f(3.14f, 3.14f);
	pd->f(3.14f, 3.14f); // Compile error
}

在编译的时候会报这样的错误

总结

  1. 虚函数是通过虚表实现运行时绑定。虚表存放在对象的起始位置。子类继承父类时,会将所有的虚表就继承下来,所有子类可能有多个虚表指针。如果子类重载了父类的某个虚函数,那么子类将重写所有虚表中该函数的地址,子类将自己的虚函数添加到第一个虚表的最后。
  2. 构造函数不能声明为虚函数,析构函数可以声明为虚函数,当类包含是虚函数的成员函数时,析构函数必须声明为虚函数
  3. 当子类和父类有同名函数时,父类的所有与这个函数同名的函数都将被隐藏,子类的对象无法访问到。

参考

本文参考了以下文章:

  1. 陈皓.C++ 虚函数表解析
  2. C++ Virtual详解
  1. 系统为ubuntu 12.04 server 64bit, 编译器为gcc 4.6.3

  2. 程序运行在64位机器上,所以使用long进行强制转换获取指针,如果在32位服务器上,就应该用int。

Comments