从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(中)

简介: 从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题

从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(上):https://developer.aliyun.com/article/1521912

2.3 接口继承和实现继承

纯虚函数也是可以实现的,但是,纯虚函数的实现没有什么太大意义,因为根本就没人能用它。

你实现一个东西是为了让人能调用你,纯虚函数谁能调用?根本没有人能调用它。

所以纯虚函数一般给个声明就可以了,它本身就是一个接口继承。


普通函数的继承是一种实现继承,子类继承了父类函数,可以使用函数,继承的是函数的实现。

虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,

达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

出现虚函数就是为了提醒你重写的,以实现多态。如果虚函数不重写,那写成虚函数就没价值了。

3. 虚函数表(VTBL)

我们首先来做一道题:sizeof(Base) 是多少(32位下)?

#include <iostream>
using namespace std;
 
class Base 
{
public:
  virtual void Func1() 
  {
    cout << "Func1()" << endl;
  }
  virtual void Func2()
  {
    cout << "Func2()" << endl;
  }
protected:
  int _b = 0;
};
 
int main()
{
  Base b;
  cout << sizeof(b) << endl;
 
  return 0;
}

居然是 8。

通过监视窗口我们发现除了 _b 成员外还有了一个 _vfptr 在 b1 对象中:

对象中的这个 _vfptr 我们称之为虚表指针(virtual function pointer),我们简称其为 虚表

一个含有虚函数的类中都至少有一个像这样的虚函数表指针,虚函数地址都会放到这个表里。

虚函数表是一个函数指针数组,虚函数表存储在数据段上(常量区)。

那么虚函数表中放了些什么呢?我们继续往下看。

3.1 观察虚表指针 __vfptr

同类型的对象用的是同一个虚表

#include <iostream>
using namespace std;
 
class Base
{
public:
  virtual void Func1()
  {
    cout << "Func1()" << endl;
  }
  virtual void Func2()
  {
    cout << "Func2()" << endl;
  }
protected:
  int _b = 0;
};
 
int main()
{
  Base b1;
  Base b2;
 
  return 0;
}


我们增加一个派生类Derive去继承Base

Base再增加一个虚函数Func2和一个普通函数Func3

#include <iostream>
using namespace std;
 
class Base 
{
public:
  virtual void Func1() 
  {
    cout << "Func1()" << endl;
  }
  virtual void Func2()
  {
    cout << "Func2()" << endl;
  }
  void Func3()
  {
    cout << "Func3()" << endl;
  }
protected:
  int _b = 0;
};
 
class Derive : public Base
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
protected:
  int _d = 1;
};
 
int main()
{
  Base b;
  cout << sizeof(b) << endl;
 
  return 0;
}

监视窗口是为了方便我们观测优化过的,相当于是一种美化。

Func3 没有放在 _vfptr 中,证明了这个表里只会存虚函数。

其实虚函数表也没搞什么特殊,也没什么了不起的,虚函数其实是和普通函数一样存在代码段的。

只是普通函数只会进符号表以方便链接,都是 "编译时决议",

而虚函数的地址会被放进虚表,是为了 "运行时决议" 做准备,这个我们后面会细说。

虚表的本质:虚表是一个 "存虚函数指针的指针数组" ,(函数指针数组)

一般情况这个数组最后面会放一个空指针,(取决于编译器)。


3.2 虚函数的重写与覆盖

介绍重写的时候还说过,"重写" 还可以称为 "覆盖",

这是为什么呢?叫重写似乎更好去理解,覆盖好像很难去理解啊。

#include <iostream>
using namespace std;
 
class Base 
{
public:
  virtual void Func1() 
  {
    cout << "Func1()" << endl;
  }
  virtual void Func2()
  {
    cout << "Func2()" << endl;
  }
  void Func3()
  {
    cout << "Func3()" << endl;
  }
protected:
  int _b = 0;
};
 
class Derive : public Base
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
protected:
  int _d = 1;
};
 
int main()
{
  Base b;
  cout << sizeof(b) << endl;
  Derive d;
  cout << sizeof(Derive) << endl;
 
  return 0;
}

父类 b 对象和子类 d 对象虚表是不一样的,这里看我们发现 Func1 完成了重写,

所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫做覆盖。

就可以理解为:子类的虚表拷贝了父类的虚表,子类的 Func1 覆盖掉了父类上的 Func1。

