一把王者的时间学会了c++中的多态

x33g5p2x  于2022-03-06 转载在 其他  
字(12.7k)|赞(0)|评价(0)|浏览(185)

1.多态的概念

2.多态的定义及其实现

2.1继承中多态形成的条件

2.2虚函数 

2.3虚函数重写 

2.4override 和 final

2.5重载、覆盖(重写)、隐藏(重定义)的对比

3.抽象类

3.1抽象类的概念

3.2接口继承和实现继承

4.多态的原理

4.1虚函数表

4.2动态绑定与静态绑定

4.3虚表何时初始化

4.4多继承中的虚表

5.继承多态常考面试题

6.问答题

1.多态的概念

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

1.举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优
先买票。
2.再举个栗子: 最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包-支付-给奖励金的活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块...,而有人扫的红包都是1毛,5毛....。其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户、比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码金额 = random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没钱,那么就不需要太鼓励你去使用支付宝,那么就你扫码金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。从语言的角度来理解个人认为就是函数调用的多种形态,使得我们调用更加的灵活。官方给出的理解是:

多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。

3.c++中的多态分为两种:

1.动态的多态:父类指针或者引用指向父类调用的就是父类的虚函数,父类的指针或者引用指向那个子类,调用的就是那个子类的虚函数(运行时)。

2.静态的多态:函数重载(编译时)

2.多态的定义及其实现

2.1继承中多态形成的条件

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

2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(两者缺一不可)

2.2虚函数 

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

2.注意只有类的非静态成员函数可以是虚函数,虚函数使用的关键字也是virtual但是两者没有关系。在这里虚函数是为了实现多态而在继承中虚继承是为了解决菱形继承和二义性两者之间没用关联。

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

2.3虚函数重写 

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

#include<iostream>
using namespace std;
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后
	基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用*/
	/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};

void Func(Person& p) {
	p.BuyTicket();
}
int main() {
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

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

虚函数重写的两个例外:

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

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;
delete p2;
return 0;
}

建议把父类的析构函数定义为虚函数方便子类的虚函数重写父类的虚函数。通过上述例子我们可以知道只有析构函数重写了,当delete父类指针时,调用析构函数才能实现多态。指向子类调用子类的析构函数,指向父类调用父类的析构函数。这样才能实现正确的调用

 2.4override 和 final

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

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

#include<iostream>
using namespace std;
class Car
{
public:
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};
int main() {
	
	return 0;
}

此时编译器已经报错:

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

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

2.5重载、覆盖(重写)、隐藏(重定义)的对比

重写和重载的区别:

范围区别:对于类中函数的重载或者重写而言,重载发生在同一个类的内部,重写发生在不同的类之间(子类和父类之间)。
参数区别:重载的函数需要与原函数有相同的函数名、不同的参数列表,不关注函数的返回值类型;重写的函数的函数名、参数列表和返回值类型都需要和原函数相同,父类中被重写的函数需要有 virtual 修饰。virtual 关键字:重写的函数基类中必须有 virtual关键字的修饰,重载的函数可以有 virtual 关键字的修饰也可以没有。

隐藏和重写,重载的区别:

范围区别:隐藏与重载范围不同,隐藏发生在不同类中。
参数区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同;当参数不同时,无论基类中的函数是否被 virtual 修饰,基类函数都是被隐藏,而不是重写。

3.抽象类

3.1抽象类的概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类
不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。可以更好的去表示现实世界中,没用实例对象对应的抽象类型比如说:人,植物,动物。体现接口继承强制子类去重写虚函数。注意和overeride区分开来,override是检查子类虚函数是否完成重写

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;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}

3.2接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的
继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所
以如果不实现多态,不要把函数定义成虚函数。

 4.多态的原理

4.1虚函数表

我们来看一道面试题: 

#include<iostream>
using namespace std;
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
	char ch = '\0';
};
int main() {
	Base bs;
	cout << sizeof(Base) << endl;
	return 0;
}

可能很多同学看到这个题的时候一看_b是4个字节然后呢ch是一个字节在内存对齐一下就是8个字节。结果真的是8个字节吗?我们运行程序:

我们发现是12个字节?为什么是12个字节呢?我们通过调试窗口来看一下:

我么发现这个类中多了个指针:全称叫做虚函数表指针简称虚表指针,复杂一点就叫做函数指针数组。所以大小为12个字节

