啃书《C++ Primer Plus》 面向对象部分 虚机制——虚函数表、虚指针

长文干货预警


虚机制,一个听起来不好惹的角色,却是C++面向对象部分的精髓。不得不承认的是,这部分的内容有些多,也不太好理解。但同样不得不承认的是,虚机制在面向对象部分准确说是多态特性发挥着举足轻重的作用。可以说没有了虚机制,C++的面向对象就没有了灵魂。

因此,这一篇,我们就来整理有关虚机制的内容。本文的内容思维导图如下:
在这里插入图片描述
有关静态编联和动态编联的内容,可以参考另一篇博文:

啃书《C++ Primer Plus》面向对象部分 静态联编与动态联编


虚函数的创建和实现

这虚函数也是函数啊,唯一不同的就是它比较虚~~

因此,只需要在成员函数前面加上“虚”就完事了。

虚函数必须是成员函数

  • 这里,需要强调,虚函数必须是成员函数!!!
    对于这样的定义:
class A{
public:
    static virtual void f(){}	//企图定义一个静态的虚函数
};

编译器会给予报错:
在这里插入图片描述
告知静态的和虚的不允许同时出现。

这样的规定不难理解,只需要认识到,静态函数是仅属于类的,而成员函数“属于”对象。
虚函数提供了一种父类声明,子类实现的机制,这与静态函数仅属于类是冲突的。

虚函数的声明

刚刚说到,在成员函数前加上“虚”的修饰,就可以让它变成虚函数。这个"虚"就是英文单词virtual

class A{
public:
	virtual void f(){}//在A类中定义了一个虚函数f
};

在子类中重写这个虚函数时,则可以省略掉virtual关键字

class A{
public:
	virtual void f(){}//基类中定义了一个虚函数
};
class B: public A{
public:
	void f(){}//派生类中重写虚函数可以省略virtual
};

override与overwrite

这里介绍两个概念:overrideoverwrite

override的意思是重写虚函数,代表着子类给父类的某些虚函数提供一种实现,这是虚函数实现多态最常见的途径,即父类声明函数,子类提供实现。

在刚刚的例子中,B类重新实现了A类的虚函数f的行为就是一种重写。

overwrite译作中文应该叫“覆盖”,(不过由于翻译的五花八门,因此博主还是建议改概念使用英文来记忆比较妥当。)这个概念的并不仅仅出现在虚函数的范围中,在一般成员函数中也有所体现。

具体的说,就是当子类使用了父类中出现过的函数名称时,不论其返回类型、参数列表,是否被const修饰等因素是否相同,子类的该函数会覆盖所有的同名父类函数。

虚函数也不例外,也难逃overwrite的魔爪

#include<iostream>
using namespace std;
class A{
public:
	/*基类定义两个版本的函数f*/
    virtual void f(){cout << "func f in class A" << endl;}				
    virtual void f(int k){cout << "func f in class A and " << k << endl;}
};

class B:public A{
public:
	/*派生类重写了其中一个函数f*/
    virtual void f(){cout << "func f in class B" << endl;}
    //overwrite了另一个版本的函数,在B类作用域中没有带有一个整型参数的f函数
};

int main()
{
    A* pa = new B();
    pa->f();
    pa->f(10);
    
    B pb = new B();
    pb->f();
    pb->f(10);	//这个调用将会报错,B类中没有这个函数
	delete p;
}

虚函数表和虚指针

说了这么多有关虚函数的使用,是时候来介绍虚函数的原理了,书中在介绍动态联编处理虚函数的时候用到了这样的描述:

编译器必须生成能够在程序运行时选择正确虚方法的代码,这称为动态联编(dynamic binding),又称为晚联编(late binding)

这段用来进行动态联编的代码便是虚函数的原理,这其中就包含了虚函数表虚指针

虚函数表

虚函数表叫做Virtual Table,简称V-Table
简单些说,就是放置了一个类中所有虚函数指针的表,是一个函数指针数组

下面来说明各种情况下虚函数表的形态

无继承时

例如对于下面的类:

class A{
public:
	virtual f(){}
	virtual g(){}
};

它的虚函数表大致就长这个样子(专业工具不够,画图来凑):
在这里插入图片描述
在这张虚函数表中,A类所有的虚函数都被按照声明顺序放置在其中。(需要注意的是析构函数,当类中存在虚函数时,析构函数必须为虚的,如果没有给出析构函数,编译器也会默认加入虚的析构函数。有关析构函数,将在文末进一步讨论)

继承但不重写基类虚方法时

