【C++】多态

多态是面向对象三大特性之一,多态即多种形态,接下来让我们开始学习吧。

一、多态的概念

通俗来说,多态就是多种形态。多态分为编译时多态(静态多态)运行时多态(动态多态)

比如函数重载和函数模板就是编译时多态(静态多态),它们传不同类型的参数就可以调用不同的函数,函数重载就不用说了调用的是不同的函数,对于函数模板来说,看似调用的是同一份模板,但其实在编译过程中会根据参数类型来实例化出不同的函数,本质上调用的也是不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或者6折)。再比如,同样是动物叫声的⼀个行为,传猫对象过去就是"喵",传狗对象过去就是"汪"。这种结果是运行时产生的,所以被称为运行时多态。

本篇内容以运行时多态为主。

二、多态的定义及实现

1、定义

多态是同一继承关系的下的不同类对象去调用同一函数,产生了不同的行为的过程。多态可以发生在基类和派生类之间,也可以发生在多个派生类之间。比如Student(派生类)继承了Person(基类),Person对象买票全价,Student对象优惠买票,这是基类和派生类之间发生的多态。再比如说,同样是动物(基类)叫声的⼀个行为,传猫(派生类1)对象过去就是"喵",传狗(派生类2)对象过去就是"汪",这就是两个派生类之间发生的多态。

2、实现

要想实现多态必须具备两个条件:

(1)必须是基类指针或者引用调用虚函数。

(2)被调用的函数必须是虚函数。

说明:要实现多态效果,第一:必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生类对象;第⼆:派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到。(重写和覆盖是一个意思,只是说法不一样)

接下来,我们进行具体分析:

首先怎么定义虚函数?

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。像下面这样:

cpp 复制代码
class Person
{ 
public:
    virtual void BuyTicket() { cout << "买票-全价" << endl;}  //这里的BuyTicket就是一个虚函数
};

虚函数的重写 / 覆盖的条件:

虚函数的重写:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表(参数的类型、位置、个数)完全相同),简称"三同",称派生类的虚函数重写了基类的虚函数。

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

代码说明:

cpp 复制代码
//基类
class Person
{
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; } //虚函数
};

//派生类
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; } //虚函数,"三同",构成重写
};

void Func(Person* ptr) //如果是Person ptr,那么调用时固定调用Person类中的BuyTicket,达不到多态的效果
{
	//这里虽然Person指针ptr在调用BuyTicket
	//但是跟ptr的类型没关系,而是由ptr指向的对象决定的,指向谁就调用它的虚函数,达到同一函数效果不同
	ptr->BuyTicket();//必须是基类指针或者引用调用虚函数,此时ptr是基类指针

	//参数必须是基类,因为基类指针或引用可以接收派生类对象,反过来不行
}

//void Func(Person& ptr)
//{
	//ptr.BuyTicket();//引用也行
//}

int main()
{
	Person ps;
	Student st;
    
    //Func的形参是指针
	Func(&ps);//这里传的是Person类的对象,那么就调用Person类中的BuyTicket方法
	Func(&st);//这里传的是Student类的对象,那么就调用Student类中的BuyTicket方法
    
    //Func的形参是引用
    //Func(ps);
    //Func(st);

	return 0;
}

运行结果:

这样在Func函数中传不同的参数对象,就可以达到多态的效果。**前提是,派生类中必须重写基类中虚函数,且必须是基类的指针或引用来进行对虚函数的调用。**两个条件必须同时满足才能达到多态的效果,缺一不可。

上述代码就是基类和派生类之间发生的多态,下面再举一个两个派生类之间发生的多态:

cpp 复制代码
//基类
class Animal
{
public:
	virtual void talk() const //基类虚函数,必须加virtual
	{}
};

//派生类1
class Cat : public Animal
{
public:
	virtual void talk() const //"三同",重写基类虚函数
	{
		cout << "喵" << endl;
	}
};

//派生类2
class Dog : public Animal
{
public:
	virtual void talk() const  //"三同",重写基类虚函数
	{
		cout << "汪" << endl;
	}
};

void letsHear(const Animal& animal) 
{
	animal.talk();//animal是基类对象,符合发生多态的条件之一
}

int main()
{
	Cat cat;
	Dog dog;
    
    //letsHear的形参是基类的引用
	letsHear(cat); //传猫对象调用的就是猫的虚函数
	letsHear(dog); //传狗对象调用的就是狗的虚函数,从而达到发出不同的声音

	return 0;
}

运行结果:

三、多态问题中的陷阱题

cpp 复制代码
class A
{
public:
	virtual void func(int val = 1) 
	{
		cout << "A->" << val << endl;
	}
	virtual void test() { func(); }
};

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

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

	return 0;
}

