万字解析C++——多态


多态的概念

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

打个比方,同样一种买票的行为,一般的人是全价买票,学生是半价买票,特殊人群可以免票,此时我们便可以用多态来实现

class Person {
public:
	virtual int Price(int Normal_Price)
	{
		cout << "全价购买: " << Normal_Price << endl;
		return Normal_Price;
	}
};

class Student :public Person {
public:
	virtual int Price(int Normal_Price)
	{
		cout << "半价购买: " << Normal_Price/2 << endl;
		return Normal_Price / 2;
	}
};

class special :public Person {
public:
	virtual int Price(int Normal_Price)
	{
		cout << "免票 " << endl;
		return 0;
	}
};

void Buy_tickets(Person& p)
{
	p.Price(100);
}
int main()
{
	Person p;
	Student st;
	special sp;

	Buy_tickets(p);
	Buy_tickets(st);
	Buy_tickets(sp);
}

9901b7bffd1f4ba7aeb1a442388fb78c.png 此时,虽然我们调用的是同一个函数,但是产生了不同的行为,这便是多态


多态的定义以及实现

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。

多态的构成 

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

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写 

我们来分别来看以上两点

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

同样是以上的代码,如果我们不传入指针或者引用,而是传入一个形参变量,最终会输出什么呢?

void Buy_tickets(Person p)
{
	p.Price(100);
}
int main()
{
	Person p;
	Student st;
	special sp;

	Buy_tickets(p);
	Buy_tickets(st);
	Buy_tickets(sp);
}

dcea4139b20a4e228a07e21e5e6a5293.png

我们发现,此时并没有出现多态的现象,所有的输出全部变成了基类中的函数

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

 如果我们不将函数加上关键字virtual,那又会输出什么呢?

class Person {
public:
	int Price(int Normal_Price)
	{
		cout << "全价购买: " << Normal_Price << endl;
		return Normal_Price;
	}
};

class Student :public Person {
public:
	int Price(int Normal_Price)
	{
		cout << "半价购买: " << Normal_Price / 2 << endl;
		return Normal_Price / 2;
	}
};

class special :public Person {
public:
	int Price(int Normal_Price)
	{
		cout << "免票 " << endl;
		return 0;
	}
};

fff03d15cdef4a6294d4c240a2e1f7e7.png

 我们发现,此时并没有出现多态的现象,所有的输出全部变成了基类中的函数

在此,我们仅仅是对此现象进行演示,具体原因将在多态的底层原理中进行分析

虚函数及其重写

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

虚函数的重写遵循三同原则:

  1. 返回值相同
  2. 函数名相同
  3. 参数列表完全相同(个数,类型,顺序) 

只有满足了以上三同,基类和派生类的两个虚函数才构成多态

但是,在C++的发展史中难免会出现例外,虚函数重写也有两个例外

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

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

class Person {
public:
    virtual Person* f() 
    {return new Person;}
};
class Student : public Person {
public:
    virtual Student* f() 
    {return new Student;}
};

不仅仅只有返回当前的基类和派生对象才构成协变,返回其他类的对象(只要构成基类和派生类关系)也可以形成协变,例如:

//其他构成基类和派生类关系的两个类
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.析构函数的重写(基类与派生类析构函数的名字不同)

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