例如下面的类:

class B: public A{
public:
	virtual void h(){}
};

它的虚函数表大概就长这个样子:
在这里插入图片描述
作为A类的子类,首先B类的虚函数表会在其前面完完整整地体现A类虚函数表。接着会在后面追加本类新增的虚函数

当继承发生了重写时

对上面例子中的类B进行改动:

class B: public A{
public:
	virtual void h(){}
	virtual void f(){}
};

使其重写基类的函数f,此时,B类的虚函数表将变成:
在这里插入图片描述
这里说明了虚函数表在继承时的另一个行为:
如果子类重写了父类的某些虚函数,在复制父类虚函数表时将会替换那些被子类重写的函数为子类的版本。

虚函数表小结

基于上面三个方面对虚函数表进行总结:

  • 当类成员函数中含有虚函数时,编译器为该类生成虚函数表。表中按照声明顺序存放着所有虚函数的指针
  • 在继承时,派生类会按照顺序拷贝基类的虚函数表并且替换掉其中被重写的函数,随后在后面添加上本类新增的虚函数。

虚指针

虚指针是对象用来记录指向哪一个虚函数表的指针,它存在于每一个有虚成员函数的对象中,指向类虚函数表的首元素。

为了演示虚指针的产生,使用sizeof函数和offsetof函数来查看类对象的大小和类成员的位置

仅有成员
#include<iostream>
using namespace std;
class A{
public:
    int k;
};
int main()
{
    cout << sizeof(A) << endl;
    cout << offsetof(A,k) << endl;
}

在这里插入图片描述
在没加入虚函数时,对象中仅有一个成员,放在首位,整个对象大小为4字节。

有成员函数
#include<iostream>
using namespace std;
class A{
public:
    int k;
    void f(){}//加入普通成员函数
};
int main()
{
    cout << sizeof(A) << endl;
    cout << offsetof(A,k) << endl;
}

在这里插入图片描述
加入普通成员函数,不影响对象大小和成员位置。

包含一个虚函数
#include<iostream>
using namespace std;
class A{
public:
    int k;
    virtual void f(){}//添加一个虚函数
};
int main()
{
    cout << sizeof(A) << endl;
    cout << offsetof(A,k) << endl;
}

在这里插入图片描述
加入一个虚函数后,对象大小增加四字节,首元素不是成员变量。

包含多个虚函数
#include<iostream>
using namespace std;
class A{
public:
    int k;
    virtual void f(){}//添加一个虚函数
    virtual void g(){}//添加另一个虚函数
};
int main()
{
    cout << sizeof(A) << endl;
    cout << offsetof(A,k) << endl;
}

在这里插入图片描述
可以看到当类中的虚函数多于一个时,对象的大小并未进一步变大。

这是因为,添加的虚函数的指针都存在了虚函数表中,而对象中多出的四个字节则用来存储指向虚函数表的虚指针

虚指针和虚函数表小结

在程序运行过程中,如果遇到了动态联编的情况,编译器就会通过使用对象中的虚指针在对应的虚函数表中找到实际应该调用的函数。

类的虚函数表将在这个类被编译或者生成对象时(由编译器决定)生成,而虚指针将在对象创建时放入对象中,指向其对应类的虚函数表(如果这个类有虚函数的话)。

这个过程解决了虚函数调用无法确定调用哪个版本的问题。
在这里插入图片描述
当使用多态时,使用虚函数表和虚指针管理虚函数的调用就如下图所示:
在这里插入图片描述


虚函数与成员函数的访问

说完了虚函数的使用和原理,现在来探究一下对虚函数的访问,来看看在调用虚函数的时候,究竟会使用那些版本。

先给出一个基本的基类和派生类,实现其无参构造和析构函数,添置一个同名成员函数和虚函数:

class A{
public:
    virtual void f(){cout << "func f in class A" << endl;}
    void g(){cout << "func g in class A" << endl;}
    A(){cout << "constructor in class A" << endl;}
    virtual ~A(){cout << "destructor in class A" << endl;}
};
class B:public A
{
public:
    virtual void f(){cout << "func f in class B" << endl;}
    void g(){cout << "func g in class B" << endl;}
    B(){cout << "constructor in class B" << endl;}
    virtual ~B(){cout << "destructor in class B" << endl;}
};

成员函数访问虚函数

使用A类函数g调用函数f

class A{
...
	void g(){cout << "func g in class A" << endl;f()}
...
};
int main()
{
	A *p = new B();
	p->A::g();
	delete p;
}

