深入了解C++ (13) | 走近vtprvtbl,揭秘动态多态

Posted loOK后端

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入了解C++ (13) | 走近vtprvtbl,揭秘动态多态相关的知识,希望对你有一定的参考价值。

观前提醒,小屏手机横屏有助于提升阅读体验。

前文,我们讲解了继承体系中的【】问题,并且以【】,进一步分析了STL是如何利用空基类优化技术来优化内存模型。这一期,我们沿着继承体系,讲解多态。


在C++里面,实现多态有两种方式:

  • 动态多态,即以虚函数实现
  • 静态多态:基于函数重载、模板实现

本期,主要讲解基于虚函数实现的动态多态。

  • 多态:由基类指针,调用基类或者子类的虚成员函数
  • 动态,即无法在编译期确定调用的是基类对象还是子类对象的虚成员函数 vf,需要等待执行期才能确定。而将 vf设置为虚成员函数,只需要在 vf的声明前,加上 virtual关键字。

比如,在下面的demo中,在成员函数print前面加上了virtual关键字,print函数就变成了虚成员函数。然后通过基类指针对象base来调用print函数,即base->print()print最终输出的是 Base 还是 Derived取决于基类指针对象base指向的是基类对象还是子类对象。

class Base {
public:
  Base() = default;
  virtual 
  void print()       
std::cout <<"Actual Type:  Base" << std::endl; }
  void PointerType() std::cout <<"Pointer Type: Base" << std::endl;}
  virtual ~Base()    { std::cout <<"base-dtor"<< std::endl;}
};

class Derived : public Base{
public:
  Derived() = default;
  void print()       std::cout <<"Actual Type:  Derived" << std::endl; }
  void PointerType() std::cout <<"Pointer Type: Derived" << std::endl;}
  ~Derived()         { std::cout <<"derived-dtor ";}
private:
   int random_{0};
};

int main(int argc, char const *argv[]) {
  Base* base = new Derived;  // base指向子类对象
  base->print();
  base->PointerType();
  delete base;

  std::cout<<"---"<<std::endl;

  base = new Base;          // base指向基类对象
  base->print();
  base->PointerType();
  delete base;
  return 0;
}

输出如下:

$ g++ virtual.cc  -o v && ./v
Actual Type: Derived
Pointer Type: Base
derived-dtor base-dtor
---
Actual Type: Base
Pointer Type: Base
base-dtor

从输出可以看出,只有加上了virtual关键字的print函数,才具有多态性质,即base->print调用的可能是基类的Base::print,也可能是子类的Derived::print,具有是哪个,由执行期base指向的对象确定。

而没有加上virtual关键字的PointerType函数,在编译期就能确定是Base::PointerType

vtbl  & vptr

那么,编译器是如何让实现动态多态?

虚函数表(Virutal Function Table,vtbl)、虚函数指针(Virutal Function Pointer,vtpr)。

深入了解C++ (13) | 走近vtpr、vtbl,揭秘动态多态

以上面的BaseDerived类为例,他们都是空类,但是会因为vtpr的存在,对象大小变成一个指针大小(X64平台为8个字节)。

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

编译输出如下:

$ g++ virtual.cc  -o v && ./v
8
8

现在,我们知道了动态多态是通过vtpr、vtbl实现的。下面我们进一步讨论下,动态多态从编译到运行,哪些任务是在编译期完成,哪些任务是在执行期决议的。

我们仍然以上面的一段demo为例:

 Base* base = new Derived; 
 base->print();

base->print()会被编译器大致转换为:

(*base->vptr[0])(base)
  • vtpr是指向虚函数表的指针
  • 0是虚函数 print在vtbl中的索引

执行期确定:真正在执行期才能确定的是base指向的对象,是 Base类的对象,还是 Derived类的对象。

「by the way」

转换后的结果中的第二个base,代表的是this指针,因为任何类的成员函数都是要转换为非成员函数,因此要在成员函数的第一个参数位置插入this指针。

这个现在不理解没关系,下一期的函数重载部分会详细讲解。

void PointerType();

// 转换后,会在第一个参数位置插入this指针
// 函数名也会经过name mangleing操作
void PointType__base(Base* this);

复现多态

下面,那我们就来获取具有虚函数的对象中的vtpr、vtbl,再来直接调用虚成员函数。

  • vtbl:vtbl的类型可以表达为 uintptr_t*,表示vtbl是一个数组,数组的每个元素类型都是 uintptr_t
  • vtpr:vtpr指向vtbl,因此 vtpr的类型是 uintptr_t**,表示指针vtpr指向的类型是 uintptr_t*

另一方面,在GCC中,vtpr是被放置在内存模型中的第一个位置,即Derived对象的内存模型如下:

class Derived {
public:
 //...
    
private:  
  uintptr_t** vptr;
  int random_{0};
};

下面以虚成员函数print为例,通过 getVirutalFunc 函数来获取vtprvtbl进而调用print函数:

using FuncType = void (*)();  // print函数类型的 函数指针 