现问,程序输出结果是什么()?

A:A->0 B:B->1 C:A->1 D:B->0 E:编译出错 F:以上都不正确

我们现来分析一下:

首先,重写要求满足"3同",返回值相同、函数名相同、参数列表也相同(与参数名称和缺省值无关),派生类中的func可以不加virtual,所以派生类中的func完成了对基类func的重写。

接着,p是派生类B的指针,它可以调用test(),因为B继承A,且test是公有成员函数,那么此时就会出现一个问题,test()中的this是A*还是B*,如果是A*在函数体中调用就会发生多态,如果是B*就不会发生多态。p->test()的意思是,现在当前类域中去找test,发现找不到,再去基类中找,虽然B将A中的test继承了下来,但实际上test中的this指针的类型是A*,实际上test的形参是A* const this,它接收了p,由于this的类型是A*,再有this->func(),即构成多态,this是B对象的指针,this->func()就会调用B中的func,结果会打印B->0,这样对吗?我们来看结果:

发现和我们想的不一样, 这是因为重写的本质是只重写函数体中的内容,所以val还应当是A中func的val,所以打印B->1。不难看出这道题的陷阱非常多,也坑了不少人。

四、虚函数重写的一些其他问题

1、协变

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

代码说明:

cpp 复制代码
class A {};  //基类
class B : public A {}; //派生类

//协变
class Person {
public:
	virtual A* BuyTicket()   //返回值是A*,A是基类
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};

class Student : public Person {
public:
	virtual B* BuyTicket()   //返回值是B*,B是派生类
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;

	Func(&ps);
	Func(&st);

	return 0;
}

运行结果:

这是多态的特殊情况, 基类中的虚函数返回基类的对象(这个对象可以是自己,也可以是其它基类对象),派生类中的虚函数返回派生类的对象(这个对象可以是自己,也可以是其它派生类对象)。

2、析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。

析构函数的名称统一处理成destructor是有一定的原因的。

代码说明:

cpp 复制代码
class A
{
public:
	 virtual ~A()  //基类虚函数
	{
		cout << "~A()" << endl;
	}
};

class B : public A {
public:
	 virtual ~B()  //派生类重写基类虚函数
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};


int main()
{
	//基类对象的指针可能指向基类对象,也可能指向派生类对象
	A* p1 = new A; //基类对象的指针指向基类对象
	A* p2 = new B; //基类对象的指针指向派生类对象
	
	//delete的处理过程:1.调用析构 2.operator delete
	//如果~B和~A不构成重写关系,那就不会发生多态,那么delete p1和delete p2调用的都是A类的析构函数,那么如果B中有资源申请的话就会造成内存泄漏(没有调用B中的析构函数)
	//所以,必须达成多态才能解决问题,多态就是指向哪个对象,就调用那个对象的析构函数,所以它们的名字都被编译器处理成destructor,这样才能构成多态
	delete p1;  //调用A类的析构函数
	delete p2;  //调用B类的析构函数

	return 0;
}

运行结果:

结果为什么多出来个~A()呢?

这是因为delete p1时会调用基类的析构函数,而p2指向派生类对象,因为构成多态的条件,所以delete p2时会调用派生类的析构函数,派生类的对象由两部分组成,一部分是派生类,一部分是基类,所以它在析构过程中会遵循"先子后父"的原则,它会先调用派生类的析构函数处理派生类的那一部分,再调用基类的析构函数处理基类的那一部分。所以最后多出来的~A()就是析构派生类中基类的那一部分调用的。

因为析构函数构成多态才能正确释放资源,所以析构函数的名字被处理成了destructor,所以派生类的析构函数和基类中的析构函数构成隐藏。

五、override 和 final

在C++11中,提供了override和final这两个关键字。

C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错或参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才会发现错误所在,因此C++11提供了override,可以帮助用户检测是否重写,如果没有重写那么会在编译时报错。

代码说明:

cpp 复制代码
//注:Benz-奔驰品牌车

//像派生类中的Dirve与基类中的Drive并不相同,不构成重写,编译时也不会报错
//当我们本意是想构成重写的,所以加一个override关键字,编译时让编译器帮我们判定
class Car {
public:
	virtual void Drive()
	{}
};

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

//修改如下
class Car {
public:
	virtual void Drive()
	{}
};

class Benz :public Car {
public:
	virtual void Dirve() override  //在参数列表后面加上关键字override,如果没有构成重写就会报错,这里就会报错,因为它们不构成重写
	{ 
		cout << "Benz-舒适" << endl; 
	}
};