class Person {
public:
	virtual ~Person() 
	{ cout << "~Person()" << endl; }
};
class Student : public Person {
public:
	virtual ~Student() 
	{ cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数
// 下面的delete对象调用析构函数,才能构成多态
// 才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
		
	delete p1;
    cout<<endl;
	delete p2;
	return 0;
}

2da80f2ac30d48188d54ee991c2e7942.png

C++11 override 和 final

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

1.final:修饰虚函数,表示该虚函数不能再被重写

0cea345b6d034ab3a0125747e688846d.png

10726ed9b9b8430692cc9dc22f34001d.png

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

426f7657081e4cb38382883df9cf1f86.png

6502358ed9d24df09584928e55ea419f.png

重载、覆盖、隐藏的对比

426f2823a4b24f2d86c9aeb700268bc0.png

简单的概括,对于两个同名函数,分为以下几种情况:

9ca580a71a7341e6ab53317ee690b4b1.png


抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。 

举一个例子:

我们把所有食物都能总结出一个基类”food“,而对每一个食物中都有一个描述口味”taste“,如果我们单单对food这个抽象的概念进行口味分析,那毫无意义,只有将一个具体的概念继承了这个food之后,taste才有意义,所以,我们在基类中定义一个纯虚函数,表示在这个基类中这一成员没有意义,需要在派生类中实现这一成员的意义

class food
{
public:
	virtual void taste() = 0;
};

class vegetable :public food
{
public:
	virtual void taste()
	{
		cout << "清香" << endl;
	}
};

class meet :public food
{
public:
	virtual void taste()
	{
		cout << "鲜美" << endl;
	}
};

如果我们忘了在派生类中重写这一虚函数,那么当我们实例化这个类的时候,编译器会报错 

所以由此我们可以看出,虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。


多态的原理

如果一个类中含有一个虚函数,那这个类的大小是多少呢?

class A
{
public:
	virtual void func();
	int _a;
};

int main()
{
	cout << sizeof(A) << endl;
}

在类与对象中,我们了解过,类中的所有函数都存放在一个公共区域,不会单独占用实例化对象的空间,所以按照这种思路,这个类的大小应该是一个int——4,对吗?

41669ebc63284cf38eb71c1c80016258.png

我们发现,最终的结果居然是16,难道一个虚函数需要占用12个空间吗?

class A
{
public:
	virtual void func1();
	virtual void func2();
	virtual void func3();
	int _a;
};

int main()
{
	cout << sizeof(A) << endl;
}

我们增加虚函数的数量,结果我们发现,最终输出的大小仍为16

bbb09479cc3f4139ab735392ae84eb79.png

也就是说,虚函数占用空间的大小与虚函数的数量无关,只要有虚函数,便会占用类中12字节的空间

果真如此吗?

虚函数表

我们在调试监视窗口中来看实例化出的A中的内存

b5d3ea9300a948268bdb9a28014880cc.png

我们发现,a中不仅存了一个成员_a,还存了一个_vfptr这一个指针数组,展开这个指针数组,我们又突然发现数组中存的是类中虚函数的地址
_vfptr,v代表virtual,f代表function,解读出来表示虚函数指针,我们把存放虚函数地址的位置叫做虚函数表,而这个虚函数指针数组指向的位置便是虚函数表中的某个具体位置
每一个含有虚函数的类中都至少有一个虚函数表指针,我们通过访问这个指针数组,便可以找到虚函数的地址,从而访问虚函数

在C语言中我们已经学到,对于64位编译器,一个指针大小为8字节,而又根据内存对齐规则,int为4字节时要对齐到8字节,所以整个类的大小为两个8字节相加16字节,而不是虚函数占用了16字节

我们据此也可以分析出,如果在32位编译器,这个类的大小应该为8字节 

我们再来看一个新问题:

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}
	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

896992a2ce1148cdb44ed74ab3b3d0a3.png

通过观察和测试以上程序,我们发现:

  1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚

    表指针也就是存在部分的另一部分是自己的成员。

  2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

  3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。

  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

  5. 总结一下派生类的虚表生成:
    a.先将基类中的虚表内容拷贝一份到派生类虚表中
    b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
    c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

  6. 虚表和虚函数都存在于代码段常量区

多态的底层原理

了解了虚函数表之后,我们再回过来看:虚函数的原理是什么?

首先,我们观察基类和派生类的虚函数表

a442fdc75c9d4a7084697bf82c6a3e88.png

基类派生类
虚函数表地址0x00007ff7878eacf80x00007ff7878ead38
Func1地址(进行重写)0x00007ff7878e10c80x00007ff7878e10b4
Func2地址(未进行重写)0x00007ff7878e12800x00007ff7878e1280

我们发现,基类和派生类虚函数表的地址不相同,进行重写了的虚函数地址也不相同,只有不进行重写的虚函数地址才是相同的

其次,我们在继承中学到,如果将派生类赋值给基类,那么不会生成中间常量进行转换,会直接以切片的形式进行赋值
但是对于虚函数表切片却有区别,切片并不会把虚函数表指针切给另一个对象,我们用一张图来简单说明

eac0e4ec97d74d5aa8421e0b5265eca6.png

  • 如果是指针和引用,则是直接指向那一块空间,其中虚函数表指针也包括在内了
    (因为引用的底层就是指针,所以其原理和指针相同)
  • 如果是非指针和引用,则是只把其中的部分成员切片赋值,而虚函数表指针会重新生成,成为一个新的对象

