C++多态

C++多态

  • 一,多态的概念
  • 二,多态的定义以实现
    • 虚函数
    • 多态构成条件
    • 虚函数的重写
      • 虚函数重写的两个例外
    • C++11的override和final
    • 重载,重写和重定义的对比
  • 抽象类
    • 概念
    • 接口继承与实现继承
  • 多态的原理
    • 虚函数表
      • 打印虚函数表
    • 多态原理
    • 静态绑定与动态绑定
  • 多继承中的虚函数表
  • 经典问题

一,多态的概念

多态: 就是多种形态,不同的对象去完成同样的事情会产生不同的结果。
举个例子:就拿购票系统来说,不同的人对于购票这个行为产生的结果就是不同的,学生购票时购买的是半价票,普通人购票的时候购买的是全价票。

二,多态的定义以实现

虚函数

虚函数:即被virtual修饰的类成员函数称为虚函数。

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

多态构成条件

构成多态要满足两个条件:
1,派生类重写基类的虚函数
2,基类指针或者引用指向派生类对象

虚函数的重写

虚函数重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型函数名称参数列表全相同),称为派生类重写了基类的虚函数。

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

void func(Person* pp)
{
	pp->BuyTickets();
}
int main()
{
	Person p;
	Student s;
	func(&p);
	func(&s);
	return 0;
}

上面这段代码满足多态的条件:1,Student类重写了Person类的虚函数。2,存在基类指针Person* 指向了派生类对象 。

在这里插入图片描述

虚函数重写的两个例外

  • 协变
    派生类重写基类的虚函数时,虚函数的返回值类型可以不同,但是要满足基类虚函数返回基类的指针或引用,派生类虚函数返回派生类的指针或引用,称为协变。
    注意: 这里的基类返回基类指针或引用,派生类返回派生类指针或引用,指的是这两个返回值存在继承关系就可以。
class A
{
public:
	A()
	{}
};
class B : public A
{
public:
	B()
	{}
};

class Person
{
public:
	virtual A* BuyTickets()
	{
		cout << "全价票" << endl;
		return nullptr;
	}
};
class Student : public Person
{
public:
	virtual B* BuyTickets()
	{
		cout << "半价票" << endl;
		return nullptr;
	}
};

void func(Person* pp)
{
	pp->BuyTickets();
}
int main()
{
	Person p;
	Student s;
	func(&p);
	func(&s);
	return 0;
}

  • 析构函数(派生类析构函数与基类析构函数名字不同)
    之所以对于析构函数虚函数的重写,可以派生类的函数名称与基类函数名称不同是因为编译器会对析构函数做调整编译器会自动将析构函数的名称修改为destructor()。

  • 基类声明为虚函数,子类可以不加virtual关键字

class A
{
public:
	A()
	{}
};
class B : public A
{
public:
	B()
	{}
};

class Person
{
public:
	virtual A* BuyTickets()
	{
		cout << "全价票" << endl;
		return nullptr;
	}
};
class Student : public Person
{
public:
	B* BuyTickets() //只要基类声明为虚函数,子类这里可以不加virtual关键字修饰
	{
		cout << "半价票" << endl;
		return nullptr;
	}
};

C++11的override和final

  • override
    作用是检查派生类虚函数是否重写了基类的某个虚函数,如果没有重写就会编译报错。
class A
{
public:
	virtual void func()
	{
		cout << "A::func()" << endl;
	}
};
class B : public A
{
public:
	virtual void func1() override
	{
		cout << "B::func1()" << endl;
	}
};


int main()
{
	B bb;
	return 0;
}

由于B这个派生类的func1函数加了override关键字修饰,但是其并没有重写了基类A的某一个虚函数,所以在这时会编译报错。

在这里插入图片描述

  • final
    作用是修饰某个虚函数,被修饰的虚函数不能被重写
class A
{
public:
	virtual void func() final
	{
		cout << "A::func()" << endl;
	}
};
class B : public A
{
public:
	virtual void func() override
	{
		cout << "B::func()" << endl;
	}
};


int main()
{
	B bb;
	return 0;
}

由于基类A的func函数被final修饰,表示这个函数不能被重写,而在B类中重写了A类的func函数,这时也会编译报错。

在这里插入图片描述

重载,重写和重定义的对比

