C++ [多态]

本文已收录至《C++语言和高级数据结构》专栏!
作者:ARMCSKGT


多态


前言

前面我学习完了面向对象三大特性的前两个,本节我们将为大家介绍最后一大特性,那就是多态,也是面向对象中最重要的一点,通过多态可以让一件事在不同对象的执行下表现出不同的状态,所以多态也可以说是多种状态。

例如我们平时坐公交车,我们普通人刷公交卡或扫二维码支付车费即可,而学生可以刷学生卡,老人可以刷老年卡,退役军人可以刷拥军卡等等,可以看出,在乘坐公交时,对于支付乘车费这个事情上,不同对象支付费用的方式不同,这就是多态!


正文

多态的概念


多态的概念: 通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成同一件事时会产生出不同的状态。

例如我们上面所说的乘坐公交车支付费用,对于支付费用这一行为,不同的对象(人)有不同的方法。

cpp 复制代码
//普通人
class Person
{
public:
   virtual void pay() { cout << "现金支付" << endl; }
};

//学生
class Student : public Person
{
public:
   virtual void pay() { cout << "学生卡" << endl; }
};

//老年人
class Seniors : public Person
{
public:
   virtual void pay() { cout << "爱心卡" << endl; }
};

//军人
class Servicemen : public Person
{
public:
   virtual void pay() { cout << "拥军卡" << endl; }
};

//乘车
void Ride(Person& person) { person.pay(); } //每个人乘车并支付费用

int main()
{
   Person person;         //普通人对象
   Student student;       //学生对象
   Seniors seniors;       //老人对象
   Servicemen servicemen; //军人对象

   //依次乘车
   Ride(person);
   Ride(student);
   Ride(seniors);
   Ride(servicemen);

   return 0;
}

同样是支付,不同对象在执行函数时,状态不同!

可以发现,实现多态需要virtual关键字修饰函数,但是这里的virtual关键字的作用与虚继承中virtual关键字的作用无关,两个地方使用是完全不同的意义!


多态的定义


多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象现金支付,Student对象刷学生卡。

构成多态的条件

两个条件

  • 1.必须通过父类的指针或者引用调用虚函数
  • 2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(构成重写的条件:子类中该函数的返回值、函数名、参数均与父类相同)

多态的定义: 在父类中将需要重写的函数使用virtual修饰为虚函数,子类继承父类并重写父类虚函数,使用父类指针或引用指向子类对象去调用虚函数时构成多态!

虚函数: 即被virtual修饰的类成员函数称为虚函数。
虚函数的重写(覆盖): 派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),此时子类继承基类的函数接口(称为接口继承),重写了基类虚函数的函数体。

多态的两个条件,任意一个不满足,则不构成多态,例如下面两个反例:

  • 1.父类函数非虚函数

    此时是父子类函数同名,隐藏父类函数,此时指针或引用是谁的类型执行谁的方法,我们使用的父类类型,则调用父类的pay!

  • 2.非父类的引用或指针指向对象

    此时构成的也是父子类的赋值兼容(切片),相当于赋值给父类,此时执行父类的方法!

从上面的示例中我们可以发现,两个条件缺一不可!

但上面的条件也有两个例外:

  • 1.父类声明虚函数必须使用virtual修饰函数,但是子类中重写的函数可以不加virtual,此时仍然构成重写!

    但是我们并不推荐这样写,推荐在子类重写的函数上加上virtual,这样清晰易懂!

  • 2.协变(基类与派生类虚函数返回值类型不同)

    派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时 ,称为协变。

    注意: 父子类返回值必须都是返回本类类型的指针,而不是只有父类或子类返回本类型指针,这样是错误的,协变只有父类返回父类对象的指针,子类返回子类对象的指针才构成!

父类指针或引用调用函数时,被virtual修饰的函数的调用关系

  • 不满足多态:是哪个类就调用哪个类的函数
  • 满足多态:指向哪个对象,就调用哪个对象的函数

