【C++】多态(万字详解) —— 条件 | 虚函数重写 | 抽象类 | 多态的原理(下)

简介: 【C++】多态(万字详解) —— 条件 | 虚函数重写 | 抽象类 | 多态的原理(下)

🔥虚函数表在哪

那虚表在哪里呢?我们就铺垫过虚函数表不能修改,所以我猜测是在常量区的

我们写一段代码来验证一下 ——


0a2653c851af460fa595bd959398a8f1.png


所以,虚函数表是存在“常量区”的


int main()
{
  int* ptr = (int*)malloc(4); 
  printf("heap: %p\n", ptr);
  int a = 0;
  printf("stack: %p\n", &a);
  static int s = 0;
  printf("数据段:%p\n", &s);
  const char* p = "always";
  printf("常量区:%p\n", p);
  printf("代码段:%p\n", &Base::func1);
  Base b;
  // 取对象头4/8个字节 —— 强转(Base* -> int*) —— 再解引用拿到_vfptr
  printf("虚函数表: %p\n", *((int*)&b));
  return 0;
}


六. 单继承和多继承关系中的虚函数表


首先我们要再来观察如下代码在监视窗中的状况,这儿vs起到了很好的误导作用,我们来一一揭秘


class Person 
{
public:
  virtual void BuyTicket() { cout << "买票——全价" << endl; }
  virtual void func1()
  {}
};
class Student :public Person {
public:
  virtual void BuyTicket() { cout << "买票——半价" << endl; }
  virtual void func2()
  {}
};
int main()
{
  Person p1;
  Person p2;
  Student s1;
  Student s2;
  return 0;
}


ps:子类的虚函数在监视窗口是看不见的

但是func2还是要进虚表的


2d65d23f6d4748949b924e4057485923.png


💦打印虚函数表

有时虚函数地址被隐藏掉了,之前我们只能在内存窗口中观察,现在我们来学习打印虚函数表


2d65d23f6d4748949b924e4057485923.png


虚函数表中都是函数指针,函数指针如何定义变量还记得吗,不是函数名不是定义在最后的,而是混杂其中的,我们在typedef时,依然保留了这个原则


//typedef void(*)() VFPTR; 这样定义是编译不过的,要像函数指针定义一样
typedef void(*VFPTR)();
//打印虚函数表           vs下虚表最后才有nullptr,linux下没有
void PrintfVFTable(VFPTR table[])
{
  for (size_t i = 0; table[i] != nullptr; i++)
  {
  printf("vtf[%d]:%p", i, table[i]);
  VFPTR pf = table[i];
  pf();
  }
}


那么在调用这个函数的时候,就需要传入虚函数表的地址,即指针数组的(首元素)地址,即对象中的虚表指针_vfptr


问题就转化成了如何取到对象头4/8个字节呢?


两个没有关系的类型(int 和 student),是没办法直接强转成int,那取&个地址,变成student* 再转成 int*,可是传入的参数类型还不匹配,那就再(VF_PTR*)强转一下 ——


//取对象头部虚函数表指针传递过去
  PrintfVFTable((VFPTR *)(*(int*)&s1));


一步步理顺:先&s1,变成student*,再强转成(int*)解引用就是开头的4个字节了,形参是VF_PTR[],类型是(VF_PTR*),最后再强转一下即可


出现打印不全的情况,只需要重新生成一下解决方案即可


0a2653c851af460fa595bd959398a8f1.png


PrintVFTable((VF_PTR*)(*(int*)&b));   // 32位
  PrintVFTable((VF_PTR*)(*(long long*)&b)); // 64位
  PrintVFTable((VF_PTR*)(*(void**)&b));  // 32/64位


32位平台,用(int*)强转

64位平台,用(long long*)强转

此处为什么就是void**?(int*) 解引用看一个int的大小,(long long*)解引用看的是long long的大小,void* 不能解引用,这(void**)解引用看的是void* 的大小,void* 的大小就和平台相关


记住我们要取的是对象头四个子杰中的内容而不是地址,这就是为什么要*解引用的作用

实际传的是二级指针


0a2653c851af460fa595bd959398a8f1.png


Linux下的写法与调用:


//Linux下:
void PrintfVFTable(VFPTR* table, size_t n)
{
  for (size_t i = 0; i < n; i++)
  {
  printf("vtf[%d]:%p ->", i, table[i]);
  VFPTR pf = table[i];
  pf();
  }
}


