前言
作者:小蜗牛向前冲
名言:我可以接受失败,但我不能接受放弃
如果觉的博主的文章还不错的话,还请点赞,收藏,关注👀支持博主。如果发现有问题的地方欢迎❀大家在评论区指正。
本期学习目标:认识什么是多态, 认识抽象类,理解多态的原理,理解多承和多态常见的面试问题。
目录
一、认识多态
1、什么是多态
2、虚函数
3、多态的定义
4、多态中虚函数的二种特殊情况
二、抽象类
1、概念
2、 接口继承和实现继承
三、多态的原理
1、虚函数表
2、多态原理剖析
3、单继承和多继承关系的虚函数表
四、多态的其他知识
1、C++11 override 和 final
2、重载、覆盖(重写)、隐藏(重定义)的对比
3、动态绑定与静态绑定
五、分享继承和多态常见的面试问题
一、认识多态
1、什么是多态
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会
产生出不同的状态。
举个例子来说:某宝常年到大学的开学季就会举办扫描领红包活动,但是我们发现每个领到的金额都不同,而且相对来是非活跃用户红包金额更大,这种我们不同角色,分到吧同金额的红包我们就可以称为多态。
2、虚函数
在认识多态前,我们首先要认识一一下什么是虚函数:
在类中被关键字virtual修饰的函数我们就称为虚函数,就如red_packet函数,在继承中我用关键字virtual解决的菱形继承问题,这里要注意区分二则的用法,一个是在继承这作用于类,一个是在多态中作用于函数。
class Peson
{
virtual void red_packet()
{
cout << "小额红包" << endl;
}
protected:
string _name;
int _age;
};
3、多态的定义
那么在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的
返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
下面我们来看一段代码:
class Person
{
public:
//虚函数
virtual void red_packet()
{
cout << "普通红包" << endl;
}
protected:
string _name;
int _age;
};
class active_people:public Person
{
public:
virtual void red_packet()
{
cout << "小额红包" << endl;
}
};
void fun(Peson& p)
{
p.red_packet();
}
int main()
{
Person p;
active_people ap;
fun(p);
fun(ap);
return 0;
}
这里我们通过父类的引用形成了多态。
特别注意:子类函数可以不加victual,但父类必须加victual才构成虚函数。
4、多态中虚函数的二种特殊情况
我们都知道,要构成多态就用就要满足构成多态的二个条件:其中被调用的函数必须是虚函数,也就是说必须加是virtual。
但是存在二种特殊情况也构成多态:
析构函数(函数名字不同构成的重写)
class Person
{
public:
~Person()
{
cout << " Person delete" << endl;
delete[]_p;
}
protected:
int* _p = new int[10];
};
class active_people:public Person
{
public:
~active_people()
{
cout << " active_people delete" << endl;
delete[]_d;
}
protected:
int* _d = new int[15];
};
int main()
{
Person p;
active_people ap;
return 0;
}
通过这段代码我们可以看出,父子类在调用析构函数的时候,是先调用子类的析构函数,子类的析构函数调用完成后,子类会自动调用父类的析构函数。
但是如果我对代码进行一下更改:
int main()
{
Person* ptr1 = new Person;
Person* ptr2 = new active_people;
delete ptr1;
delete ptr2;
return 0;
}
这里我们发现子类并没有被析构掉。为什么呢?
这就得提一下delete的特性:
1:使用指针调用析构
2:operator delete(ptr)
也就是说这时候是一个普通调用 (是什么类型就调用什么用的析构函数),所以才只会调用父类的析构,但这样的行为不是我们期望的(存在内存泄露),其实,我们期望应该是一个多态调用,指向父类调用父类的析构,指向子类调用子类的析构函数,只要我们在父类的析构函数上加上virtual就可以解决这个问题了。
virtual ~Person()
{
cout << " Person delete" << endl;
delete[]_p;
}
就是为了让父类的析构函数为虚函数:
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。
协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
举例:
class A
{};
class B : public A
{};
class Person
{
public:
virtual A* red_packet()
{
cout << "普通红包" << endl;
return nullptr;
}
};
class active_people :public Person
{
public:
virtual B* red_packet()
{
cout << "小额红包" << endl;
return nullptr;
}
};
void fun(Person& p)
{
p.red_packet();
}
int main()
{
Person p;
active_people ap;
fun(p);
fun(ap);
return 0;
}
这里我们只要简单知道在协变这要求:三同中,返回值可以不同,但是要求返回值必须是一个父子类关系的指针或者引用
二、抽象类
1、概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类)。
抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
那这有什么用呢?
其实就是强制我们重写子类的虚函数
2、 接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实
现。
虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成
多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
三、多态的原理
1、虚函数表
在了解虚函数表前,我们先来看一道笔试题
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;
char _ch;
};
我想不少同学会认为是8,可能认为 成员函数存放在公共的代码段,我们就只要计算成员变量大小就可以了,根据内存对齐就可以得到大小为8个字节。
但真的是这样吗?
我们打印出类的大小:
cout << sizeof(Base) << endl;
为什么是16个字节呢?
int main()
{
Base b;
cout << sizeof(b) << endl;
}
这时我们进行调试打开监视窗口
发现b对象中存放了_vfptr的东西,这又是什么呢?
其实是一个指针, 对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
所以说根据内存对其很容易得到类的大小为16。
那我们在思考一个问题虚函数表到底存放在哪里呢?
下面我们通过一份代码大致推断一下:
int main()
{
int a = 0;
printf("栈:%08lxp \n",& a);
const char* str = "hello world";
printf("代码段 / 常量区:%08lxp \n", str);
static int b = 0;
printf("静态区/数据段: %08lxp\n", &b);
//取类这前4个字节的地址,也就是虚表地址
Base be;
printf("虚表: %08lxp\n", *((int*)&be));
}
通过打印我们发现虚表位于代码段/常量区中。
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;
char _ch;
};
class Derive : public Base
{
public:
//子类重写的虚函数
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
//非虚函数,重定义
void Func3()
{
cout << "Derive::Func3()" << endl;
}
private:
int _d = 2;
};
虚表当中存储的就是虚函数的地址,因为父类当中的Func1和Func2都是虚函数,所以父类对象b的虚表当中存储的就是虚函数Func1和Func2的地址。
而子类虽然继承了父类的虚函数Func1和Func2,但是子类对父类的虚函数Func1进行了重写,因此,子类对象d的虚表当中存储的是父类的虚函数Func2的地址和重写的Func1的地址。这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。
其次需要注意的是:Func2是虚函数,所以继承下来后放进了子类的虚表,而Func3是普通成员函数,继承下来后不会放进子类的虚表。此外,虚函数表本质是一个存虚函数指针的指针数组,一般情况下会在这个数组最后放一个nullptr。
那我们不由的思考到底虚表是那个阶段就开始初始化?
- 其实虚表指针是在构造函数初始化列表的时候填入对象的,虚表是在编译的时候就生成了。
- 虚表里面存放是虚函数地址,同普通函数一样编译完成就放在代码段这
- 一个类这所有的虚函数,都会放在虚表这。
- 子类会将父类的虚表拷贝一份,然后用重写的虚函数地址覆盖到原来虚表中的函数地址因此虚函数的重写,也叫虚函数的覆盖。
2、多态原理剖析
前面我们说了那么多,但是虚表是如何实现多态的呢?
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;
Student Johnson;
Person* p1 = &Mike;
Person* p2 = &Johnson;
Func(p1);
Func(p2);
return 0;
}
- p1指针指向Mike对象,当我们调用Func函数,且将p1传给Func时,p1->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket。
- p2指针指向johnson对象时p2->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket.
- 这样就实现出了不同对象去完成同一行为时,展现出不同的形态
我们都知道达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
用虚函数,但这是为什么呢?下面我们通过反汇编了解一下。
void Func(Person* p)
{
p->BuyTicket();
}
int main()
{
Person mike;
Func(&mike);
mike.BuyTicket();
return 0;
}
// 以下汇编代码中跟你这个问题不相关的都被去掉了
void Func(Person* p)
{
...
p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE mov eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE mov eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来
以后到对象的中取找的。
001940EA call eax
00头1940EC cmp esi,esp
}
int main()
{
...
// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调
用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
mike.BuyTicket();
00195182 lea ecx,[mike]
00195185 call Person::BuyTicket (01914F6h)
...
}
看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的 .
3、单继承和多继承关系的虚函数表
单继承的虚函数表
这里没什么可以过多分析,上面我们都是多单继承虚表的分析,但是我们要注意一个现象。
class Base
{
public:
//父类虚函数
virtual void fun1()
{
cout << "Base::fun1" << endl;
}
virtual void fun2()
{
cout << "Base::fun2" << endl;
}
private:
int a;
};
class Derive :public Base
{
//重写的虚函数
virtual void fun1()
{
cout << "Derive::fun1" << endl;
}
//虚函数
virtual void fun3()
{
cout << "Derive::fun3" << endl;
}
//普通函数
void fun4()
{
cout << "DERIVE::fun4" << endl;
}
private:
int b;
};
int main()
{
Base b;
Derive d;
return 0;
}
这里虽然从监视窗口我们并没有从虚表中看到fun3虚函数的地址,但是我们通过查询虚表指针的地址,因为虚表本质是一个函数指针数组,我们可以发现fun3函数的地址是存在的,也就说明,只要是虚函数就会进虚表,但是vs编译器可能会对非重写的虚函数优化,从而在监视窗口中我们不能发现他。
为了更好的验证,我们对多继承关系的虚函数表的讨论,我们写一个函数来打印虚函数的地址:
//为函数指针数组重新取个名字
// 写法1函数指针数组
//void PrintVFTbale(VFPtr vft[], int n)
//{
// for (int i = 0; i < n; ++i)
// {
// printf("[%d]:%p\n", i, vft[i]);
// }
//}
typedef void(*VFPtr)();
//写法2
void PrintVFTbale(VFPtr vft[])
{
for (int i = 0; vft[i] != nullptr; ++i)
{
printf("[%d]:%p->", i, vft[i]);
vft[i]();
}
cout << endl;
这里我们要注意,要想打印虚表,就要取类这前4个字节(32位平台)/前8个字节(64位平台),
我们上面的取法是通用的,(void*)在32位平台4个字节,64位平台8个字节。而我们*(void**)就自然的找到了void*从而在不同平台上形成自适应。
多继承虚表函数(在vs2013上测试)
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:
//父类重写了func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
// 函数指针数组
typedef void(*VFPtr)();
void PrintVFTbale(VFPtr vft[])
{
for (int i = 0; vft[i] != nullptr; ++i)
{
printf("[%d]:%p->", i, vft[i]);
vft[i]();
}
cout << endl;
}
int main()
{
Base1 b1;
Base2 b2;
PrintVFTbale((VFPtr*)(*(void**)&b1));
PrintVFTbale((VFPtr*)(*(void**)&b2));
Derive d;
PrintVFTbale((VFPtr*)(*(void**)&d));
//打印父类的第二张虚表 写法1
//PrintVFTbale((VFPtr*)(*(void**)((char*)&d+sizeof(Base1))));
//写法2
Base2* ptr2 = &d;
PrintVFTbale((VFPtr*)(*(void**)ptr2));
return 0;
}
- base1h和base2都有一张虚表,都被Derive继承(有二张虚表),但是没有被重写的func3通常是放在第一张虚表中的。
- 我们在找父类的第二张虚表的时候,可以通过字节偏远的方法找到,也可以直接用 Base2* ptr2 = &d;切片的方式自动偏移。
- 这里我们打印虚函数地址结束调条件是最后一个元素为nullptr,但是这种情况在大部分情况下是适应的,要注意的是,虚表的具体实现方式可能因编译器而异,不同的编译器可能会有不同的实现细节。因此,在特定的编译器和环境中,虚表的最后一个元素是否为nullptr可能会有所不同。但根据常见的编译器实现,将最后一个元素设为nullptr是一种常见的做法。
四、多态的其他知识
1、C++11 override 和 final
在学习override和final时,我们先思考一个问题如何实现一个不能被继承的类:
方法1:将构造函数私有(c++98)
class A
{
private:
A()
{}
};
class B : public A
{};
这时候因为对象对无法建立,自然就无法被继承
方法2 类定义的时候加final
这时候我们称类为最终类,类不能被继承。
class A final
{
};
其实final还有一个功能修饰函数,这该函数就不能被重写
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
2、重载、覆盖(重写)、隐藏(重定义)的对比
重载 | 二个函数在同一个作用域 |
函数名相同/参数不同 | |
重定义(隐藏) | 二个函数分别在父类和子类的作用域 |
函数名相同 | |
二个父类和子类的同名函数不构成重写就是重定义 | |
重写(覆盖) | 二个函数分别在父类和子类的作用域 |
函数名/参数/返回值必须相同(协变除外) | |
二个函数必须是虚函数 |
3、动态绑定与静态绑定
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载.
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
五、分享继承和多态常见的面试问题
1. 什么是多态
它指的是同一种操作或接口可以被不同类型的对象以不同的方式实现和处理的能力。具体来说,多态性使得我们可以使用基类(父类)的指针或引用来引用子类(派生类)的对象,并且根据具体的对象类型执行对应的方法或操作。
多态有两种表现形式:静态多态(编译时多态)和动态多态(运行时多态)。
-
静态多态:通过函数重载和运算符重载实现,编译器在编译阶段根据参数的静态类型决定调用哪个函数或操作符。
-
动态多态:通过虚函数和基类指针/引用实现,运行时根据对象的动态类型来确定调用哪个函数。即使使用基类指针或引用,也能够在运行时确定实际调用的是子类的方法。
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
重载:同一作用域内,函数名相同参数不同
重写:子类和父类的虚函数,名称、返回值、参数都相同,称子类重写了父类的虚函数
重定义:子类和父类的函数名相同,称子类隐藏了父类的某个函数。
3. 多态的实现原理?
父类和子类之中保存的虚表指针是不一样的,通过传入指针或者引用(本质也是指针)确定去子类还是父类之中去寻找虚表指针,最后达到调用不同虚函数的目的。
4. inline函数可以是虚函数吗
可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去,如果不构成多态直接调用,则内联展开。在类里面定义的函数默认内联。
5. 静态成员可以是虚函数吗
不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗
不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
可以,并且最好把基类的析构函数定义成虚函数。
通过基类指针或引用来管理派生类对象。如果基类中的析构函数不是虚函数,当通过基类指针或引用删除派生类对象时,可能只会调用到基类的析构函数,而不会调用派生类的析构函数,从而导致派生类可能存在资源泄漏或未被正确清理的问题
8. 对象访问普通函数快还是虚函数更快?
先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?
虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?
菱形虚拟继承因为子类对象当中会有两份父类的成员,因此会导致数据冗余和二义性的问题。
虚继承对于相同的虚基类在对象当中只会存储一份,若要访问虚基类的成员需要通过虚基表获取到偏移量,进而找到对应的虚基类成员,从而解决了数据冗余和二义性的问题。
11.什么是抽象类?抽象类的作用?
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类 。
抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。