补充

  • 1.关于析构函数
    我们在继承中介绍到,析构函数会被编译器统一处理成名为 destructor() 函数,子类的析构函数在调用时会先析构自己再调用父类的析构函数。当子类被new出来时且使用父类指针指向地址时,delete时并不能正确的调用析构函数,因为此时发生了切片,父类指针只能调用父类构造函数,子类的析构函数无法被调用!

    C++为了解决这个问题,允许将析构函数使用virtual修饰为虚函数,这样子类就能重写父类的虚函数成功完成析构!


    当没有重写析构函数,使用父类指针指向子类对象时,调用父类析构函数!

重写之后,父类指针调用被重写的虚函数,即子类析构函数!

  • 2.多态细节及缺少参数
    我们观察下面这一段代码:
cpp 复制代码
class A
{
public:
	virtual void func(int n = 668) { cout << "A:" << n << endl; }
	virtual void test() { func(); }
};

class B : public A
{
public:
	virtual void func(int n = 778) { cout << "B:" << n << endl; }
};

class C : public A
{
public:
	virtual void func(int n = 888) { cout << "C:" << n << endl; }
	void test() { func(); }
};

int main()
{
	B b;
	C c;
	b.test();
	c.test();
	return 0;
}

这段代码的执行结果是什么呢?

接下来我们分析一下调用细节:

当我们调用 b.test() 时,B类中并没有test()函数,但是继承了父类A的test函数,于是调用父类的test函数,在父类test函数中,test函数中调用了func函数,func函数被B类重写,因为是父类的test函数,所以此时的指针类型是 A* this 构成了多态,所以先输出 "B:" ,但是子类只是重写了函数体,并没有重写参数,更没有重写缺少参数,所以n仍然是668,最后输出 "B:668" !

接下来可能有人疑惑,那为什么C输出的是888?

首先,test函数是虚函数,C类中重写了test函数,在调用时test函数时,func函数已经被重写,但这个重写没有意义,因为test函数被重写后里面的指针类型为 C* this,并不是父类指针,在调用func时不构成多态,所以n还是自己的参数888,这里并没有多态的成分,而是C的成员函数直接调用,需要区分清楚这两种情况!

这个例子中虽然没有很明显的使用多态,但是却涉及了多态!
还有一点就是,在多态下,子类函数参数的缺省参数以父类的缺少参数为准,因为子类只是重写了函数体,接口是继承父类的,参数是接口上的,与子类重写无关!

接下来我们通过一段代码证明上面代码test函数的分析结论:

cpp 复制代码
class A
{
public:
	virtual void func() { cout << "A" << endl; }
	virtual void test() 
	{
		cout << typeid(this).name() << endl; //获取this指针的类型
		cout << typeid(*this).name() << endl; //获取this对象的类型
		func(); 
	}
};

class B : public A
{
public:
	virtual void func() { cout << "B" << endl; }
};

class C : public A
{
public:
	virtual void func() { cout << "C" << endl; }
	virtual void test() //重写test函数
	{ 
		cout << typeid(this).name() << endl;
		cout << typeid(*this).name() << endl;
		func(); 
	}
};

int main()
{
	B b;
	C c;
	b.test();
	c.test();
	return 0;
}

这里可以发现,b对象没有重写test函数,调用的是父类的test函数,所以this指针类型是A,但是我们指向的是B类型的对象,所以解引用后是B类型,最后执行重写的func方法,而C对象也如预期的结果一样!

所以虚函数就是 虚拟 的函数,可以被覆盖的、实际形态未确定的函数,使用 virtual 修饰后,就是在告诉编译器:标记此函数,调用时要触发覆盖行为,同时虚表指针需要找到正确的函数进行调用!

注意:

  • 类的静态成员函数不能被修饰为虚函数,因为没有this指针
  • 虚继承中的virtual和虚函数中的virtual没有任何关系

关于final和override

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

final作用

  • 修饰类的虚函数时:不让子类的虚函数与其构成重写,即不构成多态
cpp 复制代码
//修饰函数方式
class Base
{
public:
	//增加在函数参数与函数体之间即可
	virtual void func() final {}
}
  • 修饰类时:不让其他类继承本类
