C++(多态)


目录

前言:

1.多态的概念

2.多态的定义及实现 

 2.1多态的构成条件

 2.2析构函数的重写(基类与派生类析构函数名字不同)

2.3虚函数重写 

2.4C++ override 和final

2.5 重载、覆盖(重写)隐藏(重定义)的对比

3.多态的原理

3.1虚表与续表指针 

3.2动态绑定与静态绑定

4单继承与多继承 

4.1单继承中虚表

4.2多继承中虚表 

4.2.1子类新增虚表归属问题 

 4.2.2多继承虚函数调用问题

4.3菱形继承多态与菱形虚拟继承多态 



前言:

上一章节对面向对象三大特性的继承做了知识复盘,本章节对最后一个特性多态做一个知识梳理和总结。

1.多态的概念

         通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会
产生出不同的状态。可以举个现实中车站买票的例子,同一个窗口,不同年龄多,不同职业的对象去买票价格是不同的(这就是多种形态也就是多态)

2.多态的定义及实现 

 2.1多态的构成条件

        实现多态需要借助虚表,这里的虚表指的是虚函数表,虽然也是借助关键字:virtual, 这里需要和继承里面的虚拟继承区分开,两个概念不能搞混。有了虚函数表,就可以使用父类指针进行不同对象调用实现不同形态(接下来会仔细介绍)

        继承中构成多态的两个条件:

                1-必须通过基类的指针或者引用调用虚函数

                2- 被调用的函数必须是虚函数 ,且派生类必须对基类的虚函数进行重写

        我们通过一个买票的demo 理清楚多态的流程

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

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

};


void Func(Person& person)
{
	person.BuyTicket();
}
int main()
{
	Person p;
	Student s;
	Func(p);
	Func(s);

	return 0;

}

C++(多态)_第1张图片

 注意:除了上面提到的构成多态的两个必要条件,有两个例外是需要注意的

  • 除父类外,其他子类中的函数不必使用 virtual 修饰,此时仍然能构成多态(注意三同,需要构成重写)
  • 父子类中的虚函数返回值可以不相同,但此时需要返回对应的父类指针或子类指针,确保构成多态,这一现象称为 协变(了解)
class A{};
class B : public A {};
class Person {
public:
 virtual A* f() {return new A;}
};
class Student : public Person {
public:
 virtual B* f() {return new B;}
};

 2.2析构函数的重写(基类与派生类析构函数名字不同)

1-析构函数可以是虚函数吗?为什么需要是虚函数?
2-析构函数加virtual,是不是虚函数重写? 

        答案是肯定的,如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor

      为什么要这么处理呢?是要要让他们构成重写吗 那为什么要让他们构成重写呢?我们可以用一个demo 来解释为什么要这么做:

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

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

	~Student() {
		cout << "~Student()" << endl;
		delete[] ptr;
	}

protected:
	int* ptr = new int[10];
};

int main()
{
	//Person p;
	//Student s;

	Person* p = new Person;
	p->BuyTicket();
	delete p;

	p = new Student;
	p->BuyTicket();
	delete p; // p->destructor() + operator delete(p)

	// 这里我们期望p->destructor()是一个多态调用,而不是普通调用

	return 0;
}

        此时 只有BuyTicket函数和父类构成了虚函数重写,且都是由父类指针进行的调用,所以我们会看到买票的多态,但是析构函数并未构成虚函数重写(既不是虚函数也不是重写)再调用delete p的时候,他只是一个普通对象,当前类型为Person* 所以只会去调用父类的析构,从而造成内存泄漏。

C++(多态)_第2张图片

        虽然编译器对析构函数名称做了特殊处理,编译后嘻哈猴函数的名称统一处理成 destructor 

我们还是希望p->destrctor()能够是一个多态调用,而不是普通调用,那就是构成虚函数重写,所以我们就能理解,为什么父类成员必须加上virtual。修改完成后就不会造成内存泄漏了,代码如下:

C++(多态)_第3张图片

总结: 

如何快速判断是否构成多态?

  • 首先观察父类的函数中是否出现了 virtual 关键字
  • 其次观察是否出现虚函数重写现象,三同:返回值、函数名、参数(协变例外)
  • 最后再看调用虚函数时,是否为【父类指针】或【父类引用】