(覆盖指的是虚表中虚函数的覆盖)

  • 虚函数重写:语法层的概念,子类对继承父类虚函数实现进行了重写。
  • 虚函数覆盖:原理层的概念,子类的虚表,拷贝父类虚表进行了修改,覆盖重写那个虚函数。

总结:虚函数的重写与覆盖,重写是语法层的叫法,覆盖是原理层的叫法。

3.3 编译器的查表行为

编译器是如何做到指针指向谁就调用谁的虚函数的?

#include <iostream>
using namespace std;
 
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 = 0;
};
 
class Derive : public Base 
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
private:
  int _d = 1;
};
 
int main()
{
  Base b;
  Derive d;
 
  Base* ptr = &b;
  ptr->Func1();   // 调用的是父类的虚函数
 
  ptr = &d;
  ptr->Func1();   // 调用的是子类的虚函数
 
  return 0;
}

能不能猜到是跟虚表有关系?它到底要调用哪个函数不是按类型去定的,

如果是按类型去定的那这里调的应该都是父类,结果会都是 Base::Func1() ,所以显然不是。

这里会去 ptr 指针指向的对象里去查表,其实对它自己而言它自己都也不知道调用的是谁,

因为子类切个片,它自己也只能看到父类对象,它根本就没法知道,但是他会查表。

编译器会从指向的对象里去找,先在父类对象里找到了 Base::Func1,

然后指向变为 &d,它就从子类对象里找,从而找到了 Derive::Func1。

所以,多态调用实现是依靠运行时去指向对象的虚表中查,调用函数地址。

对象也能切片,为什么不能实现多态?

既然指针和引用可以实现多态,那父类赋值给子类对象也可以切片,

为什么实现不了多态?

Base* ptr = &d;    √

Base& ref = d;     √

Base b = d;    ×  为什么不行?都是支持切片的,为什么对象就不行?


从编译器的角度,编译器实现时会判断构不构成多态,不满足规则不构成多态就找到地址,call。


至于为什么实现不了多态,因为实现出来会出现混乱状态。


"即使你是一门语言的设计者,遇到这种问题也很难解决 "


根本原因是:对象切片时,子类对象只会拷贝成员给父类对象,并不会拷贝虚表指针。


因为拷贝了就混乱了,父类对象中到底是父类的虚表指针?还是子类的虚表指针?


那下面的调用是调用父类的虚函数还是子类的虚函数?就不确定了:


ptr = &b;

ptr->func1();    //  ?????????? 父类的func1,还是子类的func1?


对象实现多态又不得不去拷贝虚表,因为它肯定是需要去对象里的虚表里找,


问题是拷贝虚表后就乱了套了。最大问题是 —— 这时候到底调用的是谁的问题。


如果一个父类对象切片拷贝给子类后,切片前指向子类,没切片前指向父类。"这让人头大"


所以对象不能实现多态,想实现也不行,实现了就乱套了。


总结:


一个类对象中的 __vptr 是恒定的,它永远都会指向其所属类的虚表。


当使用指针或引用时,__vptr 会指向继承类的虚表(从而达成多态的效果)

3.4 通过打印观察 __vfptr

打开监视窗口观察下列代码的虚表:

#include <iostream>
using namespace std;
 
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 = 0;
};
 
class Derive : public Base 
{
public:
  virtual void Func1() 
  {
    cout << "Derive::Func1()" << endl;
  }
  void Func3() 
  {
    cout << "Derive::Func3()" << endl;
  }
  virtual void Func4() 
  {
    cout << "Derive::Func4()" << endl;
  }
private:
  int _d = 1;
};
 
int main()
{
  Base b;
  Derive d;
 
  return 0;
}

从监视窗口观察,有时候会发现,好像有些虚函数再监视窗口显示的虚表里不存在。


这时候千万不能动摇 "只要是虚函数都会存入虚表" 这个事实。


这是监视窗口的锅,前面就过说了:监视窗口是美化过的。


想要看到真实的样子,我们可以打开内存去查看,


但是内存很难看懂,有什么办法可以把虚表打印出来?


虚表是个函数指针数组,该数组里的每个元素存放的是一个函数指针。


只要取到虚表指针,想打印虚表就很简单了:

#include <iostream>
using namespace std;
 
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 = 0;
};
 