cpp 复制代码
//修饰类方式
class Base final {};
//在类名和类 {} 之间添加final

override作用

检查是否成功重写虚函数,如果重写失败则报错

关于重载,重写和重定义

  • 重载: 两个函数在同一作用域下,函数名相同参数不同,则构成重载!

  • 重写(覆盖): 两个函数分别在基类和派生类中,函数名参数和返回值都相同(协变除外),在基类函数是虚函数的情况下,构成重写

  • 重定义(隐藏): 两个函数分别在基类和派生类中,两个函数名相同,则这两个同名函数不构成重写就是重定义!

    关于这三个概念,最容易混淆的是重写和重定义!
    注意:在类中,父类函数与子类函数同名且父类函数为非虚函数时构成隐藏,否则为重写!


抽象类


什么是抽象?抽象是一组具体事务或物品的统称,例如我们在继承中提到的"水果","水果"就是一个抽象名词,而苹果和西瓜是水果的具体表现或表达!

概念

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

类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生

类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

cpp 复制代码
//包含纯虚函数的类是抽象类 Test就是抽象类
class Base
{
public:
	//实现函数体的纯虚函数
	virtual void func1() = 0 { cout << "func" << endl; };
	//只声明的纯虚函数
	virtual void func2() = 0;
	//虚函数
	virtual void func3() { cout << "0.0" << endl; }
	//普通成员函数
	void tmp() { cout << "tmp" << endl; };
};

//如果子类继承了抽象类 但是不重写抽象类纯虚函数 则子类也是抽象类
class Child1 : public Base {};

//没有将抽象类中的纯虚函数全部重写 则该子类仍然是抽象类
class Child2 : public Base 
{
public:
	virtual void func1() {}
};

//虚函数可以选择只继承,只要重写了全部的纯虚函数就不是抽象类
class Child3 : public Base
{
public:
	virtual void func1() {}
	virtual void func2() {}
};

注意:只要类中有一个纯虚函数,那么这个类就是抽象类,纯虚函数可以实现函数体也可以不实现,被子类继承后都是要重写的,所以抽象类的纯虚函数实现意义不大,抽象类中也可以有虚函数和非虚函数,非虚函数也就是普通成员函数,被继承后也可以被子类所用,父类虚函数子类可以选择重写也可以继承直接用。如果子类继承了抽象类但是没有重写抽象类的全部纯虚函数则子类也是抽象类!

c1和c2由于使用了没有重写纯虚函数的子类(即抽象类),抽象类无法实例化出对象;而Child3把虚函数全部重写,可以正常实例化!

子类可以使用抽象类中的非虚函数和虚函数!

补充

接口继承和实现继承: 普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

抽象类类似于对一类事务的统称和描述。如果一个类型在现实中没有对应的实体,就可以定义一个抽象类(例如水果,员工等无法具体出方法)。

抽象类强制子类重写否则子类继承了纯虚函数该类变成抽象类!


多态的原理


在讲解原理前,我们先研究一段代码:

cpp 复制代码
class Base
{
public:
	virtual void func() { cout << "func" << endl; }
	
public:
	int num;
	char ch;
};

int main()
{
	cout << sizeof(Base) << endl;
	return 0;
}

这里的Base对象所占空间大小应该是多少呢?

按照正常的结构体计算方法(32位平台下),应该是8字节大小,但是却输出12,那么这多出来的四个字节是存了什么呢?

我们构建对象通过调试查看:

这个 _vfptr 是什么呢?我们可以理解为 virtual function table ptr,也就是虚函数表指针(简称虚表指针)。
没错,虚函数地址是存储在虚表中,在多态调用时,使用 虚表指针->虚表->函数声明顺序 调用虚函数!

虚表指针和虚表

通过上面的验证,我们可以肯定_vfptr就是虚表指针,通过解引用虚表指针就可以找到虚表,而虚表(虚函数表)中存储的是虚函数指针,可以在调用函数时根据不同的地址调用不同的虚函数。

接下来我们研究一下虚表:

cpp 复制代码
class Base
{
public:
	virtual void func1() { cout << "Bfunc1" << endl; }
	virtual void func2() { cout << "Bfunc2" << endl; }
	void func3() { cout << "Bfunc3" << endl; }
};

class Child : public Base
{
public:
	virtual void func1() { cout << "Cfunc1" << endl; }
	virtual void func4() { cout << "Cfunc4" << endl; }
};

int main()
{
	Base b;
	Child c;
	return 0;
}


从这里我们可以发现,虚表是真实存在的,从这里我们可以发现:

  • 对象空间的前四个字节存放虚表地址
  • 虚表指针指向虚表,虚表中存储的是虚函数地址,而 32 位平台中指针大小为 4 字节
  • 我们可以先将虚表指针强转为 指向首个虚函数 的指针,然后遍历虚表打印各个虚函数地址验证即可
  • 从两个虚函数表中我们可以看出VS对虚函数表做了特殊处理,在每一个虚函数表最后一个位置放置了一个nullptr,其他平台可能不是该规则

通过以上规则我们可以将虚函数地址依次打印出来从而得到真正的虚函数表!

cpp 复制代码
//定义一个返回值为void参数为空的函数指针
typedef void(*VF_PTR)();

//打印虚表
void printvft(VF_PTR arr[]) //arr指向虚表 相当于 VF_PTR**
{
	for (int i = 0; arr[i]; ++i)
		printf("[%d]->%p\n", i, arr[i]); //输出每一个虚函数地址
	cout << endl;
}

int main()
{
	Base b;
	Child c;
	printvft((VF_PTR*)(*(int*)(&b)));
	printvft((VF_PTR*)(*(int*)(&c)));
	return 0;
}

这里需要解释的是:

取出虚表的流程分为四步!

但是这种写法只适合32为,因为我们只取出了4个字节,如果是64位,还需要这样写:

cpp 复制代码
//64 位平台下指针大小为8字节
printvft((VF_PTR*)(*(long long*)&b));
printvft((VF_PTR*)(*(long long*)&c));

这样强转还是很繁琐,既然我们的虚函数是VF_PTR类型,虚表指针是一个二级指针,直接指向存放VF_PTR的指针数组,我们可以直接取出地址强转为 VF_PTR** 然后再解引用得到虚表的首地址,这样无论是32位还是64位都可以使用!

cpp 复制代码
printvft(*((VF_PTR**)(&b)));
printvft(*((VF_PTR**)(&c)));

最终arr得到的是一个存储 VF_PTR 函数指针的指针数组!

注意,不能写成 printvft((VF_PTR*)(&b)),虚表地址存放的是虚表首地址,需要解引用得到虚表首地址才能访问,否则直接取地址转化为 VF_PTR* 在进行指针偏移时会直接跳过整个虚表(实际是从虚表地址偏移走了,没有获取真正的虚表地址),类似于:(int arr[10];int* ptr = &arr;++ptr;)。

此时ptr并不是指向arr[1],而是直接跳过了整个arr数组(相当于跳过了40个字节)!

综上所述,虚表是真实存在的,只要当前类中涉及了虚函数,那么编译器就会为其构建相应的虚表体系!

关于虚表

  • 虚表是在 编译 阶段生成的
  • 虚表指针是在构造函数的 初始化列表 中初始化的
  • 虚表一般存储在 常量区(代码段),有的平台中可能存储在 静态区(数据段)

一段代码验证虚表的存储位置:

cpp 复制代码
int main()
{
	Base obj;
	
	int a = 0;			//栈区
	static int b = 0;   //静态区
	const char* s = "668";    //常量区
	int* d = new int;   //堆区

	printf("a 栈区->:%p\n", &a);
	printf("b 静态区->:%p\n", &b);
	printf("c 常量区->:%p\n", s);
	printf("d 堆区->:%p\n", d);
	printf("\n虚表:\n");
	printvft(*((VF_PTR**)(&obj)));

	delete d;
	return 0;
}