注意:这里跟虚拟继承是不一样的,虽然他们都用了virtual关键字但是使用场景完全不一样,解决的问题也不一样。他们之间是没有关联的虚继承产生的是虚基表,虚基表里面存的是距离虚基类的偏移量

针对上面出现的这种现象我么通过下面这段代码来研究:

#include<iostream>
using namespace std;
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;
};
void Test(Base& bs) {
	bs.Func1();
}
int main()
{
	Base b;
	Test(b);
	Derive d;
	Test(d);
	return 0;
}

相信大家都有一个疑问为什么bs调用Func1时bs引用的是父类就调用父类的虚函数,引用子类调用的就是子类的虚函数。下面我们通过调试来进一步探究。

通过调试在监视窗口我们可以发现子类和父类都有一个虚表,当一个父类的指针或者引用调用虚函数时不是编译时确定的而是运行时到指向对象的虚表中去找对应的虚函数并调用,所以指向的是父类的对象调用的就是父类的虚函数,指向的是子类的对象调用的就是子类的虚函数。

如果我们将virtual关键字去掉没有完成重写。

#include<iostream>
using namespace std;
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:
	
private:
	int _d = 2;
};
void Test(Base& bs) {
	bs.Func1();
}
int main()
{
	Base b;
	Test(b);
	Derive d;
	Test(d);
 return 0;
}

此时我们在打开监视窗口:

我们发现子类和父类的虚函数表是一模一样的,我们可以推出子类是先将父类的虚函数表拷贝一份下来,在看自己有没有重写父类的虚函数,在将虚函数表中对应的位置进行覆盖。所以如果不构成多态,那么这里调用的时候就是编译时确定调用那个函数,主要是看bs的类型。

总结:1.构成多态,指向谁调用的就是谁的虚函数,跟对象有关。

**            2.不构成多态,对象类型是什么调用的就是那个函数跟类型有关。**

思考:为什么必须是父类的指针或者引用调用虚函数时发生多态,不能是对象呢?

这和继承中的切片有关,将子类的对象给父类的对象时会调用拷贝构造函数但是不是全部拷贝,而是只拷贝属于父类的那一部分并不会将虚函数表拷贝过去,这也就是为什么不能是父类的对象。

4.2动态绑定与静态绑定

编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。
运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。

编译时多态和运行时多态的区别:

时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。

 4.3虚表何时初始化

对象中的虚表何时初始化?虚表又是在什么阶段生成的呢?

#include<iostream>
using namespace std;
class Base
{
public:
	Base() {
		cout << "Base()调用" << endl;
	}
	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:
	
private:
	int _d = 2;
};
void Test(Base& bs) {
	bs.Func1();
}
int main()
{
	Base b;
	Test(b);
	Derive d;
	Test(d);
 return 0;
}

我们继续调试:

在进入初始化列表时我们发现虚表并没有初始化,此时我们按一下F10

此时虚表已经初始化了。

总结:1.虚表中的指针是在构造函数初始化列表初始化的。

**           2.虚表是在编译时就已经生成好了。**

**           3.只有虚函数才会将函数的地址放入虚表中,虚函数和普通函数一样,编译完成后都是放到代码段。**

下面我们来看一个面试题?虚表是放在哪里?(栈 堆 静态区 代码段)写一段程序验证一下你的猜想.

1.我们可以将虚表的地址取出来,然后在和这四个区域的地址进行对比看地址和那个区域最相似

#include<iostream>
using namespace std;
class Base
{
public:
	Base() {
		cout << "Base()调用" << endl;
	}
	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:
	
private:
	int _d = 2;
};
void Test(Base& bs) {
	bs.Func1();
}
typedef void (*Func)();
int main()
{
	Base b;
	//1.先取出虚表的地址打印
	printf("vftptr:%p\n", (Func*)(*(int*)(&b)));//对象的前四个字节放的就是虚表
	//打印栈上的地址
	int i = 0;
	printf("栈:%p\n", &i);
	//打印堆上的地址
	int* p = new int(1);
	printf("堆:%p\n", p);
	//打印常量区的地址
	const char* ptr = "12323";
	printf("常量区:%p\n", ptr);

 return 0;
}

我们可以发现虚表的地址和产量区的地址最接近。

写一个程序打印虚表,确认虚表中的调用函数。

#include<iostream>
using namespace std;
class Base
{
public:
	Base() {
		cout << "Base()调用" << endl;
	}
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		
			cout << "Base::Func2()" << endl;
	}
	virtual void Func3()
	{
		cout << "Base::Func3()" << endl;
	}