重载: 重载是指函数之间构成重载,函数重载的前提是两个及多个函数在统一作用域内,并且函数名称相同,但参数列表不同(参数的个数,参数的类型不同),其原理就是编译器在编译的时候会对函数名进行修饰具体平台不同修饰的规则也不同,但是修饰后即使函数名相同只有参数列表不同也能够被区分。
重定义: 重定义又叫隐藏,指的是两个变量或者函数分别处于基类和派生类的作用域,但是两个变量或者函数名字相同(与重写的要求不同,这里只要求函数名相同即可)就会构成重定义。重定义就是用派生类对象去访问这些重名的函数或变量或者在派生类类内访问这些重名的函数或变量访问的都是派生类的,会把继承自基类的变量或函数屏蔽掉。
重写: 重写又叫覆盖, 指的是两个函数的作用与分别在基类和派生类中,并且要求这两个函数是虚函数,还要要求两个函数的返回值相同,函数名相同,参数列表相同(协变除外),这样才构成重写,并且两个基类和派生类的同名函数不构成重写就会构成重定义。

抽象类

概念

在虚函数的后面加上=0,则这个函数就会被称为纯虚函数。包含纯虚函数的类叫作抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后如果没有重写该虚函数,那么派生类仍旧为抽象,不能实例化出对象。所以纯虚函数规范了派生类必须重写,另外纯虚函数的继承体现出的是接口继承。

接口继承与实现继承

对于基类是抽象类,派生类在继承基类的时候继承的就是虚函数的接口,也就是接口继承,如果想要继承纯虚函数的实现那么必须显示的指明。
对于非纯虚函数而言,派生类在继承时,为接口继承和缺省的实现继承(派生类可以重写实现)。
对于非虚函数,派生类在继承时,为接口继承和强制的实现继承。

多态的原理

虚函数表

class A
{
public:
	virtual void func()
	{
		cout << "A::func()" << endl;
	}
	int _a;
};
int main()
{
	cout << sizeof(A) << endl;
	return 0;
}

你试着计算下A类的大小是占多少字节呢?(32位环境下)
如果没有学习过虚函数表的友友,可能张口就是4字节,事实可不是这样哟。
在这里插入图片描述
A类的大小居然是8字节,这说明A类中不仅存在一个_a变量,貌似还存在了其他东西,借助监视窗口来看一下。
C++多态_第1张图片

可以看到除了_a变量外还有一个_vfptr指针,并且该指针还指向了一个数组。其实这个指针就是虚函数表指针,指向的是虚函数表,表中存放的是虚函数的地址。只要某个类中声明了虚函数那么就会产生对应的虚函数表,里面存放的是虚函数的指针,并且该类实例化出的对象中出了包含该类的成员变量,还会包括一个虚函数表指针。

class A
{
public:
	virtual void func()
	{
		cout << "A::func()" << endl;
	}
	int _a = 0;
};
class B : public A
{
public:
	virtual void func()
	{
		cout << "B::func()" << endl;
	}
	int _b = 0;
};
int main()
{
	A aa;
	B bb;
	return 0;
}

如果某个类中存在虚函数,那么就存在其对应的虚函数表,并且该类被其子类继承时,其虚函数表也会被子类继承。
并且如果子类中重写了继承自父类的虚函数,那么就会将虚函数表中对应的函数地址覆盖称子类中重写的函数的地址,这就叫做覆盖,覆盖是原理层的而重写是语法层的。

C++多态_第2张图片
可以看到子类继承自父类的虚函数表中func函数地址是重写之后的函数地址,已经将父类的func函数地址覆盖掉。

如果父类中的虚函数没有被子类重写,那么子类的虚函数表中的地址仍然是父类中虚函数的地址。
只有虚函数才会进虚函数表,非虚函数是不进虚函数表的。