父类指针或引用调用函数时,如何判断函数调用关系?

  • 若满足多态:看其指向对象的类型,调用这个类型的成员函数
  • 不满足多态:看具体调用者的类型,进行对应的成员函数调用

2.3虚函数重写 

        通过上面的介绍,我们知道虚函数是构成多态的必要条件,我们也知道想要构成多态,还需要实现重写,可是重写具体怎么实现,我们好像一笔带过,下面我将用代码,虚函数表的具体演示,派生类如何实现覆盖,重写,以及重写了什么

#include 

using namespace std;

class A
{
public:
	virtual void func(int val = 1) { cout << "A: " << val << endl; }
};

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

int main()
{
	A* p = new B();
	p->func();
	return 0;
}

 C++(多态)_第4张图片

结果解析:初始化两个对象的时候,子类继承父类且都是是虚函数,会创建两张虚函数表,我们发现虚表指针的地址不一样,所以第一步,是两张虚表,当使用父类指针调用的时候,就会去完成重写,将父类的虚表复制,将自己的虚函数函数进行覆盖,但是重写的是实现方法,也就是外壳,内容是不会改变的,所以最后的结果就是 B:1C++(多态)_第5张图片

补充:

         我们已知多态的条件之一就是父类的指针或者引用去调用,那为什么不能子类的指针或者引用去调用呢?为啥不能是父类对象呢?

        答:1因为是复制父类的虚表进行重写,如果是父类调用父类就不用重写,父类调用子类就重写子类属于自己的那部分,如果用子类指针 永远无法调用到父类;

               2 子类赋值给父类对象切片,不会拷贝虚表,如果拷贝虚表,那么父类对象虚表中是父类虚函数还是子列就不确定,会乱套。

2.4C++ override 和final

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

  • final:修饰虚函数,表示该虚函数不能再被重写,对于父类的虚函数,如果加上final就不能被重写,也就无法实现多态

C++(多态)_第6张图片

  • override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

 C++(多态)_第7张图片

 

2.5 重载、覆盖(重写)隐藏(重定义)的对比

         

截至目前为止,我们已经学习了三个 “重” 相关函数知识:重载、重写、重定义

这三兄弟不止名字很像,而是功能也都差不多,很多面试题中也喜欢考这三者的区别

重载:即函数重载,函数参数 不同而触发,不同的 函数参数 最终修饰结果不同,确保链接时不会出错,构成重载

重写(覆盖):发生在类中,当出现虚函数且符合重写的三同原则时,则会发生重写(覆盖)行为,具体表现为 父类虚函数接口 + 子类虚函数体,是实现多态的基础

重定义(隐藏):发生在类中,当子类中的函数名与父类中的函数名起冲突时,会隐藏父类同名函数,默认调用子类的函数,可以通过 :: 指定调用

重写和重定义比较容易记混,简言之 先看看是否为虚函数,如果是虚函数且三同,则为重写;若不是虚函数且函数名相同,则为重定义

   C++(多态)_第8张图片


3.多态的原理

         之前提到过多态需要虚函数表,以及指向虚函数标的指针,我们可以写一个空类,通过测试大小验证一下:

class Parent
{
	virtual void func() {};
};

int main()
{
	Parent p;	
	cout << "Parent : " << sizeof(p) << endl;
	return 0;
}

       C++(多态)_第9张图片

        通过验证我们发现,一个带有虚函数的类的大小在64位平台下是8,因此也就验证了我们猜想,虚函数的类中包含一个虚表指针 。虚表指针->虚表 实现多态。

3.1虚表与续表指针 

        虚函数表(虚表)即 virtual function table -> vft,指向虚表的指针称为 虚表指针 virtual function pointer -> vfptr,在 vs 的监视窗口中,可以看到涉及虚函数类的对象中都有属性 __vfptr(虚表指针),可以通过虚表指针所指向的地址,找到对应的虚表。虚函数表中存储的是虚函数地址,可以在调用函数时根据不同的地址调用不同的方法。

        在下面这段代码中,父类 Person 有两个虚函数(func3 不是虚函数),子类 Student 重写了 func1 这个虚函数,同时新增了一个 func4 虚函数

#include 

using namespace std;

class Person
{
public:
	virtual void func1() { cout << "Person::fun1()" << endl; };
	virtual void func2() { cout << "Person::fun2()" << endl; };
	void func3() { cout << "Person::fun3()" << endl; };	//fun3 不是虚函数
};

