【C++】多态 —— 条件 | 虚函数重写 | 抽象类 | 多态的原理

多态即多种形态。在Linux基础IO一文中@一切皆文件,咱们说过语言上的多态是漫长软件开发过程中探索出的实现“一切皆…”的高级版本。那现在就来了解多态的语法细节。

不要害怕!不要害怕!不要害怕!怕了咱们就先玩儿完了!!

正文开始@一个人的乐队🎸

前置文章:继承;类和对象;指针进阶

反爬链接:

1. 多态

多态分为两类 ——

  • 静态的多态函数重载。传入不同参数,看起来调用一个函数,但是有不同的行为,最典型的比如流插入流提取的“自动识别类型”。

    	int i = 10;
    	double d = 1.1;
    	cout << i; //cout.operator<<(int)
    	cout << d; //cout.operator<<(double)
    
  • 动态的多态:一个父类引用或指针调用同一个函数,传递不同的对象,会调用不同的函数。

所谓静态还是动态在于 ——

  • 静态:在编译时决议,(编译时决定调用谁)
  • 动态:在运行时决议,(运行时决定调用谁)

本文重点讨论的是动态多态。

现在人类有一个买票行为,我们想让不同身份的人,买票的价格不同,就可以借助多态实现。

上层看来我们都是人类,只不过传入对象的身份不同,因买票行为也不同。

这是怎么实现的?这是传子类对象时发生切片,与类型转换无关,否则会产生临时变量,临时变量具有常属性,需要加const

class Person {
public:
	virtual void BuyTicket() 
	{ 
		cout << "买票-全价" << endl; 
	}
};

class Student : public Person {
public:
	virtual void BuyTicket() 
	{ 
		cout << "买票-半价" << endl; 
	}
};

void Func(Person& p) //父类的指针/引用
{
	p.BuyTicket(); /*多态*/
}

int main()
{
	Person p;
	Student s;

	Func(p); //传父类对象 —— 调父类的
	Func(s); //传子类对象 —— 调子类的
	return 0;
}

子类中的函数满足三同(返回值类型、函数名、参数列表完全相同)的虚函数两个条件,叫做重写(覆盖)。

注:此时函数名相同也不再是所谓的构成隐藏,实际上,父子类的同名函数非重写即隐藏。

这样就做到了,同一函数不同类型的人来做,有不同行为 ——

2. 多态的定义和实现

2.1 多态的条件

💜 多态两个条件,缺一不可 ——

  • 必须通过基类指针或者引用调用虚函数,对象没有多态。

  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(底层原理后面看)

    • 虚函数:被virtual修饰的类成员函数 ( 准确的说,只有类的非静态成员函数才能是虚函数,其他函数不能成为虚函数,原理虚函数表详谈)
    • 重写要求:虚函数 + 三同,总之,这条可以算作四个条件

下面进行一系列验证,first one,若用对象来调用,没有多态 ——

  • 构成多态,传的哪个类型的对象,调用的就是哪个类型的虚函数 - 跟对象有关
  • 不构成多态,调用的就是p类型函数 - 跟类型有关

思考为什么一定要是父类的指针或引用呢?因为这样才能既接收基类对象,又接收子类对象。这样才能在上层看来,“一切皆…”,而在其下各自行为不同。

那父类对象呢?父类对象不也是既能接收基类对象,又接收子类对象吗?yes…但对于引用,会从一而终,没法一会儿父类对象一会儿子类对象;对于指针,

next,若不是虚函数, 没有多态,所以调用什么跟p的类型有关 (这跟与隐藏无关,隐藏是针对子类对象调用的而言的) ——

then,破坏参数,也没有多态,所以调用什么跟p的类型有关——

last,返回值不同,破坏多态,直接报错咧?! ——

它们背后的原理在第5小节详谈。

2.2 虚函数重写的两个例外

虚函数重写条件例外。

2.2.1 协变

协变返回值父子关系指针或引用 (不能是对象),依然可以构成多态。

用处很少。

2.2.2 析构函数的重写

如果析构函数是虚函数,是否构成重写?yes,是因为在继承我们就说过,析构函数名儿被特殊处理了,都处理成了destructor,至于为什么要特殊处理,就是源于多态。

