【C++详解】深入解析多态 虚函数、虚函数重写、纯虚函数和抽象类、多态原理、重载/重写/隐藏的对⽐

文章目录


一、多态的概念

多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运⾏时多态(动态多态),这⾥我们重点讲运⾏时多态。编译时多态(静态多态)主要就是我们前⾯讲的函数重载和函数模板,他们传不同类型的参数就可以调⽤不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时⼀般归为静态,运⾏时归为动态。

运⾏时多态,具体点就是去完成某个⾏为(函数),可以传不同的对象就会完成不同的⾏为,就达到多种形态。⽐如买票这个⾏为,当普通⼈买票时,是全价买票;学⽣买票时,是优惠买票(5折或75折);军⼈买票时是优先买票。再⽐如,同样是动物叫的⼀个⾏为(函数),传猫对象过去,就是"(>ω<)喵",传狗对象过去,就是"汪汪"。

我们下面看一个例子:

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在调⽤BuyTicket
	// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
	ptr->BuyTicket();
}

int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

要实现上面的效果就要依托于我们继承章节讲的赋值兼容转换,这里从结果上来看就是传不同参数给同一个函数,然后在这个函数里用不同的参数调用了不同的函数,从过程来看它是在运行时达到的,区别于函数模板和函数重载。

二、多态的定义与实现

多态是⼀个继承关系的下的类对象,去调⽤子类和父类或者父类的不同子类的同⼀函数(父子间多态和子类间多态),产⽣了不同的⾏为。⽐如Student继承了Person。Person对象买票全价,Student对象优惠买票。

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

1、必须是基类的指针或者引⽤调⽤虚函数。

2、被调⽤的函数必须是虚函数,并且完成了虚函数重写/覆盖。

虚函数

类成员函数前⾯加virtual修饰,那么这个成员函数被称为虚函数。注意虽然这里关键字和前面虚继承的一样,但是它们之间没有关联,虚继承是为了解决菱形继承的数据冗余和二义性,这里是为了实现多态,他俩只是单纯的名字一样。

virtual只能用来修饰类的非静态成员函数,⾮静态函数和非成员函数如全局函数不能加virtual修饰。

虚函数的重写/覆盖

派⽣类中有⼀个跟基类完全相同的虚函数(即派⽣类虚函数要与基类虚函数的返回值类型、函数名字、参数列表类型完全相同,不看缺省值和参数名),称派⽣类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派⽣类的虚函数不加virtual关键字,也可以和基类的虚函数构成重写(因为继承后基类的虚函数被继承下来了在派⽣类依旧保持虚函数属性,可以简单理解为多态调用时是用父类的virtual声明和子类的重写实现),但是该种写法不是很规范,不建议这样使⽤,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。

一道无敌逆天恶心题

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

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

cpp 复制代码
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;
}

这里小编先引用一个在继承章节介绍的结论: 对于成员函数(非静态),当通过子类对象 /

指针调用某个函数时,如果子类没有显式定义这个函数,编译器会自动向上(父类、祖父类等)查找该函数,(因为继承原因,父类的成员函数会继承给子类,但是函数并不会直接拷贝一份给子类,所以会往上找)直到找到为止(如果整个继承链都没有,才会编译报错),如果子类显式定义这个函数,编译器仅在子类中查找匹配的函数(根据参数列表),若找到则调用;若未找到,不会自动到父类中查找(直接编译报错)。

首先p调用test时在子类没找到,所以会去父类找,找到父类test后就在父类的作用域里执行test函数,test里会调用Func,在父类的作用域就是用父类的this指针调用的Func,那么就满足多态的其中一个条件,这里Func也确实完成了虚函数的重写,所以两个条件都满足。这里就根据调用func的对象实际指向或引用的类型来调用该类型下的func函数,所以这里有个调用B里的func,然后又因为虚函数的默认参数由静态类型决定,而非动态类型,所以虽然func()

在 A 中声明的默认参数是 1,在 B 中重写时改为 0,但 func() 是在 A::test() 中被调用的,此时编译器将 this 指针视为 A* 类型(静态类型),因此使用 A 中声明的默认参数 1。最后答案选B。

协变

派⽣类重写基类虚函数时,与基类虚函数返回值类型可以不同。但是必须基类虚函数返回基类对象的指针或者引⽤,派⽣类虚函数返回派⽣类对象的指针或者引⽤时,称为协变。协变的实际意义并不⼤,所以我们了解⼀下即可。

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

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

