C++之多态

一、多态:

1、多态的概念:

通俗来说就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态)。编译时多态主要是函数重载和函数模板,传不同类型的参数就能调用不同函数,通过参数不同达到多种形态。之所以叫编译时多态,是因为它们实参传形参的参数匹配是在编译时完成的,一般把编译时归为静态,运行时归为动态。

运行时多态具体点就是去完成某个行为(函数),可以传不同对象就会完成不同的行为,就达到多种形态。比如买票这个行为,普通人买票是全价买票,学生买票是优惠买票,军人买票是优先买票。

2、多态的定义及实现:

(1)构成条件:多态是一个继承关系下的类对象去调用同一函数而产生的不同行为。比如Student继承了Person,Person对象买全价票,Student对象买半价票。

(2)实现多态还有两个必须重要条件:

*必须用指针或引用调用虚函数。

*被调用的函数必须是虚函数。

也就是说要实现多态效果,第一必须是基类的指针或者引用,因为只有基类的指针或者引用才能既指向基类又指向派生类;第二派生类必须对基类的虚函数进行重写覆盖,这样派生类才能有不同的函数,多态的不同效果才能达到。

3、虚函数:

(1)概念:类成员函数前面加上virtual修饰,那么这个成员函数称为虚函数,注意非成员函数不能加virtual修饰。

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

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

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

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

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

};

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

void test1()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
}

首先void Func(Person* ptr)得是一个基类的指针或引用才可以同时传基类和派生类的对象过去,Func(&ps); Func(&st);,然后就可以达到一个效果就是指向谁调用谁,指向基类就是全价,指向派生类就是半价。

cpp 复制代码
int main()
{
	test1();

	return 0;
}

如果把基类的成员函数前面的virtual去掉就无法实现。如果Func里面的参数是父类对象Person ptr不是指针或引用就无法实现多态。如果是Student对象那更不能实现多态。

(3)一个很恶心的题:

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

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

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

分析:B继承了A,B里面的func重写了A的func,因为返回值和函数名相同,参数列表的int val也相同,这里的相同指形参类型都是int,只要形参类型相同就算把val换个名字也满足重写。然后A的test() 也被继承下来了虽然没有完成重写。然后再看main函数,p是B对象类型的指针,所以调用test时就把B类型对象的指针传给了test里面的this指针,但是这里的this是A*类型的,因为调用的是A里面的test(),所以这时候就是A*指向了B对象,所以接下来就是A*this->func(),由于this指向的是B对象,func还被重写了,所以构成多态,指向谁调用谁,所以就调用B里面的函数。到这一步开始就有问题了,在重写的情况下,重写是重写了实现,也就是说重写的函数是父类对象函数的声明部分+子类对象重写的实现部分组成的:virtual void func(int val = 1){ std::cout << "B->" << val << endl; },所以这里B虽然给了个缺省值int val=0,但是它不用,用的是int val=1。

所以运行结果就是B->1。如果是直接去调用func函数结果就是B->0了。

cpp 复制代码
int main()
{
	B* p = new B;
	//p->test();
	p->func();
}

因为这时候就不是多态调用了而是直接普通调用,多态调用可以理解为子类的虚函数用的父类的接口声明和子类的函数体实现的。

(4)虚函数重写的其他问题:

*协变:派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或引用时,称为协变。协变实际意义不大,了解即可。

cpp 复制代码
//协变:
class A{};
class B:public A{};
class Person
{
public :
	virtual A* BuyTicket()
	{
		cout << "全价买票" << endl;
		return nullptr;
	}
};
class Student :public Person
{
public:
	virtual B* BuyTicket()
	{
		cout << "半价买票" << endl;
		return nullptr;
	}
};

*析构函数的重写:

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual,都与基类的析构函数构成重写。虽然基类与派生类的析构函数看起来不同,但是编译器做了处理将它们析构函数的名字统一成destructor,所以基类的析构函数加了virtual修饰,派生类的析构函数就构成重写。这也是为什么如果不写成虚函数的话两个类的析构函数构成隐藏关系。

下面代码~A不加virtual,就不构成多态,程序崩溃,因为编译器在进行很多检查,delete p2只调用了A的析构没调用B的析构,假如B有一些资源没有释放会造成内存泄漏,所以达成多态才可以解决。

cpp 复制代码
//析构函数重写:
class A
{
public:
	~A()
	{
		cout << "~A()" << endl;
	}
};

class B :public A
{
public:
	//构成重写:
	virtual ~B()
	{
		cout << "~B()->delete:" << _p << endl;
	}
protected:
	int* _p = new int[10];
};

void test3()
{
	A* p1 = new A;
	A* p2 = new B;

	delete p1;
	delete p2;
}

派生类调用析构函数后为了释放继承自基类的那一部分会调用基类的析构函数,达到先子后父的顺序。

(5)override和final关键字:

C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写成或者参数写成导致无法构成重写,这种情况编译时不会报错的,只有在程序运行时没有达到预期结果才来debug,会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写,如果不想让派生类重写这个虚函数,那么可以用final去修饰。

cpp 复制代码
class Car
{
public:
	virtual void Drive()final
	{}
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz" << endl;
	}
};