如上我们发现,对于普通对象不构成多态,都能正确调用:父类的调用父类的;子类的调用子类的,完了自动调用父类的,这我们在继承时候就谈过。就算我不把析构函数写成虚函数,我也不重写,都没关系。你可以自己验证。

but~ 下面这时候就糟了,这也是面试时的高频问题:

💜 那什么场景下,析构函数要是虚函数呢?

如果是动态申请的子类对象,给了父类的指针,若想正确调用,那么析构函数需要是虚函数(右图)

因为如果不是虚函数,不构成多态,那与类型有关,都会去调用父类的析构函数(左图),但是这样会导致子类对象可能有资源未被清理;我们希望指向父类调用父类的,指向子类调用子类的(完了再调用父类的),那就需要满足多态的条件,完成重写

当然了,析构函数的重写特简单,它本身函数名“相同”,没参数,自己再加一个virtual就行 ——

其他场景,析构函数是不是虚函数都可以,都可以正确调用析构函数。

当然了,我们推荐在继承体系中,把析构函数写成虚函数。

2.3 只有父类带 virtual 的情况

虚函数,允许父子类两个都是虚函数 或 只有父类是虚函数也行。这其实是C++不是很规范的地方,建议两个都写上virtual.

这是因为虽然子类没带virtual,但是它继承了父类的虚函数属性。

大佬这样设计的初衷是,考虑到“析构函数”。。因为在一个巨大的项目中父&子类可能不是一个人儿写的,如果只是父类加了virtual而子类没加,因此不构成多态,没有调用子类析构函数,就可能有内存泄漏问题。

那经过大佬的一番思索,在一个项目中,最好在父类析构函数加上virtual,那么这个漏洞确实就被完完全全的补上了。

但是大佬没有考虑到,这又构成了其他的歧义。令人震惊的事情发生了,如下图,就算Buyticket()是private的,还是继承了父类的属性,能够调的到,震惊!!

不过没关系,我们学完虚表就知道原因了。

建议我们自己写的时候,都加上virtual,肯定不会出错。

2.4 C++11 final & override

2.4.1 final

可以修饰重写函数

💜1. 设计一个不能被继承的类

在C++11没有引入final时,C++98中通过间接限制,是把父类构造函数设为私有,因为子类一定要调用父类的构造函数要初始化父类的部分,但是private对子类不可见(左图),因此这样无法实例化子类对象。同时这也带来了问题,父类A也构造不了喂!

这就是要蛋没鸡,要鸡没蛋的问题。。。现在父类构造函数private修饰,也就是我在类外调不到它(嘘~我在类内还可以调到),可是成员函数的调用本来又依赖对象,可是我连对象都没有呜呜(我就是想造对象)

