【浅尝C++】多态机制=>重载重写隐藏的区别/抽象类/单继承与多继承的虚函数表/多态原理及虚函数表内存存储详谈

🏠专栏介绍:浅尝C++专栏是用于记录C++语法基础、STL及内存剖析等。

🎯每日格言:每日努力一点点,技术变化看得见。

文章目录


多态的概念

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

在生活中,我们在乘坐公交车时,不同的人会有不同的折扣力度,例如:学生卡8折,老人卡免费等等。对于同一件事,会发生不同的不同形态就称为多态。

多态的定义及实现

多态的构成条件

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

例如:下图中Student类继承了Person类。两个对象在调用同一个函数takeBus时,一个执行的是Person中的getTickets,一个执行的是Student中的getTicket。这就是调用同一个函数takeBus,却产生了不同的行为。

继承中要构成多态还有两个条件:

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

在讲述这两个条件之前,我们先来聊聊什么是虚函数↓↓↓

虚函数

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

下面代码中,getTicket成员函数被virtual修饰,因而它是虚函数。↓↓↓

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

虚函数的重写

那虚函数有什么用途呢?如果将基类的成员函数用virtual修饰成为虚函数后,派生类中可以对该成员函数进行重写(覆盖)。

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

下面代码中的Person类与Student类中的getTicket构成了虚函数重载↓↓↓

cpp 复制代码
#include <iostream>
using namespace std;

class Person
{
public:
	virtual void getTicket()
	{
		cout << "全票" << endl;
	}
};

class Student : public Person
{
public:
	//派生类中可以省略virtual关键字,但不推荐
	//void getTicket() --> 这样写也可以
	virtual void getTicket()
	{
		cout << "8折" << endl;
	}
};

void takeBus(Person* p)
{
	p->getTicket();
}

void takeBusByReference(Person& p)
{
	p.getTicket();
}

int main()
{
	Student s;
	Person p;

	s.getTicket();
	s.Person::getTicket();
	cout << "================" << endl;
	takeBus(&s);
	takeBus(&p);
	cout << "================" << endl;
	takeBusByReference(s);
	takeBusByReference(p);
	return 0;
}

从上面代码中可以看出,如果从s.getTicket();s.Person::getTicket();两行代码来看,重写(覆盖)与隐藏并没有多大区别。没有显示指定调用基类的同名函数时,会默认调用派生类的;如果显示指定了基类作用域,则会调用基类的。

重写(覆盖)与隐藏的区别在于,重写执行takeBus及takeBusByReference时,对于Person对象和Student对象,它会调用对应对象的同名(同名、同参数列表、同返回值)函数。如果是隐藏,则都只会执行指针或引用类型对应类的函数,而不会执行它的子类的同名函数(同名、同参数列表、同返回值)。

虚函数重写(覆盖)的两个特例:

