从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

目录
相关文章
|
2月前
|
安全 编译器 C语言
C++入门1——从C语言到C++的过渡
C++入门1——从C语言到C++的过渡
61 2
|
2月前
|
网络协议 编译器 Linux
【C语言】结构体内存对齐:热门面试话题
【C语言】结构体内存对齐:热门面试话题
|
1天前
|
算法 编译器 C语言
【C语言】C++ 和 C 的优缺点是什么?
C 和 C++ 是两种强大的编程语言,各有其优缺点。C 语言以其高效性、底层控制和简洁性广泛应用于系统编程和嵌入式系统。C++ 在 C 语言的基础上引入了面向对象编程、模板编程和丰富的标准库,使其适合开发大型、复杂的软件系统。 在选择使用 C 还是 C++ 时,开发者需要根据项目的需求、语言的特性以及团队的技术栈来做出决策。无论是 C 语言还是 C++,了解其优缺点和适用场景能够帮助开发者在实际开发中做出更明智的选择,从而更好地应对挑战,实现项目目标。
14 0
|
2月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
40 2
C++入门12——详解多态1
|
2月前
|
C语言 C++
C 语言的关键字 static 和 C++ 的关键字 static 有什么区别
在C语言中,`static`关键字主要用于变量声明,使得该变量的作用域被限制在其被声明的函数内部,且在整个程序运行期间保留其值。而在C++中,除了继承了C的特性外,`static`还可以用于类成员,使该成员被所有类实例共享,同时在类外进行初始化。这使得C++中的`static`具有更广泛的应用场景,不仅限于控制变量的作用域和生存期。
59 10
|
2月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
81 1
|
3月前
|
算法 机器人 C语言
ROS仿真支持C++和C语言
ROS仿真支持C++和C语言
76 1
|
2月前
|
C语言 C++
实现两个变量值的互换[C语言和C++的区别]
实现两个变量值的互换[C语言和C++的区别]
23 0
|
2月前
|
Serverless 编译器 C语言
【C语言】指针篇- 深度解析Sizeof和Strlen:热门面试题探究(5/5)
【C语言】指针篇- 深度解析Sizeof和Strlen:热门面试题探究(5/5)
|
3月前
|
编译器 C语言 C++
从C语言到C++
本文档详细介绍了C++相较于C语言的一些改进和新特性,包括类型检查、逻辑类型 `bool`、枚举类型、可赋值的表达式等。同时,文档还讲解了C++中的标准输入输出流 `cin` 和 `cout` 的使用方法及格式化输出技巧。此外,还介绍了函数重载、运算符重载、默认参数等高级特性,并探讨了引用的概念及其应用,包括常引用和引用的本质分析。以下是简要概述: 本文档适合有一定C语言基础的学习者深入了解C++的新特性及其应用。