【C++】从零开始认识多态(二)

简介: 面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。

4 多态的底层实现

4.1 底层实现

首先我们来看一下具有多态属性的类的大小:


#include<iostream>
using namespace std;

class Base
{
public:
  virtual void Func1()
  {
    cout << "Func1()" << endl;
  }
private:
  int _b = 1;
  char _ch = 'x';
};

int main(int argc, char* argv[])
{
  cout << sizeof(Base) << endl;
  return 0;
}

Base的大小在x86环境下是12字节。这十二字节是怎么组成的呢?

首先类里面有一个虚函数表指针_vfptr

只要有虚函数就会有虚表指针,这个是实现多态的关键!!!

我们来探索一下:

通过VS的调试,我们可以发现:

那么如何实现传基类调用基类的虚函数,传派生类调用派生类的虚函数?

当然是使用切片了!

1. 首先每个实例化的类(如果有虚函数)会有一个虚函数表。

2. 传基类调用基类的虚函数,就正常在基类虚表中寻找其对应函数

3. 传派生类,因为多态函数时基类的指针,那么就会切片出来一个基类(虚函数表是派生类的),那么就会在派生类虚表调用对应虚函数。

这样就实现了执行谁就调用谁!!!

运行过程中去虚表中找对应的虚函数调用。具体的汇编语言实现还是比较直白的。

注意同类型的虚表是一样的!!!

  • 满足多态,那么运行时汇编指令会去指向对象的虚表中找对应虚函数进行调用!!!
  • 不满足多态,编译链接时直接根据对象类型,确定调用的函数,确定地址!!

这里需要分辨一下两个概念:虚表与虚基表

  • 虚表:虚函数表,存的是虚函数,用来形成多态!!!
  • 虚基表:存的是距离虚基类的位置的偏移量,用来解决菱形继承的数据冗余和二义性!!!

注意:虚函数不是存在虚表中的 , 虚表中存的是虚函数的指针。那虚函数存在哪里呢?

来验证一下:

class Person
{
public:
  virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person
{
public:
  virtual ~Student() { cout << "~Student()" << endl; }
};


int main()
{
  int i = 0;
  static int j = 1;
  int* p1 = new int;
  const char* p2 = "xxxxxxxx";
  printf("栈:%p\n", &i);
  printf("静态区:%p\n", &j);
  printf("堆:%p\n", p1);
  printf("常量区:%p\n", p2);

  Person p;
  Student s;
  Person* p3 = &p;
  Student* p4 = &s;

  printf("Person虚表地址:%p\n", *(int*)p3);
  printf("Student虚表地址:%p\n", *(int*)p4);

  return 0;
}

运行可以看到:

虚表地址与常量区最接近,那可以推断出虚表储存在常量区!!!

4.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;
};

梳理一下结构:

  1. Base作为基类 , Derive作为派生类!
  2. 派生类Derive重写了func1函数,构成多态
  3. 其余函数均不构成多态。

然后我们来探索一下:

int main()
{
  Base b;
  Derive d;
  return 0;
}

通过监视窗口可以查看一下虚表的内容:

这是VS调试的一点BUG,导致监视中派生类的虚表不能显示。在内存窗口里存在4个函数指针,接下来我们来验证一下他们是不是对应的虚函数。

虚函数表本质是一个函数指针数组!

那么如何定义一个函数指针和函数指针数组呢?

//这样定义 
//返回值是void 所以写void
void(*p)( //函数里面的参数 );
void(*p[10])( //函数里面的参数 )

当然可以使用typedef来简化(这个typedef也很特别)

typedef void(*VFPTR)();
VFPTR p1;
VFPTR p2[10];

那么如果我们想要打印出虚表,我们可以设置一个函数:

//因为是函数指针数组,所以传参是函数指针的指针(int arr[10] 传入 int*)。
void PrintVFT(VFPTR* vft )
{
  for(size_t i = 0 ; i < 4 ; i++)
  {
    printf("%p\n" , vft[i]);
  }
  
}

这样就可以打印了,那么现在就需要解决如何获取虚表的首地址。虚表首地址是类的头4个字节(x86环境),我们如何取出来了呢?

直接把类强转为int类型不就4个字节了吗!?但是没有联系的类型是不能强转的。那怎么办呢???

C/C++中指针可以直接互相强转(BUG级别的操作!!!),整型与指针也可以互相转换。

VFPTR* p =  (VFPTR*) *( (int*)&d );//这样就变成4个字节了!
  1. &d 是取类的指针
  2. (int*)&d将类指针强转为int*指针!
  3. *( (int*)&d )int * 解引用为int
  4. (VFPTR*) *( (int*)&d )int转换为VFPTR*,取到虚表首地址!!!

那么我们来验证一下:

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 PrintVFT(VFPTR* vft)
{
  for (size_t i = 0; i < 4; i++)
  {
    printf("%p  ->", vft[i]);
    (*(vft[i]))();
  }

}

int main()
{
  Base b;
  Derive d;
  VFPTR* p = (VFPTR*)*((int*)&d);//这样就变成4个字节了!
  PrintVFT(p);
  return 0;
}

来看:

这样就成功获取到了虚标的内容,验证了虚表的内容中存在4个虚函数!!!

5 抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数包含纯虚函数的类叫做抽象类(也叫接口类)

抽象类不能实例化出对象。派生类继承后也不能实例化出对象。只有重写纯虚函数,派生类才能实例化出对象

纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承

//抽象类
class Car
{
public: 
  //纯虚函数
  virtual void Drive() = 0;
};

int main()
{
  Car c;
  return 0;
}

这样一个抽象类是不可以实例化的,进行实例化就会报错:

如果派生类进行了重新那么就可以正常使用:

class Car
{
public: 
  virtual void Drive() = 0;
};

class Benz :public Car
{
public:
  virtual void Drive()
  {
    cout << "Benz-舒适" << endl;
  }
};
class BMW :public Car
{
public:
  virtual void Drive()
  {
    cout << "BMW-操控" << endl;
  }
};
void Test()
{
  //各种调用自身的虚函数
  Car* pBenz = new Benz;
  pBenz->Drive();
  Car* pBMW = new BMW;
  pBMW->Drive();
}
int main()
{
  Test();
  return 0;
}

抽象类与override关键字的区别:

  1. 抽象类间接强制了派生类必须进行虚函数重写
  2. override是在已经重写的情况下,帮助进行重写的语法检查

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;
};

分析一下继承关系:

  1. 有两个基类:Base1类与Base2类
  2. Derive继承两个基类,对func1函数进行了重写构成多态

来看看Derive类的大小是多大:

我们分析一下:Base1类应该有一个虚表指针和一个int类型数据,所以应该为8字节。Base2同理8字节。

那么Derive由于多继承的缘故会包含两个基类,所以应该为16 + 4 = 20字节

运行一下,看来我们的分析没有问题!也就是有两张虚表,func1重写会改变两个虚表(因为两个基类都有func1函数),func3是放在Base1的虚表中的,通过虚表验证:

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);
  //通过切片获取
  Base2 b2 = d;
  VFPTR* vTableb2 = (VFPTR*)(*(int*)&b2);
  PrintVTable(vTableb2);

  return 0;
}