PrintfVFTable((VFPTR *)(*(int*)&s1, 3);


💦单继承的虚函数表

class Person
{
private:
  virtual void func1() { cout << "Base::func1" << endl; }
  virtual void func2() { cout << "Base::func2" << endl; }
private:
  int _a = 0;
};
class Student :public Person
{
public:
  virtual void func1() { cout << "Derive::func1" << endl; }
  virtual void func3() { cout << "Derive::func3" << endl; }
  void fun4() { cout << "func4()" << endl; }
private:
  int _b = 1;
};
//void(*ptr)();//函数指针
//typedef void(*)() VFPTR; 这样定义是编译不过的,要像函数指针定义一样
typedef void(*VFPTR)();
//打印虚函数表           vs下虚表最后才有nullptr,linux下没有
//void PrintfVFTable(VFPTR table[])
//Linux下:
void PrintfVFTable(VFPTR* table, size_t n)
{
  for (size_t i = 0; i < n; i++)
  {
  printf("vtf[%d]:%p ->", i, table[i]);
  VFPTR pf = table[i];
  pf();
  }
}
int main()
{
  Person p1;
  PrintfVFTable((VFPTR*)(*(int*)&p1), 2);
  cout << endl;
  Student s1;
  PrintfVFTable((VFPTR *)(*(int*)&s1),3);
  return 0;
}


0a2653c851af460fa595bd959398a8f1.png


💦多继承的虚函数表

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


我们首先来算一下Derive的大小


0a2653c851af460fa595bd959398a8f1.png


还是那句话:vs下无法检测到子类新增的虚函数,虚函数总会放进虚表的嘛,那究竟是放进第一个虚表还是第二个虚表呢


我们打印一下虚表可以发现:func3在第一个虚表中


2d65d23f6d4748949b924e4057485923.png


那我们怎么样打印Base2的虚表呢?


//正常方法
  PrintVTable((VFPTR*)*((int*)((char*)&d + sizeof(Base1))));
  //切片方法
  Base2* ptr = &d;
  PrintVTable((VFPTR*)*((int*)(ptr)));


4cebaac233b3433da32a72337a77fc60.png6de278e6d6694ce5bb08e7e842b7e74b.png


可见func3()放进了第一个虚表中


0a2653c851af460fa595bd959398a8f1.png


并且即使子类重写了func1后,你发现这对象虚表中,Base1和Base2的虚函数func1的地址不一样,你早就不应该感到惊奇,因为这时jmp跳转指令的地址,最终会一跳到同一位置执行函数Derive::func1的 ——


2d65d23f6d4748949b924e4057485923.png


发现p2->func1()调用函数时,还跳了好多层。这是为了做准备工作ecx-8 ,修正this指针(eax),为什么呢?调用虚函数时,要传递this指针,-8由指向Base1到指向Base2,从而看到对应类型视角下的那部分


💦菱形继承的虚函数表


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


C++ 虚函数表解析 | 酷 壳 - CoolShell


C++ 对象的内存布局 | 酷 壳 - CoolShell


还是简简单单的说一下吧,继续来上一段老代码


class A
{
public:
  virtual void f()
  {}
public:
  int _a;
};
class B : virtual public A 
{
public:
  int _b;
};
class C : virtual public A 
{
public:
  int _c;
};
class D : public B, public C
{
public:
  int _d;
};
int main()
{
  D d;
  d.B::_a = 1;
  d.C::_a = 2;
  d._b = 3;
  d._c = 4;
  d._d = 5;
  //d._a = 0; //不存在二义性,可以直接找
  return 0;
}


0a2653c851af460fa595bd959398a8f1.png


在,菱形继承中,如果B和C都重写了A的虚函数func1,那么D必须重写func1,否则会报错“D”:“void A::f1(void)”的不明确继承,因为这儿是虚继承,共用一个虚表,不知道用哪个重写


public:
  virtual void f1() {}
public:
  int _a;
};
class B : virtual public A 
{
public:
  virtual void f1() {}
  virtual void f2() {}
public:
  int _b;
};
class C : virtual public A 
{
public:
  virtual void f1() {}
  virtual void f2() {}
public:
  int _c;
};
class D : public B, public C
{
public:
  virtual void f1() {}
public:
  int _d;
};
int main()
{
  D d;
  d.B::_a = 1;
  d.C::_a = 2;
  d._b = 3;
  d._c = 4;
  d._d = 5;
  return 0;
}


我们说过虚基表中,曾经内容是00000000是为其他东西预留的,那它究竟是什么呢?这是找虚表的偏移量。


0a2653c851af460fa595bd959398a8f1.png


虚基表存的是偏移量,用来解决数据冗余和二义性


虚函数表是存放虚函数的,解决多态


