C++多态深度剖析:从理论到实践的跨越

✨✨小新课堂开课了,欢迎欢迎~✨✨

🎈🎈养成好习惯,先赞后看哦~🎈🎈

所属专栏:C++:由浅入深篇

小新的主页:编程版小新-优快云博客

前言:

继承是多态的重要基础,学习多态时需要对继承有深入的理解。

C++继承深度剖析:从理论到实践的跨越-优快云博客

一.多态的概念

 多态的概念:通俗来说,多态就是指多种形态。同一操作用于不同的对象可以有不同的表现形式。

 比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。

再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<)喵“,传狗对象过去,就是"汪汪"。

二.多态的分类

多态分为编译时多态(静态多态)和运行时多态(动态多态)。

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

运行时多态(动态多态),具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。动态多态主要靠虚函数实现

这里我们重点讲运行时多态。

三.多态的定义和实现

3.1多态的构成条件

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

实现多态有两个必须重要条件:

• 必须是基类的指针或者引用调用虚函数

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

说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生类对象(实际上指向了派生类对象中基类的那一部分);第二派生类必须对基类的虚函数重写/覆盖,重写或者覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到。

下面我们会对这两个条件进行详细的阐述。

3.2虚函数

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。

class person
{
public:
	virtual void buyticket() { cout << "买票-全价" << endl; }
};

注意非成员函数不能加virtual修饰。

我们在前面学习继承的时候,在谈菱形继承中就用了virtual来解决了菱形继承二义性和数据冗余的问题,虚函数这里的virtual是为了实现多态,这两个virtual作用大不一样,不要混为一谈哦。

3.3虚函数的重写/覆盖

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

下面Student和Soldier两个派生类的虚函数重写了基类的虚函数。 

//基类
class Person
{
public:
	//基类的虚函数
	virtual void buyticket() 
	{
		cout << "买票-全价" << endl; 
	}
};

//派生类
class Student : public Person
{
public:
	//派生类的虚函数重写了基类的虚函数
	virtual void buyticket()
	{ 
		cout << "买票-打折" << endl;
	}
};

//派生类
class Soldier : public Person
{
public:
	//派生类的虚函数重写了基类的虚函数
	virtual void buyticket()
	{
		cout << "优先-买票" << endl;
	}
};

 以上我们已经达成了要实现多态的第二个要求(派生类的虚函数必须对基类的虚函数进行重写/覆盖)。下面我们只要让基类的指针或引用调用虚函数即可。

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

void Func(Person& ptr)
{
	// 这⾥可以看到虽然都是Person的引用ptr在调⽤buyticket
	// 但是跟ptr没关系,⽽是由ptr引用的对象决定的。
	ptr.buyticket();
}


int main()
{
	Person ps;//普通人
	Student st;//学生
	Soldier sd;//军人

	Func(&ps);//基类的指针调用虚函数
	Func(&st);
	Func(&sd);

	Func(ps);//基类的引用调用虚函数
	Func(st);
	Func(sd);

	return 0;
}

运行结果: 

到这里,我们再来回顾一下,多态的概念:同一操作(买票)用于不同的对象(三类人)可以有不同的表现形式(优惠政策)。实现多态的两个要求:第一基类的指针或者引用调用虚函数,第二被调用的函数必须是虚函数(virtual),且派生类必须对基类的虚函数进行重写(三同)。

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。

 3.4加深理解(例题)

以下程序输出结果是什么()

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

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

};

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

int main(int argc, char* argv[])
{
	B* p = new B;
    p->test();

	return 0;
}

答案:B

解析:p是一个B类的指针,p去调用test函数时,会去调用func函数,这时我们就会有分歧了,是调用A类的func函数,还是调用B类里的func函数呢,这里我们要知道test里的func函数是由this指针去调用的,这就又回到了this指针是A*还是B*的问题,虽然说B类把test继承下来了,按理说this指针不应该是B*,实际上不是的,“继承”只是一个形象的说法,这里的this指针是A*,此时你就会发现满足了多态的条件,基类的指针去调用虚函数,派生类要对基类的虚函数进行重写,我们来理解一下这个重写,重写的本质就是重写虚函数的实现,我们不会动虚函数的定义。