private:
	int _b = 1;
};
class Derive : public Base
{
public:
	
private:
	int _d = 2;
};
void Test(Base& bs) {
	bs.Func1();
}
typedef void (*VFunc)();
void PrintVFT(VFunc* ptr) {
	printf("虚表地址:%p\n", ptr);
	for (int i = 0; ptr[i]; i++) {
		ptr[i]();
	}
	printf("\n");
}
int main()
{
	Base b;
	//1.先取出虚表的地址打印
	//printf("vftptr:%p\n", (VFunc*)(*(int*)(&b)));//对象的前四个字节放的就是虚表
	PrintVFT((VFunc*)(*(int*)(&b)));
	
 return 0;
}

4.4多继承中的虚表

对于多继承而言子类会存在两份虚表,下面给出代码读者可以自己下来调试:

#include<iostream>
using namespace std;
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;
}

5.继承多态常考面试题

  1. 下面哪种面向对象的方法可以让你变得富有( )
    A: 继承 B: 封装 C: 多态 D: 抽象

  2. ( )是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关,而对方法的
    调用则可以关联于具体的对象。
    A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定

  3. 面向对象设计中的继承和组合,下面说法错误的是?()
    A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复用,也称为
    白盒复用
    B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动态复用,也
    称为黑盒复用
    C:优先使用继承,而不是组合,是面向对象设计的第二原则
    D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封装性的表现

  4. 以下关于纯虚函数的说法,正确的是( )
    A:声明纯虚函数的类不能实例化对象 B:声明纯虚函数的类是虚基类
    C:子类必须实现基类的纯虚函数 D:纯虚函数必须是空函数

  5. 关于虚函数的描述正确的是( )
    A:派生类的虚函数与基类的虚函数具有不同的参数个数和类型 B:内联函数不能是虚函数
    C:派生类必须重新定义基类的虚函数 D:虚函数可以是一个static型的函数

  6. 关于虚表说法正确的是( )
    A:一个类只能有一张虚表
    B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
    C:虚表是在运行期间动态生成的
    D:一个类的不同对象共享该类的虚表

  7. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( )
    A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址
    B:A类对象和B类对象前4个字节存储的都是虚基表的地址
    C:A类对象和B类对象前4个字节存储的虚表地址相同
    D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表

8.下面程序会输出什么?()

#include<iostream>
using namespace std;
class A{
public:
A(char *s) { cout<<s<<endl; }
~A(){}
};
class B:virtual public A
{
public:
B(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class C:virtual public A
{
public:
C(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class D:public B,public C
{
public:
D(char *s1,char *s2,char *s3,char *s4):B(s1,s2),C(s1,s3),A(s1)
{ cout<<s4<<endl;}
};
int main() {
D *p=new D("class A","class B","class C","class D");
delete p;
return 0;
}

A:class A class B class C class D B:class D class B class C class A
C:class D class C class B class A D:class A class C class B class D

  1. 多继承中指针偏移问题?下面说法正确的是( )
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main(){
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}

A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3

  1. 以下程序输出结果是什么()
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;
}

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

答案:

  1. A 2. D 3. C 4. A  5. B
  2. D 7. D 8. A 9. C 10. B

6.问答题

1. 什么是多态?答:参考博客上方内容
2. 什么是重载、重写(覆盖)、重定义(隐藏)?答:参考博客上方内容
3. 多态的实现原理?答:上面已经提过
4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为
虚函数要放到虚表中去。
5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式
无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始
化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义
成虚函数。
8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针对象或者是
引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码
段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?答:可以看看我之前的那篇博客。注意这里不要把虚函数表和虚基表搞混了。
11. 什么是抽象类?抽象类的作用?答:请看上面已经说过了。抽象类强制重写了虚函数,另外抽象类体现出
了接口继承关系。

最后面向对象的三大特性就结束了在这里小结一下:

面向对象:对象是指具体的某一个事物,这些事物的抽象就是类,类中包含数据(成员变量)和动作(成员方法)。

面向对象的三大特性:

封装:将具体的实现过程和数据封装成一个函数,只能通过接口进行访问,降低耦合性。
继承:子类继承父类的特征和行为,子类有父类的非 private 方法或成员变量,子类可以对父类的方法进行重写,增强了类之间的耦合性,但是当父类中的成员变量、成员函数或者类本身被 final 关键字修饰时,修饰的类不能继承,修饰的成员不能重写或修改。
多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。

相关文章