【C++进阶】多态详解(上)

文章详细阐述了C++中的多态概念,包括多态的构成条件、虚函数的定义与重写,以及析构函数是否应为虚函数。C++11的`override`和`final`关键字也在讨论范围内,用于确保函数重写和防止进一步重写。此外,文章介绍了抽象类以及虚函数表在多态中的作用,探讨了多态实现的内部原理。
摘要由CSDN通过智能技术生成

在这里插入图片描述

一、多态的概念

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

例如在生活中,我们帮别人的拼多多砍一刀时,如果是新用户就会砍很多,如果是老用户就只能砍一点,对于不同的对象有不同的状态。

二、多态的定义及实现

1.多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。
要实现多态,必须满足以下的几个条件:

1.子类函数对父类函数实现重写,也就是覆盖。
2.必须通过父类的指针或者引用去调用虚函数。

2.虚函数

我们在前边提到过virtual关键字,加在类的后边,使公有的类成为虚基类,来解决菱形继承产生的问题。而在这里virtual关键字来修饰函数,可以让函数成为虚函数,下边来看虚函数定义:

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

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

上边的函数就是一个虚函数。

3.虚函数的重写

(1)虚函数重写概念

在上边提到,实现多态的一个条件就是子类的虚函数对父类的虚函数进行重写,那么什么是重写呢?重写又需要哪些条件,这是我们关心的问题。

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

下边的程序中实现了两个类,父类是person类,买票全价,而子类是student类买票半价,他们中的虚函数的函数名,参数,返回值都相同,符合重写的要求,所以子类函数对父类函数进行了重写。

class Person
{
public:
	virtual void buyTicket()
	{
		cout << "person::买票全价" << endl;
	}
};
class Student : public Person
{
	virtual void buyTicket()
	{
		cout << "student::买票半价" << endl;
	}
};
void func(Person& p)
{
	p.buyTicket();
}

(2)虚函数重写的两个例外:

  1. 协变(基类与派生类虚函数返回值类型不同)
    派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class Person
{
public:
	virtual Person* buyTicket()
	{
		cout << "person::买票全价" << endl;
		return this;
	}
};
class Student : public Person
{
	virtual Student* buyTicket()
	{
		cout << "student::买票半价" << endl;
		return this;
	}
};
void func(Person& p)
{
	p.buyTicket();
}
int main()
{
	Person p;
	Student s;
	func(p);
	func(s);
	return 0;
}

我们将父子类中的虚函数的返回值改变,但是返回值也是父子关系,我们发现,他们照样构成重写。
在这里插入图片描述
2. 析构函数的重写(基类与派生类析构函数的名字不同)

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

所以另外一种特殊情况就是只要父类中的函数加上virtual,子类的函数可以不加virtual关键字,也可以构成重写,但是建议加上关键字。

class Person
{
public:
	virtual Person* buyTicket()
	{
		cout << "person::买票全价" << endl;
		return this;
	}
};
class Student : public Person
{
	Student* buyTicket()
	{
		cout << "student::买票半价" << endl;
		return this;
	}
};
void func(Person& p)
{
	p.buyTicket();
}
int main()
{
	Person p;
	Student s;
	func(p);
	func(s);
	return 0;
}

在这里插入图片描述


(3)析构函数是否要定义为虚函数

以下来探究析构函数为什么要定义为虚函数:
先来看以下的代码:

class Person {
public:
	~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
	~Student() { cout << "~Student()" << endl; }
};
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;

	delete p1;
	delete p2;
	return 0;
}

如果不将析构函数定义为虚函数,会出现以下的结果:
在这里插入图片描述
我们发现我们在堆上开辟了两份空间,却没有释放子类函数中子类的那一部分,所以这会造成内存泄露,而当我们将析构函数定义为虚函数之后,会有以下的结果:
在这里插入图片描述
我们发现内存泄露的问题解决了,那么这到底是为什么呢?
这是因为,在实现了多态之后,我们传入什么类型的对象,就去调用该类的中函数,例如我们new了一个Student类的对象,就回去该类中去找函数,这也就是多态的意义,如果不实现多态,当我们传入一个Person对象类型的指针p时,delete会转化为p->destructor(),所以只会去调用Person类的析构函数。只有子类的析构函数重写父类的析构函数,才会传入什么类型对象,就去调用该类的析构函数,指向子类调用子类,指向父类调用父类。

(4)C++11 override 和 final

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

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

在这里插入图片描述
2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
正确写法:

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

错误写法

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

int main()
{
	Car c;
	Benz b;
	b.Drive(1);

在这里插入图片描述

三、抽象类

1.概念

在虚函数的后面写上 =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;
	}
};
void Test()
{
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
}
int main()
{
	Test();
	return 0;
}

