C++重难点:虚函数与虚继承

  C++是一种面向对象的编程语言,其主要的特点是封装继承多态。其中继承指的是可以将一个类作为基类,并将另一个类继承于它,作为它的派生类。但在多重继承或存在一些复杂的继承关系时,可能会出现一些二义性,通常我们可以用虚函数与虚继承来避免这些问题。

虚函数

  在某基类中声明为virtual并在一个或多个派生类中被重新定义的成员函数称为虚函数。主要用于多继承时,由于成员函数名称相同,调用出现二义性的问题。
  声明格式:virtual 函数返回类型 函数名(参数表){函数体};

问题出现

  假设现在有1个基类,2个派生类都继承该基类,且每个类中都有1个名称相同的成员函数。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

class A
{
public:
void fun1() {cout << "A::fun1" << endl;}
};

class B :public A
{
public:
void fun1() {cout << "B::fun1" << endl;}

};

class C :public A
{
public:
void fun1() {cout << "C::fun1" << endl;}
};

  A是基类,BC是派生类,都继承于A类,且都有1个函数名为fun1的函数,如果我们使用各自的类类型实例化各自的对象,则调用fun1函数完全没问题,如:

1
2
3
4
5
6
7
8
9
10
int main()
{
A a1;
B *b1 = new B;
C c1;
a1.fun1();
b1->fun1();
c1.fun1();
return 0;
}

  程序输出为:

1
2
3
A::fun1
B::fun1
C::fun1

  但当我们想用基类A去声明2个派生类BC的对象时(BC都是继承于A,当然可以用基类去声明派生类),如:

1
2
3
4
5
6
7
8
int main()
{
A *a2 = new B;
a2->fun1();
A *a3 = new C;
a3->fun1();
cout << endl;
}

  此时,我们预期的结果是输出B::fun1 C::fun1但实际输出的结果是A::fun1 A::fun1。这就是因为3个类中的fun1函数名都相同,出现了二义性,都调用了基类的fun1函数。

解决办法

  解决的办法就是在基类中的fun1成员函数最前面的修饰符中加上虚函数的关键字virtual(当然派生类的成员函数前面也可以加上),这样fun1就是一个虚函数,类在调用的时候则会根据实际情况调用。如:

1
2
3
4
5
6
7
8
9
10
11
virtual void fun1() {cout << "A::fun1" << endl;}

int main()
{
A *a2 = new B;
a2->fun1();
A *a3 = new C;
a3->fun1();
cout << endl;
return 0;
}

  程序输出为:

1
2
B::fun1
C::fun1

应用场景

  当然,我们可能会觉得这样不是更复杂么?直接声明各自的对象不就可以了么?如果是那样的话,C++就体现不会多态的特性了。比如,我们声明一个对象数组,该数组中的每个元素都是一个对象,但是数组的数据格式必须统一,不能既是A的对象又是B的对象,那这样我们就不能调用各自类中的函数,那么也就体现不出C++多态的特性了。
  但我们可以这样做,声明一个指向基类A的对象数组,即数组里面都是指向A类的指针,然后通过加入虚函数特性,这样在调用各自的成员函数时,就不会出现二义性问题,数组的数据格式也是一样的。这也反映了多态的思想。如:

1
2
3
4
5
6
7
8
9
10
int main()
{
A *a[2];
a[0] = new B;
a[1] = new C;

a[0]->fun1();
a[1]->fun1();
return 0;
}

  程序结果:

1
2
B::fun1
C::fun1

基本原理——动态联编

  由于C++的函数可以重载,再加上指针和引用,使得程序在调用函数时,特别是多个重名的函数时,使用哪个可执行的代码块是一个非常复杂的问题。
  将源代码中的函数调用解释为执行特定的函数代码块称为函数名联编。C++一共有2种方式,一种是静态联编,即编译器可以在编译过程中完成联编,另一种是动态联编,即由于编译器不知道该选择哪种类型的对象,必须生成能够在程序运行时选择正确的虚方法的代码。
  动态联编主要与指针和引用的调用方法有关。C++不允许将一种类型的地址赋值给另一种类型的指针,比如

