- 多态?讲讲静态联编和动态连编?
- 隐藏、重写、重载是什么?协变是什么?
- 什么是继承?虚继承?菱形继承?
- 什么是纯虚函数?虚函数?虚指针?虚表?虚基表?运作方式?
- 虚指针数量?多继承的虚指针数量?虚指针在对象中的位置?
- 虚表的数量?虚基表的数量?虚表和虚基表存储的位置?
- 同一个类的实例是否是共用一份虚函数表?
- 为什么构造不能 virtual?为什么多态析构需要为虚函数?为什么构造/析构不能调用 virtual Function?虚函数调用是编译时确定还是运行时确定,如何确定调用哪个函数?
- 各种继承下的内存分配?
![[C++多态--虚函数机制.jpg]]
1.什么是虚函数?什么是多态¶
虚函数是一种由 virtual 关键字修饰的一种类内函数,可分为虚函数和纯虚函数。
多态¶
所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为;C++的多态分为编译时多态和运行时多态,编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现。
什么叫纯虚函数呢?(=0)¶
virtual void func()=0;
这样类 A 的 func 就是一个纯虚函数。
这个时候我们再编译一下(用 VS2017),会报错:
error C2259: “A”: 不能实例化抽象类
note: 由于下列成员:
note: “void A::func(void)”: 是抽象的
note: 参见“A::func”的声明
对!纯虚函数是不能被调用的,因为它根本就没有实现,只有声明。
所以 a.func();这样的代码是会报错的。
把代码变成这样(代码 1.3):
#include <iostream>
using namespace std;
class A
{
public:
virtual void func() = 0;
};
class B : public A
{
public:
void func() { cout << "B func() called." << endl; }
};
int main()
{
B b;
b.func();
return 0;
}
输出为:
B func() called.
好,那我们就知道纯虚函数,是一种不需要写实现,只需要写声明的一种函数,它留待派生类(也就是继承于此类的类)来实现它的具体细节,我们在这里称 A 为基类,B 为派生类,下文同。
派生类可否是抽象类¶
先说结论:可以 不妨试试(代码 1.4)
#include <iostream>
using namespace std;
class A
{
public:
virtual void func() = 0;
};
class B : public A
{
public:
virtual void func() = 0;
};
class C : public B
{
public:
void func() { cout << "C func() called." << endl; }
};
int main()
{
C c;
c.func();
return 0;
}
输出为:
C func() called.
套娃狂喜,也就是说,在单继承的前提下,你只要实例化的派生类不是抽象类就可以了,一个抽象类是可以继承自抽象类的,并且它可以被另一个类所继承。
2.静态联编、动态联编¶
- 通常来说联编就是将模块或者函数合并在一起生成可执行代码的处理过程,同时对每个模块或者函数调用分配内存地址,并且对外部访问也分配正确的内存地址,它是计算机程序彼此关联的过程。按照联编所进行的阶段不同,可分为两种不同的联编方法:静态联编和动态联编。
静态联编是指在编译阶段就将函数实现和函数调用关联起来,因此静态联编也叫编译时绑定或早绑定,在编译阶段就必须了解所有的函数或模块执行所需要检测的信息,它对函数的选择是基于指向对象的指针(或者引用)的类型,C 语言中,所有的联编都是静态联编,并且任何一种编译器都支持静态联编。
动态联编是指在程序执行的时候才将函数实现和函数调用关联,因此也叫运行时绑定或者晚绑定,动态联编对函数的选择不是基于指针或者引用,而是基于对象类型,不同的对象类型将做出不同的编译结果。C++中一般情况下联编也是静态联编,但是一旦涉及到多态和虚拟函数就必须要使用动态联编了。下面将介绍一下多态。
多态:字面的含义是具有多种形式或形态。C++多态有两种形式,动态多态和静态多态;动态多态是指一般的多态,是通过类继承和虚函数机制实现的多态;静态多态是通过重载和模版来实现,因为这种多态实在编译时而非运行时,所以称为静态多态。
2.隐藏、重写(覆盖)、重载¶
![[207aeaec1c5847e80680760a1ddfb80d.png]]
隐藏¶
函数隐藏是说,在不同作用域中,定义的同名函数构成函数隐藏(仅仅要求函数名称相同,对于返回值和形式参数不做更多要求,并且对于是否是虚函数也不做要求)。例如派生类同名成员函数屏蔽与其基类的同名成员函数,以及屏蔽同名全局外部函数。(经常有人隐藏和覆盖重写弄混,所以提前说下,如果在派生类中存在与基类同名的虚函数,并且返回值、形参都相同,则构成函数重写)。
重写¶
只重写函数的实现,不改变函数的返回类型和函数签名(函数名、参数列表和常量性),this 指针依旧是原来的指针 - 协变可以更改返回类型
函数的覆盖和重写是一个意思的两个叫法,同时他的作用域也和函数隐藏相同,其实可以这么看,函数覆盖和函数隐藏共同构建了在具有集成关系的纵向作用域里面的同名函数的不同衍变,只不过函数覆盖的条件更加严格些。
在介绍函数隐藏的时候,为了弄清楚函数隐藏与覆盖重写,也简单描述了函数覆盖。这里再进一步进行描述下,派生类中与基类中: - 同名函数的返回值类型、参数的都相同 - 并且基类中定义为虚函数的情况下,构成虚函数覆盖,也叫虚函数重写。
重写的例外¶
协变(C++无逆变)¶
协变(covariant) 是指 派生类 (子类)中的返回类型可以是基类(父类)中返回类型的子类型。换句话说,如果一个虚函数在基类中返回的是基类类型的指针或引用,那么派生类可以重写该虚函数并返回基类类型的子类类型的指针或引用。 C++协变(covariant)-CSDN 博客
析构¶
继承的时候,派生类 B 可以调用基类的 A 的析构函数
本质上,析构的调用是
virtual ~B(); virtual ~A();
隐藏和重写的区别¶
咋一看,感觉重写的功能基于隐藏是都可以实现,那么为什么要区分重写和隐藏呢?其实这是 C++语言层面的问题了,C++基于 virtual 函数实现了多态性,并且可以进行动态联编,但是隐藏其实是破坏了这种多态性,也就是说父类成员函数的 virtual 性,在被子类成员函数的隐藏破坏后,无法传递给孙子类了,所以还需要重写来遗产的家族传递。
3.为什么构造函数不能是虚函数?¶
- 从存储角度: 虚函数 对应一个vtable,这个vtable其实是存储在对象的内存空间的。如果构造函数是虚的,就需要通过 vtable来调用,可是对象还没有实例化,也就是内存空间还没有,无法找到vtable,所以构造函数不能是虚函数。从实现上看,vbtl在构造函数调用后才建立,因而构造函数不可能成为虚函数。
- 从使用角度: 虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
4.虚继承¶
菱形继承¶
-
常规继承:D 访问 B:: A,C:: A,输出的 a 一样么?
不一样,这样的继承,D 中会有两份 A 副本
-
虚继承:无论指不指定经过的类,a 都只会在 d 中有一份副本
class A { public: int a; }; class B : virtual public A { public: int b; }; class C : virtual public A { public: int c; }; class D : public B, public C { public: int d; }; int main() { D d; cout << &d.a << endl; cout << &d.B::a << endl; cout << &d.C::a << endl; return 0; }
虚继承的唯一性¶
但请记住,把代码 2.2 的
class D : public B, public C
写成:
class D : virtual public B, virtual public C
是不可以实现多继承的。
5.多态与析构虚函数¶
解决第一节中所提出的问题,在基类中给成员函数/析构函数分别加 virtual 到底有什么作用? 我们先来看 C++是如何实现多态的,见代码 3.1,代码 3.1 给出了一种基类调用派生类方法的例子:
#include <iostream>
using namespace std;
class Base
{
public:
virtual void func() { cout << "Base func() called." << endl; }
};
class Dervied : public Base
{
public:
void func() { cout << "Dervied func() called." << endl; }
};
int main()
{
Base *b = new Dervied();
b->func();
return 0;
}
Dervied func() called.
那我们把 Base 类的 virtual 删掉呢?那输出就会变为:
Base func() called.
为什么析构函数要为虚函数?¶
因为多态会导致内存泄漏
那么同样地,我们可以猜想,如果 Base 类的析构函数不虚,将会发生怎样的结果?(代码 3.2):
#include <iostream>
using namespace std;
class Base
{
public:
Base() { cout << "Base() called." << endl; }
~Base() { cout << "~Base() called." << endl; }
};
class Dervied : public Base
{
public:
Dervied() { cout << "Dervied() called." << endl; }
~Dervied() { cout << "Dervied() called." << endl; }
};
int main()
{
Base *b = new Dervied();
delete b;
return 0;
}
输出为:
Base() called.
Dervied() called.
~Base() called.
我们可以发现,Dervied 类的空间并没有被释放,这个时候就内存泄漏了。
析构函数不能重载¶
析构函数重写可以不同名¶
析构在堆上的释放¶
与栈区普通对象不同,堆区指针对象并不会自己主动执行析构函数,就算运行到主函数结束,指针对象的析构函数也不会被执行,只有使用 delete 才会触发析构函数
6.虚函数表及虚函数表指针¶
虚基表和虚表¶
虚继承就会产生一个虚基表指针。
class A
{
int _a;
}
class B : virual public A
{
int _b;
}
vptr 虚函数表指针¶
C++中的虚指针与虚函数表 - 知乎 (zhihu.com) - 一个指针,大小为 4,类型为 void - 一个 virtual 一个指针,包括定义的虚函数和虚析构函数,以及虚继承后重新定义的新虚函数(不管是否重名) - 注意存放关系:先存 B 类、再存 C 类,最后子类新增基类 A** ![[Pasted image 20240406174542.png]] ![[Pasted image 20240406174527.png]]
vtbl 虚函数表¶
- 虚函数表其实就是由多个函数指针组成的数组
- 一个数组,类型 void* ![[Pasted image 20240406173444.png]]
举例:¶
虚继承之后:¶
-
GrandPa:可以看到一个虚表指针,指向虚表 ![[Pasted image 20240406172423.png]]
-
Father:同样,是由祖父的虚表指针在维护 ![[Pasted image 20240406172118.png]]
-
Mother: 同样,是由祖父的虚表指针在维护 ![[Pasted image 20240406172656.png]]
-
Son:可以看到,父亲的虚表指针和祖父一样,而母亲的不一样。 ![[Pasted image 20240406172919.png]]
实现机制¶
派生类属性赋值给基类¶
- 实现继承后,父类 Base 通过虚函数调用子类 Derived,vptr 会指向子类的虚函数表。地址甚至和直接调用子类的 vtbl 也一模一样。
- 要调用函数时,直接用虚函数表找函数地址,那我们就可以调用 derived 方法。
基类为什么不能赋值给派生类?¶
编译是可以进行的,但是啊,基类调用子类函数时,走基类 Base 虚函数表。但子类虚函数表并没有这个函数,调用就崩了。
#include <iostream> using namespace std; class Base { public: Base() {} virtual ~Base() {} virtual void func() {} virtual void func2() {} }; class Dervied : public Base { public: Dervied() {} ~Dervied() {} void func() {} virtual void func3() {} }; int main() { Base *b1 = new Base(); Dervied *d1 = new Dervied(); delete b1; delete d1; return 0; }
7.各种继承下类的大小¶
- 每个 virtual function 虚函数都对应一个指针,32 位下大小为 4,包括析构函数和定义的函数
- 菱形继承大小是有两个副本的,所以相当于两个虚函数都被定义了,A 类大小要 x2
- 菱形继承虚继承后,就只有一份副本了(但是指针会变多),所以挨着算就行了。
8. 虚函数调用是在编译时确定还是运行时确定的?如何确定调用哪个函数?¶
9.内存模型¶
-
如果是有虚函数的话,虚函数表的指针始终存放在内存空间的头部;
-
除了虚函数之外,内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局;
-
如果有多继承,每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面;
-
如果有钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚表)、子类、公共基类(最上方的父类,包含虚表),并且各个父类不再拷贝公共基类中的数据成员。
首先不考虑继承的情况。如果一个类中有虚函数,那么该类就有一个虚函数表。这个虚函数表是属于类的,所有该类的实例化对象中都会有一个虚函数表指针去指向该类的虚函数表。
考虑在有继承情况下,只要基类有虚函数,子类不论实现或没实现,都有虚函数表。基类的虚函数表和子类的虚函数表不是同一个表,在多继承情况下,有多少个基类就有多少个虚函数表指针,前提是基类要有虚函数才算上这个基类。当子类有多个虚函数表的指针也就是有多个虚函数表时,子类有多出来的虚函数时,添加在第一个虚函数表中。
对于一个类的对象的内存分布,对于继承情况来说,一个子类继承父类,哪个父类有虚函数表,哪个父类就在前面。虚函数始终在最前面(指向虚函数表的指针__vfptr)! 如果父类都没有虚函数,子类有虚函数,那么对于子类的内存分布,就是子类虚函数表指针在前,然后是父类的部分,最后是子类的部分