class Derive : public Base
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
  void Func3()
  {
    cout << "Derive::Func3()" << endl;
  }
  virtual void Func4()
  {
    cout << "Derive::Func4()" << endl;
  }
private:
  int _d = 1;
};
 
typedef void(*V_FUNC)(); // 把函数指针 void(*)() typedef 成 V_FUNC
 
void Print_VFTable(V_FUNC* arr)
{
  printf("vfptr:%p\n", arr);
  for (size_t i = 0; arr[i] != nullptr; ++i) // 如果编译器在最后无nullptr,就要手动传参,有几个就打印几个
  {
    printf("vfptr[%d]: %p\n", i, arr[i]);
    V_FUNC Func = arr[i];
    Func(); // 函数指针加括号->调用对应的函数
  }
}
 
int main()
{
  Derive d;
  Print_VFTable
  (
    (V_FUNC*)(*((int*)&d))// 取d对象的头四个字节 指针之间是可以互相转换的
  );                        // 把d取地址强转成int*,解引用就取出头四个字节(int),最后强转成V_FUNC* 传参
  //  语法有规定:完全没有关系的类型强转也不行。
  //  至少得有一点关系:比如指针和int
  //  因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址
  //  指针之间可以随意转换,我想取4个字节,& d 是个 Derive* ,
  //  接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。
  //  由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又
  //  强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。
  //  “内线转外线再转内线”
 
  return 0;
}

VS下打印父类出错了(这是VS的BUG,在release下就正常),

手动传试一下,Linux下也是这样手动传:

#include <iostream>
using namespace std;
 
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 = 0;
};
 
class Derive : public Base
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
  void Func3()
  {
    cout << "Derive::Func3()" << endl;
  }
  virtual void Func4()
  {
    cout << "Derive::Func4()" << endl;
  }
private:
  int _d = 1;
};
 
typedef void(*V_FUNC)(); // 把函数指针 void(*)() typedef 成 V_FUNC
 
void Print_VFTable(V_FUNC* arr,size_t n)
{
  printf("vfptr:%p\n", arr);
  for (size_t i = 0; /*arr[i] != nullptr*/ i < n; ++i) // 如果编译器在最后无nullptr,就要手动传参,有几个就打印几个
  {
    printf("vfptr[%d]: %p\n", i, arr[i]);
    V_FUNC Func = arr[i];
    Func(); // 函数指针加括号->调用对应的函数
  }
}
 
int main()
{
  Derive d;
  Print_VFTable
  (
    (V_FUNC*)(*((int*)&d)),3// 取d对象的头四个字节 指针之间是可以互相转换的
  );                        // 把d取地址强转成int*,解引用就取出头四个字节(int),最后强转成V_FUNC* 传参
  //  语法有规定:完全没有关系的类型强转也不行。
  //  至少得有一点关系:比如指针和int
  //  因为指针虽然是地址但是也是表示地址的编号,第几个编号的地址
  //  指针之间可以随意转换,我想取4个字节,& d 是个 Derive* ,
  //  接引用后就是 Derive,此时强转成 int* 解引用就是取4个字节了。
  //  由于是 int* 要调用 Print_VFTable 函数传不过去,所以我们最外层又
  //  强转回 V_FUNC* 了,这是一个函数指针数组的地址指针。
  //  “内线转外线再转内线”
  Base b;
  cout << endl;
  Print_VFTable
  (
    (V_FUNC*)(*((int*)&b)),2
  );
  return 0;
}

结论:VS 监视窗口看到的虚函数表不一定是真实的,可能被处理过。

3.5 运行时决议与编译时决议

刚才知道了,多态调用实现是靠运行时查表做到的,再看一段代码。

注意 Func3 不是虚函数:

#include <iostream>
using namespace std;
 
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 = 0;
};
 
class Derive : public Base 
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
private:
  int _d = 1;
};
 
int main()
{
  Base b;
  Derive d;
 
  Base* ptr = &b;
  ptr->Func1(); // 调用的是父类的虚函数
  ptr->Func3();
 
  ptr = &d;
  ptr->Func1(); // 调用的是子类的虚函数
  ptr->Func3();
 
  return 0;
}

这里 Func3 为什么不是 Derive 的?因为 Func3 不是虚函数,它没有进入虚表。

如果从更深的角度 —— 汇编层面去看,就可以牵扯出编译时决议和运行时决议。