既然满足了多态的条件,我们在调用时就与对象的类型无关,而是与对象指向的内容有关,所有虽然是this指针的类型的A*,但是指向的是B*,所有我们会调用B类的func 函数。综上所述,答案选B 

3.5虚函数重写的两个特例

1.协变

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

协变的实际意义并不大,所以我们了解一下即可。

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;
	}
};
void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;

	Func(&ps);//基类对象调用基类的虚函数
	Func(&st);//派生类对象调用派生类的虚函数

    return 0;
}

2.析构函数的重写

基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。

class A
{
public :
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};

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

虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。

int main()
{
	A* p1 = new A;
	A* p2 = new B;

	delete p1;
	delete p2;
	return 0;
}

上面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为~B()中在释放资源。

只有派生类B的析构函数重写了A的析构函数,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数(与p1,p2的类型无关,而是与指向的内容有关,p1调用A的析构函数,p2调用B的析构函数)。

注意:这个问题面试中经常考察,大家⼀定要结合类似下⾯的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。

3.6 C++11 override和final关键字

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无构成重载,而这种错误在编译期间是不会报出的,只有在程序运运行时没有得到预期结果才来debug会得不偿失。

因此C++11提供了override,可以帮助用户检测是否重写

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

3.7重载/重写/隐藏的对比 

 四.纯虚函数和抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派⽣类重写,但是语法上可以实现),只要声明即可。

包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。

//抽象类
class Car
{
public :
	virtual void Drive() = 0;//纯虚函数
};

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

int main()
{
	// 编译报错:error C2259: “Car”: ⽆法实例化抽象类
	Car car;
	return 0;
}

纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

//抽象类
class Car
{
public :
	virtual void Drive() = 0;//纯虚函数
};

class Benz :public Car
{
public:
	virtual void Drive()//派生类重写虚函数
	{
		cout << "Benz-舒适" << endl;
	}
};

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

int main()
{

	// 派生类重写了纯虚函数(f非抽象类),可以实例化出对象
	Benz b1;
	BMW b2;

	//不同对象用基类指针调用Drive函数,完成不同的行为
	Car* p1 = &b1;
	Car* p2 = &b2;//派生类可以赋值给基类的指针或引用
	p1->Drive(); 
	p2->Drive();  

	return 0;
}

五.多态的原理

5.1虚函数表指针和虚函数表

我们来看一道例题。

下面编译为32位程序的运行结果是什么()

A. 编译报错 B. 运行报错 C. 8 D. 12

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

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

 根据内存对齐规则,我们简单计算出b对象的大小是8(只有_b和_ch两个成员变量),这里我们先直接给出正确答案是12,这是为什么呢?

b对象当中除了_b和_ch成员外,实际上还有一个_vfptr放在对象的前面(有些平台可能会放到对象的最后面,这个跟平台有关)。

对象中的这个指针叫做虚函数表指针(v代表virtual,f代表function),简称虚表指针,虚表指针指向一个虚函数表,简称虚表,每一个含有虚函数的类中都至少有一个虚表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中。

下面我们来看看虚函数表里到底放了什么。

//基类
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:
	//重写虚函数Func1
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

通过调试可以发现,基类对象b和基类对象d当中除了自己的成员变量之外,基类和派生类对象都有一个虚表指针,分别指向属于自己的虚表

 

虚表当中存储的就是虚函数的地址。 基类对象的虚函数表中存放基类所有虚函数的地址。因为基类当中的Func1和Func2都是虚函数,所以基类对象b的虚表当中存储的就是虚函数Func1和Func2的地址。

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

派生类中重写的基类的虚函数,派生类的虚函数表中继承下来的的虚函数就会被覆盖成派生类重写的虚函地址。派生类虽然继承了基类的虚函数Func1和Func2,但是派生类对基类的的虚函数Func1进行了重写,因此,基类对象d的虚表当中存储的是父类的虚函数Func2的地址和重写的Func1的地址。这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。