这里可以发现,虚表地址与静态区和常量区相近,因此可以推测虚表位于常量区或静态区中,因为它需要被同一类中的不同对象共享,同时不能被修改(如同代码段一样只读),虚函数地址也是一样,也存放在静态区或常量区!

小结

  • 子类对象中也有一个虚表指针,子类对象由两部分构成,一部分是父类继承下来的成员,虚
    表指针也就是存在部分的另一部分是自己的成员。
  • 基类对象和子类类对象虚表是不一样的,这里我们发现子类对象对父类虚函数(例如Base::func1)完成了重写,所以的虚表中存的是重写的子类虚函数(即Child::func1),所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数。的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  • 重写和继承下来的虚函数都会放入子类的虚表中,而非虚函数则不会放入虚表,但是子类自己的虚函数还是会放入虚表!
  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr(VS平台是这样处理的,但有时候会出现处理不到位的情况,最后一个位置不是nullptr)。
  • 关于子类的虚表生成:a.先将基类中的虚表内容拷贝一份到子类虚表中;b.如果子类重写了基类中某个虚函数,用子类自己的虚函数覆盖虚表中基类的虚函数;c.子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。
  • 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。
  • 对象中存的不是虚表,存的是虚表指针(虚表地址),存储在静态区或常量区。
  • 构造函数不能是虚函数,虚表是在初始化列表阶段初始化。
  • 如果构成多态,普通函数的调用可能比虚函数快。
  • 对虚函数的调用不一定就是动态绑定!例如在类的构造函数中,调用虚函数,也是静态绑定(构造函数中调用其它任何函数,都不会发生动态绑定)。也就是说在有对象之前是不能发生动态绑定的,构造函数执行完才能生成对象;其次,如果不是通过指针或者引用变量来调用虚函数,而是通过对象来调,那就是静态绑定。

关于将虚函数修饰为内联函数:虚函数修饰为内联函数,内联函数对编译器来说只是一个建议,编译器不会把虚函数处理为内联函数,符合多态就不是内联,不符合多态就可能成为内联函数。

关于虚函数的调用

多态的过程

父类构建虚函数,子类继承后重写虚函数,在使用父类指针或引用指向子类对象时触发切片,切出父类可以访问的范围,但是其中虚表不会受到任何影响,虚表还是子类的虚表,所以此时使用父类指针调用虚函数时会调用子类重写的虚函数,此时显现出同一事务不同执行结果的现象,使用总结下来就是以下四点:

  • 首先父类存在虚函数允许继承和重写并在子类中成功重写
  • 其次使用 父类指针 或 父类引用 指向对象,此时发生切片
  • 切片后,将子类中不属于父类的切掉,只保留父类指针可调用到的部分函数和虚表(属于子类的虚表)
  • 最终调用都是一样的,在调用时,根据函数名和函数声明位置,在虚表中找到函数地址并执行代码,展现出来的就是虚表中的同名函数执行的逻辑不同,这就是多态

我们可以通过汇编查看多态调用和普通调用的区别:

cpp 复制代码
class Base
{
public:
	virtual void func() { cout << "Base" << endl; }
};

class Child : public Base
{
public:
	virtual void func() { cout << "Child" << endl; }
};

//破坏多态条件 非多态调用
//void test(Base b) 
//{ 
//	b.func();
//}

//多态调用
void test(Base& b)
{
	b.func();
}

int main()
{
	Child c;
	test(c);
	return 0;
}

我们可以发现多态调用和非多态调用底层的执行过程完全不一样(图中是两次运行的运行过程)!

动态绑定和静态绑定

我们发现满足多态以后的函数调用不是在编译时确定的,是运行起来以后到对象的中取找的。而不满足多态的函数调用时编译时确认好的。此时我们需要介绍动态绑定和静态绑定!

概念

  • 动态绑定(后期绑定/晚绑定):是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
  • 静态绑定(前期绑定/早绑定):是在程序编译期间确定了程序的行为,也称为静态多态,例如函数重载。
cpp 复制代码
int Add(int num1, int num2)  { return num1 + num2; }
double Add(double num1, double num2) { return num1 + num2; }

