目录
参考:
https://blog.csdn.net/kkbca/article/details/134073906
https://www.bilibili.com/video/BV1Ts4y1u7rA/
https://www.zhihu.com/question/491602524/answer/2165605549
前言
梳理虚函数与多态的相关知识,并总结相关问题。
一、虚函数表和虚函数表指针
拥有虚函数的类拥有一张虚函数表vtbl,存储的是该类的虚函数指针;
该类实例化的对象拥有一个虚函数表指针vptr,指向该类的虚函数表。
表是静态创建的,指针是动态创建的
虚函数表的创建
在编译期间,如果编译器检测到类中有成员函数被virtual关键字修饰,便会为类生成一张虚函数表。经过编译和链接后,类及其虚函数表会一并保存到可执行文件中;当可执行文件执行时,虚表也会被一并装载到内存中去。
派生类和基类都有各自的虚函数表,派生类的虚函数表继承自基类(从基类拷贝),如果有重写的虚函数,则覆盖虚函数表中的内容
虚函数表存放在全局存储区,由编译器确定。
虚函数表指针的创建
编译器会为带有虚函数的类的构造函数添加初始化vptr的语句
A() // 构造函数
{
vptr = &A::vtbl;
//......
}
在实例化类对象时,调用构造函数,使对象的vptr指向类的vtbl。
每一个类都有一个自己的虚函数表,所有的类对象共享这一个虚函数表;而每一个对象都有一个自己的虚函数表指针,指向同一个虚函数表。
过程梳理:
a.在编译期间,如果类中有使用virtual修饰的函数,编译器会为该类创建虚函数表(静态创建),并且为类的构造函数添加初始化虚表指针的代码语句;
b.经过编译和链接之后,生成的可执行文件保存到硬盘中;
c.当可执行文件执行时,类及其虚表都装载到内存中去;
d.如果有实例化的类对象,那么当程序运行时会在构造函数中为对象创建虚函数表指针(动态创建)
补充:C++编译链接阶段
预处理阶段,处理头文件包含关系,对预编译命令进行替换,生成预编译文件
编译阶段,将预编译文件编译,生成汇编文件
汇编阶段,将汇编文件转换成机器码,生成可重定位目标文件(.obj文件)
链接阶段,将多个目标文件和所需要的库连接成可执行文件(.exe文件)
怎么从虚表中找到想要调用的虚函数
https://www.zhihu.com/question/425173545/answer/1520796610
编译器会记录每一次出现的虚函数名字,统计后依次放入虚函数表,并得到每一个名字对应的偏移量,最后把代码中出现的名字替换成对应的偏移量。(我的理解是相当于编译器在汇编的时候把代码中每一次出现虚函数都替换成偏移量了,我们调用虚函数的时候相当于给虚函数表一个偏移量去找是哪一个虚函数)
二、重写相关、相似概念
重写、重载、重定义对比
协变
派生类重写基类虚函数,如果基类虚函数返回值为某个类的指针/引用,而派生类重写的函数返回值不同,且是基类虚函数返回值的派生类的指针/引用,则称为协变。
class A { };
class B: public A { };
class Person
{
public:
virtual A* fun() { return new A; } // 返回基类指针/引用
};
class Student : public Person
{
public:
virtual B* fun() override { return new B; } // 返回派生类指针/引用
};
重写析构函数
#include <iostream>
class Base
{
public:
~Base() { std::cout << "~Base()" << std::endl; }
};
class Derive : public Base
{
public:
~Derive() { std::cout << "~Derive()" << std::endl; }
};
int main()
{
Base* base = new Base();
Base* derive = new Derive();
delete(base);
delete(derive);
}
/*
输出:
~Base()
~Base()
*/
对于派生类,如果我们直接析构,可以发现基类指针derive只析构了基类部分,而派生类部分并没有析构,会造成内存泄漏。
为什么会出现这种现象?(统一将析构函数视为destructor())
编译器将~Base()和 ~Derive()都处理为this->destructor(),两个同名函数发生重定义,但是调用的时候只看自己的类型,即Base就调用Base的函数,Derive就调用Derive的函数,因此这里的this指针就是Base类型的指针,最终调用的就是基类的析构函数
解决方法是使用virtual修饰析构函数:
#include <iostream>
class Base
{
public:
virtual ~Base() { std::cout << "~Base()" << std::endl; }
};
class Derive : public Base
{
public:
virtual ~Derive() { std::cout << "~Derive()" << std::endl; }
};
int main()
{
Base* base = new Base();
Base* derive = new Derive();
delete(base);
delete(derive);
}
/*
输出:
~Base()
~Derive()
~Base()
*/
如果本身就是派生类的对象指针,或者是直接创建的对象(存放在栈区,自动释放内存),那么就不需要担心派生类部分的析构
Derive* derive1 = new Derive();
delete(derive1);
Derive derive2;
/*
输出:
~Derive()
~Base()
~Derive()
~Base()
*/
三、final、override
final修饰的虚函数不能再被重写
override修饰的虚函数表示基类中一定有需要被重写的虚函数,override可以省略
四、抽象类
抽象类又称为接口类,只要包含有纯虚函数的类就是抽象类,抽象类无法实例化对象,必须由派生类重写纯虚函数后由派生类实例化对象。
// 纯虚函数
virtual void func() = 0;
普通函数的继承是实现继承,派生类继承了基类函数,可以使用基类函数,继承的是函数的实现;虚函数的继承是接口继承,派生类继承的是基类虚函数的接口,目的是为了通过重写达成多态,继承的是接口。
因此,如果不需要实现多态,尽量不要把函数定义为虚函数
五、虚函数如何体现多态
静态多态(编译期多态)–重载、模板,在编译时就确定,以后不能再改变
动态多态(运行期多态)–重写
本节后面提到的多态都是动态多态
多态:指向派生类对象的基类指针,调用虚函数时,调用的是派生类的版本。
调用虚函数时如果是通过vptr找虚函数表,找到虚函数入口并执行的方式,则是多态;如果是像普通函数一样直接调用虚函数,则不是多态。
多态的三个条件:
1.程序中存在基类和派生类,基类中必须有虚函数,派生类必须重写基类虚函数
2.基类指针指向派生类,或基类引用绑定派生类对象
3.通过基类指针/引用,调用虚函数,执行派生类的函数版本
为什么多态只能通过指针/引用体现?
#include <iostream>
class Base
{
int a;
public:
Base(int val) : a(val) {}
virtual void func() { std::cout << "Base::func()" << std::endl; }
};
class Derive : public Base
{
public:
Derive() : Base(0) {}
virtual void func() override { std::cout << "Derive::func()" << std::endl; }
};
int main()
{
Derive derive;
Base base = derive;
base.func();
return 0;
}
/*
输出:
Base::func()
*/
上面的代码中,在进行拷贝构造时,base仅仅拷贝了derive的成员,并没有拷贝虚函数表指针,base用的还是Base的虚函数表,因此不体现多态。
六、动态绑定和静态绑定
void f();
void g();
struct Base
{
virtual void virtualFun() { f(); }
};
struct Derived : Base
{
virtual void virtualFun() { g(); }
};
void fun(Base* b) { b->virtualFun(); }
当上述代码编译当fun函数内时,编译器只知道b是Base类型的指针,并不知道b具体指向什么类型的变量,因此需要去虚函数表中取函数指针再调用,这就是动态绑定(运行期绑定)。
void f();
void g();
struct Base
{
virtual void virtualFun() { f(); }
};
struct Derived : Base
{
virtual void virtualFun() { g(); }
};
void fun(Base b) { b.virtualFun(); }
当上述代码编译当fun函数内时,b确定为Base类型的对象,在将派生类对象作为参数时,会将派生类中属于Base类的一部分拎出来,拷贝构造出b。所以这里虽然也调用了虚函数virtualFun,但不涉及运行期绑定,因为b的类型和它的virtualFun已经确定了,这种方式即是编译器静态绑定。
七、类的内存模型
补充:内存分区
a.全局/静态存储区,存储全局变量和静态变量
b.常量存储区,存储常量
c.程序代码区,存放函数体的二进制代码
d.栈区,存储局部变量,能分配较小内存,由操作系统自动申请与释放
e.堆区,由程序员手动分配和释放内存,如果不主动释放,程序结束时可能会由操作系统释放内存
1.如果有虚函数,则虚函数表指针始终存放在内存空间的头部;
2.除虚函数外,内存空间按照继承顺序(基类到派生类)和成员声明的顺序布局;
3.多继承情况,每个基类有自己的虚表,并按照继承顺序布局(虚表指针+成员);当派生类重写基类函数时,在对应的基类虚表中更新相应地址;派生类自己新定义的虚函数会添加到第一个虚表后面;
4.采用虚继承的钻石继承,内存空间顺序为:各个基类、派生类、公共基类,并且各个基类不再拷贝公共基类的数据成员;
5.类内函数不占用类的内存,存储在代码区;
6.静态变量不占用类的内存,存储在全局/静态区。
八、练习
1.一个很奇妙的问题
#include <iostream>
class Base
{
public:
virtual void func(int val = 1) { std::cout << "Base->" << val << std::endl; }
virtual void test() { func(); }
};
class Derive : public Base
{
public:
virtual void func(int val = 0) override { std::cout << "Derive->" << val << std::endl; }
};
int main()
{
Base* base = new Derive();
base->test();
return 0;
}
输出:Derive->1
首先,test调用成员函数func()相当于调用this->func(),base调用test函数,this是指向派生类的基类指针,因此发生多态,调用Derive版本的虚函数func。
而函数的默认参数在编译期会按照指针的声明类型进行静态绑定;
再注意重写的关键点,仅仅是重写了Base的实现,而不会改变默认参数;
因此val的值为1,调用Derive的func函数,输出Derive->1。
2.什么是多态?C++中有哪几种多态?
多态就是指同一个函数名具有多种状态。
C++中有编译时多态,重载和模板;运行时多态,通过继承和虚函数实现
3.虚函数的实现机制
虚函数通过虚函数表实现,虚函数表中包含一个类所有虚函数的地址,在有虚函数的类对象中,它内存空间的头部会有一个虚函数表指针(虚表指针),用来管理虚函数表。当子类对象对父类虚函数进行重写时,虚函数表的相应虚函数地址会发生改变,改写成这个虚函数的地址,当我们用父类指针来操作子类对象时,实际会调用子类的虚函数。
4.虚函数调用在什么时候确定,如何确定调用哪个函数?
通过指针或者引用方式调用虚函数是运行期绑定(动态绑定),通过值调用的虚函数是编译器静态绑定,并且只有通过指针或者引用才能表现出多态性。
5.虚函数存在类中还是对象中(是否公用虚表)
存在类中,同一个类的不同对象共享同一张虚表(节省内存空间)
6.在构造函数和析构函数中调用虚函数会怎么样?
在调用构造函数时,先进行基类部分的构造,再进行派生类部分的构造。在父类构造期间,派生类的部分还没有初始化,此时调用派生类的虚函数,必然会出现错误。
在调用析构函数时,先析构派生类部分,再析构基类部分。当进行基类的析构时,派生类的部分已经销毁,无法再通过调用虚函数实现多态。