所以通俗一点的说,菱形继承谁用谁…


七. 常见考点总结


什么是多态?

什么是重载、重写(覆盖)、重定义(隐藏)?

多态的实现原理?

inline函数可以是虚函数吗?

答:不可以,因为内联函数没有地址,但是虚函数地址要被填入到虚表中。不过是可以编译通过的,因为inline只是个建议,到底有没有展开要视情况而定:若调用时不构成多态,保持inline属性;若构成多态,则没有inline属性。

静态成员可以是虚函数吗?

答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

构造函数可以是虚函数吗?

答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

析构函数可以是虚函数吗? 什么场景下析构函数是虚函数?

答:可以,并且最好把基类的析构函数定义成虚函数。

构造函数可以是虚函数吗?

答:拷贝构造也是构造函数,也有初始化列表,答案和构造一样

赋值函数可以是虚函数吗?

答:语法上可以,但是没有意义

对象访问普通函数快还是虚函数更快?

答:虚函数不构成多态就一样快,虚函数构成多态的调用,普通函数快,因为多态调用时运行时决议。

虚函数表是在什么阶段生成的,存在哪的?

答:虚函数表是在编译阶段就生成的,一般情况下存在**代码段(常量区)**的。

C++菱形继承的问题?虚继承的原理?

答:注意这里不要把虚函数表和虚基表搞混了。

什么是抽象类?抽象类的作用?

答:查上面的。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系


八. 经典题型讲解


1️⃣杀手题目

以下程序输出结果是什么()


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


A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确


本题很老六,不仅仅考了1️⃣多态还考了2️⃣接口继承的概念


首先test函数不满足多态,func函数满足多态();

p是指向子类B的,指针p传给test中的this指针是发生了切片(因为是子类指针p传给父类指针this)

this 传给func函数,this指向的是子类B,所以func函数调用的就是子类B的func

到这里答案是不是要选D,但是还有一个细节

此处的value值是1,为什么呢?因为重写是 接口继承,普通函数是实现继承。接口继承就是函数主体架子都不变,直接拿去使用(加不加virtual、缺省值相不相同无所谓),重写了是实现的部分


答案选B


改编:这样的话答案是选什么呢?


int main(int argc, char* argv[])
{
  A* p = new B;
  p->test();
  return 0;
}


答案没变还是选B,只是切片的地方发生了改变,A* p = new B,此处就发生了切片,this指针还是指向子类B的,所以调用func函数也会调用子类的!

正好符合多态的原理:基类的指针/引用指向谁,就去谁的虚函数表中找到对应位置的虚函数进行调用

如果是A* p = new A,就是调用父类A的

2️⃣杀手题目

class A {
public:
  A(char* s) { cout << s << endl; }
  ~A() {}
};
class B :virtual public A
{
public:
  B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
  C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:
  D(char* s1, char* s2, char* s3, char* s4) :B(s1, s2), C(s1, s3), A(s1)
  {
  cout << s4 << endl;
  }
};
int main() {
  D* p = new D("class A", "class B", "class C", "class D");
  delete p;
  return 0;
}


A:class A class B class C class D B:class D class B class C class A

C:class D class C class B class A D:class A class C class B class D


首先A不会重复的初始化,而且是按照声明顺序来的,所以先初始化A

A不会在B和C中初始化,因为D中只有一份A,在其他两个中初始化都不合适,只能在D中

所以答案选A


相关文章
|
1天前
|
C++
C++程序中的抽象类
C++程序中的抽象类
6 0
|
5天前
|
C++
C++|多态性与虚函数(2)|虚析构函数|重载函数|纯虚函数|抽象类
C++|多态性与虚函数(2)|虚析构函数|重载函数|纯虚函数|抽象类
|
6天前
|
设计模式 算法 C++
【C++】STL之迭代器介绍、原理、失效
【C++】STL之迭代器介绍、原理、失效
13 2
|
6天前
|
C++ 编译器 存储
|
6天前
|
存储 C++
C++中的多态
C++中的多态
8 0
|
6天前
|
C++
【C++】从零开始认识多态(二)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
24 1
|
6天前
|
C++
【C++】从零开始认识多态(一)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
29 4
|
6天前
|
存储 编译器 C++
[C++基础]-多态
[C++基础]-多态
|
18小时前
|
存储 编译器 C++
C++ 存储类
C++ 存储类
7 0
|
1天前
|
存储 编译器 C语言
从C语言到C++_11(string类的常用函数)力扣58和415(中)
从C语言到C++_11(string类的常用函数)力扣58和415
5 0