虚函数与运行多态
多态:
多态按字面的意思就是多种形态。当类之间存在层次结构,并且类之间是通过继承关联时,就会用到多态。C++ 多态意味着调用成员函数时,会根据调用函数的对象的类型来执行不同的函数。
先看最简单的情况,也就是最普通形式的继承,且父类和子类的方法都是一般成员方法:
1 |
|
输出结果如下:1
2
3
4
5
6
7
8
9
10
11
12Car constructor
Benz constructor
1 //内部没有成员变量,因此只有一个字节的空间
car start
car stop
Car constructor
Baoma constructor
4 //函数是不占用内存的,baoma中有一个int类型.所以 size of为4
car start
car stop
Car destructor
Car destructor
首先,为什么Benz类内部明明没有任何变量,还具有一个字节的 size ?这是因为C++编译器不允许对象为零长度(试想一个长度为0的对象在内存中怎么存放?怎么获取它的地址?)。为了避免这种情况,C++强制给这种类插入一个缺省成员,长度为1。如果有自定义的变量,那么变量将取代这个缺省成员。
其次,Benz和Baoma都是继承自Car类,根据 里氏替换原则 ,父类能够出现的地方,那么子类也一定能出现。依赖抽象而不去依赖具体,在上述的函数调用过程中,我们传进去的是benz和baoma指针.但是在调用函数的时候,它并没有去调用子类的方法,这也就是一般成员函数的局限性,就是在编译的时候,一般性的函数已经被静态的编译进去,所以在调用的时候不能去选择动态调用.
另外, 这里的指针都是基类指针, 如果函数不是 virtual 的,则进行的是静态绑定,即在编译期间就决定了其调用的函数. 所以, 在删除时, 只会调用基类的析构函数, 而不会调用子类的析构函数. 如果将指针的类型声明为子类类型, 那么调用顺序是先调用子类的析构函数, 再调用基类的析构函数.
里氏替换原则:派生类(子类)对象可以在程式中代替其基类(超类)对象
加入vitural关键字修饰的函数,将父类函数变为虚函数,看看变化:
在某个类中的某个函数之间加了 virtual
关键字以后, 该函数就会变成虚函数, 同时, 该类的所有派生类都会默认将此函数当做是虚函数, 无需显式使用 virtual
关键字注明. 派生类经常(但不总是)覆盖它要继承的虚函数, 如果派生类没有覆盖基类中的某个虚函数, 则派生类会自动继承基类版本的虚函数作为自己的虚函数.
1 | //和上面几乎一样,都是一般的成员方法,只不过加上了virtual关键字 |
输出结果如下:
1 |
|
从上面的输出结果中可以看到,加入了虚函数之后,调用不同指针对象指定函数的时候,这个时候都是去自动调用当前对象类中的具体函数形式,而不是像一般函数的调用一样,只是去调用父类的函数.这就是virtural关键字的作用,因为一般函数调用编译的时候是静态编译的时候就已经决定了,加入了virtural的函数,一个类中函数的调用并不是在编译的时候决定下来的,而是在运行时候被确定的,这也就是虚函数.
虚函数就是由于在编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被 为“虚”函数。 虚函数只能借助于指针或者引用来达到多态的效果, 直接声明的类对象无法达到多态目的。
这里可以看到, 指针 size 不再是1和4, 而是变成了8和16, 这是因为虚函数需要一张虚函数表来维护, 因此会使类的 size 改变, 具体原理可看下一节.
另外, 注意到这里在删除指针时, 由于指针的类型是基类, 因此同样只会调用基类的析构函数.
总结: 虚函数的调用取决于指向或者引用的对象的类型,而不是指针或者引用自身的类型。
注意:
- C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。
- 对C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数
- 带有虚函数的对象自身确实插入了一些指针信息,而且这个指针信息并不随着虚函数的增加而增大,这也就是为什么上述增加了虚函数后,出现了 size 变大的现象
虚函数表与虚函数表指针
“多态” 的关键在于通过基类指针或者引用调用一个虚函数时, 无法在编译过程中确定到底调用的是基类还是派生类的函数, 只有在运行阶段才能确定, 这种机制是如何实现的呢?
C++ 中虚函数这种多态的性质是通过虚函数表指针和一张虚函数表来实现的:
- vptr(虚函数表指针, 占 4 个字节): 一个指向虚函数表的指针,每个对象 都会拥有这样的一个指针. C++ 的编译器将虚函数表指针存放在对象实例中最前面的位置, 这是为了保证取得虚函数表时具有最高的性能.
- vtable(虚函数表): 每一个含有虚函数的类都会维护一个虚函数表,里面按照声明顺序记录了该类的全部虚函数的地址
在进行虚函数的调用时, 编译器会根据基类指针所指向(或者基类引用所引用)的对象中的虚函数表指针找到该类的虚函数表, 然后在虚函数表中查找要调用的虚函数的地址, 可以简单的认为虚函数表是以函数名作为索引来查找的, 不过实际上会使用更高效的查找方法. 最后, 根据找到的虚函数的地址进行函数调用.
上面简单介绍了虚函数表的作用, 下面我们详细讨论一下虚函数表的注意事项.
- 每个包含了虚函数的类都包含一个虚函数表.
- 当基类包含虚函数时, 继承它的派生类也会自动维护一张虚函数表. 当一个类(A)继承另一个类(B)时, 类A会继承类B的函数的调用权, 所以如果一个基类包含了虚函数, 那么其继承类也可调用这些虚函数, 换句话说, 如果一个类继承了包含虚函数的基类, 那么这个类也拥有自己的虚表.
- 虚表是一个指针数组, 其元素是指向虚函数的指针.
- 虚表是对应类而言的, 而不是对应某个具体的对象, 一个类只需要一个虚表即可, 同一个类的所有对象都共享同一张虚函数表.
- 虚表指针时对应与对象而言的, 每个具体的对象都会持有一个虚表指针, 它们都指向了该类的虚函数表. 为了使用虚表, 类或对象内部都会包含一个虚表指针, 用来指向自己所使用的虚表. 为了让每个包含虚表的类的对象都拥有一个虚表指针, 编译器会在类中添加一个指针
*__vptr
来指向自己的虚表, 这样, 类的对象在创建时便拥有了这个指针, 且这个指针的值会自动被设置为指向类的虚表.
假设类A是虚基类, 类B继承类A, 类C又继承类B, 则它们的虚表关系如下图所示:
可以看到, 类A, B, C 中都会有一个专门的指针来指向虚表(一般都处于类或对象实例的最前面, 主要是为了提高取得函数表的速度), 并且指向的不是同一个虚表, 而是每个类都有自己的虚表, 只不过这些虚表最终指向的虚函数有可能相同(也有可能不同).
接下来看看下面这个简单的例子:1
2
3
4
5
6
7
8
9
10
11
12
13class A
{
public:
virtual void fun();
};
class B
{
public:
void fun();
};
size of(A) > size of(B) // true,因为A比B多了一个虚函数表指针
下面再来看看刚刚那个加薪的例子,其多态调用的形式如下图:
通常情况下,编译器在下面两处地方添加额外的代码来维护和使用虚函数表指针:
- 在每个构造函数中。此处添加的代码会设置被创建对象的虚函数表指针指向对应类的虚函数表
- 在每次进行多态函数调用时。 无论何时调用了多态函数,编译器都会首先查找vptr指向的地址(也就是指向对象对应的类的虚函数表),一旦找到后,就会使用该地址内存储的函数(而不是基类的函数)。
单继承时的虚函数表
无虚函数覆盖:
维护了一个虚函数表, 并且表中函数指针指向基类的各个虚函数. 子类中新添加的虚函数会放到虚函数表的后面
虚函数覆盖:
对于同签名的函数, 会用子类的虚函数覆盖掉基类的同签名虚函数, 同样也只是维护一个虚函数表.
多重继承时的虚函数表
多重继承会有多个虚函数表, 几重继承, 就会有几个虚函数表. 这些表按照派生的顺序依次排列, 如果子类改写了父类的虚函数, 那么就会用子类自己的虚函数覆盖虚函数表的相应位置, 如果子类有新的虚函数, 那么就会添加到第一个函数表的末尾.
假设类B继承了包含虚函数的类A1和类A2, 则其虚函数表的情况如下所示:
无虚函数覆盖:
继承了几个基类, 就会维护几张虚函数表(按照继承顺序在实例最开始排列), 并且表中函数指针会指向其基类的各个虚函数, 子类中新添加的虚函数会放到第一个虚函数表的后面
虚函数覆盖:
会用子类中的同签名虚函数同时覆盖多个基类中的同签名虚函数. 其余与无覆盖时的情况相同.
多重继承时的类型转换:
在多重继承时, 用基类指针指向派生类对象, 派生类对象中新添加的虚函数会被添加到 第一个虚函数表的后面, 因此,
为什么虚函数表指针的类型为void *
因为对于虚函数表来说, 一个类中的所有虚函数都会放到这个表中, 但是不同的虚函数对应的函数指针类型各不相同, 所以这个表的类型也就无法确定.
为什么虚函数表前要加const
因为虚函数表是在编译时, 由编译器自动生成的, 并且不会发生改变, 当有多个B类的实例时, 每个实例都会维护一个虚函数表指针, 但是这些指针指向的都是同一个虚函数表, 该表是一个常量表.
类的 size 与虚函数
从上面一节的代码示例中我们已经发现, 在 C++ 中, 普通函数只是一种表示, 其本身并不会占有任何内存, 而如果类中没有任何变量或者虚函数时, 类的 size 不会为1, 而是会自动插入一个字节, 并且在类的 size 大于1的时候, 该字节会被覆盖掉. 下面我们就从头开始讨论一下在 C++ 中是如何计算类的 size 的.
类的 size 为零
1 |
|
上面的输出为:1
2
3
4
5
6Car constructor
Car constructor
1
1
8
Car destructor
我们知道, 在 C++ 中, 普通函数只是在名义上存在于类中, 实际上函数的 size 并不会包括在类中, 因此, 这个类的 size 应该为0, 但是对于 size 为0的类我们无法存储其地址, 因此会额外赋予一个字节的 size , 注意当类的 size 不为0时, 这个字节就会被覆盖.
类中字节非对齐
1 |
|
上面代码的输出为:1
2
3
4
5
6Car constructor
Car constructor
1
1
8
Car destructor
我们逐行来分析一下, 首先, 前两行代表调用了类的构造函数, 分别对应的对象指针和对象, 接下来, 我们求得类Car
的 size 为1个字节, 这是因为在类中有一个char
类型的变量, 对象car
的 size 也为一个字节, 与类的 size 保持一致, 而对象指针的 size 为8个字节 , 因为对象指针的 size 仅与当前平台的编译器有关, 与类的 size 无关, 无论类的 size 是多少, 其对象指针的值都为8.
再来看一下继承时的情况: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
using namespace::std;
class Base1{
public:
Base1(){
cout<<"Base1 constructor"<<endl;
}
~Base1(){
cout<<"Base1 destructor"<<endl;
}
void start() {
cout<<"Base1 start"<<endl;
}
void stop() {
cout<<"Base1 stop"<<endl;
}
private:
char c;
};
class Base2{
public:
Base2(){
cout<<"Base2 constructor"<<endl;
}
~Base2(){
cout<<"Base2 destructor"<<endl;
}
void start() {
cout<<"Base2 start"<<endl;
}
void stop() {
cout<<"Base2 stop"<<endl;
}
private:
char c;
};
class Derived:public Base1, public Base2{
public:
Derived(){
cout<<"Derived constructor"<<endl;
}
~Derived(){
cout<<"Derived destructor"<<endl;
}
void start() {
cout<<"Derived start"<<endl;
}
void stop() {
cout<<"Derived stop"<<endl;
}
private:
char c;
};
int main(int argc,char *argv[]){
Base1 *p = new Derived();
Derived d;
cout<< size of(Derived)<<endl; // 类的 size
cout<< size of(d)<<endl; // 对象的 size
cout<< size of(p)<<endl; // 对象指针的 size
return 0;
}
输出为:1
2
3
4
5
6
7
8
9
10
11
12Base1 constructor
Base2 constructor
Derived constructor
Base1 constructor
Base2 constructor
Derived constructor
3
3
8
Derived destructor
Base2 destructor
Base1 destructor
可以看到总共的 size 为两个基类所占空间和子类所占空间之和(同名不会冲突, 可以通过命名空间区分).
这里只调用了一次析构函数, 因为new
对应的内存必须要delete
才能释放.
上面的两段代码并没有进行字节对齐, 原因是因为之后更大的变量出现, 所以可以用当前的 size 而无需进行对齐
对齐方式: 变量存放的起始地址相对于结构的起始地址的偏移量必须为某个数值的倍数. 同时会根据当前结构中的元素的最大字节数将总的 size 补成最大字节数的倍数.
Char
偏移量必须为sizeof(char)``即1的倍数
int偏移量必须为
sizeof(int)即4的倍数
float偏移量必须为
sizeof(float)即4的倍数
double偏移量必须为
sizeof(double)即8的倍数
Short偏移量必须为
sizeof(short)即2的倍数
虚函数表指针
偏移量必须为
sizeof(vptr)`, 即8的倍数(64位系统)
1 |
|
代码与上面的代码基本相同, 只不过多了4个int类型的变量, 输出结果如下:1
2
3
4
5
6Car constructor
Car constructor
20
20
8
Car destructor
按理说, 类中的 size 应为: $4\times 4 + 1 = 17$ 字节, 但是这里却为20字节, 这是因为 在C++中, 会对类进行字节对齐, 这点和 struct
有些相似, 对齐后, 会使类的 size 变成4个整数倍. 注意这里对象指针的 size 依然为8个字节, 与类的 size 无关. 最后, 只调用了一次析构函数, 这是因为用 new
申请的内存不会自动释放, 必须使用 delete
手动释放才可以.
虚函数对类 size 的影响
注意, 对于不同的系统和编译器, sizeof的计算结果可能不一样, 简单来说, 虚函数表指针在32位系统中占4个字节, 在64位系统中占8个字节
1 |
|
输出结果如下:1
2
3
4
5
6
7
8
9
10
11
12Base1 constructor
Base2 constructor
Derived constructor
Base1 constructor
Base2 constructor
Derived constructor
16
16
8
Derived destructor
Base2 destructor
Base1 destructor
从上面的代码中可以看出, Base1
类中具有两个虚函数, Base2
类中具有一个虚函数, 因为对于同一个类来说, 只会维护一个虚函数表指针, 所以不论类中的虚函数的个数为多少个, 都只会产生一个虚函数表指针, 同时这里由于子类继承了两个虚基类, 所以会有两个虚函数表, 也就是要维护两个虚函数表指针, 因此其类的 size 为 $8+8=16$. 而对于对象指针p
来说, size 与类无关. 最后, 析构函数也只调用了一次, 因为没有用delete
手动释放new
对应的内存.
如果使用了虚函数, 则类的对齐方式会发生变化, 不再是与4的倍数对齐, 而是与8的倍数对齐, 如下所示, 在每个类中增加了char
类型的变量, 按理说增加的总字节数应该为3, 但是由于字节对齐, 类的 size 会变成8个倍数:
1 |
|
虚函数控制下的运行多态有什么用?
假如我们在公司的人事管理系统中定义了一个基类 Employee(员工),里面包含了升职、加薪等虚函数。 由于Manager(管理人员)和Engineer(工程人员)的加薪和晋升流程是不一样的,因此我们需要实现一些继承类并重写这些函数。
有了上面这些以后,到了一年一度每个人都要加薪的时候,我们只需要一个简单的操作就可以完成,如下所示1
2
3
4
5
6void globalRaiseSalary(Employee *emp[], int n){
for (int i = 0; i < n; i++)
emp[i]->raiseSalary(); // 会根据emp具体指向的对象类型,来选择合适的函数行为
// Polymorphic Call: Calls raiseSalary()
// according to the actual object, not according to the type of pointer
}
虚函数使得我们可以创建一个统一的基类指针,并且调用不同子类的函数而无需知道子类对象究竟是什么
虚函数中的默认参数
先看下面的代码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
using namespace std;
class Base
{
public:
virtual void fun ( int x = 0 )
{
cout << "Base::fun(), x = " << x << endl;
}
};
class Derived : public Base
{
public:
// 这里的virtual关键字可以省略,因为只要基类里面被声明为虚函数,那么在子类中默认都是虚的
virtual void fun ( int x )// 或者定义为 virtual void fun ( int x = 10)
{
cout << "Derived::fun(), x = " << x << endl;
}
};
int main()
{
Derived d1;
Base *bp = &d1;
bp->fun();
return 0;
}
上面的代码输出始终为:1
Derived::fun(), x = 0
解释:
- 首先,参数的默认值是不算做函数签名的,因此,即使基类有默认值,子类没有,这两个函数的函数签名仍然被认为是相同的,所以在调用
bp->fun();
,仍然调用了子类的fun
函数,但是因为没有给出x
的值,所以采用了基类函数给出的默认值0
. - 当基类给出默认值0,子类给出默认值10时,返回结果仍然是默认值
0
,这是因为,参数的默认值是静态绑定的,而虚函数是动态绑定的,因此, 默认参数的使用需要看指针或者引用本身的类型,而不是指向对象的类型。
-
小结:根据上面的分析,在虚函数中最好不要使用默认参数,否则很容易引起误会!
静态函数可以被声明为虚函数吗
静态函数不可以声明为虚函数,同时也不能被const和volatile关键字修饰。如下面的声明都是错误的:1
2
3virtual static void fun(){}
static void fun() const {} // 函数不能被const修饰,但是返回值可以
原因主要有两个方面:
- static成员函数不属于任何类对象或类实例,所以即使给此函数加上virtual也是没有意义的
- 虚函数依靠vptr和vtable来处理,vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,静态成员函数没有this指针,所以无法访问vptr。
构造函数可以为虚函数吗
构造函数不可以声明为虚函数。同时除了inline
之外,构造函数不允许使用其他任何关键字,原因如下:
- 尽管虚函数表vtable是在编译阶段就已经建立的,但指向虚函数表的指针vptr是在运行阶段实例化对象时才产生的。 如果类含有虚函数,编译器会在构造函数中添加代码来创建vptr。 问题来了,如果构造函数是虚的,那么它需要vptr来访问vtable,可这个时候vptr还没产生。 因此,构造函数不可以为虚函数。
- 我们之所以使用虚函数,是因为需要在信息不全的情况下进行多态运行。而构造函数是用来初始化实例的,实例的类型必须是明确的。 因此,构造函数没有必要被声明为虚函数。
析构函数可以为虚函数吗
析构函数可以声明为虚函数。如果我们需要删除一个指向派生类的基类指针时,应该把析构函数声明为虚函数。事实上,只要一个类有可能会被其他类所继承,就应该声明虚析构函数(哪怕该析构函数不执行任何操作)。原因是因为基类指针被删除后, 不会调用派生类的析构函数, 只会调用基类的析构函数, 因此, 需要将析构函数声明为虚的, 来使得进行 delete
时, 调用子类的虚构函数:
1 |
|
以上代码输出:1
2
3Constructing base
Constructing derived
Destructing base
可见,继承类的析构函数没有被调用,delete时只根据指针类型调用了基类的析构函数。 正确的操作是,基类和继承类的析构函数都应该被调用,解决方法是将基类的析构函数声明为虚函数。 如下所示: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
using namespace std;
class base {
public:
base()
{ cout<<"Constructing base \n"; }
virtual ~base()
//~base()
{ cout<<"Destructing base \n"; }
};
class derived: public base {
public:
derived()
{ cout<<"Constructing derived \n"; }
~derived()
{ cout<<"Destructing derived \n"; }
};
int main(void)
{
derived *d = new derived();
base *b = d;
delete b;
return 0;
}
输出结果为:1
2
3
4Constructing base
Constructing derived
Destructing derived
Destructing base
虚函数可以为私有函数吗
虚函数可以被私有化,但有一些细节需要注意1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using namespace std;
class Derived;
class Base {
private:
virtual void fun() { cout << "Base Fun"; }
friend int main();
};
class Derived: public Base {
public:
void fun() { cout << "Derived Fun"; }
};
int main()
{
Base *ptr = new Derived;
ptr->fun();
return 0;
}
输出结果为:1
Derived fun()
- 基类指针指向继承类对象,则调用继承类对象的函数
- int main()必须声明为Base类的友元,否则编译失败。编译器报错:ptr无法访问私有函数。当然,把基类声明为public,继承类为private,该问题就不存在了。
虚函数可以被内联吗
通常类成员函数都会被编译器考虑是否进行内联。但通过基类指针或者引用调用的虚函数必定不能被内联。当然,实体对象调用虚函数或者静态调用时可以被内联,虚析构函数的静态调用也一定会被内联展开。
纯虚函数与抽象类
纯虚函数:在基类中只声明不定义的虚函数,同时要求任何派生类都要实现该虚函数。在基类中实现纯虚函数的方法是在函数原型后加“=0”。
抽象类:含有纯虚函数的类为抽象类
纯虚函数的特点以及用途总结如下:
- 如果不在继承类中实现该函数,则继承类仍为抽象类;
- 派生类仅仅只是继承纯虚函数的接口,因此使用纯虚函数可以规范接口形式
- 抽象类无法实例化对象
- 抽象类可以有构造函数
- 析构函数被声明为纯虚函数是一种特例,允许其有具体实现。(有些时候,想要使一个类称为抽象类,但刚好有没有任何合适的纯虚函数,最简单的方法就是声明一个纯虚的析构函数)
不要重写非虚函数
在 Effective C++ 中写到: 不要重写继承来的非虚函数. 因为在子类中重写父类的非虚函数在设计上是矛盾的:
- 一方面, 父类定义了普通的非虚函数, 意味着该函数是父类的不变式, 子类如果重写了父类的不变式, 那么父类和子类的关系就不再是”is-a”关系.
- 另一方面, 如果父类的非虚函数在子类中提供了不同的实现, 那么该函数就不应该是父类的不变式, 因此应该将该函数声明为虚函数.