int main()
{
	Base* ptr = new Child;
	ptr->func();

	Add(1, 2);
	Add(3.14, 6.68);
	return 0;
}

我们可以发现,虚函数在调用时有一个查虚表取函数地址放入寄存器的过程,最终直接call调用寄存器中的函数地址所指向的函数,寄存器中具体是什么函数的地址未定,而重载函数是直接调用已经确定好了的函数地址!


单继承与多继承中的虚表


单继承中的虚表

单继承中的虚表比较简单,无非就是子类中的虚函数对父类中相应的虚函数在虚表中进行覆盖,单继承不会出现虚函数冗余的情况,顶多就是子类与父类构成重写。

在单继承下

  • 向父类中新增虚函数:父类的虚表中会新增,同时子类会继承
  • 向子类中新增虚函数:只有子类能看到,因此只会纳入子类的虚表中,使用父类指针或引用指向子类对象时无法调用
  • 向父类/子类中添加非虚函数时:不进入虚表,仅当作普通的类成员函数处理

多继承中的虚表

由于 C++ 中支持多继承,这也就意味着可能出现 多个虚函数在不同的虚表中被重写 的情况,更会面临不同虚表中的相同虚函数重写!

cpp 复制代码
class Base1
{
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
};

class Base2
{
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
};

class Child : public Base1,public Base2
{
public:
	virtual void func1() { cout << "Child::func1" << endl; }
	virtual void func3() { cout << "Child::func3" << endl; }
};

int main()
{
	Child c;
	return 0;
}

在多继承下,c对象会有两张虚表:

此时我们会发现两个问题:

  • 在两张表的情况下,子类新增的虚函数会放在那一张表?
  • 子类中重写虚函数func1,但是两张表中的函数地址却不相同?

接下来我们一 一研究这两个问题!

多继承下子类虚函数的归属问题

我们使用前面实现的printvft函数打印两个虚表,在此之前我们需要知道两张虚表在对象中的放置:

按照这个原理,我们只需要取出第一个虚表的地址后对Base1首地址(Base1虚表地址)强转为char*后 +sizeof(Base1) 就可以到第二个对象首地址,也就可以取到Base2中的虚表了!

cpp 复制代码
typedef void(*VF_PTR)();
void printvft(VF_PTR arr[])
{
	for (int i = 0; arr[i]; ++i)
	{
		printf("%p->", arr[i]);
		arr[i](); //执行函数
	}
	cout << endl;
}

int main()
{
	Child c;
	cout << "Base1:" << endl;
	printvft(*((VF_PTR**)(&c)));
	cout << "Base2:" << endl;
	printvft(*((VF_PTR**)(((char*)&c)+sizeof(Base1))));
	return 0;
}

在输出函数地址并调用后,我们发现子类新增的虚函数放在了第一张虚表中!

当然我们也可以利用切片的原理,使用Base2*指针指向子类然后取首地址就能取出第二张虚表!

cpp 复制代码
//利用切片原理
Base2* ptr = &c;
printvft(*((VF_PTR**)ptr)); //取第二个虚表首地址并打印

此时第一个问题已经有了答案,子类新增的虚函数会放在第一个虚表中!

同一个虚函数不同地址的问题

首先,如果同一个函数重写两次,那么就会发生冗余虚函数的调用问题!

我们分别使用两个父类的指针指向对象对重写的虚函数进行调用通过汇编监视调用过程:

cpp 复制代码
int main()
{
	Child c;
	Base1* ptr1 = &c;
	Base2* ptr2 = &c;
	ptr1->func1();
	ptr2->func1();
	
	return 0;
}

从汇编中我们发现,ptr1的重写虚函数调用与单继承没有什么区别,但是ptr2在真正调用之前进行了几步转换,最后调用了同一个虚函数!

这里我们可以推测:编译器在调用时,根据不同的地址寻找到同一函数,解决冗余虚函数的调用问题!

调用细节

ptr2在调用时的关键语句 sub ecx 4

sub 在汇编中表示减法,ecx 通常存储 this 指针,4(字节)则是Base1的大小(因为只有一个虚表指针)。