对父类的纯虚函数进行重写,相当于强制重写,如果不重写,就不能实例化出对象。

2.接口继承和实现继承

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

四、多态的原理

1.虚函数表

// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
 virtual void Func1()
 {
 cout << "Func1()" << endl;
 }
private:
 int _b = 1;
};

在这里插入图片描述
我们会发现这和我们之前学习的类与对象的内容不同了,不只是去计算成员变量的大小,而是再加入了一个指针,这个指针是指向虚函数表的指针。
在这里插入图片描述
我们发现b对象有两个内容,一个是成员变量,一个就是指针,这个指针就是指向虚函数表的,虚函数表中存储的就是该类中的虚函数的指针,虚函数表就是一个函数指针数组。

2.多态的原理探究

接着上边的代码我们继续通过加入一个子类来分析:

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

在这里插入图片描述
我们发现子类和父类都有虚函数表,但是子类的虚函数表中的第一个函数指针与父类不同,但是第二个是相同的,这是因为第一个函数满足重写规则,子类对父类的函数进行了重写,所以地址改变,是另外一个函数,而第二个虚函数在子类中并没有,所以没有构成重写,就直接继承了。


接下来继续进行验证:
在这里插入图片描述

那么同时使用父类的指针或者引用去调用虚函数时,当父类的对象使用父类指针调用,不会发生切片,是直接去调用,但是当子类的对象使用父类的指针或者引用去调用时会发生切片,在切片之后,只能看到子类中继承下来父类的那一部分,但是在子类的虚函数表中,如果满足重写,那么虚函数表中的函数指针就是子类中的函数重新生成的,所以是不同的函数,所以调用虚函数时,指向哪一个对象,就去调用该对象的函数。


下边通过反汇编来观察一下实现多态和不实现多态的区别:
在这里插入图片描述
在这里插入图片描述
我们发现,当构成多态时,在调用函数时,先去判断是指针指向哪个对象,然后在对象的虚函数表中去寻找函数的地址,再通过函数的地址去调用对应的函数。这也就是为什么多态调用被称为运行时决议,而普通调用就是直接在对象中寻找函数指针,找到对应函数,被称为编译时决议。
在这里插入图片描述


那么函数指针在虚函数表中是怎么存储的呢?我们可以来通过内存窗口观察一下:
在这里插入图片描述
在这里插入图片描述
我们发现虚函数表中,存储的是虚函数的地址,在子类中有几个虚函数就会有几个虚函数的地址,但是在虚表的末尾也会有一个空指针代表虚表的结束。


我们再来看下边一段代码:

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;
	}
	virtual void Func5()
	{
		cout << "Derive::Func5()" << endl;
	}
private:
	int _d = 2;
};

前边提到虚函数都会存储在虚表中,那么上边代码中子类的虚函数是否都会存储在虚表中呢?
在这里插入图片描述

通过调试,我们发现编译器显示并没有将子类中第二个虚函数显示出来,那么到底会不会加载到虚表中,那么能通过什么方式来验证一下呢?我们可以通过一段代码来验证:

//typedef定义函数指针为VFPTR
typedef void(*VFPTR)();
//由于是一个函数指针数组,所以只要传入数组首元素的地址,就可以通过循环顺序访问数组中每一个元素
//也就是函数的指针,通过函数指针来调用函数就可以知道虚函数表中分别存储了哪些函数的指针
typedef void(*VFPTR)();

void PrintVFTable(VFPTR* table,size_t n)
{
	for (size_t i = 0; i < n; ++i)
	{
		printf("vft[%d]:%p->", i, table[i]);
		//table[i]();
		VFPTR pf = table[i];
		pf();
	}
	cout << endl;
}
int main()
{
	Base b;
	Derive d;
	PrintVFTable((VFPTR*)*(int*)&d,3);
	PrintVFTable((VFPTR*)*(int*)&b,2);

	//func(b);
	//func(d);
	//return 0;
}

在这里插入图片描述
我们可以通过地址来读取函数,发现虚函数表中有哪些函数。

3.多态原理总结

上面分析了这个半天了那么多态的原理到底是什么?还记得这里Func函数传Person调用的
Person::BuyTicket,传Student调用的是Student::BuyTicket

class Person {
public:
 virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
 virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
 Func(Mike);
Student Johnson;
Func(Johnson);
 return 0;
}

在这里插入图片描述
根据下图中的示例,我们会发现其实多态的原理就是当实现多态时,不论传入哪一个对象的指针和引用,都会去该对象中找到虚函数表,再通过虚函数表找到该函数指针,再去调用相应的函数。

  1. 观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚
    函数是Person::BuyTicket。
  2. 观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
    找到虚函数是Student::BuyTicket。
  3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
  4. 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
    用虚函数。

在这里插入图片描述

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

清扰077

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值