决议的意思就是如何去确定函数的地址,一个是在运行时确定,一个是在编译时确定。

多态调用:运行时决议,即运行时确定调用函数的地址。【通过查虚函数表】

(编译完后通过指令,去对象中虚表里去找虚函数运行,是运行时去找,找到了才调用)

(这正是多态底层实现的原理,编译器去检查,

如果满足多态的条件了,它就按运行时决议的方式。)

普通调用:编译时决议,编译时确定调用函数的地址。【通过类型】

(所有的编译时确定都是看 ptr 是什么类型,跟对象没有关系,不看指向的对象,

自己是什么类型,就去哪里找 Func1)

3.6 动态绑定与静态绑定

静态库:指的是链接的那个阶段链接的库。

动态库:程序运行起来后才加载,去动态库里找。

静态绑定:又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,

也称为静态多态。比如函数重载。

动态绑定:又称后期绑定(晚绑定),在程序运行期间,

根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态。

多态在有些书上还细分了静态的多态和动态的多态。

静态的多态(编译时):指的是函数重载。

动态的多态(运行时):指的是本节内容讲的这个。

从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(下):https://developer.aliyun.com/article/1521918?spm=a2c6h.13148508.setting.17.712b4f0euyAwd3

目录
相关文章
|
4天前
|
C++
C++ 是一种面向对象的编程语言,它支持对象、类、继承、多态等面向对象的特性
C++ 是一种面向对象的编程语言,它支持对象、类、继承、多态等面向对象的特性
|
9天前
|
存储 缓存 C语言
C语言面试题30至39题
. 用变量a给出下面的定义 由于题目未明确定义,这里给出几个常见定义: 整数变量:int a; 字符变量:char a; 浮点变量:float a; 双精度浮点变量:double a; 指针变量:int *a; 通过理解和掌握这些面试题,可以更好地准备编程面试,展示对编程原理和技术细节的深刻掌握。
15 2
|
9天前
|
存储 安全 编译器
C语言面试题11至20题
在C语言中,可以使用以下方式实现循环: for循环:用于确定次数的循环。 for (int i = 0; i < 10; i++) { // 循环体 } while循环:用于条件控制的循环。 while (condition) { // 循环体 } do-while循环:至少执行一次的条件循环。 do { // 循环体 } while (condition); 通过深入理解这些面试题,可以更好地准备编程面试,展示对编程原理和技术细节的深刻掌握。
16 3
|
9天前
|
存储 安全 编译器
C语言面试题1-10
指针声明后立即初始化。 内存释放后将指针置为NULL。 避免越界访问。 10. 一个指针变量占几个字节? 一个指针变量的大小与系统和编译器相关。在32位系统中,指针变量占4个字节;在64位系统中,指针变量占8个字节。 通过深入了解以上问题,能够更好地掌握C语言内存管理的核心概念,提高编写高效、安全代码的能力。
16 1
|
15天前
|
算法 编译器 C++
C++多态与虚拟:函数重载(Function Overloading)
重载(Overloading)是C++中的一个特性,允许不同函数实体共享同一名称但通过参数差异来区分。例如,在类`CPoint`中,有两个成员函数`x()`,一个返回`float`,另一个是设置`float`值。通过函数重载,我们可以为不同数据类型(如`int`、`float`、`double`)定义同名函数`Add`,编译器会根据传入参数自动选择正确实现。不过,仅返回类型不同而参数相同的函数不能重载,这在编译时会导致错误。重载适用于成员和全局函数,而模板是另一种处理类型多样性的方式,将在后续讨论中介绍。
|
21天前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
25 1
|
16小时前
|
存储 Java
java面试题大全带答案_面试题库_java面试宝典2018
java面试题大全带答案_面试题库_java面试宝典2018
|
16小时前
|
算法 安全 网络协议
java高级面试题_java面试题大全带答案_线程面试题_java面试宝典2019
java高级面试题_java面试题大全带答案_线程面试题_java面试宝典2019
|
16小时前
|
安全 算法 Java
java线程面试题_2019java面试题库
java线程面试题_2019java面试题库
|
16小时前
|
SQL 前端开发 Java
2019史上最全java面试题题库大全800题含答案(面试宝典)(4)
2019史上最全java面试题题库大全800题含答案(面试宝典)

热门文章

最新文章