C++中多态(二)
@[toc]
一、多态的虚函数相关概念
- 1.虚函数表(和继承的虚基表要注意区分)
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
答案是8bytes
- 通过观察测试我们发现
b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面
(注意有些平台可能会放到对象的最后面,这个跟平台有关) - 对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
一个含有虚函数的类中都至少都有一个虚函数表指针
,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表即如果某类中包含虚函数,那么编译器会给该类的对象多增加4个字节,其内容是在该对象的构造函数中完成填充的,如果类中没有显示定义构造函数,那么编译器会默认生成一个构造函数,完成给该类对象前4个字节的内容填充
。- 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;
}
- a.同一个类的不同对象共用同一张虚表
- b.
派生类有自己的虚表,不再和父类公用同一张虚表,如果派生类没有重写父类的虚函数,那么此时派生类虚表的内容和基类虚表的内容是一致的,即派生类的虚表是将父类的虚表进行了一份拷贝,放到派生类的虚表中
。 - c.如果派生类重写了父类的虚函数,就用派生类自己的虚函数地址覆盖虚表中相同偏移量位置处的基类虚函数地址。
- d.
派生类虚表的构造过程是按照虚函数在类中的声明次序一次增加到虚表中
的。 - e.派生类的虚表是由父类的虚表内容继承下来和派生类自己虚函数成员组成。
- f.
虚表的本质是存放虚函数地址的指针数组,数组的最后放nullptr。
- g.注意⚠️:虚表存的是虚函数指针,不是存放的虚函数,虚函数和普通函数一样,存放在代码段,只是它的地址存放到虚函数表当中了而已,并且,类对象中存的是指向虚表的指针。
二、多态的原理
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;
}
通过上面的代码,很容易发现:
- 当传入Person类型的Mike时,p->BuyTicket在mike的虚表中找到虚函数是Person::BuyTicket()
- 当传入Student类型的Johnson时,p->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)
...
}
那么再看下面的代码:
#include <iostream>
using namespace std;
class Father
{
public:
void fun()
{
cout << "I am father!" << endl;
}
};
class Son:public Father
{
public:
void fun()
{
cout << "I am son!" << endl;
}
};
int main()
{
Son son;
Father *Pfather= &son;
Pfather->fun();
system("pause");
return 0;
}
- 它不是真正的多态,不满足多态的条件,打印结果为I am father!。
- 我相信很多人可能会误将它和C++的多态搞混,认为Son的对象son应该调用Son的成员函数,但事实却不是如此,这是为什么呢?
从编译器的角度看:
- C++编译器在编译时,会确定每个对象调用函数(非虚函数)的地址,这叫做早期绑定(也叫做静态绑定)。
- 当我们定义了派生类的对象,并取它的地址赋值给基类的指针,这时编译器会自动为派生类对象进行类型转换,将派生类对象转换为基类对象,站在内存的角度来看,访问的就是基类的成员。
这是因为派生类的对象的对象模型如下:
- 基类的成员属于派生类成员的一部分,那么父类和子类的成员变量如何初始化呢?
- 我们定义了Son的对象,编译器会自动调用Son的构造函数。
在执行派生类的构造函数体之前,编译器会先调用父类的构造函数,先为父类的成员变量初始化,再为派生类的对象初始化,最后执行派生类构造函数的函数体
。- 当我们将Son 类对象转化为父类Father 类型时,该对象就被认为是派生类对象模型的上半部分,将该对象当成父类对象执行相应的代码,自然就调用父类的函数了。
真正多态的例子:
#include <iostream>
using namespace std;
class Father
{
public:
virtual void fun()
{
cout << "I am father!" << endl;
}
};
class Son:public Father
{
public:
void fun()
{
cout << "I am son!" << endl;
}
};
int main()
{
Son son;
Father *Pfather= &son;
Father& father = son;
Pfather->fun();
father.fun();
Father fath;
Father* Pfath = &fath;
Pfath->fun();
system("pause");
return 0;
}
在这个实现机制下,发生了什么?
- 当我们将函数声明为 virtual 时,
编译器不会在编译时就确定对象要调用的函数的地址,而是在运行时再去确定要调用的函数的地址,这就是晚绑定,也叫做动态绑定
。
- 第一:编译器在发现Father 类中有虚函数时,
会自动为每个含有虚函数的类生成一份虚函数表,也叫做虚表
,该表是一个一维数组,虚表里保存了虚函数的入口地址。 - 第二:
编译器会在每个对象的前四个字节中保存一个虚表指针,即(vptr),指向对象所属类的虚表。在程序运行时的合适时机,根据对象的类型去初始化vpt
r,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数。 - 第三:
所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化
。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
。
最后再举一例说明多态原理:
class Base {
public :
virtual void func1() { cout<<"Base::func1" <<endl;}
virtual void func2() {cout<<"Base::func2" <<endl;}
private :
int a;
};
class Derive :public Base {
public :
virtual void func1() {cout<<"Derive::func1" <<endl;}
virtual void func3() {cout<<"Derive::func3" <<endl;}
virtual void func4() {cout<<"Derive::func4" <<endl;}
private :
int b;
};
typedef void(*VFPTR) ();
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()
{
Base b;
Derive d;
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是
//一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
// 1.先取b的地址,强转成一个int*的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,
//虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点
//目录栏的-生成-清理解决方案,再编译就好了。
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
PrintVTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
PrintVTable(vTabled);
return 0;
}
三、多继承中的虚表
- 1.多继承的虚表
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:
virtual void func1() {cout << "Derive::func1" << endl;}
virtual void func3() {cout << "Derive::func3" << endl;}
private:
int d1;
};
typedef void(*VFPTR) ();
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;
}
观察下图发现:多继承派生类中自己的不是重写基类的虚函数都放在第一个继承基类部分的虚函数表中
- 2.菱形继承和菱形虚拟继承的虚表
总结:
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
- c++的多态性用一句话概括就是:在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数。
四、常见问题
- ==1.inline函数可以是虚函数吗?==
答案是inline函数不能是虚函数
;原因是内联函数会在编译阶段把函数内容给展开,不需要通过虚函数表进行调用。并且在运行期间内联函数就没有函数地址,也就无法把函数地址放到虚函数表中。
- ==2.静态成员函数可以是虚函数吗?==
答案是静态成员函数不可以是虚函数
;静态成员函数不属于某个具体的对象,没有this指针,静态成员函数的调用方式可以用类名::静态成员函数进行调用,从而也就导致无法访问虚函数表,也就无法把静态成员函数放到虚函数表中。
- ==3.构造函数可以是虚函数吗?==
答案是构造函数不可以是虚函数
;因为如果类中有虚函数,那么编译器就会默认给4个字节用来存放虚函数指针,其内容是在构造函数初始化阶段完成填充
的,如果把构造函数设为虚函数,就须要通过 虚函数指针来完成调用构造函数,但是对象还没有实例化,也就是内存空间还没有,怎么找虚函数指针呢?
所以构造函数不能是虚函数。(当然构造函数也不能被const修饰)
- ==4.析构函数可以是虚函数吗?==
答案是析构函数可以是虚函数
;在多态的时候,比如基类的指针指向派生类的对象,如果删除该指针delete []p,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。如果析构函数不被声明成虚函数,会造成派生类对象析构不完全。所以析构函数声明为虚函数的十分必要的。
(如果派生类中涉及资源管理问题,最好把基类析构设成虚函数)
- ==5.对象访问普通函数快还是访问虚函数快?==
答案:首先如果是普通对象,是一样快
的。如果是指针对象或者是引用对象,则调用的普通函数快
,因为构成多态,运行时调用虚函数需要到虚函数表中去查找
评论区