派生类的虚函数表中包含,基类的虚函数地址,派生类重写的虚函数地址,派生类自己的虚函数地址三个部分。

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

虚函数存在哪的?

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

 虚函数表存在哪的?

这个问题严格说并没有标准答案C++标准并没有规定,我们写下⾯的代码可以对比验证⼀下。vs下是存在代码段(常量区)。

class Base
{
public:
	virtual void func1()
	{ 
		cout << "Base::func1" << endl;
	}
	virtual void func2() 
	{
		cout << "Base::func2" << endl;
	}
	void func5()
	{ 
		cout << "Base::func5" << endl;
	}
protected:
	int a = 1;
};

class Derive : public Base
{
public :
	// 重写基类的func1
	virtual void func1()
	{
		cout << "Derive::func1" << endl;
	}
	virtual void func3()
	{ 
		cout << "Derive::func1" << endl; 
	}
	void func4()
	{ 
		cout << "Derive::func4" << endl; 
	}
protected:
	int b = 2;
};

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);
	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;
}

 运行结果:

5.2多态的原理 

//基类
class Person
{
public:
	//基类的虚函数
	virtual void Buyticket()
	{
		cout << "买票-全价" << endl;
	}
private:
	string _name;
};

//派生类
class Student : public Person
{
public:
	//派生类的虚函数重写了基类的虚函数
	virtual void Buyticket()
	{
		cout << "买票-打折" << endl;
	}
private:
	string _id;
};

//派生类
class Soldier : public Person
{
public:
	//派生类的虚函数重写了基类的虚函数
	virtual void Buyticket()
	{
		cout << "优先-买票" << endl;
	}
private:
	string _codename;
};

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

int main()
{
	Person ps;//普通人
	Student st;//学生
	Soldier sd;//军人

	Func(&ps);//基类的指针调用虚函数
	Func(&st);
	Func(&sd);

	return 0;
}

从底层的角度Func函数中ptr->BuyTicket(),是如何作为ptr指向Person对象调Person::BuyTicket, ptr指向Student对象调用Student::BuyTicket的呢?


通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。

 

第⼀张图,ptr指向的Person对象,调用的是Person的虚函数;第⼆张图,ptr指向的Student对象,调用的是Student的虚函数。

总结一下:

  1. 构成多态,指向谁就调用谁的虚函数,跟对象有关。
  2. 不构成多态,对象类型是什么就调用谁的虚函数,跟类型有关。

5.3动态绑定和静态绑定

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

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

我们可以借用汇编来进一步理解静态绑定和动态绑定。

//基类
class Person
{
public:
	//基类的虚函数
	virtual void buyticket()
	{
		cout << "买票-全价" << endl;
	}
};

//派生类
class Student : public Person
{
public:
	//派生类的虚函数重写了基类的虚函数
	virtual void buyticket()
	{
		cout << "买票-打折" << endl;
	}
};

如果不满足多态的条件,函数的调用在编译时就确定了。

int main()
{
	
	Student st;
	Person ps = st;
	ps.buyticket();
	
	return 0;
}

将调用函数的那句代码翻译成汇编就只有以下汇编指令,也就是直接调用的函数。 

如果满足多态的要求, 则是在运行时才确定调用那个函数。

int main()
{
	
	Student st;
	Person* ps = &st;
	ps->buyticket();
	
	return 0;
}

 相比不构成多态时的代码,构成多态时调用函数的那句代码翻译成汇编后就变成了五条汇编指令,主要原因就是我们需要在运行时,先到指定对象的虚表中找到要调用的虚函数,然后才能进行函数的调用。

这样就很好的体现了静态绑定是在编译时确定的,而动态绑定是在运行时确定的。 

总结:

到此为止,C++的三大特性,封装,继承,多态我们就介绍忘完了。下面我们继续一起学习其他的知识吧。

感谢各位大佬的观看,创作不易,还请各位大佬点赞支持~ 

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值