FuncType getVirutalFunc(Base* obj, uint64_t idx) 

  uintptr_t** vptr  = reinterpret_cast<uintptr_t**>(obj);     // 1)先取出vtpr
  uintptr_t*  vtbl  = *vptr;                                  // 2)vptr指向的是vtbl,因此 vtbl 即 *vptr
  uintptr_t   func  = *vtbl;                                  // vtbl存储的第一个虚函数

  // 返回指定位置的虚函数
  return reinterpret_cast<FuncType>(func + idx);      // 3)      
}

int main(int argc, char const *argv[]) {
  Base* base = new Derived; 
  // 编译器完成调用 
  base->print();     
  // 我们自己调用
  auto print = getVirutalFunc(base, 0); // 指向print函数的函数指针
  print();  // 调用print函数

  delete base;
  return 0;
}

编译运行的输出:

$ g++ virtual.cc  -o v && ./v
Actual Type:  Derived
Actual Type:  Derived
derived-dtor base-dtor

我们来复盘下, getVirutalFunc 函数对应着编译器从编译到执行一个虚成员函数的过程,那getVirutalFunc函数的三步中哪些是在编译器完成的呢,哪些在执行期才能完成的呢?

  • 编译期:很明显, getVirutalFunc函数的第二个参数 idx在编译期就可以确定;
  • 执行期: obj指向的是基类对象还是父类对象不确定。因此,根据 obj取得的 vtpr不知道是基类的还是子类的,这会对后续的vtbl产生影响。
class Derived : public Base{
public:
  Derived() {std::cout<<"Derived: "<<this<<std::endl;};
 //...
}

int main(int argc, char const *argv[]) {
  Base* base = new Derived; 
  std::cout<<"base:    "<< base <<std::endl;

  base->print();
  getVirutalFunc(base, 0)(); // print()

  delete base;
  return 0;
}

输出如下:

$ g++ virtual.cc  -o v && ./v
Derived: 0x7fffc2c64eb0
base:    0x7fffc2c64eb0
Actual Type:  Derived
Actual Type:  Derived
derived-dtor base-dtor

发现什么没有?

我们再想一想,子类的vtbl和基类的vtbl真的不是同一个虚函数表vtbl吗?

class Base { 
public:
  Base() { Base::showVtbl(this"Base   "); };
  virtual ~Base() =default;
    
  static void showVtbl(Base* obj, const char* type) 
    uintptr_t** vptr  = reinterpret_cast<uintptr_t**>(obj);     
    uintptr_t*  vtbl  = *vptr;
    std::cout<<type<<"  vtbl: "<<vtbl<<std::endl;
  }
};

class Derived : public Base { 
public:
  Derived(){  Base::showVtbl(this"Derived"); }
};

int main(int argc, char const *argv[]) {

  Derived derived{};
  return 0;
}

编译并执行输出:

$ g++ vir.cc -o v && ./v
Base     vtbl: 0x7fa2f3804d20
Derived  vtbl: 0x7fa2f3804cf8

从输出结果可以看出,确实不是一个。

override

最后,再提下C++11引入的关键字override

在子类重写父类的虚函数vf时,可能会因为不小心导致子类重写的虚函数与基类的虚函数不完全一致,此时编译器会将写错(这里的错,是指函数名、参数等与基类的虚函数不一致)的函数,决议为新的函数,而不会报错,最终的结果是未预料的。

因此,为了确保子类重写的虚函数与基类的保持一致,C++11引入了override关键字,如果基类中没有这个虚函数,那么编译器就会报错。

比如下面的demo:

class Base { 
public:
  Base() = default;
  virtual ~Base() = default;

  virtual void func_1() std::cout<<"base:::func_1"<<std::endl; }
  virtual void func_2(int i, double d) std::cout<<"base:::func_2"<<std::endl; }
};

class Derived : public Base { 
public:
  Derived() = default;

  void func_1() override std::cout<<"Derived::func_1"<<std::endl;}
  void func_2(int i, float f) override std::cout<<"Derived::func_1"<<std::endl;}
};

int main(int argc, char const *argv[]) {
  Base* base = new Derived;
  delete base;
  return 0;
}

编译输出:

$ g++ vir.cc -o v && ./v
vir.cc:18:8: error: ‘void Derived::func_2(int, float)’ marked ‘override’, but does not override
   18 |   void func_2(int i, float f) override { std::cout<<"Derived::func_1"<<std::endl;}
      |        ^~~~~~

编译直接报错,基类中没有提供 func_2(int i, float f) 函数。

因此,一个良好的编码习惯,应该在每个子类重写的虚函数后,加上 override关键字,防止一些可预防的bug。

深入了解C++ (13) | 走近vtpr、vtbl,揭秘动态多态


深入了解C++ (13) | 走近vtpr、vtbl,揭秘动态多态


上一周,都在忙毕业答辩及其后续事情,导致这一期托更了。

感谢你的观看,你的点赞、关注与分享就是对我最大的支持。


点个在看你最好看


以上是关于深入了解C++ (13) | 走近vtprvtbl,揭秘动态多态的主要内容,如果未能解决你的问题,请参考以下文章

《深入理解Java虚拟机》读书笔记——第1章 走近Java

走近Quick Audience,了解消费者运营产品的发展和演变

C++模版的概念使用方法和深入了解

走近Quick Audience,了解消费者运营产品的发展和演变

走近Quick Audience,了解消费者运营产品的发展和演变

C++多态知识点深入了解