class A
{
public:
	virtual void func()
	{
		cout << "A::func()" << endl;
	}
	virtual void func1()
	{
		cout << "A::func1()" << endl;
	}
	void func2()
	{
		cout << "A::func2()" << endl;
	}
	int _a = 0;

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

C++多态_第3张图片
如果派生类中存在新增加的虚函数,那么就会按照在派生类中的声明顺序依次添加到派生类的虚函数表的最后。

虚函数表本质就是一个虚函数指针数组,而虚函数表指针本质就是这个数组的首元素地址。虚函数表的最后一个字段通常置为nullptr。

更细节的来说,我猜测这个虚函数指针数组,应该是开辟的一块void的空间,将每个虚函数的地址填入到数组中时再做相关的强转操作,因为我们发现不同的虚函数它们的参数类型返回值都可能不同,所以它们的函数指针类型也是不同的,但是数组中的内容必须是同类型的,由此推断这个虚函数指针数组的类型应该是 void 的。有的人可能会有疑问,那void*类型怎么调用函数呢?这个不用担心,因为在机器级里指针都是一样的就是一串数字,等到调用的时候,因为编译器预先知道了函数的参数类型,返回值类型,可以自动做好转换。

打印虚函数表

class A
{
public:
	virtual void func()
	{
		cout << "A::func()" << endl;
	}
	virtual void func1()
	{
		cout << "A::func1()" << endl;
	}
	void func2()
	{
		cout << "A::func2()" << endl;
	}
	int _a = 0;

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

就以上面这段代码为例,打印出A类和B类的虚函数表的内容。因为这些函数的参数类型和返回值都是void,所以我们就当虚函数指针数组中元素的类型为void(*)(void)。

typedef void(*pf)(void);

打印虚表的步骤:

1,取到实例对象的头部四个字节的内容(32位下指针大小四个字节)。这四个字节的内容就是虚函数指针数组的首元素的地址。
2,取到四个字节的内容后将其强转为pf*类型。
3,顺换打印数组中的内容,直到遇到nullptr就停止(虚函数指针数组的最后一个位置为nullptr。
C++多态_第4张图片

可以通过下面这段代码来打印虚函数表

int main()
{
	A aa;
	B bb;
	//1.取到对象头部的四个字节内容--虚函数表指针
	A* p = &aa;
	pf* pp = (pf*)(*((int*)p));
	//2.pp这个指针是函数指针数组的首元素的地址。
	while (*pp)
	{
		printf("%x\n", *pp);
		pp++;
	}
	cout << "-----------------" << endl;

	B* pb = &bb;
	pp = (pf*)(*((int*)pb));

	while (*pp)
	{
		printf("%x\n", *pp);
		pp++;
	}
	return 0;
}

C++多态_第5张图片

直观的可以看到,func函数的地址被覆盖,对于func1来说子类没有重写所以两个表中的地址是相同的。

多态原理

派生类对基类虚函数重写,基类指针或引用指向子类对象,再去调用重写的虚函数的时候,调用的就是子类的函数,而基类的指针或者引用指向基类对象,那么在调用的时候调用的是基类的虚函数。这是构成多态的条件。
通过汇编代码可以清楚的看出多态到底是如何完成的?重点看图片中框出的代码。
C++多态_第6张图片

00152705  mov         eax,dword ptr [pa]  
//pa是A类型的指针,指向了A类对象。
//这句代码的意思就是将指针pa赋值给eax。
00152708  mov         edx,dword ptr [eax] 
//[eax]是取eax指向内容,意思就是将虚函数表指针赋值给edx
0015270F  mov         eax,dword ptr [edx]  
//[edx]是取edx指向内容,意思是将虚函数表中的第一个虚函数指针赋值给eax
00152711  call        eax  
//call 虚函数指针,就是调用虚函数。可以看到具体调用哪个函数不是在编译时就确定的,而是在运行时再去虚函数表中去找的。

对比一下直接使用时的汇编代码
C++多态_第7张图片
不是多态调用的函数调用,可以看到在编译时就确定好了调用的函数地址。

总结:
多态的本质就是在汇编的时候做了手脚,通过上面对汇编代码的分析,可以看出这段汇编代码对于指针指向的对象到底是谁是无感的,也并不关心指向谁,反正只要是通过指针或者引用调用的虚函数,都要去虚函数表中特定的位置去找,最终都是call 这个位置的虚函数指针。
到现在你对多态的理解应该是很通透的了,现在回想一下多态的条件:1,虚函数重写。2,基类指针或引用指向派生类对象。这么做的原因就是:多态调用是去call 虚函数表中某个具体位置的虚函数指针,如果基类指针指向的基类对象那么就调用基类的虚函数,指向派生类对象就调用重写后的虚函数地址,这也印证了重写就是对虚函数表中的内容做覆盖。

思考: 在基类与派生类的对象赋值转换中,我们知道派生类对象也是可以赋值给基类对象的(不仅仅可以使指针和引用),那么是不是就可以通过对象来完成多态的调用呢?

答案是错误的,因为在派生类对象赋值给基类对象的过程中发,虚表是不会赋值的,因为一旦对象之间虚表也可以被赋值的话,那你想想看你目前拿到的对象是基类对象还是派生类对象,你是如何区分的?就会造成基类对象和派生类对象是区分不开的。

静态绑定与动态绑定

静态绑定又叫前期绑定(早绑定),是在编译期间就确定了程序的行为,也称为静态多态,典型的例子就是函数重载。
动态绑定又称后期绑定(晚绑定),是在程序的运行期间,根据具体指向的数据类型来确定程序的具体行为,也称为动态多态。

多继承中的虚函数表

以某个派生类继承了两个基类为例,那么这个派生类中肯定会存在两张虚函数表,此时你是否会有疑问?如果派生类中没有重写的虚函数的地址是放在第一张虚表中。还是放在第二张虚表中,还是都放呢。打印以下虚表来看下这个问题。

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;
};
typedef void(*pf)(void);
int main()
{
	Derive dd;
	pf* pp = (pf*)(*((int*)&dd));
	while (*pp)
	{
		printf("%x\n", *pp);
		pp++;
	}
	cout << "------------------------" << endl;

	pp = (pf*)(*((int*)((char*)&dd + sizeof(Base1))));
	while (*pp)
	{
		printf("%x\n", *pp);
		pp++;
	}

	return 0;
}

在打印第二张虚表的时候,略比第一次复杂,因为第二张虚表的位置在dd对象的中间,所以我们需要将首地址强转为char*,然后加上一个Base1对象的偏移量,找到第二张虚表的位置。起始我们可以借助基类指针指向子类对象时切片的原理,来简化这一步的操作,利用Base2*的指针指向dd对象,这样直接就获取到了第二张虚表的位置。所以代码可以简化成下面这种形式:

Base2* p2 = &dd;
pp = (pf*)(*((int*)p2));
while (*pp)
{
	printf("%x\n", *pp);
	pp++;
}

C++多态_第8张图片
实验证明派生类中没有重写的虚函数的地址放在第一张虚表中。

解决上一个问题的同时,又出现了一个新的问题,就是派生类中重写了Base1中的func1,也重写了Base2中的func1,理论上在两张虚表中关于func1函数的地址应该是相同的,但是打印出来的却不是这样,原因是什么?借助汇编来看一下。

Derive dd;
Base1* p1 = &dd;
p1->func1();
Base2* p2 = &dd;
p2->func1();

p1->func1();过程

C++多态_第9张图片
C++多态_第10张图片
C++多态_第11张图片

p2->func1();过程

C++多态_第12张图片
C++多态_第13张图片
C++多态_第14张图片
C++多态_第15张图片
C++多态_第16张图片

清晰的可以看到,用p2调用func1的时候发生了好几次jmp,而有一次jmp中修正了this指针(ecx寄存器中的值),但是在最后都调到了同一个函数。
这从侧面印证了第二张虚表中的func1的地址根本就不是函数地址。

修正this指针的原因?

通过p2对调用func1的时候,p2是指向dd的中间部分的,如果func1中需要访问dd中的某个成员变量,我们知道在类中访问一个成员变量是通过this指针+偏移量来完成的,此时p2指向dd对象中间部分如果再加上原本的偏移量是访问不到想要访问的成员变量的,并且还有越界的风险。

经典问题

inline函数可以是虚函数嘛?

inline函数可以是虚函数,但是其内联的特性也就没有了,因为inline只是对编译器的建议。内联函数是在调用的地方展开,没有函数地址,而虚函数的地址是要写入虚函数表的,所以内联函数和虚函数只能为其中的一个,不可兼得。

静态成员函数可以是虚函数嘛?

不可以,因为静态成员函数没有this指针,通常都是直接通过类名调用的,无法访问到虚函数表,所以不可以为虚函数。

构造函数可以是虚函数嘛?

不可以,因为虚函数表指针是在构造函数的初始化列表初始化的,但是虚函数又要借助虚函数表指针来调用虚函数,两者矛盾,所以不可以为虚函数。

析构函数可以是虚函数嘛?

可以,并且建议将析构函数定义为虚函数,因为这样可以避免内存泄漏的问题。如果子类对象是动态开辟的,使用父类指针指向子类对象,在delete时如果构成多态那么就会调用子类析构函数,而调用子类析构函数前系统会默认先调用父类析构函数,这样可以避免内存泄漏。

对象访问普通函数快还是访问虚函数快?

如果是通过实例化的对象访问那么是一样快的,如果是指针或引用对象访问的话是访问普通函数快的,因为指针或引用去访问虚函数时走的是多态调用是一个晚绑定,需要在运行时去需表中找函数的地址。

虚函数表是在什么时候形成的?存在哪?

虚函数是在编译期间确定的,位于代码段。

什么是抽象类?

函数纯虚函数的类叫做抽象类,此类不能实例化出对象,这也强制了其派生类如果想要实例化出对象那么就必须重写纯虚函数。

C++菱形继承解决方案和多态原理?

菱形继承具有数据冗余和二义性的问题,解决的方法是通过虚继承的方式,虚继承的派生类中会产生一个虚基表指针,该指针指向虚基表,表中的内容是一个到冗余数据的偏移量,而原本冗余的数据会被放到派生类对象的最后。
多态的原理是通过重写虚函数,达到在派生类的虚函数表中重写的虚函数地址覆盖掉原本的地址,然后通过基类的指针或者引用指向派生类对象时,调用虚函数调用的时子类重写后的虚函数,而执行基类对象时调用的就是基类的虚函数达到多态的行为。
不要将虚基表和虚函数表搞混。

你可能感兴趣的:(C++,c++,数据结构,算法,笔记,c语言)