我们可以造一个静态成员函数来造对象(右图),在Java中就是经典的静态工厂方法(算了我也不会jvav,那为什么用静态函数呢?因为静态函数的调用不依赖于对象而依赖于类,可以通过类域直接访问。

但是在C++11就优化掉了这个复杂的方法,加final直接限制 ——

💜2. C++11中final还可以限制重写

修饰虚函数,限制它不能被子类中的虚函数重写。

2.4.2 override

override放在子类重写的虚函数后面,帮助检查是否完成重写,没有重写会报错

在Java中有@override来帮助检查 (算了我也不会jvav

3. 重载 vs 重写 vs 隐藏

4. 抽象类

💛 包含纯虚函数的类叫做抽象类(接口类)。在虚函数的后面写上=0 ,则这个函数为纯虚函数

纯虚函数一般只声明,不实现,抽象类不能实例化出对象

(它是可以实现的,只不过实现的没有价值。为什么呢?因为,抽象类不能实例化出对象)

哦,那好吧,我总可以定义一个Car的指针/引用来调用函数吧(如下图)。极端一点给一个nullptr,发现可以编译通过,你可能有些震惊,p->Drive();这不是空指针解引用了吗?嘿嘿,这是我们在类和对象 - 隐藏的this指针一文中就讨论过的问题,这只是把nullptr传给了隐藏的this指针而已,并没有发生解引用,所以编译是通过了(我还用p->func();调用普通函数再次验证了这件事儿)

当然了,再看这调用虚函数的p->Drive();看上去是编译通过了,但是运行起来什么也没有打印,这是不是**崩了?**一调试,诶确实崩了。这就关系到后面的虚函数表了,在这里浅说一下,我们调用的这个虚函数地址存放在虚函数表中,虚表指针在对象中,需要通过this指针解引用去找到,这理所当然的崩了;那对于p->func();呢,确实也没崩,因为普通成员函数存放在公共的代码段,不在对象中,不需要this指针去找。

那好吧,既然我也造不出Car父类对象给Car*,那我造一个子类对象,darn it,其派生类继承后也不能实例化出对象(左图),因为继承了抽象类后,这个派生类就继承了纯虚函数,那它同样也是一个抽象类!

只有重写纯虚函数,派生类才能实例化出对象(右图)。所以呀,抽象类本质上强制继承它的子类完成虚函数重写

现在就能调用到子类的虚函数了(如右图)

综上,你真没必要去实现纯虚函数,因为实现了,也没人调用你这个父类实现,声明一下即可。

上述提到的这种种现象,你都可以自己实操一下,代码贴给宝子们了 ——

class Car
{
public:
	// virtual void Drive() = 0;
	virtual void Drive() = 0
	{
		cout << "virtual void Drive() = 0" << endl;
	}

	void func()
	{
		cout << "void func()" << endl;
	}
};

class Benz :public Car
{
public:
	public:
		virtual void Drive()
		{
			cout << "Benz-舒适" << endl;
		}
};


int main()
{
	Car* p = new Benz;
	p->Drive();
	return 0;
}

什么样的类要设计为抽象类呢?一个类型如果在现实世界中,没有具体的对应实物,就定义为抽象类,这个类没必要实例化出来。这话也够抽象的了(

在Java中,这应该是类似一个叫做“接口”的语法,确实很常用,算了我不在这儿胡说了( 我也不会jvav

override只是在语法上检查是否完成重写。

继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。

5. 多态的原理

5.1 虚函数表

💛 引入

// sizeof(Base)是多少?
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
private:
	int _b = 1;
	char _ch = 'A';
};

由结构体对齐规则,我就知道肯定不是8哈哈( ——

在这里插入图片描述

那是多了什么呢?通过监视窗口,发现这个对象多了一个成员,虚函数表指针_vfptr(简称虚表指针) ,所谓的虚函数表就是一个指针数组,里面存放的是函数指针(虚函数地址),一般这个数组的最后面放了一个nullptr——

在这里插入图片描述
这就实在的解释了我们在抽象类中讨论好久的问题@4

5.2 多态的原理

虚函数表是理解多态原理的关键,下面将集中从原理层解释2.*小节中的现象,以如下代码为例 ——

class Person {
public:
	virtual void BuyTicket() 
	{ cout << "买票-全价" << endl; }
protected:
	int _a = 0;
};

class Student : public Person 
{
public:
	virtual void BuyTicket() 
	{ cout << "买票-半价" << endl; }
protected:
	int _b = 0;
};

void Func(Person& p) {
	p.BuyTicket();
}

int main()
{
	Person Peter;
	Func(Peter);

	Student Mable;
	Func(Mable);

	return 0;
}

虚函数的“重写”也叫“覆盖”,重写是语法上的概念,覆盖是原理层的概念。子类继承父类的虚函数,可以认为深拷贝了一份虚函数表,没重写时,子类与父类虚表完全相同;若重写了,便会用新地址覆盖。这些及建议你都可以,或者你也应该自己打开监视窗口动手验证 (效果已经展示在下下图中了),代码贴给宝子们了。

转到反汇编可以发现,对于普通成员函数的调用,是在编译后就已经确定了调用地址(橙色的);却发现,给父类/子类对象,调用虚函数p.BuyTichet();汇编代码一模一样儿,那一样儿是怎样实现多态的呢?发现此时调用函数时,不再是直接确定地址,而是借助了eax这个寄存器,这是多态原理的关键。

(这段汇编指令不强求看懂,但你应该大胆猜测,这是在去虚表中拿待调用的虚函数地址,放入eax中)

💜 多态的原理:基类的指针/引用指向谁就去谁的虚函数表中找到对应位置的虚函数进行调用。这是在运行时确定的,所以这叫“动态的多态”。

💛 那现在我们再来反思,为什么一定要是基类的指针或引用类型,而对象不行,对象不是也可以传父类/子类切片吗?@2.1多态的条件

  • 引用切片就是作为给过去的子类的父类部分别名,它的_vfptr就是理所当然的和子类指向同一空间,哦那就可以实现多态了;

  • 对象切片,我们打开监视窗口, 观察发现相当于拷贝构造对象的确可以接收父类或子类,但并没有把_vfptr拷贝过来(拷贝过来可就乱了,后面说),也就是此时这个父类对象的_vfptr指向父类虚表,那当然就然调用的是父类的虚函数,就算你传参时确实好像发生了子类切片,就算你重写了虚函数,但都没用呀,因此没法实现多态——

同类型的对象虚表指针_vfptr是否一样?是的,同类型的对象,虚表指针指向同一张虚表

也就是说多个对象共享一个虚函数,虚表中的内容是不允许修改的。

我们总说多态是“运行时多态”,不构成多态时,编译时就会确定调用函数的地址;构成多态,编译时,不能确定调用哪个函数(eax),它还不知道传的是啥对象,运行时,才确定传入的是父类还是子类对象,去p指向对象的虚表中找到虚函数地址。此时p作为父类对象/子类对象那部分的引用 (指针视角同理,在原理层也是“一切皆…”的视角)。 (不要搞混,编译时是会确定虚函数地址的,不过是运行时再确定填入哪个对象的虚表)

究竟是怎样?判断的唯一标准就是“是否构成多态”,又回归到构成多态的两个条件(1+4)。

什么?!你说我编译器处理的时候可以把子类的_vfptr强制拷贝过来,这样就能实现多态了?是,但那可就乱了,因为在此之前你可以做任意行为,你都不知道这个对象里存的是父类的虚表还是子类的虚表,会造成混乱的结果:比如一父类对象调用的是子类的析构函数。。。

💛 为什子类么重写虚函数时,设置为private权限,依然能调用到,呈现多态?

因为。。因为编译器是不会检查出来的,它看到的是一个父类调用虚函数p.BuyTicket();,看到的就是父类的publlic接口。子类对象该去虚表中找,能找到就能调用,虚表中也不分公私有。重写是一种“接口继承”。

这么说,C++的访问修饰符不一定安全。。那我能不能通过一些bug的操作,访问到私有的虚函数呢?是可以的。。这个我们在6.1小节会给出。

直接顺着虚表拿到函数地址,这种非常规的操作不受公私有限制。

5.3 虚函数表在哪

普通函数和虚函数存储的位置是否一样?一样的,都在公共代码段,只不过虚函数要把地址存一份到虚表,以实现多态。

虚表在哪里呢?从前从前,我们就铺垫过虚函数表不能修改,所以我大胆的猜测是在常量区嘿嘿。

我们写一段代码来验证一下 ——

所以,虚函数表是存在“常量区”的。

(在操作系统角度,是不区分常量区和代码段的,都叫代码段。这儿是语言角度)

代码贴给宝子们了 ——

int main()
{
	int* ptr = (int*)malloc(4); 
	printf("heap: %p\n", ptr);

	int a = 0;
	printf("stack: %p\n", &a);

	static int s = 0;
	printf("数据段:%p\n", &s);

	const char* p = "always";
	printf("常量区:%p\n", p);

	printf("代码段:%p\n", &Base::func1);

	Base b;
	// 取对象头4/8个字节 —— 强转(Base* -> int*) —— 再解引用拿到_vfptr
	printf("虚函数表: %p\n", *((int*)&b));

	return 0;
}
//ps: 有一小点代码在上面

6. 单继承和多继承关系中的虚函数表

首先我们要再来观察如下代码在监视窗中的状况,这儿vs起到了很好的误导作用,我们要解释一下,以便后续内容的正常进行,当然了其实并不复杂 ——

class Base 
{
private:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	void func3() { cout << "Base::func3" << endl; }

private:
	int _a = 0;
};

class Derive : public Base 
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void fun4() { cout << "Derive::func4()" << endl; }
private:
	int _b = 1;
};

int main()
{
	Base b;
	Derive d;
	return 0;  
}

打开监视 窗口观察,发现父类对象虚表中如期有两个虚函数地址func1func2;子类继承父类,可以认为深拷贝了虚函数表,重写func1,覆盖了原来的地址,其中的func2安然不动。

对于子类新增加的虚函数func4,却没看到,是被vs给隐藏了,我们暂且通过内存 窗口观察(如下图) ——

令人疑惑的地方又来了,这内存 中的Derive::func4怎么跟监视 窗的&Derive::func4地址不一样呀。

事实上,虚函数表并不是真实地址,而是这句jmp跳转指令的地址(橙色的)!&Derive::func4这样取到的,即jmp后面跟的地址(绿色的),才是虚函数的真实地址.

6.0 打印虚函数表

虚表指针是在什么时候初始化的?是在构造函数的初始化列表初始化的。

前文我们就说过,有时虚函数地址被隐藏掉了,之前我们委屈的在内存窗口中观察,现在我们来学习打印虚函数表

从前从前,我们仔细研究过**“函数指针”如何定义变量**,它与一般int a=0;这样类型跟名字不同,是混杂在其中的,我们在typedef时,依然保留了这个原则。

我们已经熟知,虚表是一个函数指针数组,打印它并不难,这个经验来自于我们学习Linux环境变量时打印过环境表、命令行参数数组 ——

typedef void(*VF_PTR)(); //类型重定义:(虚)函数指针

void VFTable(VF_PTR table[])
{
	for (int i = 0; table[i] != nullptr; i++)
	{
		printf("vft[%d]: %p\n", i, table[i]);
	}
}

那么在调用这个函数的时候,就需要传入虚函数表的地址,即指针数组的(首元素)地址,即对象中的虚表指针_vfptr

问题就转化成了如何**取到对象头4/8个字节**呢?

嗯…没法直接转成int,那取&个地址,指针的类型决定看待内存的视角,如果强转为(int*),再解引用拿的就是头四个字节。可是传入的参数类型还不匹配,那就再(VF_PTR*)强转一下 ——

	Base b;
	PrintVFTable((VF_PTR*)(*(int*)&b));

嗯如果你直接看这一坨当然会有些眼晕,但其实只要你能稍稍的独立思考就很很很简单!

(注:vs有一些bug,你一打印可能打印出了很多无关地址,可以清理一下解决方案;或者通过函数地址来手动调用函数验证,方法如下)

typedef void(*VF_PTR)(); //(虚)函数指针

void PrintVFTable(VF_PTR table[])
{
	for (int i = 0; table[i] != nullptr; i++)
	{
		printf("vft[%d]: %p -> ", i, table[i]);
		VF_PTR f = table[i];
		f();
	}
}

是的,在尝试写它的时候,我就感受到这有多bug。。按理来说,这些函数都要通过对象调用,传入this指针,受到访问修饰限定;但是在这儿,我都没搞,就直接拿着函数地址调用。。也就是说此时this指针是一个随机值,如果访问成员就可能出现一些越界访问,打印出随机值甚至崩溃。。

我们也完全可以通过这种bug的方式,访问到私有虚函数,所以虚表是有安全隐患的。。


	Base b;
    PrintVFTable((VF_PTR*)(*(int*)&b));			// 32位
	PrintVFTable((VF_PTR*)(*(long long*)&b));	// 64位
  • 32位平台,用(int*)强转。

  • 64位平台,用(long long*)强转。用double好像行但其实不太行,因为会有精度丢失 ( double在内存中确实占了64个字节,但是double类型的有效位M只有52位,虽然我们把取出的数字按照地址看待,但是和double转int同理)

震惊的是在32位平台下,用(long long*)强转的居然还能正常跑。。

是因为之后再用(VF_PTR*)这个函数指针强转,8字节恰好截断到头上4字节。

💛 我们想要探索出,32/64位平台下能自适应的方式 ——

	Base b;
	PrintVFTable((VF_PTR*)(*(int*)&b));			// 32位
	PrintVFTable((VF_PTR*)(*(long long*)&b));	// 64位
	PrintVFTable((VF_PTR*)(*(void**)&b));		// 32/64位

怎么忽然就是是void**呢?(int*) 解引用看一个int的大小,(long long*)解引用看的是long long的大小,void* 不能解引用,这(void**)解引用看的是void* 的大小,void* 的大小就和平台相关。

当然了,这样说char** /int** 什么的都可以,只要是二级指针都可以。

💛 也可以条件编译 ——

	Base b;
#ifdef _WIN64
	PrintVFTable((VF_PTR*)(*(long long*)&b));
#else
	PrintVFTable((VF_PTR*)(*(int*)&b));
#endif

注:_WIN32:Defined for applications for Win32 and Win64. Always defined. 不能用于判断平台环境。

​ _WIN64:Defined for applications for Win64.

那好嘞,我们现在就能方便的看一看子类虚表了。

6.1 单继承的虚函数表

代码贴给宝子们了,你最好,哦不,你也应该自己验证一下 ——

class Base 
{
private:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int _a = 0;
};

class Derive :public Base 
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	void fun4() { cout << "func4()" << endl; }
private:
	int _b = 1;
};

int main()
{
	Base b;
	PrintVFTable((VF_PTR*)(*(void**)&b));

	Derive d;
	PrintVFTable((VF_PTR*)(*(void**)&d));

	return 0;
}

重写的fuc1,拷贝继承下来的func2,自己的func3 ——

6.2 多继承的虚函数表

代码贴给宝子们了,你最好,哦或者说,你也应该自己验证一下 ——

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int _b1 = 0;
};

class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int _b2 = 0;
};