1
2
double x = 1.2;
int *p = &x; long &r = x;

  但是指向基类的引用或指针可以引用派生类对象,虽然基类和派生类并不是同一种数据类型(类也是一种数据类型,即用户自定义数据类型),但派生类是由基类继承而来,所以这种引用被称为向上强制转换。但向下是不可以的。
   向上强制转换使基类指针或引用可以指向基类对象或派生类对象,因此需要动态联编,C++使用虚成员函数来满足这种要求。

  例如在上例中,如果fun1函数没有声明为虚的,当利用指针创建A *a2 = new B;A *a3 = new C;对象并调用fun1()函数时,a2a3将根据指针类型A *来调用A::fun1(),指针类型在编译时已知,因此编译器对非虚方法使用静态联编。
  但是,当fun1函数声明为虚函数时,a2a3将根据对象类型来确定,其中a2B类,a3C类,由此可见,编译器生成的代码是在程序执行时才根据对象类型将fun1关联到B::fun1()C::fun1(),所以编译器对虚方法使用的是动态联编。

  在大多数情况下,动态联编很好,因为程序能够选择为特定类型设计的方法,但是静态联编的效率更高,因为动态联编需要额外的内存开销(见深层原理分析),所以一般我们可以这样设计:如果要在派生类中重新定义基类的方法,则将它设置为虚方法,否则设置为非虚方法。

深层原理——虚函数表

  编译器处理虚函数的方法是:给每个对象添加一个隐藏成员,该隐藏成员中保存了一个指向函数地址数组的指针,这种数组称为虚函数表。虚函数表中存储了为类对象进行声明的虚函数的地址。
  虚函数表的变化:如下图所示,类A是基类,类B是派生类,每个对象都有1个针对虚函数的虚函数表,其中基类A有2个虚函数,地址分别为40646400,类B有3个虚函数,其中第一个虚函数是继承于基类A的并且未重新定义,则类B的虚函数表直接将基类A对应的虚函数地址复制下来;第二个虚函数也是继承于基类A的,但是已经重新定义了,则会产生一个新的虚函数地址;第三个虚函数是类B本身的,所以该虚函数地址也是新的。

  每个类只有1个虚函数表,每次只需要在表中添加1个地址,只是表的大小不同而已。
  当调用虚函数,首先会找到该虚函数表(该表也是有地址的),然后在表中找到相应的函数地址,最后根据地址调用函数。所以这也就是为什么虚函数需要额外的开销。因为首先要占用一定的存储空间来存放虚函数地址表,其次根据在表中寻找合适的函数地址也需要一定的运行时间。

注意事项

  1. 构造函数
    构造函数不能是虚函数。因为在创建派类对象时,将调用派生类的构造函数,而不是基类的构造函数。

  2. 析构函数
    析构函数应该是虚函数,除非不用做基类。

    1
    2
    A *p = new B;
    delete p;

  在上例中,当delete对象时,如果不是虚函数,将调用基类A的析构函数,这将释放基类指向的内存,但不会释放派生类的内存,但如是虚函数,则会先释放派生类的内存,在释放基类的内存。所以通常给基类提供一个虚析构函数

  1. 友元
    友元不能是虚函数,因为友元不是类成员,只有成员才能是虚函数。

虚继承

  在继承定义中包含了virtual关键字的继承关系被称为虚继承,在虚继承体系中的通过virtual继承而来的基类被称为虚基类。主要用于多重继承(如菱形继承)时,函数不知归属于哪个类的问题。
  声明格式:class 派生类类名: virtual [继承方式] 基类类名

问题出现

  假设现在有一种复杂的多重继承方式,如有1个基类A,2个派生类BC都继承于基类A,派生类D又继承于BC。如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;

class A
{
public:
void fun1() {cout << "A::fun1" << endl;}
};

class B :public A
{

};

class C :public A
{

};

class D :public B, public C
{
};

  当我们利用派生类D去声明一个对象,并调用fun1函数时,会出现问题。

1
2
3
4
5
6
int main()
{
D d;
d.fun1();
return 0;
}

  程序会报错,即error C2385: 对“fun1”的访问不明确,因为派生类BC都继承于A,所以派生类D中会有2份fun1函数,这样编译器就不知道该选择哪个函数了。

解决办法

  有一种解决办法就是将fun1函数在派生类D重写或者调用时指明用哪个类的fun1,如D.B::fun1();,但这种并不是最好的解决办法,因为这样会有2个副本,占用额外的内存空间。
  还有一种比较好的解决办法是将继承方式声明为虚继承。

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

class B :virtual public A
{

};

class C :virtual public A
{

};

class D :public B, public C
{
};

  publicvirtual的位置无所谓。这样的话,继承关系不变,但它们只会保留一个副本(这个副本既不来自于B,也不来自于C,是从A中单独拷贝出来的),在调用的时候也不会产生错误。

实现原理

  为了更好的分析其实现原理,我们可以调用visual studio的内存布局管理。
  在解决方案管理器中选择.cpp文件,然后右击选择属性,在打开的窗口中选择命令行,然后在其他选项中输入查看内存布局的命令:

1
2
/d1 reportSingleClassLayout[className]  //查看单个类
/d1 reportAllClassLayout //查看所有类


  确认后,按下F7即可查看内存布局情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A
{
public:
int a;
};

class B : public A
{
public:
int b;
};

class C : public A
{
public:
int c;
};

class D : public B, public C
{
public:
int d;
};

  BC声明为普通继承时,D的内存布局:

  左边的数字表示类中成员在类中排列的起始地址。由图易知,类D一共有20个字节,1个int占4个,一共有5个,为什么有5个int?因为类Aint a在类B和类C中都复制了一份,所以在调用的时候当然不知道该调用哪一个了。
  我们再将BC声明为虚继承,查看D的内存布局:

  可以看出,和之前的内存分布还是很不一样的,现在大概分成了3块,两块是BC的数据加上一个vbptr的指针,该指针是虚基类表指针,指向一个虚表(和上文提到的虚函数表类似),表的内容在下面有显示,第二项表示vbptr到共有基类元素之间的偏移量,比如类B中的vbptr指向了虚表D::$vbtable@B@,可以看出,公共基类A的成员变量a距离类B开始处的位移为20(为什么一个是20一个是12查了很多资料也没搞明白,还望有会的大佬留言指教),这样根据这个虚表就可以找到基类中的数据了。
  还有一块是基类A中的数据,这也就是为什么利用虚继承只会出现一个副本。

纯虚函数

  除了虚函数和虚继承,关于virtual关键字还有一种用法:纯虚函数。一般用于声明一个函数但不实现它,让派生类去实现。
  声明格式:virtual 函数返回类型 函数名(参数表)=0;
  至少有1个虚函数是纯虚函数的基类称为抽象类。抽象类不可实例化,相当于一个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;

class A
{
public:
virtual void fun1() = 0;
};

int main()
{
A a;
return 0;
}

  程序会报错:error C2259: “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
28
29
#include <iostream>
using namespace std;

class A
{
public:
virtual void fun1() = 0;
};

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

class C :virtual public A
{
public:
void fun1() {cout << "C::fun1()" << endl;}
};

int main()
{
B b;
C *c = new C;
b.fun1();
c->fun1();
return 0;
}

  程序结果:

B::fun1()
C::fun1()

总结

  从一个大佬的博客上拷贝下来的,有些地方仍然不是太理解(学无止境啊)。

虚基类

1. 一个类可以在一个类族中既被用作虚基类,也被用作非虚基类。   
2.在派生类的对象中,同名的虚基类只产生一个虚基类子对象,而某个非虚基类产生各自的子对象。   
3.虚基类子对象是由最派生类的构造函数通过调用虚基类的构造函数进行初始化的。   
4.最派生类是指在继承结构中建立对象时所指定的类。   
5.派生类的构造函数的成员初始化列表中必须列出对虚基类构造函数的调用;如果未列出,则表示使用该虚基类的缺省构造函数。   
6.从虚基类直接或间接派生的派生类中的构造函数的成员初始化列表中都要列出对虚基类构造函数的调用。但只有用于建立对象的最派生 类的构造函数调用虚基类的构造函数,而该派生类的所有基类中列出的对虚基类的构造函数的调用在执行中被忽略,从而保证对虚基类子对象 只初始化一次。   
7.在一个成员初始化列表中同时出现对虚基类和非虚基类构造函数的调用时,虚基类的构造函数先于非虚基类的构造函数执行。   

虚函数

1.虚函数是非静态的、非内联的成员函数,而不能是友元函数,但虚函数可以在另一个类中被声明为友元函数。   
2.虚函数声明只能出现在类定义的函数原型声明中,而不能在成员函数的函数体实现的时候声明。   
3.一个虚函数无论被公有继承多少次,它仍然保持其虚函数的特性。    
4.若类中一个成员函数被说明为虚函数,则该成员函数在派生类中可能有不同的实现。当使用该成员函数操作指针或引用所标识的对象时 ,对该成员函数调用可采用动态联编。    
5.定义了虚函数后,程序中声明的指向基类的指针就可以指向其派生类。在执行过程中,该函数可以不断改变它所指向的对象,调用不同 版本的成员函数,而且这些动作都是在运行时动态实现的。虚函数充分体现了面向对象程序设计的动态多态性。 纯虚函数 版本的成员函数,而且这些动作都是在运行时动态实现的。虚函数充分体现了面向对象程序设计的动态多态性。  

纯虚函数

1.当在基类中不能为虚函数给出一个有意义的实现时,可以将其声明为纯虚函数,其实现留待派生类完成。   
2.纯虚函数的作用是为派生类提供一个一致的接口。   
3.纯虚函数不能实化化,但可以声明指针。  
谢谢老板!