用一小段程序简单验证 

int main()
{
	Base b;
	Derive d;

	Base b2 = d;
	Base& b3 = d;
	return 0;
}

5e26c8af11254c8eb8ba63ad3ca1bf99.png我们发现,如果采用引用赋值,则是和派生类虚表地址相同,如果非引用赋值,则是和基类虚表地址相同

所以,当我们调用时,如果传入的是派生类的对象,使用的便是派生类的虚表指针,调用的是该虚表指针对应的函数地址,基类同理

而如果我们传入的不是指针和引用,那么调用时会产生一个基类的对象,这个对象使用的是该类型自己生成的基类虚表指针,故无法产生多态

动态绑定与静态绑定

  • 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,

    比如:函数重载

  • 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态


 多继承关系的虚函数表

多态的底层原理中我们已经解释到,对于单继承关系的虚函数表,在派生类中,如果虚函数被重写,则存一个与基类虚函数不同地址的新函数地址,而如果没有被重写,则存一个与基类虚函数地址相同的函数

但是,如果放在多继承,两个基类中有同名虚函数,那最后派生类的存储情况是怎么样的呢?

直接上例子实验

class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};
class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
		int b2;
};
class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
		VFPTR f = vTable[i];
		f();
	}
	cout << endl;
}
int main()
{
	Derive d;
	VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
	PrintVTable(vTableb1);
	VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(vTableb2);
	return 0;
}

2c42bae930aa4eceb38ceeaa7cf4bea6.png 以上的代码和图都非常难理解,但是我们只需要了解一个结论:

多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

菱形继承及菱形虚拟继承

什么?还想知道菱形虚拟继承?别吧……

在工程应用中,我们都会尽量避免去设计菱形继承,菱形继承甚至成为了C++一个令人诟病的地方,所以,关于菱形继承,大家就当他不存在就好了

如果是在想了解,可以看看以下两篇文章:
1.C++虚函数表解析
2.C++对象的内存分布


继承和多态的一些细节问题

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

可以,但是编译器会忽略inline属性,因为inline是在预处理阶段就进行展开,而虚函数指针是在类实例化调用构造函数初始化列表后才进入虚表,两者时机产生了冲突

2.静态成员函数可以是虚函数吗?

不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表

3.构造函数可以是虚函数吗?

不能,因为对象中的虚函数表指针在构造函数初始化列表阶段才开始初始化,两者时机产生了冲突

4.析构函数可以是虚函数吗?

可以,而且析构函数最好是虚函数,原因在析构函数的重写中便已经解释清楚了

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

如果是普通对象,则一样快
如果是引用或者指针对象,则普通函数快,因为多态需要在虚函数表里查找

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

虚函数表在编译阶段便生成,存在代码常量区

7.基类和派生类虚函数缺省参数不同怎么办? 

先说结论:

缺省参数永远以基类中的定义为准

我们来看一段拗口的程序
以后千万不要这么写代码,会被后来的人追着打

class A
{
public:
	virtual void func(int i = 1)
	{
		cout << "A->" << i << endl;
	}
	virtual void test()
	{
		func();
	}
};

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

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

最后程序的输出是什么?

61faa27c86354bccaecd297a3a765850.png 一个让所有人都意想不到的答案,不是吗?

我们来仔细分析这个程序

  1. 首先,这个程序使用派生类对象p调用test函数,采用的是B的虚表指针,B的虚表指针中test函数地址AB相同
  2. 然后,test函数正常运行,调用func函数,此时也采用的是B的虚表指针,于是调用了B的虚函数地址
  3. 最后,根据我们的结论,缺省参数永远以基类中的定义为准,所以我们采用的是基类中的缺省值1,但是实现采用的是B的虚表指针中的函数,于是便构成了以上的结果

有人可能要问,既然采用的是B的虚表指针中的函数,那不应该是使用B的缺省值吗?

我们在C++缺省参数和函数重载中已经说明过,缺省参数并不会作为一个特征记录在函数表里 ,只有函数名和其参数才会作为特征记录在函数表供参考,所以缺省参数最终还是采用了基类中的缺省参数,当然,这也是C++一个令人诟病的地方,为什么编译器不报错?


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值