class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int _d1 = 0;
};

int main()
{
	Derive d;

	Base1* p1 = &d;
	p1->func1();

	Base2* p2 = &d;
	p2->func1();

	return 0;
}

打开监视 窗口观察,此时Derive对象中理所当然的有两个虚表,并且即使子类重写了func1后,你发现这对象虚表中,Base1和Base2的虚函数func1的地址不一样,你早就不应该感到惊奇,因为这时jmp跳转指令的地址,最终会一跳到同一位置执行函数Derive::func1的 ——

发现p2->func1()调用函数时,还跳了好多层。这是为了做准备工作ecx-8 ,修正this指针(eax),为什么呢?调用虚函数时,要传递this指针,-8由指向Base1到指向Base2,从而看到对应类型视角下的那部分。当然,这你了解即可。

💜 我们需要打印虚函数表,来观察多继承下的对象模型:

由于子类中有两份虚表,我们需要再认真思考如何传入第二个虚表指针_vfptr ——

(建议,哦不,你也应该独立思考,因为你直接看下面这一坨肯定会眼晕,当然了,我就是知道你会眼晕,所以我好好给你解释)

int main()
{
	Base1 b1;
	PrintVFTable((VF_PTR*)(*(void**)&b1));

	Base2 b2;
	PrintVFTable((VF_PTR*)(*(void**)&b2));

	cout << "_____________________________________" << endl;
	Derive d;
	PrintVFTable((VF_PTR*)(*(void**)&d));
    /*打印第二个虚函数表*/
    PrintVFTable((VF_PTR*)(*(void**)((char*)&d+sizeof(Base1))));

	return 0;
}