如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。

代码说明:

cpp 复制代码
class Car {
public:
	virtual void Drive() final //final加在基类虚函数参数列表后面,表示该虚函数不能被重写
	{}
};

class Benz :public Car {
public:
	virtual void Drive() //这里就会报错
	{
		cout << "Benz-舒适" << endl;
	}
};

六、重载/重写/隐藏的对比

因为它们三个都是函数之间的关系,且它们有一个共同的特点:函数名相同的函数之间的关系。

七、纯虚函数和抽象类

在虚函数的后面写上"=0",则这个函数被称为纯虚函数,纯虚函数不需要定义实现(实现没什么意义,因为在派生类中还需重写,但是语法上可以支持在纯虚函数体中写代码),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象 ,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

什么情况下会定义抽象类呢?

那些不需要实例化出对象的类通常定义为抽象类,比如说动物,它是广义上的词,它定义的对象没有意义,再比如说植物,它定义的对象也没有什么意义,我们通常将这些类定义为抽象类。大多数情况下,是将基类定义为抽象类。

代码说明:

cpp 复制代码
//注:Benz-奔驰品牌车  BMW-宝马品牌车
 
//基类
class Car
{
public:
	// 纯虚函数
	virtual void Drive() = 0; //可以有函数体,函数体中可以放内容
};

//派生类1
class Benz :public Car
{
public:
	//如果在派生类中不重写基类中的纯虚函数,那么该类也是抽象类,不能实例化对象,这里的Benz就是抽象类
};

//派生类2
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};

int main()
{
	//Car c; //err,抽象类不能实例化对象
	//Benz z; //err,抽象类不能实例化对象
	BMW b;

	//Car* pBenz = new Benz; //err,抽象类不能实例化对象
	//pBenz->Drive();

	Car* pBMW = new BMW; //基类虽然是抽象类,但它也有指针,这时指向的内容就是派生类中基类的那一部分
	pBMW->Drive(); //达到多态效果

	return 0;
}

八、多态的原理

1、虚函数表指针

先来看一段代码:

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

protected:
	int _a = 1;
	char _ch = 'x';
};

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

这段代码在32位环境下的运行结果是什么?

我猜初学者肯定认为会打印8,因为类中对象的大小只包括成员变量的大小,这里根据内存对齐规则,成员大小就是8,所以会打印8。

其实答案是12,因为上面的成员函数是虚函数,有虚函数就会有虚函数表指针这个东西,在32位下,指针的大小是4个字节,所以会打印12。

通过调试,可以观察到虚函数表指针:

上面题目运行结果是12字节,除了_b和_ch成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关)。对象中的这个指针我们叫做虚函数表指针(虚表指针)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。

假如一个类中有多个虚函数,那么虚函数表中就有多个虚函数地址:

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

	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}

	void Func3()  //Func3不是虚函数,所以它不会放在虚函数表中
	{
		cout << "Func3()" << endl;
	}
protected:
	int _a = 1;
	char _ch = 'x';
};

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

Base类中有两个虚函数,我们调试来看一下虚函数表中的内容:

虚函数表中不会存放非虚函数的地址,可以将虚函数表理解为数组,这个数组中每个元素都是地址,每个地址对应一个函数,即函数指针数组。

下图可以更形象地理解:

2、多态的实现过程

怎样实现"指向谁,调用谁"的呢?我们根据下面代码来讲解多态的实现过程:

cpp 复制代码
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
protected:
	string _name;
};

class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
protected:
	int _id;
};

class Soldier : public Person {
public:
	virtual void BuyTicket() { cout << "买票-优先" << endl; }
protected:
	string _codename; //代号
};


void Func(Person* ptr)
{
	// 这里可以看到虽然都是Person指针Ptr在调用BuyTicket
	// 但是跟ptr没关系,而是由ptr指向的对象决定的。
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;
	Soldier sr;

	Func(&ps); //调用Person类中的BuyTicket()
	Func(&st); //调用Student类中的BuyTicket()
	Func(&sr); //调用Soldier类中的BuyTicket()

	return 0;
}

在Person类、Student类和Soldier类中都有一个虚函数表,有虚函数表就有虚函数指针,如图所示:

下面单独对这段代码进行分析:

cpp 复制代码
void Func(Person* ptr)
{
	ptr->BuyTicket();
}

