C++面向对象:多态!
前言
多态是面向对象三大基本特性其一,多态可以实现“一个接口,多个方法”。
一.多态的基本概念
在使用多态时,不同的对象完成同一件事可能会有不同的结果。
如下例:买地铁票时,普通人全价,学生半价,军人优先购票。
不同的对象对于购票的行为会产生不同的结果,因此我们需要根据不同的对象提供不同的方法。
我们可以用如下的代码模拟:
class Person
{
public:virtual void identity()//身份{cout << "普通人原价" << endl;}
};
class Student : public Person
{
public:virtual void identity()//身份{cout << "学生半价" << endl;}
};
class Soldier : public Person
{virtual void identity()//身份{cout << "军人优先" << endl;}
};
//买票
void BuyTickets(Person& people)
{people.identity();
}
int main()
{Person kuzi;//裤子这个人Student xiaoming;Soldier xiaowang;BuyTickets(kuzi);BuyTickets(xiaoming);BuyTickets(xiaowang);return 0;
}
运行结果如下:
普通人原价
学生半价
军人优先
通过这个结果,我们可以发现,虽然调用了同一个函数,但是不同的对象却有不同的运行结果。
二.多态的定义以及实现
实现多态需要借助虚表(虚表数表),构成虚表需要虚函数(virtual修饰的函数)。除此之外,我们还需要虚表指针定位函数,并调用。
2.1构成多态的条件
构成多态有两个条件:
- virtual修饰后的虚函数与其他类中的三同函数形成重写。(三同:返回值、参数列表、函数名)
- 必须通过父类指针或父类引用进行虚函数的调用。
在上例中,我们之所以能够形成多态,是因为我们都用virtual修饰。
virtual void identity(){cout<<"普通人原价"<<endl};virtual void identity(){cout<<"学生半价"<<endl};virtual void identity(){cout<<"军人优先"<<endl};
并且在使用时通过父类指针/父类引用调用了虚函数。
void BuyTickets(Person& people)
我们必须要将要实现多态的函数用virtual修饰,并使用父类引用调用,两者缺一不可。
eg1:缺少虚函数
eg2:缺少父类引用
可以发现,缺少其中任意一个条件,都无法构成多态。
但,我们还有两个例外需要大家注意
- virtual可以仅在父类中写,子类中的函数可以不写。这是一个例外
- 父子类中的虚函数返回值可以不同,但此时需要返回对应的父类指针或子类指针,确保可以构成多态,这个现象称为“协变”。
eg3:子类无virtual修饰
可以看到,子类中没有virtual,我们依旧可以实现多态。
这个例外在一些场景中是有极其大的用处的。
如: 给父类的析构函数加上virtual修饰时,在进行析构函数的调用时,父类指针可以针对不同对象调用不同的析构函数释放资源。
解释:
无论是谁的析构函数,在编译器进行了操作后,最终的函数名都为:destruction,这样则可能会出现析构错误调用的问题。因此我们给父类的析构函数加上virtual,这样子类在继承时,就可以自动形成多态。
如下这段代码:
class Person
{
public:void identity()//身份{cout << "普通人原价" << endl;}~Person(){ cout << "~person" << endl;}
};
class Student : public Person
{
public:void identity()//身份{cout << "学生半价" << endl;}~Student() { cout << "~student" << endl; }
};
class Soldier : public Person
{
public:void identity()//身份{cout << "军人优先" << endl;}~Soldier(){ cout << "~soldier" << endl; }
};int main()
{Person* p1 = new Person();Person* p2 = new Student();Person* p3 = new Soldier();delete p1;delete p2;delete p3;
在我们没有给父类析构函数加virtual时,我们的输出结果是这样的:
~person
~person
~person
这样的结果显然是我们不想得到的,子类的资源没有释放,会造成内存泄漏的问题。寄!
但,当我们给父类析构函数加上virtual之后,则可以顺利的析构掉所有的对象。
因此,我们给父类的析构函数加上virtual的目的是:构成多态,确保不同的对象的析构函数都能够被正确的调用,从而避免内存泄漏。
虽然,我们的子类不加virtual也可以构成多态,但是会让别人很难看出这个函数运用了多态,因此我们除了析构函数不建议在子类虚函数中省略virtual
eg4:协变
我们可以不确保“三同”的原则,但返回其对应类的指针,这样即能构成多态(协变)。
如下例:
我们发现,使用指针也可以达成多态。
那么,我们应该如何判断是否构成了多态呢?
- 首先观察父类的函数中是否使用了virtual关键字
- 其次观察虚函数是否进行了重写,三同:函数名、参数列表、返回值(协变除外)。
- 最后看调用虚函数是否使用了父类引用或父类指针。
2.2虚函数及重写
那么,什么是虚函数呢?
虚函数的作用是在目标函数之间构成重写(覆盖),一旦构成了重写,那么子类对象在实现此虚函数时,会继承父类中的虚函数接口(返回值、函数名、参数列表),然后覆盖到子类对应的虚函数处,因此重写又叫做覆盖。
这里我们需要注意的一点是,子类中的虚函数其实是用的父类的返回值+函数名+参数列表+子类中的函数体组合而成的。
我们可以用如下这段代码进行验证:
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;
}
根据我们刚刚说的,这段代码调用的test应该是调用了func函数,而func函数为A类中func的函数头和B类中的函数实现部分组合实现的。
也就是说,我们实际运行的是这个函数:
void func(int val=1)//A的函数头
//B的函数实现
{
std::cout<<"B->"<< val <<std::endl;
}
通过上例,我们可以得知,虚函数是虚拟的函数,是可以被覆盖的、形态没有被确定的函数。
我们需要一个标记告诉编译器,这是一个虚函数。
因此,我们使用virtual关键字是告诉编译器:这个函数可以被覆盖重写,同时会将这个函数存入虚表。
几个注意点:
- 普通函数不能使用virtual
- 此处用virtual修饰函数为虚函数,为实现多态的基础。继承中用virtual修饰类继承为虚继承,是解决菱形继承问题的。
2.3final和override
在C++11标准中,新增了两个有关于多态的关键字:final、override。
final:修饰父类的虚函数,不让子类的虚函数与其构成重写,即不可构成多态。
override:修饰子类的虚函数,检查是否构成重写,若不满足则报错。
2.3.1 final
对于final而言,我们有如下方式运用:
给父类的虚函数加上final,表示不可被继承。
2.3.2override
对于override而言,很显然,它会检查是否符合重写的条件。
因此,我们的override是放在子类的虚函数上的。
由于我们的子类的参数列表和基类不同,因此被检查为不符合重写的条件。
这里我们要注意的一点是:
- final可以修饰子类的虚函数,因为子类也可能成为某个类的基类
- override不可修饰基类的虚函数,因为基类往上没有父类了,自然也就无法构成重写了。
2.4重载、重写、重定义
截至现在,我们要分清楚三“重”的区别。
三重:重载、重写、重定义。
这三者不仅名字很像,而且功能也差不多。我们需要多加甄别。
重载:函数重载,函数参数列表不同而触发,不同的函数参数最终的结果不同。
重写(覆盖):发生在类中,当出现虚函数以及三同(函数名、参数列表、返回值)时,发生重写(覆盖),重写该函数的实现,具体表现为:父类函数头+子类实现。
重定义(隐藏):发生在类中,当子类和父类中的函数名起冲突时,隐藏父类的同名函数,从而能够默认调用子类的函数。当然,也可以通过::指定调用父类函数。
我们需要注意的一点是:重定义仅仅只需要函数名相同即就可以触发,而重写则需要满足三同才可以触发。
如下图:
三.抽象类
3.1定义与特点
抽象类即有纯虚函数的类。
那么,什么是纯虚函数呢?
我们只需要在虚函数的函数头之后加一个=0,即可将虚函数变为纯虚函数。
纯虚函数也可以与普通虚函数构成重写,也就是能够实现多态。
但是,包含纯虚函数的类是不可以实例化对象的,这点我们后续会谈到。
因此,只要类中含有纯虚函数,那么这个类就是一个抽象类。
纯虚函数:
virtual void func1() = 0{cout << "我是一个纯虚函数" << endl;}
下面我们尝试使用含有纯虚函数的抽象类实例化对象:
class A
{virtual void func1() = 0{cout << "我是一个纯虚函数" << endl;}
};
int main()
{A* p=new A;
}
可以看到有如下报警:
3.2抽象类的用途
首先,什么是抽象?
我们可以通过百度搜索得到如下:
简单来说,看到事物的本质而舍弃其杂质被称为抽象。
因此,事物=本质+杂质。
但,每个事物身上的杂质不一样,就譬如人、动物、植物。
拿人来举例:
对于一个人而言,本质属性有:性别、高、矮、胖、瘦等。
但,如果我们要写一个函数表示每个职业对一个事件的处理方式呢?
每种职业对事件的处理方式是不一样的,因此我们没有办法通过一个函数写完。
这时,我们就需要通过抽象类来完成了。
因此,抽象类适合用于描述无法拥有实体的类,比如:人、动物、植物,毕竟这些都是不能直接使用的,需要经过继承赋予特殊属性后,才能作为一个独立存在的个体(对象)。
如下代码:
class Person
{
public:Person(const string& name=string()):_name(name){}virtual void func() = 0{};
protected:string _name;
};
class Student :Person
{
public:Student(const string& name=string()):_name(name){}virtual void func(){};
protected:string _name;
};
int main()
{//抽象类无法直接实例化对象//Person p("kuzi");Student d("kuzi");return 0;
}
抽象类的继承很好的体现了函数重写时,继承的是父类虚函数接口的事实,这正是实现多态的基础。
普通继承:子类继承父类中的成员函数,子类中可以直接使用父类中的成员函数。
接口继承:子类继承父类虚函数的接口(函数头),进行重写,构成多态。
ps:
- 不是为了实现多态,最好不要用virtual修饰函数,更不要尝试定义纯虚函数。
- 若父类是抽象类,那么子类必须在继承后重写纯虚函数。否则无法实例化出对象。
四.多态实现的原理
我们先来看一道题:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
提问:在32位环境下,新建一个Base对象占多少字节?
我们通过测试发现,它占用了8个字节。 这是为什么呢?
下面,我们通过调试来观察一下这个对象中都有什么东西。
我们发现,这个对象中除了一个整型的_b外,还有一个void **的指针。
那么,这个指针是什么呢?
答案:这个指针是虚表指针。
4.1虚表与虚表指针
继承就是依靠虚函数表+虚表指针实现的。
虚函数表简称虚表,指向虚表的指针被称为虚表指针。
虚函数表即virtual function table->vft,
虚表指针即virtual function table->vfptr,
在刚刚的图片中,我们可以看到_vfptr,这个就是虚表指针。
我们可以通过虚表指针所指向的地址,找到对应的虚表。
而虚表中存储的是虚函数指针,它指向了虚函数的地址,虚函数一般是存储在代码段中。
我们大家一定要注意一点:虚表中存储的是虚函数的地址,而不是虚函数本身。
下面我们再写一段代码:
在下面这段代码中,Base类中有两个虚函数,分别为func1、func2。func3不是虚函数。
Derive类重写了Base类的func1,又新增了一个虚函数func4.
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}virtual void Func2(){cout << "Func2()" << endl;}void Func3(){cout << "Func3()" << endl;}
private:int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{cout << "我是Derive的Func1()" << endl;
}
virtual void Func4()
{cout << "Func4()" << endl;
}
};
int main()
{Base a;Derive b;return 0;
}
下面,我们来观察一下这段代码的调试。
我们发现,这个是有三层的包裹的。
下面通过画图来帮助大家理解:
那么,我们应该如何验证虚表呢?
- 虚表指针指向虚表,虚表中存储虚函数地址
- 因此,我们可以将虚表指针强转为指向某个虚函数的指针,然后遍历即可
- vs中对虚表做了特殊处理,在虚表的最后加了一个nullptr,因此下面这段代码在别的平台可能跑不了。
typedef void(*VF_T)();
void PrintVFTable(VF_T table[])
{int i = 0;while (table[i]){printf("[%d]: %p->", i, table[i]);VF_T f = table[i];f();i++;}cout << endl;
}
int main()
{Base a;Derive b;PrintVFTable((VF_T*)(*(int*)&a));//32位系统,每次取4byte,因此强转int*PrintVFTable((VF_T*)(*(int*)&b));return 0;
}
可以发现,父类和子类中继承的成员函数的地址是挨着的,也就是说它们在同一张虚表中。
而子类自己的虚函数地址却距离很远,因此它们不在同一张虚表中。
在上面的代码中,具有很强的局限性。捆绑到了32位系统中,如果我们想要在64位系统使用的 话,我们应该强转为长整型。代码如下:
PrintVFTable((VF_T*)(*(long long*)&a));
当然,我们也可以通过传递二级指针来解决这个问题。如下:
PrintVFTable(*(VF_T**)&a);
但是,这里也需要我们注意一点,我们是不可以在参数列表中传入(VF_T*)&a的。
PrintVFTable((VF_T*)&a);
为什么呢?
这是因为,这么写,我们取出的是整个虚表区域的首地址,而不是我们所需要的虚表的首地址。
如下:
我们可以看到,整个虚表区域的地址和虚函数不是紧挨着的,因此我们打印的时候会出错。
经过刚刚一系列的叙述,我们已经可以确定虚表是存在的了,下面我们再谈几个注意点:
- 虚表在编译阶段被生成。
- 虚表指针是在new一个对象中初始化列表阶段初始化的。
- 虚表一般存储在常量区(代码段),也有的编译器会将其放在静态区。
我们可以通过代码来验证一下虚表的位置。
如下:
int main()
{Base a;Derive b;int c = 10;//栈int* d = new int;//堆static int e=3;//静态区const char* f = "kuzi";//常量区printf("c->栈区:%p\n", &c);printf("d->堆区:%p\n", &d);printf("e->静态区:%p\n", &e);printf("f->常量区:%p\n", &f);printf("a->虚表a:%p\n", *(VF_T**)&a);printf("a->虚表b:%p\n", *(VF_T**)&b);return 0;
}
运行结果如下:
可以看到,在vs2022中,虚表的地址与静态区的地址是十分接近的,因此我们可以猜测虚表在静态区中。但,在大多数编译器中都是被放在常量区的,因为它需要被同一类的不同对象共享,同时不能被修改。
4.2虚函数调用的过程
现在我们来介绍一下虚函数调用的过程。
- 首先确保虚函数存在并构成重写
- 其次,必须要使用父类引用或父类指针指向对象
- 调用时,会发生切片,将子类中不属于父类的部分切掉,只保留父类指针可以调用的部分函数。
- 由于发生了切片,所以同一个地址处可以调用到不同的函数,这就是多态。
4.3动态绑定和静态绑定
下面,我们通过一段汇编代码来进行观察:
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;
}
观察上图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚
函数是Person::BuyTicket。
观察上图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
找到虚函数是Student::BuyTicket。
那么,我们现在转到汇编层去看一看。
p->BuyTicket();// p中存的是mike对象的指针,将p移动到eax中001940DE mov eax, dword ptr[p]// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx001940E1 mov edx, dword ptr[eax]// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax00B823EE mov eax, dword ptr[edx]// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来//以后到对象的中取找的。001940EA call eax001940EC cmp esi, esp
那么,如果我们不使用多态呢?
又会出现什么样的现象呢?
int main()
{// 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调//用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址mike.BuyTicket();00195182 lea ecx, [mike]00195185 call Person::BuyTicket(01914F6h)
}
可以看到,这里我们是直接call到了一个地址。
下面,我们就可以介绍一下动态绑定和静态绑定了。
静态绑定:
在编译时就确定程序的行为,被称为静态绑定。
动态绑定:
在程序运行期间调用具体的函数,被称为动态绑定。
具体表现在上图中,表现为:
1.动态绑定,我们可以发现我们是从eax寄存器中找的地址。
2.静态绑定,我们发现是直接找到的地址。
五.单继承和多继承中的虚表
5.1单继承中的虚表
单继承中的虚表比较简单,就是子类中的虚函数对父类中的虚函数进行覆盖。
在单继承中,不会出现虚函数冗余的情况。
下面,我们还是来观察一下这段代码发生的行为:
int main()
{Base a;Derive b;PrintVFTable((VF_T*)(*(int*)&a));//32位系统,每次取4byte,因此强转int*PrintVFTable((VF_T*)(*(int*)&b));return 0;
}
可以看到,我们调用的第二个虚函数是调用的父类的虚函数。而子类新增的虚函数却并没有新增到父类的虚表中。
因此,我们可以得到如下结论:
向父类中增加新的虚函数,父类的虚表会多一个函数地址,同时子类继承时也会继承这个新的虚函数,并纳入到自己的虚表中。
向子类中增加虚函数,并不会增加到父类的虚表中,只会被纳入到子类的虚表中。
5.2多继承中的虚表
C++中支持多继承,那么也就会出现一个子类继承多个父类的情况。那么,如果遇到下面的情况,又会如何处理呢?
class Base1
{
public:virtual void fun1() { cout << "Base1::func1()" << endl; }virtual void fun2() { cout << "Base1::func2()" << endl; }
};
class Base2
{
public:virtual void fun1() { cout << "Base2::func1()" << endl; }virtual void fun2() { cout << "Base2::func2()" << endl; }
};
class Derive : public Base1, public Base2
{virtual void fun1() { cout << "Derive::func1()" << endl; }virtual void fun3() { cout << "Derive::func3()" << endl; }};
int main()
{Derive d;return 0;
}
Base1和Base2中都有fun1()和fun2()函数,而Derive继承了这两个类,那么会出现什么的行为呢?
我们来看一看。
可以看到,Derive中有两张虚表。 分别是Base1+Derive::fun1()和Base2+Derive::fun2()。
此时,我们会出现的问题如下:
- 子类Derive中新增的虚函数fun3位于哪张虚表?
- 重写的fun1函数,如何调用?
这两个问题是多继承多态中的主要问题。
在单继承中,子类中新增的虚函数会放到子类的虚表中,但是这里是多继承,子类有两张虚表,我们新增的虚函数会放到哪张表中呢?
下面,我们打印一下。看一下虚表的地址。
第一张表是很简单的,我们直接取地址然后类型转换一下即可。
但是第二张表就比较麻烦了,我们需要跳过第一张虚表,才能取到第二张虚表的起始地址。
现在我们来看一看如何取地址:
大家要注意的一点是,地址也有属性,我们这里要强转为char*的地址,要不然我们取出的是整个d的地址。此时每次+1,是加一个Derive*的字节。
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;
}
观察下图发现:多继承子类的未重写的虚函数放在第一个继承基类部分的虚函数表中。
附:
其实,我们也可以通过切片行为找到目标的地址,如下。
Base2* table2 =&a;//通过切片,直接找到第二张虚表的地址。PrintVFTable(*(VF_T**)&table2);
因此,我们现在解决了第一个问题:子类新增的虚函数会添加在第一张表中。
下面,我们就需要考虑第二个问题了。
在上面的代码中,我们有两个func1函数,在监视窗口中,我们发现这两个func1函数的地址不同。那么,我们调用时要怎么调用呢?
其实,实际的调用过程是:编译器在调用时,根据不同的地址找到同一个函数,就可以解决冗余虚函数的调用问题。
5.3菱形继承多态和菱形虚拟继承多态
对于菱形继承多态和菱形虚拟继承多态,我们采取了虚继承+虚基表的相关概念。
菱形继承多态的函数调用链路极其复杂,我们后续再了解。
而菱形虚拟继承多态,则需要同时考虑两张表:虚表、虚基表
虚基表中会空处一行,空出的那一行是存储偏移量的,表示当前虚基表距离虚表多远。
后续的文章,我们再详细谈多谈调用的链路问题以及菱形继承多态和菱形虚拟继承多态。
点击链接进入:多态的内存结构