切片时会引起指针的自动偏移,可以直接打印:) ——

    Base1* p1 = &d;
	Base2* p2 = &d; // 切片 - 也干了类似 (char*)&d+sizeof(Base1) 这样的操作
	PrintVFTable((VF_PTR*)*((void**)p2)); 

当然了,在汇编角度它一定也做了我们手动移动类似的事情。

💛 发现多继承时,子类自己的虚函数Derive::func3放在第一个父亲的虚表中 ——

你可以把Base2放到前面试试。

6.3 菱形继承的虚函数表

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。可以去看下面的两篇链接:

C++ 虚函数表解析 | 酷 壳 - CoolShell

C++ 对象的内存布局 | 酷 壳 - CoolShell

当然了,还是简单跟宝子们说说,来一段老朋友代码,打开监视 窗口 ——

class A
{
public:
	virtual void f()
	{}
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;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	//d._a = 0; //不存在二义性,可以直接找
	return 0;
}

另外,在钻石型继承中,如果B和C都重写了A的虚函数func1,那么D必须重写func1,否则会报错“D”:“void A::f1(void)”的不明确继承,因为这儿是继承,共用一个虚表,不知道用哪个重写,看如下代码:

public:
	virtual void f1() {}
public:
	int _a;
};

class B : virtual public A 
{
public:
	virtual void f1() {}
	virtual void f2() {}
public:
	int _b;
};