在调用Func时无论传什么参数ptr都只会看到基类的那部分,若ptr指向的是Person对象那没问题,若ptr指向的是Student对象那么它也会指向Student类中Perosn那一部分(切片),对于Soldier也是如此(会进行切片)。 所以,ptr无论在什么场景下,看到的都是Person对象(有可能本身是Person对象,有可能是派生类切出来的Perosn对象),ptr在调用BuyTicket()时会先分析是否满足多态的条件,若满足,在编译过程中,ptr->BuyTicket()这句代码就会转换为到对应的Person对象那一部分(与传过来的参数有关),在32位平台下会解引用拿到前4个字节的虚函数表指针,通过虚函数表指针找到对应的虚函数表,在虚函数表中找到相应函数进行调用,这样就会达到传不同对象,调用不同的虚函数的效果。若不满足,就直接调用Person中的BuyTicket函数了。

"指向谁调用谁":指向哪个对象,运行时,到指向对象的虚函数表中找到对应虚函数的地址,进行调用,这就是多态的原理。

九、静态绑定与动态绑定

静态绑定: 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址。

动态绑定: 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址。

我们通过汇编代码可以更好地理解:

cpp 复制代码
//多态时,ptr->BuyTicket()底层汇编代码
//动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调用函数地址 
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax  //最终调用

//非多态时,ptr->BuyTicket()底层汇编代码
//静态绑定,在编译的时候,编译器直接确定调用函数地址 
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch) //最终调用

实际运行过程中,静态绑定效率更高些。

十、虚函数表

下面对虚函数表相关的内容做一个总结:

1、基类对象的虚函数表中存放基类所有虚函数的地址(非虚函数地址不存放在虚函数表中)

2、虚函数表是以类为单位的,不是以对象为单位的(每个类只有一个虚函数表,这个类中的所有对象共用这一个虚函数表,前提是这个类中有虚函数),同类型对象虚表共用,不同类型对象虚表独立。

3、派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也是独立的。

4、派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。

5、派生类的虚函数表中包含,基类的虚函数地址,派生类重写的虚函数地址,派生类自己的虚函数地址三个部分(先是将父类的虚函数表拷贝过来,若子类中重写了父类的虚函数,则将重写后的虚函数的地址覆盖子类中虚函数地址,最后就是自己的虚函数的地址)。

6、虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译时不会放)。

7、虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的(和普通函数一样都在代码段),只是将虚函数的地址存到了虚表中。

8、虚函数表到底存在哪里,这个问题严格说并没有标准答案,C++标准并没有规定。现来写一段代码研究一下:

cpp 复制代码
//研究一下虚函数表到底存放在哪个区域
int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区(数据段):%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区(代码段):%p\n", p2);

	printf("-----------------------------\n");

	Base b;
	Derive d;
	Base* p3 = &b;
	Derive* p4 = &d;
	printf("Base虚表地址:%p\n", *(int*)p3);
	printf("Derive虚表地址:%p\n", *(int*)p4);
	printf("虚函数地址:%p\n", &Base::func1);
	printf("普通函数地址:%p\n", &Base::func5);

	return 0;
}

运行结果:

从结果上看,vs2019下的虚函数表的地址和常量区的地址更接近,说明虚函数表是存放在常量区的。

抛开运行结果不说,虚函数表如果放在栈上肯定是不合理的,如果用一个类定义两个对象,若其中一个对象销毁了虚函数表跟着销毁,那另一个对象难道就没虚函数表了吗?显然,虚函数表放在栈上肯定不合理。

如果放在堆上,如果系统会自动释放那还好,如果不自动释放,那对我们来说就是个事。可以放到堆上,但毕竟不如放到常量区时的效果好。至于到底放在哪里,标准并未规定,最终还是要看具体的编译器是怎么处理的。

十一、结语

本篇内容到这里就结束了,主要讲了多态这一面向对象特性的基本使用和相关问题,希望对大家有所帮助,祝生活愉快,我们下一篇再见!

相关推荐
机器视觉知识推荐、就业指导1 小时前
C++设计模式:建造者模式(Builder) 房屋建造案例
c++
Yang.993 小时前
基于Windows系统用C++做一个点名工具
c++·windows·sql·visual studio code·sqlite3
熬夜学编程的小王3 小时前
【初阶数据结构篇】双向链表的实现(赋源码)
数据结构·c++·链表·双向链表
zz40_3 小时前
C++自己写类 和 运算符重载函数
c++
六月的翅膀3 小时前
C++:实例访问静态成员函数和类访问静态成员函数有什么区别
开发语言·c++
liujjjiyun4 小时前
小R的随机播放顺序
数据结构·c++·算法
¥ 多多¥4 小时前
c++中mystring运算符重载
开发语言·c++·算法
天若有情6735 小时前
c++框架设计展示---提高开发效率!
java·c++·算法
Root_Smile5 小时前
【C++】类和对象
开发语言·c++