class Car
{
public:
	virtual void Drive()
	{}
};
class Benz :public Car
{
public:
	virtual void Drive()override
	{
		cout << "Benz" << endl;
	}
};

(6)重载、重写和隐藏的对比:

重载:两个函数在同一作用域,函数名相同,参数不同,参数的类型或者个数不同,返回值可同可不同。

重写:两个函数分别在继承体系的父类和子类的不同作用域。函数名、参数、返回值必须相同,协变除外。而且两个函数必须都是虚函数。

隐藏:两个函数分别在继承体系的父类和子类的不同作用域。函数名相同,只要不构成重写就是隐藏。父子类的成员变量相同也构成隐藏。

4、纯虚函数和抽象类:

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

cpp 复制代码
//纯虚函数和抽象类:
class Car
{
public:
	virtual void Drive() = 0;
};

class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz" << endl;
	}
};

void test4()
{
	//Car c;//无法实例化出对象
	Benz b;//继承之后也是抽象类,但重写一下虚函数就可以实例化出对象了。

	//纯虚函数某种程度强制了子类去重写虚函数
}

二、多态的原理:

1.虚函数表指针:

下面编译为32程序的运行结果:

cpp 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	int _b = 1;
	char _ch = 'x';
}; 

void Output()
{
	Base b;
	cout << sizeof(b) << endl;
}

这里的B对象里面的_b占4个字节,_ch占1个字节,但开始还有一个虚函数表指针占4个字节,最后内存对齐就占了12个字节。

这个虚函数表把虚函数的指针放了进去,本质是个数组,里面放的是指针,严格点来说这是一个函数指针数组。虚函数表简称虚表。

2、多态的原理:

(1)多态的实现:从底层角度Func函数中的ptr->BuyTicket() 出发,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了用指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类的虚函数。

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;//代号
};

Person对象里面有一个虚函数表指针,指向一个表,表里面有一个Person的虚函数。

Student继承了父类,它也有一个虚函数表指针和一个_name,自己还有一个成员_id,但是指针指向一个虚函数表,表里有一个Student的虚函数。

Soldier继承了父类,它也有一个虚函数表指针和一个_name,自己有一个成员_codename,指针也指向一个虚函数表,表里有一个Soldier的虚函数。

在编译运行到ptr->BuyTicket(); 时,编译器检查语法是否满足多态,满足多态就把这段指令变成到这个指针指向的对象的虚表里面去找,从内存角度看虽然这个ptr指针切片之后看到的始终都是Person对象,只是ptr可能指向的是Person,也可能是子类切片切出来的Person对象,底层的汇编就是用ptr->BuyTicket()找到指向的对象,去这个对象的头四个或八个字节取到_vfptr,通过这个_vfptr去找到这个函数指针的数组,然后找到对应的虚函数。总结就是指向谁调用谁,指向哪个对象,运行时到指向对象的虚函数表中找到对应的虚函数的地址,进行调用。这就是多态的原理。

(2)动态绑定与静态绑定:

*对不满足多态条件的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。

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

(3)虚函数表:

*基类对象的虚函数表中存放基类所有虚函数的地址。

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

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

*派生类的虚函数表中包括:基类的虚函数地址、派生类重写的虚函数地址、派生类自己的虚函数地址三个部分。

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

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

*虚函数表存在哪C++标准并没有规定,但vs是存在代码段(常量区)的。

cpp 复制代码
//虚表地址跟常量区最接近:
void test6()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p", p2);
	Person ps;
	Student st;
	Person* p3 = &ps;
	Student* p4 = &st;
	printf("Person虚表地址:%p\n", *(int*)p3);//指针指向了类的地址,但是解引用看的是类的大小
	printf("Student虚表地址:%p\n", *(int*)p4);//强转了之后就变成看最开始四个字节,头上四个自己解引用之后就是这个虚表的地址
	printf("虚函数地址:%p\n", &Person::BuyTicket);
	printf("普通函数地址:%p\n", &Person::Func);
}
相关推荐
已是上好佳1 小时前
Tcp网络通信的基本流程梳理
linux·运维·服务器·c++
WangMing_X2 小时前
C#实现动态验证码生成器:安全防护与实际应用场景
开发语言·安全·c#·验证码·图片
m0_555762902 小时前
qt designer中的Spacer相关设置
服务器·开发语言·qt
jk_1012 小时前
MATLAB中enumeration函数用法
开发语言·matlab
十年一梦实验室3 小时前
C++ 中的 RTTI(Run-Time Type Information,运行时类型识别)
开发语言·c++
纽约恋情3 小时前
C++——STL 常用的排序算法
开发语言·c++·排序算法
千里码aicood4 小时前
【2025】基于python+django的驾校招生培训管理系统(源码、万字文档、图文修改、调试答疑)
开发语言·python·django
小李苦学C++4 小时前
C++模板特化与偏特化
开发语言·c++
小王努力学编程4 小时前
元音辅音字符串计数leetcode3305,3306
开发语言·c++·学习·算法·leetcode
佚明zj4 小时前
【C++】如何高效掌握UDP数据包解析
开发语言·c++·udp