您现在的位置是:首页 >技术交流 >[C++]:万字超详细讲解多态以及多态的实现原理(面试的必考的c++考点)网站首页技术交流
[C++]:万字超详细讲解多态以及多态的实现原理(面试的必考的c++考点)
前言
多态的概念:
一、多态的定义及实现
1.多态的构成条件
多态是在不同继承关系的类对象,去调用同一个函数,产生了不同的行为。
在继承中构成多态有两个必要条件:
1.必须通过基类的指针或者引用调用虚函数
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。重写的三同(函数名,参数,返回值)
下面我们用代码演示一下:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
//1.不满足多态---看调用者的类型,调用这个类型的成员函数
//2.满足多态 -- 看指向的对象的类型,调用这个类型的成员函数
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
以上是我们所举的一个买票的例子,基类中的函数与子类中的函数名相同前面加了virtual关键字修饰并且参数也相同都是无参的,但是实现不同,而我们的func函数是用基类的对象的引用去调用函数,满足了形成多态的条件,当子类对象调用这个函数时我们发现打印的是子类的函数实现,父类对象打印的是父类的函数实现,如下图:
我们前面学了继承都知道如果用基类对象调用函数只能调用基类的函数,现在基类对象不仅能调用自己的还能调用子类的,下面我们看看如果不是基类的指针或引用是什么现象:
我们可以看到当不是基类的指针或引用的时候调用同名函数只会调用自己的,下面我们在看看如果不写virtual是什么情况。
首先是基类函数不加virtual:(在修改前一定要恢复为基类的指针或引用)
我们可以看到基类中无virtual修饰是形成不了多态的
派生类函数不加virtual:
我们发现派生类加不加virtual关键字不重要,只要基类同名函数加了virtual就可以形成多态。
我们刚刚所演示的情况用其他方式也能实现,下面我们用一个更详细的例子来展现多态的重要性:
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person {
public:
void BuyTicket() { cout << "买票-半价" << endl; }
~Student()
{
cout << "~Student()" << endl;
}
};
void Func(Person* p)
{
p->BuyTicket();
delete p;
}
int main()
{
Func(new Person);
Func(new Student);
return 0;
}
以上代码我们加了析构函数,在调用func函数的时候开了基类空间和派生类空间,这个时候析构函数并没有重写加virtual关键字,所以一定是父类指针调用父类的析构函数,如下图:
这个结果是我们想要的吗?肯定不是!因为子类很有可能有自己的东西,如果我们用父类的析构析构子类对象必定会造成内存泄漏,这里我们想的是父类指针中存放父类对象就调用父类的析构函数,父类指针中存放子类对象就调用子类的析构函数,这个时候我们给基类中的析构函数加上virtual关键字:
这个时候我们发现正确的释放了子类的资源,为什么会析构完子类再析构父类这个问题我们在继承中已经提到过,因为派生类的构造函数结束的时候自动调用父类的构造函数实现先析构子再析构父。这里不知道会不会有人问为什么析构函数名字不同却实现了多态呢?因为我们之前说过,编译器对于任何一个析构函数的处理都是处理为destructor,可以理解为每个类中的析构函数都是同名函数。
上面我们说了虚函数的重写有三同,函数名相同,返回值相同,参数相同。但是有两个例外,第一个例外是协变(基类与派生类虚函数返回值类型不同):协变中的返回值不同必须是父子关系的指针或者引用,不可以是其他的:
class A {};
class B : public A {};
class Person {
public:
virtual A* f()
{
cout << "Person" << endl;
return new A;
}
};
class Student : public Person {
public:
virtual B* f()
{
cout << "Student" << endl;
return new B;
}
};
void Func(Person* p)
{
p->f();
delete p;
}
int main()
{
Func(new Person);
Func(new Student);
return 0;
}
上面代码中我们可以看到派生类对基类中的f()函数进行了重写,但是他们的返回值类型一个是A*一个是B*,我们看看结果:
没错 这里即使返回值不同也是多态,那么我们看看如果返回值不是父子关系的指针或引用是什么结果:
我们可以看到直接编译报错了,并且报错提示不是协变。
2.c++11的override 和 final
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; }
};
在这里我们只需要记住final关键字必须写到函数的参数列表后面。
class Car {
public:
virtual void Drive() {}
};
class Benz :public Car {
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
下面我们不重写再看看结果:
3.重载,覆盖(重写),隐藏(重定义)的对比
重载:重载必须是在同一个作用域,也就是说在一个类中可以构成重载,继承体系中不可以构成重载。重载的条件是函数名相同
覆盖(重写):重写就是我们所说的虚函数,重写是出现在继承体系中的派生类中的,只有子类对父类的函数进行重写。条件:函数名,参数,返回值必须相同(除了协变和构造函数) virtual关键字在基类中的同名函数中必须出现 必须用父类的指针或引用
重定义(隐藏):两个函数必须分别在基类和派生类的作用域
条件:函数名相同 在继承体系中两个同名函数不构成诚谢就构成重定义 隐藏在子类中是默认隐藏父类的方法使用子类的方法。
4.抽象类
class Car {
public:
virtual void Drive() = 0
{
cout << "Car" << endl;
}
};
class Benz :public Car {
public:
virtual void Drive() {}
};
int main()
{
Car s;
return 0;
}
我们可以看到将car类的Drive函数声明为纯虚函数,这个时候Car类是无法实例化对象的:
一个类中有纯虚函数这个类被称为抽象类,抽象类也会有子类去继承,但是如果子类没有重写纯虚函数的话这个子类也是抽象类,如果重写了纯虚函数那么这个子类就可以正常使用了:
红括号括起来的就是子类重写了父类的纯虚函数,下面我们看看不重写的例子:
确实如上面所说,子类不重写父类的纯虚函数那么子类也是抽象类。
5.多态的原理
再讲原理之前有一道常考的题,代码如下:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
上面这个类的大小是多少呢?我们先给出结果:
结果为什么是8呢?因为Base类中有虚函数,而编译器看到虚函数会给Base类生成一个虚表指针,我们调试看一下:
接下来我们看看派生类当中这个表都放了什么:
首先将刚刚的代码改造一下:
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;
}
我们发现派生类当中也有一个虚表指针,下面我们再用多态的代码来看一下多态的实现原理:
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 ps;
Func(ps);
Student st;
Func(st);
return 0;
}
还是之前买票的例子我们调试看一下汇编:
上面多态形成的汇编指令,我们再看看不是多态的:
我们可以看到是多态时候的汇编指令和不是多态时候的汇编指令相差很多,不是多态的汇编就简单的两行,而多态有很多指令。
上图中方框圈出来的就是父类中虚函数的地址和子类中虚函数的地址。
通过上面代码的演示,可以看出满足多态以后的系统调用,不是在编译时确定的,是运行起来以后到对象中找的,不满足多态的函数调用是编译时确定好的。
下面我们再看看同一个类不同对象是否共用同一张虚表:
我们可以看到同一个类不同对象用的确实是同一张虚表。
通过以上验证我们得到结论:
1.派生类会拷贝基类的虚表
2.同一个类的不同对象用的同一张虚表
接下来我们用程序打印虚表:
下面我们用这个代码进行演示:
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;
};
int main()
{
Base b;
Derive d;
return 0;
}
这里由于编译器的监视窗口故意隐藏了func3和func4两个函数,我们看不到虚表中这两个虚函数的地址,所以我们将地址打印出来。
打印虚表前先typedef一个函数指针:
typedef void(*VFPTR)();
这里我们打印地址的思路是:以前我们测试大小端的时候将int的1类型强转为char类型拿到int的第一个字节,然后判断这个字节是否是1如果是就是小端,现在也一样。
void PrintVFTable(VFPTR table[])
{
cout << " 虚表地址>" << table << endl;
for (int i = 0; table[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :0X%x,->", i, table[i]);
VFPTR f = table[i];
f();
}
cout << endl;
}
上面这个函数就是打印虚表的,为什么判断条件是table[i]!=nullptr呢?因为在VS系列的编译器中会在函数指针数组的最后放上一个nullptr。
那么我们该如何传参呢?根据我们刚刚的思路,因为一个对象的起始4个字节或8个字节是其存放虚表指针的位置,所以在32位下将对象的地址强转为int*也就是4字节就拿到虚表指针(在32位下指针4字节,在64位下指针是8字节)然后解引用就是一个int类型,由于int类型传参传不过去,实际类型是函数指针类型,所以我们在强制转化为函数指针类型。如下图所示:
int main()
{
Base b;
Derive d;
PrintVFTable((VFPTR*)(*(int*)&b));
PrintVFTable((VFPTR*)(*(int*)&d));
return 0;
}
当然既然函数指针数组的首元素类型为VFPTR**,所以我们可以直接强转为VFPTR**再解引用
通过以上对虚函数地址的打印我相信大家能更深刻的了解多态。
下面有几个面试容易考的知识点:
1.虚表是在什么阶段生成的?
答:虚表是在编译阶段生成的。
2.对象中虚表指针是在什么时候初始化的?
答:虚表指针是在基类的构造函数的初始化列表中初始化的。
3.虚表是存在哪里的?要回答这个问题我们先验证一下:
int main()
{
Base b;
Derive d;
int x = 0;
static int y = 0;
int* z = new int;
const char* p = "xxxxxxxxxxx";
printf("栈对象:%p
", &x);
printf("静态对象:%p
", &y);
printf("堆对象:%p
", z);
printf("常量区对象:%p
", p);
printf("b对象虚表:%p
", *((int*)&b));
printf("d对象虚表:%p
", *((int*)&d));
return 0;
}
从上图中我们可以看到b对象虚表和d对象虚表与常量区对象的地址挨的非常近,所以虚表是存在常量区(代码段)当中,而从之前查看地址的时候我们也能看到虚表的地址与函数的地址很接近,而函数的地址就存在代码段中。
6.多继承中的虚函数表
首先我们先写一个多继承的代码:
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;
};
Derive继承base1和base2,并且只重写了func1,还多加了一个虚函数func3,这个时候我们有个问题,新增加的func3要放在哪里呢?首先我们要理解,derive继承了base1和base2所以应该有两种虚表,如下图:
我们发现在这两种虚表中好像并没有看见func3,这是因为编译器将func3隐藏了,我们需要打印虚表来找func3到底存在哪里了。
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;
}
打印第一张虚表的时候还好说直接在对象的最前面4个字节,那么第二张虚表到底在哪呢?因为我们先继承的base1,再继承的base2所以base1过来就是base2的虚表,所以我们直接跳一个base1的虚表就能找到base2,因为我们要从第一个位置开始偏移所以不能像之前那样强转为int*,要强转为char*指针的偏移量才是一个字节,否则如果偏移量是int*的4个字节就走多了就不是base2虚表的起始地址了。如下图:
运行程序后打印以下的结果:
我们发现新增加的func3虚函数放在第一个虚表里面了,但是有一个问题为什么虚表中重写的func1的地址不一样呢?
我们将主函数修改一下去调试验证为什么不一样:
int main()
{
Derive d;
Base1* ptr1 = &d;
Base2* ptr2 = &d;
ptr1->func1();
ptr2->func1();
return 0;
}
首先这里满足多态,父类的指针或引用调用func1函数,func1函数是虚函数,下面我们调试看一下地址为什么不一样。
这是func1调用的汇编代码, 从上图中我们可以看到第一个函数的调用是正常的,找到func1的地址然后进去,下面我们再看看ptr2访问func1函数的结果:
以上是ptr2调用func函数的整体结果,接下来我们把ptr1调用和ptr2调用func1函数做一个对比:
我们发现ptr2调用func函数的时候对地址进行了封装,这就好像我们我们要从宁夏到北京,可以坐飞机直接到北京,但是我们先经过河南再从河南转到河北,再从河北到北京,来来回回绕了很多路,为什么要这样呢?我们在刚刚走调用过程的时候看到这样一条指令:sub ecx,ecx存的是谁的地址呢?其实ecx存的是this指针,我们再配合下面这张图理解一下:
因为这里是多态ptr1和ptr2都要调用子类的函数,而ptr1本来就指向这个Derive对象的开始所以调用子类的函数就恰好指向子类对象的开始所以ecx里面存的是this指针就是子类对象的开始,可以直接调用,而到ptr2的时候ecx就是ptr2的值,这个时候this指针指向对象中间的位置根本无法直接调用子类对象的函数,所以刚刚ptr2在跳的时候就是找到子类对象的开始。而这里谁指向子类对象的开始是谁先继承决定的,base1先继承所以base1的指针刚好指针子类对象的开始。
由于菱形继承,菱形虚拟继承太复杂容易出问题,所以菱形继承和菱形虚拟继承的虚表我们就不看了,我们只需要知道:虚表存储虚函数地址 虚基表存储偏移量