class C : virtual public A 
{
public:
	virtual void f1() {}
	virtual void f2() {}
public:
	int _c;
};

class D : public B, public C
{
public:
	virtual void f1() {}
public:
	int _d;
};

int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}

在继承中**@7.3 菱形继承的原理**,我们说过虚基表中,曾经内容是00000000是为其他东西预留的,那它究竟是什么呢?这是找虚表的偏移量。

还是挺麻烦的,所以你没事儿别定义菱形继承(

7. 总结

来几道经典的问答题小伙子小姑娘?!不完全给答案是为了让你别背~

  1. 什么是多态?

  2. 什么是重载、重写(覆盖)、重定义(隐藏)?

  3. 多态的实现原理?

  4. inline函数可以是虚函数吗?

    准确的说不可以,因为内联函数没有地址,但是虚函数地址要被填入到虚表中。不过是可以编译通过的,因为inline只是个建议,到底有没有展开要视情况而定:若调用时不构成多态,保持inline属性;若构成多态,则没有inline属性。

  5. 静态成员函数可以是虚函数吗? 不可以,会直接报错“virtual”不能和"static"一起使用。
    因为静态成员函数没有this指针,只能使用类型::成员函数的调用方式,这样无法构成多态,而虚函数的价值就在于重写后构成多态。

  6. 构造函数可以是虚函数吗? nope
    同样的,构造函数设为虚函数没有价值,虚函数的意义就在于构成多态调用。多态调用就要去虚函数表中查找虚函数,这又涉及先有鸡还是先有蛋的问题,因为对象中的虚表中的虚函数指针,就是在构造函数初始化列表阶段才初始化的。

  7. 析构函数可以是虚函数吗? 什么场景下析构函数必须是虚函数?

    yes,并且继承体系中推荐写成虚函数。

  8. 对象访问普通函数更快还是虚函数更快?要看是否构成多态。
    如果不构成多态,那都是编译时确定调用函数的地址,一样快;如果构成多态,那么虚函数调用是运行时虚函数表中确定函数地址,普通函数编译时直接确定地址,则普通函数更快。

  9. 虚函数表是在什么阶段生成的?存在于哪?

    注:别把虚基表和虚函数表搞混了。编译阶段;常量区。

  10. C++菱形继承的问题?虚继承原理?

  11. 什么是抽象类?抽象类的作用?

​ 强制子类重写虚函数,另外体现了接口继承关系。

持续更新@一个人的乐队🎸

所以你看,其实也没多复杂。还是那句话,不要害怕,不要害怕,怕了咱们就先玩儿完了!!!

  • 29
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 22
    评论
目 录 译者序 前言 第1章 对象的演化 1.1基本概念 1.1.1对象:特性十行为 1.1.2继承:类型关系 1.1.3多性 1.1.4操作概念:OOP程序像什么 1.2为什么C++会成功 1.2.1较好的C 1.2.2采用渐进的学习方式 1.2.3运行效率 1.2.4系统更容易表达和理解 1.2.5“库”使你事半功倍 1.2.6错误处理 1.2.7大程序设计 1.3方法学介绍 1.3.1复杂性 1.3.2内部原则 1.3.3外部原则 1.3.4对象设计的五个阶段 1.3.5方法承诺什么 1.3.6方法应当提供什么 1.4起草:最小的方法 1.4.1前提 1.4.2高概念 1.4.3论述(treatment) 1.4.4结构化 1.4.5开发 1.4.6重写 1.4.7逻辑 1.5其他方法 1.5.1Booch 1.5.2责任驱动的设计(RDD) 1.5.3对象建模技术(OMT) 1.6为向OOP转变而采取的策略 1.6.1逐步进入OOP 1.6.2管理障碍 1.7小结 第2章 数据抽象 2.1声明与定义 2.2一个袖珍C库 2.3放在一起:项目创建工具 2.4什么是非正常 2.5基本对象 2.6什么是对象 2.7抽象数据类型 2.8对象细节 2.9头文件形式 2.10嵌套结构 2.11小结 2.12练习 第3章 隐藏实现 3.1设置限制 3.2C++的存取控制 3.3友元 3.3.1嵌套友元 3.3.2它是纯的吗 3.4对象布局 3.5类 3.5.1用存取控制来修改stash 3.5.2用存取控制来修改stack 3.6句柄类(handleclasses) 3.6.1可见的实现部分 3.6.2减少重复编译 3.7小结 3.8练习 第4章 初始化与清除 4.1用构造函数确保初始化 4.2用析构函数确保清除 4.3清除定义块 4.3.1for循环 4.3.2空间分配 4.4含有构造函数和析构函数的stash 4.5含有构造函数和析构函数的stack 4.6集合初始化 4.7缺省构造函数 4.8小结 4.9练习 第5章 函数重载与缺省参数 5.1范围分解 5.1.1用返回值重载 5.1.2安全类型连接 5.2重载的例子 5.3缺省参数 5.4小结 5.5练习 第6章 输入输出流介绍 6.1为什么要用输入输出流 6.2解决输入输出流问题 6.2.1预先了解操作符重载 6.2.2插入符与提取符 6.2.3通常用法 6.2.4面向行的输入 6.3文件输入输出流 6.4输入输出流缓冲 6.5在输入输出流中查找 6.6strstreams 6.6.1为用户分配的存储 6.6.2自动存储分配 6.7输出流格式化 6.7.1内部格式化数据 6.7.2例子 6.8格式化操纵算子 6.9建立操纵算子 6.10输入输出流实例 6.10.1代码生成 6.10.2一个简单的数据记录 6.11小结 6.12练习 第7章 常量 7.1值替代 7.1.1头文件里的const 7.1.2const的安全性 7.1.3集合 7.1.4与C语言的区别 7.2指针 7.2.1指向const的指针 7.2.2const指针 7.2.3赋值和类型检查 7.3函数参数和返回值 7.3.1传递const值 7.3.2返回const值 7.3.3传递和返回地址 7.4类 7.4.1类里的const和enum 7.4.2编译期间类里的常量 7.4.3const对象和成员函数 7.4.4只读存储能力 7.5可变的(volatile) 7.6小结 7.7练习 第8章 内联函数 8.1预处理器的缺陷 8.2内联函数 8.2.1类内部的内联函数 8.2.2存取函数 8.3内联函数和编译器 8.3.1局限性 8.3.2赋值顺序 8.3.3在构造函数和析构函数里隐藏行为 8.4减少混乱 8.5预处理器的特点 8.6改进的错误检查 8.7小结 8.8练习 第9章 命名控制 9.1来自C语言中的静成员 9.1.1函数内部的静变量 9.1.2控制连接 9.1.3其他的存储类型指定符 9.2名字空间 9.2.1产生一个名字空间 9.2.2使用名字空间 9.3C++中的静成员 9.3.1定义静数据成员的存储 9.3.2嵌套类和局部类 9.3.3静成员函数 9.4静初始化的依赖因素 9.5转换连接指定 9.6小结 9.7练习 第10章 引用和拷贝构造函数 10.1C++中的指针 10.2C+十中的引用 10.2.1函数中的引用 10.2.2参数传递准则 10.3拷贝构造函数 10.3.1传值方式传递和返回 10.3.2拷贝构造函数 10.3.3缺省拷贝构造函数 10.3.4拷贝构造函数方法的选择 10.4指向成员的指针(简称成员指针) 10.5小结 10.6练习 第11章 运算符重载 1

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 22
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浮光 掠影

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值