int main()
{
	Person ps;
	Student st;
	Func(&ps);
	Func(&st);
	return 0;
}

这里返回值不一定非要是A和B,只要返回值类型满足父子关系就行。

析构函数的重写

我们先来看下面这个例子:

cpp 复制代码
class A
{
public:
	//~A()  错误写法,应该写成虚函数来构成多态
	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];
};

// 只有派⽣类Student的析构函数重写了Person的析构函数,
// 下⾯的delete对象调⽤析构函数,才能构成多态,
// 才能保证p1和p2指向的对象正确的调⽤析构函数。
int main()
{ 
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
	delete p2;
	return 0;
}

前言:自己new的必须自己显示调用delete。

p1、p2都是父类的指针,但是p1实际指向父类,p2实际指向子类,那么当p1、p2调用delete时都会因为p1、p2是父类类型的指针而去调用父类的析构函数(delete本质是先调用对应类型的析构函数释放资源然后调用operator delete类似free释放本体),就会导致A对象成功析构了,而B对象没有释放,引起内存泄漏。所以我们这里预期是A、B两个类的析构函数构成多态,这样就可以根据调用析构函数的对象来正确的释放资源。

需要构成多态首先析构函数必须是虚函数,且返回值、函数名、参数列表的参数类型要相同,返回值和参数列表不必说,唯一需要处理的就是析构函数函数名,这里也印证了我们在继承章节介绍的,直接在子类调用构造函数是调不到的,因为子类的构造函数把父类的构造函数隐藏了,(实际上父类的析构函数不用我们显示调,只是说明析构函数会被隐藏)原因就是系统会把析构函数会统一特殊处理为destructor,就是为了满足这里让析构函数构成多态。

我们设计出一个类如果这个类有可能被继承需要把它的析构写成虚函数,这样即使在设计派生类时析构函数忘记了加virtual,也照样和基类析构构成多态。这里也可以解释为什么只要基类虚函数加了virtual,派生类不加virtual也构成重写。

override 和 final关键字

从上⾯可以看出,C++对虚函数重写的要求⽐较严格,但是有些情况下由于疏忽,⽐如函数名写错参数写错等导致⽆法构成重写,⽽这种错误在编译期间是不会报出的,只有在程序运⾏时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助⽤⼾检测是否重写,没有完成重写就会报错。如果我们不想让派⽣类重写这个虚函数,那么可以⽤final去修饰。

cpp 复制代码
// error C3668: "Benz::Drive": 包含重写说明符"override"的⽅法没有重写任何基类⽅法
//下面报错因为函数名不同,不构成重写
class Car {
public:
	virtual void Dirve()
	{}
};

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


class Car
{
public:
	virtual void Drive() final {}
};

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

重载/重写/隐藏的对⽐

三、纯虚函数和抽象类

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

下面是两个子类重写父类的纯虚函数后构成子类间多态的例子:

cpp 复制代码
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()
{
	// 编译报错:error C2259: "Car": ⽆法实例化抽象类
	//Car car;
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}

四、多态的原理

虚函数表指针

在介绍虚表指针之前先来看下面这段带代码:

上⾯代码的运⾏结果是12bytes(32位,64位的话是16字节),除了_b和_ch成员,还多⼀个__vfptr放在对象的前⾯(注意有些平台可能会放到对象的最后⾯,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。⼀个含有虚函数的类中都⾄少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表,本质就是一个虚函数指针数组。

多态是如何实现的

重写是语法层表达,覆盖是原理层表达,如果子类的虚函数没有和父类的虚函数构成重写那么子类虚函数表存的就是从父类继承下来的虚函数和子类自己定义的虚函数,如果构成重写那么子类虚函数表存的就只有子类自己定义的虚函数,因为这个虚函数把从父类继承下来的虚函数覆盖了。

我们来看下面这个例子:

cpp 复制代码
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 sr;
	Func(&ps);
	Func(&st);
	Func(&sr);
	return 0;
}