运行看看:

多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

菱形继承和菱形虚拟继承

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。这里简单叙述一下:

先看菱形继承:

class A
{
public:
  virtual void func1() { cout << "A::func1" << endl; }
  int _a;
};

class B : public A
{
public:
  virtual void func2() { cout << "B::func2" << endl; }
  int _b;
};

 class C : public A
{
public:
  virtual void func3() { cout << "C::func3" << endl; }
  int _c;
};
class D : public B, public C
{
public:
  virtual void func4() { cout << "D::func4" << endl; }
  int _d;
};
int main()
{
  D d;
  cout<< sizeof(d) << endl;

  return 0;
}

先来看一下这个类有多大:

28 字节,这个是怎么得到的,来分析一下:

  1. A类有一个虚函数表指针和一个整型 ,应该是8字节
  2. B类继承于A类 ,包含A类的内容,B的虚函数储存在A的虚表中,所以B类一个为8 + 4 = 12
  1. C类同理
  2. D类继承于B类和C类,那么就包含B类与C类,D类的虚函数储存在B类的虚表中(A的虚表)

通过内存来验证一下:

可以看到只有两个虚表指针。所以菱形继承和多继承类似!

再来看菱形虚拟继承:

这个36字节是怎么得到的???

  1. 首先菱形虚拟继承会把共同的基类提取出来(也就是A被提出来了),那么B类就会有一个虚基表指针来指向这个提前出来的A类。所以B类大小为4 (虚表指针) + 4(虚基表指针) + 4(int数据) = 12
  2. C类同理,那么现在就有12 (B类) + 12(C类) + 4(A类的int)+ 4(D类的int) = 32
  3. 啊???这才32字节,剩下的4字节是什么?难不成还有一个虚表指针?!是的,A 里面还有一个虚表指针!!!

来看内存:

很明显,在A类中还有一个虚表指针!!!真滴复杂!

所以应该是:

12 (B类) + 12(C类) + 8(A类的int)+ 4(D类的int) = 36

那为什么A会有一个虚表指针,而不是D类有!?

  1. 首先派生类的成员是不会有虚表指针的,虚表指针都在基类的部分中!!!
  2. 我们这四个类都有自身的虚函数
  • 菱形继承中,B类与C类都继承于A类,所以BC是派生类,就不需要有独立的虚表指针,而是与A类共用。父类有了就与父类共用,父类没有才会独立创建。
  • 菱形虚拟继承中,B类与C类都虚拟继承于A类,A类被单独出去了,那么B类与C类的虚函数就不能放在A类里,因为A类是共享的,放进去就会产出问题!所以BC会独立创建一个虚表指针。

总结: 子类有虚函数,继承的父类有虚函数就有虚表,子类不需要单独建立虚表!!!如果父类是共享的,无论如何都有创建独立的虚表!!!


注意:虚基表中储存两个值:第一个是距离虚表位置的偏移量,第二个是距离基类位置的偏移量

Thanks♪(・ω・)ノ谢谢阅读!!!

下一篇文章见!!!

相关文章
|
2月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
42 2
C++入门12——详解多态1
|
7月前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
163 1
|
2月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
83 1
|
4月前
|
存储 编译器 C++
|
5月前
|
存储 编译器 C++
【C++】深度解剖多态(下)
【C++】深度解剖多态(下)
55 1
【C++】深度解剖多态(下)
|
5月前
|
存储 编译器 C++
|
5月前
|
机器学习/深度学习 算法 C++
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
|
5月前
|
Java 编译器 C++
【C++】深度解剖多态(上)
【C++】深度解剖多态(上)
56 2
|
5月前
|
程序员 C++
【C++】揭开C++多态的神秘面纱
【C++】揭开C++多态的神秘面纱
|
5月前
|
机器学习/深度学习 PyTorch 算法框架/工具
C++多态崩溃问题之在PyTorch中,如何定义一个简单的线性回归模型
C++多态崩溃问题之在PyTorch中,如何定义一个简单的线性回归模型