结果:
在这里插入图片描述

  • 在成员函数中调用虚函数使用动态联编。

虚函数访问成员函数

分别使用A类函数f和B类函数f访问g函数

class A{
...
	virtual void f(){cout << "func f in class A" << endl;g();}
...
};
class B{
...
	virtual void f(){cout << "func f in class B" << endl;g();}
...
};
int main()
{
	A* p = new B();
	p->A::f();	//调用A类f
	p->f();		//调用B类f
	delete p;
}

结果:
在这里插入图片描述

  • 在虚函数中调用成员函数使用静态联编

虚函数访问虚函数

为了不产生有可能出现的递归调用,我们在A类中引入新的虚函数h,在其中调用虚函数f

class A{
...
	virtual void h(){cout << "func h in class A" << endl;f();}
...
};
int main()
{
	A* p = new B();
	p->A::h();
	delete p;
}

结果:
在这里插入图片描述

  • 在虚函数中调用虚函数使用的是动态联编

构造和析构函数访问虚函数

我们在A类和B类构造和析构函数中分别访问虚函数f:

class A{
...
	A(){cout << "constructor in class A" << endl;f();}	//构造函数中访问虚函数f
	virtual ~A(){f();cout << "destructor in class A" << endl;}//析构函数中访问虚函数f
...
};
class B{
...
	B(){cout << "constructor in class A" << endl;f();}	//构造函数中访问虚函数f
	virtual ~B(){f();cout << "destructor in class A" << endl;}//析构函数中访问虚函数f
...
};
int main()
{
	A* p = new B();
	delete p;
}

结果:
在这里插入图片描述

  • 构造和析构函数中调用虚函数采用的是静态联编

虚函数调用小结

  • 成员函数中调用虚函数采用的是动态联编
  • 虚函数中调用成员函数采用的是静态联编
  • 虚函数中调用虚函数采用的动态联编
  • 构造和析构函数中调用虚函数采用的是静态联编

有关析构函数的问题

这里想要进行讨论的是,析构函数是否需要或合适需要为虚的问题。可以从两个角度来看这个问题

虚机制

首先,我们知道一种情况下,析构函数必须为虚——当类中存在虚成员函数时

这是个不需反驳的规定,即使不主动的将析构函数定义为虚的,编译器也会将析构函数当做是虚的放在虚函数表中。

动态内存管理

但是另一种情况往往被大家忽略,就是当基类和派生类涉及到了动态内存管理时,析构函数也有一定的必要别显式的定义为虚的。

具体的说,就是当派生类有可能使用动态内存的时候,基类的析构函数应该定义成为虚的。我们看下面的实例:

#include<iostream>
using namespace std;
class A{
public:
    ~A(){cout << "destructor in class A" << endl;};
};

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

int main()
{
    A *p = new B();
    delete p;
}

基类和派生类各自实现了析构函数,但其都是非虚的。
当程序删除一个指向派生类的基类指针指向的对象时:
在这里插入图片描述
可以看到,程序仅执行了基类的析构函数而并未执行派生类的析构函数。

  • 这在派生类中含有动态成员时会造成意料之外的内存泄漏!!!!

而将基类的析构函数定义为虚的,就可以解决这个问题。
一个虚的基类析构函数,会在释放基类指针时优先调用派生类的析构函数,销毁派生类对象后,再回来销毁基类对象。

#include<iostream>
using namespace std;
class A{
public:
    virtual ~A(){cout << "destructor in class A" << endl;}//虚的基类析构函数
};

class B:public A{
public:
    B():A(){ch = new char[1];ch[0] = '\0';}
    virtual ~B(){       //虚的派生类析构函数
        cout << "destructor in class B" << endl;
        delete[] ch;    //派生类析构函数中释放动态内存
    }
private:
    char * ch;  //派生类使用动态内存
};

int main()
{
    A *p = new B();
    delete p;
}

将刚刚的实例中基类的析构函数变成虚的,再删除指向派生类对象的基类指针时,就会先调用派生类的析构函数对其动态成员进行释放,再调用基类的析构函数。

运行结果:
在这里插入图片描述

析构函数总结一下

有关析构函数何时为虚:

  • 当成员函数中含有虚函数时
  • 当派生类存在使用动态内存的可能性时

致谢:面向对象课程陈老师,十分认真负责,许多内容是他教授给我的。


看完文章,来关注博主一起学习鸭~~~~

啃书系列往期博客

语言基础部分:

面向对象部分:

猜你喜欢

转载自blog.csdn.net/wayne_lee_lwc/article/details/106115766