协变 (基类与派生类虚函数返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同,但两个不同的返回值之间必须构成子类与父类关系。即基类虚函数返回基类对象的指针 或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

下面代码给出了协变示例↓↓↓

cpp 复制代码
#include <iostream>
using namespace std;

class Base
{};
class Son : public Base
{};

class Person
{
public:
	virtual Base* ptest()
	{
		cout << "Person's ptest()" << endl;
		return &b;
	}
	virtual Base& refertest()
	{
		cout << "Person's refertest()" << endl;
		return b;
	}
private:
	static Base b;
};

Base Person::b = Base();

class Student : public Person
{
public:
	virtual Son* ptest()
	{
		cout << "Son's ptest()" << endl;
		return &s;
	}
	virtual Son& refertest()
	{
		cout << "Son's refertest()" << endl;
		return s;
	}
private:
	static Son s;
};

Son Student::s = Son();

void test(Person* p)
{
	p->ptest();
	p->refertest();
}

int main()
{
	Person p;
	test(&p);
	Student s;
	test(&s);
	return 0;
}

上面代码中Base与Son构成父子类关系,Person与Student类中的ptest及refertest分别返回父子类的指针和引用,因而构成协变关系。故在使用父类Person指针指向Student时,会调用Student的成员函数,因为此时构成了重写(覆盖)。

析构函数的重写 (基类与派生类析构函数的名字不同)

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

析构函数的重写主要应用在:父类与子类构成函数重载时,如果父类指针指向子类对象(在没有重写的情况下),只会调用父类的析构函数,这样会导致子类的空间未释放干净,进而导致内存泄漏。为了防止内存泄漏,在使用父类指针或引用调用子类的析构函数时(在构成重写的情况下),会调用子类的析构函数,子类析构函数不仅会释放父类继承下来的成员变量,还会释放自己多出的成员变量。

override与final(C++11)

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

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

下面代码中Person重写了Animal中的showInfo,此时Person中的showInfo使用final修饰,则它的子类Student不能重写该函数。↓↓↓

cpp 复制代码
class Animal
{
public:
	virtual void showInfo()
	{}
};

class Person : public Animal
{
public:
	virtual void showInfo() final
	{}
};

class Student : public Person
{
public:
	virtual void showInfo()//error!! 父类已经使用final修饰,表示不能够被重写(覆盖)
	{}
};
  1. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

下面代码中,Person类的showInfo使用override关键字检查showInfo是否与Animal中的ShowInfo构成重写,由于两者函数声明不同(参数列表不同),故不能构成重写而报错。

cpp 复制代码
class Animal
{
public:
	virtual void showInfo()
	{}
};

class Person : public Animal
{
public:
	virtual void showInfo(int) override
	{}
};

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

下图说明了重载、重写、隐藏之间的不同之处。注意:如果两个函数分别在基类和派生类作用域,并且函数名相同,如果没有构成重写(覆盖),则构成隐藏(重定义)。

抽象类

概念

在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类 (也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数使得抽象类的各个派生类必须提供提供某个相同的接口,体现了接口继承。

下面程序中,Add类与Sub类均继承了Intern类,因而它们均需要提供show接口↓↓↓

cpp 复制代码
#include <iostream>
using namespace std;

class Intern
{
public:
	virtual void show() = 0;//纯虚函数
}; 

class Add : public Intern
{
public:
	Add(int num1, int num2)
		:_num1(num1)
		, _num2(num2)
		, _result(num1 + num2)
	{}
	virtual void show() override
	{
		cout << _num1 << " + " << _num2 << " = " << _result << endl;
	}
private:
	int _num1;
	int _num2;
	int _result;
};

class Sub : public Intern
{
public:
	Sub(int num1, int num2)
		:_num1(num1)
		, _num2(num2)
		, _result(num1 - num2)
	{}
	virtual void show() override
	{
		cout << _num1 << " - " << _num2 << " = " << _result << endl;
	}
private:
	int _num1;
	int _num2;
	int _result;
};

int main()
{
	Add add(2, 1);
	add.show();
	Sub sub(2, 1);
	sub.show();
	return 0;
}

实现继承与接口继承

●普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用基类的函数,继承的是函数的实现。

●虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口(接口等同于函数声明)。

所以如果不实现多态,建议不要把函数定义成虚函数。如果隐藏(重定义)已经能满足需求的情况下,如果将其改为虚函数,则会导致当前类及该类的派生类会多出虚函数表指针及虚函数表的空间开销。

多态的原理

虚函数表

空类的大小为1个字节,这1字节用于标识这个类的存在。非空类的大小等于它的成员变量的大小(需要考虑内存对齐),而成员函数存储于公共代码段,不计入类对象的大小中。咱使用一个代码验证空类的大小↓↓↓

cpp 复制代码
#include <iostream>
using namespace std;

class Empty
{};

class Base
{
public:
	virtual void virtualBaseFunc()
	{}
	void normalBaseFunc()
	{}
	int _base;
};

int main()
{
	Empty e;
	Base b;
	b._base = 2;
	cout << "size of Empty is " << sizeof(e) << endl;
	cout << "size of Base is " << sizeof(b) << endl;
	return 0;
}

Base类中明明只有一个成员变量_base,它的大小不应该是8吗?为什么是16呢?我们使用监视查看,b对象中不仅有成员变量_base,还多出了一个指针。

我们再使用内存查看以下对象b的存储情况↓↓↓

我们发现这个指针存储在整个对象的最前面,在它后面跟着的才是对象的成员变量。

对象中的这个指针我们叫做虚函数表指针。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表简称为虚表。

下面我们再来看看派生类中的虚函数指针指向的是什么?它是如何存储的↓↓↓

cpp 复制代码
#include <iostream>
using namespace std;

class Base
{
public:
	virtual void virtualBaseFunc1()
	{}
	virtual void virtualBaseFunc2()
	{}
	void normalBaseFunc()
	{}
	int _base;
};

class Son : public Base
{
public:
	virtual void virtualBaseFunc1()
	{}
	virtual void virtualSonFunc()
	{}
	void normalSonFunc()
	{}
	int _son;
};

int main()
{
	Base b;
	Son s;
	s._base = 1;
	s._son = 2;
	return 0;
}

上面代码通过监视窗口可以看到,对象s中的虚函数表存储的virtualBaseFunc1与对象b中的不同,因为Son类中重写(覆盖)了Base中virtualBaseFunc1虚函数;而s中的虚函数表存储的virtualBaseFunc2与对象b相同,因为Son类中没有对该虚函数进行重写。

通过观察和测试,我们发现了以下几点问题:

  1. 派生类对象s中也有一个虚表指针,s对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。(下图是虚函数表指针指向的地址的内存情况)
  2. 基类b对象和派生类s对象虚表是不一样的,这里我们发现virtualBaseFunc1完成了重写,所以s的虚表中存的是重写的Base::virtualBaseFunc1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. 另外virtualBaseFunc2继承下来后是虚函数,所以放进了虚表,normalBaseFunc也继承下来了,但是它不是虚函数,所以不会放进虚表。
  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
  5. 总结一下派生类的虚表生成:
    a.先将基类中的虚表内容拷贝一份到派生类虚表中
    b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
    c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后
  6. 这里还有一个很容易混淆的问题:虚函数存在哪的?虚表存在哪的? 对象中存储的是虚表指针,虚表并不存储在对象内,而虚函数的地址存储于虚表中,虚函数和普通函数一样的,都是存在代码段的。

下面代码用于验证虚函数表存储于代码段↓↓↓

cpp 复制代码
#include <iostream>
using namespace std;

typedef void(*VFunc_Ptr)();

class Base
{
public:
	virtual void virtualBaseFunc1()
	{}
	virtual void virtualBaseFunc2()
	{}
	void normalBaseFunc()
	{}
	int _base;
};

class Son : public Base
{
public:
	virtual void virtualBaseFunc1()
	{}
	virtual void virtualSonFunc()
	{}
	void normalSonFunc()
	{}
	int _son;
};

void PrintVFuncTable(VFunc_Ptr* p)
{
	for (int i = 0; p[i]; i++)
	{
		cout << p[i] << endl;
	}
}

int main()
{
	Base b;
	Son s;
	s._base = 1;
	s._son = 2;
	cout << main << endl;
	PrintVFuncTable((VFunc_Ptr*)(*(VFunc_Ptr**)(&s)));
	return 0;
}

上面结果打印了main函数的地址及s对象中的各个虚函数的地址。下图是虚函数指针指向的地址0x00007FF7BBE7BD18的内存情况↓↓↓

由上面可以得出虚函数与其他函数一样存储于代码段(地址相近),而虚函数表也存储于代码段(与上面一个函数的地址相近)。

含有虚函数的派生类的内存存储情况如下图所示↓↓↓

多态原理

下面代码中Son继承了Base,并重写了Base中的func1↓↓↓

cpp 复制代码
#include <iostream>
using namespace std;

class Base
{
public:
	virtual void func1()
	{
		cout << "Base-func1()" << endl;
	}
	virtual void func2()
	{
		cout << "Base-func2()" << endl;
	}
};

class Son : public Base
{
public:
	virtual void func1()
	{
		cout << "Son-fun21" << endl;
	}
	void func3()
	{
		cout << "Son-func2" << endl;
	}
};

void test(Base* p)
{
	p->func1();
	p->func2();
}

int main()
{
	Son s;
	s.func3();
	test(&s);
	return 0;
}

对上述程序使用监视窗口调试,获取它的虚函数表指针及各个虚函数的地址↓↓↓

对于func3,它不是使用多态调用的,因此,在编译阶段已经知道它的函数地址了。lea将s对象存入ecx寄存器,后调用直接call调用Son::func3

而对于多态调用的func1和func2。它无法直接像上述func3一样调用。它的调用情况如下图所示↓↓↓

从上图分析可知,满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。

动态绑定与静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

单继承和多继承关系中的虚函数表

单继承中的虚函数表

cpp 复制代码
#include <iostream>
using namespace std;

typedef void(*VFunc_Ptr)();

class Base
{
public:
	virtual void func1()
	{
		cout << "Base-func1()" << endl;
	};
	virtual void func2()
	{
		cout << "Base func2()" << endl;
	}
	int _base;
};

class Son : public Base
{
public:
	virtual void func1()
	{
		cout << "Son func1()" << endl;
	}
	virtual void func3()
	{
		cout << "Son func3()" << endl;
	}
	virtual void func4()
	{
		cout << "Son func4()" << endl;
	}
	int _son;
};

void PrintVFuncTable(VFunc_Ptr* p)
{
	for (int i = 0; p[i]; i++)
	{
		cout << p[i] << endl;
		p[i]();
	}
}

int main()
{
	Son s;
	VFunc_Ptr* p = (VFunc_Ptr*)(*(VFunc_Ptr**)(&s));
	PrintVFuncTable(p);
	return 0;
}

根据上面的代码执行结果,可以总结出如下的内存存储情况图↓↓↓

多继承中的虚函数表

cpp 复制代码
#include <iostream>
using namespace std;

typedef void(*VFunc_Ptr)();

class Base1
{
public:
	virtual void func1()
	{
		cout << "Base1-func1()" << endl;
	};
	virtual void func2()
	{
		cout << "Base1 func2()" << endl;
	}
	int _base1;
};

class Base2
{
public:
	virtual void func3()
	{
		cout << "Base2-func3()" << endl;
	};
	virtual void func4()
	{
		cout << "Base2 func4()" << endl;
	}
	int _base2;
};

class Son : public Base1, public Base2
{
public:
	virtual void func1()
	{
		cout << "Son func1()" << endl;
	}
	virtual void func3()
	{
		cout << "Son func3()" << endl;
	}
	virtual void func5()
	{
		cout << "Son func5()" << endl;
	}
	int _son;
};

void PrintVFuncTable(VFunc_Ptr* p)
{
	for (int i = 0; p[i]; i++)
	{
		cout << p[i] << endl;
		p[i]();
	}
}

int main()
{
	Son s;
	s._base1 = 1;
	s._base2 = 2;
	s._son = 3;
	VFunc_Ptr* p = (VFunc_Ptr*)(*(VFunc_Ptr**)(&s));
	PrintVFuncTable(p);
	cout << "===================================" << endl;
	VFunc_Ptr* p2 = (VFunc_Ptr*)(*((VFunc_Ptr**)(&s) + 2));
	PrintVFuncTable(p2);
	return 0;
}

上述代码通过监视窗口可以看到如下内容。Son对象保存了Base1与Base2的两个虚函数指针。由于VS下的监视窗口存在BUG,无法显示当前对象的虚函数存储哪张虚函数表中,我们可以通过查看程序执行结果来查看。

我们从执行结果查看可以得到,当前对象的自己的虚函数存储第一张虚函数表的最后。

下面给出上述程序中对象s的内存存储情况↓↓↓

因此,我们可以得到多继承的虚函数表存储示意图↓↓↓

🎈欢迎进入浅尝C++专栏,查看更多文章。

如果上述内容有任何问题,欢迎在下方留言区指正b( ̄▽ ̄)d

相关推荐
向宇it几秒前
【从零开始入门unity游戏开发之——unity篇02】unity6基础入门——软件下载安装、Unity Hub配置、安装unity编辑器、许可证管理
开发语言·unity·c#·编辑器·游戏引擎
lxyzcm11 分钟前
C++23新特性解析:[[assume]]属性
java·c++·spring boot·c++23
蜀黍@猿29 分钟前
C/C++基础错题归纳
c++
古希腊掌管学习的神37 分钟前
[LeetCode-Python版]相向双指针——611. 有效三角形的个数
开发语言·python·leetcode
赵钰老师38 分钟前
【R语言遥感技术】“R+遥感”的水环境综合评价方法
开发语言·数据分析·r语言
雨中rain44 分钟前
Linux -- 从抢票逻辑理解线程互斥
linux·运维·c++
就爱学编程1 小时前
重生之我在异世界学编程之C语言小项目:通讯录
c语言·开发语言·数据结构·算法
Oneforlove_twoforjob1 小时前
【Java基础面试题025】什么是Java的Integer缓存池?
java·开发语言·缓存
emoji1111111 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
每天都要学信号1 小时前
Python(第一天)
开发语言·python