class Student : public Person
{
public:
	virtual void func1() { cout << "Student::fun1()" << endl; };

	virtual void func4() { cout << "Student::fun4()" << endl; };
};

int main()
{
	Person p;
	Student s;
    return 0;
}

C++(多态)_第10张图片

如何通过程序验证虚表的真实性?

  • 虚表指针指向虚表,虚表中存储的是虚函数地址,而 64 位平台中指针大小为 8字节
  • 因此可以先将虚表指针强转为 指向首个虚函数 的指针,然后遍历虚表打印各个虚函数地址验证即可。
  • vs 中对虚表做了特殊处理:在虚表的结尾处放了一个 nullptr,因此下面这段代码可能在其他平台中跑不了。
     

 

typedef void (*VF_T)();//函数指针 为下面函数指针数组做铺垫


class Person
{
public:
	virtual void func1() { cout << "Person::fun1()" << endl; };
	virtual void func2() { cout << "Person::fun2()" << endl; };
	void func3() { cout << "Person::fun3()" << endl; };	//fun3 不是虚函数
};

class Student : public Person
{
public:
	virtual void func1() { cout << "Student::fun1()" << endl; };

	virtual void func4() { cout << "Student::fun4()" << endl; };
};
void test(VF_T table[])
{
	int i = 0;
	while(table[i])
	{
		printf(" [%d]:%p->", i, table[i]);
		//虚函数表里面存的是虚函数地址 直接解引用就是该虚函数
		VF_T f = table[i];
		f();
		i++;
	}
	cout << endl;
}
int main()
{
	Person p;
	Student s;
	test((VF_T*)(*(int*)&p));
	test((VF_T*)(*(int*)&s));

	return 0;
}

        C++(多态)_第11张图片

 因为平台不同指针大小不同,因此上述传递参数的方式(VF_T*)(*(int*)&p 具有一定的局限性
假设在 64 位平台下,需要更改为 (VF_T*)(*(long long*)&p

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

虚表相关知识补充:

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

 

int main()
{
	//验证虚表的存储位置
	Person p;
	Student s;

	int a = 10;	//栈
	int* b = new int;	//堆
	static int c = 0;	//静态区(数据段)
	const char* d = "xxx";	//常量区(代码段)

	printf("a-栈地址:%p\n", &a);
	printf("b-堆地址:%p\n", b);
	printf("c-静态区地址:%p\n", &c);
	printf("d-常量区地址:%p\n", d);

	printf("p 对象虚表地址:%p\n", *(VF_T**)&p);
	printf("s 对象虚表地址:%p\n", *(VF_T**)&s);


	return 0;
}

 C++(多态)_第12张图片

显然,虚表地址与常量区的地址十分接近,因此可以推测 虚表位于常量区中,因为它需要被同一类中的不同对象共享,同时不能被修改(如同代码一样)

函数代码也是位于 常量区(代码段),可以在监视窗口中观察两者的差异

C++(多态)_第13张图片 

3.2动态绑定与静态绑定

 静态绑定(前期绑定/早绑定)

  • 在编译时确定程序的行为,也称为静态多态

动态绑定(后期绑定/晚绑定)

  • 在程序运行期间调用具体的函数,也称为动态多态

 

p1->func1();
p2->func1();

add(1, 2);
add(1.1, 2.2);

        简单来说,静态绑定就像函数重载,在编译阶段就确定了不同函数的调用;而动态绑定是虚函数的调用过程,需要 虚表指针+虚表,在程序运行时,根据不同的对象调用不同的函数

C++(多态)_第14张图片 


4单继承与多继承 

         需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类 的虚表模型前面我们已经看过了,没什么需要特别研究的。

4.1单继承中虚表

我们上面研究的基本都是子类继承父类,对父类中的虚函数进行覆盖重写。 

C++(多态)_第15张图片 

向父类中新增虚函数:父类的虚表中会新增,同时子类会继承,并纳入自己的虚表之中

向子类中新增虚函数:只有子类能看到,因此只会纳入子类的虚表中,父类是看不到并且无法调用的

向父类/子类中添加非虚函数时:不属于虚函数,不进入虚表,仅当作普通的类成员函数处理

4.2多继承中虚表 

   C++ 中支持多继承,这也就意味着可能出现 多个虚函数重写 的情况,当父类指针面临 不同虚表中的相同虚函数重写 时,该如何处理呢?  

#include 
using namespace std;

//父类1
class Base1
{
public:
	virtual void func1() { cout << "Base1::func1()" << endl; }
	virtual void func2() { cout << "Base1::func2()" << endl; }
};

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

//多继承子类
class Derive : public Base1, public Base2
{
public:
	virtual void func1() { cout << "Derive::func1()" << endl; }
	virtual void func3() { cout << "Derive::func3()" << endl; }	//子类新增虚函数
};

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

此时,derive继承了base1和base2,所以derive有两张虚表,分别为 Base1 + Derive::func1 构成的虚表Base2 + Derive::func1 构成的虚表 

 C++(多态)_第16张图片

此时出现了两个问题:

  1. 子类 Derive 中新增的虚函数 func3 位于哪张虚表中?
  2. 为什么重写的同一个 func1 函数,在两张虚表中的地址不相同?

下面我们对这两个问题做一个深度解析。

4.2.1子类新增虚表归属问题 

         在单继承中,子类中新增的虚函数会放到子类的虚表中,因为只有一张表我们没有疑问,多继承中,子类中新增的虚函数默认添加至第一张虚表中,我们可以通过test打印进行验证,因为此时有两张虚表,所以需要分别打印;第一张虚表的地址和子类的首地址重合,只需要取地址+类型强转;第二张虚表就比较麻烦,需要在第一张虚表的起始地址处,跳过第一张虚表的大小,然后才能获取第二张虚表的起始地址。

C++(多态)_第17张图片

 

//打印虚表
typedef void(*VF_T)();

void test(VF_T table[])
{
	//vs中在虚表的结尾处添加了 nullptr
	int i = 0;
	while (table[i])
	{
		printf("[%d]:%p->", i, table[i]);
		VF_T f = table[i];
		f();	//调用函数,相当于 func()
		i++;
	}
	cout << endl;
}

int main()
{
	Derive d;

	test(*(VF_T**)&d);	//第一张虚表
	test(*(VF_T**)((char*)&d + sizeof(Base1)));	//第二张虚表
	return 0;
}

C++(多态)_第18张图片 

        可以看出新增的 func3 函数确实在第一张虚表中;可能有的人觉得取第二张虚表的起始地址很麻烦,那么可以试试利用 切片 机制,天然的取出第二张虚表的地址切片行为是天然的,可以完美取到目标地址.

Base2* table2 = &d;	//切片
PrintVFTable(*(VF_T**)table2);	//第二张虚表

 4.2.2多继承虚函数调用问题

        在上面的多继承多态代码中,子类分别重写了两个父类中的 func1 函数,但最终通过监视窗口发现:同一个函数在两张虚表中的地址不相同;因此可以推测:编译器在调用时,根据不同的地址寻找到同一函数,解决冗余虚函数的调用问题至于实际调用链路,还得通过汇编代码展现:

C++(多态)_第19张图片 

C++(多态)_第20张图片 

        ptr2 在调用时的关键语句 sub ecx 4;sub 表示减法,ecx 通常存储 this 指针4 表示 Base1 的大小;这条语句表示将当前的 this 指针向前偏移 sizeof(Base1),后续再 jmp 时,调用的就是同一个 func1;这一过程称为 this 指针修正,用于解决冗余虚函数的调用问题

        为什么是 Base2 修正?因为先继承了 Base1,后继承了 Base2,假设先继承的是 Base2,那么修正的就是 Base1这种设计很大胆也很巧妙,完美解决了多继承多态带来的问题因此回答问题二:两张虚表中同一个函数的地址不同,是因为调用方式不同,后继承类中的虚表需要通过 this 指针修正的方式调用虚函数。

4.3菱形继承多态与菱形虚拟继承多态 

        菱形继承问题是 C++ 多继承中的大坑,为了解决菱形继承问题,提出了 虚继承 + 虚基表 的相关概念,那么在多态的加持之下,菱形继承多态变得更加复杂:需要函数调用链路设计的更加复杂菱形虚拟继承多态就更不得了:需要同时考虑两张表:虚表、虚基表

  • 虚基表中空余出来的那一行是用来存储偏移量的:表示当前虚基表距离虚表有多远
     

 C++(多态)_第21张图片

 

 

 

 

 

你可能感兴趣的:(C++,c++,面试,开发语言)