在编译期编译器会去检查调用的虚函数构不构成多态,如果不构成多态,就变成普通函数调用,根据调用对象的类型去对应类型里面查找是否有被调用的函数,如果没有则报错,如果有则call这个函数的地址调用这个函数。如果构成多态,那么就一定是基类类型的指针或引用在调用,这个基类类型的指针或引用就有可能因为赋值兼容转换指向或引用一个基类对象或者指向或引用一个派生类对象里的父类的那一部分,所以本质都是引用的父类对象,区别是在引用的哪个类型的父类对象,因为每个类的虚表里都存着这个类自己的虚函数指针,所以区别方法就是去引用对象的头四个或八个字节里找到对象的虚表指针,然后去虚表指针指向的虚表里找到类中虚函数的地址,调用这个类里的虚函数。
那为什么多态必须要在运行时达成呢,我们来看下面C++标准规定的多态实现机制:

编译期:确定每个类的虚函数表结构(包含哪些虚函数、函数地址如何排列),并生成 "通过虚表指针查找虚函数表并调用函数" 的通用代码。

运行时:对象是在运行时才被创建的,所以只有在运行时才能拿到调用虚函数的对象里的虚函数表的值。根据实际创建的对象类型,确定对象的虚表指针指向哪个类的虚函数表,最终通过这个虚函数表找到要调用的函数地址。

换言之,调用虚函数的基类指针可以指向基类或其所有子类对象,但具体指向哪个对象,要在运行时才能确定。
精髓提炼:多态依赖于运行时才能确定的调用函数的对象里的值,模板依赖于在编译器就可以确定的调用函数的实参类型。

所以多态的原理就是在运行时,到指定对象的虚表中找到对应虚函数的地址进行调用。

最后一点,多态为什么只能传指针和引用:

多态依赖于 "对象的实际类型不变",而指针 / 引用恰好保证了这一点,对象传递则因切片破坏了这一前提。

动态绑定与静态绑定

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

因为静态绑定时调⽤函数是由在编译就能确定的调用函数的对象类型决定的。
满⾜多态条件的函数调⽤是在运⾏时绑定,也就是在运⾏时到指向对象的虚函数表中找到调⽤函数的地址,也就做动态绑定。

动态绑定时,函数调用由运行时才能确定的、对象实例化后内存中的具体值所决定。(对象里的虚表指针)

虚函数表

1、基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共⽤同⼀张虚表,不同类型的对象各⾃有独⽴的虚表,所以基类和派⽣类有各⾃独⽴的虚表(不论是否完成重写)。

2、派⽣类由两部分构成,继承下来的基类和⾃⼰的成员,⼀般情况下,继承下来的基类中有虚函数表指针,⾃⼰就不会再⽣成虚函数表指针。但是要注意的这⾥继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派⽣类对象中的基类对象成员也独⽴的。

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

4、派⽣类的虚函数表中包含,(1)继承下来没有完成重写的基类的虚函数地址 (2)派⽣类重写的虚函数地址(3)派⽣类⾃⼰的虚函数地址三个部分。

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

6、虚函数存在哪的?虚函数和普通函数⼀样的,编译好后是⼀段指令,都是存在代码段(静态区)的,只是虚函数的地址⼜存到了虚表中,为了实现多态。

7、虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下⾯的代码可以对⽐验证⼀下。

cpp 复制代码
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;
}

我们可以发现vs下是存在代码段(常量区)。

以上就是小编分享的全部内容了,如果觉得不错还请留下免费的关注和收藏如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~

相关推荐
两颗泡腾片5 小时前
C++提高编程学习--模板
c++·学习
你好!蒋韦杰-(烟雨平生)6 小时前
扫雷游戏C++
c++·单片机·游戏
monicaaaaan7 小时前
搜索二维矩阵Ⅱ C++
c++·线性代数·矩阵
zh_xuan8 小时前
duiLib 自定义资源目录
c++·ui
西红柿煎蛋8 小时前
C++11的可变参数模板 (Variadic Templates) 是如何工作的?如何使用递归解包一个参数包 (parameter pack)?
c++
源代码•宸8 小时前
深入浅出设计模式——创建型模式之原型模式 Prototype
c++·经验分享·设计模式·原型模式
晨曦学习日记9 小时前
Leetcode239:滑动窗口最大值,双端队列的实现!
数据结构·c++·算法
wait a minutes9 小时前
【c++】leetcode763 划分字母区间
开发语言·c++
菜还不练就废了9 小时前
7.25 C/C++蓝桥杯 |排序算法【下】
c语言·c++·排序算法
饭碗的彼岸one10 小时前
重生之我在10天内卷赢C++ - DAY 2
linux·开发语言·c++·笔记·算法·vim