这条语句表示将当前的 this 指针向前偏移 sizeof(Base1)偏移到Base1的虚表处,后续再 jmp 时,调用的就是同一个 func1(与我们打印虚表思想一致)。

这一过程称为 this 指针修正,用于解决冗余虚函数的调用问题!

为什么是 Base2 修正而不是对Base1修正,因为在继承顺序上我们先继承了Base1,如果我们先继承Base2,那么修正的就是Base1。

这种巧妙的设计成功解决了多继承下虚表的调用问题!

这也回答了为什么Base1和Base2中重写的虚函数地址不同的原因,在调用时会对this指针进行修正,最终仍然调用的是Base1中的重写虚函数!

菱形继承与多态

菱形继承问题是 C++ 多继承中的大坑,为了解决菱形继承问题,提出了虚继承和虚基表的相关概念,那么在多态的加持之下又多了一个虚表,菱形继承多态变得更加复杂,需要函数调用链路设计的更加复杂!

关于虚表和虚基表

  • 虚表:存虚函数的地址,实现多态
  • 虚基表:存偏移量,解决菱形继承下数据冗余和二义性问题

在菱形继承中,我们发现冗余成员的访问是通过虚基表中的后四个字节存放的偏移量结合虚基表首地址偏移就能访问到冗余成员,而虚基表中的前四个字节就是存放虚表相当于虚基表的偏移量!

cpp 复制代码
//菱形继承+多态
class Base
{
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	int n = 1;
};

class Base1 : virtual public Base
{
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
	int n1 = 2;
};

class Base2 : virtual public Base
{
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
	int n2 = 3;
};

class Child : public Base1,public Base2
{
public:
	virtual void func1() { cout << "Child::func1" << endl; }
	virtual void func3() { cout << "Child::func3" << endl; }
	int num = 4;
};

int main()
{
	Child c;
	return 0;
}

涉及菱形继承+多态的场景几乎很少且非常复杂,如果不是必须,了解即可!

如果想要了解更多关于 菱形继承+多态,可以参考下面的文章:


最后

多态介绍到这里基本就差不多了,本节我们介绍了C++的多态的使用,多态的构成条件,多继承下多态问题,多态的原理等等,相信本节的内容一定可以让大家对C++的设计更加惊叹。多态的学习只看远远不够,还需要大家自己动手使用代码并画图去验证每一个细节,才能对C++多态有更深的印象!

本次 <C++ 多态> 就先介绍到这里啦,希望能够尽可能帮助到大家。

如果文章中有瑕疵,还请各位大佬细心点评和留言,我将立即修补错误,谢谢!

🌟其他文章阅读推荐🌟
C++ <继承> -CSDN博客
C++ <STL容器适配器> -CSDN博客
C++ <STL之list模拟实现> -CSDN博客
C++ <STL之list使用> -CSDN博客
🌹欢迎读者多多浏览多多支持!🌹

​​

​​

相关推荐
Theodore_10223 小时前
4 设计模式原则之接口隔离原则
java·开发语言·设计模式·java-ee·接口隔离原则·javaee
‘’林花谢了春红‘’4 小时前
C++ list (链表)容器
c++·链表·list
----云烟----5 小时前
QT中QString类的各种使用
开发语言·qt
lsx2024065 小时前
SQL SELECT 语句:基础与进阶应用
开发语言
开心工作室_kaic5 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
向宇it5 小时前
【unity小技巧】unity 什么是反射?反射的作用?反射的使用场景?反射的缺点?常用的反射操作?反射常见示例
开发语言·游戏·unity·c#·游戏引擎
武子康5 小时前
Java-06 深入浅出 MyBatis - 一对一模型 SqlMapConfig 与 Mapper 详细讲解测试
java·开发语言·数据仓库·sql·mybatis·springboot·springcloud
转世成为计算机大神6 小时前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
机器视觉知识推荐、就业指导6 小时前
C++设计模式:建造者模式(Builder) 房屋建造案例
c++
宅小海6 小时前
scala String
大数据·开发语言·scala